Files
gstack/test/setup-runtime-root-guard.test.ts
T
e6edff0265 v1.91.89.0 fix(windows,setup): Git Bash hook false-positive recovery, incoming-checker auto-update, setup never deletes its own checkout (Wave 9 Lane A0) (#3140)
Git Bash hands native Bun C:/ paths while gstack-hook-check looked them up as
/c/, so every TypeScript hook was refused and team-mode auto-update held every
release since 1.91.31. The checker maps Bun's paths back to the shell spelling,
the auto-updater lets the incoming release's own checker decide (installed
checker only as a fallback, hook-check-unavailable when neither verifies), and
docs/troubleshooting.md gives stuck installs one recovery command.
/gstack-upgrade and /spec guards use the --show-prefix root check, gstack-wtree
treats drive-letter --git-path answers as absolute, and setup refuses to
replace a runtime root that is or contains its own checkout, Kiro's in-place
root included (#3084), with --status suggesting the move. New sourced helper
bin/gstack-path-spelling.sh and TS twin lib/path-spelling.ts.

Co-authored-by: yuriysan <63948747+yuriysan@users.noreply.github.com>
Co-authored-by: y$un_ <3579304+szsunyuan@users.noreply.github.com>
Co-authored-by: Frederik Reventlow <267160539+dmfrede@users.noreply.github.com>
Co-authored-by: skleinbe-ny <226563731+skleinbe-ny@users.noreply.github.com>
Co-authored-by: bkermen <184220642+bkermen@users.noreply.github.com>
Co-authored-by: Drew Jankowski <3200063+andrewkjankowski@users.noreply.github.com>
2026-10-11 16:48:11 +00:00

166 lines
9.4 KiB
TypeScript

/**
* #3084 (data loss): with the gstack checkout at ~/.cursor/skills/gstack and
* ~/.claude/skills/gstack a symlink to it, `./setup --host cursor` swapped the
* Cursor runtime root in over the checkout and deleted it, and `--status` had
* suggested exactly that command. Setup now refuses to replace or remove a
* runtime root that is, or contains, the running checkout (before any write,
* and again in _activate_runtime_root, which covers all six host arms), and
* `--status` suggests moving the checkout instead. Positive controls: the
* canonical Claude install, a repeat ./setup, and the Codex direct-install
* migration still succeed.
*/
import { afterAll, afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { cleanupFixtures, cleanupSeed, fixtureMkdirSync, makeFixture, makeSource, registryRows, runSetup, tree } from './helpers/install-fixture';
afterEach(cleanupFixtures);
afterAll(cleanupSeed);
const ANCHOR = 'docs/troubleshooting.md#setup-runtime-root-contains-checkout';
describe.skipIf(process.platform === 'win32')('setup never replaces a runtime root that holds the running checkout (#3084)', () => {
function cursorLayout() {
const f = makeFixture();
const src = makeSource(f, join(f.home, '.cursor/skills/gstack'));
const claudeLink = join(f.home, '.claude/skills/gstack');
fixtureMkdirSync(join(f.home, '.claude/skills'), { recursive: true });
symlinkSync(src, claudeLink, 'dir');
return { f, src: realpathSync(src), claudeLink };
}
test('the issue layout: --status suggests the move, and ./setup --host cursor is refused with the checkout byte-identical', () => {
const { f, src, claudeLink } = cursorLayout();
const move = `rm ${claudeLink} && mv ${src} ${claudeLink} && cd ${claudeLink} && ./setup --host claude && ./setup --host cursor`;
const status = runSetup(f, join(src, 'setup'), ['--status']);
expect(status.stdout).toContain(move);
expect(status.stdout).toContain(ANCHOR);
expect(status.stdout).not.toContain(`register it: cd ${src} && ./setup --host cursor`);
const before = tree(src);
const r = runSetup(f, join(src, 'setup'), ['--host', 'cursor']);
expect(r.status, r.stdout + r.stderr).toBe(1);
expect(r.stderr).toContain(`gstack setup: refusing to replace ${src} for cursor: this runtime directory contains the source checkout setup runs from`);
expect(r.stderr).toContain('Nothing was changed.');
expect(r.stderr).toContain(` ${move}`);
expect(r.stderr).toContain(`https://github.com/garrytan/gstack/blob/main/${ANCHOR}`);
expect(r.stderr).not.toContain('previous install was left in place');
expect(tree(src)).toEqual(before);
expect(lstatSync(claudeLink).isSymbolicLink()).toBe(true);
expect(registryRows(f).filter(row => row[0] === 'cursor')).toEqual([]);
}, 90_000);
test('a checkout nested inside a host runtime root is refused for every arm that writes a root', () => {
for (const [host, root] of [['kiro', '.kiro/skills/gstack'], ['factory', '.factory/skills/gstack'], ['opencode', '.config/opencode/skills/gstack'],
['copilot', '.copilot/skills/gstack'], ['agy', '.gemini/antigravity-cli/skills/gstack']] as const) {
const f = makeFixture();
const src = makeSource(f, join(f.home, root, 'checkout'));
const before = tree(join(f.home, root));
const r = runSetup(f, join(src, 'setup'), ['--host', host]);
expect(r.status, `${host}: ${r.stdout}${r.stderr}`).toBe(1);
expect(r.stderr).toContain(`refusing to replace ${realpathSync(join(f.home, root))} for ${host}`);
expect(r.stderr).toContain(`mv ${realpathSync(src)} ~/gstack && cd ~/gstack && ./setup --host ${host}`);
expect(tree(join(f.home, root))).toEqual(before);
}
}, 240_000);
test('the guard sits in _activate_runtime_root, which every host arm that swaps a root calls', () => {
const setup = readFileSync(join(import.meta.dir, '..', 'setup'), 'utf8');
const fn = setup.slice(setup.indexOf('_activate_runtime_root() {'));
expect(fn.slice(0, fn.indexOf('\n}\n'))).toMatch(/_runtime_root_refused "\$\{_SETUP_ARM%%\t\*\}" "\$root" && return 1/);
const arms = [...setup.matchAll(/_activate_runtime_root create_(\w+)_runtime_root/g)].map(m => m[1]);
expect([...new Set(arms)].sort()).toEqual(['agy', 'codex', 'copilot', 'cursor', 'factory', 'opencode']);
});
test('control: the canonical ~/.claude/skills/gstack install and a repeat ./setup succeed', () => {
const f = makeFixture();
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
for (let run = 0; run < 2; run++) {
const r = runSetup(f, join(src, 'setup'), ['--host', 'claude', '--no-team']);
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stderr).not.toContain('refusing to replace');
}
expect(existsSync(join(src, 'setup'))).toBe(true);
expect(registryRows(f).some(row => row[0] === 'claude')).toBe(true);
}, 120_000);
// @szsunyuan (#3128): only the link goes, so a symlinked root is replaced.
test('control: a runtime root that is a symlink to the checkout is replaced, and the checkout survives', () => {
const f = makeFixture();
const src = makeSource(f, join(f.dir, 'gstack'));
fixtureMkdirSync(join(f.home, '.cursor/skills'), { recursive: true });
symlinkSync(src, join(f.home, '.cursor/skills/gstack'), 'dir');
const r = runSetup(f, join(src, 'setup'), ['--host', 'cursor']);
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stderr).not.toContain('refusing to replace');
expect(lstatSync(join(f.home, '.cursor/skills/gstack')).isSymbolicLink()).toBe(false);
expect(existsSync(join(f.home, '.cursor/skills/gstack/SKILL.md'))).toBe(true);
for (const file of ['setup', 'VERSION', 'bin/gstack-hook-check']) expect(existsSync(join(src, file))).toBe(true);
}, 120_000);
test('a Kiro checkout at ~/.kiro/skills/gstack is refused, not linked into itself', () => {
const f = makeFixture();
const src = makeSource(f, join(f.home, '.kiro/skills/gstack'));
const before = tree(src);
const r = runSetup(f, join(src, 'setup'), ['--host', 'kiro']);
expect(r.status, r.stdout + r.stderr).toBe(1);
expect(r.stderr).toContain(`refusing to replace ${realpathSync(src)} for kiro`);
expect(tree(src)).toEqual(before);
}, 120_000);
test('control: a Cursor install from a checkout elsewhere replaces its runtime root, twice', () => {
const f = makeFixture();
const src = makeSource(f, join(f.dir, 'gstack'));
for (let run = 0; run < 2; run++) {
const r = runSetup(f, join(src, 'setup'), ['--host', 'cursor']);
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stderr).not.toContain('refusing to replace');
}
expect(existsSync(join(f.home, '.cursor/skills/gstack/SKILL.md'))).toBe(true);
expect(existsSync(join(src, 'setup'))).toBe(true);
}, 120_000);
test('control: the Codex direct-install migration still moves ~/.codex/skills/gstack to ~/.gstack/repos/gstack', () => {
const f = makeFixture();
const src = makeSource(f, join(f.home, '.codex/skills/gstack'));
const r = runSetup(f, join(src, 'setup'), ['--host', 'codex']);
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stdout + r.stderr).toContain('Migrating direct Codex install');
expect(r.stderr).not.toContain('refusing to replace');
expect(existsSync(join(f.home, '.gstack/repos/gstack/setup'))).toBe(true);
expect(existsSync(join(f.home, '.codex/skills/gstack/SKILL.md'))).toBe(true);
}, 120_000);
test('the failure summary says "previous install was left in place" only after verifying it', () => {
const setup = readFileSync(join(import.meta.dir, '..', 'setup'), 'utf8');
const helpers = setup.slice(setup.indexOf('_SETUP_ROWS=""'), setup.indexOf('# Aside (aside.com'));
const base = realpathSync(mkdtempSync(join(tmpdir(), 'setup-summary-')));
try {
mkdirSync(join(base, 'src'));
writeFileSync(join(base, 'src', 'setup'), '');
mkdirSync(join(base, 'skills', 'gstack'), { recursive: true });
const summary = (src: string, root: string, refused = '') => spawnSync('bash', ['-c', [
'QUIET=0 SETUP_VERSION=1.0 _DISABLED_SKILLS=""',
'gstack_install_registry_render() { cat; }',
helpers,
`SOURCE_GSTACK_DIR='${src}'`,
`_setup_arm_begin cursor global '${join(base, 'skills')}'`,
`_SETUP_ARM_ROOT='${root}' _SETUP_REFUSED='${refused}'`,
'_setup_exit_summary 1',
].join('\n')], { encoding: 'utf8', timeout: 10_000 }).stderr;
expect(summary(join(base, 'src'), join(base, 'skills', 'gstack'))).toContain('its previous install was left in place. Retry: cd ');
const gone = summary(join(base, 'gone'), join(base, 'skills', 'gstack'));
expect(gone).not.toContain('left in place');
expect(gone).toContain('its install could not be verified');
expect(gone).toContain('git clone --depth 1 https://github.com/garrytan/gstack.git ~/gstack && cd ~/gstack && ./setup --host cursor');
expect(summary(join(base, 'src'), join(base, 'skills', 'missing-root'))).toContain('could not be verified');
expect(summary(join(base, 'src'), join(base, 'skills', 'gstack'), 'refused: moved')).toContain('refused: moved');
} finally {
rmSync(base, { recursive: true, force: true });
}
});
});