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("bunx skvlt restore"); expect(result.stdout).not.toContain("bunx 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("does not stream a skill progress bar during restore dry-run", async () => { let streamed = ""; const result = await runRestore( ["--dry-run", "--only-source", "anthropics/skills"], { readManifest: () => sampleManifest, streamOutput: true, reportProgress: (chunk) => { streamed += chunk; }, }, ); const plainStream = streamed.replace(/\x1b\[[0-9;]*m/g, ""); expect(result.exitCode).toBe(0); expect(plainStream).not.toContain("Progress:"); }); 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("streams a skill progress bar during live restore execution", async () => { let streamed = ""; 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 () => ({ exitCode: 0, stdout: "", stderr: "" }), streamOutput: true, reportProgress: (chunk) => { streamed += chunk; }, }); const plainStream = streamed.replace(/\x1b\[[0-9;]*m/g, ""); expect(result.exitCode).toBe(0); expect(plainStream).toContain( "Installing from 2 source(s) with concurrency 1 (lock-safe mode).", ); expect(plainStream).toContain("Progress: [----------] 0/2 skills"); expect(plainStream).toContain("Progress: [#####-----] 1/2 skills"); expect(plainStream).toContain("Progress: [##########] 2/2 skills"); }); 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((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("bunx skvlt restore [options]"); expect(plainStderr).toContain("Example:"); expect(plainStderr).toContain("bunx skvlt restore --all"); expect(plainStderr.endsWith("\n\n")).toBe(true); });