Files
gstack/bin/gstack-relink
T
Garry TanandClaude Fable 5.1 2548634d96 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 <noreply@anthropic.com>
2026-09-04 17:28:00 +00:00

256 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
# gstack-relink — re-create skill symlinks based on skill_prefix config
#
# Usage:
# gstack-relink
#
# Env overrides (for testing):
# GSTACK_STATE_DIR — override ~/.gstack state directory
# GSTACK_INSTALL_DIR — override gstack install directory
# GSTACK_SKILLS_DIR — override target skills directory
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
GSTACK_CONFIG="${SCRIPT_DIR}/gstack-config"
# Detect install dir
INSTALL_DIR="${GSTACK_INSTALL_DIR:-}"
if [ -z "$INSTALL_DIR" ]; then
if [ -d "$HOME/.claude/skills/gstack" ]; then
INSTALL_DIR="$HOME/.claude/skills/gstack"
elif [ -d "${SCRIPT_DIR}/.." ] && [ -f "${SCRIPT_DIR}/../setup" ]; then
INSTALL_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
fi
fi
if [ -z "$INSTALL_DIR" ] || [ ! -d "$INSTALL_DIR" ]; then
echo "Error: gstack install directory not found." >&2
echo "Run: cd ~/.claude/skills/gstack && ./setup" >&2
exit 1
fi
# Detect target skills dir
SKILLS_DIR="${GSTACK_SKILLS_DIR:-$(dirname "$INSTALL_DIR")}"
[ -d "$SKILLS_DIR" ] || mkdir -p "$SKILLS_DIR"
# Read prefix setting
PREFIX=$("$GSTACK_CONFIG" get skill_prefix 2>/dev/null || echo "false")
# #2569: rendered :user variants (brain-aware blocks) live in an UNTRACKED
# out-dir instead of the tracked install checkout. When a render exists for a
# skill, relink serves it — otherwise a config change would silently flip
# every skill back to the canonical (blockless) source.
RENDER_DIR="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}"
# ─── Ownership gate ───────────────────────────────────────────────────────────
# relink runs on every ./setup and used to `rm -rf` any same-name entry with a
# symlinked SKILL.md and `ln -snf` over any existing SKILL.md — so a user's own
# 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 (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 (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.
# Both spellings are ours. (Roots stay quoted inside the case patterns, so a
# glob character or space in a path is matched literally.)
_INSTALL_REAL="$(cd "$INSTALL_DIR" 2>/dev/null && pwd -P || printf '%s' "$INSTALL_DIR")"
_RENDER_REAL="$(cd "$RENDER_DIR" 2>/dev/null && pwd -P || printf '%s' "$RENDER_DIR")"
_target_is_ours() {
# $1 = an ABSOLUTE path a symlink resolves to; ours when it lives under one
# of our roots. "$ROOT"/* requires the separator, so /home/u/gstack2/x never
# 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
}
# readlink of a RELATIVE symlink (older installs wrote `gstack/qa/SKILL.md`)
# is relative to the link's own directory, not to $PWD. Anchor it there, then
# canonicalize the DIRECTORY part (pwd -P) so `..` segments and symlinked
# path components (a `gstack` alias dir, a symlinked install) compare against
# the real roots. The basename is kept verbatim: canonicalizing it would follow
# the final link and turn every dangling target into "not ours".
_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="${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
}
# _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" skill="${2:-}" dest src
if [ -L "$entry" ]; then
dest="$(_link_target_abs "$entry")" || return 1
_target_is_ours "$dest"
return $?
fi
if [ -d "$entry" ]; then
[ -f "$entry/.gstack-owned" ] && return 0
if [ -L "$entry/SKILL.md" ]; then
dest="$(_link_target_abs "$entry/SKILL.md")" || return 1
_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
*'<!-- AUTO-GENERATED from '*'<!-- Regenerate: bun run gen:skill-docs -->'*) return 0 ;;
esac
fi
return 1
fi
return 1
}
FOREIGN_SKIPPED=()
_report_foreign() {
echo " skipped $1: not a gstack-managed entry (foreign skill with the same name) — left untouched" >&2
FOREIGN_SKIPPED+=("$1")
}
# 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" skill="${2:-}"
[ -e "$entry" ] || [ -L "$entry" ] || return 0
if ! _entry_is_ours "$entry" "$skill"; then
_report_foreign "$entry"
return 0
fi
if [ -L "$entry" ]; then
rm -f "$entry"
elif [ -d "$entry" ]; then
rm -rf "$entry"
fi
}
_link_root_skill_alias() {
local target="$SKILLS_DIR/_gstack-command"
[ -f "$INSTALL_DIR/SKILL.md" ] || return 0
[ -L "$target" ] && rm -f "$target"
mkdir -p "$target"
# Copy-then-rewrite, never a symlink (#2511): a symlinked alias re-serves
# the canonical `name: gstack`, Claude Code sees a duplicate skill name,
# and drops the ENTIRE personal-skills set. sed reads the source and writes
# a fresh copy — remove any prior symlink first so the redirect can never
# write through it into the generated source.
rm -f "$target/SKILL.md"
sed "1,/^---\$/ s/^name:[[:space:]].*/name: _gstack-command/" "$INSTALL_DIR/SKILL.md" > "$target/SKILL.md"
}
_link_root_skill_alias
# Discover skills (directories with SKILL.md, excluding meta dirs)
SKILL_COUNT=0
for skill_dir in "$INSTALL_DIR"/*/; do
[ -d "$skill_dir" ] || continue
# Skip symlinked skill dirs (connect-chrome → open-gstack-browser): linking
# one under the symlink's basename would duplicate the canonical frontmatter
# name and collide in Claude Code's skill registry (#2201). setup owns the
# rewritten-copy alias for those.
[ -L "${skill_dir%/}" ] && continue
skill=$(basename "$skill_dir")
# Skip non-skill directories
case "$skill" in bin|browse|design|docs|extension|lib|node_modules|scripts|test|.git|.github) continue ;; esac
[ -f "$skill_dir/SKILL.md" ] || continue
if [ "$PREFIX" = "true" ]; then
# Don't double-prefix directories already named gstack-*
case "$skill" in
gstack-*) link_name="$skill" ;;
*) 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" "$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" "$skill" ;;
esac
fi
target="$SKILLS_DIR/$link_name"
# A destination that already exists and is NOT ours is a foreign skill that
# 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" "$skill"; then
_report_foreign "$target"
continue
fi
# Upgrade old directory symlinks to real directories
[ -L "$target" ] && rm -f "$target"
# Create real directory with symlinked SKILL.md (absolute path)
mkdir -p "$target"
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
# Patch SKILL.md name: fields to match prefix setting. When a gbrain render
# is active the loop above links SKILL.md from RENDER_DIR — the file the host
# actually serves — so patch THAT tree too or skill_prefix=true is a no-op
# for every brain-aware skill (#2738). gstack-patch-names takes an arbitrary
# root, skips already-prefixed names (idempotent), and the render dir is
# user-owned and untracked, so patching it never dirties a checkout.
"$INSTALL_DIR/bin/gstack-patch-names" "$INSTALL_DIR" "$PREFIX"
[ -d "$RENDER_DIR" ] && "$INSTALL_DIR/bin/gstack-patch-names" "$RENDER_DIR" "$PREFIX"
if [ "$PREFIX" = "true" ]; then
echo "Relinked $SKILL_COUNT skills as gstack-*"
else
echo "Relinked $SKILL_COUNT skills as flat names"
fi
if [ ${#FOREIGN_SKIPPED[@]} -gt 0 ]; then
echo "Skipped ${#FOREIGN_SKIPPED[@]} foreign entr$( [ ${#FOREIGN_SKIPPED[@]} -eq 1 ] && echo y || echo ies) (not gstack-managed, left untouched): ${FOREIGN_SKIPPED[*]}"
fi