v1.92.22.0 feat: Gemini CLI host, and older installs drop the dev toolchain (#3778)

* setup: explicit contributor marker; move pre-v1.91.231.0 installs to production deps (#3447)

./setup used node_modules/typescript as its contributor signal, so every
install made before v1.91.231.0 (a full bun install) kept ~520 MB of test and
eval packages forever. The contributor is now the clone bin/dev-setup marks
with git config gstack.contributor true, or GSTACK_CONTRIBUTOR=1. An unmarked
clone with the dev toolchain moves node_modules aside under a checkout lock,
installs production-only, verifies every dependency, and drops the backup; any
failure puts the full install back with SETUP_DEPS_MIGRATE_FAILED. CI and a
clone whose git config cannot be read never migrate. Bun cache entries for the
pinned Agent SDK are removed only when no other project links to them.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* hosts: add Gemini CLI as an experimental host (#1247)

./setup --host gemini (alias gemini-cli; --host auto when gemini is on PATH)
installs gstack-<skill> directories into ~/.gemini/skills, which Gemini CLI
0.66.0 lists with gemini skills list, plus the ~/.gemini/skills/gstack runtime
root. Renders go to .gemini-cli/ (gitignored) with name+description
frontmatter whose names match their directories, CLAUDE.md rewritten to
GEMINI.md, and a tool glossary for Gemini CLI's names (and Antigravity's, which
can read the same folder). gstack-uninstall removes only gstack-managed entries
there, so the user's own skills stay. Docs: ADDING_A_HOST.md verified facts,
hosts.md, uninstall.md, README.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (leave Bun's shared cache alone)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* changelog: Gemini CLI host and contributor marker (#1247, #3447)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: host lists name Copilot, Antigravity and Gemini CLI

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: the plan-mode re-render guard count includes the Gemini CLI host

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: troubleshooting's runtime-root guard host list names Gemini CLI

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-01 09:35:42 +00:00
1 parent c03f185d15
commit e4e4e42967
34 files changed
+910 -74

No files matched your search

+5
View File
@@ -29,6 +29,11 @@
"factory/gstack-review": 125920,
"gbrain/gstack-autoplan": 108748,
"gbrain/gstack-plan-eng-review": 124972,
"gemini/gstack-autoplan": 108899,
"gemini/gstack-plan-design-review": 109210,
"gemini/gstack-plan-devex-review": 114248,
"gemini/gstack-plan-eng-review": 143588,
"gemini/gstack-review": 103679,
"hermes/gstack-autoplan": 108751,
"hermes/gstack-plan-eng-review": 125008,
"kiro/gstack-autoplan": 108115,
+1 -1
View File
@@ -3693,7 +3693,7 @@ describe('setup script validation', () => {
expect(setupContent).toContain('--host');
// #2361: slate moved OUT of the install accept-list (it was accepted but
// never dispatched — a silent exit-0 no-op) into an informational arm.
expect(setupContent).toContain('claude|codex|kiro|factory|opencode|cursor|copilot|agy|openclaw|hermes|auto');
expect(setupContent).toContain('claude|codex|kiro|factory|opencode|cursor|copilot|agy|gemini|openclaw|hermes|auto');
expect(setupContent).toMatch(/^ {2}slate\)/m);
});
+52 -4
View File
@@ -24,6 +24,7 @@ import {
cursor,
openclaw,
agy,
gemini,
} from '../hosts/index';
import { HOST_PATHS } from '../scripts/resolvers/types';
import { RESOLVERS } from '../scripts/resolvers';
@@ -37,8 +38,8 @@ const RESOLVER_NAMES = new Set(Object.keys(RESOLVERS));
// ─── hosts/index.ts ─────────────────────────────────────────
describe('hosts/index.ts', () => {
test('ALL_HOST_CONFIGS has 12 hosts', () => {
expect(ALL_HOST_CONFIGS.length).toBe(12);
test('ALL_HOST_CONFIGS has 13 hosts', () => {
expect(ALL_HOST_CONFIGS.length).toBe(13);
});
test('ALL_HOST_NAMES matches config names', () => {
@@ -61,6 +62,7 @@ describe('hosts/index.ts', () => {
expect(cursor.name).toBe('cursor');
expect(openclaw.name).toBe('openclaw');
expect(agy.name).toBe('agy');
expect(gemini.name).toBe('gemini');
});
test('getHostConfig returns correct config', () => {
@@ -83,6 +85,7 @@ describe('hosts/index.ts', () => {
expect(resolveHostArg('agents')).toBe('codex');
expect(resolveHostArg('droid')).toBe('factory');
expect(resolveHostArg('antigravity')).toBe('agy');
expect(resolveHostArg('gemini-cli')).toBe('gemini');
});
test('resolveHostArg throws on unknown alias', () => {
@@ -514,10 +517,11 @@ describe('golden-file regression', () => {
// ─── Individual host config correctness ─────────────────────
describe('host config correctness', () => {
test('Codex renders the GPT overlay, Antigravity the Gemini overlay, every other host the Claude overlay', () => {
test('Codex renders the GPT overlay, Antigravity and Gemini CLI the Gemini overlay, every other host the Claude overlay', () => {
expect(codex.defaultModel).toBe('gpt');
expect(agy.defaultModel).toBe('gemini');
for (const host of ALL_HOST_CONFIGS.filter(h => h.name !== 'codex' && h.name !== 'agy')) {
expect(gemini.defaultModel).toBe('gemini');
for (const host of ALL_HOST_CONFIGS.filter(h => !['codex', 'agy', 'gemini'].includes(h.name))) {
expect(host.defaultModel).toBe('claude');
}
});
@@ -811,6 +815,50 @@ describe('Antigravity CLI host render', () => {
});
});
describe('Gemini CLI host render', () => {
const OUT = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gemini-'));
beforeAll(() => {
const r = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'gemini', '--out-dir', OUT], { cwd: ROOT, timeout: 120_000 });
if (r.exitCode !== 0) throw new Error(r.stderr.toString());
});
afterAll(() => fs.rmSync(OUT, { recursive: true, force: true }));
const dir = path.join(OUT, '.gemini-cli', 'skills');
const read = (skill: string) => fs.readFileSync(path.join(dir, skill, 'SKILL.md'), 'utf8');
test('paths name the Gemini CLI install, never Claude, Codex or Antigravity skill dirs', () => {
for (const skill of ['gstack-review', 'gstack-ship', 'gstack-careful', 'gstack-upgrade']) {
const md = readSkillStartup(path.join(dir, skill, 'SKILL.md'));
expect(md).not.toContain('~/.claude/skills/gstack');
expect(md).not.toContain('$HOME/.claude/skills/gstack');
expect(md).not.toContain('.codex/skills');
expect(md).not.toContain('antigravity-cli/skills');
}
expect(read('gstack-review')).toContain('_r=~/.gemini/skills/gstack');
expect(read('gstack-upgrade')).toContain('$HOME/.gemini/skills/gstack/.source-path');
expect(read('gstack-upgrade')).toContain('./setup --host gemini --refresh-registered');
});
test('the tool-name glossary names Gemini CLI 0.66.0 tools and Antigravity\'s, ahead of the preamble STATUS rules', () => {
const md = read('gstack-review');
const glossary = md.indexOf('**Gemini CLI tool names:**');
expect(glossary).toBeGreaterThan(-1);
expect(glossary).toBeLessThan(md.indexOf('Read the echoed `KEY: value` STATUS lines'));
const line = md.slice(glossary, md.indexOf('\n', glossary));
expectTokens(line, ['`ask_user`', '`run_shell_command`', '`read_file`', '`write_file`', '`replace`', '`grep_search`', '`glob`', '`invoke_agent`', '`exit_plan_mode`', '`activate_skill`']);
expectTokens(line, ['Antigravity', '`run_command`', '`view_file`', '`ask_question`']);
});
test('frontmatter keeps only name and description, and every name equals its directory', () => {
const names = fs.readdirSync(dir).filter(d => fs.existsSync(path.join(dir, d, 'SKILL.md')));
expect(names.length).toBeGreaterThan(20);
for (const name of names) {
const fm = read(name).split('\n---\n')[0];
expect(fm.match(/^name:\s*(\S+)/m)![1]).toBe(name);
expect(fm.split('\n').filter(l => /^[a-z-]+:/.test(l)).map(l => l.split(':')[0])).toEqual(['name', 'description']);
}
});
});
describe('host renders name the host\'s own tools and identities (#2626, #2015, #2825, #2338)', () => {
const OUT = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-host-tools-'));
beforeAll(() => {
+3 -2
View File
@@ -31,9 +31,10 @@ const DISCOVERY: Record<string, string> = {
cursor: '.cursor/skills',
copilot: '.copilot/skills',
agy: '.gemini/antigravity-cli/skills',
gemini: '.gemini/skills',
};
/** Every directory any host (or a shared agents layout) reads; .gemini/skills is Gemini CLI's. */
const ALL_DISCOVERY_DIRS = [...Object.values(DISCOVERY), '.agents/skills', '.copilot/skills', '.hermes/skills', '.gemini/skills'];
/** Every directory any host (or a shared agents layout) reads. */
const ALL_DISCOVERY_DIRS = [...Object.values(DISCOVERY), '.agents/skills', '.hermes/skills'];
/** Harness hosts (install.registration 'harness') register through gstack-harness: test/harness-register.test.ts certifies them. */
const installable = ALL_HOST_CONFIGS.filter(c => c.tier !== 'instruction-only' && c.install.registration !== 'harness').map(c => c.name);
+2 -2
View File
@@ -17,7 +17,7 @@ import { runGeneration } from '../scripts/gen-skill-docs';
import { cleanupFixtures, cleanupSeed, makeFixture, makeSource, put, runSetup, type Fixture } from './helpers/install-fixture';
const ROOT = path.resolve(import.meta.dir, '..');
const OTHER_DIRS = ['.agents', '.factory', '.kiro', '.opencode', '.cursor', '.copilot', '.agy', '.slate', '.openclaw', '.hermes', '.gbrain'];
const OTHER_DIRS = ['.agents', '.factory', '.kiro', '.opencode', '.cursor', '.copilot', '.agy', '.gemini-cli', '.slate', '.openclaw', '.hermes', '.gbrain'];
afterEach(cleanupFixtures);
afterAll(cleanupSeed);
@@ -68,7 +68,7 @@ describe.skipIf(process.platform === 'win32')('setup prunes unrecorded host rend
expect(fs.existsSync(path.join(f.dir, 'outside'))).toBe(true);
expect(fs.existsSync(path.join(src, '.cursor/skills/gstack/bin'))).toBe(false);
expect(fs.existsSync(path.join(src, 'bin/gstack-config'))).toBe(true);
for (const dir of ['.agents', '.opencode', '.copilot', '.agy', '.slate', '.openclaw', '.hermes', '.gbrain']) expect(fs.existsSync(path.join(src, dir)), dir).toBe(false);
for (const dir of ['.agents', '.opencode', '.copilot', '.agy', '.gemini-cli', '.slate', '.openclaw', '.hermes', '.gbrain']) expect(fs.existsSync(path.join(src, dir)), dir).toBe(false);
expect(r.stdout).toContain('kept .factory/notes.md: not proven generated (no gstack banner or byte match)');
expect(r.stdout).toContain('kept .kiro/skills/my-skill/SKILL.md: not proven generated');
+1 -1
View File
@@ -49,7 +49,7 @@ describe('every installed-skill render carries the plan-mode arg', () => {
test('setup: a build (which renders without the arg) does not skip the host re-renders when the key is off', () => {
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf8');
const guards = setup.split('\n').filter(line => /^if \[ "\$INSTALL_[A-Z]+" -eq 1 \] && .*NEEDS_BUILD/.test(line));
expect(guards.length).toBe(5);
expect(guards.length).toBe(6);
for (const line of guards) expect(line, line).toContain('{ [ "$NEEDS_BUILD" -eq 0 ] || [ -n "$_PLAN_MODE_ARG" ]; }');
});
+1
View File
@@ -37,6 +37,7 @@ const ROOT_BUILDERS: Record<string, string> = {
cursor: 'create_cursor_runtime_root',
copilot: 'create_copilot_runtime_root',
agy: 'create_agy_runtime_root',
gemini: 'create_gemini_runtime_root',
};
// Deferred, with the reason recorded in TODOS.md: /ship's measure loop runs
+90 -3
View File
@@ -191,20 +191,25 @@ describe.skipIf(process.platform === 'win32')('Antigravity CLI shares ~/.gemini
skill: readFileSync(join(home, '.gemini/skills/my-skill/SKILL.md'), 'utf8'),
});
test.skipIf(Bun.which('agy') !== null)('auto-detect ignores a Gemini-CLI-only ~/.gemini and selects agy by its own config dir', () => {
// A machine with gemini or agy installed must not change what auto selects.
const pinnedPath = (f: { commands: string }) => ({
PATH: [f.commands, ...(process.env.PATH ?? '').split(':').filter(d => d && !['gemini', 'agy'].some(bin => existsSync(join(d, bin))))].join(':'),
});
test('auto-detect ignores a Gemini-CLI-only ~/.gemini and selects agy by its own config dir', () => {
const f = makeFixture();
const src = makeSource(f, join(f.home, 'gstack'));
put(join(f.home, '.gemini/settings.json'), '{"theme":"mine"}\n');
put(join(f.home, '.gemini/skills/my-skill/SKILL.md'), '---\nname: my-skill\ndescription: mine\n---\n');
const before = geminiCliFiles(f.home);
const geminiOnly = runSetup(f, join(src, 'setup'), ['--host', 'auto']);
const geminiOnly = runSetup(f, join(src, 'setup'), ['--host', 'auto'], { env: pinnedPath(f) });
expect(geminiOnly.status, geminiOnly.stdout + geminiOnly.stderr).toBe(0);
expect(existsSync(join(f.home, '.gemini/antigravity-cli'))).toBe(false);
expect(registryRows(f).map(row => row[0])).not.toContain('agy');
mkdirSync(join(f.home, '.gemini/antigravity-cli'), { recursive: true });
const withAgy = runSetup(f, join(src, 'setup'), ['--host', 'auto']);
const withAgy = runSetup(f, join(src, 'setup'), ['--host', 'auto'], { env: pinnedPath(f) });
expect(withAgy.status, withAgy.stdout + withAgy.stderr).toBe(0);
expect(existsSync(join(f.home, '.gemini/antigravity-cli/skills/gstack-review/SKILL.md'))).toBe(true);
expect(registryRows(f).map(row => row[0])).toContain('agy');
@@ -236,6 +241,88 @@ describe.skipIf(process.platform === 'win32')('Antigravity CLI shares ~/.gemini
expect(geminiCliFiles(f.home)).toEqual(before);
expect(registryRows(f)).toEqual([]);
}, 120_000);
test('auto selects Gemini CLI only for gemini on PATH, never for a bare ~/.gemini', () => {
const f = makeFixture();
const src = makeSource(f, join(f.home, 'gstack'));
put(join(f.home, '.gemini/settings.json'), '{"theme":"mine"}\n');
put(join(f.home, '.gemini/skills/my-skill/SKILL.md'), '---\nname: my-skill\ndescription: mine\n---\n');
const before = geminiCliFiles(f.home);
const bare = runSetup(f, join(src, 'setup'), ['--host', 'auto'], { env: pinnedPath(f) });
expect(bare.status, bare.stdout + bare.stderr).toBe(0);
expect(registryRows(f).map(row => row[0])).not.toContain('gemini');
expect(readdirSync(join(f.home, '.gemini/skills'))).toEqual(['my-skill']);
put(join(f.commands, 'gemini'), '#!/bin/sh\nexit 0\n', 0o755);
const onPath = runSetup(f, join(src, 'setup'), ['--host', 'auto'], { env: pinnedPath(f) });
expect(onPath.status, onPath.stdout + onPath.stderr).toBe(0);
expect(registryRows(f).filter(row => row[0] === 'gemini').map(row => row[3])).toEqual([join(f.home, '.gemini/skills')]);
expect(existsSync(join(f.home, '.gemini/skills/gstack-review/SKILL.md'))).toBe(true);
expect(existsSync(join(f.home, '.gemini/antigravity-cli'))).toBe(false);
expect(geminiCliFiles(f.home)).toEqual(before);
}, 120_000);
test('--host gemini-cli (alias) writes only gstack entries in ~/.gemini/skills; uninstall removes only those', () => {
const f = makeFixture();
const src = makeSource(f, join(f.home, 'gstack'));
put(join(f.home, '.gemini/settings.json'), '{"theme":"mine"}\n');
put(join(f.home, '.gemini/skills/my-skill/SKILL.md'), '---\nname: my-skill\ndescription: mine\n---\n');
put(join(f.home, '.gemini/skills/gstack-notes/SKILL.md'), '---\nname: gstack-notes\ndescription: my notes\n---\n');
put(join(f.home, '.gemini/antigravity-cli/settings.json'), '{"agentMode":"plan"}\n');
const before = { ...geminiCliFiles(f.home), notes: readFileSync(join(f.home, '.gemini/skills/gstack-notes/SKILL.md'), 'utf8') };
const agyBefore = tree(join(f.home, '.gemini/antigravity-cli'));
const r = runSetup(f, join(src, 'setup'), ['--host', 'gemini-cli']);
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stdout).toContain('gstack ready (gemini)');
expect(registryRows(f).map(row => [row[0], row[3]])).toEqual([['gemini', join(f.home, '.gemini/skills')]]);
expect(readFileSync(join(f.home, '.gemini/skills/gstack/.source-path'), 'utf8').trim()).toBe(src);
expect(readFileSync(join(src, '.gstack-installed-hosts'), 'utf8').split('\n')).toContain('gemini');
expect(existsSync(join(src, '.gemini-cli/skills/gstack-review/SKILL.md'))).toBe(true);
const entries = readdirSync(join(f.home, '.gemini/skills'));
expect(entries.filter(n => !n.startsWith('gstack'))).toEqual(['my-skill']);
expect(entries).toContain('gstack-review');
expect({ ...geminiCliFiles(f.home), notes: readFileSync(join(f.home, '.gemini/skills/gstack-notes/SKILL.md'), 'utf8') }).toEqual(before);
expect(tree(join(f.home, '.gemini/antigravity-cli'))).toEqual(agyBefore);
const un = spawnSync('bash', [join(src, 'bin/gstack-uninstall'), '--force', '--keep-state'], { cwd: f.home, env: f.env, encoding: 'utf8', timeout: 30_000 });
expect(un.status, un.stderr).toBe(0);
expect(readdirSync(join(f.home, '.gemini/skills')).sort()).toEqual(['gstack-notes', 'my-skill']);
expect({ ...geminiCliFiles(f.home), notes: readFileSync(join(f.home, '.gemini/skills/gstack-notes/SKILL.md'), 'utf8') }).toEqual(before);
expect(tree(join(f.home, '.gemini/antigravity-cli'))).toEqual(agyBefore);
expect(registryRows(f)).toEqual([]);
}, 120_000);
test('--local --host gemini is refused with the trusted-folder note and writes no skills', () => {
const f = makeFixture();
const src = makeSource(f, join(f.home, 'gstack'));
const r = runSetup(f, join(src, 'setup'), ['--local', '--host', 'gemini']);
expect(r.status).toBe(1);
expect(r.stderr).toContain('Error: --local is only supported for Claude Code (not gemini).');
expect(r.stderr).toContain('Gemini CLI loads workspace skills (.gemini/skills) only in trusted folders (/trust)');
expect(existsSync(join(f.home, '.gemini'))).toBe(false);
expect(existsSync(join(f.home, '.claude/skills'))).toBe(false);
expect(r.stdout).not.toContain('gstack ready');
}, 60_000);
test('gemini and agy installed together: each arm writes only its own directory', () => {
const f = makeFixture();
const src = makeSource(f, join(f.home, 'gstack'));
expect(runSetup(f, join(src, 'setup'), ['--host', 'gemini']).status).toBe(0);
const geminiTree = tree(join(f.home, '.gemini/skills'));
const agy = runSetup(f, join(src, 'setup'), ['--host', 'agy']);
expect(agy.status, agy.stdout + agy.stderr).toBe(0);
expect(tree(join(f.home, '.gemini/skills'))).toEqual(geminiTree);
const agyTree = tree(join(f.home, '.gemini/antigravity-cli'));
const again = runSetup(f, join(src, 'setup'), ['--host', 'gemini']);
expect(again.status, again.stdout + again.stderr).toBe(0);
expect(tree(join(f.home, '.gemini/antigravity-cli'))).toEqual(agyTree);
expect(registryRows(f).map(row => [row[0], row[3]]).sort()).toEqual([
['agy', join(f.home, '.gemini/antigravity-cli/skills')], ['gemini', join(f.home, '.gemini/skills')]]);
expect(readFileSync(join(f.home, '.gemini/skills/gstack-review/SKILL.md'), 'utf8')).toContain('**Gemini CLI tool names:**');
expect(readFileSync(join(f.home, '.gemini/antigravity-cli/skills/gstack-review/SKILL.md'), 'utf8')).toContain('**Antigravity tool names:**');
}, 180_000);
});
describe('install registry helper', () => {
+280 -14
View File
@@ -40,6 +40,23 @@ function sliceFunction(source: string, name: string): string {
return source.slice(start + 1, end + 3);
}
/** install_runtime_deps and the helpers it calls, sliced out of setup in definition order. */
function depsFunctions(setup: string): string {
const names = ['bun_cmd', '_deps_role', '_deps_has_dev', '_deps_verified', '_deps_lock', '_deps_unlock', '_deps_recover',
'_deps_migration_start', '_deps_migration_finish', 'install_runtime_deps'];
return names.map((n) => (n === '_deps_unlock' ? setup.split('\n').find((l) => l.startsWith('_deps_unlock() {'))! + '\n' : sliceFunction(setup, n))).join('\n');
}
/** A git repository marked as a contributor's checkout (what bin/dev-setup writes). */
function markContributor(dir: string): void {
gitInit(dir);
spawnSync('git', ['-C', dir, 'config', '--local', 'gstack.contributor', 'true'], { timeout: 10_000 });
}
function gitInit(dir: string): void {
spawnSync('git', ['init', '-q', dir], { timeout: 10_000 });
}
/**
* A bun that records each argv line. Like Bun, a --frozen-lockfile install fails
* ($FROZEN_RC) until `install --lockfile-only` has refreshed the lockfile; any other
@@ -56,7 +73,7 @@ function stubBun(base: string): { bun: string; calls: () => string[] } {
describe.skipIf(process.platform === 'win32')('setup installs production dependencies only', () => {
const setup = read('setup');
const script = (bun: string, call: string) =>
`set -e\nBUN_CMD=${JSON.stringify(bun)}\n${sliceFunction(setup, 'bun_cmd')}\n${sliceFunction(setup, 'install_runtime_deps')}\n${call}\n`;
`set -e\nBUN_CMD=${JSON.stringify(bun)}\n${depsFunctions(setup)}\n${call}\n`;
test('install_runtime_deps passes --production with the frozen lockfile, and refreshes a stale lockfile before installing production-only again', () => {
const base = tempBase();
@@ -81,17 +98,20 @@ describe.skipIf(process.platform === 'win32')('setup installs production depende
expect(stub3.calls()).toEqual(['install --production --frozen-lockfile', 'install --lockfile-only']);
});
// Value: protects=a contributor checkout (dev toolchain present) keeps its dev packages at bun.lock's versions after ./setup;
// Value: protects=a contributor checkout (marked, dev toolchain present) keeps its dev packages at bun.lock's versions after ./setup;
// fails_when=setup runs the production install there, which Bun reports as "no changes" and leaves old dev versions;
// why_new=every other row starts without node_modules; seam=none; no_claim=Bun's own update behavior (stub bun)
test('a checkout with the dev toolchain gets the full frozen install, falling back to a plain install', () => {
// #3447: the contributor is the checkout marked with git config gstack.contributor, not any checkout that has typescript.
test('a marked contributor checkout gets the full frozen install, falling back to a plain install', () => {
const base = tempBase();
markContributor(base);
fs.mkdirSync(path.join(base, 'node_modules', 'typescript'), { recursive: true });
const { bun, calls } = stubBun(base);
expect(runBashScript(script(bun, 'install_runtime_deps'), { cwd: base, env: { ...process.env, FROZEN_RC: '0' } }).status).toBe(0);
expect(calls()).toEqual(['install --frozen-lockfile']);
const base2 = tempBase();
markContributor(base2);
fs.mkdirSync(path.join(base2, 'node_modules', 'typescript'), { recursive: true });
const stub2 = stubBun(base2);
expect(runBashScript(script(stub2.bun, 'install_runtime_deps'), { cwd: base2, env: { ...process.env, FROZEN_RC: '1' } }).status).toBe(0);
@@ -103,6 +123,7 @@ describe.skipIf(process.platform === 'win32')('setup installs production depende
// why_new=both rows above succeed, and the failure row in the test above runs the production branch; seam=none;
// no_claim=setup's own subshell wiring around the call (stub bun)
const base3 = tempBase();
markContributor(base3);
fs.mkdirSync(path.join(base3, 'node_modules', 'typescript'), { recursive: true });
const stub3 = stubBun(base3);
const failed = runBashScript(script(stub3.bun, 'install_runtime_deps\necho AFTER'), { cwd: base3, env: { ...process.env, FROZEN_RC: '1', FALLBACK_RC: '4' } });
@@ -150,6 +171,25 @@ describe.skipIf(process.platform === 'win32')('setup installs production depende
const r = runBashScript(`set -e\nREPO_ROOT=${JSON.stringify(base)}\nbun() { ${JSON.stringify(bun)} "$@"; }\n${block}`);
expect(r.status).toBe(0);
expect(calls()).toEqual(['install']);
// #3447: outside git the marker cannot be written; dev-setup says how to mark the run instead and still installs.
expect(r.stderr).toContain('GSTACK_CONTRIBUTOR=1');
});
// Value: protects=a contributor's clone keeps its dev toolchain on every later ./setup;
// fails_when=bin/dev-setup stops writing the marker install_runtime_deps reads; why_new=#3447 adds the marker; seam=none; no_claim=none
test('bin/dev-setup marks the clone as a contributor checkout', () => {
const devSetup = read('bin/dev-setup');
const start = devSetup.indexOf('# 2. Install dependencies');
const block = devSetup.slice(start, devSetup.indexOf('# 3.', start));
const base = tempBase();
gitInit(base);
const { bun } = stubBun(base);
const r = runBashScript(`set -e\nREPO_ROOT=${JSON.stringify(base)}\nbun() { ${JSON.stringify(bun)} "$@"; }\n${block}`, { env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' } });
expect(r.status, r.stderr).toBe(0);
const marker = spawnSync('git', ['-C', base, 'config', '--bool', '--get', 'gstack.contributor'], { encoding: 'utf-8', timeout: 10_000 });
expect(marker.stdout.trim()).toBe('true');
const role = runBashScript(`${sliceFunction(read('setup'), '_deps_role')}\n_deps_role\n`, { cwd: base, env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' } });
expect(role.stdout.trim()).toBe('contributor');
});
});
@@ -174,7 +214,7 @@ describe.skipIf(process.platform === 'win32')('install_runtime_deps with the rea
const env = { ...isolatedBunEnv(base), BUN_INSTALL_CACHE_DIR: path.join(base, 'cache') };
const setup = read('setup');
const run = (call: string) => runBashScript(
`set -e\nBUN_CMD=${JSON.stringify(process.execPath)}\n${sliceFunction(setup, 'bun_cmd')}\n${sliceFunction(setup, 'install_runtime_deps')}\n${call}\n`,
`set -e\nBUN_CMD=${JSON.stringify(process.execPath)}\n${depsFunctions(setup)}\n${call}\n`,
{ cwd: project, env, timeout: 90_000 });
const has = (name: string) => fs.existsSync(path.join(project, 'node_modules', name, 'package.json'));
@@ -214,7 +254,7 @@ describe.skipIf(process.platform === 'win32')('install_runtime_deps with the rea
const env = { ...isolatedBunEnv(base), BUN_INSTALL_CACHE_DIR: path.join(base, 'cache') };
const setup = read('setup');
const run = (call: string) => runBashScript(
`set -e\nBUN_CMD=${JSON.stringify(process.execPath)}\n${sliceFunction(setup, 'bun_cmd')}\n${sliceFunction(setup, 'install_runtime_deps')}\n${call}\n`,
`set -e\nBUN_CMD=${JSON.stringify(process.execPath)}\n${depsFunctions(setup)}\n${call}\n`,
{ cwd: project, env, timeout: 90_000 });
const has = (name: string) => fs.existsSync(path.join(project, 'node_modules', name, 'package.json'));
@@ -229,6 +269,230 @@ describe.skipIf(process.platform === 'win32')('install_runtime_deps with the rea
});
});
// #3447: installs made before v1.91.231.0 ran a full bun install, so they carry the dev
// toolchain, and the old rule ("node_modules/typescript means contributor") kept them on it
// forever. The contributor is now the checkout marked with git config gstack.contributor
// (bin/dev-setup) or GSTACK_CONTRIBUTOR=1; every other checkout with dev packages migrates once.
describe.skipIf(process.platform === 'win32')('install_runtime_deps moves older installs to production dependencies', () => {
const setup = read('setup');
const SDK = '@anthropic-ai/claude-agent-sdk';
/** A user checkout as an install made before v1.91.231.0 leaves it: a git clone with the full dev toolchain. */
function oldInstall(opts: { git?: boolean } = {}): string {
const base = tempBase();
if (opts.git !== false) gitInit(base);
fs.writeFileSync(path.join(base, 'package.json'), JSON.stringify({ name: 'fixture', dependencies: { 'runtime-pkg': '1.0.0' } }, null, 2) + '\n');
fs.writeFileSync(path.join(base, 'bun.lock'), `{\n "packages": {\n "${SDK}": ["${SDK}@0.2.141", "", {}],\n }\n}\n`);
for (const dir of ['typescript', SDK, 'runtime-pkg']) {
fs.mkdirSync(path.join(base, 'node_modules', dir), { recursive: true });
fs.writeFileSync(path.join(base, 'node_modules', dir, 'package.json'), '{}');
}
return base;
}
/**
* A bun for the migration rows: records argv; a --production install recreates node_modules with
* runtime-pkg unless PROD_RC is nonzero (or PROD_SKIP_DEPS=1 leaves it out); `pm cache` prints
* $CACHE_DIR; any other install exits 0.
*/
function migrateBun(base: string): { bun: string; calls: () => string[] } {
const log = path.join(base, '.calls');
const bun = path.join(base, '.bun-stub');
fs.writeFileSync(bun, `#!/usr/bin/env bash
echo "$*" >> ${JSON.stringify(log)}
case "$*" in
"pm cache") echo "\${CACHE_DIR:-}"; exit 0 ;;
*--production*)
[ "\${PROD_RC:-0}" -ne 0 ] && { echo "error: network down" >&2; exit "\${PROD_RC}"; }
mkdir -p node_modules
[ "\${PROD_SKIP_DEPS:-0}" = 1 ] || { mkdir -p node_modules/runtime-pkg && echo '{}' > node_modules/runtime-pkg/package.json; }
exit 0 ;;
esac
exit 0
`, { mode: 0o755 });
return { bun, calls: () => (fs.existsSync(log) ? fs.readFileSync(log, 'utf-8').trim().split('\n').filter((l) => l !== 'pm cache') : []) };
}
const run = (base: string, bun: string, env: Record<string, string> = {}) => {
const clean = Object.fromEntries(Object.entries(process.env).filter(([k]) => k !== 'CI' && k !== 'GSTACK_CONTRIBUTOR'));
return runBashScript(`set -e\nBUN_CMD=${JSON.stringify(bun)}\n${depsFunctions(setup)}\ninstall_runtime_deps\n`,
{ cwd: base, env: { ...clean, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', ...env } });
};
const has = (base: string, dir: string) => fs.existsSync(path.join(base, 'node_modules', dir));
const leftovers = (base: string) => ['node_modules.gstack-migrate', 'node_modules.gstack-migrate.lock'].filter((p) => fs.existsSync(path.join(base, p)));
// Value: protects=an install made before v1.91.231.0 drops its ~520 MB of dev packages on the next ./setup;
// fails_when=setup keeps treating node_modules/typescript as a contributor marker (the pre-#3447 rule);
// why_new=no row ran an unmarked checkout that already has the dev toolchain; seam=none; no_claim=real Bun (stub bun)
test('an unmarked checkout with the dev toolchain migrates to the production install once', () => {
const base = oldInstall();
const { bun, calls } = migrateBun(base);
const r = run(base, bun);
expect(r.status, r.stderr).toBe(0);
expect(calls()).toEqual(['install --production --frozen-lockfile']);
expect([has(base, 'typescript'), has(base, SDK), has(base, 'runtime-pkg')]).toEqual([false, false, true]);
expect(leftovers(base)).toEqual([]);
expect(r.stdout).toContain('gstack: moved this install to production dependencies');
expect(r.stdout).toContain('git config gstack.contributor true, then ./setup again');
const again = run(base, bun);
expect(again.status, again.stderr).toBe(0);
expect(again.stdout).not.toContain('moved this install');
expect(calls()).toEqual(['install --production --frozen-lockfile', 'install --production --frozen-lockfile']);
});
test('a contributor, a CI run and a checkout whose role git cannot read keep the dev toolchain', () => {
const marked = oldInstall();
spawnSync('git', ['-C', marked, 'config', '--local', 'gstack.contributor', 'true'], { timeout: 10_000 });
const envOnly = oldInstall();
const ci = oldInstall();
const unreadable = oldInstall();
const fakeGit = path.join(unreadable, '.fake-bin');
fs.mkdirSync(fakeGit);
fs.writeFileSync(path.join(fakeGit, 'git'), '#!/usr/bin/env bash\necho "fatal: detected dubious ownership" >&2\nexit 128\n', { mode: 0o755 });
for (const [base, env] of [[marked, {}], [envOnly, { GSTACK_CONTRIBUTOR: '1' }], [ci, { CI: 'true' }], [unreadable, { PATH: `${fakeGit}:${process.env.PATH}` }]] as const) {
const { bun, calls } = migrateBun(base);
const r = run(base, bun, env);
expect(r.status, r.stderr).toBe(0);
expect(calls()).toEqual(['install --frozen-lockfile']);
expect([has(base, 'typescript'), has(base, SDK)]).toEqual([true, true]);
expect(leftovers(base)).toEqual([]);
}
const marker = spawnSync('git', ['-C', marked, 'config', '--bool', 'gstack.contributor', 'false'], { timeout: 10_000 });
expect(marker.status).toBe(0);
const { bun } = migrateBun(marked);
expect(run(marked, bun).status).toBe(0);
expect(has(marked, 'typescript')).toBe(false);
});
test('a fresh CI checkout still gets the production install', () => {
const base = tempBase();
gitInit(base);
fs.writeFileSync(path.join(base, 'package.json'), JSON.stringify({ name: 'fixture', dependencies: { 'runtime-pkg': '1.0.0' } }, null, 2) + '\n');
const { bun, calls } = migrateBun(base);
expect(run(base, bun, { CI: 'true' }).status).toBe(0);
expect(calls()).toEqual(['install --production --frozen-lockfile']);
});
// Value: protects=an offline upgrade never leaves a user without node_modules;
// fails_when=the migration deletes the old node_modules before the production install is proven, or swallows the failure;
// why_new=the success row cannot fail the install; seam=none; no_claim=real network failures (stub bun)
test('a failed or unverified production install puts the full install back and says why', () => {
for (const env of [{ PROD_RC: '7' }, { PROD_SKIP_DEPS: '1' }] as Record<string, string>[]) {
const base = oldInstall();
const { bun, calls } = migrateBun(base);
const r = run(base, bun, env);
expect(r.status, r.stderr).toBe(0);
expect([has(base, 'typescript'), has(base, SDK), has(base, 'runtime-pkg')]).toEqual([true, true, true]);
expect(leftovers(base)).toEqual([]);
expect(r.stderr).toContain('SETUP_DEPS_MIGRATE_FAILED');
expect(r.stderr).toContain('Nothing was removed');
expect(calls().at(-1)).toBe('install --frozen-lockfile');
}
const offline = oldInstall();
expect(run(offline, migrateBun(offline).bun, { PROD_RC: '7' }).stderr).toContain('error: network down');
});
test('a backup left by an interrupted migration is recovered on the next run', () => {
const moved = oldInstall();
fs.renameSync(path.join(moved, 'node_modules'), path.join(moved, 'node_modules.gstack-migrate'));
const stub = migrateBun(moved);
expect(run(moved, stub.bun).status).toBe(0);
expect([has(moved, 'typescript'), has(moved, 'runtime-pkg')]).toEqual([false, true]);
expect(leftovers(moved)).toEqual([]);
const finished = oldInstall();
fs.renameSync(path.join(finished, 'node_modules'), path.join(finished, 'node_modules.gstack-migrate'));
fs.mkdirSync(path.join(finished, 'node_modules', 'runtime-pkg'), { recursive: true });
fs.writeFileSync(path.join(finished, 'node_modules', 'runtime-pkg', 'package.json'), '{}');
expect(run(finished, migrateBun(finished).bun).status).toBe(0);
expect(has(finished, 'typescript')).toBe(false);
expect(leftovers(finished)).toEqual([]);
});
test('a live migration lock skips the migration; a dead holder is reclaimed', () => {
const busy = oldInstall();
fs.mkdirSync(path.join(busy, 'node_modules.gstack-migrate.lock'));
fs.writeFileSync(path.join(busy, 'node_modules.gstack-migrate.lock', 'pid'), String(process.pid));
const b = migrateBun(busy);
expect(run(busy, b.bun).status).toBe(0);
expect(b.calls()).toEqual(['install --frozen-lockfile']);
expect(has(busy, 'typescript')).toBe(true);
const stale = oldInstall();
fs.mkdirSync(path.join(stale, 'node_modules.gstack-migrate.lock'));
fs.writeFileSync(path.join(stale, 'node_modules.gstack-migrate.lock', 'pid'), '2147483646');
expect(run(stale, migrateBun(stale).bun).status).toBe(0);
expect(has(stale, 'typescript')).toBe(false);
expect(leftovers(stale)).toEqual([]);
});
// Value: protects=another project's install that reads the Agent SDK out of Bun's shared cache (Bun's symlink
// backend links each file there with no extra hardlink) keeps working after a gstack migration;
// fails_when=the migration deletes Bun cache entries; why_new=a review reproduced a symlink-backend consumer
// losing its package.json when cache entries with no extra hardlinks were deleted; seam=none; no_claim=Bun's cache layout
test('the migration never touches Bun\'s shared cache', () => {
const base = oldInstall();
const cache = path.join(base, '.cache');
const entry = path.join(cache, '@anthropic-ai', 'claude-agent-sdk-linux-x64@0.2.141@@@1');
fs.mkdirSync(entry, { recursive: true });
fs.writeFileSync(path.join(entry, 'cli.js'), 'x');
const consumer = path.join(base, '.symlink-project', 'node_modules', 'sdk');
fs.mkdirSync(consumer, { recursive: true });
fs.symlinkSync(path.join(entry, 'cli.js'), path.join(consumer, 'cli.js'));
const { bun, calls } = migrateBun(base);
const r = run(base, bun, { CACHE_DIR: cache });
expect(r.status, r.stderr).toBe(0);
expect(has(base, 'typescript')).toBe(false);
expect(calls()).toEqual(['install --production --frozen-lockfile']);
expect(fs.readFileSync(path.join(consumer, 'cli.js'), 'utf-8')).toBe('x');
});
test('a checkout outside git with the dev toolchain is a user install', () => {
const base = oldInstall({ git: false });
expect(run(base, migrateBun(base).bun).status).toBe(0);
expect(has(base, 'typescript')).toBe(false);
});
});
// Value: protects=with the real Bun, an older full install loses its dev packages and a marked contributor keeps them;
// fails_when=the production install after the move resolves differently from a fresh one, or the marker read breaks;
// why_new=the migration rows stub bun; seam=none; no_claim=registry packages (local file: packages; offline)
describe.skipIf(process.platform === 'win32')('the migration with the real Bun', () => {
test('an unmarked full install loses its dev packages; a marked one keeps them across two runs', () => {
for (const marked of [false, true]) {
const base = tempBase();
const project = path.join(base, 'project');
for (const name of ['runtime-pkg', 'typescript']) {
fs.mkdirSync(path.join(project, 'pkgs', name), { recursive: true });
fs.writeFileSync(path.join(project, 'pkgs', name, 'package.json'), JSON.stringify({ name, version: '1.0.0' }));
}
fs.writeFileSync(path.join(project, 'package.json'), JSON.stringify({
name: 'migrate-fixture', version: '0.0.0',
dependencies: { 'runtime-pkg': 'file:./pkgs/runtime-pkg' },
devDependencies: { typescript: 'file:./pkgs/typescript' },
}, null, 2) + '\n');
gitInit(project);
if (marked) spawnSync('git', ['-C', project, 'config', '--local', 'gstack.contributor', 'true'], { timeout: 10_000 });
const env = { ...isolatedBunEnv(base), BUN_INSTALL_CACHE_DIR: path.join(base, 'cache'), GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' };
delete (env as Record<string, string | undefined>).CI;
delete (env as Record<string, string | undefined>).GSTACK_CONTRIBUTOR;
const setup = read('setup');
const runIt = (call: string) => runBashScript(`set -e\nBUN_CMD=${JSON.stringify(process.execPath)}\n${depsFunctions(setup)}\n${call}\n`, { cwd: project, env, timeout: 90_000 });
const has = (name: string) => fs.existsSync(path.join(project, 'node_modules', name, 'package.json'));
const full = runIt('bun_cmd install');
expect(full.status, full.stderr).toBe(0);
expect(has('typescript')).toBe(true);
for (let i = 0; i < 2; i++) {
const r = runIt('install_runtime_deps');
expect(r.status, r.stderr).toBe(0);
expect([has('runtime-pkg'), has('typescript')]).toEqual([true, marked]);
}
expect(fs.existsSync(path.join(project, 'node_modules.gstack-migrate'))).toBe(false);
}
});
});
/** Bare package names a source file loads (import/require/dynamic import), type-only imports excluded. */
function importedPackages(text: string): string[] {
const out = new Set<string>();
@@ -339,14 +603,14 @@ describe('setup and runtime need only production dependencies', () => {
});
// Generated by /ship Step 7 coverage audit (GSTA-97).
// Value: protects=a production-only install never creates the dev-toolchain marker install_runtime_deps reads, so a user's next ./setup stays production-only;
// Value: protects=a production-only install never creates a dev-toolchain package _deps_has_dev reads, so a user's next ./setup stays production-only;
// fails_when=a production dependency, or a package it pulls in (required peers included), starts depending on the marker package, so every repeat ./setup goes full;
// why_new=the real-Bun rows use local fixture packages, and nothing walks the repo's own bun.lock production closure;
// seam=none; no_claim=Bun's actual hoisting or platform filtering (a static, name-based walk of bun.lock that counts every candidate entry)
test('the production closure in bun.lock never installs the dev-toolchain marker setup checks', () => {
const marker = sliceFunction(read('setup'), 'install_runtime_deps').match(/\[ -d node_modules\/([^\s\]]+) \]/)?.[1];
expect(marker).toBeDefined();
expect(PKG.devDependencies).toHaveProperty([marker!]);
test('the production closure in bun.lock never installs the dev-toolchain packages setup checks', () => {
const markers = [...sliceFunction(read('setup'), '_deps_has_dev').matchAll(/\[ -d node_modules\/([^\s\]]+) \]/g)].map((m) => m[1]);
expect(markers).toEqual(['typescript', '@anthropic-ai/claude-agent-sdk']);
for (const marker of markers) expect(PKG.devDependencies).toHaveProperty([marker]);
type Lock = { workspaces: Record<string, { dependencies?: Record<string, string> }>; packages: Record<string, unknown[]> };
const closure = (lock: Lock): Set<string> => {
const seen = new Set<string>();
@@ -369,14 +633,16 @@ describe('setup and runtime need only production dependencies', () => {
const lock: Lock = JSON.parse(read('bun.lock').replace(/,(\s*[}\]])/g, '$1'));
const production = closure(lock);
expect(production.has('playwright-core')).toBe(true);
expect(production.has(marker!)).toBe(false);
// Negative controls: a required peer on the marker is caught, through a nested entry too; an optional peer is not.
for (const marker of markers) expect(production.has(marker), marker).toBe(false);
// Negative controls: a required peer on a marker is caught, through a nested entry too; an optional peer is not.
const synthetic = (meta: Record<string, unknown>): Lock => ({
workspaces: { '': { dependencies: { a: '1' } } },
packages: { a: ['a@1', '', { dependencies: { b: '1' } }, 'sha'], b: ['b@1', '', {}, 'sha'], 'a/b': ['b@2', '', meta, 'sha'] },
});
expect(closure(synthetic({ peerDependencies: { [marker!]: '*' } })).has(marker!)).toBe(true);
expect(closure(synthetic({ peerDependencies: { [marker!]: '*' }, optionalPeers: [marker!] })).has(marker!)).toBe(false);
for (const marker of markers) {
expect(closure(synthetic({ peerDependencies: { [marker]: '*' } })).has(marker)).toBe(true);
expect(closure(synthetic({ peerDependencies: { [marker]: '*' }, optionalPeers: [marker] })).has(marker)).toBe(false);
}
});
test('the dev toolchain stays out of the user install', () => {
+2 -2
View File
@@ -55,7 +55,7 @@ describe.skipIf(process.platform === 'win32')('setup never replaces a runtime ro
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) {
['copilot', '.copilot/skills/gstack'], ['agy', '.gemini/antigravity-cli/skills/gstack'], ['gemini', '.gemini/skills/gstack']] as const) {
const f = makeFixture();
const src = makeSource(f, join(f.home, root, 'checkout'));
const before = tree(join(f.home, root));
@@ -72,7 +72,7 @@ describe.skipIf(process.platform === 'win32')('setup never replaces a runtime ro
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']);
expect([...new Set(arms)].sort()).toEqual(['agy', 'codex', 'copilot', 'cursor', 'factory', 'gemini', 'opencode']);
});
test('control: the canonical ~/.claude/skills/gstack install and a repeat ./setup succeed', () => {
+40
View File
@@ -204,6 +204,46 @@ describe('gstack-uninstall', () => {
expect(fs.existsSync(path.join(mockGitRoot, '.cursor', 'rules', 'keep.md'))).toBe(true);
});
// Value: protects=uninstall clears gstack's Gemini CLI skills from ~/.gemini/skills, a folder the user's own skills share;
// fails_when=the Gemini block is dropped, or loses the provenance gate and sweeps a user's gstack-prefixed skill;
// why_new=#1247 adds the Gemini CLI host; seam=none; no_claim=Gemini CLI itself (temp HOME fixture)
test('--force removes Gemini CLI gstack skills and leaves the user\'s own', () => {
const banner = '<!-- AUTO-GENERATED from SKILL.md.tmpl - DO NOT EDIT DIRECTLY -->\n# x\n';
const skills = path.join(mockHome, '.gemini', 'skills');
const render = path.join(tmpDir, 'render', 'gstack-review');
fs.mkdirSync(render, { recursive: true });
fs.writeFileSync(path.join(render, 'SKILL.md'), banner);
fs.mkdirSync(path.join(skills, 'gstack'), { recursive: true });
fs.writeFileSync(path.join(skills, 'gstack', 'SKILL.md'), banner);
fs.symlinkSync(render, path.join(skills, 'gstack-review'));
for (const [name, body] of [['gstack-my-notes', '# mine\n'], ['frontend-design', 'keep']]) {
fs.mkdirSync(path.join(skills, name), { recursive: true });
fs.writeFileSync(path.join(skills, name, 'SKILL.md'), body);
}
fs.writeFileSync(path.join(mockHome, '.gemini', 'settings.json'), '{}');
const result = spawnSync('bash', [UNINSTALL, '--force'], {
stdio: 'pipe',
timeout: 30_000,
env: {
...process.env,
HOME: mockHome,
GSTACK_DIR: path.join(mockHome, '.claude', 'skills', 'gstack'),
GSTACK_STATE_DIR: path.join(mockHome, '.gstack'),
},
cwd: mockGitRoot,
});
expect(result.status).toBe(0);
expect(fs.existsSync(path.join(skills, 'gstack'))).toBe(false);
expect(fs.existsSync(path.join(skills, 'gstack-review'))).toBe(false);
expect(fs.existsSync(path.join(render, 'SKILL.md'))).toBe(true);
expect(fs.existsSync(path.join(skills, 'gstack-my-notes', 'SKILL.md'))).toBe(true);
expect(fs.existsSync(path.join(skills, 'frontend-design', 'SKILL.md'))).toBe(true);
expect(fs.existsSync(path.join(mockHome, '.gemini', 'settings.json'))).toBe(true);
expect(result.stderr.toString()).toContain('gstack-my-notes');
});
test("a user's own gstack-prefixed Cursor dir (no banner) survives and is listed", () => {
// S5: the bare gstack* glob must not sweep a dir that merely starts
// with "gstack" — provenance comes from the generated banner, and a