fix(installer): make updates idempotent and harden CI staging
This commit is contained in:
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
|
||||
8
.github/workflows/repo-hygiene.yml
vendored
8
.github/workflows/repo-hygiene.yml
vendored
@@ -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."
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"react-dom": "^19.2.3"
|
||||
},
|
||||
"overrides": {
|
||||
"picomatch": "^4.0.4",
|
||||
"rollup": "^4.59.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
85
tools/scripts/tests/installer_update_sync.test.js
Normal file
85
tools/scripts/tests/installer_update_sync.test.js
Normal 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 });
|
||||
}
|
||||
@@ -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")],
|
||||
|
||||
Reference in New Issue
Block a user