mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +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>
94 lines
4.9 KiB
TypeScript
94 lines
4.9 KiB
TypeScript
/**
|
|
* Required team mode registers its PreToolUse hook as a command string that
|
|
* the hook runner hands to whatever shell it uses: sh on macOS and Linux,
|
|
* cmd.exe or PowerShell on Windows and under Copilot CLI. The old
|
|
* `"$CLAUDE_PROJECT_DIR/.claude/hooks/check-gstack.sh"` only worked in a POSIX
|
|
* shell, so on Windows an intended allow or deny became a hook error (#2217).
|
|
*
|
|
* This reads the hook source and the registered command straight out of
|
|
* bin/gstack-team-init (no bash needed) and runs the command through the
|
|
* platform's default shell (`shell: true`: /bin/sh here, cmd.exe on Windows),
|
|
* plus PowerShell when one is installed, from a project path with a space.
|
|
*/
|
|
import { afterAll, describe, expect, test } from 'bun:test';
|
|
import { spawnSync } from 'node:child_process';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
|
|
const TEAM_INIT = fs.readFileSync(new URL('../bin/gstack-team-init', import.meta.url), 'utf-8');
|
|
|
|
const HOOK_START = 'cat > "$HOOKS_DIR/check-gstack.cjs" << \'HOOK_EOF\'\n';
|
|
const hookSource = TEAM_INIT.slice(TEAM_INIT.indexOf(HOOK_START) + HOOK_START.length, TEAM_INIT.indexOf('\nHOOK_EOF\n'));
|
|
const commandLiteral = TEAM_INIT.match(/^\s*HOOK_COMMAND="((?:[^"\\]|\\.)*)"$/m)?.[1] ?? '';
|
|
const command = commandLiteral.replace(/\\(["\\$`])/g, '$1');
|
|
|
|
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'team-hook-neutral-'));
|
|
afterAll(() => fs.rmSync(base, { recursive: true, force: true }));
|
|
const project = path.join(base, 'my project');
|
|
fs.mkdirSync(path.join(project, '.claude', 'hooks'), { recursive: true });
|
|
fs.writeFileSync(path.join(project, '.claude', 'hooks', 'check-gstack.cjs'), hookSource);
|
|
const elsewhere = path.join(base, 'elsewhere');
|
|
fs.mkdirSync(elsewhere);
|
|
|
|
function hookEnv(home: Record<string, string>): NodeJS.ProcessEnv {
|
|
const env: NodeJS.ProcessEnv = { ...process.env, CLAUDE_PROJECT_DIR: project, ...home };
|
|
delete env.GSTACK_ROOT;
|
|
if (!('HOME' in home)) delete env.HOME;
|
|
return env;
|
|
}
|
|
|
|
type Runner = (env: NodeJS.ProcessEnv) => { status: number | null; stdout: string; stderr: string };
|
|
const viaDefaultShell: Runner = (env) => spawnSync(command, { shell: true, cwd: elsewhere, env, encoding: 'utf-8', timeout: 20_000 });
|
|
const powershell = process.platform === 'win32' ? 'powershell' : Bun.which('pwsh');
|
|
const viaPowerShell: Runner = (env) =>
|
|
spawnSync(powershell!, ['-NoProfile', '-NonInteractive', '-Command', command], { cwd: elsewhere, env, encoding: 'utf-8', timeout: 30_000 });
|
|
|
|
describe('required team hook: shell-neutral registration', () => {
|
|
test('the extracted command is a node launcher with no shell variable syntax', () => {
|
|
expect(hookSource).toContain("'use strict';");
|
|
expect(command).toStartWith('node -e "');
|
|
expect(command).toContain('process.env.CLAUDE_PROJECT_DIR');
|
|
expect(commandLiteral).not.toContain('$');
|
|
expect(command).not.toContain('%');
|
|
});
|
|
|
|
// #3319: without node the shell's own error is all the runner gets, which is
|
|
// neither exit 0 nor 2, so the registration must carry onFailure: block.
|
|
test('without node on PATH the default shell exits neither 0 nor 2, so the handler is registered with onFailure: block', () => {
|
|
expect(TEAM_INIT).toContain("const handler = { type: 'command', command: process.env.GSTACK_HOOK_COMMAND, onFailure: 'block' };");
|
|
const noNode = path.join(base, 'no-node-bin');
|
|
fs.mkdirSync(noNode, { recursive: true });
|
|
const failed = viaDefaultShell({ ...hookEnv({ HOME: elsewhere, USERPROFILE: elsewhere }), PATH: noNode, Path: noNode });
|
|
expect(failed.stdout.trim()).toBe('');
|
|
expect([0, 2]).not.toContain(failed.status);
|
|
});
|
|
|
|
// `powershell -Command` reports a failing native command as exit 1, so only
|
|
// the default shell sees the hook's own exit 2; the deny JSON is the same.
|
|
for (const [name, runner, enabled, denyStatus] of [
|
|
['the default shell', viaDefaultShell, true, 2],
|
|
['PowerShell', viaPowerShell, Boolean(powershell), 1],
|
|
] as const) {
|
|
test.skipIf(!enabled)(`through ${name}: deny with exit 2 when no install resolves, {} when one does`, () => {
|
|
const home = path.join(base, `home-${name.replace(/\W+/g, '-')}`);
|
|
fs.mkdirSync(home, { recursive: true });
|
|
|
|
const denied = runner(hookEnv({ HOME: home, USERPROFILE: home }));
|
|
expect(denied.status).toBe(denyStatus);
|
|
expect(JSON.parse(denied.stdout.trim()).hookSpecificOutput).toMatchObject({ hookEventName: 'PreToolUse', permissionDecision: 'deny' });
|
|
expect(denied.stderr).toContain('BLOCKED: gstack is not installed globally.');
|
|
|
|
fs.mkdirSync(path.join(home, '.claude/skills/gstack/bin'), { recursive: true });
|
|
const allowed = runner(hookEnv({ HOME: home, USERPROFILE: home }));
|
|
expect(allowed.stderr).toBe('');
|
|
expect(allowed.stdout.trim()).toBe('{}');
|
|
expect(allowed.status).toBe(0);
|
|
|
|
const viaProfile = runner(hookEnv({ USERPROFILE: home }));
|
|
expect(viaProfile.stdout.trim()).toBe('{}');
|
|
expect(viaProfile.status).toBe(0);
|
|
});
|
|
}
|
|
});
|