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

71 lines
3.3 KiB
JavaScript

// End-to-end: dry-run installer prints expected file plan without touching disk.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const INSTALLER = path.resolve(HERE, '..', '..', 'cli', 'install.js');
function freshTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'cm-dryrun-'));
}
test('dry-run --only claude prints plan and writes nothing', () => {
const cfg = freshTmpDir();
const r = spawnSync('node', [INSTALLER,
// --with-hooks: since #392/#393 the default only wires standalone hooks
// when the plugin install fails. Force the hook-planning path so the
// "would install / would merge" assertions below are exercised.
'--dry-run', '--only', 'claude', '--with-hooks', '--no-mcp-shrink', '--non-interactive',
'--config-dir', cfg,
], { encoding: 'utf8', env: { ...process.env, CLAUDE_CONFIG_DIR: cfg } });
assert.equal(r.status, 0);
// Only fires if `claude` is on PATH on the test runner. If not, this assertion
// is a no-op (the installer just prints "nothing detected" and exits 0).
if (/Claude Code detected/.test(r.stdout)) {
assert.match(r.stdout, /would run: claude plugin marketplace add/);
assert.match(r.stdout, /would run: claude plugin install caveman@caveman/);
assert.match(r.stdout, /would mkdir -p .*\/hooks/);
assert.match(r.stdout, /would install .*caveman-activate\.js/);
assert.match(r.stdout, /would merge SessionStart \+ UserPromptSubmit \+ statusline/);
}
// Nothing should have been written.
assert.equal(fs.existsSync(path.join(cfg, 'settings.json')), false);
assert.equal(fs.existsSync(path.join(cfg, 'hooks')), false);
});
test('dry-run --only gemini passes --consent (issue #676 — avoids the confirmation-prompt hang)', () => {
const cfg = freshTmpDir();
// --only forces installGemini to run regardless of whether `gemini` is
// actually on PATH — safe to assert against on any CI runner.
const r = spawnSync('node', [INSTALLER,
'--dry-run', '--only', 'gemini', '--non-interactive', '--config-dir', cfg,
], { encoding: 'utf8', env: { ...process.env, CLAUDE_CONFIG_DIR: cfg } });
assert.equal(r.status, 0);
assert.match(r.stdout, /would run: gemini extensions install https:\/\/github\.com\/\S+ --consent/);
});
test('dry-run --uninstall does not delete files', () => {
const cfg = freshTmpDir();
// Seed a fake installation
fs.mkdirSync(path.join(cfg, 'hooks'), { recursive: true });
const fake = path.join(cfg, 'hooks', 'caveman-activate.js');
fs.writeFileSync(fake, '// fake');
fs.writeFileSync(path.join(cfg, 'settings.json'),
JSON.stringify({ hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'node ' + fake }] }] } }, null, 2));
const before = fs.readFileSync(path.join(cfg, 'settings.json'), 'utf8');
const r = spawnSync('node', [INSTALLER, '--uninstall', '--dry-run', '--non-interactive', '--config-dir', cfg],
{ encoding: 'utf8', env: { ...process.env, CLAUDE_CONFIG_DIR: cfg } });
assert.equal(r.status, 0);
// File still present, settings unchanged.
assert.equal(fs.existsSync(fake), true);
assert.equal(fs.readFileSync(path.join(cfg, 'settings.json'), 'utf8'), before);
});