Files
gstack/test/setup-windows-rerun-refresh.test.ts
T
2be9bd0660 feat(setup): wire --host cursor through the full install path
'./setup --host cursor' was accepted by the flag parser and then did
nothing: no INSTALL_CURSOR branch existed, so the script built binaries,
printed no 'ready' line, and installed zero skills — Cursor users had no
way to install gstack at all.

Full install slice, re-derived from PR #2547 by @szsunyuan onto the
current installers: generate .cursor/ skill docs (host config already
existed), create a minimal ~/.cursor/skills/gstack runtime root (root
SKILL.md + bin/lib/browse assets + review checklist pair + ETHOS.md +
supabase config — bin and lib travel together because bin scripts import
../lib), link the generated gstack-* skills, and plant the repo-local
.cursor/skills/gstack sidecar WITHOUT ever wiping the generated SKILL.md
files it shares a directory with (link-before-sidecar ordering keeps the
generation fallback alive). Auto mode detects Cursor via the cursor
binary or the ~/.cursor footprint. gstack-uninstall removes
~/.cursor/skills/gstack* and per-project .cursor/skills/gstack* — and
never rmdir's .cursor itself, where Cursor stores user rules.

Re-derivation deltas from the PR: the link guards carry the #2444
IS_WINDOWS bypass (re-runs refresh real-dir copies), lib/ and
supabase/config.sh ride along like every other runtime root, and the
hosts/cursor.ts sidecar field is omitted (HostConfig no longer carries
one — sidecar behavior lives in setup).

Fixes #1358

Co-authored-by: Yuan Sun <forrest.sun527@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 09:49:31 -07:00

196 lines
8.2 KiB
TypeScript

