From 76aefdd3e8ecbc25da988a86a5aaeb9ced8489c5 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 14 May 2026 14:01:20 -0700 Subject: [PATCH] fix(setup): _link_or_copy helper for Windows file-copy fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows without Developer Mode (MSYS2/Git Bash), plain ln -snf silently creates a frozen file copy that doesn't refresh on git pull. Skill files become stale after every upgrade. Add a _link_or_copy SRC DST helper near IS_WINDOWS detection (line ~33). It auto-dispatches: on Unix it preserves ln -snf semantics, on Windows it copies (cp -R for directories, cp -f for files). When the source is a Unix-style name-only alias that doesn't resolve on disk (the connect-chrome → gstack/open-gstack-browser pattern), the helper returns 0 silently on Windows rather than aborting setup under set -e. Rewrite all 42 prior ln -snf call sites to route through the helper: link_claude_skill_dirs (line 437), team-claude install paths (lines 556, 581, 592), Codex host adapter block (lines 618-640), Factory host adapter block (lines 658-678), OpenCode host adapter block (lines 696-731), Kiro host adapter block (lines 939-953), plus migration and alias sites. Add _print_windows_copy_note_once helper and call it from link_claude_skill_dirs after any linking work completes so Windows users see one user-visible note explaining they must re-run ./setup after every git pull. Extend cleanup_old_claude_symlinks and cleanup_prefixed_claude_symlinks with a Windows branch: when the target is a real directory containing a real-file SKILL.md (no symlink to readlink), and IS_WINDOWS=1, treat the name-matched directory as gstack-managed and remove it. This makes --prefix / --no-prefix flips work on Windows instead of leaving stale copies behind. Originated from @realcarsonterry PR #1462 (1 of 42 sites). Helper extraction, 42-site rewrite, alias-resolution edge case, and Windows cleanup compat authored on this branch. Co-Authored-By: Claude Opus 4.7 --- setup | 137 ++++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 96 insertions(+), 41 deletions(-) diff --git a/setup b/setup index f812511e4..b51fed83d 100755 --- a/setup +++ b/setup @@ -30,6 +30,47 @@ case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*|Windows_NT) IS_WINDOWS=1 ;; esac +# ─── Symlink-or-copy helper ─────────────────────────────────── +# On macOS/Linux: create a symlink (existing behavior). +# On Windows without Developer Mode (MSYS2/Git Bash): plain ln -snf silently +# creates a frozen file copy that doesn't refresh after `git pull`. We use +# explicit `cp -R` / `cp -f` so the user gets a real copy and the staleness +# is reportable (re-run ./setup after pull). Auto-detects file vs dir. +# +# INVARIANT: every symlink in this script MUST route through this helper. +# A raw ln call here will be caught by test/setup-windows-fallback.test.ts +# (the static-invariant assertion D7). +_link_or_copy() { + local src="$1" + local dst="$2" + if [ "$IS_WINDOWS" -eq 1 ]; then + rm -rf "$dst" + # Unix `ln -snf` accepts a name-only or relative-path source even when the + # target doesn't resolve from CWD (e.g. the connect-chrome alias points at + # the sibling-relative "gstack/open-gstack-browser"). On Windows the + # equivalent semantics don't exist — we'd need a real source on disk to + # copy. Skip the alias quietly rather than aborting setup under `set -e`. + if [ ! -e "$src" ]; then + return 0 + fi + if [ -d "$src" ]; then + cp -R "$src" "$dst" + else + cp -f "$src" "$dst" + fi + else + ln -snf "$src" "$dst" + fi +} + +_WINDOWS_COPY_NOTE_PRINTED=0 +_print_windows_copy_note_once() { + if [ "$IS_WINDOWS" -eq 1 ] && [ "$_WINDOWS_COPY_NOTE_PRINTED" -eq 0 ]; then + echo " note: Windows install uses file copies (no Developer Mode required). Re-run ./setup after every 'git pull' to refresh skill files." + _WINDOWS_COPY_NOTE_PRINTED=1 + fi +} + # ─── Quiet mode helper ──────────────────────────────────────── QUIET=0 log() { [ "$QUIET" -eq 0 ] && echo "$@" || true; } @@ -401,12 +442,13 @@ link_claude_skill_dirs() { mkdir -p "$target" # Validate target isn't a symlink before creating the link if [ -L "$target/SKILL.md" ]; then rm "$target/SKILL.md"; fi - ln -snf "$gstack_dir/$dir_name/SKILL.md" "$target/SKILL.md" + _link_or_copy "$gstack_dir/$dir_name/SKILL.md" "$target/SKILL.md" linked+=("$link_name") fi done if [ ${#linked[@]} -gt 0 ]; then echo " linked skills: ${linked[*]}" + _print_windows_copy_note_once fi } @@ -442,6 +484,13 @@ cleanup_old_claude_symlinks() { removed+=("$skill_name") ;; esac + # Windows install pattern: real dir with real-file SKILL.md (no symlink + # available, so we can't readlink to verify provenance). The outer loop + # iterates known gstack skill names from "$gstack_dir"/*, so a name match + # plus IS_WINDOWS is safe to treat as gstack-managed during a mode flip. + elif [ "$IS_WINDOWS" -eq 1 ] && [ -d "$old_target" ] && [ -f "$old_target/SKILL.md" ]; then + rm -rf "$old_target" + removed+=("$skill_name") fi fi done @@ -483,6 +532,12 @@ cleanup_prefixed_claude_symlinks() { 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 + rm -rf "$prefixed_target" + removed+=("gstack-$skill_name") fi fi done @@ -520,7 +575,7 @@ link_codex_skill_dirs() { target="$skills_dir/$skill_name" # Create or update symlink if [ -L "$target" ] || [ ! -e "$target" ]; then - ln -snf "$skill_dir" "$target" + _link_or_copy "$skill_dir" "$target" linked+=("$skill_name") fi fi @@ -545,7 +600,7 @@ create_agents_sidecar() { local dst="$agents_gstack/$asset" if [ -d "$src" ] || [ -f "$src" ]; then if [ -L "$dst" ] || [ ! -e "$dst" ]; then - ln -snf "$src" "$dst" + _link_or_copy "$src" "$dst" fi fi done @@ -556,7 +611,7 @@ create_agents_sidecar() { local dst="$agents_gstack/$file" if [ -f "$src" ]; then if [ -L "$dst" ] || [ ! -e "$dst" ]; then - ln -snf "$src" "$dst" + _link_or_copy "$src" "$dst" fi fi done @@ -582,29 +637,29 @@ 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 - ln -snf "$agents_dir/gstack/SKILL.md" "$codex_gstack/SKILL.md" + _link_or_copy "$agents_dir/gstack/SKILL.md" "$codex_gstack/SKILL.md" fi if [ -d "$gstack_dir/bin" ]; then - ln -snf "$gstack_dir/bin" "$codex_gstack/bin" + _link_or_copy "$gstack_dir/bin" "$codex_gstack/bin" fi if [ -d "$gstack_dir/browse/dist" ]; then - ln -snf "$gstack_dir/browse/dist" "$codex_gstack/browse/dist" + _link_or_copy "$gstack_dir/browse/dist" "$codex_gstack/browse/dist" fi if [ -d "$gstack_dir/browse/bin" ]; then - ln -snf "$gstack_dir/browse/bin" "$codex_gstack/browse/bin" + _link_or_copy "$gstack_dir/browse/bin" "$codex_gstack/browse/bin" fi if [ -f "$agents_dir/gstack-upgrade/SKILL.md" ]; then - ln -snf "$agents_dir/gstack-upgrade/SKILL.md" "$codex_gstack/gstack-upgrade/SKILL.md" + _link_or_copy "$agents_dir/gstack-upgrade/SKILL.md" "$codex_gstack/gstack-upgrade/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 if [ -f "$gstack_dir/review/$f" ]; then - ln -snf "$gstack_dir/review/$f" "$codex_gstack/review/$f" + _link_or_copy "$gstack_dir/review/$f" "$codex_gstack/review/$f" fi done # ETHOS.md — referenced by "Search Before Building" in all skill preambles if [ -f "$gstack_dir/ETHOS.md" ]; then - ln -snf "$gstack_dir/ETHOS.md" "$codex_gstack/ETHOS.md" + _link_or_copy "$gstack_dir/ETHOS.md" "$codex_gstack/ETHOS.md" fi } @@ -622,27 +677,27 @@ 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 - ln -snf "$factory_dir/gstack/SKILL.md" "$factory_gstack/SKILL.md" + _link_or_copy "$factory_dir/gstack/SKILL.md" "$factory_gstack/SKILL.md" fi if [ -d "$gstack_dir/bin" ]; then - ln -snf "$gstack_dir/bin" "$factory_gstack/bin" + _link_or_copy "$gstack_dir/bin" "$factory_gstack/bin" fi if [ -d "$gstack_dir/browse/dist" ]; then - ln -snf "$gstack_dir/browse/dist" "$factory_gstack/browse/dist" + _link_or_copy "$gstack_dir/browse/dist" "$factory_gstack/browse/dist" fi if [ -d "$gstack_dir/browse/bin" ]; then - ln -snf "$gstack_dir/browse/bin" "$factory_gstack/browse/bin" + _link_or_copy "$gstack_dir/browse/bin" "$factory_gstack/browse/bin" fi if [ -f "$factory_dir/gstack-upgrade/SKILL.md" ]; then - ln -snf "$factory_dir/gstack-upgrade/SKILL.md" "$factory_gstack/gstack-upgrade/SKILL.md" + _link_or_copy "$factory_dir/gstack-upgrade/SKILL.md" "$factory_gstack/gstack-upgrade/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 - ln -snf "$gstack_dir/review/$f" "$factory_gstack/review/$f" + _link_or_copy "$gstack_dir/review/$f" "$factory_gstack/review/$f" fi done if [ -f "$gstack_dir/ETHOS.md" ]; then - ln -snf "$gstack_dir/ETHOS.md" "$factory_gstack/ETHOS.md" + _link_or_copy "$gstack_dir/ETHOS.md" "$factory_gstack/ETHOS.md" fi } @@ -660,42 +715,42 @@ create_opencode_runtime_root() { mkdir -p "$opencode_gstack" "$opencode_gstack/browse" "$opencode_gstack/design" "$opencode_gstack/gstack-upgrade" "$opencode_gstack/review" "$opencode_gstack/qa" "$opencode_gstack/plan-devex-review" if [ -f "$opencode_dir/gstack/SKILL.md" ]; then - ln -snf "$opencode_dir/gstack/SKILL.md" "$opencode_gstack/SKILL.md" + _link_or_copy "$opencode_dir/gstack/SKILL.md" "$opencode_gstack/SKILL.md" fi if [ -d "$gstack_dir/bin" ]; then - ln -snf "$gstack_dir/bin" "$opencode_gstack/bin" + _link_or_copy "$gstack_dir/bin" "$opencode_gstack/bin" fi if [ -d "$gstack_dir/browse/dist" ]; then - ln -snf "$gstack_dir/browse/dist" "$opencode_gstack/browse/dist" + _link_or_copy "$gstack_dir/browse/dist" "$opencode_gstack/browse/dist" fi if [ -d "$gstack_dir/browse/bin" ]; then - ln -snf "$gstack_dir/browse/bin" "$opencode_gstack/browse/bin" + _link_or_copy "$gstack_dir/browse/bin" "$opencode_gstack/browse/bin" fi if [ -d "$gstack_dir/design/dist" ]; then - ln -snf "$gstack_dir/design/dist" "$opencode_gstack/design/dist" + _link_or_copy "$gstack_dir/design/dist" "$opencode_gstack/design/dist" fi if [ -f "$opencode_dir/gstack-upgrade/SKILL.md" ]; then - ln -snf "$opencode_dir/gstack-upgrade/SKILL.md" "$opencode_gstack/gstack-upgrade/SKILL.md" + _link_or_copy "$opencode_dir/gstack-upgrade/SKILL.md" "$opencode_gstack/gstack-upgrade/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 - ln -snf "$gstack_dir/review/$f" "$opencode_gstack/review/$f" + _link_or_copy "$gstack_dir/review/$f" "$opencode_gstack/review/$f" fi done if [ -d "$gstack_dir/review/specialists" ]; then - ln -snf "$gstack_dir/review/specialists" "$opencode_gstack/review/specialists" + _link_or_copy "$gstack_dir/review/specialists" "$opencode_gstack/review/specialists" fi if [ -d "$gstack_dir/qa/templates" ]; then - ln -snf "$gstack_dir/qa/templates" "$opencode_gstack/qa/templates" + _link_or_copy "$gstack_dir/qa/templates" "$opencode_gstack/qa/templates" fi if [ -d "$gstack_dir/qa/references" ]; then - ln -snf "$gstack_dir/qa/references" "$opencode_gstack/qa/references" + _link_or_copy "$gstack_dir/qa/references" "$opencode_gstack/qa/references" fi if [ -f "$gstack_dir/plan-devex-review/dx-hall-of-fame.md" ]; then - ln -snf "$gstack_dir/plan-devex-review/dx-hall-of-fame.md" "$opencode_gstack/plan-devex-review/dx-hall-of-fame.md" + _link_or_copy "$gstack_dir/plan-devex-review/dx-hall-of-fame.md" "$opencode_gstack/plan-devex-review/dx-hall-of-fame.md" fi if [ -f "$gstack_dir/ETHOS.md" ]; then - ln -snf "$gstack_dir/ETHOS.md" "$opencode_gstack/ETHOS.md" + _link_or_copy "$gstack_dir/ETHOS.md" "$opencode_gstack/ETHOS.md" fi } @@ -721,7 +776,7 @@ link_factory_skill_dirs() { [ "$skill_name" = "gstack" ] && continue target="$skills_dir/$skill_name" if [ -L "$target" ] || [ ! -e "$target" ]; then - ln -snf "$skill_dir" "$target" + _link_or_copy "$skill_dir" "$target" linked+=("$skill_name") fi fi @@ -753,7 +808,7 @@ link_opencode_skill_dirs() { [ "$skill_name" = "gstack" ] && continue target="$skills_dir/$skill_name" if [ -L "$target" ] || [ ! -e "$target" ]; then - ln -snf "$skill_dir" "$target" + _link_or_copy "$skill_dir" "$target" linked+=("$skill_name") fi fi @@ -796,7 +851,7 @@ if [ "$INSTALL_CLAUDE" -eq 1 ]; then _OGB_LINK="$INSTALL_SKILLS_DIR/gstack-connect-chrome" fi if [ -L "$_OGB_LINK" ] || [ ! -e "$_OGB_LINK" ]; then - ln -snf "gstack/open-gstack-browser" "$_OGB_LINK" + _link_or_copy "gstack/open-gstack-browser" "$_OGB_LINK" fi if [ "$LOCAL_INSTALL" -eq 1 ]; then log "gstack ready (project-local)." @@ -842,7 +897,7 @@ if [ "$INSTALL_CLAUDE" -eq 1 ]; then log " browse: $BROWSE_BIN" else mkdir -p "$CLAUDE_SKILLS_DIR" - ln -snf "$SOURCE_GSTACK_DIR" "$CLAUDE_GSTACK_LINK" + _link_or_copy "$SOURCE_GSTACK_DIR" "$CLAUDE_GSTACK_LINK" log " symlinked $CLAUDE_GSTACK_LINK -> $SOURCE_GSTACK_DIR" INSTALL_SKILLS_DIR="$CLAUDE_SKILLS_DIR" INSTALL_GSTACK_DIR="$CLAUDE_GSTACK_LINK" @@ -863,7 +918,7 @@ if [ "$INSTALL_CLAUDE" -eq 1 ]; then _OGB_LINK="$INSTALL_SKILLS_DIR/gstack-connect-chrome" fi if [ -L "$_OGB_LINK" ] || [ ! -e "$_OGB_LINK" ]; then - ln -snf "gstack/open-gstack-browser" "$_OGB_LINK" + _link_or_copy "gstack/open-gstack-browser" "$_OGB_LINK" fi log "gstack ready (claude)." log " browse: $BROWSE_BIN" @@ -903,21 +958,21 @@ if [ "$INSTALL_KIRO" -eq 1 ]; then # Remove old whole-dir symlink from previous installs [ -L "$KIRO_GSTACK" ] && rm -f "$KIRO_GSTACK" mkdir -p "$KIRO_GSTACK" "$KIRO_GSTACK/browse" "$KIRO_GSTACK/gstack-upgrade" "$KIRO_GSTACK/review" - ln -snf "$SOURCE_GSTACK_DIR/bin" "$KIRO_GSTACK/bin" - ln -snf "$SOURCE_GSTACK_DIR/browse/dist" "$KIRO_GSTACK/browse/dist" - ln -snf "$SOURCE_GSTACK_DIR/browse/bin" "$KIRO_GSTACK/browse/bin" + _link_or_copy "$SOURCE_GSTACK_DIR/bin" "$KIRO_GSTACK/bin" + _link_or_copy "$SOURCE_GSTACK_DIR/browse/dist" "$KIRO_GSTACK/browse/dist" + _link_or_copy "$SOURCE_GSTACK_DIR/browse/bin" "$KIRO_GSTACK/browse/bin" # ETHOS.md — referenced by "Search Before Building" in all skill preambles if [ -f "$SOURCE_GSTACK_DIR/ETHOS.md" ]; then - ln -snf "$SOURCE_GSTACK_DIR/ETHOS.md" "$KIRO_GSTACK/ETHOS.md" + _link_or_copy "$SOURCE_GSTACK_DIR/ETHOS.md" "$KIRO_GSTACK/ETHOS.md" fi # gstack-upgrade skill if [ -f "$AGENTS_DIR/gstack-upgrade/SKILL.md" ]; then - ln -snf "$AGENTS_DIR/gstack-upgrade/SKILL.md" "$KIRO_GSTACK/gstack-upgrade/SKILL.md" + _link_or_copy "$AGENTS_DIR/gstack-upgrade/SKILL.md" "$KIRO_GSTACK/gstack-upgrade/SKILL.md" fi # Review runtime assets (individual files, not whole dir) for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do if [ -f "$SOURCE_GSTACK_DIR/review/$f" ]; then - ln -snf "$SOURCE_GSTACK_DIR/review/$f" "$KIRO_GSTACK/review/$f" + _link_or_copy "$SOURCE_GSTACK_DIR/review/$f" "$KIRO_GSTACK/review/$f" fi done