fix(installer): make updates idempotent and harden CI staging

This commit is contained in:
sickn33
2026-03-27 15:26:41 +01:00
parent 8cdb1ef658
commit 8ad1ef9d83
14 changed files with 274 additions and 62 deletions

View File

@@ -143,8 +143,12 @@ jobs:
- name: Report generated drift
run: |
managed_files=$(node tools/scripts/generated_files.js --shell --include-mixed)
drift_files=$(git diff --name-only -- $managed_files)
mapfile -t managed_files < <(node tools/scripts/generated_files.js --include-mixed)
if [ "${#managed_files[@]}" -eq 0 ]; then
echo "::error::No managed files resolved from generated_files contract."
exit 1
fi
drift_files=$(git diff --name-only -- "${managed_files[@]}")
{
echo "## Artifact Preview"
@@ -232,11 +236,15 @@ jobs:
- name: Auto-commit canonical artifacts
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
managed_files=$(node tools/scripts/generated_files.js --shell --include-mixed)
mapfile -t managed_files < <(node tools/scripts/generated_files.js --include-mixed)
if [ "${#managed_files[@]}" -eq 0 ]; then
echo "No managed files resolved from generated_files contract."
exit 1
fi
git diff --quiet && exit 0
git add $managed_files || true
git add -- "${managed_files[@]}" || true
git diff --cached --quiet && exit 0

View File

@@ -40,7 +40,11 @@ jobs:
run: |
set -euo pipefail
managed_files=$(node tools/scripts/generated_files.js --shell --include-mixed)
mapfile -t managed_files < <(node tools/scripts/generated_files.js --include-mixed)
if [ "${#managed_files[@]}" -eq 0 ]; then
echo "No managed files resolved from generated_files contract."
exit 1
fi
if git diff --quiet; then
echo "No repo-state drift detected."
@@ -49,7 +53,7 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add $managed_files || true
git add -- "${managed_files[@]}" || true
if git diff --cached --quiet; then
echo "Repo hygiene produced unmanaged drift."

View File

@@ -50,7 +50,6 @@ def test_tls_handshake(host, port=443, timeout=5):
"""Testa tempo do handshake TLS."""
try:
context = ssl.create_default_context()
context.minimum_version = ssl.TLSVersion.TLSv1_2
start = time.time()
with socket.create_connection((host, port), timeout=timeout) as sock:
with context.wrap_socket(sock, server_hostname=host) as ssock:

View File

@@ -1527,9 +1527,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {

View File

@@ -23,7 +23,6 @@
"react-dom": "^19.2.3"
},
"overrides": {
"picomatch": "^4.0.4",
"rollup": "^4.59.0"
}
}

View File

