mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 23:14:33 +01:00
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.
98 lines
5.0 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|