fix(setup): _link_or_copy helper for Windows file-copy fallback

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 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-14 14:01:20 -07:00
parent 9cd86e0f76
commit 76aefdd3e8
+96 -41
View File
@@ -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