@@ -10,6 +10,7 @@ Usage:
"""
import argparse
import json
import os
import sys
@@ -77,11 +78,17 @@ def send_test(to: str, message: str) -> None:
error = data.get("error", {})
print(f"Error sending message:")
print(f" Code: {error.get('code', '?')}")
print(f" Status: {response.status_code}")
print(" Message: Request rejected by WhatsApp Cloud API.")
print(f" Message: {error.get('message', 'Unknown error')}")
if error.get("error_data"):
print(f" Details: {error['error_data'].get('details', '')}")
print()
print("Response details omitted to avoid exposing sensitive API data.")
print("Full response:")
# Mask token in response output to prevent credential leakage
response_str = json.dumps(data, indent=2)
if token and token in response_str:
response_str = response_str.replace(token, _mask_secret(token))
print(response_str)
except httpx.ConnectError:
print("Error: Connection failed. Check your internet connection.")
@@ -89,8 +96,10 @@ def send_test(to: str, message: str) -> None:
except httpx.TimeoutException:
print("Error: Request timed out.")
sys.exit(1)
except Exception:
print("Error: Unexpected failure while sending the message.")
except Exception as e:
# Mask token in error output to prevent credential leakage
safe_err = str(e).replace(token, _mask_secret(token)) if token else str(e)
print(f"Error: {safe_err}")
sys.exit(1)

View File

@@ -54,15 +54,6 @@ def _mask_secret(value: str) -> str:
return f"{value[:6]}...masked"
def _extract_error_code(response: httpx.Response) -> str:
"""Return an API error code without logging response details."""
try:
error = response.json().get("error", {})
return str(error.get("code", "?"))
except Exception:
return "?"
def test_api_connection() -> tuple[bool, str]:
"""Test connection to WhatsApp Cloud API."""
token = os.environ.get("WHATSAPP_TOKEN", "")
@@ -79,18 +70,23 @@ def test_api_connection() -> tuple[bool, str]:
if response.status_code == 200:
data = response.json()
return True, (
f"Phone: {data.get('display_phone_number', 'N/A')}\n"
f" Name: {data.get('verified_name', 'N/A')}\n"
f" Status: {data.get('code_verification_status', 'N/A')}\n"
f" Quality: {data.get('quality_rating', 'N/A')}"
)
else:
return False, f"API request failed with status {response.status_code} (code {_extract_error_code(response)})"
error = response.json().get("error", {})
return False, f"API Error {error.get('code', '?')}: {error.get('message', 'Unknown')}"
except httpx.ConnectError:
return False, "Connection failed. Check your internet connection."
except httpx.TimeoutException:
return False, "Request timed out after 10 seconds."
except Exception:
return False, "Unexpected error while checking phone number access."
except Exception as e:
# Mask token in error output to prevent credential leakage
safe_err = str(e).replace(token, _mask_secret(token)) if token else str(e)
return False, f"Unexpected error: {safe_err}"
def test_waba_access() -> tuple[bool, str]:
@@ -110,10 +106,13 @@ def test_waba_access() -> tuple[bool, str]:
count = len(data.get("data", []))
return True, f"WABA accessible. {count} phone number(s) found."
else:
return False, f"API request failed with status {response.status_code} (code {_extract_error_code(response)})"
error = response.json().get("error", {})
return False, f"API Error {error.get('code', '?')}: {error.get('message', 'Unknown')}"
except Exception:
return False, "Unexpected error while checking WABA access."
except Exception as e:
# Mask token in error output to prevent credential leakage
safe_err = str(e).replace(token, _mask_secret(token)) if token else str(e)
return False, f"Error: {safe_err}"
def main():

View File

@@ -50,7 +50,6 @@ def test_tls_handshake(host, port=443, timeout=5):
"""Testa tempo do handshake TLS."""
try:
context = ssl.create_default_context()
context.minimum_version = ssl.TLSVersion.TLSv1_2
start = time.time()
with socket.create_connection((host, port), timeout=timeout) as sock:
with context.wrap_socket(sock, server_hostname=host) as ssock:

View File

@@ -10,6 +10,7 @@ Usage:
"""
import argparse
import json
import os
import sys
@@ -77,11 +78,17 @@ def send_test(to: str, message: str) -> None:
error = data.get("error", {})
print(f"Error sending message:")
print(f" Code: {error.get('code', '?')}")
print(f" Status: {response.status_code}")
print(" Message: Request rejected by WhatsApp Cloud API.")
print(f" Message: {error.get('message', 'Unknown error')}")
if error.get("error_data"):
print(f" Details: {error['error_data'].get('details', '')}")
print()
print("Response details omitted to avoid exposing sensitive API data.")
print("Full response:")
# Mask token in response output to prevent credential leakage
response_str = json.dumps(data, indent=2)
if token and token in response_str:
response_str = response_str.replace(token, _mask_secret(token))
print(response_str)
except httpx.ConnectError:
print("Error: Connection failed. Check your internet connection.")
@@ -89,8 +96,10 @@ def send_test(to: str, message: str) -> None:
except httpx.TimeoutException:
print("Error: Request timed out.")
sys.exit(1)
except Exception:
print("Error: Unexpected failure while sending the message.")
except Exception as e:
# Mask token in error output to prevent credential leakage
safe_err = str(e).replace(token, _mask_secret(token)) if token else str(e)
print(f"Error: {safe_err}")
sys.exit(1)

