From 2548634d96f566f38a6837074ef394a6a9cbd07b Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 4 Sep 2026 17:28:00 +0000 Subject: [PATCH] fix(setup): never link over, copy over, or reap a skill gstack does not own (#2119) The relink gate alone left three destructive sites open: - link_claude_skill_dirs runs BEFORE relink on every ./setup and used `ln -snf` (Linux replaces a user's real SKILL.md with a symlink into gstack) or, on Windows, rm -rf + cp followed by a marker that made the user's directory "ours" on the next flip. It and _install_alias_skill_md now consult _claude_entry_is_ours first and skip loudly. - cleanup_prefixed_claude_symlinks kept a bare name-match deletion and a `*gstack*` substring match. Symlink arms use anchored `gstack/` segment patterns; the Windows real-file arm proves provenance (marker, byte-identity with our source, or the full two-line gen-skill-docs banner within the first 40 lines, never a one-line substring another generator could emit). cleanup_old_claude_symlinks uses the same banner rule. - gstack-relink's fast path judged absolute targets before canonicalizing, so `/x/gstack/../foreign/SKILL.md` counted as ours; dot-segment targets now canonicalize first. Its banner rule matches setup's. The `.gstack-owned` marker records the owning payload's realpath. Entries skipped by setup or relink are listed in the final setup summary. Chromium bootstrap refinements from the pre-landing review: an INT/TERM trap kills the installer's process tree; the Windows npm chain no longer masks an install failure; GSTACK_SKIP_PLAYWRIGHT=1 is reported as a choice rather than a failure and sends no telemetry; the timeout knob is normalized (0, 000, non-numeric, or more than nine digits fall back to the 600s default instead of killing on the first poll or never killing). Co-Authored-By: Claude Fable 5.1 --- bin/gstack-relink | 65 +++- setup | 159 ++++++++-- test/relink.test.ts | 171 ++++++++++ test/setup-cleanup-orphans.test.ts | 31 +- test/setup-link-ownership.test.ts | 213 +++++++++++++ test/setup-playwright-best-effort.test.ts | 361 +++++++++++++++++++++- 6 files changed, 949 insertions(+), 51 deletions(-) create mode 100644 test/setup-link-ownership.test.ts diff --git a/bin/gstack-relink b/bin/gstack-relink index 743f71893..6ce308715 100755 --- a/bin/gstack-relink +++ b/bin/gstack-relink @@ -48,17 +48,22 @@ RENDER_DIR="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claud # skill that happened to share a name (a personal `qa`, a fork under another # path) was deleted or had its SKILL.md replaced by a symlink into gstack # (#2119; Linux replaces a real file with `ln -snf`, macOS refuses by accident). -# setup:1040 and gstack-uninstall:204 already gate on readlink; this is the -# same rule for the one remaining unguarded deleter. +# setup (link_claude_skill_dirs, cleanup_old_claude_symlinks, +# cleanup_prefixed_claude_symlinks) and gstack-uninstall apply the same rule; +# keep the four in sync until the shared helper TODOS.md files lands. # # An entry is OURS when: -# - it is a symlink resolving into $INSTALL_DIR or $RENDER_DIR, or -# - it is a real dir whose SKILL.md is a symlink resolving into either, or -# - it is a real dir carrying the .gstack-owned marker setup writes for -# Windows copy installs (no symlinks there to read). -# Anything else — a foreign symlink, a real dir with a real SKILL.md and no -# marker, or an entry whose readlink fails — is FOREIGN: never deleted, never -# linked over, reported on stderr. +# - it is a symlink resolving into $INSTALL_DIR or $RENDER_DIR (as written or +# as realpath), or into any path with a `gstack` segment — the convention +# setup and gstack-uninstall use, so a sibling worktree's entries and a +# moved checkout's dangling links still count as ours, or +# - it is a real dir whose SKILL.md is such a symlink, or +# - it is a real dir with a real-file SKILL.md proven by the .gstack-owned +# marker (Windows copy installs), byte-identity with our source, or +# gen-skill-docs' generated header (legacy copies made before the marker). +# Anything else — a foreign symlink, a real dir with a hand-written SKILL.md, +# or an entry whose readlink fails — is FOREIGN: never deleted, never linked +# over, reported on stderr. # The install/render roots as written AND as resolved: a standalone relink # detects INSTALL_DIR as ~/.claude/skills/gstack, which may itself be a symlink # to a checkout, while setup linked entries against the checkout's real path. @@ -72,6 +77,7 @@ _target_is_ours() { # matches a /home/u/gstack root. case "$1" in "$INSTALL_DIR"/*|"$RENDER_DIR"/*|"$_INSTALL_REAL"/*|"$_RENDER_REAL"/*) return 0 ;; + gstack/*|*/gstack/*|*/.gstack/render/claude/*) return 0 ;; *) return 1 ;; esac } @@ -86,11 +92,17 @@ _link_target_abs() { local link="$1" dest d b d_real dest="$(readlink "$link" 2>/dev/null || true)" [ -n "$dest" ] || return 1 + # Fast path: the common absolute link setup/relink wrote is decided without + # any further fork (relink runs on every ./setup and gstack-config set). + case "$dest" in + */../*|*/./*|*/..|*/.) ;; # dot segments: canonicalize before judging (/x/gstack/../foreign) + /*) if _target_is_ours "$dest"; then printf '%s\n' "$dest"; return 0; fi ;; + esac case "$dest" in /*) ;; - *) dest="$(dirname "$link")/$dest" ;; + *) dest="${link%/*}/$dest" ;; esac - d="$(dirname "$dest")"; b="$(basename "$dest")" + d="${dest%/*}"; b="${dest##*/}" if d_real="$(cd "$d" 2>/dev/null && pwd -P)"; then printf '%s\n' "$d_real/$b" else @@ -98,8 +110,10 @@ _link_target_abs() { fi } +# _entry_is_ours ENTRY SKILL — SKILL names the gstack skill this entry would +# serve, so a real-file copy can be compared against our own source. _entry_is_ours() { - local entry="$1" dest + local entry="$1" skill="${2:-}" dest src if [ -L "$entry" ]; then dest="$(_link_target_abs "$entry")" || return 1 _target_is_ours "$dest" @@ -112,6 +126,18 @@ _entry_is_ours() { _target_is_ours "$dest" return $? fi + if [ -f "$entry/SKILL.md" ]; then + for src in "$RENDER_DIR/$skill/SKILL.md" "$INSTALL_DIR/$skill/SKILL.md"; do + [ -n "$skill" ] && [ -f "$src" ] && cmp -s "$entry/SKILL.md" "$src" && return 0 + done + # Pre-marker legacy copy: gen-skill-docs' full two-line banner near the + # top (same rule as setup's _gstack_generated_header), not a one-line + # substring another generator could emit. A gstack fork rendering the + # same banner is the accepted, filed residual. + case "$(head -n 40 "$entry/SKILL.md" 2>/dev/null)" in + *''*) return 0 ;; + esac + fi return 1 fi return 1 @@ -126,9 +152,9 @@ _report_foreign() { # Helper: remove an OLD skill entry from the opposite prefix mode. Only entries # we can prove are ours are removed; anything else is reported and kept. _cleanup_skill_entry() { - local entry="$1" + local entry="$1" skill="${2:-}" [ -e "$entry" ] || [ -L "$entry" ] || return 0 - if ! _entry_is_ours "$entry"; then + if ! _entry_is_ours "$entry" "$skill"; then _report_foreign "$entry" return 0 fi @@ -177,13 +203,13 @@ for skill_dir in "$INSTALL_DIR"/*/; do *) link_name="gstack-$skill" ;; esac # Remove old flat entry if it exists (and isn't the same as the new link) - [ "$link_name" != "$skill" ] && _cleanup_skill_entry "$SKILLS_DIR/$skill" + [ "$link_name" != "$skill" ] && _cleanup_skill_entry "$SKILLS_DIR/$skill" "$skill" else link_name="$skill" # Don't remove gstack-* dirs that are their real name (e.g., gstack-upgrade) case "$skill" in gstack-*) ;; # Already the real name, no old prefixed link to clean - *) _cleanup_skill_entry "$SKILLS_DIR/gstack-$skill" ;; + *) _cleanup_skill_entry "$SKILLS_DIR/gstack-$skill" "$skill" ;; esac fi target="$SKILLS_DIR/$link_name" @@ -191,7 +217,7 @@ for skill_dir in "$INSTALL_DIR"/*/; do # shares our name. Never `ln -snf` over its SKILL.md (on Linux that replaces # a real file with a symlink into gstack) and never mkdir into it — skip # loudly and leave registration of that one name to the user. - if { [ -e "$target" ] || [ -L "$target" ]; } && ! _entry_is_ours "$target"; then + if { [ -e "$target" ] || [ -L "$target" ]; } && ! _entry_is_ours "$target" "$skill"; then _report_foreign "$target" continue fi @@ -202,6 +228,11 @@ for skill_dir in "$INSTALL_DIR"/*/; do skill_md_src="$INSTALL_DIR/$skill/SKILL.md" [ -f "$RENDER_DIR/$skill/SKILL.md" ] && skill_md_src="$RENDER_DIR/$skill/SKILL.md" ln -snf "$skill_md_src" "$target/SKILL.md" + # On Windows without Developer Mode `ln -snf` degrades to a copy; leave the + # same provenance marker setup writes so the next flip can prove ownership. + if [ ! -L "$target/SKILL.md" ]; then + printf '%s\n' "$_INSTALL_REAL" > "$target/.gstack-owned" 2>/dev/null || true + fi SKILL_COUNT=$((SKILL_COUNT + 1)) done diff --git a/setup b/setup index d1cfebb06..fa0ea2773 100755 --- a/setup +++ b/setup @@ -116,6 +116,79 @@ _link_or_copy() { fi } +# ─── 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 +# them in sync until the shared helper TODOS.md files lands). An entry is OURS +# when: it is a symlink resolving into the gstack payload / render dir (or any +# path with a `gstack` segment, the convention cleanup and gstack-uninstall +# already use, so entries from a sibling worktree still count), a real dir +# whose SKILL.md is such a symlink, or a real-file copy proven by the +# .gstack-owned marker, byte-identity with the source, or gen-skill-docs' +# generated header. Anything else is FOREIGN: skipped, reported, listed in +# the final summary. +_FOREIGN_SKIPPED_ENTRIES=() +_gstack_link_target_abs() { + # readlink of a relative link is relative to the link's directory; anchor it + # there and canonicalize the directory part (`..`, symlinked components). + local link="$1" dest d b d_real + dest="$(readlink "$link" 2>/dev/null || true)" + [ -n "$dest" ] || return 1 + case "$dest" in /*) ;; *) dest="$(dirname "$link")/$dest" ;; esac + d="${dest%/*}"; b="${dest##*/}" + if d_real="$(cd "$d" 2>/dev/null && pwd -P)"; then printf '%s\n' "$d_real/$b"; else printf '%s\n' "$dest"; fi +} +_gstack_target_is_ours() { + # $1 = absolute target path, $2 = gstack payload dir + local t="$1" g="$2" g_real render render_real + g_real="$(cd "$g" 2>/dev/null && pwd -P || printf '%s' "$g")" + render="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}" + render_real="$(cd "$render" 2>/dev/null && pwd -P || printf '%s' "$render")" + case "$t" in + "$g"/*|"$g_real"/*|"$render"/*|"$render_real"/*|gstack/*|*/gstack/*|*/.gstack/render/claude/*) return 0 ;; + esac + return 1 +} +_claude_entry_is_ours() { + # $1 = existing entry (dir or symlink), $2 = the gstack source SKILL.md it + # would be linked to, $3 = gstack payload dir + local entry="$1" src_md="$2" g="$3" dest + if [ -L "$entry" ]; then + dest="$(_gstack_link_target_abs "$entry")" || return 1 + _gstack_target_is_ours "$dest" "$g"; return $? + fi + [ -d "$entry" ] || return 1 + [ -f "$entry/.gstack-owned" ] && return 0 + if [ -L "$entry/SKILL.md" ]; then + dest="$(_gstack_link_target_abs "$entry/SKILL.md")" || return 1 + _gstack_target_is_ours "$dest" "$g"; return $? + fi + if [ -f "$entry/SKILL.md" ]; then + [ -n "$src_md" ] && [ -f "$src_md" ] && cmp -s "$entry/SKILL.md" "$src_md" && return 0 + _gstack_generated_header "$entry/SKILL.md" && return 0 + fi + return 1 +} +# _gstack_generated_header FILE — a pre-marker legacy COPY (Windows, before +# .gstack-owned existed) is recognized by gen-skill-docs' full two-line banner +# near the top, not by a one-line substring another generator could plausibly +# emit. Still forgeable by a gstack fork that renders the same banner — that +# residual is accepted and filed; the marker is the load-bearing signal. +_gstack_generated_header() { + local f="$1" head40 + head40="$(head -n 40 "$f" 2>/dev/null)" || return 1 + case "$head40" in + *''*) return 0 ;; + esac + return 1 +} +_write_owned_marker() { + # Windows copy installs have no symlink to readlink; the marker proves + # provenance. Records the owning payload's real path for forensics. + local dir="$1" g="$2" + printf '%s\n' "$(cd "$g" 2>/dev/null && pwd -P || printf '%s' "$g")" > "$dir/.gstack-owned" 2>/dev/null || true +} + # ─── Ownership gates for the Windows refresh bypass (#2444 → #2142) ───────── # On Windows a refresh means rm -rf + re-copy (_link_or_copy). The host # skills dirs are SHARED namespaces (~/.codex/skills, ~/.factory/skills, @@ -798,7 +871,16 @@ _pw_fail() { echo " Chromium bootstrap: $code — $*" >&2 } _PW_INSTALL_TIMEOUT="${GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT:-600}" +# Positive seconds only: 0 would kill the install on the first poll, so it +# (and anything non-numeric) falls back to the default. case "$_PW_INSTALL_TIMEOUT" in ''|*[!0-9]*) _PW_INSTALL_TIMEOUT=600 ;; esac +# Normalize to a plain positive integer: "000"/"0" mean the default (never +# kill-on-first-poll), "0600" is 600, and anything past nine digits is garbage +# rather than a deadline (a value bash cannot compare would leave the install +# unbounded — the exact failure the bound exists to prevent). +[ "${#_PW_INSTALL_TIMEOUT}" -le 9 ] || _PW_INSTALL_TIMEOUT=600 +_PW_INSTALL_TIMEOUT=$((10#$_PW_INSTALL_TIMEOUT)) +[ "$_PW_INSTALL_TIMEOUT" -gt 0 ] || _PW_INSTALL_TIMEOUT=600 if [ "${GSTACK_SKIP_PLAYWRIGHT:-0}" = "1" ]; then _pw_fail skipped "GSTACK_SKIP_PLAYWRIGHT=1 — Chromium install skipped by request (#913)" @@ -833,8 +915,14 @@ elif ! ensure_playwright_browser; then bunx playwright install chromium fi ) & + _PW_PID=$! + # Ctrl-C during the download: a backgrounded child ignores SIGINT, so + # without this the installer would keep running as an orphan while the + # EXIT trap frees the lock — the #2136 pile-up the lock exists to prevent. + trap '_kill_tree "$_PW_PID" 2>/dev/null; rm -rf "$_PW_LOCK" 2>/dev/null || true; cleanup_copied_bun; exit 130' INT TERM _PW_RC=0 - _wait_with_deadline $! "$_PW_INSTALL_TIMEOUT" || _PW_RC=$? + _wait_with_deadline "$_PW_PID" "$_PW_INSTALL_TIMEOUT" || _PW_RC=$? + trap - INT TERM if [ "$_PW_RC" -eq 124 ]; then _pw_fail chromium-install-timeout "bunx playwright install chromium exceeded ${_PW_INSTALL_TIMEOUT}s and was killed (raise with GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=)" elif [ "$_PW_RC" -ne 0 ]; then @@ -857,12 +945,14 @@ elif ! ensure_playwright_browser; then echo "Windows detected — verifying Node.js can load Playwright..." if ! ( cd "$SOURCE_GSTACK_DIR" - # Bun's node_modules already has playwright; verify Node can require it - node -e "require('playwright')" 2>/dev/null || npm install --no-save playwright - # @ngrok/ngrok is externalized in server-node.mjs and resolved at runtime. - # Verify the platform-specific native binary is installed so /pair-agent + # Bun's node_modules already has playwright; verify Node can require it. + # @ngrok/ngrok is externalized in server-node.mjs and resolved at runtime; + # verify the platform-specific native binary is installed so /pair-agent # tunnels don't fail later with a cryptic module-not-found error. - node -e "require('@ngrok/ngrok')" 2>/dev/null || npm install --no-save @ngrok/ngrok + # &&-chained: errexit is off inside an `if` condition, so a failed npm + # install on the first line must not be masked by the second. + { node -e "require('playwright')" 2>/dev/null || npm install --no-save playwright; } && + { node -e "require('@ngrok/ngrok')" 2>/dev/null || npm install --no-save @ngrok/ngrok; } ); then _pw_fail windows-node-modules "npm could not install playwright / @ngrok/ngrok for Node.js" fi @@ -969,6 +1059,13 @@ link_claude_skill_dirs() { link_name="$skill_name" fi target="$skills_dir/$link_name" + # #2119: a destination that exists and is NOT ours is a user's skill that + # shares our name. Never rm/mkdir/ln into it — skip and report. + if { [ -e "$target" ] || [ -L "$target" ]; } && ! _claude_entry_is_ours "$target" "$gstack_dir/$dir_name/SKILL.md" "$gstack_dir"; then + echo " skipped $link_name: existing entry is not gstack-managed (foreign skill with the same name) — left untouched" >&2 + _FOREIGN_SKIPPED_ENTRIES+=("$link_name") + continue + fi # Upgrade old directory symlinks to real directories if [ -L "$target" ]; then rm -f "$target" @@ -993,7 +1090,7 @@ link_claude_skill_dirs() { # Ownership marker for Windows COPY installs (#2119): there is no symlink # to readlink, so gstack-relink and the mode-flip cleanup below prove # provenance by this marker instead of by name. - if [ "$IS_WINDOWS" -eq 1 ]; then : > "$target/.gstack-owned" 2>/dev/null || true; fi + if [ "$IS_WINDOWS" -eq 1 ]; then _write_owned_marker "$target" "$gstack_dir"; fi # Link every runtime asset the skill ships next to its SKILL.md (#2317, # #2454): sections/ for carved skills, review's checklist.md + # specialists/, qa's templates/ + references/, gstack-upgrade's @@ -1027,6 +1124,14 @@ _install_alias_skill_md() { local dst_dir="$2" local alias_name="$3" [ -f "$src_skill_md" ] || return 0 + # #2119: an existing alias-named entry that is not ours is a user's skill. + # (Ours: a whole-dir symlink into gstack, or a copy carrying the generated + # header — every alias copy does.) + if { [ -e "$dst_dir" ] || [ -L "$dst_dir" ]; } && ! _claude_entry_is_ours "$dst_dir" "$src_skill_md" "$SOURCE_GSTACK_DIR"; then + echo " skipped $alias_name: existing entry is not gstack-managed (foreign skill with the same name) — left untouched" >&2 + _FOREIGN_SKIPPED_ENTRIES+=("$alias_name") + return 0 + fi # Old installs left the alias as a whole-dir symlink — replace it. if [ -L "$dst_dir" ]; then rm -f "$dst_dir"; fi mkdir -p "$dst_dir" @@ -1114,7 +1219,7 @@ cleanup_old_claude_symlinks() { && [ -f "$old_target/SKILL.md" ] && [ ! -L "$old_target/SKILL.md" ] \ && { [ -f "$old_target/.gstack-owned" ] \ || cmp -s "$old_target/SKILL.md" "$skill_dir/SKILL.md" \ - || grep -q 'AUTO-GENERATED from SKILL.md.tmpl' "$old_target/SKILL.md" 2>/dev/null; }; then + || _gstack_generated_header "$old_target/SKILL.md"; }; then rm -rf "$old_target" removed+=("$skill_name") fi @@ -1141,11 +1246,13 @@ cleanup_prefixed_claude_symlinks() { # (e.g., remove gstack-qa but NOT gstack-upgrade which is the real dir name) case "$skill_name" in gstack-*) continue ;; esac prefixed_target="$skills_dir/gstack-$skill_name" - # Remove directory symlinks pointing into gstack/ + # Remove directory symlinks pointing into gstack/ — anchored path + # segments, same as cleanup_old_claude_symlinks and gstack-uninstall: a + # bare *gstack* substring would wipe a user skill under ~/tools/gstack-fork/. if [ -L "$prefixed_target" ]; then link_dest="$(readlink "$prefixed_target" 2>/dev/null || true)" case "$link_dest" in - gstack/*|*/gstack/*) + gstack/*|*/gstack/*|*/.gstack/render/claude/*) rm -f "$prefixed_target" removed+=("gstack-$skill_name") ;; @@ -1154,15 +1261,19 @@ cleanup_prefixed_claude_symlinks() { elif [ -d "$prefixed_target" ] && [ -L "$prefixed_target/SKILL.md" ]; then link_dest="$(readlink "$prefixed_target/SKILL.md" 2>/dev/null || true)" case "$link_dest" in - *gstack*) + gstack/*|*/gstack/*|*/.gstack/render/claude/*) rm -rf "$prefixed_target" removed+=("gstack-$skill_name") ;; esac - # Windows install pattern: real dir with real-file SKILL.md. Same - # reasoning as cleanup_old_claude_symlinks — directory name match plus - # IS_WINDOWS is safe during a mode flip. - elif [ "$IS_WINDOWS" -eq 1 ] && [ -d "$prefixed_target" ] && [ -f "$prefixed_target/SKILL.md" ]; then + # Windows install pattern: real dir with real-file SKILL.md. Provenance + # must be PROVEN (#2119), never assumed from the name: the marker + # link_claude_skill_dirs writes, a byte-identical copy of the source, or + # gen-skill-docs' generated header (legacy copies made before the marker). + elif [ "$IS_WINDOWS" -eq 1 ] && [ -d "$prefixed_target" ] && [ -f "$prefixed_target/SKILL.md" ] && [ ! -L "$prefixed_target/SKILL.md" ] \ + && { [ -f "$prefixed_target/.gstack-owned" ] \ + || cmp -s "$prefixed_target/SKILL.md" "$skill_dir/SKILL.md" \ + || _gstack_generated_header "$prefixed_target/SKILL.md"; }; then rm -rf "$prefixed_target" removed+=("gstack-$skill_name") fi @@ -1721,7 +1832,10 @@ if [ "$INSTALL_CLAUDE" -eq 1 ]; then # setup, stale git state, or gen:skill-docs left name: fields out of sync. GSTACK_RELINK="$SOURCE_GSTACK_DIR/bin/gstack-relink" if [ -x "$GSTACK_RELINK" ]; then - GSTACK_SKILLS_DIR="$INSTALL_SKILLS_DIR" GSTACK_INSTALL_DIR="$SOURCE_GSTACK_DIR" "$GSTACK_RELINK" >/dev/null 2>&1 || true + # relink's own "skipped" lines (foreign entries) must reach the user; + # everything else it prints is noise here. + _RELINK_OUT="$(GSTACK_SKILLS_DIR="$INSTALL_SKILLS_DIR" GSTACK_INSTALL_DIR="$SOURCE_GSTACK_DIR" "$GSTACK_RELINK" 2>&1 || true)" + printf '%s\n' "$_RELINK_OUT" | grep '^ skipped ' >&2 || true fi # Backwards-compat alias: /connect-chrome → /open-gstack-browser # Rewritten copy, not a symlink: a symlinked alias re-serves the canonical @@ -1794,7 +1908,8 @@ if [ "$INSTALL_CLAUDE" -eq 1 ]; then _CLAUDE_SKILLS_LINKED=1 GSTACK_RELINK="$SOURCE_GSTACK_DIR/bin/gstack-relink" if [ -x "$GSTACK_RELINK" ]; then - GSTACK_SKILLS_DIR="$INSTALL_SKILLS_DIR" GSTACK_INSTALL_DIR="$SOURCE_GSTACK_DIR" "$GSTACK_RELINK" >/dev/null 2>&1 || true + _RELINK_OUT="$(GSTACK_SKILLS_DIR="$INSTALL_SKILLS_DIR" GSTACK_INSTALL_DIR="$SOURCE_GSTACK_DIR" "$GSTACK_RELINK" 2>&1 || true)" + printf '%s\n' "$_RELINK_OUT" | grep '^ skipped ' >&2 || true fi # Rewritten copy, not a symlink: a symlinked alias re-serves the # canonical name: open-gstack-browser, so one of the two silently @@ -2630,10 +2745,16 @@ fi # ─── Chromium bootstrap summary (best-effort browser, see # 2) ─────────────── # Printed LAST so it is the thing the user sees, after every skill registered. -if [ -n "${_PW_FAIL_REASON:-}" ]; then +_PW_BROWSER_SKILLS="/qa, /qa-only, /design-review, /browse, make-pdf, /pair-agent" +if [ "${_PW_FAIL_REASON:-}" = "skipped" ]; then + # An explicit opt-out is not a failure: say what is unavailable and stop. + log "" + log "Chromium install skipped by request (GSTACK_SKIP_PLAYWRIGHT=1)." + log " Browser skills ($_PW_BROWSER_SKILLS) need it; re-run ./setup without the flag when you want them." +elif [ -n "${_PW_FAIL_REASON:-}" ]; then log "" log "Browser unavailable: Chromium bootstrap did not complete ($_PW_FAIL_REASON)." - log " Skills that need it: /qa, /qa-only, /design-review, /browse, make-pdf, /pair-agent." + log " Skills that need it: $_PW_BROWSER_SKILLS." log " Everything else is installed and works. Fix the cause and re-run ./setup." case "$_PW_FAIL_REASON" in *chromium-install-timeout*) log " Slow link? Raise the bound: GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=1800 ./setup" ;; diff --git a/test/relink.test.ts b/test/relink.test.ts index fe495c960..f6d69a246 100644 --- a/test/relink.test.ts +++ b/test/relink.test.ts @@ -805,4 +805,175 @@ describe('gstack-relink ownership gate (#2119)', () => { expect(fs.readFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), 'utf-8')).toBe(FOREIGN); expect(out).toContain('skipped'); }); + + // Before the gate, a stray regular FILE named like a skill made `mkdir -p` + // fail under set -e and relink died mid-loop (exit 1, later skills never + // linked). A non-dir, non-symlink entry is simply foreign now. + test('a stray regular file with a skill name is foreign: relink completes, file untouched, other skills link', () => { + setupMockInstall(['qa', 'ship']); + fs.writeFileSync(path.join(skillsDir, 'qa'), 'stray notes\n'); + setPrefix('false'); + const out = relink(); + expect(fs.lstatSync(path.join(skillsDir, 'qa')).isFile()).toBe(true); + expect(fs.readFileSync(path.join(skillsDir, 'qa'), 'utf-8')).toBe('stray notes\n'); + expect(fs.lstatSync(path.join(skillsDir, 'ship', 'SKILL.md')).isSymbolicLink()).toBe(true); + expect(out).toContain('Relinked 1 skills as flat names'); + expect(out).toContain('Skipped 1 foreign entry'); + }); + + test('a real dir whose SKILL.md symlink points OUTSIDE install/render is foreign in both the link pass and the flip cleanup', () => { + setupMockInstall(['qa']); + const elsewhere = path.join(tmpDir, 'elsewhere', 'qa'); + fs.mkdirSync(elsewhere, { recursive: true }); + fs.writeFileSync(path.join(elsewhere, 'SKILL.md'), FOREIGN); + fs.mkdirSync(path.join(skillsDir, 'qa')); + fs.symlinkSync(path.join(elsewhere, 'SKILL.md'), path.join(skillsDir, 'qa', 'SKILL.md')); + // Link pass (flat mode): the destination is not ours → never re-pointed. + setPrefix('false'); + let out = relink(); + expect(fs.readlinkSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(path.join(elsewhere, 'SKILL.md')); + expect(out).toContain('skipped'); + // Cleanup pass (prefix flip): the stale flat name is not ours → kept. + setPrefix('true'); + out = relink(); + expect(fs.readlinkSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(path.join(elsewhere, 'SKILL.md')); + expect(fs.existsSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'))).toBe(true); + expect(out).toContain('skipped'); + }); + + test('a sibling directory that merely shares the install dir as a prefix (…/gstack-install-fork) is not ours', () => { + setupMockInstall(['qa']); + const fork = installDir + '-fork'; + fs.mkdirSync(path.join(fork, 'qa'), { recursive: true }); + fs.writeFileSync(path.join(fork, 'qa', 'SKILL.md'), FOREIGN); + fs.mkdirSync(path.join(skillsDir, 'qa')); + fs.symlinkSync(path.join(fork, 'qa', 'SKILL.md'), path.join(skillsDir, 'qa', 'SKILL.md')); + setPrefix('false'); + const out = relink(); + expect(fs.readlinkSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(path.join(fork, 'qa', 'SKILL.md')); + expect(fs.readFileSync(path.join(fork, 'qa', 'SKILL.md'), 'utf-8')).toBe(FOREIGN); + expect(out).toContain('skipped'); + }); + + test('several foreign entries: pluralized summary lists every path and the relinked count excludes them', () => { + setupMockInstall(['qa', 'ship', 'review']); + for (const name of ['qa', 'ship']) { + fs.mkdirSync(path.join(skillsDir, name)); + fs.writeFileSync(path.join(skillsDir, name, 'SKILL.md'), FOREIGN); + } + setPrefix('false'); + const out = relink(); + expect(out).toContain('Relinked 1 skills as flat names'); + expect(out).toContain('Skipped 2 foreign entries'); + expect(out).toContain(path.join(skillsDir, 'qa')); + expect(out).toContain(path.join(skillsDir, 'ship')); + expect(out).not.toContain('foreign entry '); + expect(fs.lstatSync(path.join(skillsDir, 'review', 'SKILL.md')).isSymbolicLink()).toBe(true); + }); + + test('an opposite-mode WHOLE-DIR symlink into the install (oldest install shape) is ours and is removed on a flip', () => { + setupMockInstall(['qa']); + fs.symlinkSync(path.join(installDir, 'qa'), path.join(skillsDir, 'gstack-qa')); + setPrefix('false'); + const out = relink(); + expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false); + expect(fs.lstatSync(path.join(skillsDir, 'gstack-qa'), { throwIfNoEntry: false })).toBeUndefined(); + expect(fs.lstatSync(path.join(skillsDir, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(true); + expect(out).not.toContain('skipped'); + }); +}); + +describe('gstack-relink ownership gate parity with setup (#2119 review fixes)', () => { + const FOREIGN = '---\nname: qa\ndescription: my own qa skill\n---\n# not gstack'; + function relink(env: Record = {}): string { + return run(`${path.join(installDir, 'bin', 'gstack-relink')} 2>&1`, { + GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, ...env, + }); + } + function setPrefix(v: 'true' | 'false') { + run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix ${v}`, { + GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, + }); + } + + test('a pre-marker legacy COPY carrying the generated header is ours (same rule as setup) and is cleaned on a flip', () => { + setupMockInstall(['qa']); + setPrefix('false'); + fs.mkdirSync(path.join(skillsDir, 'gstack-qa')); + fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), '---\nname: gstack-qa\n---\n\n\n# qa\n'); + const out = relink(); + expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false); + expect(out).not.toContain('skipped'); + }); + + test('a ONE-line AUTO-GENERATED substring is not provenance (another generator could emit it): entry survives', () => { + setupMockInstall(['qa']); + setPrefix('false'); + fs.mkdirSync(path.join(skillsDir, 'gstack-qa')); + fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), '---\nname: gstack-qa\n---\n\n# theirs\n'); + const out = relink(); + expect(fs.existsSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'))).toBe(true); + expect(out).toContain('skipped'); + }); + + test('an absolute link that walks through a gstack segment with `..` is canonicalized first, not fast-pathed as ours', () => { + setupMockInstall(['qa']); + const decoy = path.join(tmpDir, 'x', 'gstack'); + const foreign = path.join(tmpDir, 'x', 'foreign'); + fs.mkdirSync(decoy, { recursive: true }); + fs.mkdirSync(foreign, { recursive: true }); + fs.writeFileSync(path.join(foreign, 'SKILL.md'), FOREIGN); + fs.mkdirSync(path.join(skillsDir, 'gstack-qa')); + fs.symlinkSync(path.join(decoy, '..', 'foreign', 'SKILL.md'), path.join(skillsDir, 'gstack-qa', 'SKILL.md')); + setPrefix('false'); + const out = relink(); + expect(fs.existsSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'))).toBe(true); + expect(out).toContain('skipped'); + }); + + test('a byte-identical copy of our source SKILL.md is ours even without marker or header', () => { + setupMockInstall(['qa']); + setPrefix('false'); + fs.mkdirSync(path.join(skillsDir, 'gstack-qa')); + fs.copyFileSync(path.join(installDir, 'qa', 'SKILL.md'), path.join(skillsDir, 'gstack-qa', 'SKILL.md')); + const out = relink(); + expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false); + expect(out).not.toContain('skipped'); + }); + + test('an entry linked into a SIBLING gstack checkout (path segment `gstack`) is ours, like setup and uninstall treat it', () => { + setupMockInstall(['qa']); + setPrefix('false'); + const sibling = path.join(tmpDir, 'worktrees', 'gstack', 'qa'); + fs.mkdirSync(sibling, { recursive: true }); + fs.writeFileSync(path.join(sibling, 'SKILL.md'), '---\nname: qa\n---\n'); + fs.mkdirSync(path.join(skillsDir, 'gstack-qa')); + fs.symlinkSync(path.join(sibling, 'SKILL.md'), path.join(skillsDir, 'gstack-qa', 'SKILL.md')); + const out = relink(); + expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false); + expect(out).not.toContain('skipped'); + }); + + test('a fork under a `gstack-fork` directory is NOT ours (segment must be exactly gstack)', () => { + setupMockInstall(['qa']); + const fork = path.join(tmpDir, 'tools', 'gstack-fork', 'qa'); + fs.mkdirSync(fork, { recursive: true }); + fs.writeFileSync(path.join(fork, 'SKILL.md'), FOREIGN); + fs.mkdirSync(path.join(skillsDir, 'qa')); + fs.symlinkSync(path.join(fork, 'SKILL.md'), path.join(skillsDir, 'qa', 'SKILL.md')); + setPrefix('false'); + const out = relink(); + expect(fs.readlinkSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(path.join(fork, 'SKILL.md')); + expect(out).toContain('skipped'); + }); + + test('a DANGLING link from a moved checkout is healed, not reported foreign', () => { + setupMockInstall(['qa']); + fs.mkdirSync(path.join(skillsDir, 'qa')); + fs.symlinkSync(path.join(tmpDir, 'old-checkout', 'gstack', 'qa', 'SKILL.md'), path.join(skillsDir, 'qa', 'SKILL.md')); + setPrefix('false'); + const out = relink(); + expect(out).not.toContain('skipped'); + expect(fs.readlinkSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(path.join(installDir, 'qa', 'SKILL.md')); + }); }); diff --git a/test/setup-cleanup-orphans.test.ts b/test/setup-cleanup-orphans.test.ts index 26a1793eb..a3f4d25fc 100644 --- a/test/setup-cleanup-orphans.test.ts +++ b/test/setup-cleanup-orphans.test.ts @@ -24,7 +24,7 @@ function extractFn(name: string): string { } function cleanupBody(): string { - return extractFn('cleanup_old_claude_symlinks'); + return extractFn('_gstack_generated_header') + extractFn('cleanup_old_claude_symlinks'); } describe('setup: cleanup_old_claude_symlinks — static (#2204)', () => { @@ -68,6 +68,7 @@ describe.skipIf(process.platform === 'win32')('setup: cleanup_old_claude_symlink const script = [ 'set -e', `IS_WINDOWS=${opts.isWindows ?? '0'}`, + extractFn('_gstack_generated_header'), extractFn('cleanup_old_claude_symlinks'), `cleanup_old_claude_symlinks "${gstackArg}" "${skills}"`, ].join('\n'); @@ -264,7 +265,7 @@ describe.skipIf(process.platform === 'win32')('setup: cleanup_old_claude_symlink // marker, a byte-identical copy of the payload source, or gen-skill-docs' // AUTO-GENERATED header (legacy copies made before the marker existed). test('Windows real-file leftover is removed only when provably gstack-owned', () => { - const generated = '---\nname: ship\n---\n\n# ship\n'; + const generated = '---\nname: ship\n---\n\n\n# ship\n'; const r = runCleanup({ isWindows: '1', payload: true, @@ -296,4 +297,30 @@ describe.skipIf(process.platform === 'win32')('setup: cleanup_old_claude_symlink fs.rmSync(r.tmp, { recursive: true, force: true }); } }); + + // The Windows arm is the ONLY path that touches a real-file SKILL.md. On + // Unix a same-name real-file skill must survive even when the payload names + // it and even when its bytes are identical to the payload source. + test('Unix (IS_WINDOWS=0): a same-name real-file skill is never reaped, even if byte-identical to the payload', () => { + const r = runCleanup({ + isWindows: '0', + payload: true, + plant(skills, payload) { + fs.mkdirSync(path.join(payload, 'qa')); + fs.writeFileSync(path.join(payload, 'qa', 'SKILL.md'), '---\nname: qa\n---\n'); + fs.mkdirSync(path.join(skills, 'qa')); + fs.copyFileSync(path.join(payload, 'qa', 'SKILL.md'), path.join(skills, 'qa', 'SKILL.md')); + fs.mkdirSync(path.join(skills, 'ship')); + fs.writeFileSync(path.join(skills, 'ship', 'SKILL.md'), '---\nname: ship\n---\n'); + fs.writeFileSync(path.join(skills, 'ship', '.gstack-owned'), ''); + }, + }); + try { + expect(r.status).toBe(0); + expect(r.stdout).toBe(''); + expect(r.names).toEqual(['gstack', 'qa', 'ship']); + } finally { + fs.rmSync(r.tmp, { recursive: true, force: true }); + } + }); }); diff --git a/test/setup-link-ownership.test.ts b/test/setup-link-ownership.test.ts new file mode 100644 index 000000000..c574ebf22 --- /dev/null +++ b/test/setup-link-ownership.test.ts @@ -0,0 +1,213 @@ +/** + * setup never links over, copies over, or deletes a skill it does not own + * (#2119). The relink gate alone was not enough: link_claude_skill_dirs runs + * BEFORE relink on every ./setup, and on Linux `ln -snf` replaces a user's + * real SKILL.md with a symlink into gstack (on Windows: rm -rf + cp, then a + * marker that makes the user's dir "ours" on the next flip). The reverse + * mode-flip cleanup (cleanup_prefixed_claude_symlinks) kept a bare name-match + * deletion and a `*gstack*` substring match. Same anchor-sliced convention as + * test/setup-cleanup-orphans.test.ts. + */ +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(`function not found: ${name}`); + return SETUP_SRC.slice(start, end + 2); +} +const HELPERS = [ + '_FOREIGN_SKIPPED_ENTRIES=()', + '_link_skill_runtime_assets() { :; }', + '_print_windows_copy_note_once() { :; }', + extractFn('_link_or_copy'), + extractFn('_gstack_link_target_abs'), + extractFn('_gstack_target_is_ours'), + extractFn('_claude_entry_is_ours'), + extractFn('_write_owned_marker'), + extractFn('_gstack_generated_header'), +].join('\n'); + +const FOREIGN = '---\nname: qa\ndescription: mine\n---\n# not gstack\n'; +const GENERATED = (name: string) => `---\nname: ${name}\n---\n\n\n# ${name}\n`; + +function mkTree(): { tmp: string; skills: string; payload: string } { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-link-own-')); + const skills = path.join(tmp, 'skills'); + const payload = path.join(skills, 'gstack'); + fs.mkdirSync(payload, { recursive: true }); + return { tmp, skills, payload }; +} +function bash(lines: string[], tmp: string) { + const r = spawnSync('bash', ['-c', lines.join('\n')], { + encoding: 'utf-8', timeout: 10_000, + env: { PATH: process.env.PATH ?? '', HOME: tmp, GSTACK_USER_RENDER_DIR: path.join(tmp, 'no-render') }, + }); + return { status: r.status ?? -1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }; +} + +describe.skipIf(process.platform === 'win32')('setup: link_claude_skill_dirs never links over a foreign skill (#2119)', () => { + for (const isWindows of ['0', '1'] as const) { + test(`IS_WINDOWS=${isWindows}: a foreign real SKILL.md survives byte-identical, gets no marker, is reported and counted`, () => { + const t = mkTree(); + try { + fs.mkdirSync(path.join(t.payload, 'qa')); + fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa')); + fs.mkdirSync(path.join(t.payload, 'ship')); + fs.writeFileSync(path.join(t.payload, 'ship', 'SKILL.md'), GENERATED('ship')); + fs.mkdirSync(path.join(t.skills, 'qa')); + fs.writeFileSync(path.join(t.skills, 'qa', 'SKILL.md'), FOREIGN); + const r = bash(['set -e', `IS_WINDOWS=${isWindows}`, 'SKILL_PREFIX=0', HELPERS, + extractFn('link_claude_skill_dirs'), + `link_claude_skill_dirs "${t.payload}" "${t.skills}"`, + 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp); + expect(r.status).toBe(0); + const md = path.join(t.skills, 'qa', 'SKILL.md'); + expect(fs.lstatSync(md).isSymbolicLink()).toBe(false); + expect(fs.readFileSync(md, 'utf-8')).toBe(FOREIGN); + expect(fs.existsSync(path.join(t.skills, 'qa', '.gstack-owned'))).toBe(false); + expect(r.stderr).toContain('skipped qa'); + expect(r.stdout).toContain('FOREIGN=qa'); + // The other skill still links normally. + expect(fs.existsSync(path.join(t.skills, 'ship', 'SKILL.md'))).toBe(true); + expect(r.stdout).toContain('linked skills: ship'); + } finally { + fs.rmSync(t.tmp, { recursive: true, force: true }); + } + }); + } + + test('our own previous entry (symlink into the payload) is refreshed, not skipped', () => { + const t = mkTree(); + try { + fs.mkdirSync(path.join(t.payload, 'qa')); + fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa')); + fs.mkdirSync(path.join(t.skills, 'qa')); + fs.symlinkSync(path.join(t.payload, 'qa', 'SKILL.md'), path.join(t.skills, 'qa', 'SKILL.md')); + const r = bash(['set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', HELPERS, extractFn('link_claude_skill_dirs'), + `link_claude_skill_dirs "${t.payload}" "${t.skills}"`, 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp); + expect(r.status).toBe(0); + expect(r.stdout).toContain('FOREIGN=\n'); + expect(fs.lstatSync(path.join(t.skills, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(true); + } finally { + fs.rmSync(t.tmp, { recursive: true, force: true }); + } + }); + + test('the Windows marker records the owning payload path and a marked copy is ours on the next run', () => { + const t = mkTree(); + try { + fs.mkdirSync(path.join(t.payload, 'qa')); + fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa')); + const first = bash(['set -e', 'IS_WINDOWS=1', 'SKILL_PREFIX=0', HELPERS, extractFn('link_claude_skill_dirs'), + `link_claude_skill_dirs "${t.payload}" "${t.skills}"`], t.tmp); + expect(first.status).toBe(0); + const marker = path.join(t.skills, 'qa', '.gstack-owned'); + expect(fs.readFileSync(marker, 'utf-8').trim()).toBe(fs.realpathSync(t.payload)); + // Second run over our own copy: refreshed, not reported. + const second = bash(['set -e', 'IS_WINDOWS=1', 'SKILL_PREFIX=0', HELPERS, extractFn('link_claude_skill_dirs'), + `link_claude_skill_dirs "${t.payload}" "${t.skills}"`, 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp); + expect(second.stdout).toContain('FOREIGN=\n'); + } finally { + fs.rmSync(t.tmp, { recursive: true, force: true }); + } + }); +}); + +describe.skipIf(process.platform === 'win32')('setup: _install_alias_skill_md never overwrites a foreign alias-named skill (#2119)', () => { + test('a user skill named connect-chrome survives; a generated alias copy is refreshed', () => { + const t = mkTree(); + try { + fs.mkdirSync(path.join(t.payload, 'open-gstack-browser')); + fs.writeFileSync(path.join(t.payload, 'open-gstack-browser', 'SKILL.md'), GENERATED('open-gstack-browser')); + fs.mkdirSync(path.join(t.skills, 'connect-chrome')); + fs.writeFileSync(path.join(t.skills, 'connect-chrome', 'SKILL.md'), FOREIGN); + fs.mkdirSync(path.join(t.skills, 'gstack-connect-chrome')); + fs.writeFileSync(path.join(t.skills, 'gstack-connect-chrome', 'SKILL.md'), GENERATED('gstack-connect-chrome').replace('# gstack-connect-chrome', '# old alias copy')); + const r = bash(['set -e', 'IS_WINDOWS=0', `SOURCE_GSTACK_DIR="${t.payload}"`, HELPERS, extractFn('_install_alias_skill_md'), + `_install_alias_skill_md "${t.payload}/open-gstack-browser/SKILL.md" "${t.skills}/connect-chrome" connect-chrome`, + `_install_alias_skill_md "${t.payload}/open-gstack-browser/SKILL.md" "${t.skills}/gstack-connect-chrome" gstack-connect-chrome`, + 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp); + expect(r.status).toBe(0); + expect(fs.readFileSync(path.join(t.skills, 'connect-chrome', 'SKILL.md'), 'utf-8')).toBe(FOREIGN); + expect(r.stdout).toContain('FOREIGN=connect-chrome'); + expect(fs.readFileSync(path.join(t.skills, 'gstack-connect-chrome', 'SKILL.md'), 'utf-8')).toContain('name: gstack-connect-chrome'); + expect(fs.readFileSync(path.join(t.skills, 'gstack-connect-chrome', 'SKILL.md'), 'utf-8')).not.toContain('old alias copy'); + } finally { + fs.rmSync(t.tmp, { recursive: true, force: true }); + } + }); +}); + +describe.skipIf(process.platform === 'win32')('setup: cleanup_prefixed_claude_symlinks proves provenance (#2119)', () => { + function runFlip(isWindows: '0' | '1', plant: (skills: string, payload: string) => void) { + const t = mkTree(); + fs.mkdirSync(path.join(t.payload, 'qa')); + fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa')); + plant(t.skills, t.payload); + const r = bash(['set -e', `IS_WINDOWS=${isWindows}`, extractFn('_gstack_generated_header'), extractFn('cleanup_prefixed_claude_symlinks'), + `cleanup_prefixed_claude_symlinks "${t.payload}" "${t.skills}"`], t.tmp); + const names = fs.readdirSync(t.skills).sort(); + fs.rmSync(t.tmp, { recursive: true, force: true }); + return { ...r, names }; + } + + test('Windows: a user-owned gstack-qa (no marker, not identical, no header) survives the prefix→flat flip', () => { + const r = runFlip('1', (skills) => { + fs.mkdirSync(path.join(skills, 'gstack-qa')); + fs.writeFileSync(path.join(skills, 'gstack-qa', 'SKILL.md'), '---\nname: gstack-qa\n---\n# user-owned\n'); + }); + expect(r.status).toBe(0); + expect(r.names).toEqual(['gstack', 'gstack-qa']); + expect(r.stdout).toBe(''); + }); + + test('Windows: marker, byte-identical, and generated-header copies are reaped', () => { + const r = runFlip('1', (skills, payload) => { + fs.mkdirSync(path.join(skills, 'gstack-qa')); + fs.copyFileSync(path.join(payload, 'qa', 'SKILL.md'), path.join(skills, 'gstack-qa', 'SKILL.md')); + }); + expect(r.names).toEqual(['gstack']); + const m = runFlip('1', (skills) => { + fs.mkdirSync(path.join(skills, 'gstack-qa')); + fs.writeFileSync(path.join(skills, 'gstack-qa', 'SKILL.md'), '---\nname: gstack-qa\n---\n# stale\n'); + fs.writeFileSync(path.join(skills, 'gstack-qa', '.gstack-owned'), ''); + }); + expect(m.names).toEqual(['gstack']); + const h = runFlip('1', (skills) => { + fs.mkdirSync(path.join(skills, 'gstack-qa')); + fs.writeFileSync(path.join(skills, 'gstack-qa', 'SKILL.md'), GENERATED('gstack-qa').replace('# gstack-qa', '# older render')); + }); + expect(h.names).toEqual(['gstack']); + }); + + test('Windows: a ONE-line AUTO-GENERATED substring from another generator is not provenance — the entry survives', () => { + const r = runFlip('1', (skills) => { + fs.mkdirSync(path.join(skills, 'gstack-qa')); + fs.writeFileSync(path.join(skills, 'gstack-qa', 'SKILL.md'), '---\nname: gstack-qa\n---\n\n# theirs\n'); + }); + expect(r.status).toBe(0); + expect(r.names).toEqual(['gstack', 'gstack-qa']); + }); + + test('a SKILL.md symlink whose target merely CONTAINS the substring gstack is not reaped; an anchored gstack/ segment is', () => { + const keep = runFlip('0', (skills) => { + fs.mkdirSync(path.join(skills, 'gstack-qa')); + fs.symlinkSync('../../archive/my-gstack-backup/SKILL.md', path.join(skills, 'gstack-qa', 'SKILL.md')); + }); + expect(keep.names).toEqual(['gstack', 'gstack-qa']); + const reap = runFlip('0', (skills) => { + fs.mkdirSync(path.join(skills, 'gstack-qa')); + fs.symlinkSync('../gstack/qa/SKILL.md', path.join(skills, 'gstack-qa', 'SKILL.md')); + }); + expect(reap.names).toEqual(['gstack']); + expect(reap.stdout).toContain('cleaned up prefixed entries: gstack-qa'); + }); +}); diff --git a/test/setup-playwright-best-effort.test.ts b/test/setup-playwright-best-effort.test.ts index a4cde9542..42ac69b0d 100644 --- a/test/setup-playwright-best-effort.test.ts +++ b/test/setup-playwright-best-effort.test.ts @@ -46,8 +46,13 @@ const codeLines = block.split('\n').filter((l) => !l.trim().startsWith('#')).joi describe('setup: Chromium bootstrap static invariants', () => { test('no exit inside the bootstrap block (skills must always register)', () => { - expect(codeLines).not.toMatch(/\bexit 1\b/); - expect(codeLines).not.toMatch(/\bexit\b/); + // The only `exit` allowed is inside the INT/TERM trap string (Ctrl-C must + // still terminate setup after killing the installer); a bare statement is not. + const statements = codeLines.split('\n').filter((l) => !/^\s*trap /.test(l)).join('\n'); + expect(statements).not.toMatch(/\bexit 1\b/); + expect(statements).not.toMatch(/\bexit\b/); + expect(codeLines).toMatch(/trap '_kill_tree "\$_PW_PID".*exit 130' INT TERM/); + expect(codeLines).toContain('trap - INT TERM'); }); test('every failure arm records a reason code', () => { @@ -60,15 +65,19 @@ describe('setup: Chromium bootstrap static invariants', () => { }); test('the download is deadline-bounded through the shared helper and env knob', () => { - expect(codeLines).toContain('_wait_with_deadline $! "$_PW_INSTALL_TIMEOUT"'); + expect(codeLines).toContain('_wait_with_deadline "$_PW_PID" "$_PW_INSTALL_TIMEOUT"'); expect(codeLines).toContain('GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT'); - // Non-numeric knob falls back to the default instead of breaking arithmetic. + // Non-numeric and 0 fall back to the default instead of breaking arithmetic + // or killing the install on the first poll. expect(codeLines).toMatch(/case "\$_PW_INSTALL_TIMEOUT" in ''\|\*\[!0-9\]\*\)/); + expect(codeLines).toContain('_PW_INSTALL_TIMEOUT=$((10#$_PW_INSTALL_TIMEOUT))'); + expect(codeLines).toContain('[ "$_PW_INSTALL_TIMEOUT" -gt 0 ] || _PW_INSTALL_TIMEOUT=600'); + expect(codeLines).toContain('[ "${#_PW_INSTALL_TIMEOUT}" -le 9 ] || _PW_INSTALL_TIMEOUT=600'); }); test('lock contention is a reason code, not a fatal', () => { - const lockElse = codeLines.slice(codeLines.indexOf('else\n _pw_fail chromium-install-locked')); - expect(lockElse.length).toBeGreaterThan(0); + expect(codeLines).toContain('_pw_fail chromium-install-locked'); + expect(codeLines).not.toMatch(/another gstack setup is already installing[\s\S]*exit 1/); expect(block).toContain('GSTACK_SKIP_PLAYWRIGHT'); }); @@ -102,11 +111,14 @@ describe('setup: Chromium bootstrap static invariants', () => { * can prove setup continued past the block. */ function runBlock(opts: { - probe: 'ok' | 'fail'; + probe: 'ok' | 'fail' | 'fail-then-ok'; // fail-then-ok = fresh install: probe fails, install runs, probe passes bunx: string; // body of the bunx stub env?: Record; preLockPid?: string; // pre-create the install lock held by this pid markKill?: boolean; // record _kill_tree invocations to $MARK + isWindows?: '0' | '1'; + prelude?: string; // extra shell lines (node/npm stubs) injected before the block + platformOverride?: string; // value for _PLAYWRIGHT_PLATFORM_OVERRIDE (Ubuntu 26.04 path) }): { stdout: string; stderr: string; status: number; elapsedMs: number; tmp: string } { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pw-block-')); const mark = path.join(tmp, 'mark'); @@ -119,21 +131,29 @@ function runBlock(opts: { fs.mkdirSync(lock); fs.writeFileSync(path.join(lock, 'pid'), opts.preLockPid); } + const probeFn = opts.probe === 'fail-then-ok' + ? 'ensure_playwright_browser() { if [ -f "$MARK.probed" ]; then return 0; fi; : > "$MARK.probed"; return 1; }' + : `ensure_playwright_browser() { ${opts.probe === 'ok' ? 'return 0' : 'return 1'}; }`; const script = [ 'set -e', - 'IS_WINDOWS=0', + `IS_WINDOWS=${opts.isWindows ?? '0'}`, `SOURCE_GSTACK_DIR="${tmp}"`, `TMPDIR="${tmp}"`, `MARK="${mark}"`, - '_PLAYWRIGHT_PLATFORM_OVERRIDE=""', - 'cleanup_copied_bun() { :; }', + `_PLAYWRIGHT_PLATFORM_OVERRIDE="${opts.platformOverride ?? ''}"`, + // EXIT-trap witness: the block chains its lock trap onto cleanup_copied_bun + // and must leave cleanup_copied_bun installed when it is done. + 'cleanup_copied_bun() { echo cleanup >> "$MARK.exit"; }', 'trap cleanup_copied_bun EXIT', '_clear_playwright_quarantine() { :; }', - `ensure_playwright_browser() { ${opts.probe === 'ok' ? 'return 0' : 'return 1'}; }`, - `bunx() { echo bunx-called >> "$MARK"; ${opts.bunx}; }`, + probeFn, + opts.prelude ?? '', + `bunx() { echo "bunx-called override=${'$'}{PLAYWRIGHT_HOST_PLATFORM_OVERRIDE:-unset}" >> "$MARK"; ${opts.bunx}; }`, killTree, extractFn('_wait_with_deadline'), - block, + // The block opens with the /etc/os-release probe that RESETS the override + // variable; a test that injects an override must start after that probe. + opts.platformOverride !== undefined ? slice('# Chromium is BEST-EFFORT', BLOCK_END) : block, 'echo "REASON=$_PW_FAIL_REASON"', 'echo "REACHED_END=1"', ].join('\n'); @@ -216,3 +236,318 @@ describe('setup: Chromium bootstrap block executes best-effort', () => { expect(fs.existsSync(path.join(r.tmp, 'mark'))).toBe(false); }); }); + +/** A PATH that carries the tools the block needs but NO node — `command -v` + * also finds shell functions, so hiding node from PATH is the only faithful + * way to stand in for a Windows box without Node.js. */ +function pathWithoutNode(): { bin: string; cleanup: () => void } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-nonode-')); + const bin = path.join(dir, 'bin'); + fs.mkdirSync(bin); + for (const name of ['bash', 'mkdir', 'rm', 'sleep', 'cat', 'pgrep', 'grep', 'cut', 'tr', 'dirname', 'basename']) { + const real = (spawnSync('which', [name], { encoding: 'utf-8', timeout: 10_000 }).stdout ?? '').trim(); + if (real) fs.symlinkSync(real, path.join(bin, name)); + } + return { bin, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) }; +} + +describe('setup: Chromium bootstrap block — fresh install, lock hygiene, override, Windows arms', () => { + test('fresh install happy path: probe fails, install succeeds, re-probe passes → no reason, lock released, EXIT trap restored', () => { + const r = runBlock({ probe: 'fail-then-ok', bunx: 'exit 0' }); + expect(r.status).toBe(0); + expect(r.stdout).toContain('REASON=\n'); + expect(r.stdout).toContain('REACHED_END=1'); + // Installer ran exactly once (no override on a non-26.04 path). + const mark = fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8'); + expect(mark.split('\n').filter((l) => l.startsWith('bunx-called')).length).toBe(1); + expect(mark).toContain('override=unset'); + // The mkdir-mutex is released for the next setup... + expect(fs.existsSync(path.join(r.tmp, 'gstack-playwright-install.lock'))).toBe(false); + // ...and the chained trap was restored, so cleanup_copied_bun still fires at exit. + expect(fs.readFileSync(path.join(r.tmp, 'mark.exit'), 'utf-8')).toContain('cleanup'); + }); + + test('failed install still releases the lock and keeps cleanup_copied_bun on the EXIT trap', () => { + const r = runBlock({ probe: 'fail', bunx: 'exit 7' }); + expect(r.status).toBe(0); + expect(r.stdout).toContain('REASON=chromium-install\n'); + expect(fs.existsSync(path.join(r.tmp, 'gstack-playwright-install.lock'))).toBe(false); + expect(fs.readFileSync(path.join(r.tmp, 'mark.exit'), 'utf-8')).toContain('cleanup'); + }); + + test('Ubuntu 26.04 override reaches the installer as PLAYWRIGHT_HOST_PLATFORM_OVERRIDE (#2101)', () => { + const r = runBlock({ probe: 'fail-then-ok', bunx: 'exit 0', platformOverride: 'ubuntu24.04-x64' }); + expect(r.status).toBe(0); + expect(r.stdout).toContain('REASON=\n'); + expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('override=ubuntu24.04-x64'); + }); + + test('Windows without Node.js: reason windows-no-node, setup continues (was: exit 1), post-install probe skipped', () => { + const { bin, cleanup } = pathWithoutNode(); + try { + const r = runBlock({ probe: 'fail', bunx: 'exit 0', isWindows: '1', env: { PATH: bin } }); + expect(r.status).toBe(0); + expect(r.stdout).toContain('REASON=windows-no-node\n'); + expect(r.stdout).toContain('REACHED_END=1'); + expect(r.stderr).toContain('nodejs.org'); + // The install itself ran; only the Node verification failed. + expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('bunx-called'); + } finally { + cleanup(); + } + }); + + test('Windows with Node.js but npm cannot install playwright/@ngrok: reason windows-node-modules, setup continues', () => { + const r = runBlock({ + probe: 'fail', bunx: 'exit 0', isWindows: '1', + prelude: 'node() { return 1; }\nnpm() { echo "npm $*" >> "$MARK"; return 1; }', + }); + expect(r.status).toBe(0); + expect(r.stdout).toContain('REASON=windows-node-modules\n'); + expect(r.stdout).toContain('REACHED_END=1'); + expect(r.stdout).toContain('Windows detected'); + expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('npm install --no-save playwright'); + }); + + test('Windows with Node.js loading Playwright but the launch probe failing: Windows-specific post-install-launch hint', () => { + const r = runBlock({ probe: 'fail', bunx: 'exit 0', isWindows: '1', prelude: 'node() { return 0; }' }); + expect(r.status).toBe(0); + expect(r.stdout).toContain('REASON=post-install-launch\n'); + expect(r.stderr).toContain('via Node.js'); + expect(r.stderr).toContain('oven-sh/bun#4253'); + // The Linux userns hint belongs to the other arm. + expect(r.stderr).not.toContain('GSTACK_CHROMIUM_NO_SANDBOX'); + }); +}); + +/** The 2b emoji-font step: the daemon font refresh must only run when the + * browser actually works — otherwise setup prints a second failure line. */ +function runEmojiStep(reason: string, fontOk: boolean): { stdout: string; stderr: string; status: number } { + const emoji = slice(BLOCK_END, '# 3. Ensure ~/.gstack global state directory exists'); + const script = [ + 'set -e', + `_PW_FAIL_REASON="${reason}"`, + `ensure_emoji_font() { return ${fontOk ? 0 : 1}; }`, + 'refresh_browse_daemon_for_fonts() { echo REFRESHED; }', + emoji, + 'echo "REACHED_END=1"', + ].join('\n'); + const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 10_000 }); + return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1 }; +} + +describe('setup: emoji-font daemon refresh is gated on Chromium availability', () => { + test('font installed + Chromium usable → daemon refreshed; font installed + Chromium unavailable → no refresh; font missing → note, no refresh', () => { + const usable = runEmojiStep('', true); + expect(usable.status).toBe(0); + expect(usable.stdout).toContain('REFRESHED'); + expect(usable.stdout).toContain('REACHED_END=1'); + + const unavailable = runEmojiStep('chromium-install', true); + expect(unavailable.status).toBe(0); + expect(unavailable.stdout).not.toContain('REFRESHED'); + expect(unavailable.stdout).toContain('REACHED_END=1'); + + const noFont = runEmojiStep('', false); + expect(noFont.status).toBe(0); + expect(noFont.stdout).not.toContain('REFRESHED'); + expect(noFont.stderr).toContain('could not auto-install a color-emoji font'); + expect(noFont.stdout).toContain('REACHED_END=1'); + }); +}); + +/** The final summary block, executed with a recording telemetry stub. */ +function runSummary(reason: string, telemetry: 'ok' | 'fail' | 'missing'): { stdout: string; stderr: string; status: number; argv: string } { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pw-summary-')); + try { + const argvFile = path.join(tmp, 'telemetry.argv'); + if (telemetry !== 'missing') { + fs.mkdirSync(path.join(tmp, 'bin')); + const stub = path.join(tmp, 'bin', 'gstack-telemetry-log'); + fs.writeFileSync(stub, `#!/usr/bin/env bash\necho "$@" >> "${argvFile}"\nexit ${telemetry === 'ok' ? 0 : 1}\n`); + fs.chmodSync(stub, 0o755); + } + const tail = SETUP_SRC.slice(SETUP_SRC.indexOf('# ─── Chromium bootstrap summary')); + const script = [ + 'set -e', + 'QUIET=0', + 'log() { [ "$QUIET" -eq 0 ] && echo "$@" || true; }', + `SOURCE_GSTACK_DIR="${tmp}"`, + `_PW_FAIL_REASON="${reason}"`, + tail, + 'echo "REACHED_END=1"', + ].join('\n'); + const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 10_000 }); + const argv = fs.existsSync(argvFile) ? fs.readFileSync(argvFile, 'utf-8') : ''; + return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1, argv }; + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + +describe('setup: Chromium bootstrap summary block executes', () => { + test('names the reason and the affected skills; hints are reason-specific; telemetry gets the reason code only', () => { + const timeout = runSummary('chromium-install-timeout', 'ok'); + expect(timeout.status).toBe(0); + expect(timeout.stdout).toContain('Browser unavailable: Chromium bootstrap did not complete (chromium-install-timeout)'); + for (const skill of ['/qa', '/qa-only', '/design-review', '/browse', 'make-pdf', '/pair-agent']) { + expect(timeout.stdout).toContain(skill); + } + expect(timeout.stdout).toContain('GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=1800'); + expect(timeout.stdout).not.toContain('GSTACK_CHROMIUM_NO_SANDBOX'); + // Reason code only, never a path or command line; a synthetic session id + // keeps the one-shot event from sweeping other sessions' pending markers. + expect(timeout.argv.trim()).toBe('--event-type onboarding --skill _setup_playwright --outcome chromium-install-timeout --no-sweep'); + + const launch = runSummary('post-install-launch', 'ok'); + expect(launch.stdout).toContain('GSTACK_CHROMIUM_NO_SANDBOX=1'); + expect(launch.stdout).not.toContain('GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=1800'); + expect(launch.argv).toContain('--outcome post-install-launch'); + + const offline = runSummary('chromium-install', 'ok'); + expect(offline.stdout).toContain('Browser unavailable'); + expect(offline.stdout).not.toContain('GSTACK_CHROMIUM_NO_SANDBOX'); + expect(offline.stdout).not.toContain('GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=1800'); + }); + + test('silent when Chromium is fine; a missing or failing telemetry binary never breaks the tail of setup', () => { + const fine = runSummary('', 'ok'); + expect(fine.status).toBe(0); + expect(fine.stdout).toBe('REACHED_END=1\n'); + expect(fine.argv).toBe(''); + + const missing = runSummary('chromium-install', 'missing'); + expect(missing.status).toBe(0); + expect(missing.stdout).toContain('Browser unavailable'); + expect(missing.stdout).toContain('REACHED_END=1'); + + const failing = runSummary('chromium-install', 'fail'); + expect(failing.status).toBe(0); + expect(failing.stdout).toContain('REACHED_END=1'); + expect(failing.argv).toContain('--outcome chromium-install'); + }); + + test('an explicit opt-out (skipped) is reported as a choice, not a failure, and sends no telemetry', () => { + const skipped = runSummary('skipped', 'ok'); + expect(skipped.status).toBe(0); + expect(skipped.stdout).toContain('Chromium install skipped by request (GSTACK_SKIP_PLAYWRIGHT=1)'); + expect(skipped.stdout).not.toContain('Browser unavailable'); + expect(skipped.stdout).not.toContain('Fix the cause'); + expect(skipped.argv).toBe(''); + }); + + test('GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=0, 000, or a value past nine digits means the default, not kill-on-first-poll or unbounded', () => { + for (const v of ['0', '000', '99999999999999999999', 'abc', '']) { + const r = runBlock({ probe: 'fail', bunx: 'exit 3', env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: v } }); + expect(r.status).toBe(0); + expect(r.stdout).toContain('REASON=chromium-install\n'); + } + // A wedged installer under "000" is still bounded by the DEFAULT, so with a + // 1s override written as "0001" it is killed and classified as a timeout. + const bounded = runBlock({ probe: 'fail', bunx: 'sleep 30', env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: '0001' } }); + expect(bounded.stdout).toContain('REASON=chromium-install-timeout\n'); + }, 30_000); +}); + +/** + * .gstack-owned marker (#2119): Windows installs COPY SKILL.md, so there is no + * symlink to readlink. link_claude_skill_dirs writes the marker; gstack-relink + * and cleanup_old_claude_symlinks prove provenance by it instead of by name. + */ +function runLinker(opts: { + isWindows: '0' | '1'; + payload: string[]; + plant?: (skills: string, payload: string) => void; +}): { status: number; stdout: string; stderr: string; skills: string; payload: string; tmp: string } { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-marker-')); + const skills = path.join(tmp, 'skills'); + const payload = path.join(skills, 'gstack'); + fs.mkdirSync(payload, { recursive: true }); + for (const name of opts.payload) { + fs.mkdirSync(path.join(payload, name)); + fs.writeFileSync(path.join(payload, name, 'SKILL.md'), `---\nname: ${name}\n---\n\n\n# ${name}\n`); + } + opts.plant?.(skills, payload); + const script = [ + 'set -e', + `IS_WINDOWS=${opts.isWindows}`, + 'SKILL_PREFIX=0', + // Keep the operator's real render dir out of the picture. + `GSTACK_USER_RENDER_DIR="${path.join(tmp, 'no-render')}"`, + '_link_skill_runtime_assets() { :; }', + '_print_windows_copy_note_once() { :; }', + '_FOREIGN_SKIPPED_ENTRIES=()', + extractFn('_link_or_copy'), + extractFn('_gstack_link_target_abs'), + extractFn('_gstack_target_is_ours'), + extractFn('_claude_entry_is_ours'), + extractFn('_write_owned_marker'), + extractFn('_gstack_generated_header'), + extractFn('link_claude_skill_dirs'), + extractFn('cleanup_old_claude_symlinks'), + `link_claude_skill_dirs "${payload}" "${skills}"`, + 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"', + ].join('\n'); + const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 10_000, env: { PATH: process.env.PATH ?? '', HOME: tmp } }); + return { status: r.status ?? -1, stdout: r.stdout ?? '', stderr: r.stderr ?? '', skills, payload, tmp }; +} + +describe.skipIf(process.platform === 'win32')('setup: .gstack-owned ownership marker for Windows copy installs (#2119)', () => { + test('IS_WINDOWS=1 writes the marker beside a COPIED SKILL.md; IS_WINDOWS=0 writes no marker beside a symlinked one', () => { + const win = runLinker({ isWindows: '1', payload: ['qa', 'ship'] }); + try { + expect(win.status).toBe(0); + for (const name of ['qa', 'ship']) { + const md = path.join(win.skills, name, 'SKILL.md'); + expect(fs.lstatSync(md).isSymbolicLink()).toBe(false); + expect(fs.existsSync(path.join(win.skills, name, '.gstack-owned'))).toBe(true); + } + expect(win.stdout).toContain('linked skills: qa ship'); + } finally { + fs.rmSync(win.tmp, { recursive: true, force: true }); + } + + const unix = runLinker({ isWindows: '0', payload: ['qa'] }); + try { + expect(unix.status).toBe(0); + expect(fs.lstatSync(path.join(unix.skills, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(true); + expect(fs.existsSync(path.join(unix.skills, 'qa', '.gstack-owned'))).toBe(false); + } finally { + fs.rmSync(unix.tmp, { recursive: true, force: true }); + } + }); + + test('marker writer → cleanup reader: copies the Windows linker marked are reaped on a mode flip; an unmarked same-name skill survives', () => { + // Phase 1: a Windows install links qa + ship (copies + markers). + const r = runLinker({ isWindows: '1', payload: ['qa', 'ship'] }); + try { + expect(r.status).toBe(0); + expect(fs.existsSync(path.join(r.skills, 'qa', '.gstack-owned'))).toBe(true); + expect(fs.existsSync(path.join(r.skills, 'ship', '.gstack-owned'))).toBe(true); + // Phase 2: the payload now also ships `review`, and the user has their + // OWN hand-written `review` (no marker, not byte-identical, no generated + // header) plus an unrelated `my-own`. A mode flip runs the cleanup. + fs.mkdirSync(path.join(r.payload, 'review')); + fs.writeFileSync(path.join(r.payload, 'review', 'SKILL.md'), '---\nname: review\n---\n\n\n# review\n'); + fs.mkdirSync(path.join(r.skills, 'review')); + fs.writeFileSync(path.join(r.skills, 'review', 'SKILL.md'), '---\nname: review\n---\n# mine, hand-written\n'); + fs.mkdirSync(path.join(r.skills, 'my-own')); + fs.writeFileSync(path.join(r.skills, 'my-own', 'SKILL.md'), '---\nname: my-own\n---\n'); + const flip = spawnSync('bash', ['-c', [ + 'set -e', 'IS_WINDOWS=1', + extractFn('_gstack_generated_header'), + extractFn('cleanup_old_claude_symlinks'), + `cleanup_old_claude_symlinks "${r.payload}" "${r.skills}"`, + ].join('\n')], { encoding: 'utf-8', timeout: 10_000 }); + expect(flip.status).toBe(0); + expect(flip.stdout).toContain('cleaned up old entries:'); + expect(flip.stdout).toContain('qa'); + expect(flip.stdout).toContain('ship'); + expect(flip.stdout).not.toContain('review'); + expect(fs.readdirSync(r.skills).sort()).toEqual(['gstack', 'my-own', 'review']); + expect(fs.readFileSync(path.join(r.skills, 'review', 'SKILL.md'), 'utf-8')).toContain('mine, hand-written'); + } finally { + fs.rmSync(r.tmp, { recursive: true, force: true }); + } + }); +});