Files
Julius BrusseeandClaude Fable 5 ed1fbb7f4c fix(install): rename bin/ to cli/, harden Windows quoting, clean uninstall
Marketplace fix (#712, #705): Claude Desktop rejects plugins containing a
top-level bin/ directory, and .claude-plugin/marketplace.json packages the
repo root, so the installer directory is now cli/. Every reference updated
(package.json bin entry + files, shims, docs, tests, caveman-init require
path). Supersedes PR #726.

Security (PR #717 verified): quoteWinArg only quoted on whitespace/quotes,
leaving cmd.exe metacharacters (& | ^ < > % parens) unescaped on the
shell:true Windows spawn path. Attacker-influenced arguments (--with-init
cwd, --with-mcp-shrink value) could chain commands. Trigger regex now
covers the metacharacter set; quoting logic split into a platform-
independent, unit-tested helper.

Also:
- uninstall removes .caveman-active.prev, .caveman-mode-log.jsonl,
  .caveman-statusline-suffix, .caveman-nudge-shown; keeps
  .caveman-history.jsonl with a printed note; dry-run now says
  'would remove' instead of lying (#635, supersedes PRs #693 #636)
- Array.isArray guard in rewriteLegacyManagedHookCommands — malformed
  hook event no longer crashes the installer mid-run (supersedes PR #646)
- gemini extensions install --consent: the security prompt hung every
  piped/non-interactive install forever (#676, part of PR #664)
- OpenClaw skill stamps the real PINNED_REF version instead of hardcoded
  1.0.0; new --no-always flag for load-on-demand installs (supersedes
  PR #720)
- shims scope NPM_CONFIG_ALLOW_GIT=all to the npx call — npm >=12
  defaults allow-git to none and EALLOWGITs github: installs (#698)
- .codex/config.toml ships hooks + codex_hooks keys so auto-activation
  works on both sides of the codex-cli rename (#617)
- caveman-help card shows the Windows config path (%APPDATA%) (#723)
- caveman-parse.js added to HOOK_FILES, opencode payload (.cjs), and the
  regenerated checksums.sha256; manifest now matches shipped hook
  contents — release must bump PINNED_REF to a tag containing these files

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ySX6TBWZuvFze4ajf7Hpf
2026-07-21 02:01:27 +02:00

202 lines
8.6 KiB
JavaScript

// Unit tests for the argv parser embedded in cli/install.js.
// We don't import parseArgs (it's not exported) — instead we shell out to the
// installer with --help / --list / unknown flags and assert the framing.
// For deeper coverage of flag-resolution semantics, exec --dry-run --list and
// check the rendered defaults.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const INSTALLER = path.resolve(HERE, '..', '..', 'cli', 'install.js');
const requireCjs = createRequire(import.meta.url);
const { winQuoteIfNeeded } = requireCjs(INSTALLER);
function run(...args) {
return spawnSync('node', [INSTALLER, ...args], { encoding: 'utf8' });
}
test('--help prints usage and exits 0', () => {
const r = run('--help');
assert.equal(r.status, 0);
assert.match(r.stdout, /USAGE/);
assert.match(r.stdout, /--with-hooks/);
});
test('--list prints provider matrix', () => {
const r = run('--list');
assert.equal(r.status, 0);
assert.match(r.stdout, /caveman provider matrix/);
assert.match(r.stdout, /claude\b/);
assert.match(r.stdout, /gemini\b/);
assert.match(r.stdout, /antigravity\b.*\(soft\)/);
});
test('unknown flag exits 2 with error', () => {
const r = run('--bogus');
assert.equal(r.status, 2);
assert.match(r.stderr, /unknown flag/);
});
test('--all + --minimal mutually exclusive', () => {
const r = run('--all', '--minimal');
assert.equal(r.status, 2);
assert.match(r.stderr, /mutually exclusive/);
});
test('--only without arg fails', () => {
const r = run('--only');
assert.equal(r.status, 2);
assert.match(r.stderr, /--only requires an argument/);
});
test('--config-dir without arg fails', () => {
const r = run('--config-dir');
assert.equal(r.status, 2);
assert.match(r.stderr, /--config-dir requires a path/);
});
test('--config-dir followed by another flag fails', () => {
const r = run('--config-dir', '--all');
assert.equal(r.status, 2);
assert.match(r.stderr, /--config-dir requires a path/);
});
test('aider alias rewrites to aider-desk in dry-run output', () => {
const r = run('--dry-run', '--only', 'aider', '--non-interactive', '--config-dir', '/tmp/__cm_alias_test');
// No detection means no install lines, but the script should not crash.
assert.equal(r.status, 0);
});
test('--only with unknown agent id exits 2', () => {
const r = run('--only', 'definitely-not-an-agent', '--non-interactive');
assert.equal(r.status, 2);
assert.match(r.stderr, /unknown agent: definitely-not-an-agent/);
assert.match(r.stderr, /caveman --list/);
});
test('--only known id passes argv validation', () => {
// Dry-run + --only claude exits 0 even if the claude binary isn't on PATH.
const r = run('--dry-run', '--only', 'claude', '--non-interactive', '--config-dir', '/tmp/__cm_only_test');
assert.equal(r.status, 0);
});
test('--config-dir expands ~ to home directory', async () => {
// Pass `~/cm-test-…` and assert the dry-run plan resolves it relative to $HOME.
// Use a unique suffix so the assertion is unambiguous.
const suffix = `cm-test-${process.pid}`;
// --with-hooks: since #392/#393 the hooks plan (which echoes the resolved
// config-dir) is only emitted when the plugin install fails OR hooks are
// forced. Force it so the path-expansion assertion below has something to
// match even when the caveman plugin is already installed.
const r = run('--dry-run', '--only', 'claude', '--with-hooks', '--non-interactive', '--config-dir', `~/${suffix}`);
assert.equal(r.status, 0);
// If the literal `~` had survived, we'd see `~/cm-test-…/hooks` in the plan.
// The fix expands it in parseArgs, so we expect the absolute home path.
assert.doesNotMatch(r.stdout, /~\/cm-test-/);
// The plan only includes the hooks dir if claude is detected. Skip the
// positive assertion when claude isn't on PATH on the runner.
if (/Claude Code detected/.test(r.stdout)) {
const { homedir } = await import('node:os');
assert.match(r.stdout, new RegExp(homedir().replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '/' + suffix));
}
});
test('bare -- (POSIX end-of-options) is accepted and ignored', () => {
// Regression: npx forwarded `--` from `curl|bash -- --only openclaw` to the
// package, and parseArgs rejected it as an unknown flag. Now we accept it.
const r = run('--', '--only', 'claude', '--non-interactive', '--dry-run', '--config-dir', '/tmp/__cm_dashdash');
assert.equal(r.status, 0);
});
test('bare --with-mcp-shrink (no upstream) exits 2 with hint', () => {
// Regression for issue where --with-mcp-shrink registered a stub MCP entry
// that crashed on every Claude Code startup. caveman-shrink is a proxy and
// requires an upstream command — we now refuse the bare flag (#474).
const r = run('--with-mcp-shrink', '--non-interactive', '--dry-run');
assert.equal(r.status, 2);
assert.match(r.stderr, /requires an upstream command/);
assert.match(r.stderr, /server-filesystem/);
});
test('--with-mcp-shrink followed by another flag (no value) exits 2', () => {
// The next-token form must distinguish "no value" from "value happens to
// start with --". A user typing `--with-mcp-shrink --dry-run` clearly
// forgot the upstream; refuse.
const r = run('--with-mcp-shrink', '--dry-run', '--non-interactive');
assert.equal(r.status, 2);
assert.match(r.stderr, /requires an upstream command/);
});
test('--with-mcp-shrink="<cmd>" registers wrapping that upstream', () => {
const r = run(
'--with-mcp-shrink=npx @modelcontextprotocol/server-filesystem /tmp',
'--only', 'claude', '--dry-run', '--non-interactive',
'--config-dir', '/tmp/__cm_shrink_test'
);
assert.equal(r.status, 0);
// Dry-run only emits the planned `claude mcp add` line when claude is on
// PATH (installMcpShrink probes `claude mcp --help` first). Assert the
// wrapping content only when that line is actually present.
if (/would run: claude mcp add caveman-shrink/.test(r.stdout)) {
assert.match(r.stdout, /claude mcp add caveman-shrink .* npx -y caveman-shrink npx @modelcontextprotocol\/server-filesystem \/tmp/);
}
});
test('--with-mcp-shrink "<cmd>" (space-separated) also accepted', () => {
const r = run(
'--with-mcp-shrink', 'npx @modelcontextprotocol/server-filesystem /tmp',
'--only', 'claude', '--dry-run', '--non-interactive',
'--config-dir', '/tmp/__cm_shrink_space'
);
assert.equal(r.status, 0);
if (/would run: claude mcp add caveman-shrink/.test(r.stdout)) {
assert.match(r.stdout, /caveman-shrink npx @modelcontextprotocol\/server-filesystem \/tmp/);
}
});
test('--all does NOT auto-enable mcp-shrink (no sensible default upstream)', () => {
const r = run('--all', '--only', 'claude', '--dry-run', '--non-interactive', '--config-dir', '/tmp/__cm_all_no_shrink');
assert.equal(r.status, 0);
// Whether or not claude is on PATH, the wiring banner should not appear
// because withMcpShrink stays false under --all alone.
assert.doesNotMatch(r.stdout, /wiring caveman-shrink MCP proxy/);
});
test('winQuoteIfNeeded leaves a plain argument untouched', () => {
assert.equal(winQuoteIfNeeded('claude'), 'claude');
assert.equal(winQuoteIfNeeded('/tmp/plain-path'), '/tmp/plain-path');
});
test('winQuoteIfNeeded quotes whitespace and embedded quotes (pre-existing behavior)', () => {
assert.equal(winQuoteIfNeeded('has space'), '"has space"');
assert.equal(winQuoteIfNeeded(''), '""');
});
test('winQuoteIfNeeded quotes cmd.exe metacharacters (Windows command-injection fix)', () => {
// Pre-fix, the trigger regex only matched /[\s"]/ — none of these contain
// whitespace or a quote, so they reached cmd.exe (via spawnXplat's
// `shell: true`) completely unquoted. An attacker-influenced arg like a
// --with-mcp-shrink value or --with-init cwd containing one of these could
// chain a second command (e.g. `foo & calc.exe`).
for (const ch of ['&', '|', '^', '<', '>', '%', '(', ')']) {
const arg = `foo${ch}bar`;
assert.equal(winQuoteIfNeeded(arg), `"${arg}"`, `metacharacter ${JSON.stringify(ch)} must trigger quoting`);
}
});
test('--help discloses --config-dir scope', () => {
const r = run('--help');
assert.equal(r.status, 0);
// Disclosure: --config-dir does NOT scope third-party CLI invocations.
// Help text wraps mid-phrase, so collapse whitespace before matching.
const collapsed = r.stdout.replace(/\s+/g, ' ');
assert.match(collapsed, /Does NOT scope/);
assert.match(collapsed, /XDG_CONFIG_HOME/);
assert.match(collapsed, /OPENCLAW_WORKSPACE/);
});