View File

@@ -54,15 +54,6 @@ def _mask_secret(value: str) -> str:
return f"{value[:6]}...masked"
def _extract_error_code(response: httpx.Response) -> str:
"""Return an API error code without logging response details."""
try:
error = response.json().get("error", {})
return str(error.get("code", "?"))
except Exception:
return "?"
def test_api_connection() -> tuple[bool, str]:
"""Test connection to WhatsApp Cloud API."""
token = os.environ.get("WHATSAPP_TOKEN", "")
@@ -79,18 +70,23 @@ def test_api_connection() -> tuple[bool, str]:
if response.status_code == 200:
data = response.json()
return True, (
f"Phone: {data.get('display_phone_number', 'N/A')}\n"
f" Name: {data.get('verified_name', 'N/A')}\n"
f" Status: {data.get('code_verification_status', 'N/A')}\n"
f" Quality: {data.get('quality_rating', 'N/A')}"
)
else:
return False, f"API request failed with status {response.status_code} (code {_extract_error_code(response)})"
error = response.json().get("error", {})
return False, f"API Error {error.get('code', '?')}: {error.get('message', 'Unknown')}"
except httpx.ConnectError:
return False, "Connection failed. Check your internet connection."
except httpx.TimeoutException:
return False, "Request timed out after 10 seconds."
except Exception:
return False, "Unexpected error while checking phone number access."
except Exception as e:
# Mask token in error output to prevent credential leakage
safe_err = str(e).replace(token, _mask_secret(token)) if token else str(e)
return False, f"Unexpected error: {safe_err}"
def test_waba_access() -> tuple[bool, str]:
@@ -110,10 +106,13 @@ def test_waba_access() -> tuple[bool, str]:
count = len(data.get("data", []))
return True, f"WABA accessible. {count} phone number(s) found."
else:
return False, f"API request failed with status {response.status_code} (code {_extract_error_code(response)})"
error = response.json().get("error", {})
return False, f"API Error {error.get('code', '?')}: {error.get('message', 'Unknown')}"
except Exception:
return False, "Unexpected error while checking WABA access."
except Exception as e:
# Mask token in error output to prevent credential leakage
safe_err = str(e).replace(token, _mask_secret(token)) if token else str(e)
return False, f"Error: {safe_err}"
def main():

View File

