Files
gstack/test/setup-skill-copies.test.ts
T
garrytan d0598054c6 C3: install router and nested SKILL.md files as real copies; back up hand edits; --status checks
Codex skips a symlinked SKILL.md file, so the gstack router linked into
~/.codex/skills/gstack was invisible. Every host runtime root (Codex,
Factory, OpenCode, Cursor, Copilot, Kiro) and Kiro's per-skill installs
now get real copies, rewritten on every run (the auto-update path runs
setup --refresh-registered, so it refreshes them too). setup records each
copy's hash in <state root>/skill-copies.tsv; a copy that no longer matches
is saved to a uniquely named backup under <state root>/backups/skill-copies/
before _activate_runtime_root replaces the tree, and a failed backup keeps
the old tree. setup prints what it refreshed or removed.

./setup --status now prints, per registered install, whether the router is a
real file and whether every section pointer resolves, with the fix command.

Test migration: setup-codex-model pinned the Kiro router/upgrade links as
_link_or_copy; C3 deliberately changes them to copies.
2026-10-03 21:01:32 +00:00

98 lines
5.0 KiB
TypeScript

import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { runBashScript } from './helpers/bash-script';
// C3 (#333): host runtime roots hold real SKILL.md copies. setup records each
// copy's hash under the state root; an edited copy is saved to a uniquely
// named backup before the tree is replaced, and a failed backup stops the
// replacement.
const setup = fs.readFileSync(path.resolve(import.meta.dir, '../setup'), 'utf8');
const fn = (name: string) => {
const start = setup.indexOf(`\n${name}() {`);
const end = setup.indexOf('\n}\n', start);
if (start < 0 || end < 0) throw new Error(`missing setup helper: ${name}`);
return setup.slice(start + 1, end + 2);
};
const helpers = ['_copy_skill_md', '_skill_copy_hash', '_skill_copy_unmodified', '_preserve_skill_copy_edits', '_record_skill_copies', '_activate_runtime_root'].map(fn).join('\n');
const tmps: string[] = [];
afterEach(() => { for (const t of tmps.splice(0)) { fs.chmodSync(t, 0o755); fs.rmSync(t, { recursive: true, force: true }); } });
function run(tmp: string, body: string) {
return runBashScript([
'set -e', 'log() { echo "$@"; }', `GSTACK_STATE_ROOT="${tmp}/state"`, '_SKILL_COPIES_FILE="$GSTACK_STATE_ROOT/skill-copies.tsv"',
helpers,
// A runtime root build: router plus one nested copy, from $SRC.
'build() { mkdir -p "$2/gstack-upgrade"; _copy_skill_md "$SRC/router.md" "$2/SKILL.md"; [ -f "$SRC/upgrade.md" ] && _copy_skill_md "$SRC/upgrade.md" "$2/gstack-upgrade/SKILL.md"; return 0; }',
`SRC="${tmp}/src"; ROOT="${tmp}/home/.codex/skills/gstack"`,
body,
].join('\n'), { timeout: 30_000 });
}
describe.skipIf(process.platform === 'win32')('setup: real-file SKILL.md copies (C3)', () => {
test('copies, refreshes, saves hand edits uniquely, and reports removals', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-skill-copies-'));
tmps.push(tmp);
fs.mkdirSync(path.join(tmp, 'src'));
fs.writeFileSync(path.join(tmp, 'src/router.md'), 'router v1\n');
fs.writeFileSync(path.join(tmp, 'src/upgrade.md'), 'upgrade v1\n');
const root = path.join(tmp, 'home/.codex/skills/gstack');
let r = run(tmp, '_activate_runtime_root build "$SRC" "$ROOT"');
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toContain(`refreshed SKILL.md copies in ${root}: SKILL.md gstack-upgrade/SKILL.md`);
expect(fs.lstatSync(path.join(root, 'SKILL.md')).isSymbolicLink()).toBe(false);
// An unedited copy is refreshed with no backup.
fs.writeFileSync(path.join(tmp, 'src/router.md'), 'router v2\n');
r = run(tmp, '_activate_runtime_root build "$SRC" "$ROOT"');
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).not.toContain('saved your edited');
expect(fs.readFileSync(path.join(root, 'SKILL.md'), 'utf8')).toBe('router v2\n');
// Two runs with hand edits keep two separate backups.
const backups: string[] = [];
for (const note of ['edit one', 'edit two']) {
fs.appendFileSync(path.join(root, 'SKILL.md'), `${note}\n`);
r = run(tmp, '_activate_runtime_root build "$SRC" "$ROOT"');
expect(r.status, r.stderr).toBe(0);
const m = r.stdout.match(/saved your edited (\S+) to (\S+) /);
expect(m, r.stdout).not.toBeNull();
expect(m![1]).toBe(path.join(root, 'SKILL.md'));
expect(m![2].startsWith(path.join(tmp, 'state/backups/skill-copies/'))).toBe(true);
expect(fs.readFileSync(m![2], 'utf8')).toBe(`router v2\n${note}\n`);
backups.push(m![2]);
expect(fs.readFileSync(path.join(root, 'SKILL.md'), 'utf8')).toBe('router v2\n');
}
expect(new Set(backups).size).toBe(2);
expect(fs.readFileSync(backups[0], 'utf8')).toBe('router v2\nedit one\n');
// A copy setup no longer writes is reported as removed.
fs.rmSync(path.join(tmp, 'src/upgrade.md'));
r = run(tmp, '_activate_runtime_root build "$SRC" "$ROOT"');
expect(r.stdout).toContain(`removed SKILL.md copies from ${root}: gstack-upgrade/SKILL.md`);
});
test('a backup that cannot be written keeps the old tree', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-skill-copies-'));
tmps.push(tmp);
fs.mkdirSync(path.join(tmp, 'src'));
fs.writeFileSync(path.join(tmp, 'src/router.md'), 'router v1\n');
const root = path.join(tmp, 'home/.codex/skills/gstack');
expect(run(tmp, '_activate_runtime_root build "$SRC" "$ROOT"').status).toBe(0);
fs.appendFileSync(path.join(root, 'SKILL.md'), 'mine\n');
fs.mkdirSync(path.join(tmp, 'state/backups'), { recursive: true });
fs.chmodSync(path.join(tmp, 'state/backups'), 0o555);
try {
const r = run(tmp, '_activate_runtime_root build "$SRC" "$ROOT" || echo "rc=$?"');
expect(r.stdout).toContain('rc=1');
expect(r.stderr).toContain('could not back up an edited SKILL.md copy');
expect(fs.readFileSync(path.join(root, 'SKILL.md'), 'utf8')).toBe('router v1\nmine\n');
} finally {
fs.chmodSync(path.join(tmp, 'state/backups'), 0o755);
}
});
});