17e4321761
getClaudeJsonPath() was the only one of cc-discovery's 20 read paths not derived from getClaudeHome(), so a CLAUDE_HOME install read one file while the CLI wrote another. The default case was fixed by dropping the CLAUDE_CONFIG_DIR pin; this closes the mirrored half. Also replaces the Remove button's display-string comparison with a removable flag from the reader: rewording the source label silently resurrected a button that always fails.
323 lines
11 KiB
JavaScript
323 lines
11 KiB
JavaScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
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 <name> and <name>@<marketplace> 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("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",
|
|
]);
|
|
assert.deepEqual(argvOf(await cli.pluginUpdate({ spec: "demo@m" })), [
|
|
"plugin",
|
|
"update",
|
|
"demo@m",
|
|
]);
|
|
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-<unset>}\"";
|
|
|
|
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=<unset>");
|
|
} 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 one half each. This pins the pair: the file
|
|
// the dashboard READS must be the file the CLI WRITES, under both settings.
|
|
// `.claude.json` was the only read path in cc-discovery not derived from
|
|
// getClaudeHome(), which is exactly how the two halves drifted apart.
|
|
it("reads the same .claude.json the CLI writes, with CLAUDE_HOME set or unset", () => {
|
|
const cc = require("../lib/cc-discovery");
|
|
const originalHome = process.env.CLAUDE_HOME;
|
|
|
|
// CLI's write target = <CLAUDE_CONFIG_DIR>/.claude.json, and
|
|
// CLAUDE_CONFIG_DIR is either CLAUDE_HOME or the CLI's own default,
|
|
// which is the same directory getClaudeHome() returns.
|
|
try {
|
|
delete process.env.CLAUDE_HOME;
|
|
assert.equal(cc.getClaudeJsonPath(), path.join(os.homedir(), ".claude", ".claude.json"));
|
|
|
|
process.env.CLAUDE_HOME = "/tmp/some-other-claude-home";
|
|
assert.equal(cc.getClaudeJsonPath(), "/tmp/some-other-claude-home/.claude.json");
|
|
} finally {
|
|
if (originalHome === undefined) delete process.env.CLAUDE_HOME;
|
|
else process.env.CLAUDE_HOME = originalHome;
|
|
}
|
|
});
|
|
});
|
|
|
|
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();
|
|
}
|
|
});
|
|
});
|