mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-13 16:38:56 +02:00
v1.80.0.0 fix: setup survives a failed Chromium install, hooks share one state root, gstack never clobbers a skill it did not create (#2802)
* fix(freeze): hook reads the same state root /freeze writes — fails closed under GSTACK_HOME (#1459, #1509) check-freeze.sh resolved its state dir as ${CLAUDE_PLUGIN_DATA:-$HOME/.gstack} while every writer (/freeze, /guard, /unfreeze, /investigate) resolves through bin/gstack-paths, GSTACK_HOME first. With GSTACK_HOME set, /freeze wrote freeze-dir.txt under GSTACK_HOME, the hook read $HOME/.gstack, found no file, and allowed everything — a deny-tier boundary failing open. One resolver now: gstack_hook_state_root() in careful/bin/hook-extract.sh (already sourced by both check-freeze.sh and check-careful.sh) implements the exact gstack-paths chain, including the CLAUDE_PLUGIN_ROOT guard that keeps a CLAUDE_PLUGIN_DATA leaked from another plugin from redirecting our state. check-freeze.sh and gstack_hook_log_fire both call it; nothing spawns gstack-paths from a hook. Tests: the GSTACK_HOME deny regression, GSTACK_HOME-over-CLAUDE_PLUGIN_DATA precedence, plugin-root guard both ways, and a byte-parity check against bin/gstack-paths across six env combinations. Existing freeze tests now pass CLAUDE_PLUGIN_ROOT like a real plugin install would. Idea from PR #1509 (@NikhileshNanduri); implemented natively against the shared resolver rather than a second fallback chain. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(relink): never delete or link over a skill gstack does not own (#2119) gstack-relink runs on every ./setup. Its cleanup did `rm -rf` on any same-name entry whose SKILL.md was a symlink, with no readlink check, and its link step did `mkdir -p` then `ln -snf` onto any existing SKILL.md — on Linux that replaces a user's real file with a symlink into gstack (macOS refused by accident). setup's Windows mode-flip cleanup deleted any real dir whose name matched a gstack skill. A personal `qa` skill, or a fork installed under another path, was destroyed by the installer of a tool it never asked for. Ownership is now proven, never assumed. An entry is ours when it is a symlink resolving into INSTALL_DIR or RENDER_DIR, a real dir whose SKILL.md is such a symlink, or a real dir carrying the .gstack-owned marker setup now writes for Windows copy installs (legacy copies count when byte-identical to the source or carrying gen-skill-docs' AUTO-GENERATED header). Anything else — including an entry whose readlink fails — is foreign: left untouched, reported on stderr, and listed in relink's summary line. The same rule replaces setup's Windows name-match deletion; setup:1040 and gstack-uninstall:204 already gated on readlink, so this closes the last unguarded deleter of the class. Tests: foreign real dir in flat mode, foreign flat entry on a prefix flip, foreign directory symlink, RENDER_DIR-targeted entry (ours), marker-carrying copy (ours), marker-less copy (foreign); the Windows cleanup test now proves provenance three ways and keeps the user's own same-name skill. Idea and two regression cases from PR #2119 (@smblight); implemented on the destination entry, not only the symlink target. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(setup): Chromium bootstrap is best-effort and bounded — skills always register (#1900, #1901, #1902, #913, #2233) setup runs under `set -e`, and the Chromium bootstrap in section 2 sat ahead of skill registration in section 4 with a bare `bunx playwright install chromium`, an unbounded download, and an explicit `exit 1` after the post-install launch probe. On an offline, proxied, or AppArmor-restricted box the user ended with ZERO skills registered and a re-run that died at the same line; a wedged download hung setup indefinitely. Every browser failure now records a reason code in _PW_FAIL_REASON and setup continues: skipped (GSTACK_SKIP_PLAYWRIGHT=1, #913), chromium-install, chromium-install-timeout (the download is bounded by the existing _wait_with_deadline helper, default 600s, env GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT, process tree killed via _kill_tree), chromium-install-locked (another setup holds the lock: this one registers skills and re-probes next time instead of exiting), windows-no-node, windows-node-modules, post-install-launch (with the GSTACK_CHROMIUM_NO_SANDBOX=1 hint for Ubuntu 24.04's userns policy, #2157). The daemon font refresh is skipped when Chromium is unavailable. The final summary names the skills that need the browser (/qa, /qa-only, /design-review, /browse, make-pdf, /pair-agent) and the fix for the recorded reason, and logs the reason code (never a path) through gstack-telemetry-log when telemetry is on. Tests: static invariants over the anchor-sliced block (no exit, every reason code, deadline helper, trap chaining, guarded refresh, summary contents) plus an integration harness that executes the real block with a stubbed probe and installer: install failure, hang killed at the deadline with the tree kill recorded, non-numeric knob fallback, live lock (continues, installer not run, lock preserved), stale lock reclaimed, post-install probe failure, and the skip flag. Credit @DavidMiserak (PR #1900) for the best-effort shape; re-implemented on the current block. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(designs): preserve the time-attack fork-port residual evaluation The read-only evaluation of what remains portable from time-attack/gstack (583 raw candidates, 415 canonical, 287 with a residual, 48 adversarially refuted, 14 standing) lived only on a throwaway VM. This records the report, the lite residual index, the absorbed/superseded ledger, the refuter verdicts, and SHAS.md with the fork tip, upstream HEAD, merge-base, and a sha256 per file, so every scheduled fix in this wave series traces to its evidence. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: file the fork-port residual deferrals and document the Chromium bootstrap knobs TODOS.md gains the seven items the CEO and eng reviews of the fork-port residual plan deliberately deferred (shared ownership helper, config-key reader tripwire, "pre-existing" vocabulary, opt-in reply_language, .auth.json writer removal, the fork-derived-change rule for CONTRIBUTING, hook slug parity audit), each with rationale, and updates the two residual bullets for PR #2232 and PR #2233 with their dispositions. README's Troubleshooting section explains the best-effort Chromium bootstrap and its three knobs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(relink): canonicalize link targets before the ownership check Pre-landing review finding: the ownership gate compared readlink output textually against INSTALL_DIR and RENDER_DIR, so two shapes of gstack's OWN entries read as foreign and were left behind on a mode flip — a legacy relative link (`gstack/qa/SKILL.md`, resolved against $PWD instead of the link's directory) and an entry linked against the real path of a symlinked install dir (~/.claude/skills/gstack -> checkout). Both now resolve: relative targets anchor at the link's directory, the directory part is canonicalized with pwd -P (the basename stays verbatim so a dangling managed target is not misread), and both spellings of each root are accepted. Two regression tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(telemetry): one-shot setup events never sweep other sessions' pending markers gstack-telemetry-log finalizes every .pending-<session> marker that is not the caller's own as outcome:unknown and deletes it. setup's onboarding events (_setup_welcome, _setup_playwright) have no session of their own, so a Chromium bootstrap failure during a live skill session recorded a false unknown for that session and removed its marker. New --no-sweep flag skips the stale-marker pass; both setup call sites use it (the synthetic --session-id did not prevent the sweep). Surfaced by the Codex adversarial pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(hooks): partial upgrades fail closed for freeze and fall back for careful A hook script and its sourced helper can be copied at different times. With an older careful/bin/hook-extract.sh that lacks gstack_hook_state_root: - check-freeze.sh now emits a deny ("fail closed, re-run ./setup or /unfreeze") instead of dying under set -e with no decision JSON. - check-careful.sh falls back to ${GSTACK_HOME:-$HOME/.gstack} so project rules under the plain chain still load and a decision is always emitted (a warn hook must never break on a stale helper). gstack_hook_state_root prints its root without a trailing newline and both callers capture it with a printf-x sentinel, so a GSTACK_HOME ending in a newline round-trips byte-for-byte with the writer's %q form. gstack_hook_log_fire stays on ${GSTACK_HOME:-$HOME/.gstack}/analytics, the same two-step chain every other analytics writer and reader uses, so the usage log remains one file under a plugin install. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * 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> * docs: README Chromium note outside the CLAUDE.md fence; report banner stripped; deferrals name the four gate sites - README: the Chromium troubleshooting paragraph sat inside the CLAUDE.md snippet code fence, so copy-paste put it into users' CLAUDE.md. Moved to the troubleshooting list. - docs/designs/fork-port-residual-2026-09/REPORT.md: the scratch-run preamble banner is gone; SHAS.md re-hashed. - TODOS: the ownership-gate deferral names the four sites and the marker-path idea for the fork-with-banner residual. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(todos): the bootstrap block coverage gap is pinned except the quarantine helper Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(setup,relink): ownership proof has two strengths; weak proof never deletes a directory or discards a differing file The first #2119 gate treated a byte-identical or banner-bearing real-file SKILL.md as full ownership, so a prefix flip could rm -rf a user's directory (their own qa skill started from a gstack SKILL.md, plus my-templates/) and the link pass could replace their customized file with a symlink. Two strengths now: - STRONG: the .gstack-owned marker (we created the directory), or a directory holding nothing but symlinks and the marker (deleting it loses no data). Only strong proof removes a directory whole. - WEAK: byte-identity with our source or the two-line gen-skill-docs banner on a real file. Weak proof covers that SKILL.md and our runtime-asset links only; a differing file is moved to ${GSTACK_HOME:-~/.gstack}/backups/skills/<ts>/<skill>/ before we link over it, and setup/relink print one summary line naming what moved. The marker is written on every platform now (path-independent proof for Windows copies and for checkouts whose path carries no gstack segment), but only for a directory gstack creates: a directory we merely link into (unclaimed, or a legacy install) never becomes deletable whole. A directory with no SKILL.md at all is unclaimed: the link pass may add our file, the cleanup pass has nothing to remove. Also from the review passes: the banner check reads 8192 bytes, not 40 lines (investigate, office-hours, plan-ceo-review and design-consultation carry the banner past line 40 and were left "foreign" on pre-marker Windows installs); a link into a checkout named without a gstack segment (git worktree add ../gstack-<branch>) is ours when that tree carries setup + VERSION + bin/; relink's fast path is gone so both files canonicalize before judging; relink's root alias (_gstack-command) is gated and stamped like every other entry; relink reports the bare entry name with setup's wording and setup dedupes when forwarding (_run_relink_quiet); the summary names the browser skills as examples. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(setup): Chromium-install lock reclaim is atomic and pid-validated; abandoned locks expire; the tree kill walks /proc without pgrep - A pid file holding "", "-1" or "0" counted as a live holder (kill -0 -1 signals every process and succeeds), locking Chromium out for good. A pid must be a positive integer; anything else is stale. - Two setups judging the same lock stale raced on rm -rf + mkdir and the loser deleted the winner's fresh lock. The stale dir is renamed first (atomic), so exactly one reclaims. - A lock dir with no pid file (killed between mkdir and echo) was never reclaimed; it now expires once older than the install bound. - _kill_tree needed pgrep; debian-slim and git-bash ship none, so the bound killed only the wrapper subshell and the installer kept running. Without pgrep the children are found by walking /proc/*/stat. - The timeout knob is normalized in one place with one comment; the trap's exit 130 is the only exit the block may contain. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(freeze): an unexpected non-zero death denies via an EXIT backstop instead of exiting with no decision set -e plus a failing pipeline (a tool on PATH exiting non-zero, a deleted cwd) ended the deny-tier hook with no JSON, which Claude Code treats as non-blocking: the edit outside the boundary proceeded. The EXIT trap now prints a deny for any non-zero exit that happens before a decision was written; every deliberate output sets _FREEZE_DECIDED first so a late failure never prints a second object. Tests also pin careful's state-root precedence (GSTACK_HOME over CLAUDE_PLUGIN_DATA, plugin data when CLAUDE_PLUGIN_ROOT names gstack) and the specific "out of date" deny for a helper without gstack_hook_state_root. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(telemetry): guard the stale-marker sweep with an if, not a break inside the loop Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(todos): the ownership gate lives in six sites, and the cleanup arms inline their own chain Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test: the two remaining linker harnesses extract the ownership helpers; the marker is the one allowed dotfile setup-claude-skill-assets and user-render-out-dir-install slice link_claude_skill_dirs out of setup without the helpers it now calls, so the extracted function died with "command not found" (or, inside an if, degraded into "foreign, skipped"). Both harnesses now carry the full helper set and the globals. The hidden-files census allows .gstack-owned, which the linker writes for directories it creates rather than copying from the skill source. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(setup,relink): weak proof never costs the user a file — assets, flips, failed backups, foreign dir links, alias markers Third review cycle on the ownership model, every item reproduced against a fixture before the fix: - Runtime assets (sections/, templates/, checklist.md, ...) were refreshed with rm -rf regardless of who owned the directory, so an unclaimed or weakly-owned directory lost the user's same-named real files. Real assets are now replaced only in a directory gstack created or strongly owns (marker, or SKILL.md symlink into gstack), plus the legacy Windows real-copy shape; elsewhere they are kept and reported. Symlinks are never content and are always refreshed. - The prefix-flip cleanup deleted a customized banner-bearing SKILL.md that the link pass would have backed up. Both cleanups now compare the file against the source (raw, or with its name: line rewritten to the entry name, which is how alias and prefixed copies legitimately differ) and move a differing file to the backup root. - A failed backup (unwritable root) returned success and the caller linked over the file anyway. It now fails, and the entry is left untouched and reported. - A foreign DIRECTORY symlink whose target had no SKILL.md fell through to the "unclaimed directory" rule and was replaced by a real directory. A symlink that does not resolve into gstack is foreign, full stop. - The alias installers stamped .gstack-owned into pre-existing directories; they now follow the same created-or-already-marked rule. - A directory counts as "only links" only when every link resolves into gstack: a user's own symlink makes it mixed, so their link survives. - The gstack-tree heuristic requires bin/gstack-relink, not just a VERSION file, a setup script and a bin/ directory. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(setup): lock reclaim hands a fresh lock back; a live holder past the bound is stale; /proc walk strips through the last paren - Reclaim renamed the lock by path after judging it stale, so a second setup that had already reclaimed and re-created it lost its fresh lock and two installers ran. After the rename the moved directory's pid is re-read: a new live holder, or a fresh lock whose pid is not written yet, is moved straight back. - A pid file whose process is alive but whose lock is older than the install bound is stale too (the holder is past its own deadline, or the pid was recycled to an unrelated long-lived process); it was locked forever. - The /proc fallback stripped the comm field to the FIRST ") ", so a comm containing ") " hid a child from the kill. proc(5) says the last paren. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(freeze): mark the decision written after the helper prints, not before If gstack_hook_decision ever failed between the flag and its output the backstop would have stayed silent; setting the flag after the print keeps the deny backstop armed until a decision is actually on stdout. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore: bump version and changelog (v1.80.0.0) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: update project documentation for v1.80.0.0 README troubleshooting + manual uninstall cover the skill ownership gate (.gstack-owned marker, ~/.gstack/backups/skills/<ts>/, foreign same-name skills left untouched). CLAUDE.md and CONTRIBUTING carry the ownership and best-effort Chromium bootstrap invariants for people editing setup and gstack-relink. PROJECT_STRUCTURE gains careful/, freeze/, guard/, unfreeze/, gstack-upgrade/, gstack-relink, and the setup/relink/hook test files. TESTING_INTERNALS documents the anchor-sliced setup harness convention. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(setup): the final summary reports customized SKILL.md files moved to the backup root The linker moved a weakly-proven, customized SKILL.md aside before linking over it but never said so; only relink printed a "Moved N" line, and by the time relink runs the file is already a symlink. The summary now names each moved file and where it went, next to the foreign-entry report. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test: run assembled setup harness scripts from a temp file, not `bash -c` argv (Windows MSYS2 8 KB truncation) windows-free-tests (run 33907177851) failed in test/setup-alias-name-uniqueness.test.ts with bash: -c: line 178: unexpected EOF while looking for matching `' The harness slices functions out of `setup` and passed the joined script as one `bash -c` argv element. The ownership gate grew that script from 6.7 KB to 15.7 KB, and on Windows bash is an MSYS2 program: when its parent is a non-MSYS process (bun), msys-2.0.dll's build_argv() runs any argument containing `?*["'(){}` through globify()/glob(), which copies the pattern into a fixed `Char patbuf[8192]` and silently stops after 8192 - MB_CUR_MAX (8186 chars under C.UTF-8); GLOB_NOCHECK then returns the truncated text as the argument. Character 8186 lands inside the single-quoted sed token on line 178. Rebuilding the exact script with CI path shapes and cutting it at 8186-8190 characters reproduces the identical message locally; cmd.exe's 8191-UTF-16 cap and CreateProcess's 32767 do not fit the evidence. Fix: test/helpers/bash-script.ts writes the script to a temp file and runs `bash <path>` — a short glob-free argument that never enters globify. Every setup harness that assembled a script for `bash -c` (11 files, 22 sites) uses it; timeouts and env are preserved verbatim, spawn/timeout errors are appended to stderr, temp cleanup is best-effort. `spawnSync('bash', [<Windows absolute path>])` already passes on windows-latest in setup-help, uninstall-windows-copies and the migration tests. The Windows-curated list is byte-identical before and after. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(test-free-shards): the rerun-refresh harness spawns bash <tempfile> via test/helpers/bash-script.ts, not bash -c Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
0d1bd5616c
commit
c241216637
@@ -116,6 +116,177 @@ _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 filed in TODOS.md lands). Proof has two
|
||||
# strengths: STRONG (a symlink resolving into gstack, or the .gstack-owned
|
||||
# marker) means we created the entry and may delete or refresh it whole; WEAK
|
||||
# (byte-identity with our source, or the two-line generated banner on a real
|
||||
# file) covers only that SKILL.md — never the directory — and a differing
|
||||
# weakly-proven file is moved to ${GSTACK_HOME:-~/.gstack}/backups/skills/<ts>/
|
||||
# before we install over it. 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
|
||||
# A checkout named without a `gstack` segment (git worktree add
|
||||
# ../gstack-<branch>, a ZIP unpacked as gstack-main): the target's skill
|
||||
# root is a gstack tree if it carries setup + VERSION + bin/gstack-relink
|
||||
# (a hand-written skill repo with a VERSION file and a setup script does not).
|
||||
local root="${t%/*/SKILL.md}"
|
||||
if [ "$root" != "$t" ] && [ -f "$root/VERSION" ] && [ -f "$root/setup" ] && [ -f "$root/bin/gstack-relink" ]; then return 0; fi
|
||||
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" render_md
|
||||
_claude_entry_owned_strongly "$entry" "$g" && return 0
|
||||
# A symlink that did not resolve into gstack is someone else's; never follow
|
||||
# it into the "unclaimed directory" rule below.
|
||||
[ -L "$entry" ] && return 1
|
||||
# No SKILL.md at all: an UNCLAIMED directory (a weak cleanup left the user's
|
||||
# other files behind, or it was never a skill). Adding our SKILL.md
|
||||
# overwrites nothing, so installing into it is allowed; the cleanup arms
|
||||
# require a SKILL.md and so never touch it.
|
||||
if [ -d "$entry" ] && [ ! -e "$entry/SKILL.md" ] && [ ! -L "$entry/SKILL.md" ]; then return 0; fi
|
||||
if [ -d "$entry" ] && [ -f "$entry/SKILL.md" ] && [ ! -L "$entry/SKILL.md" ]; then
|
||||
[ -n "$src_md" ] && [ -f "$src_md" ] && cmp -s "$entry/SKILL.md" "$src_md" && return 0
|
||||
# A gbrain install serves the RENDERED file (link_claude_skill_dirs prefers
|
||||
# it), so an exact copy of that render is ours too.
|
||||
if [ -n "$src_md" ]; then
|
||||
render_md="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}/$(basename "$(dirname "$src_md")")/SKILL.md"
|
||||
[ -f "$render_md" ] && cmp -s "$entry/SKILL.md" "$render_md" && return 0
|
||||
fi
|
||||
_gstack_generated_header "$entry/SKILL.md" && return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
# _claude_entry_owned_strongly ENTRY GSTACK_DIR — we created it: a symlink into
|
||||
# gstack, or a real dir with the .gstack-owned marker or a SKILL.md symlink
|
||||
# into gstack. Only strong proof authorizes deleting a directory whole.
|
||||
_claude_entry_owned_strongly() {
|
||||
local entry="$1" g="$2" 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
|
||||
return 1
|
||||
}
|
||||
# Weakly-proven real files we would otherwise overwrite are moved here (mv, so
|
||||
# the path is free for the link); the final summary prints one line.
|
||||
_SKILL_BACKUP_ROOT="${GSTACK_HOME:-$HOME/.gstack}/backups/skills/$(date +%Y%m%dT%H%M%S)"
|
||||
_BACKED_UP_SKILL_MDS=()
|
||||
_backup_skill_md() {
|
||||
# Returns non-zero when the file could NOT be moved: the caller must then
|
||||
# leave the entry untouched (a failed backup is never a license to overwrite).
|
||||
local file="$1" name="$2"
|
||||
mkdir -p "$_SKILL_BACKUP_ROOT/$name" 2>/dev/null || return 1
|
||||
mv -f "$file" "$_SKILL_BACKUP_ROOT/$name/SKILL.md" 2>/dev/null || return 1
|
||||
_BACKED_UP_SKILL_MDS+=("$name")
|
||||
return 0
|
||||
}
|
||||
# _cleanup_weak_dir DIR — remove only what weak proof covers: the SKILL.md and
|
||||
# our marker. User files in the directory stay, and so does the directory
|
||||
# when it is not empty afterwards.
|
||||
# _cleanup_weak_dir DIR GSTACK_DIR [SRC_SKILL_MD NAME] — remove only what weak
|
||||
# proof covers. A real SKILL.md that differs from our source (raw, or with its
|
||||
# name: line rewritten to NAME, which is how alias and prefixed copies differ)
|
||||
# is a customized file: it is moved to the backup root, never deleted, and if
|
||||
# the backup fails it stays. Our runtime-asset links go; the user's files stay.
|
||||
_cleanup_weak_dir() {
|
||||
local d="$1" g="$2" src="${3:-}" name="${4:-${1##*/}}" e dest
|
||||
if [ -f "$d/SKILL.md" ] && [ ! -L "$d/SKILL.md" ] && [ -n "$src" ] && [ -f "$src" ] \
|
||||
&& ! cmp -s "$d/SKILL.md" "$src" \
|
||||
&& ! sed "1,/^---\$/ s/^name:[[:space:]].*/name: $name/" "$src" | cmp -s - "$d/SKILL.md"; then
|
||||
if ! _backup_skill_md "$d/SKILL.md" "$name"; then
|
||||
echo " kept $name/SKILL.md: could not back up the customized file — left untouched" >&2
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
rm -f "$d/SKILL.md"
|
||||
fi
|
||||
rm -f "$d/.gstack-owned"
|
||||
for e in "$d"/* "$d"/.[!.]* "$d"/..?*; do
|
||||
[ -L "$e" ] || continue
|
||||
dest="$(_gstack_link_target_abs "$e")" || continue
|
||||
if _gstack_target_is_ours "$dest" "$g"; then rm -f "$e"; fi
|
||||
done
|
||||
rmdir "$d" 2>/dev/null || echo " cleaned ${d##*/}/SKILL.md (other files in that directory were left in place)"
|
||||
}
|
||||
# _gstack_dir_only_links DIR GSTACK_DIR — true when deleting DIR whole loses
|
||||
# nothing of the user's: every entry is a symlink resolving into gstack, or
|
||||
# our marker. A user's own link (notes.md -> ~/notes) makes the dir mixed.
|
||||
_gstack_dir_only_links() {
|
||||
local d="$1" g="$2" e dest
|
||||
for e in "$d"/* "$d"/.[!.]* "$d"/..?*; do
|
||||
{ [ -e "$e" ] || [ -L "$e" ]; } || continue
|
||||
[ "${e##*/}" = ".gstack-owned" ] && continue
|
||||
[ -L "$e" ] || return 1
|
||||
dest="$(_gstack_link_target_abs "$e")" || return 1
|
||||
_gstack_target_is_ours "$dest" "$g" || return 1
|
||||
done
|
||||
return 0
|
||||
}
|
||||
# _cleanup_linked_dir DIR GSTACK_DIR — a real dir whose SKILL.md is a symlink
|
||||
# into gstack. Whole-directory removal needs the marker (we created it) or a
|
||||
# directory holding nothing but our links; otherwise only our files go.
|
||||
_cleanup_linked_dir() {
|
||||
if [ -f "$1/.gstack-owned" ] || _gstack_dir_only_links "$1" "$2"; then rm -rf "$1"; else _cleanup_weak_dir "$1" "$2"; fi
|
||||
}
|
||||
# _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() {
|
||||
# Bytes, not lines: a long frontmatter pushes the banner past line 40 in
|
||||
# four real skills (investigate: line 57), and a line-count check left them
|
||||
# "foreign" on every pre-marker Windows install.
|
||||
local f="$1" head40
|
||||
head40="$(head -c 8192 "$f" 2>/dev/null)" || return 1
|
||||
case "$head40" in
|
||||
*'<!-- AUTO-GENERATED from '*'<!-- Regenerate: bun run gen:skill-docs -->'*) 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,
|
||||
@@ -420,6 +591,13 @@ _kill_tree() {
|
||||
for child in $(pgrep -P "$pid" 2>/dev/null); do
|
||||
_kill_tree "$child"
|
||||
done
|
||||
elif [ -d /proc ]; then
|
||||
# debian-slim and git-bash ship no pgrep: walk /proc for children. The
|
||||
# comm field "(name)" may contain spaces and parens, so strip through the
|
||||
# LAST closing paren (proc(5)) before reading the ppid (second field after it).
|
||||
for child in $(awk -v p="$pid" '{ s=$0; sub(/^.*\) /, "", s); split(s, f, " "); if (f[2]==p) print $1 }' /proc/[0-9]*/stat 2>/dev/null); do
|
||||
_kill_tree "$child"
|
||||
done
|
||||
fi
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
}
|
||||
@@ -776,7 +954,42 @@ if [ -f /etc/os-release ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! ensure_playwright_browser; then
|
||||
# Chromium is BEST-EFFORT (#1900, #1901, #1902, #913, #2233). Every later step
|
||||
# — skill registration (# 4), Codex/Kiro installs, migrations, hooks — is
|
||||
# independent of the browser, so a failed or wedged download must never abort
|
||||
# setup under `set -e`. Each failure records a reason code in _PW_FAIL_REASON;
|
||||
# the skills that need Chromium (/qa, /qa-only, /design-review, /browse,
|
||||
# make-pdf, /pair-agent) are named in the final summary instead of the user
|
||||
# discovering a half-installed gstack. Lock contention is a reason too: another
|
||||
# setup is installing Chromium right now, so this run registers skills and
|
||||
# re-probes next time. The download is bounded (default 600s, env
|
||||
# GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT) because Playwright's own retries cover a
|
||||
# flaky socket but not a wedged bunx; a wedged installer is killed with its
|
||||
# child tree (_kill_tree: pgrep-walked, /proc-walked where pgrep is missing).
|
||||
# Reason codes: skipped, chromium-install,
|
||||
# chromium-install-timeout, chromium-install-locked, windows-no-node,
|
||||
# windows-node-modules, post-install-launch.
|
||||
# test/setup-playwright-best-effort.test.ts pins this block.
|
||||
_PW_FAIL_REASON=""
|
||||
_pw_fail() {
|
||||
local code="$1"; shift
|
||||
_PW_FAIL_REASON="${_PW_FAIL_REASON:+$_PW_FAIL_REASON,}$code"
|
||||
echo " Chromium bootstrap: $code — $*" >&2
|
||||
}
|
||||
_PW_INSTALL_TIMEOUT="${GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT:-600}"
|
||||
# Normalize to a plain positive integer or fall back to the default: empty and
|
||||
# non-numeric are garbage; "0"/"000" would kill the install on the first poll;
|
||||
# "0600" is 600; anything past nine digits is not a deadline (a value bash
|
||||
# cannot compare would leave the install unbounded — the exact failure the
|
||||
# bound exists to prevent).
|
||||
case "$_PW_INSTALL_TIMEOUT" in ''|*[!0-9]*) _PW_INSTALL_TIMEOUT=600 ;; esac
|
||||
[ "${#_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)"
|
||||
elif ! ensure_playwright_browser; then
|
||||
echo "Installing Playwright Chromium..."
|
||||
# XProtect self-heal (#2554): the probe failure may be the OS killing the
|
||||
# cached Chromium, not a missing install. Clear quarantine on the Playwright
|
||||
@@ -786,11 +999,47 @@ if ! ensure_playwright_browser; then
|
||||
# Stale-lock self-heal: a SIGKILL'd prior setup leaves the lock dir behind
|
||||
# forever (mkdir mutexes have no owner). If the recorded holder PID is dead,
|
||||
# reclaim instead of telling the user to rmdir by hand.
|
||||
if [ -d "$_PW_LOCK" ] && [ -f "$_PW_LOCK/pid" ]; then
|
||||
_PW_HOLDER=$(cat "$_PW_LOCK/pid" 2>/dev/null || true)
|
||||
if [ -n "$_PW_HOLDER" ] && ! kill -0 "$_PW_HOLDER" 2>/dev/null; then
|
||||
echo " reclaiming stale Chromium-install lock (holder pid $_PW_HOLDER is gone)" >&2
|
||||
rm -rf "$_PW_LOCK" 2>/dev/null || true
|
||||
if [ -d "$_PW_LOCK" ]; then
|
||||
_PW_STALE=0; _PW_HOLDER=0
|
||||
_PW_LOCK_OLD=""
|
||||
[ -n "$(find "$_PW_LOCK" -maxdepth 0 -mmin +$(( _PW_INSTALL_TIMEOUT / 60 + 1 )) 2>/dev/null)" ] && _PW_LOCK_OLD=1
|
||||
if [ ! -f "$_PW_LOCK/pid" ]; then
|
||||
# No holder recorded (killed between mkdir and echo, or mid-write by a
|
||||
# live setup): nothing to probe, so only age can prove abandonment.
|
||||
[ -n "$_PW_LOCK_OLD" ] && _PW_STALE=1
|
||||
else
|
||||
_PW_HOLDER=$(cat "$_PW_LOCK/pid" 2>/dev/null || true)
|
||||
# A pid must be a positive integer: "", "-1" (kill -0 -1 signals every
|
||||
# process and "succeeds") or "0" (the process group) are stale, not live.
|
||||
case "$_PW_HOLDER" in ''|*[!0-9]*) _PW_HOLDER=0 ;; esac
|
||||
if [ "$_PW_HOLDER" -eq 0 ] || ! kill -0 "$_PW_HOLDER" 2>/dev/null; then
|
||||
_PW_STALE=1
|
||||
elif [ -n "$_PW_LOCK_OLD" ]; then
|
||||
# The holder is alive but the lock is older than the install bound: the
|
||||
# holder is past its own deadline, or its pid was recycled to an
|
||||
# unrelated long-lived process. Either way nobody is installing.
|
||||
_PW_STALE=1
|
||||
fi
|
||||
fi
|
||||
if [ "$_PW_STALE" -eq 1 ]; then
|
||||
echo " reclaiming stale Chromium-install lock (holder pid ${_PW_HOLDER:-?} is gone or past the install bound)" >&2
|
||||
# Rename first: two setups judging the same lock stale race on rm -rf +
|
||||
# mkdir, and the loser would delete the winner's fresh lock. mv of a
|
||||
# directory is atomic, so exactly one of them reclaims — and if the dir
|
||||
# we moved already belongs to a NEW live holder (it re-created the lock
|
||||
# between our judgment and our mv), hand it straight back.
|
||||
if mv "$_PW_LOCK" "$_PW_LOCK.stale.$$" 2>/dev/null; then
|
||||
_PW_MOVED_PID=$(cat "$_PW_LOCK.stale.$$/pid" 2>/dev/null || true)
|
||||
case "$_PW_MOVED_PID" in ''|*[!0-9]*) _PW_MOVED_PID=0 ;; esac
|
||||
if { [ "$_PW_MOVED_PID" -gt 0 ] && [ "$_PW_MOVED_PID" != "$_PW_HOLDER" ] && kill -0 "$_PW_MOVED_PID" 2>/dev/null; } \
|
||||
|| { [ ! -f "$_PW_LOCK.stale.$$/pid" ] && [ -z "$_PW_LOCK_OLD" ]; }; then
|
||||
# A new live holder, or a fresh lock whose holder has not written its
|
||||
# pid yet (mkdir done, echo pending): not ours to reclaim.
|
||||
mv "$_PW_LOCK.stale.$$" "$_PW_LOCK" 2>/dev/null || true
|
||||
else
|
||||
rm -rf "$_PW_LOCK.stale.$$" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if mkdir "$_PW_LOCK" 2>/dev/null; then
|
||||
@@ -806,48 +1055,58 @@ if ! ensure_playwright_browser; then
|
||||
else
|
||||
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_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=<seconds>)"
|
||||
elif [ "$_PW_RC" -ne 0 ]; then
|
||||
_pw_fail chromium-install "bunx playwright install chromium exited $_PW_RC (offline, proxy, or blocked download?)"
|
||||
fi
|
||||
rm -rf "$_PW_LOCK" 2>/dev/null || true
|
||||
# Restore the original handler (never `trap - EXIT`, which would clear
|
||||
# cleanup_copied_bun for the rest of the script).
|
||||
trap cleanup_copied_bun EXIT
|
||||
else
|
||||
echo " another gstack setup is already installing Chromium (lock: $_PW_LOCK)." >&2
|
||||
echo " Wait for it to finish, then re-run ./setup. If no other setup is running," >&2
|
||||
echo " remove the stale lock: rm -rf \"$_PW_LOCK\"" >&2
|
||||
exit 1
|
||||
_pw_fail chromium-install-locked "another gstack setup is installing Chromium (lock: $_PW_LOCK) — re-run ./setup after it finishes, or remove a stale lock: rm -rf \"$_PW_LOCK\""
|
||||
fi
|
||||
|
||||
if [ "$IS_WINDOWS" -eq 1 ]; then
|
||||
if [ -z "$_PW_FAIL_REASON" ] && [ "$IS_WINDOWS" -eq 1 ]; then
|
||||
# On Windows, Node.js launches Chromium (not Bun — see oven-sh/bun#4253).
|
||||
# Ensure playwright is importable by Node from the gstack directory.
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "gstack setup failed: Node.js is required on Windows (Bun cannot launch Chromium due to a pipe bug)" >&2
|
||||
echo " Install Node.js: https://nodejs.org/" >&2
|
||||
exit 1
|
||||
_pw_fail windows-no-node "Node.js is required on Windows to launch Chromium (Bun cannot: oven-sh/bun#4253) — install from https://nodejs.org/ and re-run ./setup"
|
||||
else
|
||||
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.
|
||||
# @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.
|
||||
# &&-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
|
||||
fi
|
||||
echo "Windows detected — verifying Node.js can load Playwright..."
|
||||
(
|
||||
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
|
||||
# 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
|
||||
)
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! ensure_playwright_browser; then
|
||||
if [ -z "$_PW_FAIL_REASON" ] && ! ensure_playwright_browser; then
|
||||
if [ "$IS_WINDOWS" -eq 1 ]; then
|
||||
echo "gstack setup failed: Playwright Chromium could not be launched via Node.js" >&2
|
||||
echo " This is a known issue with Bun on Windows (oven-sh/bun#4253)." >&2
|
||||
echo " Ensure Node.js is installed and 'node -e \"require('playwright')\"' works." >&2
|
||||
_pw_fail post-install-launch "Playwright Chromium could not be launched via Node.js (oven-sh/bun#4253) — ensure 'node -e \"require('playwright')\"' works, then re-run ./setup"
|
||||
else
|
||||
echo "gstack setup failed: Playwright Chromium could not be launched" >&2
|
||||
_pw_fail post-install-launch "Playwright Chromium installed but could not be launched — on Ubuntu 24.04+ (AppArmor blocks unprivileged user namespaces) try GSTACK_CHROMIUM_NO_SANDBOX=1 (#2157)"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2b. Ensure a color-emoji font is installed so make-pdf emoji render (Linux).
|
||||
@@ -859,7 +1118,9 @@ if ! ensure_emoji_font; then
|
||||
echo " Fedora: sudo dnf install google-noto-color-emoji-fonts" >&2
|
||||
echo " Arch: sudo pacman -S noto-fonts-emoji" >&2
|
||||
echo " Alpine: sudo apk add font-noto-emoji" >&2
|
||||
else
|
||||
elif [ -z "$_PW_FAIL_REASON" ]; then
|
||||
# Only when Chromium is actually usable — restarting a daemon that cannot
|
||||
# launch its browser just produces a second failure line.
|
||||
refresh_browse_daemon_for_fonts
|
||||
fi
|
||||
|
||||
@@ -885,6 +1146,12 @@ mkdir -p "$HOME/.gstack/projects"
|
||||
_link_skill_runtime_assets() {
|
||||
local src_dir="$1"
|
||||
local dst_dir="$2"
|
||||
# $3 = 0 when the destination directory is not provably ours (pre-existed
|
||||
# unclaimed, or only weakly proven where gstack never wrote real assets): its
|
||||
# real files are the user's, so a same-named real asset is kept and reported
|
||||
# instead of replaced (#2119). Symlinks are never content and are always
|
||||
# refreshed. Default 1 = the directory is ours.
|
||||
local replace_real="${3:-1}"
|
||||
local asset asset_name
|
||||
for asset in "$src_dir"/*; do
|
||||
[ -e "$asset" ] || continue # empty-glob guard
|
||||
@@ -892,8 +1159,12 @@ _link_skill_runtime_assets() {
|
||||
case "$asset_name" in
|
||||
SKILL.md|node_modules|dist|test|*.tmpl) continue ;;
|
||||
esac
|
||||
# Refresh unconditionally: rm the old entry (symlink OR real copy — the
|
||||
# Windows install pattern) so re-runs after `git pull` pick up changes.
|
||||
if [ -e "$dst_dir/$asset_name" ] && [ ! -L "$dst_dir/$asset_name" ] && [ "$replace_real" != "1" ]; then
|
||||
echo " kept ${dst_dir##*/}/$asset_name: a file you own already uses that name — left untouched" >&2
|
||||
continue
|
||||
fi
|
||||
# Refresh: rm the old entry (symlink OR real copy — the Windows install
|
||||
# pattern) so re-runs after `git pull` pick up changes.
|
||||
if [ -e "$dst_dir/$asset_name" ] || [ -L "$dst_dir/$asset_name" ]; then
|
||||
rm -rf "$dst_dir/$asset_name"
|
||||
fi
|
||||
@@ -917,6 +1188,27 @@ _link_skill_runtime_assets() {
|
||||
# gstack/ (which would auto-prefix them as gstack-*).
|
||||
# When SKILL_PREFIX=1, directories are prefixed with "gstack-".
|
||||
# Use --no-prefix to restore flat names.
|
||||
# Run gstack-relink and surface only what the user must see: foreign entries it
|
||||
# skipped (deduped against the ones this setup already reported, same wording)
|
||||
# and any pre-existing SKILL.md it moved to the backup root.
|
||||
_run_relink_quiet() {
|
||||
local out line name
|
||||
out="$(GSTACK_SKILLS_DIR="$INSTALL_SKILLS_DIR" GSTACK_INSTALL_DIR="$SOURCE_GSTACK_DIR" "$GSTACK_RELINK" 2>&1 || true)"
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
' skipped '*)
|
||||
name="${line# skipped }"; name="${name%%:*}"
|
||||
case " ${_FOREIGN_SKIPPED_ENTRIES[*]:-} " in
|
||||
*" $name "*) ;;
|
||||
*) echo "$line" >&2; _FOREIGN_SKIPPED_ENTRIES+=("$name") ;;
|
||||
esac ;;
|
||||
'Moved '*|' cleaned '*) echo " ${line# }" >&2 ;;
|
||||
esac
|
||||
done <<EOF
|
||||
$out
|
||||
EOF
|
||||
}
|
||||
|
||||
link_claude_skill_dirs() {
|
||||
local gstack_dir="$1"
|
||||
local skills_dir="$2"
|
||||
@@ -939,6 +1231,32 @@ 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
|
||||
# Remember whether WE are creating this directory: only then may the
|
||||
# provenance marker below make it deletable whole. A directory we merely
|
||||
# link into (unclaimed, or a legacy install) never gets one — legacy
|
||||
# all-links dirs are removed by the only-links rule instead.
|
||||
# _assets_replace: may _link_skill_runtime_assets replace a REAL file or
|
||||
# dir already present under the target? Yes for a directory we create or
|
||||
# strongly own (marker, or SKILL.md symlink into gstack). On Windows also
|
||||
# for a weakly-proven real-file copy install (the legacy pre-marker shape,
|
||||
# whose asset copies are ours). Otherwise (unclaimed, or a weak copy on a
|
||||
# platform where gstack never wrote real assets) real files are the
|
||||
# user's and are kept.
|
||||
_pre_exists=0; _assets_replace=1
|
||||
if [ -e "$target" ] || [ -L "$target" ]; then
|
||||
_pre_exists=1
|
||||
if _claude_entry_owned_strongly "$target" "$gstack_dir"; then _assets_replace=1
|
||||
elif [ "$IS_WINDOWS" -eq 1 ] && [ -f "$target/SKILL.md" ] && [ ! -L "$target/SKILL.md" ]; then _assets_replace=1
|
||||
else _assets_replace=0
|
||||
fi
|
||||
fi
|
||||
# Upgrade old directory symlinks to real directories
|
||||
if [ -L "$target" ]; then
|
||||
rm -f "$target"
|
||||
@@ -959,7 +1277,23 @@ link_claude_skill_dirs() {
|
||||
if [ -f "$_render_dir/$dir_name/SKILL.md" ]; then
|
||||
_skill_md_src="$_render_dir/$dir_name/SKILL.md"
|
||||
fi
|
||||
# A real-file SKILL.md we can only WEAKLY prove ours and whose content
|
||||
# differs from what we are about to serve is moved aside, not overwritten.
|
||||
if [ -f "$target/SKILL.md" ] && [ ! -L "$target/SKILL.md" ] && ! _claude_entry_owned_strongly "$target" "$gstack_dir" \
|
||||
&& ! cmp -s "$target/SKILL.md" "$_skill_md_src"; then
|
||||
if ! _backup_skill_md "$target/SKILL.md" "$link_name"; then
|
||||
echo " skipped $link_name: could not back up its customized SKILL.md — left untouched" >&2
|
||||
_FOREIGN_SKIPPED_ENTRIES+=("$link_name")
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
_link_or_copy "$_skill_md_src" "$target/SKILL.md"
|
||||
# Provenance marker (#2119) on every platform — path-independent proof
|
||||
# for Windows copies and for checkouts whose path carries no `gstack`
|
||||
# segment — but only for a directory we created or already owned: a
|
||||
# pre-existing unclaimed or weakly-proven directory must not become
|
||||
# deletable whole because we linked one file into it.
|
||||
if [ "$_pre_exists" -eq 0 ] || [ -f "$target/.gstack-owned" ]; 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
|
||||
@@ -967,7 +1301,7 @@ link_claude_skill_dirs() {
|
||||
# landed and /review 404'd at "Read .claude/skills/review/checklist.md"
|
||||
# on every fresh Claude install. Routes through _link_or_copy so Windows
|
||||
# gets real copies refreshed on every ./setup.
|
||||
_link_skill_runtime_assets "$gstack_dir/$dir_name" "$target"
|
||||
_link_skill_runtime_assets "$gstack_dir/$dir_name" "$target" "$_assets_replace"
|
||||
linked+=("$link_name")
|
||||
fi
|
||||
done
|
||||
@@ -993,13 +1327,27 @@ _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.
|
||||
_alias_pre=0
|
||||
if [ -e "$dst_dir" ] || [ -L "$dst_dir" ]; then _alias_pre=1; fi
|
||||
if [ -L "$dst_dir" ]; then rm -f "$dst_dir"; fi
|
||||
mkdir -p "$dst_dir"
|
||||
# Remove any prior symlinked SKILL.md so the redirect below cannot write
|
||||
# through it into the generated source.
|
||||
rm -f "$dst_dir/SKILL.md"
|
||||
sed "1,/^---\$/ s/^name:[[:space:]].*/name: $alias_name/" "$src_skill_md" > "$dst_dir/SKILL.md"
|
||||
# A rewritten copy is a real file on every platform; the marker proves it
|
||||
# ours on the next run without leaning on the banner — but only for a
|
||||
# directory we created (or already marked), never one we merely wrote into.
|
||||
if [ "$_alias_pre" -eq 0 ] || [ -f "$dst_dir/.gstack-owned" ]; then _write_owned_marker "$dst_dir" "$SOURCE_GSTACK_DIR"; fi
|
||||
}
|
||||
|
||||
# Claude Code skips the repo-shaped ~/.claude/skills/gstack directory when
|
||||
@@ -1055,17 +1403,20 @@ cleanup_old_claude_symlinks() {
|
||||
# render prefix (~/.gstack/render/claude/...), which is not `/gstack/`.
|
||||
case "$link_dest" in
|
||||
gstack/*|*/gstack/*|*/.gstack/render/claude/*)
|
||||
rm -rf "$old_target"
|
||||
_cleanup_linked_dir "$old_target" "$gstack_dir"
|
||||
removed+=("$skill_name")
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
# Windows install pattern: real dir with real-file SKILL.md (no symlink
|
||||
# available, so we can't readlink to verify provenance). Iterate 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. When the payload
|
||||
# is gone this branch is a no-op — a real file has no proven owner.
|
||||
# available, so we can't readlink to verify provenance). A bare name match
|
||||
# deleted a user's own same-name skill (#2119); ownership is now proven by
|
||||
# the .gstack-owned marker link_claude_skill_dirs writes, or — for copies
|
||||
# made before the marker existed — by the copy being byte-identical to the
|
||||
# gstack source SKILL.md or carrying gen-skill-docs' AUTO-GENERATED header
|
||||
# (every generated SKILL.md does; a hand-written skill does not). Anything
|
||||
# else is foreign and is left alone.
|
||||
if [ "${IS_WINDOWS:-0}" -eq 1 ] && [ -d "$gstack_dir" ]; then
|
||||
for skill_dir in "$gstack_dir"/*/; do
|
||||
if [ -f "$skill_dir/SKILL.md" ]; then
|
||||
@@ -1074,8 +1425,13 @@ cleanup_old_claude_symlinks() {
|
||||
case "$skill_name" in gstack-*) continue ;; esac
|
||||
old_target="$skills_dir/$skill_name"
|
||||
if [ -d "$old_target" ] && [ ! -L "$old_target" ] \
|
||||
&& [ -f "$old_target/SKILL.md" ] && [ ! -L "$old_target/SKILL.md" ]; then
|
||||
rm -rf "$old_target"
|
||||
&& [ -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" \
|
||||
|| _gstack_generated_header "$old_target/SKILL.md"; }; then
|
||||
# Only the marker proves we created the directory; weak proof covers
|
||||
# the SKILL.md alone (a user's files next to it survive).
|
||||
if [ -f "$old_target/.gstack-owned" ]; then rm -rf "$old_target"; else _cleanup_weak_dir "$old_target" "$gstack_dir" "$skill_dir/SKILL.md" "$skill_name"; fi
|
||||
removed+=("$skill_name")
|
||||
fi
|
||||
fi
|
||||
@@ -1101,11 +1457,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")
|
||||
;;
|
||||
@@ -1114,16 +1472,20 @@ 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*)
|
||||
rm -rf "$prefixed_target"
|
||||
gstack/*|*/gstack/*|*/.gstack/render/claude/*)
|
||||
_cleanup_linked_dir "$prefixed_target" "$gstack_dir"
|
||||
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"
|
||||
# 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
|
||||
if [ -f "$prefixed_target/.gstack-owned" ]; then rm -rf "$prefixed_target"; else _cleanup_weak_dir "$prefixed_target" "$gstack_dir" "$skill_dir/SKILL.md" "gstack-$skill_name"; fi
|
||||
removed+=("gstack-$skill_name")
|
||||
fi
|
||||
fi
|
||||
@@ -1681,7 +2043,7 @@ 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
|
||||
_run_relink_quiet
|
||||
fi
|
||||
# Backwards-compat alias: /connect-chrome → /open-gstack-browser
|
||||
# Rewritten copy, not a symlink: a symlinked alias re-serves the canonical
|
||||
@@ -1754,7 +2116,7 @@ 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
|
||||
_run_relink_quiet
|
||||
fi
|
||||
# Rewritten copy, not a symlink: a symlinked alias re-serves the
|
||||
# canonical name: open-gstack-browser, so one of the two silently
|
||||
@@ -1981,7 +2343,7 @@ if [ ! -f "$HOME/.gstack/.welcome-seen" ]; then
|
||||
log ""
|
||||
# Best-effort onboarding telemetry (respects telemetry!=off; never blocks setup).
|
||||
if [ -x "$SOURCE_GSTACK_DIR/bin/gstack-telemetry-log" ]; then
|
||||
"$SOURCE_GSTACK_DIR/bin/gstack-telemetry-log" --event-type onboarding --skill _setup_welcome --outcome shown >/dev/null 2>&1 || true
|
||||
"$SOURCE_GSTACK_DIR/bin/gstack-telemetry-log" --event-type onboarding --skill _setup_welcome --outcome shown --no-sweep >/dev/null 2>&1 || true
|
||||
fi
|
||||
touch "$HOME/.gstack/.welcome-seen"
|
||||
fi
|
||||
@@ -2587,3 +2949,41 @@ if ! grep -q '^redact_prepush_hook:' "$_GSTACK_CFG_FILE" 2>/dev/null; then
|
||||
log " installs the hook automatically in every repo you ship from."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─── Chromium bootstrap summary (best-effort browser, see # 2) ───────────────
|
||||
# Printed LAST so it is the thing the user sees, after every skill registered.
|
||||
_PW_BROWSER_SKILLS="/qa, /qa-only, /design-review, /browse, make-pdf, /pair-agent, and any other skill that drives the browser"
|
||||
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: $_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" ;;
|
||||
esac
|
||||
case "$_PW_FAIL_REASON" in
|
||||
*post-install-launch*) log " Ubuntu 24.04+ (AppArmor user namespaces): GSTACK_CHROMIUM_NO_SANDBOX=1 ./setup (#2157)" ;;
|
||||
esac
|
||||
# Reason code only — never a path, hostname, or the installer's output. The
|
||||
# event is a one-shot with no session of its own, so --no-sweep keeps it
|
||||
# from finalizing other live sessions' in-flight .pending markers as
|
||||
# outcome:unknown. Telemetry-gated inside gstack-telemetry-log.
|
||||
if [ -x "$SOURCE_GSTACK_DIR/bin/gstack-telemetry-log" ]; then
|
||||
"$SOURCE_GSTACK_DIR/bin/gstack-telemetry-log" --event-type onboarding --skill _setup_playwright --outcome "$_PW_FAIL_REASON" --no-sweep >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
if [ ${#_FOREIGN_SKIPPED_ENTRIES[@]} -gt 0 ]; then
|
||||
log ""
|
||||
log "Not registered (a skill you own already uses the name; left untouched): ${_FOREIGN_SKIPPED_ENTRIES[*]}"
|
||||
log " Rename or move yours, or switch modes (./setup --prefix / --no-prefix) so the names no longer collide."
|
||||
fi
|
||||
if [ ${#_BACKED_UP_SKILL_MDS[@]} -gt 0 ]; then
|
||||
log ""
|
||||
log "Moved ${#_BACKED_UP_SKILL_MDS[@]} customized SKILL.md file(s) to $_SKILL_BACKUP_ROOT before installing gstack's: ${_BACKED_UP_SKILL_MDS[*]}"
|
||||
log " Those were gstack-generated files you had edited; restore anything you meant to keep under a different skill name."
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user