mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
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>
166 lines
9.4 KiB
TypeScript
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 });
|
|
}
|
|
});
|
|
});
|