/** * @file Tests for server/lib/cc-plugin-cli.js — the `claude plugin …` wrapper * that backs plugin/marketplace management from the dashboard. Uses a real * fake `claude` script on PATH (the fixture style server/__tests__/gh.test.js * uses) rather than mocking child_process, and pins the exact argv each * operation builds — that argv IS the security boundary. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, after } = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ccplugin-")); const cli = require("../lib/cc-plugin-cli"); /** * Put a fake `claude` on PATH. The default body echoes the argv, one element * per line, so a test can assert the exact array that was spawned. */ function stubClaude(scriptBody = 'for a in "$@"; do echo "$a"; done') { const bin = path.join(ROOT, `bin-${process.hrtime.bigint()}`); fs.mkdirSync(bin, { recursive: true }); const file = path.join(bin, "claude"); fs.writeFileSync(file, `#!/usr/bin/env bash\n${scriptBody}\n`); fs.chmodSync(file, 0o755); const originalPath = process.env.PATH; process.env.PATH = `${bin}${path.delimiter}${originalPath}`; return () => { process.env.PATH = originalPath; }; } /** PATH with no `claude` on it at all. */ function stubMissingClaude() { const empty = path.join(ROOT, `empty-${process.hrtime.bigint()}`); fs.mkdirSync(empty, { recursive: true }); const originalPath = process.env.PATH; process.env.PATH = empty; return () => { process.env.PATH = originalPath; }; } const argvOf = (result) => result.stdout.trim().split("\n"); after(() => fs.rmSync(ROOT, { recursive: true, force: true, maxRetries: 5 })); describe("cc-plugin-cli argument validation", () => { it("accepts owner/repo, http(s) URLs and absolute paths as a source", () => { assert.equal(cli.assertSource("anthropics/claude-plugins"), "anthropics/claude-plugins"); assert.equal(cli.assertSource("https://github.com/a/b"), "https://github.com/a/b"); assert.equal(cli.assertSource("/opt/marketplace"), "/opt/marketplace"); }); it("rejects sources that are neither repo, URL nor absolute path", () => { for (const bad of ["./relative", "git@github.com:a/b.git", "file:///tmp/x", "a/b/c/d e"]) { assert.throws(() => cli.assertSource(bad), /source must be/, `accepted ${bad}`); } }); it("rejects a source that could become a flag or escape upward", () => { assert.throws(() => cli.assertSource("--scope"), /may not start with/); assert.throws(() => cli.assertSource("/opt/../etc"), /'\.\.'/); }); it("rejects control characters and empty values", () => { assert.throws(() => cli.assertSource("a/b\nrm -rf /"), /control characters/); assert.throws(() => cli.assertSource(""), /required/); assert.throws(() => cli.assertSource(null), /required/); }); it("accepts and @ plugin specs only", () => { assert.equal(cli.assertPluginSpec("agenthub"), "agenthub"); assert.equal(cli.assertPluginSpec("agenthub@sg"), "agenthub@sg"); for (const bad of [ "agenthub@bad/repo", "../agenthub", "agenthub agenthub", "@sg", "-agenthub", ]) { assert.throws(() => cli.assertPluginSpec(bad), undefined, `accepted ${bad}`); } }); it("rejects marketplace names that are not plain identifiers", () => { assert.equal(cli.assertMarketplaceName("sg"), "sg"); assert.throws(() => cli.assertMarketplaceName("sg/other"), /valid identifier/); }); it("defaults the CLI scope to user and rejects unknown scopes", () => { assert.equal(cli.assertCliScope(undefined), "user"); assert.equal(cli.assertCliScope("local"), "local"); // `project-local` is the dashboard's settings vocabulary, not the CLI's. assert.throws(() => cli.assertCliScope("project-local"), /scope must be one of/); }); }); describe("cc-plugin-cli argv construction", () => { it("builds the marketplace add argv with an explicit scope", async () => { const restore = stubClaude(); try { const result = await cli.marketplaceAdd({ source: "owner/repo" }); assert.deepEqual(argvOf(result), [ "plugin", "marketplace", "add", "owner/repo", "--scope", "user", ]); } finally { restore(); } }); it("retries without -y on a Claude Code too old to know the flag", async () => { // Older CLIs answer `error: unknown option '-y'` and exit 1; they also // never prompt, so the same command without the flag is the right retry. const restore = stubClaude( 'for a in "$@"; do [ "$a" = "-y" ] && { echo "error: unknown option \'-y\'" >&2; exit 1; }; done\n' + 'for a in "$@"; do echo "$a"; done' ); try { const r = await cli.pluginUpdate({ spec: "demo@m" }); assert.equal(r.ok, true); assert.deepEqual(argvOf(r), ["plugin", "update", "demo@m"]); } finally { restore(); } }); it("passes -y on install so a non-TTY run never waits for a prompt", async () => { const restore = stubClaude(); try { const result = await cli.pluginInstall({ spec: "demo@tmpmk", scope: "project" }); assert.deepEqual(argvOf(result), [ "plugin", "install", "demo@tmpmk", "--scope", "project", "-y", ]); } finally { restore(); } }); it("builds uninstall / enable / disable / update argvs", async () => { const restore = stubClaude(); try { assert.deepEqual(argvOf(await cli.pluginUninstall({ spec: "demo@m" })), [ "plugin", "uninstall", "demo@m", ]); assert.deepEqual(argvOf(await cli.pluginEnable({ spec: "demo@m" })), [ "plugin", "enable", "demo@m", ]); assert.deepEqual(argvOf(await cli.pluginDisable({ spec: "demo@m" })), [ "plugin", "disable", "demo@m", ]); // `-y` is mandatory, not cosmetic: runClaude closes stdin, and the CLI // refuses to update without it whenever stdin is not a TTY. assert.deepEqual(argvOf(await cli.pluginUpdate({ spec: "demo@m" })), [ "plugin", "update", "demo@m", "-y", ]); assert.deepEqual(argvOf(await cli.marketplaceRemove({ name: "m" })), [ "plugin", "marketplace", "remove", "m", ]); assert.deepEqual(argvOf(await cli.marketplaceUpdate({ name: "m" })), [ "plugin", "marketplace", "update", "m", ]); } finally { restore(); } }); it("never lets a source reach a shell", async () => { // If the value were interpolated into a shell string this would create the // file; through execFile's argv array it stays one literal argument. const canary = path.join(ROOT, "pwned"); const restore = stubClaude(); try { await assert.rejects( () => cli.marketplaceAdd({ source: `owner/repo; touch ${canary}` }), /source must be/ ); assert.equal(fs.existsSync(canary), false); } finally { restore(); } }); }); describe("cc-plugin-cli CLAUDE_CONFIG_DIR pinning", () => { // N1: pinning CLAUDE_CONFIG_DIR unconditionally to getClaudeHome() made the // CLI write ~/.claude/.claude.json while cc-discovery reads ~/.claude.json // — a different file, so every default-install add/remove was inert. The // fix is to leave CLAUDE_CONFIG_DIR unset unless the operator explicitly // set CLAUDE_HOME; this is the env contract that would have caught it. const PRINT_ENV = "printf '%s\\n' \"CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR-}\""; it("leaves CLAUDE_CONFIG_DIR unset when CLAUDE_HOME is not set", async () => { const originalHome = process.env.CLAUDE_HOME; delete process.env.CLAUDE_HOME; const restore = stubClaude(PRINT_ENV); try { const result = await cli.marketplaceUpdate({ name: "m" }); assert.equal(result.stdout.trim(), "CLAUDE_CONFIG_DIR="); } finally { restore(); if (originalHome !== undefined) process.env.CLAUDE_HOME = originalHome; } }); it("pins CLAUDE_CONFIG_DIR to CLAUDE_HOME when the operator set it", async () => { const originalHome = process.env.CLAUDE_HOME; process.env.CLAUDE_HOME = "/tmp/some-other-claude-home"; const restore = stubClaude(PRINT_ENV); try { const result = await cli.marketplaceUpdate({ name: "m" }); assert.equal(result.stdout.trim(), "CLAUDE_CONFIG_DIR=/tmp/some-other-claude-home"); } finally { restore(); if (originalHome === undefined) delete process.env.CLAUDE_HOME; else process.env.CLAUDE_HOME = originalHome; } }); // The two env tests above pin the env var each half of the pair depends // on. This pins the pair itself end to end: the file getClaudeJsonPath() // returns must be the file the CLI actually wrote — under BOTH an unset // and a set CLAUDE_HOME. A fake `claude` that writes `.claude.json` // exactly where the real binary does (verified against it: flat inside // CLAUDE_CONFIG_DIR when set, beside — NOT inside — $HOME when unset) // stands in for the real CLI; no real CLI or real $HOME/CLAUDE_HOME touched. const WRITE_CLAUDE_JSON = 'echo "{}" > "${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json"'; it("writes .claude.json exactly where getClaudeJsonPath() reads it, with CLAUDE_HOME unset", async () => { const cc = require("../lib/cc-discovery"); const originalHome = process.env.HOME; const originalClaudeHome = process.env.CLAUDE_HOME; const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-fakehome-")); delete process.env.CLAUDE_HOME; process.env.HOME = fakeHome; const restore = stubClaude(WRITE_CLAUDE_JSON); try { await cli.marketplaceUpdate({ name: "m" }); assert.equal(cc.getClaudeJsonPath(), path.join(fakeHome, ".claude.json")); assert.ok(fs.existsSync(cc.getClaudeJsonPath()), "the CLI's write landed where reads look"); } finally { restore(); process.env.HOME = originalHome; if (originalClaudeHome !== undefined) process.env.CLAUDE_HOME = originalClaudeHome; fs.rmSync(fakeHome, { recursive: true, force: true }); } }); it("writes .claude.json exactly where getClaudeJsonPath() reads it, with CLAUDE_HOME set", async () => { const cc = require("../lib/cc-discovery"); const originalClaudeHome = process.env.CLAUDE_HOME; const fakeClaudeHome = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-fakeclaudehome-")); process.env.CLAUDE_HOME = fakeClaudeHome; const restore = stubClaude(WRITE_CLAUDE_JSON); try { await cli.marketplaceUpdate({ name: "m" }); assert.equal(cc.getClaudeJsonPath(), path.join(fakeClaudeHome, ".claude.json")); assert.ok(fs.existsSync(cc.getClaudeJsonPath()), "the CLI's write landed where reads look"); } finally { restore(); if (originalClaudeHome === undefined) delete process.env.CLAUDE_HOME; else process.env.CLAUDE_HOME = originalClaudeHome; fs.rmSync(fakeClaudeHome, { recursive: true, force: true }); } }); }); describe("cc-plugin-cli run outcomes", () => { it("reports a non-zero exit as ok:false instead of throwing", async () => { const restore = stubClaude('echo "✘ Failed to install plugin \\"nope\\"" >&2\nexit 1'); try { const result = await cli.pluginInstall({ spec: "nope@m" }); assert.equal(result.ok, false); assert.equal(result.code, 1); assert.match(cli.cliMessage(result), /Failed to install plugin/); } finally { restore(); } }); it("throws a clear error when the claude CLI is not on PATH", async () => { const restore = stubMissingClaude(); try { await assert.rejects( () => cli.marketplaceUpdate({ name: "m" }), (err) => { assert.equal(err.code, "ECLINOTFOUND"); assert.match(err.message, /not found on PATH/); return true; } ); } finally { restore(); } }); it("parses `plugin list --available --json`", async () => { const payload = JSON.stringify({ installed: [{ id: "demo@m" }], available: [{ pluginId: "other@m", name: "other" }], }); const restore = stubClaude(`cat <<'JSON'\n${payload}\nJSON`); try { const listed = await cli.listAvailable(); assert.equal(listed.installed.length, 1); assert.equal(listed.available[0].pluginId, "other@m"); } finally { restore(); } }); it("surfaces unparseable list output as an error", async () => { const restore = stubClaude("echo 'not json'"); try { await assert.rejects(() => cli.listAvailable(), /could not parse/); } finally { restore(); } }); it("serializes mutating calls so two clicks cannot interleave", async () => { // Each run appends its marker around a sleep; interleaved runs would // produce "a1 b1 a2 b2" rather than two complete pairs. const log = path.join(ROOT, `order-${process.hrtime.bigint()}.txt`); const restore = stubClaude(`echo "start $3" >> ${log}\nsleep 0.2\necho "end $3" >> ${log}`); try { await Promise.all([ cli.marketplaceUpdate({ name: "first" }), cli.marketplaceUpdate({ name: "second" }), ]); const lines = fs.readFileSync(log, "utf8").trim().split("\n"); assert.equal(lines[0].replace("start ", ""), lines[1].replace("end ", "")); assert.equal(lines[2].replace("start ", ""), lines[3].replace("end ", "")); } finally { restore(); } }); });