mirror of https://github.com/garrytan/gstack.git
Merge 3623783b8a into 2beb636f7c
This commit is contained in:
commit
c6b6111146
|
|
@ -0,0 +1,3 @@
|
|||
# Dispatch inbox
|
||||
|
||||
- [ ] Investigate Windows-safe suite filtering and prerequisites for `test/readme-throughput.test.ts` and `browse/test/tab-isolation.test.ts`; `origin/main` reproduced the failures. (files: package.json, test/readme-throughput.test.ts, browse/test/tab-isolation.test.ts) — noticed 2026-07-10
|
||||
|
|
@ -58,7 +58,7 @@ From inside your repo, paste this. Switches you to team mode, bootstraps the rep
|
|||
(cd ~/.claude/skills/gstack && ./setup --team) && ~/.claude/skills/gstack/bin/gstack-team-init required && git add .claude/ CLAUDE.md && git commit -m "require gstack for AI-assisted work"
|
||||
```
|
||||
|
||||
No vendored files in your repo, no version drift, no manual upgrades. Every Claude Code session starts with a fast auto-update check (throttled to once/hour, network-failure-safe, completely silent).
|
||||
No vendored gstack install in your repo, no version drift, no manual upgrades. Required mode commits one Node 18+ CommonJS enforcement hook, so the same project hook works from POSIX and Windows hook runners without duplicate shell-specific handlers. If the hook runner omits its project directory or provides a stale path, enforcement fails closed with an intentional denial instead of a hook error. Every Claude Code session starts with a fast auto-update check (throttled to once/hour, network-failure-safe, completely silent).
|
||||
|
||||
Swap `required` for `optional` if you'd rather nudge teammates than block them.
|
||||
|
||||
|
|
|
|||
|
|
@ -65,12 +65,13 @@ cd ~/.claude/skills/gstack && ./setup --team
|
|||
Skills like /qa, /ship, /review, /investigate, and /browse become available after install.
|
||||
Use /browse for all web browsing. Use ~/.claude/skills/gstack/... for gstack file paths.'
|
||||
else
|
||||
SNIPPET='## gstack (REQUIRED — global install)
|
||||
SNIPPET=$(cat <<'SNIPPET_EOF'
|
||||
## gstack (REQUIRED — global install)
|
||||
|
||||
**Before doing ANY work, verify gstack is installed:**
|
||||
|
||||
```bash
|
||||
test -d ~/.claude/skills/gstack/bin && echo "GSTACK_OK" || echo "GSTACK_MISSING"
|
||||
```
|
||||
node -e "const fs=require('node:fs'),os=require('node:os'),p=require('node:path'); console.log(fs.existsSync(p.join(os.homedir(),'.claude','skills','gstack','bin')) ? 'GSTACK_OK' : 'GSTACK_MISSING')"
|
||||
```
|
||||
|
||||
If GSTACK_MISSING: STOP. Do not proceed. Tell the user:
|
||||
|
|
@ -87,7 +88,9 @@ Do not skip skills, ignore gstack errors, or work around missing gstack.
|
|||
|
||||
Using gstack skills: After install, skills like /qa, /ship, /review, /investigate,
|
||||
and /browse are available. Use /browse for all web browsing.
|
||||
Use ~/.claude/skills/gstack/... for gstack file paths (the global path).'
|
||||
Use ~/.claude/skills/gstack/... for gstack file paths (the global path).
|
||||
SNIPPET_EOF
|
||||
)
|
||||
fi
|
||||
|
||||
# Check if CLAUDE.md already has a gstack section
|
||||
|
|
@ -109,33 +112,60 @@ if [ "$MODE" = "required" ]; then
|
|||
HOOKS_DIR="$REPO_ROOT/.claude/hooks"
|
||||
SETTINGS="$REPO_ROOT/.claude/settings.json"
|
||||
|
||||
# Create enforcement hook script
|
||||
# Create a CommonJS hook so consumer package.json module settings cannot change
|
||||
# how Node 18+ loads it.
|
||||
mkdir -p "$HOOKS_DIR"
|
||||
cat > "$HOOKS_DIR/check-gstack.sh" << 'HOOK_EOF'
|
||||
#!/bin/bash
|
||||
# Block skill usage when gstack is not installed globally.
|
||||
cat > "$HOOKS_DIR/check-gstack.cjs" << 'HOOK_EOF'
|
||||
'use strict';
|
||||
|
||||
if [ ! -d "$HOME/.claude/skills/gstack/bin" ]; then
|
||||
cat >&2 <<'MSG'
|
||||
BLOCKED: gstack is not installed globally.
|
||||
// Block skill usage when gstack is not installed globally.
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
gstack is required for AI-assisted work in this repo.
|
||||
const homeDir = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
||||
const gstackBin = path.join(homeDir, '.claude', 'skills', 'gstack', 'bin');
|
||||
let verificationError = null;
|
||||
let installed = false;
|
||||
|
||||
try {
|
||||
installed = fs.statSync(gstackBin).isDirectory();
|
||||
} catch (error) {
|
||||
if (error && error.code !== 'ENOENT') {
|
||||
verificationError = error;
|
||||
}
|
||||
}
|
||||
|
||||
if (installed) {
|
||||
process.stdout.write('{}\n');
|
||||
} else {
|
||||
const projectDir = process.env.CLAUDE_PROJECT_DIR || '(unknown project)';
|
||||
const heading = verificationError
|
||||
? 'BLOCKED: the global gstack install could not be verified.'
|
||||
: 'BLOCKED: gstack is not installed globally.';
|
||||
const instructions = `${heading}
|
||||
|
||||
gstack is required for AI-assisted work in ${projectDir}.
|
||||
|
||||
Install it:
|
||||
git clone --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack
|
||||
cd ~/.claude/skills/gstack && ./setup --team
|
||||
|
||||
Then restart your AI coding tool.
|
||||
MSG
|
||||
echo '{"permissionDecision":"deny","message":"gstack is required but not installed. See stderr for install instructions."}'
|
||||
exit 0
|
||||
fi
|
||||
`;
|
||||
|
||||
echo '{}'
|
||||
process.stderr.write(instructions);
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: instructions,
|
||||
},
|
||||
})}\n`);
|
||||
}
|
||||
HOOK_EOF
|
||||
chmod +x "$HOOKS_DIR/check-gstack.sh"
|
||||
GENERATED+=(".claude/hooks/check-gstack.sh")
|
||||
echo " + .claude/hooks/check-gstack.sh — enforcement hook"
|
||||
GENERATED+=(".claude/hooks/check-gstack.cjs")
|
||||
echo " + .claude/hooks/check-gstack.cjs — cross-platform enforcement hook"
|
||||
|
||||
# Add hook to project-level settings.json
|
||||
if command -v bun >/dev/null 2>&1; then
|
||||
|
|
@ -149,18 +179,32 @@ HOOK_EOF
|
|||
if (!settings.hooks) settings.hooks = {};
|
||||
if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
|
||||
|
||||
// Dedup
|
||||
const exists = settings.hooks.PreToolUse.some(entry =>
|
||||
entry.matcher === 'Skill' &&
|
||||
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('check-gstack'))
|
||||
);
|
||||
const hookCommand = \"node -e \\\"const deny=reason=>{console.error(reason);console.log(JSON.stringify({hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}))};const projectDir=process.env.CLAUDE_PROJECT_DIR;if(!projectDir){deny('BLOCKED: CLAUDE_PROJECT_DIR is unavailable, so the required gstack hook cannot be loaded.')}else{try{require(require('node:path').join(projectDir, '.claude', 'hooks', 'check-gstack.cjs'))}catch{deny('BLOCKED: the required gstack hook could not be loaded. Verify project hook setup and retry.')}}\\\"\";
|
||||
let found = false;
|
||||
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(entry => {
|
||||
if (entry.matcher !== 'Skill' || !entry.hooks) return true;
|
||||
|
||||
if (!exists) {
|
||||
const matchingHooks = entry.hooks.filter(
|
||||
h => h.command && h.command.includes('check-gstack')
|
||||
);
|
||||
if (matchingHooks.length === 0) return true;
|
||||
|
||||
entry.hooks = entry.hooks.filter(
|
||||
h => !h.command || !h.command.includes('check-gstack')
|
||||
);
|
||||
if (!found) {
|
||||
entry.hooks.push({ type: 'command', command: hookCommand });
|
||||
found = true;
|
||||
}
|
||||
return entry.hooks.length > 0;
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
settings.hooks.PreToolUse.push({
|
||||
matcher: 'Skill',
|
||||
hooks: [{
|
||||
type: 'command',
|
||||
command: '\"\$CLAUDE_PROJECT_DIR/.claude/hooks/check-gstack.sh\"'
|
||||
command: hookCommand
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
|
@ -171,6 +215,16 @@ HOOK_EOF
|
|||
" 2>/dev/null
|
||||
GENERATED+=(".claude/settings.json")
|
||||
echo " + .claude/settings.json — PreToolUse hook registered"
|
||||
|
||||
# Remove the obsolete Bash hook only after its replacement is generated and
|
||||
# registered successfully. Other project hooks are left untouched.
|
||||
if [ -e "$HOOKS_DIR/check-gstack.sh" ] || [ -L "$HOOKS_DIR/check-gstack.sh" ]; then
|
||||
rm "$HOOKS_DIR/check-gstack.sh"
|
||||
if (cd "$REPO_ROOT" && git ls-files --error-unmatch -- .claude/hooks/check-gstack.sh >/dev/null 2>&1); then
|
||||
GENERATED+=(".claude/hooks/check-gstack.sh")
|
||||
fi
|
||||
echo " - .claude/hooks/check-gstack.sh — removed legacy enforcement hook"
|
||||
fi
|
||||
else
|
||||
echo " ! bun not found — manually add the PreToolUse hook to .claude/settings.json"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Cross-platform gstack enforcement hook</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Cross-platform gstack enforcement hook</h1>
|
||||
|
||||
<h2>Design decisions</h2>
|
||||
<ul>
|
||||
<li>Required mode generates <code>.claude/hooks/check-gstack.cjs</code>. The explicit CommonJS extension is unaffected by a consumer repository's <code>package.json</code> module type.</li>
|
||||
<li>The one registered command launches Node 18+ and resolves <code>CLAUDE_PROJECT_DIR</code> inside JavaScript through <code>process.env</code>. It contains no Bash, PowerShell, or cmd environment expansion.</li>
|
||||
<li>The launcher validates <code>CLAUDE_PROJECT_DIR</code> before calling <code>path.join</code>. Missing or empty project context emits a structured <code>PreToolUse</code> denial, writes a clear reason to stderr, and exits zero instead of surfacing a hook execution error.</li>
|
||||
<li>Path resolution and module loading run inside the same <code>try/catch</code>. A nonexistent project, an existing project without the generated hook, or any other load failure returns the same concise denial without exposing a stack trace or the full project path.</li>
|
||||
<li>The generated hook resolves the user home in <code>HOME</code>, <code>USERPROFILE</code>, then <code>os.homedir()</code> order so POSIX, Git Bash, PowerShell, and cmd runners use the expected global install root.</li>
|
||||
<li>The hook contract follows <code>docs/spikes/claude-code-hook-mutation.md</code>: pass-through writes <code>{}</code>; denial writes a <code>hookSpecificOutput</code> object for <code>PreToolUse</code> with <code>permissionDecision: "deny"</code>.</li>
|
||||
<li>Missing or unreadable gstack is an intentional denial with exit code zero. Install instructions are present in stderr and <code>permissionDecisionReason</code>, so the runner does not report a hook execution error.</li>
|
||||
<li>Rerunning required mode replaces a matching legacy <code>check-gstack.sh</code> registration, removes duplicate matching registrations, and deletes the obsolete Bash hook only after the CommonJS hook is generated and registered successfully. If Git tracks the deleted hook, the printed <code>git add</code> command includes its path so the suggested migration commit stages the deletion; untracked removed hooks are omitted because they have no index change. Unrelated project hooks remain unchanged.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Deviations</h2>
|
||||
<p>None. The implementation uses the requested Node launcher, one hook handler, and the repository's documented hook output schema.</p>
|
||||
|
||||
<h2>Tradeoffs</h2>
|
||||
<ul>
|
||||
<li>The generated project now requires Node 18+ at hook execution time. This removes shell selection ambiguity and matches the requested runtime floor.</li>
|
||||
<li>The generated hook is invoked through Node rather than as an executable file, so POSIX executable mode bits are no longer part of the contract.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Open questions</h2>
|
||||
<p>None.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -2,12 +2,21 @@ 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';
|
||||
import { execSync, spawnSync } from 'child_process';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const SETTINGS_HOOK = path.join(ROOT, 'bin', 'gstack-settings-hook');
|
||||
const SESSION_UPDATE = path.join(ROOT, 'bin', 'gstack-session-update');
|
||||
const TEAM_INIT = path.join(ROOT, 'bin', 'gstack-team-init');
|
||||
|
||||
function bashCommand(filePath: string): string {
|
||||
return `bash "${filePath.replace(/\\/g, '/')}"`;
|
||||
}
|
||||
|
||||
const SETTINGS_HOOK = bashCommand(path.join(ROOT, 'bin', 'gstack-settings-hook'));
|
||||
const SESSION_UPDATE = bashCommand(path.join(ROOT, 'bin', 'gstack-session-update'));
|
||||
const TEAM_INIT = bashCommand(path.join(ROOT, 'bin', 'gstack-team-init'));
|
||||
const MISSING_PROJECT_REASON =
|
||||
'BLOCKED: CLAUDE_PROJECT_DIR is unavailable, so the required gstack hook cannot be loaded.';
|
||||
const HOOK_LOAD_REASON =
|
||||
'BLOCKED: the required gstack hook could not be loaded. Verify project hook setup and retry.';
|
||||
|
||||
function mkTmpDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-team-test-'));
|
||||
|
|
@ -17,7 +26,11 @@ function run(cmd: string, opts: { cwd?: string; env?: Record<string, string> } =
|
|||
try {
|
||||
const stdout = execSync(cmd, {
|
||||
cwd: opts.cwd,
|
||||
env: { ...process.env, ...opts.env },
|
||||
env: {
|
||||
...process.env,
|
||||
...(process.platform === 'win32' ? { MSYS_NO_PATHCONV: '1' } : {}),
|
||||
...opts.env,
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
|
@ -27,6 +40,63 @@ function run(cmd: string, opts: { cwd?: string; env?: Record<string, string> } =
|
|||
}
|
||||
}
|
||||
|
||||
function runHook(
|
||||
command: string,
|
||||
opts: { cwd: string; env: Record<string, string> },
|
||||
): { stdout: string; stderr: string; exitCode: number } {
|
||||
const result = spawnSync(command, {
|
||||
cwd: opts.cwd,
|
||||
env: { ...process.env, ...opts.env },
|
||||
encoding: 'utf-8',
|
||||
shell: true,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return {
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
exitCode: result.status ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
function runHookInPowerShell(
|
||||
command: string,
|
||||
opts: { cwd: string; env: Record<string, string> },
|
||||
): { stdout: string; stderr: string; exitCode: number } {
|
||||
const result = spawnSync(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-NonInteractive', '-Command', command],
|
||||
{
|
||||
cwd: opts.cwd,
|
||||
env: { ...process.env, ...opts.env },
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
exitCode: result.status ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
function expectStructuredDeny(
|
||||
result: { stdout: string; stderr: string; exitCode: number },
|
||||
reason: string,
|
||||
): void {
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stderr.trim()).toBe(reason);
|
||||
expect(result.stderr).not.toContain('MODULE_NOT_FOUND');
|
||||
expect(JSON.parse(result.stdout)).toEqual({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('gstack-settings-hook', () => {
|
||||
let tmpDir: string;
|
||||
let settingsFile: string;
|
||||
|
|
@ -227,29 +297,290 @@ describe('gstack-team-init', () => {
|
|||
const claude = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
||||
expect(claude).toContain('## gstack (REQUIRED');
|
||||
expect(claude).toContain('GSTACK_MISSING');
|
||||
expect(claude).toContain("require('node:os')");
|
||||
expect(claude).not.toContain('test -d ~/.claude/skills/gstack/bin');
|
||||
});
|
||||
|
||||
test('required: creates enforcement hook', () => {
|
||||
run(`${TEAM_INIT} required`, { cwd: tmpDir });
|
||||
const hookPath = path.join(tmpDir, '.claude', 'hooks', 'check-gstack.sh');
|
||||
const result = run(`${TEAM_INIT} required`, { cwd: tmpDir });
|
||||
const hookPath = path.join(tmpDir, '.claude', 'hooks', 'check-gstack.cjs');
|
||||
expect(fs.existsSync(hookPath)).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(path.join(tmpDir, '.claude', 'hooks', 'check-gstack.sh')),
|
||||
).toBe(false);
|
||||
const hook = fs.readFileSync(hookPath, 'utf-8');
|
||||
expect(hook).toContain("'use strict'");
|
||||
expect(hook).toContain("require('node:fs')");
|
||||
expect(hook).toContain(
|
||||
'process.env.HOME || process.env.USERPROFILE || os.homedir()',
|
||||
);
|
||||
expect(hook).toContain('process.env.CLAUDE_PROJECT_DIR');
|
||||
expect(hook).toContain("hookEventName: 'PreToolUse'");
|
||||
expect(hook).toContain("permissionDecision: 'deny'");
|
||||
expect(hook).toContain('BLOCKED: gstack is not installed');
|
||||
// Should be executable
|
||||
const stat = fs.statSync(hookPath);
|
||||
expect(stat.mode & 0o111).toBeGreaterThan(0);
|
||||
expect(hook).not.toContain('#!/bin/bash');
|
||||
expect(result.stdout).not.toMatch(/git add .*check-gstack\.sh/);
|
||||
});
|
||||
|
||||
test('required: creates project settings.json with PreToolUse hook', () => {
|
||||
test('required: registers one shell-neutral project hook', () => {
|
||||
run(`${TEAM_INIT} required`, { cwd: tmpDir });
|
||||
const settingsPath = path.join(tmpDir, '.claude', 'settings.json');
|
||||
expect(fs.existsSync(settingsPath)).toBe(true);
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||
expect(settings.hooks.PreToolUse).toHaveLength(1);
|
||||
expect(settings.hooks.PreToolUse[0].matcher).toBe('Skill');
|
||||
expect(settings.hooks.PreToolUse[0].hooks[0].command).toContain('check-gstack');
|
||||
const entry = settings.hooks.PreToolUse[0];
|
||||
expect(entry.matcher).toBe('Skill');
|
||||
expect(entry.hooks).toHaveLength(1);
|
||||
expect(entry.hooks[0]).not.toHaveProperty('shell');
|
||||
expect(entry.hooks[0].command).toBe(
|
||||
`node -e "const deny=reason=>{console.error(reason);console.log(JSON.stringify({hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}))};const projectDir=process.env.CLAUDE_PROJECT_DIR;if(!projectDir){deny('BLOCKED: CLAUDE_PROJECT_DIR is unavailable, so the required gstack hook cannot be loaded.')}else{try{require(require('node:path').join(projectDir, '.claude', 'hooks', 'check-gstack.cjs'))}catch{deny('BLOCKED: the required gstack hook could not be loaded. Verify project hook setup and retry.')}}"`,
|
||||
);
|
||||
expect(entry.hooks[0].command).not.toMatch(
|
||||
/\$CLAUDE_PROJECT_DIR|\$env:CLAUDE_PROJECT_DIR|%CLAUDE_PROJECT_DIR%/,
|
||||
);
|
||||
expect(entry.hooks[0].command).not.toContain("permissionDecision:'allow'");
|
||||
});
|
||||
|
||||
test('required: hook allows with valid empty JSON when gstack is installed', () => {
|
||||
run(`${TEAM_INIT} required`, { cwd: tmpDir });
|
||||
const settings = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpDir, '.claude', 'settings.json'), 'utf-8'),
|
||||
);
|
||||
const command = settings.hooks.PreToolUse[0].hooks[0].command;
|
||||
const fakeHome = path.join(tmpDir, 'home');
|
||||
const nestedCwd = path.join(tmpDir, 'nested', 'working', 'directory');
|
||||
fs.mkdirSync(path.join(fakeHome, '.claude', 'skills', 'gstack', 'bin'), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.mkdirSync(nestedCwd, { recursive: true });
|
||||
|
||||
const result = runHook(command, {
|
||||
cwd: nestedCwd,
|
||||
env: {
|
||||
CLAUDE_PROJECT_DIR: tmpDir,
|
||||
HOME: fakeHome,
|
||||
USERPROFILE: path.join(tmpDir, 'unused-userprofile'),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
expect(JSON.parse(result.stdout)).toEqual({});
|
||||
});
|
||||
|
||||
test('required: missing project env denies through the platform default shell', () => {
|
||||
run(`${TEAM_INIT} required`, { cwd: tmpDir });
|
||||
const settings = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpDir, '.claude', 'settings.json'), 'utf-8'),
|
||||
);
|
||||
const command = settings.hooks.PreToolUse[0].hooks[0].command;
|
||||
|
||||
const result = runHook(command, {
|
||||
cwd: tmpDir,
|
||||
env: { CLAUDE_PROJECT_DIR: '' },
|
||||
});
|
||||
|
||||
expectStructuredDeny(result, MISSING_PROJECT_REASON);
|
||||
});
|
||||
|
||||
test('required: missing project env denies through PowerShell', () => {
|
||||
if (process.platform !== 'win32') return;
|
||||
|
||||
run(`${TEAM_INIT} required`, { cwd: tmpDir });
|
||||
const settings = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpDir, '.claude', 'settings.json'), 'utf-8'),
|
||||
);
|
||||
const command = settings.hooks.PreToolUse[0].hooks[0].command;
|
||||
|
||||
const result = runHookInPowerShell(command, {
|
||||
cwd: tmpDir,
|
||||
env: { CLAUDE_PROJECT_DIR: '' },
|
||||
});
|
||||
|
||||
expectStructuredDeny(result, MISSING_PROJECT_REASON);
|
||||
}, 30_000);
|
||||
|
||||
test('required: stale project paths deny through the platform default shell', () => {
|
||||
run(`${TEAM_INIT} required`, { cwd: tmpDir });
|
||||
const settings = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpDir, '.claude', 'settings.json'), 'utf-8'),
|
||||
);
|
||||
const command = settings.hooks.PreToolUse[0].hooks[0].command;
|
||||
const existingWithoutHook = path.join(tmpDir, 'existing-without-hook');
|
||||
fs.mkdirSync(existingWithoutHook);
|
||||
|
||||
for (const projectDir of [
|
||||
path.join(tmpDir, 'nonexistent-project'),
|
||||
existingWithoutHook,
|
||||
]) {
|
||||
const result = runHook(command, {
|
||||
cwd: tmpDir,
|
||||
env: { CLAUDE_PROJECT_DIR: projectDir },
|
||||
});
|
||||
|
||||
expectStructuredDeny(result, HOOK_LOAD_REASON);
|
||||
expect(result.stderr).not.toContain(projectDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('required: stale project paths deny through PowerShell', () => {
|
||||
if (process.platform !== 'win32') return;
|
||||
|
||||
run(`${TEAM_INIT} required`, { cwd: tmpDir });
|
||||
const settings = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpDir, '.claude', 'settings.json'), 'utf-8'),
|
||||
);
|
||||
const command = settings.hooks.PreToolUse[0].hooks[0].command;
|
||||
const existingWithoutHook = path.join(tmpDir, 'powershell-without-hook');
|
||||
fs.mkdirSync(existingWithoutHook);
|
||||
|
||||
for (const projectDir of [
|
||||
path.join(tmpDir, 'powershell-nonexistent'),
|
||||
existingWithoutHook,
|
||||
]) {
|
||||
const result = runHookInPowerShell(command, {
|
||||
cwd: tmpDir,
|
||||
env: { CLAUDE_PROJECT_DIR: projectDir },
|
||||
});
|
||||
|
||||
expectStructuredDeny(result, HOOK_LOAD_REASON);
|
||||
expect(result.stderr).not.toContain(projectDir);
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test('required: hook runs in PowerShell with USERPROFILE home fallback', () => {
|
||||
if (process.platform !== 'win32') return;
|
||||
|
||||
run(`${TEAM_INIT} required`, { cwd: tmpDir });
|
||||
const settings = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpDir, '.claude', 'settings.json'), 'utf-8'),
|
||||
);
|
||||
const command = settings.hooks.PreToolUse[0].hooks[0].command;
|
||||
const fakeHome = path.join(tmpDir, 'powershell-home');
|
||||
fs.mkdirSync(path.join(fakeHome, '.claude', 'skills', 'gstack', 'bin'), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
const result = runHookInPowerShell(command, {
|
||||
cwd: tmpDir,
|
||||
env: {
|
||||
CLAUDE_PROJECT_DIR: tmpDir,
|
||||
HOME: '',
|
||||
USERPROFILE: fakeHome,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
expect(JSON.parse(result.stdout)).toEqual({});
|
||||
}, 30_000);
|
||||
|
||||
test('required: missing gstack returns an intentional deny, not a hook error', () => {
|
||||
run(`${TEAM_INIT} required`, { cwd: tmpDir });
|
||||
const settings = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpDir, '.claude', 'settings.json'), 'utf-8'),
|
||||
);
|
||||
const command = settings.hooks.PreToolUse[0].hooks[0].command;
|
||||
const fakeHome = path.join(tmpDir, 'home-without-gstack');
|
||||
fs.mkdirSync(fakeHome, { recursive: true });
|
||||
|
||||
const result = runHook(command, {
|
||||
cwd: tmpDir,
|
||||
env: {
|
||||
CLAUDE_PROJECT_DIR: tmpDir,
|
||||
HOME: fakeHome,
|
||||
USERPROFILE: fakeHome,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stderr).toContain('BLOCKED: gstack is not installed globally.');
|
||||
expect(result.stderr).toContain('git clone --depth 1');
|
||||
const decision = JSON.parse(result.stdout);
|
||||
expect(decision).toEqual({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: expect.stringContaining(
|
||||
'Then restart your AI coding tool.',
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('required: rerun removes a legacy Bash hook without touching other hooks', () => {
|
||||
const hooksDir = path.join(tmpDir, '.claude', 'hooks');
|
||||
const legacyHook = path.join(hooksDir, 'check-gstack.sh');
|
||||
const unrelatedHook = path.join(hooksDir, 'check-project.cjs');
|
||||
const unrelatedHookContents = "console.log('project hook');\n";
|
||||
fs.mkdirSync(hooksDir, { recursive: true });
|
||||
fs.writeFileSync(legacyHook, '#!/bin/bash\n');
|
||||
fs.writeFileSync(unrelatedHook, unrelatedHookContents);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.claude', 'settings.json'),
|
||||
JSON.stringify({
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Skill',
|
||||
hooks: [{
|
||||
type: 'command',
|
||||
command: '"$CLAUDE_PROJECT_DIR/.claude/hooks/check-gstack.sh"',
|
||||
}],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
execSync('git add .claude', { cwd: tmpDir });
|
||||
execSync('git commit -m "add legacy hook"', { cwd: tmpDir });
|
||||
|
||||
const migration = run(`${TEAM_INIT} required`, { cwd: tmpDir });
|
||||
expect(fs.existsSync(legacyHook)).toBe(false);
|
||||
expect(fs.readFileSync(unrelatedHook, 'utf-8')).toBe(unrelatedHookContents);
|
||||
const suggestedGitAdd = migration.stdout
|
||||
.split('\n')
|
||||
.find(line => line.startsWith(' git add '))
|
||||
?.trim();
|
||||
if (!suggestedGitAdd) {
|
||||
throw new Error('gstack-team-init did not print a git add command');
|
||||
}
|
||||
expect(suggestedGitAdd.split(/\s+/)).toContain(
|
||||
'.claude/hooks/check-gstack.sh',
|
||||
);
|
||||
execSync(suggestedGitAdd, { cwd: tmpDir });
|
||||
execSync('git commit -m "migrate required hook"', { cwd: tmpDir });
|
||||
expect(
|
||||
execSync('git status --short', { cwd: tmpDir, encoding: 'utf-8' }),
|
||||
).toBe('');
|
||||
|
||||
const rerun = run(`${TEAM_INIT} required`, { cwd: tmpDir });
|
||||
const settings = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpDir, '.claude', 'settings.json'), 'utf-8'),
|
||||
);
|
||||
expect(fs.existsSync(legacyHook)).toBe(false);
|
||||
expect(fs.readFileSync(unrelatedHook, 'utf-8')).toBe(unrelatedHookContents);
|
||||
const rerunGitAdd = rerun.stdout
|
||||
.split('\n')
|
||||
.find(line => line.startsWith(' git add '))
|
||||
?.trim();
|
||||
if (!rerunGitAdd) {
|
||||
throw new Error('gstack-team-init rerun did not print a git add command');
|
||||
}
|
||||
expect(rerunGitAdd.split(/\s+/)).not.toContain(
|
||||
'.claude/hooks/check-gstack.sh',
|
||||
);
|
||||
execSync(rerunGitAdd, { cwd: tmpDir });
|
||||
expect(
|
||||
execSync('git status --short', { cwd: tmpDir, encoding: 'utf-8' }),
|
||||
).toBe('');
|
||||
expect(settings.hooks.PreToolUse).toHaveLength(1);
|
||||
expect(settings.hooks.PreToolUse[0].hooks).toHaveLength(1);
|
||||
expect(settings.hooks.PreToolUse[0].hooks[0].command).toContain(
|
||||
'check-gstack.cjs',
|
||||
);
|
||||
}, 30_000);
|
||||
|
||||
test('idempotent: running twice does not duplicate CLAUDE.md section', () => {
|
||||
run(`${TEAM_INIT} optional`, { cwd: tmpDir });
|
||||
run(`${TEAM_INIT} optional`, { cwd: tmpDir });
|
||||
|
|
@ -291,7 +622,11 @@ describe('gstack-team-init', () => {
|
|||
fs.mkdirSync(skillsDir, { recursive: true });
|
||||
const targetDir = mkTmpDir();
|
||||
fs.writeFileSync(path.join(targetDir, 'VERSION'), '0.14.0.0');
|
||||
fs.symlinkSync(targetDir, path.join(skillsDir, 'gstack'));
|
||||
fs.symlinkSync(
|
||||
targetDir,
|
||||
path.join(skillsDir, 'gstack'),
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
);
|
||||
|
||||
const result = run(`${TEAM_INIT} optional`, { cwd: tmpDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
|
@ -329,7 +664,7 @@ describe('setup --team / --no-team / -q', () => {
|
|||
test(
|
||||
'setup -q produces no stdout',
|
||||
() => {
|
||||
const result = run(`${path.join(ROOT, 'setup')} -q`, { cwd: ROOT });
|
||||
const result = run(`${bashCommand(path.join(ROOT, 'setup'))} -q`, { cwd: ROOT });
|
||||
// -q should suppress informational output (may still have some output from build)
|
||||
// The key test is that the "Skill naming:" prompt and "gstack ready" messages are suppressed
|
||||
expect(result.stdout).not.toContain('Skill naming:');
|
||||
|
|
@ -341,8 +676,7 @@ describe('setup --team / --no-team / -q', () => {
|
|||
test(
|
||||
'setup --local prints deprecation warning',
|
||||
() => {
|
||||
// stderr capture: run via bash redirect so we can capture stderr
|
||||
const result = run(`bash -c '${path.join(ROOT, 'setup')} --local -q 2>&1'`, { cwd: ROOT });
|
||||
const result = run(`${bashCommand(path.join(ROOT, 'setup'))} --local -q 2>&1`, { cwd: ROOT });
|
||||
expect(result.stdout).toContain('deprecated');
|
||||
},
|
||||
180_000,
|
||||
|
|
|
|||
Loading…
Reference in New Issue