Files
caveman/tests/installer/unit.settings.test.mjs
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

347 lines
13 KiB
JavaScript

// Unit tests for cli/lib/settings.js — the JSONC-tolerant settings helper.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const SETTINGS = require('../../cli/lib/settings.js');
function tmpFile(name, contents) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cm-settings-'));
const p = path.join(dir, name);
fs.writeFileSync(p, contents);
return p;
}
test('stripJsonComments strips // line comments', () => {
const out = SETTINGS.stripJsonComments('{"a":1}// trail');
assert.equal(out.trim(), '{"a":1}');
});
test('stripJsonComments preserves ,} and ,] inside string values (issue #595)', () => {
// Trailing-comma removal must be string-aware: a hook command like
// `echo ,}` or shell brace expansion `cp file{,.bak}` must survive.
const src = '{"cmd": "echo ,}", // comment\n"glob": "cp file{,]x", }';
const parsed = JSON.parse(SETTINGS.stripJsonComments(src));
assert.equal(parsed.cmd, 'echo ,}');
assert.equal(parsed.glob, 'cp file{,]x');
});
test('stripJsonComments still removes real trailing commas after strings', () => {
const src = '{"a": [1, 2, 3,], "b": {"c": 1,},}';
const parsed = JSON.parse(SETTINGS.stripJsonComments(src));
assert.deepEqual(parsed, { a: [1, 2, 3], b: { c: 1 } });
});
test('stripJsonComments handles escaped quotes before ,} in strings', () => {
const src = '{"cmd": "say \\",}\\" done", }';
const parsed = JSON.parse(SETTINGS.stripJsonComments(src));
assert.equal(parsed.cmd, 'say ",}" done');
});
test('stripJsonComments strips /* block */ comments', () => {
const out = SETTINGS.stripJsonComments('{/* leading */"a":1/* mid */, "b":2}');
assert.match(out, /"a":1/);
assert.match(out, /"b":2/);
assert.doesNotMatch(out, /leading/);
});
test('stripJsonComments leaves comment-looking sequences inside strings alone', () => {
const out = SETTINGS.stripJsonComments('{"url":"http://example.com//path"}');
assert.equal(out, '{"url":"http://example.com//path"}');
});
test('stripJsonComments strips trailing commas', () => {
const out = SETTINGS.stripJsonComments('{"a":[1,2,3,],}');
assert.doesNotThrow(() => JSON.parse(out));
});
test('readSettings handles plain JSON', () => {
const p = tmpFile('s.json', '{"theme":"dark"}');
assert.deepEqual(SETTINGS.readSettings(p), { theme: 'dark' });
});
test('readSettings handles JSONC (comments + trailing commas)', () => {
const p = tmpFile('s.json', `// my settings
{
"theme": "dark", /* mode */
"hooks": {},
}`);
assert.deepEqual(SETTINGS.readSettings(p), { theme: 'dark', hooks: {} });
});
test('readSettings returns {} for missing file', () => {
assert.deepEqual(SETTINGS.readSettings('/nonexistent/path/xyz.json'), {});
});
test('readSettings returns null for unrecoverable garbage', () => {
const p = tmpFile('s.json', 'this is not json at all {{{');
assert.equal(SETTINGS.readSettings(p), null);
});
test('writeSettings round-trips with newline', () => {
const p = tmpFile('s.json', '');
SETTINGS.writeSettings(p, { a: 1 });
const raw = fs.readFileSync(p, 'utf8');
assert.equal(raw.endsWith('\n'), true);
assert.deepEqual(JSON.parse(raw), { a: 1 });
});
test('validateHookFields drops malformed command hook (missing command)', () => {
const s = {
hooks: {
SessionStart: [{ hooks: [{ type: 'command' }, { type: 'command', command: 'good' }] }],
},
};
SETTINGS.validateHookFields(s);
assert.equal(s.hooks.SessionStart[0].hooks.length, 1);
assert.equal(s.hooks.SessionStart[0].hooks[0].command, 'good');
});
test('validateHookFields drops malformed agent hook (missing prompt)', () => {
const s = {
hooks: {
SessionStart: [{ hooks: [{ type: 'agent' }] }],
},
};
SETTINGS.validateHookFields(s);
assert.equal(s.hooks, undefined);
});
test('validateHookFields drops empty events and empty hooks parent', () => {
const s = { hooks: { SessionStart: [], UserPromptSubmit: [{ hooks: [] }] } };
SETTINGS.validateHookFields(s);
assert.equal(s.hooks, undefined);
});
test('addCommandHook is idempotent on substring marker', () => {
const s = {};
const a = SETTINGS.addCommandHook(s, 'SessionStart', { command: '/abs/path/caveman-activate.js', marker: 'caveman-activate' });
const b = SETTINGS.addCommandHook(s, 'SessionStart', { command: '/different/abs/path/caveman-activate.js', marker: 'caveman-activate' });
assert.equal(a, true);
assert.equal(b, false);
assert.equal(s.hooks.SessionStart.length, 1);
});
test('hasCavemanHook detects via substring', () => {
const s = { hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'node /x/caveman-activate.js' }] }] } };
assert.equal(SETTINGS.hasCavemanHook(s, 'SessionStart', 'caveman-activate'), true);
assert.equal(SETTINGS.hasCavemanHook(s, 'SessionStart', 'gsd'), false);
assert.equal(SETTINGS.hasCavemanHook(s, 'UserPromptSubmit'), false);
});
test('removeCavemanHooks tolerates malformed hook event values without throwing', () => {
// Pre-fix bug: settings.hooks.SessionStart = "oops" (string, not array)
// would crash on .filter(...) inside the filter loop. Fix delegates to
// validateHookFields first + adds Array.isArray guard.
const s = { hooks: { SessionStart: "oops", UserPromptSubmit: { not: 'an array either' } } };
let removed;
assert.doesNotThrow(() => { removed = SETTINGS.removeCavemanHooks(s); });
assert.equal(removed, 0);
assert.equal(s.hooks, undefined);
});
test('removeCavemanHooks strips managed scripts and cleans empties', () => {
const s = {
hooks: {
SessionStart: [
{ hooks: [{ type: 'command', command: 'node /x/hooks/caveman-activate.js' }] },
{ hooks: [{ type: 'command', command: 'other' }] },
],
UserPromptSubmit: [{ hooks: [{ type: 'command', command: '"/usr/bin/node" "/x/hooks/caveman-mode-tracker.js"' }] }],
},
};
const removed = SETTINGS.removeCavemanHooks(s);
assert.equal(removed, 2);
assert.equal(s.hooks.SessionStart.length, 1);
assert.equal(s.hooks.UserPromptSubmit, undefined);
});
test('removeCavemanHooks leaves user hooks that merely mention caveman (issue #593)', () => {
const s = {
hooks: {
PreToolUse: [
// Path contains the word "caveman" but targets a user-authored script.
{ hooks: [{ type: 'command', command: 'node /Users/me/Projects/caveman-notes/my-hook.js' }] },
// Basename is a superstring of a managed name — still not ours.
{ hooks: [{ type: 'command', command: 'node /x/mycaveman-activate.js' }] },
],
SessionStart: [
{ hooks: [{ type: 'command', command: '"/usr/bin/node" "/x/hooks/caveman-activate.js"' }] },
],
},
};
const removed = SETTINGS.removeCavemanHooks(s);
assert.equal(removed, 1, 'only the managed SessionStart hook should be removed');
assert.equal(s.hooks.PreToolUse.length, 2, 'user hooks mentioning caveman must survive uninstall');
assert.equal(s.hooks.SessionStart, undefined);
});
test('removeCavemanHooks removes the Windows statusline-stats wiring (caveman-stats.js / .ps1)', () => {
const s = {
hooks: {
Stop: [{ hooks: [{ type: 'command', command: '"C:\\Program Files\\nodejs\\node.exe" "C:\\Users\\me\\.claude\\hooks\\caveman-stats.js"' }] }],
},
};
const removed = SETTINGS.removeCavemanHooks(s);
assert.equal(removed, 1);
assert.equal(s.hooks, undefined);
});
test('rewriteLegacyManagedHookCommands tolerates malformed hook event values without throwing', () => {
// installHooks calls this BEFORE validateHookFields, so a hook event value
// that survives JSONC parse as an object/string (not an array) must not
// throw here — pre-fix, `for (const entry of settings.hooks[ev])` blew up
// with a TypeError on a non-iterable object, killing the installer mid-run.
// Mirror of the guard removeCavemanHooks already has.
const s = { hooks: { SessionStart: { not: 'an array' }, UserPromptSubmit: 'oops' } };
let n;
assert.doesNotThrow(() => { n = SETTINGS.rewriteLegacyManagedHookCommands(s, '/usr/local/bin/node'); });
assert.equal(n, 0);
});
test('rewriteLegacyManagedHookCommands rewrites bare-node managed scripts', () => {
const s = {
hooks: {
SessionStart: [{ hooks: [
{ type: 'command', command: 'node /abs/hooks/caveman-activate.js' },
{ type: 'command', command: 'node /abs/hooks/some-user-hook.js' },
] }],
},
};
const n = SETTINGS.rewriteLegacyManagedHookCommands(s, '/usr/local/bin/node');
assert.equal(n, 1);
assert.match(s.hooks.SessionStart[0].hooks[0].command, /"\/usr\/local\/bin\/node" "\/abs\/hooks\/caveman-activate\.js"/);
assert.equal(s.hooks.SessionStart[0].hooks[1].command, 'node /abs/hooks/some-user-hook.js');
});
test('rewriteLegacyManagedHookCommands ignores already-absolute node commands', () => {
const s = {
hooks: {
SessionStart: [{ hooks: [
{ type: 'command', command: '"/usr/local/bin/node" "/abs/hooks/caveman-activate.js"' },
] }],
},
};
const n = SETTINGS.rewriteLegacyManagedHookCommands(s, '/somewhere/else/node');
assert.equal(n, 0);
});
test('pruneOrphanedManagedHooks removes managed hook whose target is missing (absolute-node)', () => {
const s = {
hooks: {
SessionStart: [{ hooks: [
{ type: 'command', command: '"/opt/node/bin/node" "/no/such/dir/caveman-activate.js"' },
] }],
},
};
const removed = SETTINGS.pruneOrphanedManagedHooks(s, '/tmp/__cm_cfg_missing');
assert.equal(removed, 1);
assert.equal(s.hooks, undefined);
});
test('pruneOrphanedManagedHooks removes orphan bare-node managed hook', () => {
const s = {
hooks: {
UserPromptSubmit: [{ hooks: [
{ type: 'command', command: 'node /no/such/dir/caveman-mode-tracker.js' },
] }],
},
};
const removed = SETTINGS.pruneOrphanedManagedHooks(s, '/tmp/__cm_cfg_missing');
assert.equal(removed, 1);
assert.equal(s.hooks, undefined);
});
test('pruneOrphanedManagedHooks keeps managed hook whose target exists', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cm-prune-'));
const script = path.join(dir, 'caveman-activate.js');
fs.writeFileSync(script, '// real');
const s = {
hooks: {
SessionStart: [{ hooks: [
{ type: 'command', command: `"/opt/node/bin/node" "${script}"` },
] }],
},
};
const removed = SETTINGS.pruneOrphanedManagedHooks(s, dir);
assert.equal(removed, 0);
assert.equal(s.hooks.SessionStart.length, 1);
});
test('pruneOrphanedManagedHooks leaves non-managed hooks alone even if missing', () => {
const s = {
hooks: {
SessionStart: [{ hooks: [
{ type: 'command', command: 'node /no/such/dir/some-user-hook.js' },
{ type: 'command', command: '[ -n "$SUPERSET_HOME_DIR" ] && "$SUPERSET_HOME_DIR/hooks/notify.sh" || true' },
] }],
},
};
const removed = SETTINGS.pruneOrphanedManagedHooks(s, '/tmp/__cm_cfg_missing');
assert.equal(removed, 0);
assert.equal(s.hooks.SessionStart[0].hooks.length, 2);
});
test('pruneOrphanedManagedHooks resolves relative target against configDir', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cm-prune-rel-'));
// hooks/caveman-activate.js intentionally NOT created → missing
const s = {
hooks: {
SessionStart: [{ hooks: [
{ type: 'command', command: 'node hooks/caveman-activate.js' },
] }],
},
};
const removed = SETTINGS.pruneOrphanedManagedHooks(s, dir);
assert.equal(removed, 1);
assert.equal(s.hooks, undefined);
});
test('pruneOrphanedManagedHooks does NOT match a user script whose name merely contains a managed basename', () => {
const s = {
hooks: {
SessionStart: [{ hooks: [
// basename is "mycaveman-activate.js" — not an exact managed basename
{ type: 'command', command: 'node /no/such/dir/mycaveman-activate.js' },
] }],
},
};
const removed = SETTINGS.pruneOrphanedManagedHooks(s, '/tmp/__cm_cfg_missing');
assert.equal(removed, 0);
assert.equal(s.hooks.SessionStart[0].hooks.length, 1);
});
test('pruneOrphanedManagedHooks handles quoted paths containing spaces', () => {
const s = {
hooks: {
SessionStart: [{ hooks: [
{ type: 'command', command: '"/opt/node/bin/node" "/no such dir/caveman-activate.js"' },
] }],
},
};
const removed = SETTINGS.pruneOrphanedManagedHooks(s, '/tmp/__cm_cfg_missing');
assert.equal(removed, 1);
assert.equal(s.hooks, undefined);
});
test('pruneOrphanedManagedHooks drops orphaned managed statusLine', () => {
const s = {
statusLine: { type: 'command', command: 'bash /no/such/dir/caveman-statusline.sh' },
};
const removed = SETTINGS.pruneOrphanedManagedHooks(s, '/tmp/__cm_cfg_missing');
assert.equal(removed, 1);
assert.equal(s.statusLine, undefined);
});
test('claudeConfigDir honors CLAUDE_CONFIG_DIR env', () => {
const orig = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = '/tmp/__cm_test_cfg';
try { assert.equal(SETTINGS.claudeConfigDir(), '/tmp/__cm_test_cfg'); }
finally { if (orig === undefined) delete process.env.CLAUDE_CONFIG_DIR; else process.env.CLAUDE_CONFIG_DIR = orig; }
});