mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 06:28:59 +02:00
- settings-hook: identity removal pinned for each source in KNOWN_HOOKS; list-items unknown flag and combined --owned-by/--command-regex - gstack-memorable: enable/disable failure paths (lock give-up exit 5 with the test-only lock timeout override, consent-write failures guarded by canRevokeWrites, canonical-version mismatch, no-bun status) - hook: non-object JSON, missing cwd, non-ASCII bytes, held-open stdin, shim without bun, stripControl, resolveVendor, runExternal ENOENT - egress-receipt: lockBudgetMs 0 and writeOutcome on garbage input - uninstall: no memorable entry present reports nothing removed Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1459 lines
61 KiB
TypeScript
1459 lines
61 KiB
TypeScript
/**
|
|
* gstack-settings-hook schema-aware surface (T3 plan-tune cathedral).
|
|
*
|
|
* Verifies add-event / remove-source / diff-event / rollback / list-sources
|
|
* for PreToolUse + PostToolUse registration. Existing team-mode.test.ts
|
|
* covers the legacy `add <cmd>` / `remove <cmd>` shape; this file only
|
|
* covers the new surface introduced for the plan-tune cathedral.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import { execSync } from 'child_process';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const SETTINGS_HOOK = path.join(ROOT, 'bin', 'gstack-settings-hook');
|
|
|
|
let tmpDir: string;
|
|
let settingsFile: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-shsa-'));
|
|
settingsFile = path.join(tmpDir, 'settings.json');
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function run(args: string[]): { stdout: string; stderr: string; exitCode: number } {
|
|
try {
|
|
const stdout = execSync([SETTINGS_HOOK, ...args].map((s) => `'${s}'`).join(' '), {
|
|
env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile },
|
|
encoding: 'utf-8',
|
|
timeout: 10000,
|
|
});
|
|
return { stdout, stderr: '', exitCode: 0 };
|
|
} catch (e: any) {
|
|
return { stdout: e.stdout || '', stderr: e.stderr || '', exitCode: e.status ?? 1 };
|
|
}
|
|
}
|
|
|
|
function settings(): any {
|
|
return JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
|
|
}
|
|
|
|
// ----------------------------------------------------------------------
|
|
// add-event
|
|
// ----------------------------------------------------------------------
|
|
|
|
describe('add-event', () => {
|
|
test('registers a PreToolUse hook with matcher + source tag', () => {
|
|
const r = run([
|
|
'add-event',
|
|
'--event', 'PreToolUse',
|
|
'--matcher', '(AskUserQuestion|mcp__.*__AskUserQuestion)',
|
|
'--command', '/abs/path/to/question-preference-hook',
|
|
'--source', 'plan-tune-cathedral',
|
|
'--timeout', '5',
|
|
]);
|
|
expect(r.exitCode).toBe(0);
|
|
const s = settings();
|
|
expect(s.hooks.PreToolUse).toHaveLength(1);
|
|
expect(s.hooks.PreToolUse[0].matcher).toBe('(AskUserQuestion|mcp__.*__AskUserQuestion)');
|
|
expect(s.hooks.PreToolUse[0]._gstack_source).toBe('plan-tune-cathedral');
|
|
expect(s.hooks.PreToolUse[0].hooks[0].command).toBe('/abs/path/to/question-preference-hook');
|
|
expect(s.hooks.PreToolUse[0].hooks[0].timeout).toBe(5);
|
|
});
|
|
|
|
test('registers a PostToolUse hook independently of PreToolUse', () => {
|
|
run([
|
|
'add-event',
|
|
'--event', 'PreToolUse',
|
|
'--matcher', 'AskUserQuestion',
|
|
'--command', '/pre',
|
|
'--source', 'plan-tune-cathedral',
|
|
]);
|
|
const r = run([
|
|
'add-event',
|
|
'--event', 'PostToolUse',
|
|
'--matcher', 'AskUserQuestion',
|
|
'--command', '/post',
|
|
'--source', 'plan-tune-cathedral',
|
|
]);
|
|
expect(r.exitCode).toBe(0);
|
|
const s = settings();
|
|
expect(s.hooks.PreToolUse).toHaveLength(1);
|
|
expect(s.hooks.PostToolUse).toHaveLength(1);
|
|
expect(s.hooks.PreToolUse[0].hooks[0].command).toBe('/pre');
|
|
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe('/post');
|
|
});
|
|
|
|
test('idempotent: re-adding same (event, matcher, source) updates in place', () => {
|
|
run([
|
|
'add-event',
|
|
'--event', 'PreToolUse',
|
|
'--matcher', 'AskUserQuestion',
|
|
'--command', '/v1',
|
|
'--source', 'plan-tune-cathedral',
|
|
]);
|
|
run([
|
|
'add-event',
|
|
'--event', 'PreToolUse',
|
|
'--matcher', 'AskUserQuestion',
|
|
'--command', '/v2',
|
|
'--source', 'plan-tune-cathedral',
|
|
]);
|
|
const s = settings();
|
|
expect(s.hooks.PreToolUse).toHaveLength(1);
|
|
expect(s.hooks.PreToolUse[0].hooks[0].command).toBe('/v2');
|
|
});
|
|
|
|
test('dedup includes command: same (event, matcher, command) with different source updates in place', () => {
|
|
run([
|
|
'add-event',
|
|
'--event', 'PostToolUse',
|
|
'--matcher', '(AskUserQuestion|mcp__.*__AskUserQuestion)',
|
|
'--command', '/abs/path/to/question-log-hook',
|
|
'--source', 'source-A',
|
|
'--timeout', '5',
|
|
]);
|
|
run([
|
|
'add-event',
|
|
'--event', 'PostToolUse',
|
|
'--matcher', '(AskUserQuestion|mcp__.*__AskUserQuestion)',
|
|
'--command', '/abs/path/to/question-log-hook',
|
|
'--source', 'source-B',
|
|
'--timeout', '5',
|
|
]);
|
|
const s = settings();
|
|
expect(s.hooks.PostToolUse).toHaveLength(1);
|
|
expect(s.hooks.PostToolUse[0]._gstack_source).toBe('source-B');
|
|
});
|
|
|
|
test('dedup includes command: untagged entry with same command is updated not duplicated', () => {
|
|
fs.writeFileSync(
|
|
settingsFile,
|
|
JSON.stringify({
|
|
hooks: {
|
|
PostToolUse: [
|
|
{
|
|
matcher: '(AskUserQuestion|mcp__.*__AskUserQuestion)',
|
|
hooks: [{ type: 'command', command: '/abs/path/to/question-log-hook', timeout: 5 }],
|
|
},
|
|
],
|
|
},
|
|
}, null, 2),
|
|
);
|
|
run([
|
|
'add-event',
|
|
'--event', 'PostToolUse',
|
|
'--matcher', '(AskUserQuestion|mcp__.*__AskUserQuestion)',
|
|
'--command', '/abs/path/to/question-log-hook',
|
|
'--source', 'plan-tune-cathedral',
|
|
'--timeout', '5',
|
|
]);
|
|
const s = settings();
|
|
expect(s.hooks.PostToolUse).toHaveLength(1);
|
|
expect(s.hooks.PostToolUse[0]._gstack_source).toBe('plan-tune-cathedral');
|
|
});
|
|
|
|
test('preserves unrelated existing hooks', () => {
|
|
fs.writeFileSync(
|
|
settingsFile,
|
|
JSON.stringify({
|
|
hooks: {
|
|
PreToolUse: [
|
|
{
|
|
matcher: 'Bash',
|
|
hooks: [{ type: 'command', command: '/user-own-hook' }],
|
|
},
|
|
],
|
|
},
|
|
}, null, 2),
|
|
);
|
|
run([
|
|
'add-event',
|
|
'--event', 'PreToolUse',
|
|
'--matcher', 'AskUserQuestion',
|
|
'--command', '/gstack-hook',
|
|
'--source', 'plan-tune-cathedral',
|
|
]);
|
|
const s = settings();
|
|
expect(s.hooks.PreToolUse).toHaveLength(2);
|
|
// User's Bash hook still present
|
|
const bash = s.hooks.PreToolUse.find((e: any) => e.matcher === 'Bash');
|
|
expect(bash).toBeDefined();
|
|
expect(bash.hooks[0].command).toBe('/user-own-hook');
|
|
});
|
|
|
|
test('writes a timestamped backup before mutating', () => {
|
|
fs.writeFileSync(settingsFile, JSON.stringify({ existing: 'value' }));
|
|
run([
|
|
'add-event',
|
|
'--event', 'PreToolUse',
|
|
'--matcher', 'AskUserQuestion',
|
|
'--command', '/gstack',
|
|
'--source', 'plan-tune-cathedral',
|
|
]);
|
|
const backups = fs
|
|
.readdirSync(tmpDir)
|
|
.filter((f) => f.startsWith('settings.json.bak.'));
|
|
expect(backups.length).toBeGreaterThanOrEqual(1);
|
|
const backupContent = JSON.parse(fs.readFileSync(path.join(tmpDir, backups[0]), 'utf-8'));
|
|
expect(backupContent.existing).toBe('value');
|
|
expect(backupContent.hooks).toBeUndefined();
|
|
});
|
|
|
|
test('rejects invalid --event', () => {
|
|
const r = run([
|
|
'add-event',
|
|
'--event', 'NotAnEvent',
|
|
'--command', '/x',
|
|
'--source', 'plan-tune',
|
|
]);
|
|
expect(r.exitCode).not.toBe(0);
|
|
expect(r.stderr).toMatch(/invalid --event/);
|
|
});
|
|
});
|
|
|
|
// ----------------------------------------------------------------------
|
|
// ensure-event: duplicate (event, source) collapse
|
|
// ----------------------------------------------------------------------
|
|
|
|
describe('ensure-event collapses duplicate (event, source) entries', () => {
|
|
test('two same-source entries from the old matcher-keyed dedup collapse to ONE updated entry', () => {
|
|
// Pre-existing installs can carry two entries with the same
|
|
// (event, _gstack_source) — the old dedup keyed on the matcher too, so a
|
|
// matcher change pushed a second registration. `.find()` updated only the
|
|
// first and left the stale twin running forever.
|
|
const { spawnSync } = require('child_process');
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: {
|
|
PostToolUse: [
|
|
{ _gstack_source: 'plan-tune-cathedral', matcher: 'OldMatcherA', hooks: [{ type: 'command', command: '/old-a', timeout: 5 }] },
|
|
{ matcher: 'Bash', hooks: [{ type: 'command', command: '/user-own-hook' }] },
|
|
{ _gstack_source: 'plan-tune-cathedral', matcher: 'OldMatcherB', hooks: [{ type: 'command', command: '/old-b', timeout: 5 }] },
|
|
],
|
|
},
|
|
}, null, 2));
|
|
|
|
const r = spawnSync('bash', [
|
|
SETTINGS_HOOK, 'ensure-event',
|
|
'--event', 'PostToolUse',
|
|
'--matcher', 'NewMatcher',
|
|
'--command', '/canonical',
|
|
'--source', 'plan-tune-cathedral',
|
|
'--timeout', '5',
|
|
], { env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, encoding: 'utf-8', timeout: 15_000 });
|
|
|
|
expect(r.status).toBe(0);
|
|
// The collapse is reported on stderr, never silent.
|
|
expect(r.stderr).toContain('collapsed 1 duplicate');
|
|
const s = settings();
|
|
const mine = s.hooks.PostToolUse.filter((e: any) => e._gstack_source === 'plan-tune-cathedral');
|
|
expect(mine).toHaveLength(1); // ONE canonical entry — the stale twin is gone
|
|
expect(mine[0].matcher).toBe('NewMatcher');
|
|
expect(mine[0].hooks[0].command).toBe('/canonical');
|
|
// Unrelated user hook untouched.
|
|
const bash = s.hooks.PostToolUse.find((e: any) => e.matcher === 'Bash');
|
|
expect(bash.hooks[0].command).toBe('/user-own-hook');
|
|
expect(s.hooks.PostToolUse).toHaveLength(2);
|
|
});
|
|
|
|
test('no duplicates → no collapse message, single entry updated as before', () => {
|
|
const { spawnSync } = require('child_process');
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: {
|
|
PostToolUse: [
|
|
{ _gstack_source: 'plan-tune-cathedral', matcher: 'OldMatcher', hooks: [{ type: 'command', command: '/old', timeout: 5 }] },
|
|
],
|
|
},
|
|
}, null, 2));
|
|
|
|
const r = spawnSync('bash', [
|
|
SETTINGS_HOOK, 'ensure-event',
|
|
'--event', 'PostToolUse',
|
|
'--matcher', 'NewMatcher',
|
|
'--command', '/new',
|
|
'--source', 'plan-tune-cathedral',
|
|
'--timeout', '5',
|
|
], { env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, encoding: 'utf-8', timeout: 15_000 });
|
|
|
|
expect(r.status).toBe(0);
|
|
expect(r.stderr).not.toContain('collapsed');
|
|
const s = settings();
|
|
expect(s.hooks.PostToolUse).toHaveLength(1);
|
|
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe('/new');
|
|
});
|
|
});
|
|
|
|
// ----------------------------------------------------------------------
|
|
// remove-source
|
|
// ----------------------------------------------------------------------
|
|
|
|
describe('remove-source', () => {
|
|
test('removes all entries with a given source tag, leaves others alone', () => {
|
|
fs.writeFileSync(
|
|
settingsFile,
|
|
JSON.stringify({
|
|
hooks: {
|
|
PreToolUse: [
|
|
{ matcher: 'Bash', hooks: [{ command: '/keep-me' }] },
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
run([
|
|
'add-event',
|
|
'--event', 'PreToolUse',
|
|
'--matcher', 'AskUserQuestion',
|
|
'--command', '/a',
|
|
'--source', 'plan-tune-cathedral',
|
|
]);
|
|
run([
|
|
'add-event',
|
|
'--event', 'PostToolUse',
|
|
'--matcher', 'AskUserQuestion',
|
|
'--command', '/b',
|
|
'--source', 'plan-tune-cathedral',
|
|
]);
|
|
const r = run(['remove-source', '--source', 'plan-tune-cathedral']);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toMatch(/removed 2 hook/);
|
|
const s = settings();
|
|
expect(s.hooks.PostToolUse).toBeUndefined();
|
|
expect(s.hooks.PreToolUse).toHaveLength(1);
|
|
expect(s.hooks.PreToolUse[0].hooks[0].command).toBe('/keep-me');
|
|
});
|
|
|
|
test('safely no-ops when settings.json missing', () => {
|
|
const r = run(['remove-source', '--source', 'plan-tune-cathedral']);
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ----------------------------------------------------------------------
|
|
// diff-event
|
|
// ----------------------------------------------------------------------
|
|
|
|
describe('diff-event', () => {
|
|
test('emits BEFORE + AFTER without mutating settings.json', () => {
|
|
fs.writeFileSync(settingsFile, JSON.stringify({ existing: 'value' }));
|
|
const r = run([
|
|
'diff-event',
|
|
'--event', 'PreToolUse',
|
|
'--matcher', 'AskUserQuestion',
|
|
'--command', '/gstack',
|
|
'--source', 'plan-tune-cathedral',
|
|
]);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toContain('--- BEFORE');
|
|
expect(r.stdout).toContain('--- AFTER');
|
|
expect(r.stdout).toContain('plan-tune-cathedral');
|
|
// Settings file unchanged.
|
|
expect(JSON.parse(fs.readFileSync(settingsFile, 'utf-8'))).toEqual({ existing: 'value' });
|
|
});
|
|
});
|
|
|
|
// ----------------------------------------------------------------------
|
|
// rollback
|
|
// ----------------------------------------------------------------------
|
|
|
|
describe('rollback', () => {
|
|
test('restores latest backup', () => {
|
|
fs.writeFileSync(settingsFile, JSON.stringify({ original: true }));
|
|
run([
|
|
'add-event',
|
|
'--event', 'PreToolUse',
|
|
'--matcher', 'AskUserQuestion',
|
|
'--command', '/gstack',
|
|
'--source', 'plan-tune-cathedral',
|
|
]);
|
|
expect(settings().hooks).toBeDefined();
|
|
const r = run(['rollback']);
|
|
expect(r.exitCode).toBe(0);
|
|
const s = settings();
|
|
expect(s.original).toBe(true);
|
|
expect(s.hooks).toBeUndefined();
|
|
});
|
|
|
|
test('fails clearly when no backup pointer exists', () => {
|
|
const r = run(['rollback']);
|
|
expect(r.exitCode).not.toBe(0);
|
|
expect(r.stderr).toMatch(/no backup pointer/);
|
|
});
|
|
});
|
|
|
|
// ----------------------------------------------------------------------
|
|
// list-sources
|
|
// ----------------------------------------------------------------------
|
|
|
|
describe('list-sources', () => {
|
|
test('shows source-tagged hooks across all events', () => {
|
|
run([
|
|
'add-event',
|
|
'--event', 'PreToolUse',
|
|
'--matcher', 'AskUserQuestion',
|
|
'--command', '/pre',
|
|
'--source', 'plan-tune-cathedral',
|
|
]);
|
|
run([
|
|
'add-event',
|
|
'--event', 'PostToolUse',
|
|
'--matcher', 'AskUserQuestion',
|
|
'--command', '/post',
|
|
'--source', 'plan-tune-cathedral',
|
|
]);
|
|
const r = run(['list-sources']);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toContain('PreToolUse');
|
|
expect(r.stdout).toContain('PostToolUse');
|
|
expect(r.stdout).toContain('plan-tune-cathedral');
|
|
});
|
|
|
|
test('empty when no settings file', () => {
|
|
const r = run(['list-sources']);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toMatch(/no settings file/);
|
|
});
|
|
});
|
|
|
|
// ----------------------------------------------------------------------
|
|
// Phantom-hooks heal surface (v1.67.2): KNOWN_HOOKS identity table,
|
|
// per-item mutation, prune-stale, mutation lock, fail-closed parse.
|
|
//
|
|
// Ownership is intrinsic (basename + relpath suffix + event/matcher against
|
|
// the fixed table) because Claude Code strips the _gstack_source key when it
|
|
// rewrites settings.json — tag-only dedupe is what let every Conductor
|
|
// worktree append a fresh dead entry.
|
|
// ----------------------------------------------------------------------
|
|
|
|
const AUQ_MATCHER = '(AskUserQuestion|mcp__.*__AskUserQuestion)';
|
|
const HOOK_NAMES = [
|
|
'question-log-hook',
|
|
'question-preference-hook',
|
|
'auq-error-fallback-hook',
|
|
'timeline-stop-hook',
|
|
];
|
|
|
|
/** run() with hermetic gstack-config state (prune-stale consults plan_tune_hooks). */
|
|
function runIso(args: string[], extraEnv: Record<string, string> = {}) {
|
|
try {
|
|
const stdout = execSync([SETTINGS_HOOK, ...args].map((s) => `'${s}'`).join(' '), {
|
|
env: {
|
|
...process.env,
|
|
GSTACK_SETTINGS_FILE: settingsFile,
|
|
GSTACK_STATE_ROOT: tmpDir,
|
|
...extraEnv,
|
|
},
|
|
encoding: 'utf-8',
|
|
timeout: 15000,
|
|
});
|
|
return { stdout, stderr: '', exitCode: 0 };
|
|
} catch (e: any) {
|
|
return { stdout: e.stdout || '', stderr: e.stderr || '', exitCode: e.status ?? 1 };
|
|
}
|
|
}
|
|
|
|
/** A fake stable install with executable hooks, under `base`. */
|
|
function mkCanon(base: string, name = 'canon'): string {
|
|
const canon = path.join(base, name);
|
|
fs.mkdirSync(path.join(canon, 'hosts', 'claude', 'hooks'), { recursive: true });
|
|
fs.mkdirSync(path.join(canon, 'bin'), { recursive: true });
|
|
for (const h of HOOK_NAMES) {
|
|
const p = path.join(canon, 'hosts', 'claude', 'hooks', h);
|
|
fs.writeFileSync(p, '#!/bin/sh\n');
|
|
fs.chmodSync(p, 0o755);
|
|
}
|
|
const su = path.join(canon, 'bin', 'gstack-session-update');
|
|
fs.writeFileSync(su, '#!/bin/sh\n');
|
|
fs.chmodSync(su, 0o755);
|
|
return canon;
|
|
}
|
|
|
|
function hookEntry(cmd: string, matcher?: string, src?: string, extraItems: any[] = []) {
|
|
const e: any = { hooks: [...extraItems, { type: 'command', command: cmd, timeout: 5 }] };
|
|
if (matcher) e.matcher = matcher;
|
|
if (src) e._gstack_source = src;
|
|
return e;
|
|
}
|
|
|
|
function backups(): string[] {
|
|
return fs.readdirSync(tmpDir).filter((f) => f.startsWith('settings.json.bak.'));
|
|
}
|
|
|
|
describe('add-event: per-item identity re-point', () => {
|
|
test('tag-stripped stale worktree path is re-pointed in place, tag restored', () => {
|
|
const canon = mkCanon(tmpDir);
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: { PostToolUse: [hookEntry('/dead/wt/hosts/claude/hooks/question-log-hook', AUQ_MATCHER)] },
|
|
}, null, 2));
|
|
runIso([
|
|
'add-event', '--event', 'PostToolUse', '--matcher', AUQ_MATCHER,
|
|
'--command', `${canon}/hosts/claude/hooks/question-log-hook`,
|
|
'--source', 'plan-tune-cathedral', '--timeout', '5',
|
|
]);
|
|
const s = settings();
|
|
expect(s.hooks.PostToolUse).toHaveLength(1);
|
|
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe(`${canon}/hosts/claude/hooks/question-log-hook`);
|
|
expect(s.hooks.PostToolUse[0]._gstack_source).toBe('plan-tune-cathedral');
|
|
});
|
|
|
|
test('foreign path with a gstack basename is NOT claimed (wrong relpath suffix)', () => {
|
|
const canon = mkCanon(tmpDir);
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: { PostToolUse: [hookEntry('/home/u/myhooks/question-log-hook', AUQ_MATCHER)] },
|
|
}, null, 2));
|
|
runIso([
|
|
'add-event', '--event', 'PostToolUse', '--matcher', AUQ_MATCHER,
|
|
'--command', `${canon}/hosts/claude/hooks/question-log-hook`,
|
|
'--source', 'plan-tune-cathedral',
|
|
]);
|
|
const s = settings();
|
|
expect(s.hooks.PostToolUse).toHaveLength(2);
|
|
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe('/home/u/myhooks/question-log-hook');
|
|
});
|
|
|
|
test('mixed entry: only the gstack item (index > 0) is replaced; the user item survives', () => {
|
|
const canon = mkCanon(tmpDir);
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: {
|
|
PostToolUse: [hookEntry(
|
|
'/dead/wt/hosts/claude/hooks/question-log-hook', AUQ_MATCHER, undefined,
|
|
[{ type: 'command', command: '/Users/me/my-own-hook' }],
|
|
)],
|
|
},
|
|
}, null, 2));
|
|
runIso([
|
|
'add-event', '--event', 'PostToolUse', '--matcher', AUQ_MATCHER,
|
|
'--command', `${canon}/hosts/claude/hooks/question-log-hook`,
|
|
'--source', 'plan-tune-cathedral',
|
|
]);
|
|
const s = settings();
|
|
expect(s.hooks.PostToolUse).toHaveLength(1);
|
|
const items = s.hooks.PostToolUse[0].hooks;
|
|
expect(items).toHaveLength(2);
|
|
expect(items[0].command).toBe('/Users/me/my-own-hook');
|
|
expect(items[1].command).toBe(`${canon}/hosts/claude/hooks/question-log-hook`);
|
|
});
|
|
});
|
|
|
|
describe('legacy remove: per-item (regression)', () => {
|
|
test('mixed SessionStart entry: user item survives in place, gstack item removed', () => {
|
|
// REGRESSION pin: the pre-v1.67.2 legacy `remove` dropped the ENTIRE
|
|
// entry when any item matched gstack-session-update, destroying a user's
|
|
// co-located hook. The rewrite filters per-item; this is the only test of
|
|
// that branch (team-mode.test.ts covers single-item entries only).
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: {
|
|
SessionStart: [{
|
|
hooks: [
|
|
{ type: 'command', command: '/Users/me/my-own-session-hook' },
|
|
{ type: 'command', command: '/old/install/bin/gstack-session-update' },
|
|
],
|
|
}],
|
|
},
|
|
}, null, 2));
|
|
const r = runIso(['remove', '/old/install/bin/gstack-session-update']);
|
|
expect(r.exitCode).toBe(0);
|
|
const s = settings();
|
|
expect(s.hooks.SessionStart).toHaveLength(1);
|
|
expect(s.hooks.SessionStart[0].hooks).toHaveLength(1);
|
|
expect(s.hooks.SessionStart[0].hooks[0].command).toBe('/Users/me/my-own-session-hook');
|
|
});
|
|
});
|
|
|
|
describe('review-army hardening (specialist findings)', () => {
|
|
test('legacy remove preserves malformed/foreign entries it never touched', () => {
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: {
|
|
SessionStart: [
|
|
{ comment: 'no hooks array at all' },
|
|
{ hooks: 'not-an-array' },
|
|
{ hooks: [] },
|
|
{ hooks: [{ type: 'command', command: '/x/bin/gstack-session-update' }] },
|
|
],
|
|
},
|
|
}, null, 2));
|
|
runIso(['remove', '/x/bin/gstack-session-update']);
|
|
const s = settings();
|
|
// Only the entry we emptied is gone; the three malformed/foreign ones stay.
|
|
expect(s.hooks.SessionStart).toHaveLength(3);
|
|
});
|
|
|
|
test('add-event never tags a mixed entry (old-version ratchet guard)', () => {
|
|
const canon = mkCanon(tmpDir);
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: {
|
|
PostToolUse: [{
|
|
matcher: AUQ_MATCHER,
|
|
_gstack_source: 'plan-tune-cathedral',
|
|
hooks: [
|
|
{ type: 'command', command: '/Users/me/my-own-hook' },
|
|
{ type: 'command', command: '/dead/wt/hosts/claude/hooks/question-log-hook' },
|
|
],
|
|
}],
|
|
},
|
|
}, null, 2));
|
|
runIso([
|
|
'add-event', '--event', 'PostToolUse', '--matcher', AUQ_MATCHER,
|
|
'--command', `${canon}/hosts/claude/hooks/question-log-hook`,
|
|
'--source', 'plan-tune-cathedral',
|
|
]);
|
|
const s = settings();
|
|
expect(s.hooks.PostToolUse).toHaveLength(1);
|
|
const e = s.hooks.PostToolUse[0];
|
|
expect(e.hooks).toHaveLength(2);
|
|
expect(e.hooks[0].command).toBe('/Users/me/my-own-hook');
|
|
// A tag on a mixed entry hands old-version remove-source permission to
|
|
// destroy the user's item — it must be gone.
|
|
expect(e._gstack_source).toBeUndefined();
|
|
});
|
|
|
|
test('two dead twins of one hook in ONE entry collapse to a single item after --repoint', () => {
|
|
const canon = mkCanon(tmpDir);
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: {
|
|
PostToolUse: [{
|
|
matcher: AUQ_MATCHER,
|
|
hooks: [
|
|
{ type: 'command', command: '/dead/a/hosts/claude/hooks/question-log-hook' },
|
|
{ type: 'command', command: '/dead/b/hosts/claude/hooks/question-log-hook' },
|
|
],
|
|
}],
|
|
},
|
|
}, null, 2));
|
|
runIso(['prune-stale', '--repoint', canon]);
|
|
const items = settings().hooks.PostToolUse[0].hooks;
|
|
expect(items).toHaveLength(1); // pre-fix: two identical items → hook fires twice per event
|
|
expect(items[0].command).toBe(`${canon}/hosts/claude/hooks/question-log-hook`);
|
|
});
|
|
|
|
test('a 0600 settings.json keeps its mode across mutations (API keys stay private)', () => {
|
|
fs.writeFileSync(settingsFile, JSON.stringify({ env: { SECRET: 'x' } }, null, 2));
|
|
fs.chmodSync(settingsFile, 0o600);
|
|
runIso(['add-event', '--event', 'Stop', '--command', '/x/hosts/claude/hooks/timeline-stop-hook', '--source', 'gstack-timeline-stop']);
|
|
const mode = fs.statSync(settingsFile).mode & 0o777;
|
|
expect(mode).toBe(0o600);
|
|
});
|
|
|
|
test('a canonical root containing $ is escaped in the registered command', () => {
|
|
const trickyBase = path.join(tmpDir, 'weird$dir');
|
|
fs.mkdirSync(trickyBase, { recursive: true });
|
|
const canon = mkCanon(trickyBase);
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: { Stop: [hookEntry('/dead/wt/hosts/claude/hooks/timeline-stop-hook')] },
|
|
}, null, 2));
|
|
runIso(['prune-stale', '--repoint', canon]);
|
|
const cmd = settings().hooks.Stop[0].hooks[0].command;
|
|
expect(cmd.startsWith('"')).toBe(true);
|
|
expect(cmd).toContain('\\$'); // $ neutralized — shell must not expand it at hook-fire time
|
|
// Idempotent: the escaped command is still recognized as ours.
|
|
const before = fs.readFileSync(settingsFile, 'utf-8');
|
|
const r2 = runIso(['prune-stale', '--repoint', canon]);
|
|
expect(r2.stdout).toMatch(/removed 0 gstack hook entries \(repointed 0\)/);
|
|
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
|
|
});
|
|
|
|
test('backups rotate: at most 10 .bak files survive repeated mutations', () => {
|
|
for (let i = 0; i < 13; i++) {
|
|
runIso(['add-event', '--event', 'Stop', '--command', `/x/hosts/claude/hooks/timeline-stop-hook-${i}`, '--source', 'gstack-timeline-stop']);
|
|
}
|
|
expect(backups().length).toBeLessThanOrEqual(10);
|
|
// The rollback pointer still resolves to an existing backup.
|
|
const latest = fs.readFileSync(path.join(tmpDir, 'settings.json.bak-latest'), 'utf-8').trim();
|
|
expect(fs.existsSync(latest)).toBe(true);
|
|
});
|
|
|
|
test('tag-stripped verify-gate entry is table-owned: healed by --repoint, swept by --all', () => {
|
|
// Red-team catch: verify-gate is a README-documented opt-in Stop hook.
|
|
// Without a KNOWN_HOOKS row, a tag-stripped entry survived uninstall and
|
|
// errored at the end of EVERY turn after the install root was deleted.
|
|
const canon = mkCanon(tmpDir);
|
|
const vg = path.join(canon, 'bin', 'gstack-verify-gate');
|
|
fs.writeFileSync(vg, '#!/bin/sh\n');
|
|
fs.chmodSync(vg, 0o755);
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: { Stop: [hookEntry('/dead/install/bin/gstack-verify-gate')] }, // tag STRIPPED
|
|
}, null, 2));
|
|
runIso(['prune-stale', '--repoint', canon]);
|
|
let s = settings();
|
|
expect(s.hooks.Stop[0].hooks[0].command).toBe(vg);
|
|
expect(s.hooks.Stop[0]._gstack_source).toBe('verify-gate');
|
|
const r = runIso(['prune-stale', '--all']);
|
|
expect(r.stdout).toMatch(/removed 1/);
|
|
expect(settings().hooks).toBeUndefined();
|
|
});
|
|
|
|
test('a foreign entry that STARTED empty survives prune-stale untouched', () => {
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [] }] },
|
|
}, null, 2) + '\n');
|
|
const before = fs.readFileSync(settingsFile, 'utf-8');
|
|
const r = runIso(['prune-stale', '--repoint', mkCanon(tmpDir, 'c2')]);
|
|
expect(r.stdout).toMatch(/removed 0 gstack hook entries \(repointed 0\)/);
|
|
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
|
|
});
|
|
|
|
test('add-event is the quoting authority: spaced canonical path stored escaped-quoted, healer idempotent', () => {
|
|
// Red-team catch (empirically verified pre-fix): setup registered raw
|
|
// paths and the very next heal rewrote them — fresh installs shipped a
|
|
// form the codebase itself considered wrong.
|
|
const spacedBase = path.join(tmpDir, 'canon root');
|
|
fs.mkdirSync(spacedBase, { recursive: true });
|
|
const canon = mkCanon(spacedBase);
|
|
runIso([
|
|
'add-event', '--event', 'PostToolUse', '--matcher', AUQ_MATCHER,
|
|
'--command', `${canon}/hosts/claude/hooks/question-log-hook`,
|
|
'--source', 'plan-tune-cathedral', '--timeout', '5',
|
|
]);
|
|
const stored = settings().hooks.PostToolUse[0].hooks[0].command;
|
|
expect(stored).toBe(`"${canon}/hosts/claude/hooks/question-log-hook"`);
|
|
const before = fs.readFileSync(settingsFile, 'utf-8');
|
|
const r = runIso(['prune-stale', '--repoint', canon]);
|
|
expect(r.stdout).toMatch(/removed 0 gstack hook entries \(repointed 0\)/);
|
|
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
|
|
});
|
|
|
|
test('rollback refuses a pointer that names a non-backup file', () => {
|
|
fs.writeFileSync(settingsFile, JSON.stringify({ a: 1 }, null, 2));
|
|
const evil = path.join(tmpDir, 'evil.json');
|
|
fs.writeFileSync(evil, JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: '/evil' }] }] } }));
|
|
fs.writeFileSync(path.join(tmpDir, 'settings.json.bak-latest'), evil + '\n');
|
|
const r = runIso(['rollback']);
|
|
expect(r.exitCode).not.toBe(0);
|
|
expect(r.stderr).toMatch(/refusing/);
|
|
expect(settings().a).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('ownership negatives', () => {
|
|
test('owned basename+relpath under the WRONG matcher stays foreign (not re-pointed)', () => {
|
|
const canon = mkCanon(tmpDir);
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: {
|
|
PostToolUse: [hookEntry('/dead/wt/hosts/claude/hooks/question-log-hook', 'Bash')],
|
|
},
|
|
}, null, 2));
|
|
const before = fs.readFileSync(settingsFile, 'utf-8');
|
|
const r = runIso(['prune-stale', '--repoint', canon]);
|
|
expect(r.stdout).toMatch(/removed 0 gstack hook entries \(repointed 0\)/);
|
|
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
|
|
});
|
|
|
|
test('prune-stale on an absent settings file exits 0 with removed 0', () => {
|
|
const r = runIso(['prune-stale', '--repoint', '/nonexistent-root']);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toMatch(/removed 0 gstack hook entries \(repointed 0\)/);
|
|
expect(fs.existsSync(settingsFile)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('remove-source: per-item', () => {
|
|
test('mixed tagged entry: gstack item removed, user item survives, tag dropped', () => {
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: {
|
|
PostToolUse: [hookEntry(
|
|
'/x/hosts/claude/hooks/question-log-hook', AUQ_MATCHER, 'plan-tune-cathedral',
|
|
[{ type: 'command', command: '/Users/me/my-own-hook' }],
|
|
)],
|
|
},
|
|
}, null, 2));
|
|
const r = runIso(['remove-source', '--source', 'plan-tune-cathedral']);
|
|
expect(r.stdout).toMatch(/removed 1 hook/);
|
|
const s = settings();
|
|
expect(s.hooks.PostToolUse).toHaveLength(1);
|
|
expect(s.hooks.PostToolUse[0].hooks).toHaveLength(1);
|
|
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe('/Users/me/my-own-hook');
|
|
expect(s.hooks.PostToolUse[0]._gstack_source).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('Memorable UserPromptSubmit hook ownership', () => {
|
|
const source = 'gstack-memorable';
|
|
const stale = '/old/worktree/hosts/claude/hooks/memorable-user-prompt-hook';
|
|
const canonical = '/stable/gstack/hosts/claude/hooks/memorable-user-prompt-hook';
|
|
const foreign = '/Users/me/my-user-prompt-hook';
|
|
|
|
test('ensure-event is idempotent once the canonical wrapper is registered', () => {
|
|
const args = [
|
|
'ensure-event', '--event', 'UserPromptSubmit',
|
|
'--command', canonical, '--source', source,
|
|
];
|
|
const first = runIso(args);
|
|
expect(first.exitCode).toBe(0);
|
|
expect(first.stdout).toContain('hook registered');
|
|
const afterFirst = fs.readFileSync(settingsFile, 'utf-8');
|
|
const backupsAfterFirst = backups();
|
|
|
|
const second = runIso(args);
|
|
expect(second.exitCode).toBe(0);
|
|
expect(second.stdout).toContain('hook unchanged');
|
|
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(afterFirst);
|
|
expect(backups()).toEqual(backupsAfterFirst);
|
|
expect(settings().hooks.UserPromptSubmit).toHaveLength(1);
|
|
});
|
|
|
|
test('ensure-event re-points only the wrapper in a mixed entry and preserves the foreign hook', () => {
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: {
|
|
UserPromptSubmit: [{
|
|
hooks: [
|
|
{ type: 'command', command: foreign },
|
|
{ type: 'command', command: stale },
|
|
],
|
|
}],
|
|
},
|
|
}, null, 2));
|
|
|
|
const r = runIso([
|
|
'ensure-event', '--event', 'UserPromptSubmit',
|
|
'--command', canonical, '--source', source,
|
|
]);
|
|
expect(r.exitCode).toBe(0);
|
|
const entries = settings().hooks.UserPromptSubmit;
|
|
expect(entries).toHaveLength(1);
|
|
expect(entries[0].hooks).toEqual([
|
|
{ type: 'command', command: foreign },
|
|
{ type: 'command', command: canonical },
|
|
]);
|
|
expect(entries[0]._gstack_source).toBeUndefined();
|
|
});
|
|
|
|
test('remove-source removes only the Memorable wrapper from a tagged mixed entry', () => {
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: {
|
|
UserPromptSubmit: [{
|
|
_gstack_source: source,
|
|
hooks: [
|
|
{ type: 'command', command: foreign },
|
|
{ type: 'command', command: stale },
|
|
],
|
|
}],
|
|
},
|
|
}, null, 2));
|
|
|
|
const r = runIso(['remove-source', '--source', source]);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toMatch(/removed 1 hook/);
|
|
const entries = settings().hooks.UserPromptSubmit;
|
|
expect(entries).toHaveLength(1);
|
|
expect(entries[0].hooks).toEqual([{ type: 'command', command: foreign }]);
|
|
expect(entries[0]._gstack_source).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('remove-source: identity-aware (tag OR table)', () => {
|
|
// Claude Code strips _gstack_source when it rewrites settings.json. A
|
|
// tag-only remove-source therefore no-ops on exactly the entries it was
|
|
// written for (the PR #2831 disable bug). Identity via KNOWN_HOOKS now
|
|
// drives removal; the tag is metadata.
|
|
const memo = '/stable/gstack/hosts/claude/hooks/memorable-user-prompt-hook';
|
|
const foreign = '/Users/me/my-user-prompt-hook';
|
|
const vendor = '"/Users/me/.memorable/bin/memorable" hook user-prompt';
|
|
|
|
test('removes an UNTAGGED single-item memorable entry by identity', () => {
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: memo, timeout: 5 }] }] },
|
|
}, null, 2));
|
|
const r = run(['remove-source', '--source', 'gstack-memorable']);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toMatch(/removed 1 /);
|
|
expect(settings().hooks).toBeUndefined();
|
|
});
|
|
|
|
test('untagged mixed entry: only the memorable item goes, the foreign item stays, no tag is added', () => {
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: { UserPromptSubmit: [{ hooks: [
|
|
{ type: 'command', command: foreign },
|
|
{ type: 'command', command: memo },
|
|
] }] },
|
|
}, null, 2));
|
|
const r = run(['remove-source', '--source', 'gstack-memorable']);
|
|
expect(r.stdout).toMatch(/removed 1 /);
|
|
const entries = settings().hooks.UserPromptSubmit;
|
|
expect(entries).toHaveLength(1);
|
|
expect(entries[0].hooks).toEqual([{ type: 'command', command: foreign }]);
|
|
expect(entries[0]._gstack_source).toBeUndefined();
|
|
});
|
|
|
|
test('the bash-prefixed, quoted (Windows) form is recognised and removed', () => {
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: `bash "${memo}"` }] }] },
|
|
}, null, 2));
|
|
const r = run(['remove-source', '--source', 'gstack-memorable']);
|
|
expect(r.stdout).toMatch(/removed 1 /);
|
|
expect(settings().hooks).toBeUndefined();
|
|
});
|
|
|
|
test('CRITICAL regression: identity is per source -- another source\'s tag-stripped item is never touched', () => {
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: {
|
|
Stop: [{ hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/timeline-stop-hook' }] }],
|
|
PostToolUse: [{ matcher: AUQ_MATCHER, hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/question-log-hook' }] }],
|
|
},
|
|
}, null, 2));
|
|
const r = run(['remove-source', '--source', 'plan-tune-cathedral']);
|
|
expect(r.stdout).toMatch(/removed 1 /); // its own tag-stripped question-log item
|
|
const s = settings();
|
|
expect(s.hooks.Stop).toHaveLength(1); // timeline (gstack-timeline-stop) untouched
|
|
expect(s.hooks.PostToolUse).toBeUndefined();
|
|
});
|
|
|
|
test('a tagged entry of source A holding an item of source B keeps B\'s item and its tag (nothing of A inside)', () => {
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: { Stop: [{ _gstack_source: 'plan-tune-cathedral', hooks: [
|
|
{ type: 'command', command: '/x/hosts/claude/hooks/timeline-stop-hook' },
|
|
] }] },
|
|
}, null, 2));
|
|
const before = fs.readFileSync(settingsFile, 'utf-8');
|
|
const r = run(['remove-source', '--source', 'plan-tune-cathedral']);
|
|
expect(r.stdout).toMatch(/removed 0 /);
|
|
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
|
|
});
|
|
|
|
test('a foreign-only entry is untouched byte for byte and no backup is written', () => {
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: foreign }] }, { hooks: [{ type: 'command', command: vendor }] }] },
|
|
}, null, 2));
|
|
const before = fs.readFileSync(settingsFile, 'utf-8');
|
|
const r = run(['remove-source', '--source', 'gstack-memorable']);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toMatch(/removed 0 /);
|
|
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
|
|
expect(backups()).toEqual([]);
|
|
});
|
|
|
|
test('setup --no-team sweep: GSTACK_SWEEP_EXCLUDE_SOURCES keeps verify-gate AND gstack-memorable (tagged or tag-stripped), sweeps timeline', () => {
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: {
|
|
Stop: [
|
|
{ _gstack_source: 'verify-gate', hooks: [{ type: 'command', command: '/x/bin/gstack-verify-gate' }] },
|
|
{ _gstack_source: 'gstack-timeline-stop', hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/timeline-stop-hook' }] },
|
|
],
|
|
UserPromptSubmit: [
|
|
{ _gstack_source: 'gstack-memorable', hooks: [{ type: 'command', command: memo }] },
|
|
{ hooks: [{ type: 'command', command: `bash "${memo}"` }] }, // tag stripped by Claude Code
|
|
],
|
|
},
|
|
}, null, 2));
|
|
const r = runIso(['prune-stale', '--all'], { GSTACK_SWEEP_EXCLUDE_SOURCES: 'verify-gate,gstack-memorable' });
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toMatch(/removed 1 /);
|
|
const s = settings();
|
|
expect(s.hooks.Stop).toHaveLength(1);
|
|
expect(s.hooks.Stop[0]._gstack_source).toBe('verify-gate');
|
|
expect(s.hooks.UserPromptSubmit).toHaveLength(2);
|
|
// and WITHOUT the exclusion (uninstall) the memorable items go too
|
|
const r2 = runIso(['prune-stale', '--all']);
|
|
expect(r2.stdout).toMatch(/removed 3 /);
|
|
expect(settings().hooks).toBeUndefined();
|
|
});
|
|
|
|
test('a tagged legacy stray (single item, no table row) is still removed', () => {
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: { UserPromptSubmit: [{ _gstack_source: 'gstack-memorable', hooks: [{ type: 'command', command: '/legacy/anything' }] }] },
|
|
}, null, 2));
|
|
const r = run(['remove-source', '--source', 'gstack-memorable']);
|
|
expect(r.stdout).toMatch(/removed 1 /);
|
|
expect(settings().hooks).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('remove-source: identity removal holds for EVERY KNOWN_HOOKS source (regression)', () => {
|
|
// The semantics change applies to all six rows, but setup's --no-team path
|
|
// and uninstall lean on four sources this file never exercised behaviourally.
|
|
const seedAll = () => fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: {
|
|
Stop: [
|
|
{ hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/timeline-stop-hook' }] },
|
|
{ hooks: [{ type: 'command', command: '/x/bin/gstack-verify-gate' }] },
|
|
{ hooks: [{ type: 'command', command: '/Users/me/my-stop-hook' }] },
|
|
],
|
|
PostToolUse: [
|
|
{ matcher: AUQ_MATCHER, hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/auq-error-fallback-hook' }] },
|
|
{ matcher: AUQ_MATCHER, hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/question-log-hook' }] },
|
|
],
|
|
SessionStart: [
|
|
{ hooks: [{ type: 'command', command: '/x/bin/gstack-session-update' }] },
|
|
{ hooks: [{ type: 'command', command: '/Users/me/my-session-hook' }] },
|
|
],
|
|
},
|
|
}, null, 2));
|
|
const allCommands = () => {
|
|
const h = settings().hooks ?? {};
|
|
return Object.values(h).flatMap((entries: any) => entries.flatMap((e: any) => e.hooks.map((i: any) => i.command))).sort();
|
|
};
|
|
|
|
for (const [source, own] of [
|
|
['gstack-timeline-stop', '/x/hosts/claude/hooks/timeline-stop-hook'],
|
|
['verify-gate', '/x/bin/gstack-verify-gate'],
|
|
['auq-error-fallback', '/x/hosts/claude/hooks/auq-error-fallback-hook'],
|
|
['gstack-session-update', '/x/bin/gstack-session-update'],
|
|
['plan-tune-cathedral', '/x/hosts/claude/hooks/question-log-hook'],
|
|
] as const) {
|
|
test(`remove-source --source ${source} removes exactly its own UNTAGGED item and nothing else`, () => {
|
|
seedAll();
|
|
const before = allCommands();
|
|
const r = run(['remove-source', '--source', source]);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toMatch(/removed 1 /);
|
|
expect(allCommands()).toEqual(before.filter((c) => c !== own));
|
|
});
|
|
}
|
|
|
|
test('a non-array hooks.<event> value is never touched (foreign shape), exit 0', () => {
|
|
fs.writeFileSync(settingsFile, JSON.stringify({ hooks: { UserPromptSubmit: { weird: true }, Stop: [{ hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/timeline-stop-hook' }] }] } }, null, 2));
|
|
const r = run(['remove-source', '--source', 'gstack-memorable']);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toMatch(/removed 0 /);
|
|
expect(settings().hooks.UserPromptSubmit).toEqual({ weird: true });
|
|
});
|
|
|
|
test('a tagged entry holding only a command-less item, and a tagged multi-item entry with no table rows, are kept with their tags', () => {
|
|
fs.writeFileSync(settingsFile, JSON.stringify({ hooks: { UserPromptSubmit: [
|
|
{ _gstack_source: 'gstack-memorable', hooks: [{ type: 'command' }] },
|
|
{ _gstack_source: 'gstack-memorable', hooks: [{ type: 'command', command: '/a/foreign' }, { type: 'command', command: '/b/foreign' }] },
|
|
] } }, null, 2));
|
|
const before = fs.readFileSync(settingsFile, 'utf-8');
|
|
const r = run(['remove-source', '--source', 'gstack-memorable']);
|
|
expect(r.stdout).toMatch(/removed 0 /);
|
|
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
|
|
});
|
|
});
|
|
|
|
describe('list-items: read-only identity view', () => {
|
|
const memo = '/stable/gstack/hosts/claude/hooks/memorable-user-prompt-hook';
|
|
const foreign = '/Users/me/my-user-prompt-hook';
|
|
const vendor = '"/Users/me/.memorable/bin/memorable" hook user-prompt';
|
|
const weird = '/tab\tand\nnewline/hook';
|
|
const seed = () => fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: { UserPromptSubmit: [
|
|
{ hooks: [{ type: 'command', command: foreign }, { type: 'command', command: memo }] },
|
|
{ hooks: [{ type: 'command', command: vendor }] },
|
|
{ hooks: [{ type: 'command', command: weird }] },
|
|
] },
|
|
}, null, 2));
|
|
|
|
test('--owned-by prints only the table-identified item, as a JSON string literal, tag or no tag', () => {
|
|
seed();
|
|
const r = run(['list-items', '--event', 'UserPromptSubmit', '--owned-by', 'gstack-memorable']);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout.trim().split('\n')).toEqual([JSON.stringify(memo)]);
|
|
});
|
|
|
|
test('--command-regex is a JavaScript RegExp applied only to items no table row owns', () => {
|
|
seed();
|
|
const r = run(['list-items', '--event', 'UserPromptSubmit', '--command-regex', '[Mm]emorable.*hook\\s+user-prompt']);
|
|
expect(r.stdout.trim().split('\n')).toEqual([JSON.stringify(vendor)]);
|
|
});
|
|
|
|
test('every line is one JSON literal: tabs and newlines inside a command cannot split it', () => {
|
|
seed();
|
|
const r = run(['list-items', '--event', 'UserPromptSubmit']);
|
|
const lines = r.stdout.trim().split('\n');
|
|
expect(lines).toHaveLength(4);
|
|
expect(lines.map((l) => JSON.parse(l))).toEqual([foreign, memo, vendor, weird]);
|
|
});
|
|
|
|
test('no matches, an unknown event, or no settings file -> empty stdout, exit 0', () => {
|
|
seed();
|
|
expect(run(['list-items', '--event', 'UserPromptSubmit', '--owned-by', 'verify-gate'])).toMatchObject({ exitCode: 0, stdout: '' });
|
|
expect(run(['list-items', '--event', 'Notification'])).toMatchObject({ exitCode: 0, stdout: '' });
|
|
fs.rmSync(settingsFile);
|
|
expect(run(['list-items', '--event', 'UserPromptSubmit'])).toMatchObject({ exitCode: 0, stdout: '' });
|
|
});
|
|
|
|
test('an unknown flag exits 1; --owned-by combined with --command-regex intersects (a regex never widens a selection)', () => {
|
|
seed();
|
|
expect(run(['list-items', '--event', 'UserPromptSubmit', '--bogus', 'x']).exitCode).toBe(1);
|
|
const both = run(['list-items', '--event', 'UserPromptSubmit', '--owned-by', 'gstack-memorable', '--command-regex', 'memorable-user-prompt-hook$']);
|
|
expect(both.stdout.trim().split('\n')).toEqual([JSON.stringify(memo)]);
|
|
const none = run(['list-items', '--event', 'UserPromptSubmit', '--owned-by', 'gstack-memorable', '--command-regex', 'no-such-thing']);
|
|
expect(none).toMatchObject({ exitCode: 0, stdout: '' });
|
|
const vendorOnly = run(['list-items', '--event', 'UserPromptSubmit', '--command-regex', 'memorable']);
|
|
expect(vendorOnly.stdout.trim().split('\n')).toEqual([JSON.stringify(vendor)]); // regex alone still excludes owned items
|
|
});
|
|
|
|
test('exit codes mirror the mutating verbs: 1 usage, 3 unparseable, 4 unexpected shape', () => {
|
|
seed();
|
|
expect(run(['list-items']).exitCode).toBe(1);
|
|
expect(run(['list-items', '--event', 'UserPromptSubmit', '--command-regex', '(']).exitCode).toBe(1);
|
|
fs.writeFileSync(settingsFile, '{bad json');
|
|
expect(run(['list-items', '--event', 'UserPromptSubmit']).exitCode).toBe(3);
|
|
fs.writeFileSync(settingsFile, JSON.stringify({ hooks: { UserPromptSubmit: {} } }));
|
|
const r = run(['list-items', '--event', 'UserPromptSubmit']);
|
|
expect(r.exitCode).toBe(4);
|
|
expect(r.stderr).toContain('not an array');
|
|
});
|
|
});
|
|
|
|
describe('prune-stale', () => {
|
|
test('prunes dead gstack items; keeps live gstack and dead non-gstack', () => {
|
|
const canon = mkCanon(tmpDir);
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: {
|
|
PostToolUse: [
|
|
hookEntry(`${canon}/hosts/claude/hooks/question-log-hook`, AUQ_MATCHER), // live gstack
|
|
hookEntry('/dead/wt/hosts/claude/hooks/auq-error-fallback-hook', AUQ_MATCHER), // dead gstack
|
|
hookEntry('/dead/user/own-hook', AUQ_MATCHER), // dead NON-gstack
|
|
],
|
|
},
|
|
}, null, 2));
|
|
const r = runIso(['prune-stale']);
|
|
expect(r.stdout).toMatch(/removed 1 gstack hook entries/);
|
|
const s = settings();
|
|
expect(s.hooks.PostToolUse).toHaveLength(2);
|
|
const cmds = s.hooks.PostToolUse.map((e: any) => e.hooks[0].command);
|
|
expect(cmds).toContain(`${canon}/hosts/claude/hooks/question-log-hook`);
|
|
expect(cmds).toContain('/dead/user/own-hook');
|
|
});
|
|
|
|
test('no-op run writes no backup and leaves the file byte-identical', () => {
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: { PreToolUse: [hookEntry('/Users/me/my-own-hook', 'Bash')] },
|
|
}, null, 2) + '\n');
|
|
const before = fs.readFileSync(settingsFile, 'utf-8');
|
|
const r = runIso(['prune-stale']);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
|
|
expect(backups()).toHaveLength(0);
|
|
expect(fs.existsSync(path.join(tmpDir, 'settings.json.bak-latest'))).toBe(false);
|
|
});
|
|
|
|
test('--repoint re-points dead AND live items, preserves bash prefix, restores tags', () => {
|
|
const canon = mkCanon(tmpDir);
|
|
const live = mkCanon(tmpDir, 'live-worktree');
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: {
|
|
Stop: [hookEntry(`${live}/hosts/claude/hooks/timeline-stop-hook`)], // LIVE but ephemeral
|
|
PostToolUse: [hookEntry('bash /dead/wt/hosts/claude/hooks/question-log-hook', AUQ_MATCHER)],
|
|
},
|
|
}, null, 2));
|
|
const r = runIso(['prune-stale', '--repoint', canon]);
|
|
expect(r.stdout).toMatch(/repointed 2/);
|
|
const s = settings();
|
|
expect(s.hooks.Stop[0].hooks[0].command).toBe(`${canon}/hosts/claude/hooks/timeline-stop-hook`);
|
|
expect(s.hooks.Stop[0]._gstack_source).toBe('gstack-timeline-stop');
|
|
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe(`bash ${canon}/hosts/claude/hooks/question-log-hook`);
|
|
expect(s.hooks.PostToolUse[0]._gstack_source).toBe('plan-tune-cathedral');
|
|
});
|
|
|
|
test('--repoint collapses exact duplicates preferring the tagged twin', () => {
|
|
const canon = mkCanon(tmpDir);
|
|
const cmd = `${canon}/hosts/claude/hooks/question-log-hook`;
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: {
|
|
PostToolUse: [
|
|
hookEntry('/dead/a/hosts/claude/hooks/question-log-hook', AUQ_MATCHER),
|
|
hookEntry(cmd, AUQ_MATCHER, 'plan-tune-cathedral'),
|
|
],
|
|
},
|
|
}, null, 2));
|
|
runIso(['prune-stale', '--repoint', canon]);
|
|
const s = settings();
|
|
expect(s.hooks.PostToolUse).toHaveLength(1);
|
|
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe(cmd);
|
|
expect(s.hooks.PostToolUse[0]._gstack_source).toBe('plan-tune-cathedral');
|
|
});
|
|
|
|
test('--repoint never ADDS entries (repair, not registration)', () => {
|
|
const canon = mkCanon(tmpDir);
|
|
fs.writeFileSync(settingsFile, JSON.stringify({ theme: 'dark' }, null, 2) + '\n');
|
|
const before = fs.readFileSync(settingsFile, 'utf-8');
|
|
runIso(['prune-stale', '--repoint', canon]);
|
|
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
|
|
});
|
|
|
|
test('Windows backslash path is classified as gstack-owned and pruned when dead', () => {
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: {
|
|
PostToolUse: [hookEntry('C:\\dead\\wt\\hosts\\claude\\hooks\\question-log-hook', AUQ_MATCHER)],
|
|
},
|
|
}, null, 2));
|
|
const r = runIso(['prune-stale']);
|
|
expect(r.stdout).toMatch(/removed 1/);
|
|
expect(settings().hooks).toBeUndefined();
|
|
});
|
|
|
|
test('spaced canonical root produces a quoted command that stays owned (idempotent)', () => {
|
|
const spacedBase = path.join(tmpDir, 'My Claude');
|
|
fs.mkdirSync(spacedBase, { recursive: true });
|
|
const canon = mkCanon(spacedBase);
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: { Stop: [hookEntry('/dead/wt/hosts/claude/hooks/timeline-stop-hook')] },
|
|
}, null, 2));
|
|
runIso(['prune-stale', '--repoint', canon]);
|
|
const s = settings();
|
|
expect(s.hooks.Stop[0].hooks[0].command).toBe(`"${canon}/hosts/claude/hooks/timeline-stop-hook"`);
|
|
// Second run: the quoted command is still recognized as ours — no churn.
|
|
const before = fs.readFileSync(settingsFile, 'utf-8');
|
|
const r2 = runIso(['prune-stale', '--repoint', canon]);
|
|
expect(r2.stdout).toMatch(/removed 0 gstack hook entries \(repointed 0\)/);
|
|
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
|
|
});
|
|
|
|
test('--all removes live untagged gstack items, spares user hooks and mixed-entry user items', () => {
|
|
const canon = mkCanon(tmpDir);
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: {
|
|
PostToolUse: [hookEntry(`${canon}/hosts/claude/hooks/question-log-hook`, AUQ_MATCHER)],
|
|
Stop: [hookEntry(
|
|
`${canon}/hosts/claude/hooks/timeline-stop-hook`, undefined, 'gstack-timeline-stop',
|
|
[{ type: 'command', command: '/Users/me/custom-stop-hook' }],
|
|
)],
|
|
PreCompact: [hookEntry('/Users/me/my-own-hook')],
|
|
},
|
|
}, null, 2));
|
|
const r = runIso(['prune-stale', '--all']);
|
|
expect(r.stdout).toMatch(/removed 2/);
|
|
const s = settings();
|
|
expect(s.hooks.PostToolUse).toBeUndefined();
|
|
expect(s.hooks.Stop[0].hooks).toHaveLength(1);
|
|
expect(s.hooks.Stop[0].hooks[0].command).toBe('/Users/me/custom-stop-hook');
|
|
expect(s.hooks.Stop[0]._gstack_source).toBeUndefined();
|
|
expect(s.hooks.PreCompact[0].hooks[0].command).toBe('/Users/me/my-own-hook');
|
|
});
|
|
|
|
test('--all removes tagged single-item legacy strays (no table match)', () => {
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: { Stop: [hookEntry('/old/install/bin/gstack-verify-gate', undefined, 'gstack-verify-gate')] },
|
|
}, null, 2));
|
|
const r = runIso(['prune-stale', '--all']);
|
|
expect(r.stdout).toMatch(/removed 1/);
|
|
expect(settings().hooks).toBeUndefined();
|
|
});
|
|
|
|
test('--all and --repoint are mutually exclusive', () => {
|
|
const r = runIso(['prune-stale', '--all', '--repoint', '/x']);
|
|
expect(r.exitCode).not.toBe(0);
|
|
expect(r.stderr).toMatch(/mutually exclusive/);
|
|
});
|
|
|
|
test('explicit plan_tune_hooks:no — dead plan-tune pruned, live plan-tune NOT re-pointed, Stop still re-pointed', () => {
|
|
const canon = mkCanon(tmpDir);
|
|
const live = mkCanon(tmpDir, 'live-worktree');
|
|
execSync(`'${path.join(ROOT, 'bin', 'gstack-config')}' set plan_tune_hooks no`, {
|
|
env: { ...process.env, GSTACK_STATE_ROOT: tmpDir },
|
|
timeout: 30_000,
|
|
});
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: {
|
|
PostToolUse: [
|
|
hookEntry('/dead/wt/hosts/claude/hooks/question-log-hook', AUQ_MATCHER), // dead plan-tune
|
|
hookEntry(`${live}/hosts/claude/hooks/auq-error-fallback-hook`, AUQ_MATCHER), // LIVE plan-tune
|
|
],
|
|
Stop: [hookEntry('/dead/wt/hosts/claude/hooks/timeline-stop-hook')],
|
|
},
|
|
}, null, 2));
|
|
runIso(['prune-stale', '--repoint', canon]);
|
|
const s = settings();
|
|
expect(s.hooks.PostToolUse).toHaveLength(1);
|
|
// Live plan-tune hook left exactly where it was (no re-activation without consent).
|
|
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe(`${live}/hosts/claude/hooks/auq-error-fallback-hook`);
|
|
// Stop hook is not part of the opt-out — re-pointed to canonical.
|
|
expect(s.hooks.Stop[0].hooks[0].command).toBe(`${canon}/hosts/claude/hooks/timeline-stop-hook`);
|
|
});
|
|
|
|
test('incident facsimile: the exact live-damage shape heals to canonical', () => {
|
|
// Replays the 2026-08-17 production state: 6 PostToolUse / 3 PreToolUse /
|
|
// 2 Stop entries; 6 dead (deleted worktrees), tags stripped on some, one
|
|
// live-but-ephemeral Stop hook, plus a user hook that must survive.
|
|
const canon = mkCanon(tmpDir);
|
|
const cebu = mkCanon(tmpDir, 'cebu-v4');
|
|
const dead = (n: string) => `/dead/biarritz-v3/hosts/claude/hooks/${n}`;
|
|
const dead2 = (n: string) => `/dead/taipei-v2/hosts/claude/hooks/${n}`;
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: {
|
|
SessionStart: [hookEntry(`${canon}/bin/gstack-session-update`)],
|
|
PostToolUse: [
|
|
hookEntry(`${canon}/hosts/claude/hooks/auq-error-fallback-hook`, AUQ_MATCHER),
|
|
hookEntry(`${canon}/hosts/claude/hooks/question-log-hook`, AUQ_MATCHER),
|
|
hookEntry(dead('question-log-hook'), AUQ_MATCHER),
|
|
hookEntry(dead('auq-error-fallback-hook'), AUQ_MATCHER),
|
|
hookEntry(dead2('question-log-hook'), AUQ_MATCHER, 'plan-tune-cathedral'),
|
|
hookEntry(dead2('auq-error-fallback-hook'), AUQ_MATCHER, 'auq-error-fallback'),
|
|
],
|
|
PreToolUse: [
|
|
hookEntry(`${canon}/hosts/claude/hooks/question-preference-hook`, AUQ_MATCHER),
|
|
hookEntry(dead('question-preference-hook'), AUQ_MATCHER),
|
|
hookEntry(dead2('question-preference-hook'), AUQ_MATCHER, 'plan-tune-cathedral'),
|
|
],
|
|
Stop: [
|
|
hookEntry(`${cebu}/hosts/claude/hooks/timeline-stop-hook`),
|
|
hookEntry(dead2('timeline-stop-hook'), undefined, 'gstack-timeline-stop'),
|
|
],
|
|
PreCompact: [hookEntry('/Users/me/my-own-hook')],
|
|
},
|
|
}, null, 2));
|
|
const r = runIso(['prune-stale', '--repoint', canon]);
|
|
expect(r.exitCode).toBe(0);
|
|
const s = settings();
|
|
expect(s.hooks.SessionStart).toHaveLength(1);
|
|
expect(s.hooks.PostToolUse).toHaveLength(2);
|
|
expect(s.hooks.PreToolUse).toHaveLength(1);
|
|
expect(s.hooks.Stop).toHaveLength(1);
|
|
expect(s.hooks.PreCompact[0].hooks[0].command).toBe('/Users/me/my-own-hook');
|
|
for (const ev of ['SessionStart', 'PostToolUse', 'PreToolUse', 'Stop']) {
|
|
for (const e of s.hooks[ev]) {
|
|
expect(e._gstack_source).toBeDefined();
|
|
for (const it of e.hooks) expect(it.command.startsWith(canon)).toBe(true);
|
|
}
|
|
}
|
|
const postSources = s.hooks.PostToolUse.map((e: any) => e._gstack_source).sort();
|
|
expect(postSources).toEqual(['auq-error-fallback', 'plan-tune-cathedral']);
|
|
});
|
|
});
|
|
|
|
describe('fail-closed parse (pre-existing data-loss fix)', () => {
|
|
const MUTATORS: string[][] = [
|
|
['add', '/x/bin/gstack-session-update'],
|
|
['remove', '/x/bin/gstack-session-update'],
|
|
['add-event', '--event', 'Stop', '--command', '/x', '--source', 's'],
|
|
['remove-source', '--source', 'plan-tune-cathedral'],
|
|
['prune-stale'],
|
|
];
|
|
test('every mutator refuses to touch a corrupt settings.json', () => {
|
|
for (const args of MUTATORS) {
|
|
fs.writeFileSync(settingsFile, '{definitely not json');
|
|
const r = runIso(args);
|
|
expect(r.exitCode).not.toBe(0);
|
|
expect(r.stderr).toMatch(/refusing to mutate/);
|
|
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe('{definitely not json');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('mutation lock', () => {
|
|
test('stale lock (old mtime) is taken over; mutation proceeds', () => {
|
|
const lockDir = `${settingsFile}.lock`;
|
|
fs.mkdirSync(lockDir);
|
|
fs.writeFileSync(path.join(lockDir, 'owner'), 'dead-process');
|
|
const old = new Date(Date.now() - 120_000);
|
|
fs.utimesSync(lockDir, old, old);
|
|
const r = runIso(['add-event', '--event', 'Stop', '--command', '/x/hosts/claude/hooks/timeline-stop-hook', '--source', 'gstack-timeline-stop']);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(settings().hooks.Stop).toHaveLength(1);
|
|
expect(fs.existsSync(lockDir)).toBe(false); // released after the mutation
|
|
});
|
|
|
|
test('fresh foreign lock: mutation skipped loudly (exit 5), file untouched', () => {
|
|
fs.writeFileSync(settingsFile, JSON.stringify({ theme: 'dark' }, null, 2) + '\n');
|
|
const before = fs.readFileSync(settingsFile, 'utf-8');
|
|
const lockDir = `${settingsFile}.lock`;
|
|
fs.mkdirSync(lockDir);
|
|
fs.writeFileSync(path.join(lockDir, 'owner'), 'another-live-process');
|
|
const r = runIso(
|
|
['add-event', '--event', 'Stop', '--command', '/x', '--source', 's'],
|
|
{ GSTACK_SETTINGS_LOCK_TIMEOUT_MS: '300' },
|
|
);
|
|
expect(r.exitCode).toBe(5); // loud give-up, not silent skip
|
|
expect(r.stderr).toMatch(/could not acquire lock/);
|
|
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
|
|
expect(fs.existsSync(lockDir)).toBe(true); // foreign lock NOT stolen
|
|
});
|
|
|
|
test('two concurrent add-events both land (lock serializes; file stays valid JSON)', () => {
|
|
const q = (args: string[]) =>
|
|
[SETTINGS_HOOK, ...args].map((s) => `'${s}'`).join(' ');
|
|
const a = q(['add-event', '--event', 'PreToolUse', '--matcher', AUQ_MATCHER, '--command', '/pre-hook', '--source', 'src-a']);
|
|
const b = q(['add-event', '--event', 'PostToolUse', '--matcher', AUQ_MATCHER, '--command', '/post-hook', '--source', 'src-b']);
|
|
execSync(`sh -c "${a} & ${b} & wait"`, {
|
|
env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile, GSTACK_STATE_ROOT: tmpDir },
|
|
encoding: 'utf-8',
|
|
timeout: 20000,
|
|
});
|
|
const s = settings(); // throws if the file is corrupt
|
|
expect(s.hooks.PreToolUse).toHaveLength(1);
|
|
expect(s.hooks.PostToolUse).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('gstack-settings-hook adversarial hardening', () => {
|
|
let tmpDir: string;
|
|
let settingsFile: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-settings-adv-'));
|
|
settingsFile = path.join(tmpDir, 'settings.json');
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function runAdv(args: string[], extraEnv: Record<string, string> = {}) {
|
|
try {
|
|
const stdout = execSync([SETTINGS_HOOK, ...args].map((s) => `'${s}'`).join(' '), {
|
|
env: {
|
|
...process.env,
|
|
GSTACK_SETTINGS_FILE: settingsFile,
|
|
GSTACK_STATE_ROOT: tmpDir,
|
|
...extraEnv,
|
|
},
|
|
encoding: 'utf-8',
|
|
timeout: 15000,
|
|
});
|
|
return { stdout, stderr: '', exitCode: 0 };
|
|
} catch (e: any) {
|
|
return { stdout: e.stdout || '', stderr: e.stderr || '', exitCode: e.status ?? 1 };
|
|
}
|
|
}
|
|
|
|
const advSettings = (): any => JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
|
|
|
|
test('wrong-shape hooks value fails LOUD (exit 4), file untouched', () => {
|
|
// bun -e swallows uncaught exceptions after a require() and exits 0
|
|
// (verified on bun 1.3.13) -- without the gsMain umbrella this exact
|
|
// input produced a silent exit-0 no-op that reported clean.
|
|
const raw = JSON.stringify({ hooks: { Stop: { bogus: 'shape' } } }, null, 2) + '\n';
|
|
fs.writeFileSync(settingsFile, raw);
|
|
const r = runAdv(['prune-stale', '--all']);
|
|
expect(r.exitCode).toBe(4);
|
|
expect(r.stderr).toMatch(/internal error/);
|
|
expect(r.stderr).toMatch(/refusing to mutate/);
|
|
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(raw);
|
|
});
|
|
|
|
test('foreign hook whose basename collides with Object.prototype survives --all', () => {
|
|
// KNOWN_HOOKS["toString"] returns an inherited member without the
|
|
// hasOwnProperty guard -- pre-fix, this threw mid-scan and turned the
|
|
// sweep into a silent no-op.
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: {
|
|
Stop: [
|
|
{ hooks: [{ type: 'command', command: '/usr/local/bin/toString' }] },
|
|
{ _gstack_source: 'gstack-timeline-stop', hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/timeline-stop-hook' }] },
|
|
],
|
|
},
|
|
}, null, 2) + '\n');
|
|
const r = runAdv(['prune-stale', '--all']);
|
|
expect(r.exitCode).toBe(0);
|
|
const s = advSettings();
|
|
expect(s.hooks.Stop).toHaveLength(1); // gstack entry swept...
|
|
expect(s.hooks.Stop[0].hooks[0].command).toBe('/usr/local/bin/toString'); // ...foreign one kept
|
|
});
|
|
|
|
test('GSTACK_SWEEP_EXCLUDE_SOURCES preserves verify-gate during an --all sweep', () => {
|
|
// `setup --no-team` sweeps team hooks but must not delete the
|
|
// user-registered verify-gate opt-in whose binary still exists.
|
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
|
hooks: {
|
|
Stop: [
|
|
{ _gstack_source: 'verify-gate', hooks: [{ type: 'command', command: '/x/bin/gstack-verify-gate' }] },
|
|
{ _gstack_source: 'gstack-timeline-stop', hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/timeline-stop-hook' }] },
|
|
],
|
|
},
|
|
}, null, 2) + '\n');
|
|
const r = runAdv(['prune-stale', '--all'], { GSTACK_SWEEP_EXCLUDE_SOURCES: 'verify-gate' });
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toMatch(/removed 1 /);
|
|
const s = advSettings();
|
|
expect(s.hooks.Stop).toHaveLength(1);
|
|
expect(s.hooks.Stop[0]._gstack_source).toBe('verify-gate');
|
|
});
|
|
});
|