diff --git a/setup b/setup index 87c9573e9..b841efd42 100755 --- a/setup +++ b/setup @@ -62,6 +62,65 @@ for _arg in "$@"; do esac done # --status is read-only: no bun, no build, no writes (docs/ADDING_A_HOST.md). +# _status_install_checks — per registered install (C3): is the router a real +# file (Codex skips a symlinked SKILL.md), and does every section pointer in an +# installed SKILL.md resolve? A pointer is "Read `/sections/.md`", +# optionally "relative to the installed `` SKILL.md directory". A bare +# `sections/...` pointer counts in a carved skill (sections/ installed, or a +# **STOP.** line); prefixed ones ($GSTACK_ROOT, ~, absolute, ../) everywhere. +_status_install_checks() { + local reg="$GSTACK_STATE_ROOT/installs.tsv" host scope project dest root src rest router md dir stop tok names n path checked broken first fix + [ -f "$reg" ] || return 0 + echo "Install checks:" + while IFS=' ' read -r host scope project dest root src rest; do + case "$host" in ''|'#'*) continue ;; esac + fix="Fix: cd $src && ./setup --host $host" + router="$root/SKILL.md" + if [ -L "$router" ]; then router="router is a symlink, which Codex skips. $fix" + elif [ -f "$router" ]; then router="router is a real file" + else router="router missing at $router. $fix"; fi + checked=0 broken=0 first="" + for md in "$dest"/*/SKILL.md; do + [ -f "$md" ] || continue + dir="${md%/SKILL.md}" + while IFS='|' read -r stop tok names; do + [ -n "$tok" ] || continue + path="" + case "$tok" in + sections/*) + if [ -n "$names" ]; then + for n in $names; do path="$dest/$n/$tok"; [ -r "$path" ] && break; done + elif [ -d "$dir/sections" ] || [ "$stop" = 1 ]; then path="$dir/$tok" + else continue; fi ;; + '$GSTACK_ROOT/'*) path="$root/${tok#\$GSTACK_ROOT/}" ;; + '~/'*) path="$HOME/${tok#\~/}" ;; + /*) path="$tok" ;; + ../*) path="$dir/$tok" ;; + *) continue ;; + esac + checked=$((checked + 1)) + [ -r "$path" ] && continue + broken=$((broken + 1)) + [ -n "$first" ] || first="${md#"$dest"/} names $tok" + done <path); a copy whose bytes no longer match was +# edited by hand and is saved to a uniquely named backup under the state root +# before the tree is replaced, so the backup outlives the old tree. +_SKILL_COPIES_FILE="$GSTACK_STATE_ROOT/skill-copies.tsv" +_copy_skill_md() { + rm -f "$2" && cp "$1" "$2" +} +_skill_copy_hash() { + git hash-object "$1" 2>/dev/null +} +# _skill_copy_unmodified FILE — FILE is a copy setup wrote and nobody edited. +_skill_copy_unmodified() { + local h + [ -f "$1" ] && [ ! -L "$1" ] && [ -f "$_SKILL_COPIES_FILE" ] || return 1 + h="$(_skill_copy_hash "$1")" || return 1 + grep -qxF "$h $1" "$_SKILL_COPIES_FILE" +} +# _preserve_skill_copy_edits ROOT — back up every recorded copy under ROOT +# whose bytes changed. Non-zero when a backup failed: the caller must then +# keep the old tree (a failed backup is never a license to overwrite). +_preserve_skill_copy_edits() { + local root="$1" hash path dir="" + [ -f "$_SKILL_COPIES_FILE" ] || return 0 + while IFS=' ' read -r hash path; do + case "$path" in "$root"/*) ;; *) continue ;; esac + { [ -f "$path" ] && [ ! -L "$path" ]; } || continue + [ "$(_skill_copy_hash "$path")" = "$hash" ] && continue + if [ -z "$dir" ]; then + mkdir -p "$GSTACK_STATE_ROOT/backups/skill-copies" || return 1 + dir="$(mktemp -d "$GSTACK_STATE_ROOT/backups/skill-copies/$(date +%Y%m%dT%H%M%S)-XXXXXX")" || return 1 + fi + mkdir -p "$dir/$(dirname "${path#/}")" && cp -p "$path" "$dir/${path#/}" || return 1 + echo " saved your edited $path to $dir/${path#/} (setup rewrites gstack's SKILL.md copies on every run)" + done < "$_SKILL_COPIES_FILE" +} +# _record_skill_copies ROOT — replace ROOT's rows with the real SKILL.md files +# now under it (find does not descend into linked directories). Sets +# _COPIES_REFRESHED and _COPIES_REMOVED (paths relative to ROOT). +_record_skill_copies() { + local root="$1" f tmp old="" new + _COPIES_REFRESHED="" _COPIES_REMOVED="" + if [ -f "$_SKILL_COPIES_FILE" ]; then + old="$(awk -F'\t' -v r="$root/" 'index($2, r) == 1 { print substr($2, length(r) + 1) }' "$_SKILL_COPIES_FILE" | sort)" + fi + new="$(cd "$root" 2>/dev/null && find . -name SKILL.md -type f | sed 's|^\./||' | sort)" + mkdir -p "$GSTACK_STATE_ROOT" || return 1 + tmp="$_SKILL_COPIES_FILE.tmp.$$" + { + [ -f "$_SKILL_COPIES_FILE" ] && awk -F'\t' -v r="$root/" 'index($2, r) != 1' "$_SKILL_COPIES_FILE" + for f in $new; do printf '%s\t%s\n' "$(_skill_copy_hash "$root/$f")" "$root/$f"; done + } > "$tmp" && mv -f "$tmp" "$_SKILL_COPIES_FILE" || { rm -f "$tmp"; return 1; } + _COPIES_REFRESHED="$(printf '%s\n' "$new" | tr '\n' ' ' | sed 's/ *$//')" + _COPIES_REMOVED="$(printf '%s\n' "$old" | grep -vxF -f <(printf '%s\n' "$new") | tr '\n' ' ' | sed 's/ *$//')" +} + # ─── Ownership gate for skill entries (#2119) ───────────────────────────────── # setup and gstack-relink must never delete or link over a skill they do not # own. This is the single rule both use (relink carries the same logic — keep @@ -2370,6 +2489,11 @@ _activate_runtime_root() { echo " error: could not build the new runtime root for $root (exit $rc); the previous one is unchanged" >&2 return "$rc" fi + if ! _preserve_skill_copy_edits "$root"; then + rm -rf "$new" + echo " error: could not back up an edited SKILL.md copy under $root; the previous runtime root is unchanged" >&2 + return 1 + fi if [ -e "$root" ] || [ -L "$root" ]; then mv "$root" "$old" || { rm -rf "$new"; return 1; } fi @@ -2379,6 +2503,10 @@ _activate_runtime_root() { return 1 fi rm -rf "$old" + _record_skill_copies "$root" || echo " warning: could not record SKILL.md copy hashes in $_SKILL_COPIES_FILE; a later hand edit there may not be backed up" >&2 + [ -n "$_COPIES_REFRESHED" ] && log " refreshed SKILL.md copies in $root: $_COPIES_REFRESHED" + [ -n "$_COPIES_REMOVED" ] && log " removed SKILL.md copies from $root: $_COPIES_REMOVED" + return 0 } # ─── Helper: create a minimal ~/.codex/skills/gstack runtime root ─────────── @@ -2401,7 +2529,7 @@ create_codex_runtime_root() { mkdir -p "$codex_gstack" "$codex_gstack/browse" "$codex_gstack/gstack-upgrade" "$codex_gstack/review" if [ -f "$agents_dir/gstack/SKILL.md" ]; then - _link_or_copy "$agents_dir/gstack/SKILL.md" "$codex_gstack/SKILL.md" + _copy_skill_md "$agents_dir/gstack/SKILL.md" "$codex_gstack/SKILL.md" fi if [ -d "$gstack_dir/bin" ]; then _link_or_copy "$gstack_dir/bin" "$codex_gstack/bin" @@ -2417,14 +2545,14 @@ create_codex_runtime_root() { fi _link_runtime_dists "$gstack_dir" "$codex_gstack" if [ -f "$agents_dir/gstack-upgrade/SKILL.md" ]; then - _link_or_copy "$agents_dir/gstack-upgrade/SKILL.md" "$codex_gstack/gstack-upgrade/SKILL.md" + _copy_skill_md "$agents_dir/gstack-upgrade/SKILL.md" "$codex_gstack/gstack-upgrade/SKILL.md" fi # plan-eng-review's inline office-hours step reads # $GSTACK_ROOT/office-hours/SKILL.md (#2449) — install the codex-rendered # variant there so the documented path exists. if [ -f "${agents_dir}/gstack-office-hours/SKILL.md" ]; then mkdir -p "${codex_gstack}/office-hours" - _link_or_copy "${agents_dir}/gstack-office-hours/SKILL.md" "${codex_gstack}/office-hours/SKILL.md" + _copy_skill_md "${agents_dir}/gstack-office-hours/SKILL.md" "${codex_gstack}/office-hours/SKILL.md" fi # Review runtime assets (individual files, NOT the whole review/ dir which has SKILL.md) for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do @@ -2457,7 +2585,7 @@ create_factory_runtime_root() { mkdir -p "$factory_gstack" "$factory_gstack/browse" "$factory_gstack/gstack-upgrade" "$factory_gstack/review" if [ -f "$factory_dir/gstack/SKILL.md" ]; then - _link_or_copy "$factory_dir/gstack/SKILL.md" "$factory_gstack/SKILL.md" + _copy_skill_md "$factory_dir/gstack/SKILL.md" "$factory_gstack/SKILL.md" fi if [ -d "$gstack_dir/bin" ]; then _link_or_copy "$gstack_dir/bin" "$factory_gstack/bin" @@ -2473,14 +2601,14 @@ create_factory_runtime_root() { fi _link_runtime_dists "$gstack_dir" "$factory_gstack" if [ -f "$factory_dir/gstack-upgrade/SKILL.md" ]; then - _link_or_copy "$factory_dir/gstack-upgrade/SKILL.md" "$factory_gstack/gstack-upgrade/SKILL.md" + _copy_skill_md "$factory_dir/gstack-upgrade/SKILL.md" "$factory_gstack/gstack-upgrade/SKILL.md" fi # plan-eng-review's inline office-hours step reads # $GSTACK_ROOT/office-hours/SKILL.md (#2449) — install the factory-rendered # variant there so the documented path exists. if [ -f "${factory_dir}/gstack-office-hours/SKILL.md" ]; then mkdir -p "${factory_gstack}/office-hours" - _link_or_copy "${factory_dir}/gstack-office-hours/SKILL.md" "${factory_gstack}/office-hours/SKILL.md" + _copy_skill_md "${factory_dir}/gstack-office-hours/SKILL.md" "${factory_gstack}/office-hours/SKILL.md" fi for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do if [ -f "$gstack_dir/review/$f" ]; then @@ -2511,7 +2639,7 @@ create_opencode_runtime_root() { mkdir -p "$opencode_gstack" "$opencode_gstack/browse" "$opencode_gstack/gstack-upgrade" "$opencode_gstack/review" "$opencode_gstack/qa" "$opencode_gstack/plan-devex-review" if [ -f "$opencode_dir/gstack/SKILL.md" ]; then - _link_or_copy "$opencode_dir/gstack/SKILL.md" "$opencode_gstack/SKILL.md" + _copy_skill_md "$opencode_dir/gstack/SKILL.md" "$opencode_gstack/SKILL.md" fi if [ -d "$gstack_dir/bin" ]; then _link_or_copy "$gstack_dir/bin" "$opencode_gstack/bin" @@ -2527,14 +2655,14 @@ create_opencode_runtime_root() { fi _link_runtime_dists "$gstack_dir" "$opencode_gstack" if [ -f "$opencode_dir/gstack-upgrade/SKILL.md" ]; then - _link_or_copy "$opencode_dir/gstack-upgrade/SKILL.md" "$opencode_gstack/gstack-upgrade/SKILL.md" + _copy_skill_md "$opencode_dir/gstack-upgrade/SKILL.md" "$opencode_gstack/gstack-upgrade/SKILL.md" fi # plan-eng-review's inline office-hours step reads # $GSTACK_ROOT/office-hours/SKILL.md (#2449) — install the opencode-rendered # variant there so the documented path exists. if [ -f "${opencode_dir}/gstack-office-hours/SKILL.md" ]; then mkdir -p "${opencode_gstack}/office-hours" - _link_or_copy "${opencode_dir}/gstack-office-hours/SKILL.md" "${opencode_gstack}/office-hours/SKILL.md" + _copy_skill_md "${opencode_dir}/gstack-office-hours/SKILL.md" "${opencode_gstack}/office-hours/SKILL.md" fi for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do if [ -f "$gstack_dir/review/$f" ]; then @@ -2673,7 +2801,7 @@ create_cursor_runtime_root() { mkdir -p "$cursor_gstack" "$cursor_gstack/browse" "$cursor_gstack/gstack-upgrade" "$cursor_gstack/review" if [ -f "$cursor_dir/gstack/SKILL.md" ]; then - _link_or_copy "$cursor_dir/gstack/SKILL.md" "$cursor_gstack/SKILL.md" + _copy_skill_md "$cursor_dir/gstack/SKILL.md" "$cursor_gstack/SKILL.md" fi # bin scripts import shared modules via ../lib — bin and lib travel together. if [ -d "$gstack_dir/bin" ]; then @@ -2690,7 +2818,7 @@ create_cursor_runtime_root() { fi _link_runtime_dists "$gstack_dir" "$cursor_gstack" if [ -f "$cursor_dir/gstack-upgrade/SKILL.md" ]; then - _link_or_copy "$cursor_dir/gstack-upgrade/SKILL.md" "$cursor_gstack/gstack-upgrade/SKILL.md" + _copy_skill_md "$cursor_dir/gstack-upgrade/SKILL.md" "$cursor_gstack/gstack-upgrade/SKILL.md" fi # Review runtime assets — the cursor host config ships the lean pair. for f in checklist.md TODOS-format.md; do @@ -2850,7 +2978,7 @@ create_copilot_runtime_root() { mkdir -p "$copilot_gstack" "$copilot_gstack/browse" "$copilot_gstack/gstack-upgrade" "$copilot_gstack/review" printf '%s\n' "$gstack_dir" > "$copilot_gstack/.source-path" if [ -f "$copilot_dir/gstack/SKILL.md" ]; then - _link_or_copy "$copilot_dir/gstack/SKILL.md" "$copilot_gstack/SKILL.md" + _copy_skill_md "$copilot_dir/gstack/SKILL.md" "$copilot_gstack/SKILL.md" fi for asset in bin lib browse/dist browse/bin; do if [ -d "$gstack_dir/$asset" ]; then @@ -2859,11 +2987,11 @@ create_copilot_runtime_root() { done _link_runtime_dists "$gstack_dir" "$copilot_gstack" if [ -f "$copilot_dir/gstack-upgrade/SKILL.md" ]; then - _link_or_copy "$copilot_dir/gstack-upgrade/SKILL.md" "$copilot_gstack/gstack-upgrade/SKILL.md" + _copy_skill_md "$copilot_dir/gstack-upgrade/SKILL.md" "$copilot_gstack/gstack-upgrade/SKILL.md" fi if [ -f "$copilot_dir/gstack-office-hours/SKILL.md" ]; then mkdir -p "$copilot_gstack/office-hours" - _link_or_copy "$copilot_dir/gstack-office-hours/SKILL.md" "$copilot_gstack/office-hours/SKILL.md" + _copy_skill_md "$copilot_dir/gstack-office-hours/SKILL.md" "$copilot_gstack/office-hours/SKILL.md" fi for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do if [ -f "$gstack_dir/review/$f" ]; then @@ -3150,11 +3278,15 @@ if [ "$INSTALL_KIRO" -eq 1 ]; then mkdir -p "$KIRO_GSTACK/supabase" _link_or_copy "$SOURCE_GSTACK_DIR/supabase/config.sh" "$KIRO_GSTACK/supabase/config.sh" fi - _link_or_copy "$KIRO_DIR/gstack-upgrade/SKILL.md" "$KIRO_GSTACK/gstack-upgrade/SKILL.md" - _link_or_copy "$KIRO_DIR/gstack/SKILL.md" "$KIRO_GSTACK/SKILL.md" + if ! _preserve_skill_copy_edits "$KIRO_GSTACK"; then + echo " error: could not back up an edited SKILL.md copy under $KIRO_GSTACK; Kiro install stopped before overwriting it" >&2 + exit 1 + fi + _copy_skill_md "$KIRO_DIR/gstack-upgrade/SKILL.md" "$KIRO_GSTACK/gstack-upgrade/SKILL.md" + _copy_skill_md "$KIRO_DIR/gstack/SKILL.md" "$KIRO_GSTACK/SKILL.md" if [ -f "$KIRO_DIR/gstack-office-hours/SKILL.md" ]; then mkdir -p "$KIRO_GSTACK/office-hours" - _link_or_copy "$KIRO_DIR/gstack-office-hours/SKILL.md" "$KIRO_GSTACK/office-hours/SKILL.md" + _copy_skill_md "$KIRO_DIR/gstack-office-hours/SKILL.md" "$KIRO_GSTACK/office-hours/SKILL.md" fi for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do if [ -f "$SOURCE_GSTACK_DIR/review/$f" ]; then @@ -3176,13 +3308,15 @@ if [ "$INSTALL_KIRO" -eq 1 ]; then mkdir -p "$target_dir" if [ -f "$target_dir/SKILL.md" ] && [ ! -L "$target_dir/SKILL.md" ] \ && ! _claude_entry_owned_strongly "$target_dir" "$SOURCE_GSTACK_DIR" \ + && ! _skill_copy_unmodified "$target_dir/SKILL.md" \ && ! cmp -s "$target_dir/SKILL.md" "$skill_dir/SKILL.md"; then if ! _backup_skill_md "$target_dir/SKILL.md" "$skill_name"; then echo " skipped $skill_name: could not back up its customized SKILL.md — left untouched" >&2 continue fi fi - _link_or_copy "$skill_dir/SKILL.md" "$target_dir/SKILL.md" + _copy_skill_md "$skill_dir/SKILL.md" "$target_dir/SKILL.md" + _record_skill_copies "$target_dir" || true # Native sections already contain Kiro paths/provider markers. Refresh # generated files individually so user assets next to them survive. if [ -d "$skill_dir/sections" ]; then @@ -3236,6 +3370,8 @@ if [ "$INSTALL_KIRO" -eq 1 ]; then fi done _prune_stale_generated "$SOURCE_GSTACK_DIR" "$KIRO_DIR" "$KIRO_SKILLS" + _record_skill_copies "$KIRO_GSTACK" || echo " warning: could not record SKILL.md copy hashes in $_SKILL_COPIES_FILE" >&2 + [ -n "$_COPIES_REFRESHED" ] && log " refreshed SKILL.md copies in $KIRO_GSTACK: $_COPIES_REFRESHED" _setup_arm_publish kiro global - "$KIRO_SKILLS" "$KIRO_GSTACK" - echo "gstack ready (kiro)." echo " browse: $BROWSE_BIN" diff --git a/test/host-conformance.test.ts b/test/host-conformance.test.ts index 793488a5e..60aebbd98 100644 --- a/test/host-conformance.test.ts +++ b/test/host-conformance.test.ts @@ -11,7 +11,7 @@ * namespace (#2879). Instruction-only hosts must change nothing. */ import { afterEach, describe, expect, test } from 'bun:test'; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync } from 'node:fs'; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { ALL_HOST_CONFIGS } from '../hosts/index'; import { cleanupFixtures, makeFixture, makeSource, put, registryRows, runSetup, setVersion, tree } from './helpers/install-fixture'; @@ -45,6 +45,18 @@ function skillNames(dir: string): string[] { return names; } +/** Every SKILL.md under dir that is itself a symlink; linked directories are not entered (INV-3). */ +function symlinkedSkillMds(dir: string): string[] { + const found: string[] = []; + for (const name of readdirSync(dir)) { + const p = join(dir, name); + const st = lstatSync(p); + if (st.isSymbolicLink()) { if (name === 'SKILL.md') found.push(p); continue; } + if (st.isDirectory()) found.push(...symlinkedSkillMds(p)); + } + return found; +} + describe.skipIf(process.platform === 'win32')('host conformance kit', () => { test('the kit covers every installable host', () => { expect(Object.keys(DISCOVERY).sort()).toEqual([...installable].sort()); @@ -84,6 +96,12 @@ describe.skipIf(process.platform === 'win32')('host conformance kit', () => { const status = runSetup(f, join(src, 'setup'), ['--status']); expect(status.status).toBe(0); expect(status.stdout).toMatch(new RegExp(`\\b${host}\\s+${ALL_HOST_CONFIGS.find(c => c.name === host)!.tier}\\s+global\\s+current`)); + + // C3: Codex skips a symlinked SKILL.md file; directory links are fine. + if (host !== 'claude') { + expect(symlinkedSkillMds(dest), `${host}: symlinked SKILL.md files (Codex skips them)`).toEqual([]); + expect(status.stdout).toContain(`\n ${host} global: router is a real file; section links: `); + } }, 60_000); } @@ -128,6 +146,20 @@ describe.skipIf(process.platform === 'win32')('host conformance kit', () => { expect(status.stdout).toContain(`register it: cd ${realpathSync(src)} && ./setup --host codex`); }, 60_000); + test('--status names a symlinked router and a broken section link per host (C3)', () => { + const f = makeFixture(); + const src = makeSource(f, join(f.dir, 'gstack')); + expect(runSetup(f, join(src, 'setup'), ['--host', 'codex']).status).toBe(0); + const router = join(f.home, '.codex/skills/gstack/SKILL.md'); + rmSync(router); + symlinkSync(join(src, '.agents/skills/gstack/SKILL.md'), router); + const qa = join(src, '.agents/skills/gstack-qa/sections'); + rmSync(join(qa, readdirSync(qa).find(n => n.endsWith('.md'))!)); + const status = runSetup(f, join(src, 'setup'), ['--status']); + expect(status.status).toBe(0); + expect(status.stdout).toMatch(/codex global: router is a symlink, which Codex skips\. Fix: cd \S+ && \.\/setup --host codex; section links: [1-9]\d* of \d+ broken \(first: [^)]*\)\. Fix: cd /); + }, 60_000); + test('upgrade from the previous release layout refreshes every installed host (#1925)', () => { const f = makeFixture(); const src = makeSource(f, join(f.home, '.claude/skills/gstack'), '1.91.12.0'); @@ -135,6 +167,11 @@ describe.skipIf(process.platform === 'win32')('host conformance kit', () => { // v1.91.13.0 and earlier: same links, no registry, marker in ~/.gstack. rmSync(join(f.home, '.gstack/installs.tsv')); setVersion(src, '1.91.14.0'); + // C3: a hand edit to the router copy is saved under the state root before + // the runtime root is replaced, and the refresh rewrites the copy. + const router = join(f.home, '.codex/skills/gstack/SKILL.md'); + expect(lstatSync(router).isSymbolicLink()).toBe(false); + writeFileSync(router, readFileSync(router, 'utf8') + '\nmy local note\n'); const oldUpgrade = runSetup(f, join(src, 'setup'), []); expect(oldUpgrade.status).toBe(0); @@ -147,6 +184,13 @@ describe.skipIf(process.platform === 'win32')('host conformance kit', () => { expect(registryRows(f).map(r => [r[0], r[6]]).sort()).toEqual([['claude', '1.91.14.0'], ['codex', '1.91.14.0']]); // The refreshed Codex install runs the new checkout's render. expect(realpathSync(join(f.home, '.codex/skills/gstack-review/SKILL.md'))).toBe(join(realpathSync(src), '.agents/skills/gstack-review/SKILL.md')); + expect(readFileSync(router, 'utf8')).toBe(readFileSync(join(src, '.agents/skills/gstack/SKILL.md'), 'utf8')); + const saved = upgrade.stdout.match(/saved your edited (\S+) to (\S+) /); + expect(saved, upgrade.stdout).not.toBeNull(); + expect(saved![1]).toBe(router); + expect(saved![2].startsWith(join(f.home, '.gstack/backups/skill-copies/'))).toBe(true); + expect(readFileSync(saved![2], 'utf8')).toEndWith('\nmy local note\n'); + expect(upgrade.stdout).toContain(`refreshed SKILL.md copies in ${join(f.home, '.codex/skills/gstack')}: SKILL.md gstack-upgrade/SKILL.md office-hours/SKILL.md`); }, 90_000); test('a Codex namespace captured by a vendored project copy is reported, never repointed (#2879)', () => { diff --git a/test/setup-codex-design-pdf-runtime.test.ts b/test/setup-codex-design-pdf-runtime.test.ts index 86a1a4416..55ef99449 100644 --- a/test/setup-codex-design-pdf-runtime.test.ts +++ b/test/setup-codex-design-pdf-runtime.test.ts @@ -100,7 +100,7 @@ const BUILDERS: Record Built> = { }, }; -const FUNCTIONS = ['_link_or_copy', '_link_runtime_dists', '_sidecar_root_user_owned', '_gstack_generated_header', 'create_agents_sidecar', 'create_codex_runtime_root'] +const FUNCTIONS = ['_link_or_copy', '_link_runtime_dists', '_copy_skill_md', '_sidecar_root_user_owned', '_gstack_generated_header', 'create_agents_sidecar', 'create_codex_runtime_root'] .filter(name => SETUP_SRC.includes(`\n${name}() {`)) .map(extractFunction) .join('\n'); diff --git a/test/setup-codex-model.test.ts b/test/setup-codex-model.test.ts index ab2a24ccb..b7d6db21f 100644 --- a/test/setup-codex-model.test.ts +++ b/test/setup-codex-model.test.ts @@ -78,8 +78,8 @@ describe('setup Codex model activation', () => { const kiroStart = setup.indexOf('# 6. Install for Kiro CLI'); const kiroEnd = setup.indexOf('# 6b.', kiroStart); const block = setup.slice(kiroStart, kiroEnd); - expect(block).toContain('_link_or_copy "$KIRO_DIR/gstack-upgrade/SKILL.md"'); - expect(block).toContain('_link_or_copy "$KIRO_DIR/gstack/SKILL.md"'); + expect(block).toContain('_copy_skill_md "$KIRO_DIR/gstack-upgrade/SKILL.md"'); + expect(block).toContain('_copy_skill_md "$KIRO_DIR/gstack/SKILL.md"'); expect(block).toContain('_link_or_copy "$SOURCE_GSTACK_DIR/bin" "$KIRO_GSTACK/bin"'); expect(block).toContain('_link_or_copy "$SOURCE_GSTACK_DIR/lib" "$KIRO_GSTACK/lib"'); expect(block).not.toContain('$AGENTS_DIR'); diff --git a/test/setup-kiro-native.test.ts b/test/setup-kiro-native.test.ts index 229a37529..ddfd1eb57 100644 --- a/test/setup-kiro-native.test.ts +++ b/test/setup-kiro-native.test.ts @@ -21,6 +21,7 @@ const helpers = [ '_claude_entry_owned_strongly', '_gstack_link_target_abs', '_gstack_target_is_ours', '_gstack_generated_header', '_backup_skill_md', '_prune_stale_generated', '_skill_source_exists', '_owned_for_windows_refresh', '_cleanup_weak_dir', + '_copy_skill_md', '_skill_copy_hash', '_skill_copy_unmodified', '_preserve_skill_copy_edits', '_record_skill_copies', ].map(fn).join('\n'); describe.skipIf(process.platform === 'win32')('native Kiro setup installation', () => { @@ -51,6 +52,7 @@ describe.skipIf(process.platform === 'win32')('native Kiro setup installation', 'bun_cmd() { printf "%s\\n" "$*" >> "$BUN_LOG"; }', 'INSTALL_KIRO=1', `IS_WINDOWS=${windowsCopy}`, 'BROWSE_BIN=unused', '_BACKED_UP_SKILL_MDS=()', '_SKILL_BACKUP_ROOT="$HOME/backups"', + 'GSTACK_STATE_ROOT="$HOME/.gstack"', '_SKILL_COPIES_FILE="$GSTACK_STATE_ROOT/skill-copies.tsv"', 'KIRO_SKILLS="$HOME/.kiro/skills"', block, ].join('\n'), { env: { ...process.env, HOME: home, SOURCE_GSTACK_DIR: root, BUN_LOG: log }, timeout: 30_000, diff --git a/test/setup-skill-copies.test.ts b/test/setup-skill-copies.test.ts new file mode 100644 index 000000000..549586267 --- /dev/null +++ b/test/setup-skill-copies.test.ts @@ -0,0 +1,97 @@ +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); + } + }); +});