/**
* Windows re-run refresh (#2444).
*
* On Windows, _link_or_copy installs REAL directory copies (no Developer
* Mode symlinks). The skill-linking guards `[ -L "$target" ] || [ ! -e
* "$target" ]` in link_codex_skill_dirs / link_factory_skill_dirs /
* link_opencode_skill_dirs / create_agents_sidecar therefore skipped every
* re-run: `./setup --host codex` reported "gstack ready" but never refreshed
* an already-installed SKILL.md after `git pull`. The fix bypasses the guard
* when IS_WINDOWS=1 — _link_or_copy rm -rf's the destination first, so the
* copy refreshes in place.
*
* The behavior fixture drives the REAL link_codex_skill_dirs /
* create_agents_sidecar functions (extracted from setup) against a fake
* install tree; the static block pins the bypass at all five guard sites so
* factory/opencode can't silently regress.
*/
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const ROOT = path.resolve(import.meta.dir, '..');
const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
function extractFn(name: string): string {
const start = SETUP_SRC.indexOf(`${name}() {`);
const end = SETUP_SRC.indexOf('\n}\n', start);
if (start < 0 || end < 0) throw new Error(`Could not locate ${name}() in setup`);
return SETUP_SRC.slice(start, end + 2);
}
const WINDOWS_BYPASS = '[ "$IS_WINDOWS" -eq 1 ] || [ -L ';
describe('setup: Windows re-run refresh — static guard sites (#2444)', () => {
test('no install guard is missing the IS_WINDOWS bypass', () => {
// Every `[ -L ...] || [ ! -e ...]` refresh guard in setup must carry the
// bypass — a bare guard is a Windows re-run no-op waiting to happen.
const bareGuards = SETUP_SRC
.split('\n')
.filter((l) => /\[ -L "\$[A-Za-z_/${}.]+" \] \|\| \[ ! -e /.test(l) && !l.includes('IS_WINDOWS'));
expect(bareGuards).toEqual([]);
expect(SETUP_SRC.split(WINDOWS_BYPASS).length - 1).toBeGreaterThanOrEqual(5);
});
test.each([
'link_codex_skill_dirs',
'link_factory_skill_dirs',
'link_opencode_skill_dirs',
'link_cursor_skill_dirs',
'create_agents_sidecar',
'create_cursor_sidecar',
])('%s bypasses the symlink-or-missing guard on Windows', (fn) => {
expect(extractFn(fn)).toContain(WINDOWS_BYPASS);
});
});
interface RunResult {
status: number | null;
stdout: string;
stderr: string;
}
/** Run the extracted installer functions against a fake tree. */
function runInstaller(
isWindows: '0' | '1',
fns: string[],
invocation: string,
extraVars = '',
): RunResult {
const script = [
'set -e',
`IS_WINDOWS=${isWindows}`,
extraVars,
extractFn('_link_or_copy'),
...fns.map(extractFn),
invocation,
].join('\n');
const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 15_000 });
return { status: r.status, stdout: r.stdout, stderr: r.stderr };
}
describe('setup: Windows re-run refresh — behavior fixture (#2444)', () => {
test('IS_WINDOWS=1: link_codex_skill_dirs refreshes an already-installed skill', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rerun-'));
try {
const fake = path.join(tmp, 'gstack');
const skills = path.join(tmp, 'skills');
const demo = path.join(fake, '.agents', 'skills', 'gstack-demo');
fs.mkdirSync(demo, { recursive: true });
fs.mkdirSync(skills, { recursive: true });
fs.writeFileSync(path.join(demo, 'SKILL.md'), 'v1-original\n');
// First run: installs the copy.
let r = runInstaller('1', ['link_codex_skill_dirs'], `link_codex_skill_dirs "${fake}" "${skills}"`);
expect(r.status).toBe(0);
const installed = path.join(skills, 'gstack-demo', 'SKILL.md');
expect(fs.readFileSync(installed, 'utf-8')).toBe('v1-original\n');
expect(fs.lstatSync(path.join(skills, 'gstack-demo')).isSymbolicLink()).toBe(false);
// Upstream ships a change (the git pull).
fs.writeFileSync(path.join(demo, 'SKILL.md'), 'v2-UPDATED\n');
// Second run: pre-#2444 this was a silent no-op on Windows.
r = runInstaller('1', ['link_codex_skill_dirs'], `link_codex_skill_dirs "${fake}" "${skills}"`);
expect(r.status).toBe(0);
expect(fs.readFileSync(installed, 'utf-8')).toBe('v2-UPDATED\n');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('IS_WINDOWS=1: create_agents_sidecar refreshes copied runtime assets', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rerun-sidecar-'));
try {
const fake = path.join(tmp, 'gstack');
fs.mkdirSync(path.join(fake, 'bin'), { recursive: true });
fs.writeFileSync(path.join(fake, 'bin', 'tool.sh'), 'v1\n');
fs.writeFileSync(path.join(fake, 'ETHOS.md'), 'ethos-v1\n');
const vars = `SOURCE_GSTACK_DIR="${fake}"`;
let r = runInstaller('1', ['create_agents_sidecar'], `create_agents_sidecar "${fake}"`, vars);
expect(r.status).toBe(0);
const sidecarBin = path.join(fake, '.agents', 'skills', 'gstack', 'bin', 'tool.sh');
const sidecarEthos = path.join(fake, '.agents', 'skills', 'gstack', 'ETHOS.md');
expect(fs.readFileSync(sidecarBin, 'utf-8')).toBe('v1\n');
expect(fs.readFileSync(sidecarEthos, 'utf-8')).toBe('ethos-v1\n');
fs.writeFileSync(path.join(fake, 'bin', 'tool.sh'), 'v2\n');
fs.writeFileSync(path.join(fake, 'ETHOS.md'), 'ethos-v2\n');
r = runInstaller('1', ['create_agents_sidecar'], `create_agents_sidecar "${fake}"`, vars);
expect(r.status).toBe(0);
expect(fs.readFileSync(sidecarBin, 'utf-8')).toBe('v2\n');
expect(fs.readFileSync(sidecarEthos, 'utf-8')).toBe('ethos-v2\n');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('IS_WINDOWS=1: the gstack sidecar dir is still skipped by the skill loop', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rerun-skip-'));
try {
const fake = path.join(tmp, 'gstack');
const skills = path.join(tmp, 'skills');
const sidecar = path.join(fake, '.agents', 'skills', 'gstack');
fs.mkdirSync(sidecar, { recursive: true });
fs.mkdirSync(skills, { recursive: true });
fs.writeFileSync(path.join(sidecar, 'SKILL.md'), 'sidecar\n');
const r = runInstaller('1', ['link_codex_skill_dirs'], `link_codex_skill_dirs "${fake}" "${skills}"`);
expect(r.status).toBe(0);
expect(fs.existsSync(path.join(skills, 'gstack'))).toBe(false);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});
// On real Windows, `ln -snf` under Git Bash silently produces copies, so the
// Unix-mode symlink assertions are meaningless there — the same skip the
// _link_or_copy behavior matrix uses (test/setup-windows-fallback.test.ts).
describe.skipIf(process.platform === 'win32')(
'setup: Unix path unchanged by the #2444 bypass',
() => {
test('IS_WINDOWS=0: installs a symlink and re-runs still refresh through it', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rerun-unix-'));
try {
const fake = path.join(tmp, 'gstack');
const skills = path.join(tmp, 'skills');
const demo = path.join(fake, '.agents', 'skills', 'gstack-demo');
fs.mkdirSync(demo, { recursive: true });
fs.mkdirSync(skills, { recursive: true });
fs.writeFileSync(path.join(demo, 'SKILL.md'), 'v1-original\n');
let r = runInstaller('0', ['link_codex_skill_dirs'], `link_codex_skill_dirs "${fake}" "${skills}"`);
expect(r.status).toBe(0);
const target = path.join(skills, 'gstack-demo');
expect(fs.lstatSync(target).isSymbolicLink()).toBe(true);
// A symlink serves updates without any re-run at all…
fs.writeFileSync(path.join(demo, 'SKILL.md'), 'v2-UPDATED\n');
expect(fs.readFileSync(path.join(target, 'SKILL.md'), 'utf-8')).toBe('v2-UPDATED\n');
// …and the re-run keeps it a symlink (guard still passes via -L).
r = runInstaller('0', ['link_codex_skill_dirs'], `link_codex_skill_dirs "${fake}" "${skills}"`);
expect(r.status).toBe(0);
expect(fs.lstatSync(target).isSymbolicLink()).toBe(true);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
},
);