/** * capy/skill/gstack/: the vendored Capy skill. scripts/install.sh is the * frozen bootstrapper the Drive copy holds: it prints its wrapper revision * first, learns the supported minimum from origin's default branch * (`git show FETCH_HEAD:capy/support.env`), fast-forwards a clean, unpinned * checkout below that minimum in install mode only (--check never moves the * checkout), and execs the checkout's own full wrapper, bin/gstack-capy-session, * handing over the held install lock (GSTACK_CAPY_LOCK_HELD=1). Pinned on the * machine lines (`gstack-capy-skill: wrapper|upgraded|owner action`, the * `gstack:` trailer as the last line), exit codes and HEAD before/after. * Fixture: a bare remote whose main moves from A (1.0.0, no capy/ directory: * an old checkout) to B (1.0.1, capy/support.env minimum 1.0.1, the vendored * scripts and a stub bin/gstack-capy-install that logs argv and env), a * depth-1 file:// clone at A, on the simulated Capy host. */ import { afterEach, describe, expect, test } from 'bun:test'; import { spawnSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import { RESULT_CODES } from '../lib/result-codes'; import { capyHost, type CapyHost } from './helpers/capy-host'; const REPO = path.resolve(import.meta.dir, '..'); const SKILL = path.join(REPO, 'capy/skill/gstack'); const BOOT = path.join(SKILL, 'scripts/install.sh'); const hosts: CapyHost[] = []; afterEach(() => { for (const h of hosts.splice(0)) h.cleanup(); }); const write = (file: string, text: string, mode = 0o644) => { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, text, { mode }); }; const envValue = (text: string, key: string) => text.match(new RegExp(`^${key}=([0-9.]+)`, 'm'))?.[1] ?? ''; const below = (a: string, b: string) => { const x = a.split('.').map(Number), y = b.split('.').map(Number); for (let i = 0; i < Math.max(x.length, y.length); i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) < (y[i] ?? 0); return false; }; const STUB_INSTALLER = [ '#!/usr/bin/env bash', 'echo "installer $* lock=${GSTACK_CAPY_LOCK_HELD:-} rev=${GSTACK_CAPY_WRAPPER_REV:-} min=${GSTACK_CAPY_REMOTE_MIN:-} from=${GSTACK_CAPY_UPGRADED_FROM:-}" >> "$WRAP_LOG"', 'cd "$(dirname "$0")/.."', 'if [ "${1:-}" != --check ] && [ -f "$WRAP_FAIL_ONCE" ]; then rm -f "$WRAP_FAIL_ONCE"; echo "gstack: fail claude"; exit 1; fi', 'echo "GSTACK_LAUNCH: GSTACK_SESSION_KIND=unattended GSTACK_CAPY=1"', 'echo "gstack: ok $(cat VERSION) for=autoplan"', ].join('\n') + '\n'; function fixture(opts: { minWrapper?: number } = {}) { const host = capyHost({ systemBun: '1.4.2' }); hosts.push(host); const git = (args: string[], cwd: string) => { const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: host.env, timeout: 30_000 }); if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`); return r.stdout.trim(); }; const log = path.join(host.base, 'wrap.log'); const seed = path.join(host.base, 'seed'); const bare = path.join(host.base, 'remote.git'); fs.mkdirSync(seed, { recursive: true }); git(['init', '-q', '-b', 'main'], seed); write(path.join(seed, 'VERSION'), '1.0.0\n'); write(path.join(seed, 'README.md'), 'old gstack\n'); write(path.join(seed, 'setup'), '#!/usr/bin/env bash\nexit 0\n', 0o755); git(['add', '-A'], seed); git(['commit', '-qm', 'A'], seed); const a = git(['rev-parse', 'HEAD'], seed); git(['init', '-q', '--bare', '-b', 'main', bare], host.base); git(['push', '-q', bare, 'main'], seed); const root = path.join(host.base, 'gstack'); git(['clone', '-q', '--single-branch', '--depth', '1', `file://${bare}`, root], host.base); write(path.join(seed, 'VERSION'), '1.0.1\n'); write(path.join(seed, 'capy/support.env'), `GSTACK_MIN_REVISION=1.0.1\nGSTACK_CAPY_MIN_WRAPPER=${opts.minWrapper ?? 1}\n`); for (const rel of ['capy/skill/gstack/scripts/install.sh', 'capy/skill/gstack/SKILL.md', 'bin/gstack-capy-session', 'bin/gstack-install-verdict.sh']) { if (fs.existsSync(path.join(REPO, rel))) write(path.join(seed, rel), fs.readFileSync(path.join(REPO, rel), 'utf8'), 0o755); } write(path.join(seed, 'bin/gstack-capy-install'), STUB_INSTALLER, 0o755); git(['add', '-A'], seed); git(['commit', '-qm', 'B'], seed); const b = git(['rev-parse', 'HEAD'], seed); git(['push', '-q', bare, 'main'], seed); const env = { ...host.env, GSTACK_CAPY_ROOT: root, GSTACK_CAPY_REPO: `file://${bare}`, WRAP_LOG: log, WRAP_FAIL_ONCE: path.join(host.base, 'fail-once') }; const run = (args: string[] = [], extra: Record = {}) => { const r = spawnSync('bash', [BOOT, ...args], { cwd: host.home, encoding: 'utf8', env: { ...env, ...extra }, timeout: 120_000 }); const lines = r.stdout.trim().split('\n'); return { ...r, lines, last: lines.at(-1) ?? '' }; }; return { host, root, bare, a, b, run, git: (args: string[]) => git(args, root), calls: () => fs.existsSync(log) ? fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean) : [], }; } describe('capy skill bootstrapper (capy/skill/gstack/scripts/install.sh)', () => { test('the first line names the wrapper revision and path; bash -n passes for the bootstrapper and the full wrapper', () => { for (const file of [BOOT, path.join(REPO, 'bin/gstack-capy-session')]) { expect(fs.existsSync(file), file).toBe(true); const r = spawnSync('bash', ['-n', file], { encoding: 'utf8', timeout: 30_000 }); expect(r.status, `${file}: ${r.stderr}`).toBe(0); } const f = fixture(); const r = f.run(['--check', '--for', 'autoplan']); expect(r.lines[0]).toMatch(new RegExp(`^gstack-capy-skill: wrapper \\d+ \\(${BOOT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\)$`)); }); test('below the remote minimum: --check is read-only and names the fast-forwarding command; install fast-forwards, installs and ends ok; the next session start is ok without moving', () => { const f = fixture(); expect(f.git(['rev-parse', '--is-shallow-repository'])).toBe('true'); const check = f.run(['--check', '--for', 'autoplan']); expect(check.status, check.stdout + check.stderr).toBe(1); expect(check.last).toBe(`gstack: fail revision — installed 1.0.0 is below the supported minimum 1.0.1 (fast-forward available); fix: bash ${BOOT} --for autoplan (fast-forwards) (REVISION_BELOW_MINIMUM)`); expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a); expect(f.calls()).toEqual([]); const install = f.run(['--for', 'autoplan']); expect(install.status, install.stdout + install.stderr).toBe(0); expect(install.stdout).toContain('gstack-capy-skill: upgraded 1.0.0 -> 1.0.1'); expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); expect(f.git(['rev-parse', '--is-shallow-repository'])).toBe('true'); expect(install.last).toBe('gstack: ok 1.0.1 for=autoplan'); expect(f.calls()).toEqual([`installer --root ${f.root} --for autoplan lock=1 rev=1 min=1.0.1 from=1.0.0`]); const next = f.run(['--check', '--for', 'autoplan']); expect(next.status, next.stdout + next.stderr).toBe(0); expect(next.stdout).not.toContain('upgraded'); expect(next.last).toBe('gstack: ok 1.0.1 for=autoplan'); expect(f.calls().at(-1)).toBe(`installer --check --root ${f.root} --for autoplan lock= rev=1 min=1.0.1 from=`); expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); }); test('dirty, pinned, diverged or auto-upgrade disabled: no change, fail revision names the cause and the exact commands', () => { const f = fixture(); fs.writeFileSync(path.join(f.root, 'README.md'), 'local edit\n'); const dirty = f.run(['--for', 'autoplan']); expect(dirty.status).toBe(1); expect(dirty.last).toMatch(/^gstack: fail revision — installed 1\.0\.0 is below the supported minimum 1\.0\.1 \(tracked files changed: README\.md\); fix: .*git -C \S+ status --short.*\(REVISION_BELOW_MINIMUM\)$/); expect(fs.readFileSync(path.join(f.root, 'README.md'), 'utf8')).toBe('local edit\n'); expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a); f.git(['checkout', '-q', '--', 'README.md']); const pinned = f.run(['--for', 'autoplan', '--revision', f.a]); expect(pinned.status).toBe(1); expect(pinned.last).toContain('(pinned by --revision'); f.git(['checkout', '-q', '--detach', 'HEAD']); const detached = f.run(['--for', 'autoplan']); expect(detached.last).toContain('(pinned: HEAD is detached'); f.git(['checkout', '-q', 'main']); const disabled = f.run(['--for', 'autoplan'], { GSTACK_CAPY_AUTO_UPGRADE: '0' }); expect(disabled.status).toBe(1); expect(disabled.last).toContain('(auto-upgrade disabled)'); expect(disabled.last).toContain('GSTACK_CAPY_AUTO_UPGRADE'); fs.writeFileSync(path.join(f.root, 'README.md'), 'local commit\n'); f.git(['commit', '-qam', 'local']); const head = f.git(['rev-parse', 'HEAD']); const diverged = f.run(['--for', 'autoplan']); expect(diverged.status).toBe(1); expect(diverged.last).toContain('(diverged: '); expect(f.git(['rev-parse', 'HEAD'])).toBe(head); expect(f.git(['stash', 'list'])).toBe(''); expect(f.calls()).toEqual([]); }); test('an interrupted install after the fast-forward leaves the trailer fail; the next install run completes it', () => { const f = fixture(); write(path.join(f.host.base, 'fail-once'), '1'); const first = f.run(['--for', 'autoplan']); expect(first.status).toBe(1); expect(first.last).toBe('gstack: fail claude'); expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); const second = f.run(['--for', 'autoplan']); expect(second.status, second.stdout + second.stderr).toBe(0); expect(second.last).toBe('gstack: ok 1.0.1 for=autoplan'); }); test('offline: the remote minimum is unknown and said so; an old checkout is never ok', () => { const f = fixture(); fs.renameSync(f.bare, `${f.bare}.gone`); const old = f.run(['--check', '--for', 'autoplan']); expect(old.status).toBe(1); expect(old.stdout).toContain('remote minimum unknown (fetch failed)'); expect(old.last).toMatch(/^gstack: fail /); fs.renameSync(`${f.bare}.gone`, f.bare); expect(f.run(['--for', 'autoplan']).status).toBe(0); fs.renameSync(f.bare, `${f.bare}.gone`); const current = f.run(['--check', '--for', 'autoplan']); expect(current.stdout).toContain('remote minimum unknown (fetch failed)'); expect(f.calls().at(-1)).toContain('min=unknown'); }); test('a stale bootstrapper still runs the checkout wrapper and names the owner action', () => { const f = fixture({ minWrapper: 99 }); const r = f.run(['--for', 'autoplan']); expect(r.status, r.stdout + r.stderr).toBe(0); expect(r.stdout).toMatch(new RegExp(`^gstack-capy-skill: owner action — Drive copy is wrapper \\d+, minimum 99; copy ${f.root}/capy/skill/gstack/ \\(CAPY_WRAPPER_STALE\\)$`, 'm')); expect(r.last).toBe('gstack: ok 1.0.1 for=autoplan'); }); test('no checkout: --check records the not-installed trailer; install clones and runs the checkout wrapper', () => { const f = fixture(); fs.rmSync(f.root, { recursive: true, force: true }); const check = f.run(['--check', '--for', 'autoplan']); expect(check.status).toBe(1); expect(check.last).toBe(`gstack: fail not-installed — no gstack checkout at ${f.root}; fix: bash ${BOOT} --for autoplan (installs)`); const install = f.run(['--for', 'autoplan']); expect(install.status, install.stdout + install.stderr).toBe(0); expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b); expect(install.last).toBe('gstack: ok 1.0.1 for=autoplan'); }); }); describe('capy skill: vendored text and support file', () => { const support = fs.existsSync(path.join(REPO, 'capy/support.env')) ? fs.readFileSync(path.join(REPO, 'capy/support.env'), 'utf8') : ''; test('the wrapper revision meets the checkout minimum; GSTACK_MIN_REVISION <= VERSION', () => { const wrapper = Number(fs.readFileSync(BOOT, 'utf8').match(/^GSTACK_CAPY_WRAPPER=(\d+)$/m)?.[1] ?? 0); expect(wrapper).toBeGreaterThanOrEqual(Number(envValue(support, 'GSTACK_CAPY_MIN_WRAPPER') || 999)); const min = envValue(support, 'GSTACK_MIN_REVISION'); expect(min).toMatch(/^\d+\.\d+\.\d+\.\d+$/); expect(below(fs.readFileSync(path.join(REPO, 'VERSION'), 'utf8').trim(), min)).toBe(false); }); test('SKILL.md: frontmatter, line cap, the session protocol tokens, and every command it names exists', () => { const text = fs.readFileSync(path.join(SKILL, 'SKILL.md'), 'utf8'); expect(text).toMatch(/^---\nname: gstack\ndescription: .+\n---\n/); expect(text.split('\n').length).toBeLessThanOrEqual(90); for (const token of ['--check', 'GSTACK_LAUNCH', '^gstack: ok', 'gstack: fail not-installed', 'gstack-pregate sealed', 'autoplan/DRIVER.md', 'first report']) { expect(text, token).toContain(token); } const named = [...text.matchAll(/\b(?:bin\/)?(gstack-[a-z0-9-]+)\b/g)].map(m => m[1]).filter(n => n !== 'gstack-capy-skill'); const missing = [...new Set(named)].filter(n => !fs.existsSync(path.join(REPO, 'bin', n)) && n !== 'gstack-pregate'); expect(missing).toEqual([]); expect(fs.existsSync(path.join(REPO, 'bin/gstack-pregate'))).toBe(true); }); test('every (CODE) the install-side bash bins print resolves to a troubleshooting anchor', () => { const bins = ['bin/gstack-capy-install', 'bin/gstack-upgrade', 'bin/gstack-capy-session', 'bin/gstack-install-verdict.sh', 'capy/skill/gstack/scripts/install.sh']; const codes = new Set(); for (const rel of bins) for (const m of fs.readFileSync(path.join(REPO, rel), 'utf8').matchAll(/\(([A-Z][A-Z0-9]+_[A-Z0-9_]+)[;)]/g)) codes.add(m[1]); for (const m of fs.readFileSync(path.join(REPO, 'bin/gstack-install-verdict.sh'), 'utf8').matchAll(/code=([A-Z][A-Z0-9_]+)/g)) codes.add(m[1]); expect([...codes].sort()).toEqual(expect.arrayContaining(['CAPY_WRAPPER_STALE', 'INSTALL_UNVERIFIED', 'REVISION_BELOW_MINIMUM', 'RUNTIME_BELOW_MINIMUM', 'UPGRADE_FETCH_FAILED', 'UPGRADE_REFUSED'])); expect([...codes].filter(c => !(c in RESULT_CODES))).toEqual([]); }); });