264 lines
9.7 KiB
JavaScript
264 lines
9.7 KiB
JavaScript
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"));
|
|
const packageVersion = require(path.resolve(__dirname, "..", "..", "..", "package.json")).version;
|
|
const pinnedRef = `v${packageVersion}`;
|
|
|
|
function writeSkill(repoRoot, skillPath, frontmatter) {
|
|
const skillDir = path.join(repoRoot, "skills", skillPath);
|
|
fs.mkdirSync(skillDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(skillDir, "SKILL.md"),
|
|
`---\n${frontmatter}\n---\n\n# ${skillPath}\n`,
|
|
"utf8",
|
|
);
|
|
}
|
|
|
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "installer-exact-selection-"));
|
|
|
|
try {
|
|
const repoRoot = path.join(tmpRoot, "repo");
|
|
fs.mkdirSync(path.join(repoRoot, "skills"), { recursive: true });
|
|
fs.mkdirSync(path.join(repoRoot, "docs"), { recursive: true });
|
|
fs.writeFileSync(path.join(repoRoot, "docs", "README.md"), "# Docs\n", "utf8");
|
|
|
|
writeSkill(
|
|
repoRoot,
|
|
"frontend-design",
|
|
"name: frontend-design\ncategory: development\nrisk: safe\ntags: [frontend]",
|
|
);
|
|
writeSkill(
|
|
repoRoot,
|
|
path.join("game-development", "2d-games"),
|
|
"name: 2D Games\ncategory: games\nrisk: safe\ntags: [game]",
|
|
);
|
|
writeSkill(
|
|
repoRoot,
|
|
path.join("legacy-games", "2d-games"),
|
|
"name: Legacy 2D Games\ncategory: games\nrisk: safe\ntags: [game]",
|
|
);
|
|
writeSkill(
|
|
repoRoot,
|
|
"shared-one",
|
|
"name: Shared Skill\ncategory: development\nrisk: safe\ntags: [shared]",
|
|
);
|
|
writeSkill(
|
|
repoRoot,
|
|
"shared-two",
|
|
"name: Shared Skill\ncategory: development\nrisk: safe\ntags: [shared]",
|
|
);
|
|
|
|
const requested = installer.parseExactSkillArg("frontend-design,game-development/2d-games");
|
|
assert.deepStrictEqual(
|
|
installer.getInstallEntries(repoRoot, installer.buildInstallSelectors({}), requested),
|
|
["frontend-design", "game-development/2d-games", "docs"],
|
|
"--skills should resolve exact root ids and full nested paths",
|
|
);
|
|
|
|
assert.deepStrictEqual(
|
|
installer.getInstallEntries(
|
|
repoRoot,
|
|
installer.buildInstallSelectors({ categoryArg: "development" }),
|
|
requested,
|
|
),
|
|
["frontend-design", "docs"],
|
|
"--skills must combine with metadata filters using AND",
|
|
);
|
|
|
|
assert.throws(
|
|
() => installer.getInstallEntries(
|
|
repoRoot,
|
|
installer.buildInstallSelectors({ categoryArg: "games" }),
|
|
installer.parseExactSkillArg("frontend-design"),
|
|
),
|
|
/No skills matched/i,
|
|
"an empty exact-selection/filter intersection must fail instead of installing docs or a broad fallback",
|
|
);
|
|
|
|
assert.throws(
|
|
() => installer.getInstallEntries(
|
|
repoRoot,
|
|
installer.buildInstallSelectors({}),
|
|
installer.parseExactSkillArg("does-not-exist"),
|
|
),
|
|
/unknown skill requested/i,
|
|
"unknown exact skill selections must fail",
|
|
);
|
|
assert.throws(
|
|
() => installer.getInstallEntries(
|
|
repoRoot,
|
|
installer.buildInstallSelectors({}),
|
|
installer.parseExactSkillArg("2d-games"),
|
|
),
|
|
/ambiguous skill requested/i,
|
|
"ambiguous basename selections must require a full nested path",
|
|
);
|
|
assert.throws(
|
|
() => installer.getInstallEntries(
|
|
repoRoot,
|
|
installer.buildInstallSelectors({}),
|
|
installer.parseExactSkillArg("Shared Skill"),
|
|
),
|
|
/ambiguous skill requested/i,
|
|
"ambiguous canonical names must fail",
|
|
);
|
|
|
|
const absentTarget = path.join(tmpRoot, "absent-target");
|
|
assert.throws(
|
|
() => installer.installForTarget(
|
|
repoRoot,
|
|
{ name: "Absent", path: absentTarget },
|
|
installer.buildInstallSelectors({}),
|
|
null,
|
|
installer.parseExactSkillArg("does-not-exist"),
|
|
),
|
|
/unknown skill requested/i,
|
|
);
|
|
assert.strictEqual(
|
|
fs.existsSync(absentTarget),
|
|
false,
|
|
"invalid --skills selection must fail before an absent target is created",
|
|
);
|
|
|
|
const existingTarget = path.join(tmpRoot, "existing-target");
|
|
fs.mkdirSync(existingTarget, { recursive: true });
|
|
const sentinelPath = path.join(existingTarget, "sentinel.txt");
|
|
fs.writeFileSync(sentinelPath, "keep", "utf8");
|
|
const manifestPath = path.join(existingTarget, ".antigravity-install-manifest.json");
|
|
fs.writeFileSync(
|
|
manifestPath,
|
|
JSON.stringify({
|
|
schemaVersion: 1,
|
|
entries: ["frontend-design", "legacy-skill", "docs", "../outside"],
|
|
}),
|
|
"utf8",
|
|
);
|
|
const plan = installer.buildDryRunPlan(
|
|
pinnedRef,
|
|
[
|
|
{ name: "Existing", path: existingTarget },
|
|
{ name: "Absent", path: absentTarget },
|
|
],
|
|
["game-development/2d-games", "frontend-design", "docs"],
|
|
);
|
|
assert.deepStrictEqual(plan.targets[0].remove, ["legacy-skill"]);
|
|
assert.deepStrictEqual(plan.targets[0].ignoredUnsafeManifestEntries, ["../outside"]);
|
|
assert.strictEqual(plan.targets[1].targetExists, false);
|
|
const output = [];
|
|
const originalLog = console.log;
|
|
console.log = (message) => output.push(String(message));
|
|
try {
|
|
installer.printDryRunPlan(plan);
|
|
} finally {
|
|
console.log = originalLog;
|
|
}
|
|
assert.strictEqual(fs.readFileSync(sentinelPath, "utf8"), "keep");
|
|
assert.strictEqual(fs.existsSync(absentTarget), false);
|
|
assert.match(output.join("\n"), new RegExp(`Ref: ${pinnedRef.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
|
|
assert.match(output.join("\n"), new RegExp(existingTarget.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
assert.match(output.join("\n"), /Exact skill set \(2\):/);
|
|
assert.match(output.join("\n"), /frontend-design/);
|
|
assert.match(output.join("\n"), /remove stale managed entries \(1\)/i);
|
|
assert.match(output.join("\n"), /legacy-skill/);
|
|
assert.match(output.join("\n"), /ignored unsafe manifest entry: \.\.\/outside/i);
|
|
|
|
// Exercise the real CLI boundary with a deterministic fake git clone. This
|
|
// proves --dry-run returns before installForTarget can mutate either an
|
|
// existing target or a missing one.
|
|
const fakeBin = path.join(tmpRoot, "fake-bin");
|
|
fs.mkdirSync(fakeBin, { recursive: true });
|
|
const fakeGit = path.join(fakeBin, "git");
|
|
fs.writeFileSync(
|
|
fakeGit,
|
|
`#!/usr/bin/env node
|
|
const fs = require("fs");
|
|
const destination = process.argv[process.argv.length - 1];
|
|
fs.appendFileSync(process.env.FAKE_GIT_LOG, JSON.stringify(process.argv.slice(2)) + "\\n");
|
|
fs.cpSync(process.env.FAKE_GIT_SOURCE, destination, { recursive: true, force: true });
|
|
`,
|
|
"utf8",
|
|
);
|
|
fs.chmodSync(fakeGit, 0o755);
|
|
|
|
const fakeGitLog = path.join(tmpRoot, "fake-git-log.jsonl");
|
|
const cliEnv = {
|
|
...process.env,
|
|
FAKE_GIT_SOURCE: repoRoot,
|
|
FAKE_GIT_LOG: fakeGitLog,
|
|
PATH: `${fakeBin}${path.delimiter}${process.env.PATH || ""}`,
|
|
};
|
|
const installerPath = path.resolve(__dirname, "..", "..", "bin", "install.js");
|
|
const beforeDryRunEntries = fs.readdirSync(existingTarget).sort();
|
|
const dryRun = spawnSync(
|
|
process.execPath,
|
|
[installerPath, "--path", existingTarget, "--release", packageVersion, "--skills", "frontend-design", "--dry-run"],
|
|
{ encoding: "utf8", env: cliEnv },
|
|
);
|
|
assert.strictEqual(dryRun.status, 0, dryRun.stderr);
|
|
assert.match(dryRun.stdout, new RegExp(`Ref: ${pinnedRef.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
|
|
assert.match(dryRun.stdout, /Exact skill set \(1\):/);
|
|
assert.match(dryRun.stdout, /remove stale managed entries \(1\)/i);
|
|
assert.match(dryRun.stdout, /legacy-skill/);
|
|
assert.deepStrictEqual(fs.readdirSync(existingTarget).sort(), beforeDryRunEntries);
|
|
assert.strictEqual(fs.readFileSync(sentinelPath, "utf8"), "keep");
|
|
const cloneArgs = JSON.parse(fs.readFileSync(fakeGitLog, "utf8").trim().split("\n")[0]);
|
|
assert.deepStrictEqual(
|
|
cloneArgs.slice(0, 5),
|
|
["clone", "--depth", "1", "--branch", pinnedRef],
|
|
"the real CLI dry run must clone the explicitly pinned release",
|
|
);
|
|
|
|
const outsideSentinel = path.join(tmpRoot, "outside");
|
|
fs.writeFileSync(outsideSentinel, "outside stays", "utf8");
|
|
assert.doesNotThrow(() => installer.pruneRemovedEntries(existingTarget, ["../outside"], []));
|
|
assert.strictEqual(fs.readFileSync(outsideSentinel, "utf8"), "outside stays");
|
|
|
|
const unknownTarget = path.join(tmpRoot, "unknown-target");
|
|
const unknown = spawnSync(
|
|
process.execPath,
|
|
[installerPath, "--path", unknownTarget, "--skills", "does-not-exist", "--dry-run"],
|
|
{ encoding: "utf8", env: cliEnv },
|
|
);
|
|
assert.strictEqual(unknown.status, 1);
|
|
assert.match(unknown.stderr, /Unknown skill requested/);
|
|
assert.strictEqual(fs.existsSync(unknownTarget), false);
|
|
|
|
const multiHome = path.join(tmpRoot, "multi-home");
|
|
const firstTarget = path.join(multiHome, ".claude", "skills");
|
|
const codexHome = path.join(tmpRoot, "codex-home");
|
|
const unsafeTarget = path.join(codexHome, "skills");
|
|
const unsafeRealTarget = path.join(tmpRoot, "unsafe-real-target");
|
|
fs.mkdirSync(firstTarget, { recursive: true });
|
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
fs.mkdirSync(unsafeRealTarget, { recursive: true });
|
|
const firstSentinel = path.join(firstTarget, "first-sentinel.txt");
|
|
fs.writeFileSync(firstSentinel, "first stays", "utf8");
|
|
let createdUnsafeSymlink = false;
|
|
try {
|
|
fs.symlinkSync(unsafeRealTarget, unsafeTarget, "dir");
|
|
createdUnsafeSymlink = true;
|
|
} catch (error) {
|
|
// Some platforms disallow unprivileged directory symlinks.
|
|
}
|
|
if (createdUnsafeSymlink) {
|
|
const multiTarget = spawnSync(
|
|
process.execPath,
|
|
[installerPath, "--claude", "--codex", "--skills", "frontend-design"],
|
|
{
|
|
encoding: "utf8",
|
|
env: { ...cliEnv, HOME: multiHome, CODEX_HOME: codexHome },
|
|
},
|
|
);
|
|
assert.strictEqual(multiTarget.status, 1);
|
|
assert.match(multiTarget.stderr, /symlinked target/i);
|
|
assert.deepStrictEqual(fs.readdirSync(firstTarget), ["first-sentinel.txt"]);
|
|
assert.strictEqual(fs.readFileSync(firstSentinel, "utf8"), "first stays");
|
|
}
|
|
} finally {
|
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
}
|