@@ -8,6 +8,7 @@ const { resolveSafeRealPath } = require("../lib/symlink-safety");
const REPO = "https://github.com/sickn33/antigravity-awesome-skills.git";
const HOME = process.env.HOME || process.env.USERPROFILE || "";
const INSTALL_MANIFEST_FILE = ".antigravity-install-manifest.json";
function resolveDir(p) {
if (!p) return null;
@@ -170,23 +171,114 @@ function copyRecursiveSync(src, dest, rootDir = src, skipGit = true) {
}
/** Copy contents of repo's skills/ into target so each skill is target/skill-name/ (for Claude Code etc.). */
function installSkillsIntoTarget(tempDir, target) {
function getInstallEntries(tempDir) {
const repoSkills = path.join(tempDir, "skills");
if (!fs.existsSync(repoSkills)) {
console.error("Cloned repo has no skills/ directory.");
process.exit(1);
}
fs.readdirSync(repoSkills).forEach((name) => {
const entries = fs.readdirSync(repoSkills);
if (fs.existsSync(path.join(tempDir, "docs"))) {
entries.push("docs");
}
return entries;
}
function installSkillsIntoTarget(tempDir, target, installEntries) {
const repoSkills = path.join(tempDir, "skills");
installEntries.forEach((name) => {
if (name === "docs") {
const repoDocs = path.join(tempDir, "docs");
const docsDest = path.join(target, "docs");
if (!fs.existsSync(docsDest)) fs.mkdirSync(docsDest, { recursive: true });
copyRecursiveSync(repoDocs, docsDest, repoDocs);
return;
}
const src = path.join(repoSkills, name);
const dest = path.join(target, name);
copyRecursiveSync(src, dest, repoSkills);
});
const repoDocs = path.join(tempDir, "docs");
if (fs.existsSync(repoDocs)) {
const docsDest = path.join(target, "docs");
if (!fs.existsSync(docsDest)) fs.mkdirSync(docsDest, { recursive: true });
copyRecursiveSync(repoDocs, docsDest, repoDocs);
}
function resolveManagedPath(targetPath, entry) {
const resolvedTargetPath = path.resolve(targetPath);
const candidate = path.resolve(targetPath, entry);
const relative = path.relative(resolvedTargetPath, candidate);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
return null;
}
return candidate;
}
function readInstallManifest(targetPath) {
const manifestPath = path.join(targetPath, INSTALL_MANIFEST_FILE);
if (!fs.existsSync(manifestPath)) {
return [];
}
try {
const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
if (!parsed || !Array.isArray(parsed.entries)) {
return [];
}
return parsed.entries.filter((entry) => typeof entry === "string");
} catch (error) {
console.warn(` Ignoring invalid install manifest at ${manifestPath}`);
return [];
}
}
function writeInstallManifest(targetPath, installEntries) {
const manifestPath = path.join(targetPath, INSTALL_MANIFEST_FILE);
fs.writeFileSync(
manifestPath,
JSON.stringify(
{
schemaVersion: 1,
updatedAt: new Date().toISOString(),
entries: installEntries.slice().sort(),
},
null,
2,
) + "\n",
"utf8",
);
}
function pruneRemovedEntries(targetPath, previousEntries, installEntries) {
const next = new Set(installEntries);
for (const entry of previousEntries) {
if (next.has(entry)) {
continue;
}
const candidate = resolveManagedPath(targetPath, entry);
if (!candidate) {
console.warn(` Skipping unsafe managed entry path from manifest: ${entry}`);
continue;
}
fs.rmSync(candidate, { recursive: true, force: true });
console.log(` Removed stale managed entry: ${entry}`);
}
}
function ensureTargetIsDirectory(targetPath) {
if (!fs.existsSync(targetPath)) {
return;
}
const stats = fs.lstatSync(targetPath);
if (stats.isDirectory()) {
return;
}
if (stats.isSymbolicLink()) {
try {
if (fs.statSync(targetPath).isDirectory()) {
return;
}
} catch (error) {
// Fall through to the error below for dangling links or non-directory targets.
}
}
console.error(` Install path exists but is not a directory: ${targetPath}`);
process.exit(1);
}
function run(cmd, args, opts = {}) {
@@ -196,6 +288,7 @@ function run(cmd, args, opts = {}) {
function installForTarget(tempDir, target) {
if (fs.existsSync(target.path)) {
ensureTargetIsDirectory(target.path);
const gitDir = path.join(target.path, ".git");
if (fs.existsSync(gitDir)) {
console.log(` Migrating from full-repo install to skills-only layout…`);
@@ -232,7 +325,11 @@ function installForTarget(tempDir, target) {
fs.mkdirSync(target.path, { recursive: true });
}
installSkillsIntoTarget(tempDir, target.path);
const installEntries = getInstallEntries(tempDir);
const previousEntries = readInstallManifest(target.path);
pruneRemovedEntries(target.path, previousEntries, installEntries);
installSkillsIntoTarget(tempDir, target.path, installEntries);
writeInstallManifest(target.path, installEntries);
console.log(` ✓ Installed to ${target.path}`);
}
@@ -322,7 +419,11 @@ if (require.main === module) {
module.exports = {
copyRecursiveSync,
getPostInstallMessages,
getInstallEntries,
installSkillsIntoTarget,
installForTarget,
main,
pruneRemovedEntries,
readInstallManifest,
writeInstallManifest,
};

View File

@@ -130,8 +130,8 @@ assert.match(
);
assert.match(
hygieneWorkflow,
/generated_files\.js --shell --include-mixed/,
"repo hygiene workflow should stage the mixed generated files contract",
/generated_files\.js --include-mixed/,
"repo hygiene workflow should resolve and stage the mixed generated files contract",
);
assert.match(publishWorkflow, /run: npm ci/, "npm publish workflow should install dependencies");

View File

@@ -0,0 +1,85 @@
const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { spawnSync } = require("child_process");
const installer = require(path.resolve(__dirname, "..", "..", "bin", "install.js"));
function writeSkill(repoRoot, skillName, content = "# Skill\n") {
const skillDir = path.join(repoRoot, "skills", skillName);
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, "SKILL.md"), content, "utf8");
}
function createFakeRepo(rootDir, skills) {
fs.mkdirSync(path.join(rootDir, "skills"), { recursive: true });
for (const skillName of skills) {
writeSkill(rootDir, skillName, `# ${skillName}\n`);
}
}
function readManifestEntries(targetDir) {
const manifestPath = path.join(targetDir, ".antigravity-install-manifest.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
return manifest.entries;
}
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "installer-update-sync-"));
try {
const repoV1 = path.join(tmpRoot, "repo-v1");
const repoV2 = path.join(tmpRoot, "repo-v2");
const targetDir = path.join(tmpRoot, "target");
fs.mkdirSync(targetDir, { recursive: true });
createFakeRepo(repoV1, ["skill-a", "skill-b"]);
createFakeRepo(repoV2, ["skill-a"]);
installer.installForTarget(repoV1, { name: "Test", path: targetDir });
assert.ok(fs.existsSync(path.join(targetDir, "skill-a", "SKILL.md")));
assert.ok(fs.existsSync(path.join(targetDir, "skill-b", "SKILL.md")));
installer.installForTarget(repoV2, { name: "Test", path: targetDir });
assert.ok(fs.existsSync(path.join(targetDir, "skill-a", "SKILL.md")));
assert.strictEqual(
fs.existsSync(path.join(targetDir, "skill-b")),
false,
"stale managed skill should be pruned during updates",
);
assert.deepStrictEqual(
readManifestEntries(targetDir),
["skill-a"],
"install manifest should mirror the latest installed entries",
);
const badTargetPath = path.join(tmpRoot, "bad-target");
fs.writeFileSync(badTargetPath, "not-a-directory", "utf8");
const badTargetCheck = spawnSync(
process.execPath,
[
"-e",
`
const installer = require(${JSON.stringify(path.resolve(__dirname, "..", "..", "bin", "install.js"))});
installer.installForTarget(${JSON.stringify(repoV2)}, { name: "BadTarget", path: ${JSON.stringify(badTargetPath)} });
`,
],
{
stdio: "pipe",
encoding: "utf8",
},
);
assert.notStrictEqual(
badTargetCheck.status,
0,
"installer should fail fast when target path exists as a non-directory",
);
assert.match(
`${badTargetCheck.stdout}\n${badTargetCheck.stderr}`,
/not a directory/i,
"installer should print a clear error for non-directory targets",
);
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}

View File

@@ -19,6 +19,7 @@ const LOCAL_TEST_COMMANDS = [
[path.join(TOOL_SCRIPTS, "run-python.js"), path.join(TOOL_TESTS, "test_editorial_bundles.py")],
[path.join(TOOL_SCRIPTS, "run-python.js"), path.join(TOOL_TESTS, "test_plugin_compatibility.py")],
[path.join(TOOL_TESTS, "installer_antigravity_guidance.test.js")],
[path.join(TOOL_TESTS, "installer_update_sync.test.js")],
[path.join(TOOL_TESTS, "jetski_gemini_loader.test.cjs")],
[path.join(TOOL_TESTS, "npm_package_contents.test.js")],
[path.join(TOOL_TESTS, "setup_web_sync.test.js")],