435 lines
14 KiB
TypeScript
435 lines
14 KiB
TypeScript
import { expect, test } from "bun:test";
|
||
import { join } from "node:path";
|
||
import { mkdtempSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
import {
|
||
parseManifestText,
|
||
type Manifest,
|
||
} from "../src/internal/manifest/parse-manifest";
|
||
import { runRestore } from "../src/commands/restore";
|
||
import { parseRestoreArgs } from "../src/internal/args/parse-restore-args";
|
||
import {
|
||
defaultGlobalLockFilePath,
|
||
defaultGlobalSkillsPath,
|
||
} from "../src/internal/paths/defaults";
|
||
|
||
const sampleManifest: Manifest = {
|
||
scope: "global",
|
||
totalSources: 2,
|
||
totalSkills: 3,
|
||
sources: new Map([
|
||
["anthropics/skills", { count: 2, skills: ["alpha", "beta"] }],
|
||
["github/awesome-copilot", { count: 1, skills: ["gamma"] }],
|
||
]),
|
||
};
|
||
|
||
test("shows restore help text", async () => {
|
||
const result = await runRestore(["--help"]);
|
||
const plainStdout = result.stdout.replace(/\x1b\[[0-9;]*m/g, "");
|
||
|
||
expect(result.exitCode).toBe(0);
|
||
expect(plainStdout.startsWith("\n")).toBe(true);
|
||
expect(result.stdout).toContain("Usage:");
|
||
expect(result.stdout).toContain("skvlt restore");
|
||
expect(result.stdout).not.toContain("skvlt restore --dry-run");
|
||
expect(plainStdout).toContain(
|
||
"\n\nExplore the open-source repo at https://github.com/xixu-me/skills-vault",
|
||
);
|
||
expect(plainStdout.endsWith(
|
||
"Explore the open-source repo at https://github.com/xixu-me/skills-vault\n\n",
|
||
)).toBe(true);
|
||
expect(result.stdout).toContain(
|
||
`Global restore verifies ${defaultGlobalSkillsPath} against ${defaultGlobalLockFilePath}`,
|
||
);
|
||
expect(result.stdout).toContain(
|
||
"Explore the open-source repo at https://github.com/xixu-me/skills-vault",
|
||
);
|
||
});
|
||
|
||
test("defaults restore manifest path to the current working directory", () => {
|
||
const options = parseRestoreArgs([]);
|
||
|
||
expect(options.manifestPath).toBe("./skvlt.yaml");
|
||
});
|
||
|
||
test("prints per-source install commands during restore dry-run", async () => {
|
||
const result = await runRestore(
|
||
["--dry-run", "--only-source", "anthropics/skills", "--concurrency", "4"],
|
||
{
|
||
readManifest: () => sampleManifest,
|
||
},
|
||
);
|
||
const plainStdout = result.stdout.replace(/\x1b\[[0-9;]*m/g, "");
|
||
|
||
expect(result.exitCode).toBe(0);
|
||
expect(plainStdout.startsWith("\n")).toBe(true);
|
||
expect(result.stdout).toContain(
|
||
"Parallel install concurrency: 1 (configured).",
|
||
);
|
||
expect(result.stdout).toContain(
|
||
"[dry-run] bunx skills add anthropics/skills -g -y --skill alpha beta",
|
||
);
|
||
expect(plainStdout.endsWith("\n\n")).toBe(true);
|
||
expect(result.stdout).not.toContain("github/awesome-copilot");
|
||
});
|
||
|
||
test("prints passthrough skills add options during restore dry-run", async () => {
|
||
const result = await runRestore(
|
||
[
|
||
"--dry-run",
|
||
"--only-source",
|
||
"anthropics/skills",
|
||
"--agent",
|
||
"codex",
|
||
"--agent",
|
||
"claude-code",
|
||
"--copy",
|
||
"--all",
|
||
],
|
||
{
|
||
readManifest: () => sampleManifest,
|
||
},
|
||
);
|
||
|
||
expect(result.exitCode).toBe(0);
|
||
expect(result.stdout).toContain(
|
||
"[dry-run] bunx skills add anthropics/skills -g -y --agent codex --agent claude-code --copy --all",
|
||
);
|
||
expect(result.stdout).not.toContain("--skill");
|
||
});
|
||
|
||
test("uses the manifest scope when no scope flag is provided", async () => {
|
||
const scopeAwareManifest: Manifest = {
|
||
scope: "project",
|
||
totalSources: 1,
|
||
totalSkills: 1,
|
||
sources: new Map([["anthropics/skills", { count: 1, skills: ["alpha"] }]]),
|
||
};
|
||
const scopeCalls: boolean[] = [];
|
||
const commands: string[][] = [];
|
||
|
||
const result = await runRestore(["--manifest", "ignored.yaml"], {
|
||
readManifest: () => scopeAwareManifest,
|
||
getInstalledSkillNames: async (projectScope) => {
|
||
scopeCalls.push(projectScope);
|
||
return new Set();
|
||
},
|
||
runBunx: async (args) => {
|
||
commands.push(args);
|
||
return { exitCode: 0, stdout: "", stderr: "" };
|
||
},
|
||
});
|
||
|
||
expect(result.exitCode).toBe(0);
|
||
expect(scopeCalls).toEqual([true]);
|
||
expect(commands).toEqual([
|
||
["skills", "add", "anthropics/skills", "-y", "--skill", "alpha"],
|
||
]);
|
||
expect(result.payload?.projectScope).toBe(true);
|
||
});
|
||
|
||
test("restores only missing skills during live execution", async () => {
|
||
const commands: string[][] = [];
|
||
|
||
const result = await runRestore(["--manifest", "ignored.yaml"], {
|
||
readManifest: () => sampleManifest,
|
||
getInstalledSkillNames: async () => new Set(["alpha"]),
|
||
readTrackedGlobalSkillNames: async () =>
|
||
new Set(["alpha", "beta", "gamma"]),
|
||
readInstalledGlobalSkillNames: async () =>
|
||
new Set(["alpha", "beta", "gamma"]),
|
||
runBunx: async (args) => {
|
||
commands.push(args);
|
||
return { exitCode: 0, stdout: "", stderr: "" };
|
||
},
|
||
});
|
||
const plainStdout = result.stdout.replace(/\x1b\[[0-9;]*m/g, "");
|
||
|
||
expect(result.exitCode).toBe(0);
|
||
expect(plainStdout.startsWith("\n")).toBe(true);
|
||
expect(result.stdout).toContain("Loading manifest from ignored.yaml...");
|
||
expect(result.stdout).toContain("Planning restore operations...");
|
||
expect(result.stdout).toContain(
|
||
"Source 1/2: anthropics/skills (1 pending, 1 already installed).",
|
||
);
|
||
expect(result.stdout).toContain(
|
||
"Source 2/2: github/awesome-copilot (1 pending, 0 already installed).",
|
||
);
|
||
expect(result.stdout).toContain("2 pending, 1 already installed.");
|
||
expect(result.stdout).toContain(
|
||
"Installing 1 missing skill(s) from anthropics/skills and skipping 1 already installed.",
|
||
);
|
||
expect(result.stdout).toContain(
|
||
"Restore summary: 2 source(s) processed, 2 source install(s) attempted, 2 skill(s) scheduled.",
|
||
);
|
||
expect(plainStdout.endsWith("\n\n")).toBe(true);
|
||
expect(result.stdout).not.toContain("›");
|
||
expect(commands).toEqual([
|
||
["skills", "add", "anthropics/skills", "-g", "-y", "--skill", "beta"],
|
||
["skills", "add", "github/awesome-copilot", "-g", "-y", "--skill", "gamma"],
|
||
]);
|
||
});
|
||
|
||
test("passes agent and copy options through during live restore", async () => {
|
||
const commands: string[][] = [];
|
||
|
||
const result = await runRestore(
|
||
[
|
||
"--manifest",
|
||
"ignored.yaml",
|
||
"--agent",
|
||
"codex",
|
||
"--agent",
|
||
"claude-code",
|
||
"--copy",
|
||
],
|
||
{
|
||
readManifest: () => sampleManifest,
|
||
getInstalledSkillNames: async () => new Set(["alpha"]),
|
||
readTrackedGlobalSkillNames: async () =>
|
||
new Set(["alpha", "beta", "gamma"]),
|
||
readInstalledGlobalSkillNames: async () =>
|
||
new Set(["alpha", "beta", "gamma"]),
|
||
runBunx: async (args) => {
|
||
commands.push(args);
|
||
return { exitCode: 0, stdout: "", stderr: "" };
|
||
},
|
||
},
|
||
);
|
||
|
||
expect(result.exitCode).toBe(0);
|
||
expect(commands).toEqual([
|
||
[
|
||
"skills",
|
||
"add",
|
||
"anthropics/skills",
|
||
"-g",
|
||
"-y",
|
||
"--agent",
|
||
"codex",
|
||
"--agent",
|
||
"claude-code",
|
||
"--copy",
|
||
"--skill",
|
||
"beta",
|
||
],
|
||
[
|
||
"skills",
|
||
"add",
|
||
"github/awesome-copilot",
|
||
"-g",
|
||
"-y",
|
||
"--agent",
|
||
"codex",
|
||
"--agent",
|
||
"claude-code",
|
||
"--copy",
|
||
"--skill",
|
||
"gamma",
|
||
],
|
||
]);
|
||
});
|
||
|
||
test("installs all source skills when --all is enabled", async () => {
|
||
const commands: string[][] = [];
|
||
|
||
const result = await runRestore(["--manifest", "ignored.yaml", "--all"], {
|
||
readManifest: () => sampleManifest,
|
||
getInstalledSkillNames: async () => new Set(["alpha", "beta", "gamma"]),
|
||
readTrackedGlobalSkillNames: async () =>
|
||
new Set(["alpha", "beta", "gamma"]),
|
||
readInstalledGlobalSkillNames: async () =>
|
||
new Set(["alpha", "beta", "gamma"]),
|
||
runBunx: async (args) => {
|
||
commands.push(args);
|
||
return { exitCode: 0, stdout: "", stderr: "" };
|
||
},
|
||
});
|
||
|
||
expect(result.exitCode).toBe(0);
|
||
expect(result.stdout).toContain(
|
||
"Manifest skill selections are ignored when --all is enabled; upstream source state determines what gets installed.",
|
||
);
|
||
expect(result.stdout).toContain(
|
||
"Restore summary: 2 source(s) processed, 2 source install(s) attempted, upstream source state determined the installed skill count.",
|
||
);
|
||
expect(commands).toEqual([
|
||
["skills", "add", "anthropics/skills", "-g", "-y", "--all"],
|
||
["skills", "add", "github/awesome-copilot", "-g", "-y", "--all"],
|
||
]);
|
||
});
|
||
|
||
test("clarifies dry-run semantics when --all is enabled", async () => {
|
||
const result = await runRestore(["--dry-run", "--all"], {
|
||
readManifest: () => sampleManifest,
|
||
});
|
||
|
||
expect(result.exitCode).toBe(0);
|
||
expect(result.stdout).toContain(
|
||
"Manifest skill selections are ignored when --all is enabled; upstream source state determines what gets installed.",
|
||
);
|
||
});
|
||
|
||
test("serializes live installs to avoid lock file races", async () => {
|
||
let activeInstalls = 0;
|
||
let maxActiveInstalls = 0;
|
||
let releaseFirstInstall!: () => void;
|
||
const firstInstallGate = new Promise<void>((resolve) => {
|
||
releaseFirstInstall = resolve;
|
||
});
|
||
|
||
const restorePromise = runRestore(
|
||
["--manifest", "ignored.yaml", "--concurrency", "4"],
|
||
{
|
||
readManifest: () => sampleManifest,
|
||
getInstalledSkillNames: async () => new Set(),
|
||
readTrackedGlobalSkillNames: async () =>
|
||
new Set(["alpha", "beta", "gamma"]),
|
||
readInstalledGlobalSkillNames: async () =>
|
||
new Set(["alpha", "beta", "gamma"]),
|
||
runBunx: async (args) => {
|
||
activeInstalls += 1;
|
||
maxActiveInstalls = Math.max(maxActiveInstalls, activeInstalls);
|
||
|
||
if (args[2] === "anthropics/skills") {
|
||
await firstInstallGate;
|
||
}
|
||
|
||
activeInstalls -= 1;
|
||
return { exitCode: 0, stdout: "", stderr: "" };
|
||
},
|
||
},
|
||
);
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
expect(maxActiveInstalls).toBe(1);
|
||
releaseFirstInstall();
|
||
|
||
const result = await restorePromise;
|
||
expect(result.exitCode).toBe(0);
|
||
expect(result.stdout).toContain(
|
||
"Installing from 2 source(s) with concurrency 1 (lock-safe mode).",
|
||
);
|
||
expect(result.stdout).not.toContain("›");
|
||
});
|
||
|
||
test("fails when global skills and the lock file diverge after restore", async () => {
|
||
const result = await runRestore(["--manifest", "ignored.yaml"], {
|
||
readManifest: () => sampleManifest,
|
||
getInstalledSkillNames: async () => new Set(["alpha"]),
|
||
readTrackedGlobalSkillNames: async () => new Set(["alpha"]),
|
||
readInstalledGlobalSkillNames: async () =>
|
||
new Set(["alpha", "beta", "gamma"]),
|
||
runBunx: async () => ({ exitCode: 0, stdout: "", stderr: "" }),
|
||
});
|
||
|
||
expect(result.exitCode).toBe(1);
|
||
expect(result.stderr).toContain(
|
||
"Global skills state mismatch: 2 installed skill(s) missing from .skill-lock.json.",
|
||
);
|
||
expect(result.stderr).toContain("beta");
|
||
expect(result.stderr).toContain("gamma");
|
||
});
|
||
|
||
test("fails when the lock file tracks skills missing from the skills directory", async () => {
|
||
const result = await runRestore(["--manifest", "ignored.yaml"], {
|
||
readManifest: () => sampleManifest,
|
||
getInstalledSkillNames: async () => new Set(["alpha", "beta", "gamma"]),
|
||
readTrackedGlobalSkillNames: async () =>
|
||
new Set(["alpha", "beta", "gamma", "stale-lock-only"]),
|
||
readInstalledGlobalSkillNames: async () =>
|
||
new Set(["alpha", "beta", "gamma"]),
|
||
runBunx: async () => ({ exitCode: 0, stdout: "", stderr: "" }),
|
||
});
|
||
|
||
expect(result.exitCode).toBe(1);
|
||
expect(result.errorCode).toBe("GLOBAL_STATE_MISMATCH");
|
||
expect(result.stderr).toContain(
|
||
"Global skills state mismatch: 1 tracked skill(s) missing from the skills directory.",
|
||
);
|
||
expect(result.stderr).toContain("stale-lock-only");
|
||
});
|
||
|
||
test("continues and returns success when continue-on-error is enabled", async () => {
|
||
const result = await runRestore(
|
||
["--manifest", "ignored.yaml", "--continue-on-error"],
|
||
{
|
||
readManifest: () => sampleManifest,
|
||
getInstalledSkillNames: async () => new Set(),
|
||
readTrackedGlobalSkillNames: async () =>
|
||
new Set(["alpha", "beta", "gamma"]),
|
||
readInstalledGlobalSkillNames: async () =>
|
||
new Set(["alpha", "beta", "gamma"]),
|
||
runBunx: async (args) => {
|
||
if (args[2] === "anthropics/skills") {
|
||
return { exitCode: 1, stdout: "", stderr: "source failed" };
|
||
}
|
||
|
||
return { exitCode: 0, stdout: "installed gamma", stderr: "" };
|
||
},
|
||
},
|
||
);
|
||
|
||
expect(result.exitCode).toBe(0);
|
||
expect(result.stdout).toContain(
|
||
"Installing from 2 source(s) with concurrency 1 (lock-safe mode).",
|
||
);
|
||
expect(result.stderr).toContain("source failed");
|
||
expect(result.stderr).toContain(
|
||
"Install failed for source 'anthropics/skills' with exit code 1",
|
||
);
|
||
expect(result.stderr).toContain(
|
||
"Completed restore with 1 source failure(s).",
|
||
);
|
||
expect(result.stderr).not.toContain(" ERROR ");
|
||
});
|
||
|
||
test("parses manifest text with quoted source and skill names", () => {
|
||
const manifest = parseManifestText(
|
||
[
|
||
"total_sources: 1",
|
||
"total_skills: 2",
|
||
"",
|
||
"sources:",
|
||
' "owner/repo":',
|
||
" count: 2",
|
||
" skills:",
|
||
" - 'simple'",
|
||
' - "needs \\"quotes\\""',
|
||
"",
|
||
].join("\n"),
|
||
"memory.yaml",
|
||
);
|
||
|
||
expect(manifest.totalSources).toBe(1);
|
||
expect(manifest.totalSkills).toBe(2);
|
||
expect(manifest.sources.get("owner/repo")).toEqual({
|
||
count: 2,
|
||
skills: ["simple", 'needs "quotes"'],
|
||
});
|
||
});
|
||
|
||
test("returns a stable error code when the manifest is missing", async () => {
|
||
const result = await runRestore(["--manifest", "missing.yaml"]);
|
||
|
||
expect(result.exitCode).toBe(1);
|
||
expect(result.errorCode).toBe("MANIFEST_NOT_FOUND");
|
||
expect(result.stderr).toContain("Manifest not found: missing.yaml");
|
||
});
|
||
|
||
test("renders restore argument errors as a usage page", async () => {
|
||
const result = await runRestore(["--concurrency"]);
|
||
const plainStderr = result.stderr.replace(/\x1b\[[0-9;]*m/g, "");
|
||
|
||
expect(result.exitCode).toBe(1);
|
||
expect(result.errorCode).toBe("INVALID_ARGUMENT");
|
||
expect(plainStderr.startsWith("\n")).toBe(true);
|
||
expect(plainStderr).toContain("ERROR");
|
||
expect(plainStderr).toContain("--concurrency requires a value");
|
||
expect(plainStderr).toContain("Usage:");
|
||
expect(plainStderr).toContain("skvlt restore [options]");
|
||
expect(plainStderr).toContain("Example:");
|
||
expect(plainStderr).toContain("skvlt restore --all");
|
||
expect(plainStderr.endsWith("\n\n")).toBe(true);
|
||
});
|