mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
Without node on PATH the node -e launcher exits 127, and with check-gstack.cjs missing it exits 1; Claude Code treats both as non-blocking and lets the Skill call through. Register the handler with onFailure: block (Claude Code 2.1.295+), upgrade existing registrations in place, and tell the agent and the developer what the two block reasons mean. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
164 lines
7.0 KiB
TypeScript
164 lines
7.0 KiB
TypeScript
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import { execSync, execFileSync } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
|
|
// Regression guard for #2413: gstack-team-init required generated a
|
|
// PreToolUse hook that emitted a flat {"permissionDecision":...} payload and
|
|
// exited 0. Claude Code only nests decisions under hookSpecificOutput, and
|
|
// only exit code 2 reliably blocks a PreToolUse call. The old shape was
|
|
// silently ignored as a non-blocking error, so `required` mode enforced
|
|
// nothing — the BLOCKED text printed to stderr but the tool call proceeded.
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const TEAM_INIT = path.join(ROOT, 'bin', 'gstack-team-init');
|
|
|
|
let repoDir: string;
|
|
let fakeHomeAbsent: string;
|
|
let fakeHomePresent: string;
|
|
|
|
describe('gstack-team-init required: PreToolUse hook schema (#2413)', () => {
|
|
beforeEach(() => {
|
|
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-team-init-repo-'));
|
|
execFileSync('git', ['init', '-q'], { cwd: repoDir });
|
|
execFileSync(TEAM_INIT, ['required'], { cwd: repoDir, encoding: 'utf-8' });
|
|
|
|
fakeHomeAbsent = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-team-init-home-absent-'));
|
|
|
|
fakeHomePresent = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-team-init-home-present-'));
|
|
fs.mkdirSync(path.join(fakeHomePresent, '.claude', 'skills', 'gstack', 'bin'), { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(repoDir, { recursive: true, force: true });
|
|
fs.rmSync(fakeHomeAbsent, { recursive: true, force: true });
|
|
fs.rmSync(fakeHomePresent, { recursive: true, force: true });
|
|
});
|
|
|
|
// Run the command exactly as registered in settings.json, the way a hook
|
|
// runner does: through a shell, with CLAUDE_PROJECT_DIR set.
|
|
function runHook(home: string): { status: number; stdout: string; stderr: string } {
|
|
const settings = JSON.parse(fs.readFileSync(path.join(repoDir, '.claude', 'settings.json'), 'utf-8'));
|
|
const command = settings.hooks.PreToolUse[0].hooks[0].command;
|
|
try {
|
|
const stdout = execSync(command, {
|
|
env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: repoDir, GSTACK_ROOT: '' },
|
|
encoding: 'utf-8',
|
|
timeout: 30_000,
|
|
});
|
|
return { status: 0, stdout, stderr: '' };
|
|
} catch (err) {
|
|
const e = err as { status: number; stdout: string; stderr: string };
|
|
return { status: e.status, stdout: e.stdout, stderr: e.stderr };
|
|
}
|
|
}
|
|
|
|
test('generates the hook and registers it in settings.json', () => {
|
|
expect(fs.existsSync(path.join(repoDir, '.claude', 'hooks', 'check-gstack.cjs'))).toBe(true);
|
|
const settings = JSON.parse(
|
|
fs.readFileSync(path.join(repoDir, '.claude', 'settings.json'), 'utf-8'),
|
|
);
|
|
expect(JSON.stringify(settings)).toContain('check-gstack.cjs');
|
|
expect(settings.hooks.PreToolUse[0].hooks[0].command).not.toContain('$');
|
|
});
|
|
|
|
test('gstack absent: exits 2 (blocking) with schema-valid deny JSON', () => {
|
|
const { status, stdout, stderr } = runHook(fakeHomeAbsent);
|
|
expect(status).toBe(2);
|
|
expect(stderr).toContain('BLOCKED: gstack is not installed globally.');
|
|
|
|
const payload = JSON.parse(stdout.trim());
|
|
expect(payload.hookSpecificOutput.hookEventName).toBe('PreToolUse');
|
|
expect(payload.hookSpecificOutput.permissionDecision).toBe('deny');
|
|
expect(typeof payload.hookSpecificOutput.permissionDecisionReason).toBe('string');
|
|
// The old top-level shape must be gone, not just supplemented.
|
|
expect(payload.permissionDecision).toBeUndefined();
|
|
expect(payload.message).toBeUndefined();
|
|
});
|
|
|
|
test('gstack present: exits 0 with an empty (no-opinion) payload', () => {
|
|
const { status, stdout, stderr } = runHook(fakeHomePresent);
|
|
expect(status).toBe(0);
|
|
expect(stderr).toBe('');
|
|
expect(JSON.parse(stdout.trim())).toEqual({});
|
|
});
|
|
});
|
|
|
|
// #3319: when the launcher itself cannot run, the hook prints nothing and exits
|
|
// with neither 0 nor 2, which Claude Code treats as a non-blocking error. The
|
|
// handler is registered with onFailure: block (Claude Code 2.1.295+) so those
|
|
// failures block the Skill call instead of letting it through.
|
|
describe('gstack-team-init required: the hook fails closed (#3319)', () => {
|
|
let repo: string;
|
|
let home: string;
|
|
let noNodeBin: string;
|
|
|
|
beforeEach(() => {
|
|
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-team-init-closed-'));
|
|
execFileSync('git', ['init', '-q'], { cwd: repo });
|
|
home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-team-init-closed-home-'));
|
|
noNodeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-team-init-no-node-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const dir of [repo, home, noNodeBin]) fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
const init = () => execFileSync(TEAM_INIT, ['required'], { cwd: repo, encoding: 'utf-8', timeout: 30_000 });
|
|
const settingsPath = () => path.join(repo, '.claude', 'settings.json');
|
|
const ourHandlers = () => JSON.parse(fs.readFileSync(settingsPath(), 'utf-8')).hooks.PreToolUse
|
|
.flatMap((e: { hooks: { command: string }[] }) => e.hooks)
|
|
.filter((h: { command: string }) => h.command.includes('check-gstack'));
|
|
|
|
function runRegistered(pathEnv: string): { status: number; stdout: string } {
|
|
const [{ command }] = ourHandlers();
|
|
try {
|
|
const stdout = execSync(command, {
|
|
env: { PATH: pathEnv, HOME: home, CLAUDE_PROJECT_DIR: repo },
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
timeout: 30_000,
|
|
});
|
|
return { status: 0, stdout };
|
|
} catch (err) {
|
|
const e = err as { status: number; stdout: string };
|
|
return { status: e.status, stdout: e.stdout };
|
|
}
|
|
}
|
|
|
|
test('the check-gstack handler is registered with onFailure: block', () => {
|
|
init();
|
|
const handlers = ourHandlers();
|
|
expect(handlers).toHaveLength(1);
|
|
expect(handlers[0]).toEqual({ type: 'command', command: handlers[0].command, onFailure: 'block' });
|
|
});
|
|
|
|
test('node missing from PATH: no output and exit 127, a failure only onFailure turns into a block', () => {
|
|
init();
|
|
const { status, stdout } = runRegistered(noNodeBin);
|
|
expect(status).toBe(127);
|
|
expect(stdout).toBe('');
|
|
});
|
|
|
|
test('check-gstack.cjs missing: no output and exit 1, a failure only onFailure turns into a block', () => {
|
|
init();
|
|
fs.rmSync(path.join(repo, '.claude', 'hooks', 'check-gstack.cjs'));
|
|
const { status, stdout } = runRegistered(process.env.PATH!);
|
|
expect(status).toBe(1);
|
|
expect(stdout).toBe('');
|
|
});
|
|
|
|
test('re-running upgrades a handler registered without onFailure, keeping one handler', () => {
|
|
init();
|
|
const settings = JSON.parse(fs.readFileSync(settingsPath(), 'utf-8'));
|
|
const [{ command }] = ourHandlers();
|
|
settings.hooks.PreToolUse = [{ matcher: 'Skill|skill', hooks: [{ type: 'command', command }] }];
|
|
fs.writeFileSync(settingsPath(), JSON.stringify(settings));
|
|
|
|
const out = init();
|
|
expect(ourHandlers()).toEqual([{ type: 'command', command, onFailure: 'block' }]);
|
|
expect(out).toContain('Claude Code 2.1.295 or later');
|
|
});
|
|
});
|