mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 23:14:33 +01:00
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:
1 parent
c03f185d15
commit
e4e4e42967
34 files changed
+910
-74
No files matched your search
+5
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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" ]; }');
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user