diff --git a/README.md b/README.md index 4bb177c3a7..121f42099c 100644 --- a/README.md +++ b/README.md @@ -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). +Your project stays small, and everyone gets the same up-to-date version of gstack. If gstack is missing or its safety check cannot run, Claude Code and Copilot stop and explain what to do. If everything is ready, the check stays out of the way and your usual permission questions still appear. Updates happen quietly when a Claude Code session starts, at most once an hour. Swap `required` for `optional` if you'd rather nudge teammates than block them. diff --git a/bin/gstack-team-init b/bin/gstack-team-init index 256735f8b4..6157bad9c6 100755 --- a/bin/gstack-team-init +++ b/bin/gstack-team-init @@ -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,64 @@ 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. - -if [ ! -d "$HOME/.claude/skills/gstack/bin" ]; then - cat >&2 <<'MSG' -BLOCKED: gstack is not installed globally. - -gstack is required for AI-assisted work in this repo. + cat > "$HOOKS_DIR/check-gstack.cjs" << 'HOOK_EOF' +'use strict'; + +// Block skill usage when gstack is not installed globally. +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +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); + const decision = { + permissionDecision: 'deny', + permissionDecisionReason: instructions, + }; + process.stdout.write(`${JSON.stringify({ + ...decision, + hookSpecificOutput: { + hookEventName: 'PreToolUse', + ...decision, + }, + })}\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 +183,34 @@ 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')) - ); - - if (!exists) { + const hookMatcher = 'Skill|skill'; + const hookCommand = \"node -e \\\"const deny=reason=>{const decision={permissionDecision:'deny',permissionDecisionReason:reason};console.error(reason);console.log(JSON.stringify({...decision,hookSpecificOutput:{hookEventName:'PreToolUse',...decision}}))};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 (!['Skill', hookMatcher].includes(entry.matcher) || !entry.hooks) return true; + + 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.matcher = hookMatcher; + entry.hooks.push({ type: 'command', command: hookCommand }); + found = true; + } + return entry.hooks.length > 0; + }); + + if (!found) { settings.hooks.PreToolUse.push({ - matcher: 'Skill', + matcher: hookMatcher, hooks: [{ type: 'command', - command: '\"\$CLAUDE_PROJECT_DIR/.claude/hooks/check-gstack.sh\"' + command: hookCommand }] }); } @@ -171,6 +221,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 diff --git a/test/team-mode.test.ts b/test/team-mode.test.ts index ce8c1d6107..5a4715e2e4 100644 --- a/test/team-mode.test.ts +++ b/test/team-mode.test.ts @@ -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 } = 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,65 @@ function run(cmd: string, opts: { cwd?: string; env?: Record } = } } +function runHook( + command: string, + opts: { cwd: string; env: Record }, +): { 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 }, +): { 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({ + permissionDecision: 'deny', + permissionDecisionReason: reason, + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: reason, + }, + }); +} + describe('gstack-settings-hook', () => { let tmpDir: string; let settingsFile: string; @@ -227,29 +299,347 @@ 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|skill'); + expect(entry.hooks).toHaveLength(1); + expect(entry.hooks[0]).not.toHaveProperty('shell'); + expect(entry.hooks[0].command).toBe( + `node -e "const deny=reason=>{const decision={permissionDecision:'deny',permissionDecisionReason:reason};console.error(reason);console.log(JSON.stringify({...decision,hookSpecificOutput:{hookEventName:'PreToolUse',...decision}}))};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({ + permissionDecision: 'deny', + permissionDecisionReason: expect.stringContaining( + 'Then restart your AI coding tool.', + ), + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: expect.stringContaining( + 'Then restart your AI coding tool.', + ), + }, + }); }); + test('required: install verification errors return a structured deny', () => { + 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, 'unreadable-home'); + const preload = path.join(tmpDir, 'fail-gstack-stat.cjs'); + fs.mkdirSync(fakeHome, { recursive: true }); + fs.writeFileSync( + preload, + `'use strict'; +const fs = require('node:fs'); +const original = fs.statSync; +fs.statSync = function (target, ...args) { + if (String(target).replace(/\\\\/g, '/').includes('skills/gstack/bin')) { + const error = new Error('injected verification failure'); + error.code = 'EACCES'; + throw error; + } + return original.call(this, target, ...args); +}; +`, + ); + + const result = runHook(command, { + cwd: tmpDir, + env: { + CLAUDE_PROJECT_DIR: tmpDir, + HOME: fakeHome, + USERPROFILE: fakeHome, + NODE_OPTIONS: `--require=${preload}`, + }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain( + 'BLOCKED: the global gstack install could not be verified.', + ); + expect(result.stderr).not.toContain('injected verification failure'); + const decision = JSON.parse(result.stdout); + expect(decision.permissionDecision).toBe('deny'); + expect(decision.permissionDecisionReason).toContain( + 'BLOCKED: the global gstack install could not be verified.', + ); + expect(decision.hookSpecificOutput).toEqual({ + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: decision.permissionDecisionReason, + }); + }); + + 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].matcher).toBe('Skill|skill'); + 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 +681,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 +723,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 +735,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,