mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-13 00:19:03 +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
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Run an assembled bash script from a TEMP FILE — never via `bash -c <argv>`.
|
||||
*
|
||||
* Why: the setup harnesses slice functions out of `setup` and join them into
|
||||
* one script. On Windows, bash is an MSYS2 program; when its parent is a
|
||||
* non-MSYS process (bun), msys-2.0.dll's build_argv() runs every argument
|
||||
* containing any of `?*["'(){}` through globify()/glob(). glob() copies the
|
||||
* pattern into a fixed `Char patbuf[8192]` and silently stops after
|
||||
* 8192 - MB_CUR_MAX (8186 characters under C.UTF-8); GLOB_NOCHECK then hands
|
||||
* the truncated text to bash as the argument. Observed on windows-free-tests
|
||||
* when the alias harness grew from 6.7 KB to 15.7 KB: the `-c` script was cut
|
||||
* inside a single-quoted token on line 178 ("unexpected EOF while looking for
|
||||
* matching `'"). A short, glob-character-free file path never enters
|
||||
* globify, and bash reads the file's bytes directly, so quoting and encoding
|
||||
* are never re-parsed by any argument layer. The same ~8186-char ceiling
|
||||
* applies to any MSYS tool (sh, sed, awk, grep) spawned from bun on Windows
|
||||
* with a long single argument.
|
||||
*
|
||||
* `timeout` is always set (test/spawnsync-timeout-tripwire.test.ts).
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface BashScriptResult {
|
||||
status: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
signal: NodeJS.Signals | null;
|
||||
}
|
||||
|
||||
export interface BashScriptOptions {
|
||||
timeout?: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export function runBashScript(script: string, opts: BashScriptOptions = {}): BashScriptResult {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-bash-script-'));
|
||||
const file = path.join(dir, 'script.sh');
|
||||
// LF only: a stray CR would reach bash as part of a token.
|
||||
fs.writeFileSync(file, script.replace(/\r\n/g, '\n'));
|
||||
try {
|
||||
const r = spawnSync('bash', [file], {
|
||||
encoding: 'utf-8',
|
||||
timeout: opts.timeout ?? 60_000,
|
||||
...(opts.env ? { env: opts.env } : {}),
|
||||
...(opts.cwd ? { cwd: opts.cwd } : {}),
|
||||
});
|
||||
// A spawn failure (bash missing) or a timeout kill has no bash stderr of
|
||||
// its own; surface the cause instead of a bare status -1.
|
||||
const stderr = (r.stderr ?? '') + (r.error ? `\n[spawn] ${r.error.message}` : '');
|
||||
return { status: r.status ?? -1, stdout: r.stdout ?? '', stderr, signal: r.signal ?? null };
|
||||
} finally {
|
||||
// Best-effort: an AV scanner or indexer still holding script.sh on
|
||||
// Windows must never turn a good result into an unlink error.
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort temp cleanup */ }
|
||||
}
|
||||
}
|
||||
+291
-15
@@ -76,12 +76,45 @@ function withFreezeDir(freezePath: string, fn: (stateDir: string) => void) {
|
||||
}
|
||||
}
|
||||
|
||||
// The freeze WRITER resolves its state root through bin/gstack-paths, which
|
||||
// trusts CLAUDE_PLUGIN_DATA only when CLAUDE_PLUGIN_ROOT names gstack; the
|
||||
// reader mirrors that exact chain (#1459 / #1509). A test standing in for a
|
||||
// plugin install must supply both, and must neutralize a GSTACK_HOME inherited
|
||||
// from the shard's process.env (an empty value reads as unset in ${VAR:-}).
|
||||
function freezeEnv(stateDir: string, extra: Record<string, string> = {}): Record<string, string> {
|
||||
return { GSTACK_HOME: '', CLAUDE_PLUGIN_DATA: stateDir, CLAUDE_PLUGIN_ROOT: '/plugins/gstack', ...extra };
|
||||
}
|
||||
|
||||
const HOOK_EXTRACT = path.join(ROOT, 'careful', 'bin', 'hook-extract.sh');
|
||||
const GSTACK_PATHS = path.join(ROOT, 'bin', 'gstack-paths');
|
||||
|
||||
/** What the hook helper resolves as the state root under a given env. */
|
||||
function hookStateRoot(env: Record<string, string>): string {
|
||||
const r = spawnSync('bash', ['-c', `. "${HOOK_EXTRACT}" && gstack_hook_state_root`], {
|
||||
env: { PATH: process.env.PATH ?? '', ...env }, encoding: 'utf-8', timeout: 5000,
|
||||
});
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
/** What bin/gstack-paths resolves as GSTACK_STATE_ROOT under the same env. */
|
||||
function pathsStateRoot(env: Record<string, string>): string {
|
||||
const r = spawnSync('bash', ['-c', `eval "$("${GSTACK_PATHS}")" && printf '%s' "$GSTACK_STATE_ROOT"`], {
|
||||
env: { PATH: process.env.PATH ?? '', ...env }, encoding: 'utf-8', timeout: 5000,
|
||||
});
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Frontmatter hook wiring (#2469 / #1871)
|
||||
// ============================================================
|
||||
// Frontmatter hooks run before any runtime variable exists, so a
|
||||
// ${CLAUDE_SKILL_DIR}-relative command silently never resolves and the guard
|
||||
// never fires. Every command: line must anchor on $HOME like careful/freeze.
|
||||
function withEmptyDir(fn: (dir: string) => void) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-hook-empty-'));
|
||||
try { fn(dir); } finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
describe('frontmatter hook command paths', () => {
|
||||
test.each(['investigate/SKILL.md', 'careful/SKILL.md', 'freeze/SKILL.md', 'guard/SKILL.md'])(
|
||||
'%s hook commands are $HOME-anchored, never CLAUDE_SKILL_DIR',
|
||||
@@ -666,6 +699,52 @@ describe('check-careful.sh', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('an older hook-extract.sh without gstack_hook_state_root still loads rules from $HOME/.gstack and emits a decision (no set -e death)', () => {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-careful-oldhelper-'));
|
||||
const carefulBin = path.join(base, 'careful', 'bin');
|
||||
fs.mkdirSync(carefulBin, { recursive: true });
|
||||
fs.copyFileSync(CAREFUL_SCRIPT, path.join(carefulBin, 'check-careful.sh'));
|
||||
const helper = fs.readFileSync(HOOK_EXTRACT, 'utf-8');
|
||||
const start = helper.indexOf('gstack_hook_state_root() {');
|
||||
const end = helper.indexOf('\n}\n', start) + 3;
|
||||
fs.writeFileSync(path.join(carefulBin, 'hook-extract.sh'), helper.slice(0, start) + helper.slice(end));
|
||||
const fakeHome = path.join(base, 'home');
|
||||
fs.mkdirSync(path.join(fakeHome, '.gstack'), { recursive: true });
|
||||
fs.writeFileSync(path.join(fakeHome, '.gstack', 'careful-patterns.txt'), 'terraform\\s+destroy\n');
|
||||
try {
|
||||
const { exitCode, output } = runHook(path.join(carefulBin, 'check-careful.sh'), carefulInput('terraform destroy'), { HOME: fakeHome, GSTACK_HOME: '' });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
|
||||
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('Project rule');
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('plugin install: patterns under CLAUDE_PLUGIN_DATA load when CLAUDE_PLUGIN_ROOT names gstack (same root the writer uses)', () => {
|
||||
withPatternFile('terraform\\s+destroy\n', (pluginData) => {
|
||||
withEmptyDir((fakeHome) => {
|
||||
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('terraform destroy'),
|
||||
{ HOME: fakeHome, GSTACK_HOME: '', CLAUDE_PLUGIN_DATA: pluginData, CLAUDE_PLUGIN_ROOT: '/plugins/gstack' });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
|
||||
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('Project rule');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('GSTACK_HOME outranks CLAUDE_PLUGIN_DATA for careful patterns, exactly as for the freeze file', () => {
|
||||
withPatternFile('terraform\\s+destroy\n', (gstackHome) => {
|
||||
withEmptyDir((pluginData) => {
|
||||
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('terraform destroy'),
|
||||
{ GSTACK_HOME: gstackHome, CLAUDE_PLUGIN_DATA: pluginData, CLAUDE_PLUGIN_ROOT: '/plugins/gstack' });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
|
||||
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('Project rule');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('safe commands still allow with a pattern file present', () => {
|
||||
withPatternFile('terraform\\s+destroy\n', (gstackHome) => {
|
||||
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('ls -la'), { GSTACK_HOME: gstackHome });
|
||||
@@ -687,7 +766,7 @@ describe('check-freeze.sh', () => {
|
||||
const { exitCode, output } = runHook(
|
||||
FREEZE_SCRIPT,
|
||||
freezeInput('/Users/dev/project/src/index.ts'),
|
||||
{ CLAUDE_PLUGIN_DATA: stateDir },
|
||||
freezeEnv(stateDir),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
|
||||
@@ -699,7 +778,7 @@ describe('check-freeze.sh', () => {
|
||||
const { exitCode, output } = runHook(
|
||||
FREEZE_SCRIPT,
|
||||
freezeInput('/Users/dev/project/src/components/Button.tsx'),
|
||||
{ CLAUDE_PLUGIN_DATA: stateDir },
|
||||
freezeEnv(stateDir),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
|
||||
@@ -713,7 +792,7 @@ describe('check-freeze.sh', () => {
|
||||
const { exitCode, output } = runHook(
|
||||
FREEZE_SCRIPT,
|
||||
freezeInput('/Users/dev/other-project/index.ts'),
|
||||
{ CLAUDE_PLUGIN_DATA: stateDir },
|
||||
freezeEnv(stateDir),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
@@ -727,7 +806,7 @@ describe('check-freeze.sh', () => {
|
||||
const { exitCode, output } = runHook(
|
||||
FREEZE_SCRIPT,
|
||||
freezeInput('/etc/hosts'),
|
||||
{ CLAUDE_PLUGIN_DATA: stateDir },
|
||||
freezeEnv(stateDir),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
@@ -743,7 +822,7 @@ describe('check-freeze.sh', () => {
|
||||
const { exitCode, output } = runHook(
|
||||
FREEZE_SCRIPT,
|
||||
freezeInput('/Users/dev/project/src-old/index.ts'),
|
||||
{ CLAUDE_PLUGIN_DATA: stateDir },
|
||||
freezeEnv(stateDir),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
@@ -759,7 +838,7 @@ describe('check-freeze.sh', () => {
|
||||
const { exitCode, output } = runHook(
|
||||
FREEZE_SCRIPT,
|
||||
freezeInput('/anywhere/at/all.ts'),
|
||||
{ CLAUDE_PLUGIN_DATA: stateDir },
|
||||
freezeEnv(stateDir),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
|
||||
@@ -775,7 +854,7 @@ describe('check-freeze.sh', () => {
|
||||
const { exitCode, output } = runHook(
|
||||
FREEZE_SCRIPT,
|
||||
{ tool_input: {} },
|
||||
{ CLAUDE_PLUGIN_DATA: stateDir },
|
||||
freezeEnv(stateDir),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
|
||||
@@ -787,7 +866,7 @@ describe('check-freeze.sh', () => {
|
||||
const { exitCode, output } = runHookRaw(
|
||||
FREEZE_SCRIPT,
|
||||
'not json at all {{{{',
|
||||
{ CLAUDE_PLUGIN_DATA: stateDir },
|
||||
freezeEnv(stateDir),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
@@ -803,7 +882,7 @@ describe('check-freeze.sh', () => {
|
||||
const { exitCode, output, raw } = runHook(
|
||||
FREEZE_SCRIPT,
|
||||
freezeInput('/tmp/evil"quoted/x.ts'),
|
||||
{ CLAUDE_PLUGIN_DATA: stateDir },
|
||||
freezeEnv(stateDir),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(() => JSON.parse(raw)).not.toThrow();
|
||||
@@ -816,7 +895,7 @@ describe('check-freeze.sh', () => {
|
||||
const { exitCode, output, raw } = runHook(
|
||||
FREEZE_SCRIPT,
|
||||
freezeInput('/tmp/evil\npath.ts'),
|
||||
{ CLAUDE_PLUGIN_DATA: stateDir },
|
||||
freezeEnv(stateDir),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(() => JSON.parse(raw)).not.toThrow();
|
||||
@@ -834,11 +913,11 @@ describe('check-freeze.sh', () => {
|
||||
fs.mkdirSync(boundary, { recursive: true });
|
||||
try {
|
||||
withFreezeDir(boundary + '/', (stateDir) => {
|
||||
const inside = runHook(FREEZE_SCRIPT, freezeInput(path.join(boundary, 'index.ts')), { CLAUDE_PLUGIN_DATA: stateDir });
|
||||
const inside = runHook(FREEZE_SCRIPT, freezeInput(path.join(boundary, 'index.ts')), freezeEnv(stateDir));
|
||||
expect(inside.exitCode).toBe(0);
|
||||
expect(inside.output.hookSpecificOutput?.permissionDecision).toBeUndefined();
|
||||
|
||||
const outside = runHook(FREEZE_SCRIPT, freezeInput(path.join(base, 'elsewhere.ts')), { CLAUDE_PLUGIN_DATA: stateDir });
|
||||
const outside = runHook(FREEZE_SCRIPT, freezeInput(path.join(base, 'elsewhere.ts')), freezeEnv(stateDir));
|
||||
expect(outside.exitCode).toBe(0);
|
||||
expect(outside.output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
});
|
||||
@@ -859,7 +938,7 @@ describe('check-freeze.sh', () => {
|
||||
fs.copyFileSync(FREEZE_SCRIPT, script);
|
||||
try {
|
||||
withFreezeDir('/Users/dev/project/src/', (stateDir) => {
|
||||
const { exitCode, output } = runHook(script, freezeInput('/Users/dev/project/src/x.ts'), { CLAUDE_PLUGIN_DATA: stateDir });
|
||||
const { exitCode, output } = runHook(script, freezeInput('/Users/dev/project/src/x.ts'), freezeEnv(stateDir));
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('fail closed');
|
||||
@@ -884,13 +963,13 @@ describe('check-freeze.sh', () => {
|
||||
fs.symlinkSync(path.join(outside, 'secret.txt'), path.join(boundary, 'link.txt'));
|
||||
try {
|
||||
withFreezeDir(boundary + '/', (stateDir) => {
|
||||
const viaLink = runHook(FREEZE_SCRIPT, freezeInput(path.join(boundary, 'link.txt')), { CLAUDE_PLUGIN_DATA: stateDir });
|
||||
const viaLink = runHook(FREEZE_SCRIPT, freezeInput(path.join(boundary, 'link.txt')), freezeEnv(stateDir));
|
||||
expect(viaLink.exitCode).toBe(0);
|
||||
expect(viaLink.output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
|
||||
// A real in-boundary file is unaffected.
|
||||
fs.writeFileSync(path.join(boundary, 'real.txt'), 'y');
|
||||
const real = runHook(FREEZE_SCRIPT, freezeInput(path.join(boundary, 'real.txt')), { CLAUDE_PLUGIN_DATA: stateDir });
|
||||
const real = runHook(FREEZE_SCRIPT, freezeInput(path.join(boundary, 'real.txt')), freezeEnv(stateDir));
|
||||
expect(real.exitCode).toBe(0);
|
||||
expect(real.output.hookSpecificOutput?.permissionDecision).toBeUndefined();
|
||||
});
|
||||
@@ -900,3 +979,200 @@ describe('check-freeze.sh', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// check-freeze.sh state-root resolution (#1459 / #1509)
|
||||
// ============================================================
|
||||
// /freeze writes freeze-dir.txt under the root gstack-paths resolves
|
||||
// (GSTACK_HOME first). The reader used to read ${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}
|
||||
// — so with GSTACK_HOME set it found no file and ALLOWED everything. A deny-tier
|
||||
// boundary that fails open is not a boundary; writer and reader now share one
|
||||
// chain (gstack_hook_state_root in careful/bin/hook-extract.sh).
|
||||
describe('check-freeze.sh state-root resolution (#1459 / #1509)', () => {
|
||||
const BOUNDARY = '/Users/dev/project/src/';
|
||||
const OUTSIDE = '/Users/dev/other-project/index.ts';
|
||||
|
||||
|
||||
test('REGRESSION: freeze file under GSTACK_HOME (HOME has none) denies an outside edit', () => {
|
||||
withFreezeDir(BOUNDARY, (gstackHome) => {
|
||||
withEmptyDir((fakeHome) => {
|
||||
const { exitCode, output } = runHook(FREEZE_SCRIPT, freezeInput(OUTSIDE), {
|
||||
GSTACK_HOME: gstackHome, HOME: fakeHome, CLAUDE_PLUGIN_DATA: '', CLAUDE_PLUGIN_ROOT: '',
|
||||
});
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('GSTACK_HOME wins over CLAUDE_PLUGIN_DATA (matches gstack-paths precedence)', () => {
|
||||
withFreezeDir(BOUNDARY, (pluginData) => {
|
||||
withEmptyDir((gstackHome) => {
|
||||
// The freeze file lives under CLAUDE_PLUGIN_DATA, but GSTACK_HOME is set and
|
||||
// has none — the writer would have written there, so the reader must look there.
|
||||
const { output } = runHook(FREEZE_SCRIPT, freezeInput(OUTSIDE),
|
||||
freezeEnv(pluginData, { GSTACK_HOME: gstackHome }));
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('CLAUDE_PLUGIN_DATA is ignored when CLAUDE_PLUGIN_ROOT is another plugin', () => {
|
||||
withFreezeDir(BOUNDARY, (pluginData) => {
|
||||
withEmptyDir((fakeHome) => {
|
||||
const { output } = runHook(FREEZE_SCRIPT, freezeInput(OUTSIDE),
|
||||
freezeEnv(pluginData, { CLAUDE_PLUGIN_ROOT: '/plugins/codex', HOME: fakeHome }));
|
||||
// Falls through to $HOME/.gstack, which has no freeze file → allow.
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('CLAUDE_PLUGIN_DATA is honoured when CLAUDE_PLUGIN_ROOT names gstack', () => {
|
||||
withFreezeDir(BOUNDARY, (pluginData) => {
|
||||
withEmptyDir((fakeHome) => {
|
||||
const { output } = runHook(FREEZE_SCRIPT, freezeInput(OUTSIDE), freezeEnv(pluginData, { HOME: fakeHome }));
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('gstack_hook_state_root is byte-identical to gstack-paths GSTACK_STATE_ROOT', () => {
|
||||
const combos: Record<string, string>[] = [
|
||||
{ HOME: '/home/u', GSTACK_HOME: '/state/x', CLAUDE_PLUGIN_DATA: '/plug/data', CLAUDE_PLUGIN_ROOT: '/plugins/gstack' },
|
||||
{ HOME: '/home/u', GSTACK_HOME: '', CLAUDE_PLUGIN_DATA: '/plug/data', CLAUDE_PLUGIN_ROOT: '/plugins/gstack' },
|
||||
{ HOME: '/home/u', GSTACK_HOME: '', CLAUDE_PLUGIN_DATA: '/plug/data', CLAUDE_PLUGIN_ROOT: '/plugins/codex' },
|
||||
{ HOME: '/home/u', GSTACK_HOME: '', CLAUDE_PLUGIN_DATA: '/plug/data', CLAUDE_PLUGIN_ROOT: '' },
|
||||
{ HOME: '/home/u', GSTACK_HOME: '', CLAUDE_PLUGIN_DATA: '', CLAUDE_PLUGIN_ROOT: '' },
|
||||
{ HOME: '', GSTACK_HOME: '', CLAUDE_PLUGIN_DATA: '', CLAUDE_PLUGIN_ROOT: '' },
|
||||
];
|
||||
for (const env of combos) {
|
||||
expect(hookStateRoot(env)).toBe(pathsStateRoot(env));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// gstack_hook_log_fire analytics sink follows the same state root (#1459)
|
||||
// ============================================================
|
||||
// The hook_fire record lands under ${GSTACK_HOME:-$HOME/.gstack}/analytics —
|
||||
// the SAME two-step chain every other analytics writer and reader uses
|
||||
// (gstack-skill-start, gstack-retro-metrics, gstack-analytics) — deliberately
|
||||
// NOT the plugin-aware state root the freeze FILE uses, so the usage log stays
|
||||
// one file. Logging is best-effort: an unwritable sink never changes the decision.
|
||||
describe('gstack_hook_log_fire writes under the resolved state root', () => {
|
||||
const BOUNDARY = '/Users/dev/project/src/';
|
||||
const OUTSIDE = '/Users/dev/other-project/index.ts';
|
||||
|
||||
function lastRecord(file: string): any {
|
||||
const lines = fs.readFileSync(file, 'utf-8').trim().split('\n');
|
||||
return JSON.parse(lines[lines.length - 1]);
|
||||
}
|
||||
|
||||
test('REGRESSION: a freeze deny under GSTACK_HOME appends hook_fire to $GSTACK_HOME/analytics, not $HOME/.gstack', () => {
|
||||
withFreezeDir(BOUNDARY, (gstackHome) => {
|
||||
withEmptyDir((fakeHome) => {
|
||||
const { exitCode, output } = runHook(FREEZE_SCRIPT, freezeInput(OUTSIDE), {
|
||||
GSTACK_HOME: gstackHome, HOME: fakeHome, CLAUDE_PLUGIN_DATA: '', CLAUDE_PLUGIN_ROOT: '',
|
||||
});
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
const rec = lastRecord(path.join(gstackHome, 'analytics', 'skill-usage.jsonl'));
|
||||
expect(rec.event).toBe('hook_fire');
|
||||
expect(rec.skill).toBe('freeze');
|
||||
expect(rec.pattern).toBe('boundary_deny');
|
||||
expect(typeof rec.ts).toBe('string');
|
||||
expect(fs.existsSync(path.join(fakeHome, '.gstack'))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('plugin install: the freeze FILE is read from CLAUDE_PLUGIN_DATA but hook_fire still lands under $HOME/.gstack/analytics (one usage log)', () => {
|
||||
withFreezeDir(BOUNDARY, (pluginData) => {
|
||||
withEmptyDir((fakeHome) => {
|
||||
const { output } = runHook(FREEZE_SCRIPT, freezeInput(OUTSIDE),
|
||||
freezeEnv(pluginData, { HOME: fakeHome, CLAUDE_PLUGIN_ROOT: '/Plugins/GSTACK' }));
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
const rec = lastRecord(path.join(fakeHome, '.gstack', 'analytics', 'skill-usage.jsonl'));
|
||||
expect(rec.event).toBe('hook_fire');
|
||||
expect(rec.skill).toBe('freeze');
|
||||
expect(fs.existsSync(path.join(pluginData, 'analytics'))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('a GSTACK_HOME ending in a newline round-trips exactly (writer %q and reader sentinel agree)', () => {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-freeze-nl-'));
|
||||
const nlDir = path.join(base, 'root\n');
|
||||
fs.mkdirSync(nlDir);
|
||||
fs.writeFileSync(path.join(nlDir, 'freeze-dir.txt'), BOUNDARY);
|
||||
try {
|
||||
const { output } = runHook(FREEZE_SCRIPT, freezeInput(OUTSIDE), {
|
||||
GSTACK_HOME: nlDir, HOME: base, CLAUDE_PLUGIN_DATA: '', CLAUDE_PLUGIN_ROOT: '',
|
||||
});
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('an unexpected set -e death inside the hook (a tool on PATH failing) DENIES via the EXIT backstop instead of exiting with no JSON', () => {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-freeze-backstop-'));
|
||||
const fakeBin = path.join(base, 'bin');
|
||||
fs.mkdirSync(fakeBin);
|
||||
fs.writeFileSync(path.join(fakeBin, 'head'), '#!/bin/sh\nexit 1\n');
|
||||
fs.chmodSync(path.join(fakeBin, 'head'), 0o755);
|
||||
try {
|
||||
withFreezeDir(BOUNDARY, (stateDir) => {
|
||||
const { exitCode, output } = runHook(FREEZE_SCRIPT, freezeInput('/Users/dev/project/src/x.ts'),
|
||||
freezeEnv(stateDir, { PATH: `${fakeBin}:${process.env.PATH ?? ''}` }));
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('failed unexpectedly');
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a hook helper from an older install that lacks gstack_hook_state_root DENIES (fail closed), never exit 127', () => {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-freeze-oldhelper-'));
|
||||
const freezeBin = path.join(base, 'freeze', 'bin');
|
||||
const carefulBin = path.join(base, 'careful', 'bin');
|
||||
fs.mkdirSync(freezeBin, { recursive: true });
|
||||
fs.mkdirSync(carefulBin, { recursive: true });
|
||||
fs.copyFileSync(FREEZE_SCRIPT, path.join(freezeBin, 'check-freeze.sh'));
|
||||
const helper = fs.readFileSync(HOOK_EXTRACT, 'utf-8');
|
||||
const start = helper.indexOf('gstack_hook_state_root() {');
|
||||
const end = helper.indexOf('\n}\n', start) + 3;
|
||||
fs.writeFileSync(path.join(carefulBin, 'hook-extract.sh'), helper.slice(0, start) + helper.slice(end));
|
||||
try {
|
||||
withFreezeDir(BOUNDARY, (stateDir) => {
|
||||
const { exitCode, output } = runHook(path.join(freezeBin, 'check-freeze.sh'), freezeInput('/Users/dev/project/src/x.ts'), freezeEnv(stateDir));
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
// 'out of date' is the helper-without-function branch; the plain
|
||||
// helpers-unavailable deny also says 'fail closed', so pin the specific one.
|
||||
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('out of date');
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('an unwritable analytics sink never changes the decision: deny is still emitted as valid JSON', () => {
|
||||
withFreezeDir(BOUNDARY, (gstackHome) => {
|
||||
// `analytics` is a regular FILE, so mkdir -p and the >> append both fail.
|
||||
fs.writeFileSync(path.join(gstackHome, 'analytics'), 'not a directory');
|
||||
withEmptyDir((fakeHome) => {
|
||||
const { exitCode, output, raw } = runHook(FREEZE_SCRIPT, freezeInput(OUTSIDE), {
|
||||
GSTACK_HOME: gstackHome, HOME: fakeHome, CLAUDE_PLUGIN_DATA: '', CLAUDE_PLUGIN_ROOT: '',
|
||||
});
|
||||
expect(exitCode).toBe(0);
|
||||
expect(() => JSON.parse(raw)).not.toThrow();
|
||||
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
expect(fs.readFileSync(path.join(gstackHome, 'analytics'), 'utf-8')).toBe('not a directory');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -672,3 +672,563 @@ describe('gstack-patch-names (#620/#578)', () => {
|
||||
expect(content).toBe('# qa\nSome content.');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Ownership gate (#2119): relink runs on every ./setup and must never delete
|
||||
// or link over a skill it does not own. Ownership = symlink into INSTALL_DIR
|
||||
// or RENDER_DIR, a real dir whose SKILL.md is such a symlink, or the
|
||||
// .gstack-owned marker setup writes for Windows copy installs.
|
||||
// ============================================================
|
||||
describe('gstack-relink ownership gate (#2119)', () => {
|
||||
const FOREIGN = '---\nname: qa\ndescription: my own qa skill\n---\n# not gstack';
|
||||
|
||||
function relink(env: Record<string, string> = {}): string {
|
||||
return run(`${path.join(installDir, 'bin', 'gstack-relink')} 2>&1`, {
|
||||
GSTACK_INSTALL_DIR: installDir,
|
||||
GSTACK_SKILLS_DIR: skillsDir,
|
||||
GSTACK_HOME: path.join(tmpDir, 'home'), GSTACK_USER_RENDER_DIR: path.join(tmpDir, 'no-render'),
|
||||
...env,
|
||||
});
|
||||
}
|
||||
function setPrefix(v: 'true' | 'false') {
|
||||
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix ${v}`, {
|
||||
GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, GSTACK_HOME: path.join(tmpDir, 'home'), GSTACK_USER_RENDER_DIR: path.join(tmpDir, 'no-render'),
|
||||
});
|
||||
}
|
||||
|
||||
test('flat mode: a foreign real dir with a real SKILL.md is never linked over (Linux ln -snf would replace the file)', () => {
|
||||
setupMockInstall(['qa', 'ship']);
|
||||
// The foreign skill exists before gstack ever runs (gstack-config `set`
|
||||
// auto-relinks, so fixtures go in first).
|
||||
fs.mkdirSync(path.join(skillsDir, 'qa'));
|
||||
fs.writeFileSync(path.join(skillsDir, 'qa', 'SKILL.md'), FOREIGN);
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
const md = path.join(skillsDir, 'qa', 'SKILL.md');
|
||||
expect(fs.lstatSync(md).isSymbolicLink()).toBe(false);
|
||||
expect(fs.readFileSync(md, 'utf-8')).toBe(FOREIGN);
|
||||
expect(out).toContain('skipped');
|
||||
expect(out).toContain('Skipped 1 foreign entry');
|
||||
// The other skill still links normally.
|
||||
expect(fs.lstatSync(path.join(skillsDir, 'ship', 'SKILL.md')).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
test('prefix flip: a foreign flat entry sharing a skill name survives the cleanup pass', () => {
|
||||
setupMockInstall(['qa']);
|
||||
fs.mkdirSync(path.join(skillsDir, 'qa'));
|
||||
fs.writeFileSync(path.join(skillsDir, 'qa', 'SKILL.md'), FOREIGN);
|
||||
setPrefix('true');
|
||||
const out = relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(true);
|
||||
expect(fs.readFileSync(path.join(skillsDir, 'qa', 'SKILL.md'), 'utf-8')).toBe(FOREIGN);
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'))).toBe(true);
|
||||
expect(out).toContain('skipped');
|
||||
});
|
||||
|
||||
test('a foreign directory symlink sharing a skill name is left in place', () => {
|
||||
setupMockInstall(['qa']);
|
||||
const elsewhere = path.join(tmpDir, 'elsewhere', 'qa');
|
||||
fs.mkdirSync(elsewhere, { recursive: true });
|
||||
fs.writeFileSync(path.join(elsewhere, 'SKILL.md'), FOREIGN);
|
||||
fs.symlinkSync(elsewhere, path.join(skillsDir, 'qa'));
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(fs.lstatSync(path.join(skillsDir, 'qa')).isSymbolicLink()).toBe(true);
|
||||
expect(fs.readlinkSync(path.join(skillsDir, 'qa'))).toBe(elsewhere);
|
||||
expect(fs.readFileSync(path.join(elsewhere, 'SKILL.md'), 'utf-8')).toBe(FOREIGN);
|
||||
expect(out).toContain('skipped');
|
||||
});
|
||||
|
||||
test('an entry whose SKILL.md links into RENDER_DIR is ours and is cleaned on a mode flip', () => {
|
||||
setupMockInstall(['qa']);
|
||||
setPrefix('false');
|
||||
const renderDir = path.join(tmpDir, 'render', 'claude');
|
||||
fs.mkdirSync(path.join(renderDir, 'qa'), { recursive: true });
|
||||
fs.writeFileSync(path.join(renderDir, 'qa', 'SKILL.md'), '---\nname: gstack-qa\ndescription: rendered\n---\n');
|
||||
// Stale prefixed entry from a prior prefix-mode run, pointing at the render.
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
|
||||
fs.symlinkSync(path.join(renderDir, 'qa', 'SKILL.md'), path.join(skillsDir, 'gstack-qa', 'SKILL.md'));
|
||||
const out = relink({ GSTACK_USER_RENDER_DIR: renderDir });
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
|
||||
expect(fs.readlinkSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(path.join(renderDir, 'qa', 'SKILL.md'));
|
||||
expect(out).not.toContain('skipped');
|
||||
});
|
||||
|
||||
test('a real-file copy carrying the .gstack-owned marker (Windows install shape) is ours', () => {
|
||||
setupMockInstall(['qa']);
|
||||
setPrefix('false');
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
|
||||
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), '---\nname: gstack-qa\n---\n');
|
||||
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', '.gstack-owned'), '');
|
||||
const out = relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
|
||||
expect(out).not.toContain('skipped');
|
||||
});
|
||||
|
||||
test('a legacy RELATIVE symlink into the install (gstack/qa/SKILL.md) is ours and is cleaned', () => {
|
||||
setupMockInstall(['qa']);
|
||||
// Older setups wrote relative links; the skills dir sits beside the install
|
||||
// dir named `gstack`, so `gstack/qa/SKILL.md` resolves into INSTALL_DIR.
|
||||
const installAlias = path.join(skillsDir, 'gstack');
|
||||
fs.symlinkSync(installDir, installAlias);
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
|
||||
fs.symlinkSync('../gstack/qa/SKILL.md', path.join(skillsDir, 'gstack-qa', 'SKILL.md'));
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
|
||||
expect(out).not.toContain('skipped');
|
||||
});
|
||||
|
||||
test('an entry linked against the REAL path of a symlinked install dir is ours', () => {
|
||||
setupMockInstall(['qa']);
|
||||
const realInstall = fs.realpathSync(installDir);
|
||||
const linkInstall = path.join(tmpDir, 'install-link');
|
||||
fs.symlinkSync(realInstall, linkInstall);
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
|
||||
fs.symlinkSync(path.join(realInstall, 'qa', 'SKILL.md'), path.join(skillsDir, 'gstack-qa', 'SKILL.md'));
|
||||
// relink detects the install through the symlinked spelling.
|
||||
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, {
|
||||
GSTACK_INSTALL_DIR: linkInstall, GSTACK_SKILLS_DIR: skillsDir,
|
||||
});
|
||||
const out = run(`${path.join(installDir, 'bin', 'gstack-relink')} 2>&1`, {
|
||||
GSTACK_INSTALL_DIR: linkInstall, GSTACK_SKILLS_DIR: skillsDir,
|
||||
});
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
|
||||
expect(out).not.toContain('skipped');
|
||||
});
|
||||
|
||||
test('a real-file copy WITHOUT the marker is foreign and survives', () => {
|
||||
setupMockInstall(['qa']);
|
||||
setPrefix('false');
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
|
||||
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), FOREIGN);
|
||||
const out = relink();
|
||||
expect(fs.readFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), 'utf-8')).toBe(FOREIGN);
|
||||
expect(out).toContain('skipped');
|
||||
});
|
||||
|
||||
// Before the gate, a stray regular FILE named like a skill made `mkdir -p`
|
||||
// fail under set -e and relink died mid-loop (exit 1, later skills never
|
||||
// linked). A non-dir, non-symlink entry is simply foreign now.
|
||||
test('a stray regular file with a skill name is foreign: relink completes, file untouched, other skills link', () => {
|
||||
setupMockInstall(['qa', 'ship']);
|
||||
fs.writeFileSync(path.join(skillsDir, 'qa'), 'stray notes\n');
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(fs.lstatSync(path.join(skillsDir, 'qa')).isFile()).toBe(true);
|
||||
expect(fs.readFileSync(path.join(skillsDir, 'qa'), 'utf-8')).toBe('stray notes\n');
|
||||
expect(fs.lstatSync(path.join(skillsDir, 'ship', 'SKILL.md')).isSymbolicLink()).toBe(true);
|
||||
expect(out).toContain('Relinked 1 skills as flat names');
|
||||
expect(out).toContain('Skipped 1 foreign entry');
|
||||
});
|
||||
|
||||
test('a real dir whose SKILL.md symlink points OUTSIDE install/render is foreign in both the link pass and the flip cleanup', () => {
|
||||
setupMockInstall(['qa']);
|
||||
const elsewhere = path.join(tmpDir, 'elsewhere', 'qa');
|
||||
fs.mkdirSync(elsewhere, { recursive: true });
|
||||
fs.writeFileSync(path.join(elsewhere, 'SKILL.md'), FOREIGN);
|
||||
fs.mkdirSync(path.join(skillsDir, 'qa'));
|
||||
fs.symlinkSync(path.join(elsewhere, 'SKILL.md'), path.join(skillsDir, 'qa', 'SKILL.md'));
|
||||
// Link pass (flat mode): the destination is not ours → never re-pointed.
|
||||
setPrefix('false');
|
||||
let out = relink();
|
||||
expect(fs.readlinkSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(path.join(elsewhere, 'SKILL.md'));
|
||||
expect(out).toContain('skipped');
|
||||
// Cleanup pass (prefix flip): the stale flat name is not ours → kept.
|
||||
setPrefix('true');
|
||||
out = relink();
|
||||
expect(fs.readlinkSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(path.join(elsewhere, 'SKILL.md'));
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'))).toBe(true);
|
||||
expect(out).toContain('skipped');
|
||||
});
|
||||
|
||||
test('a sibling directory that merely shares the install dir as a prefix (…/gstack-install-fork) is not ours', () => {
|
||||
setupMockInstall(['qa']);
|
||||
const fork = installDir + '-fork';
|
||||
fs.mkdirSync(path.join(fork, 'qa'), { recursive: true });
|
||||
fs.writeFileSync(path.join(fork, 'qa', 'SKILL.md'), FOREIGN);
|
||||
fs.mkdirSync(path.join(skillsDir, 'qa'));
|
||||
fs.symlinkSync(path.join(fork, 'qa', 'SKILL.md'), path.join(skillsDir, 'qa', 'SKILL.md'));
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(fs.readlinkSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(path.join(fork, 'qa', 'SKILL.md'));
|
||||
expect(fs.readFileSync(path.join(fork, 'qa', 'SKILL.md'), 'utf-8')).toBe(FOREIGN);
|
||||
expect(out).toContain('skipped');
|
||||
});
|
||||
|
||||
test('several foreign entries: pluralized summary lists every name and the relinked count excludes them', () => {
|
||||
setupMockInstall(['qa', 'ship', 'review']);
|
||||
for (const name of ['qa', 'ship']) {
|
||||
fs.mkdirSync(path.join(skillsDir, name));
|
||||
fs.writeFileSync(path.join(skillsDir, name, 'SKILL.md'), FOREIGN);
|
||||
}
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(out).toContain('Relinked 1 skills as flat names');
|
||||
expect(out).toContain('Skipped 2 foreign entries');
|
||||
// Bare names, same wording as setup's own line, so setup can dedupe when it forwards relink's output.
|
||||
expect(out).toContain('skipped qa: existing entry is not gstack-managed');
|
||||
expect(out).toContain('skipped ship: existing entry is not gstack-managed');
|
||||
expect(out).toContain('left untouched): qa ship');
|
||||
expect(out).not.toContain('foreign entry ');
|
||||
expect(fs.lstatSync(path.join(skillsDir, 'review', 'SKILL.md')).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
test('an opposite-mode WHOLE-DIR symlink into the install (oldest install shape) is ours and is removed on a flip', () => {
|
||||
setupMockInstall(['qa']);
|
||||
fs.symlinkSync(path.join(installDir, 'qa'), path.join(skillsDir, 'gstack-qa'));
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
|
||||
expect(fs.lstatSync(path.join(skillsDir, 'gstack-qa'), { throwIfNoEntry: false })).toBeUndefined();
|
||||
expect(fs.lstatSync(path.join(skillsDir, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(true);
|
||||
expect(out).not.toContain('skipped');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-relink ownership gate parity with setup (#2119 review fixes)', () => {
|
||||
const FOREIGN = '---\nname: qa\ndescription: my own qa skill\n---\n# not gstack';
|
||||
function relink(env: Record<string, string> = {}): string {
|
||||
return run(`${path.join(installDir, 'bin', 'gstack-relink')} 2>&1`, {
|
||||
GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, GSTACK_HOME: path.join(tmpDir, 'home'), GSTACK_USER_RENDER_DIR: path.join(tmpDir, 'no-render'),
|
||||
...env,
|
||||
});
|
||||
}
|
||||
function setPrefix(v: 'true' | 'false') {
|
||||
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix ${v}`, {
|
||||
GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, GSTACK_HOME: path.join(tmpDir, 'home'), GSTACK_USER_RENDER_DIR: path.join(tmpDir, 'no-render'),
|
||||
});
|
||||
}
|
||||
|
||||
test('a pre-marker legacy COPY carrying the generated header is ours (same rule as setup) and is cleaned on a flip', () => {
|
||||
setupMockInstall(['qa']);
|
||||
setPrefix('false');
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
|
||||
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), '---\nname: gstack-qa\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n# qa\n');
|
||||
const out = relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
|
||||
expect(out).not.toContain('skipped');
|
||||
});
|
||||
|
||||
test('a ONE-line AUTO-GENERATED substring is not provenance (another generator could emit it): entry survives', () => {
|
||||
setupMockInstall(['qa']);
|
||||
setPrefix('false');
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
|
||||
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), '---\nname: gstack-qa\n---\n<!-- AUTO-GENERATED from my-tool -->\n# theirs\n');
|
||||
const out = relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'))).toBe(true);
|
||||
expect(out).toContain('skipped');
|
||||
});
|
||||
|
||||
test('an absolute link that walks through a gstack segment with `..` is canonicalized first, not fast-pathed as ours', () => {
|
||||
setupMockInstall(['qa']);
|
||||
const decoy = path.join(tmpDir, 'x', 'gstack');
|
||||
const foreign = path.join(tmpDir, 'x', 'foreign');
|
||||
fs.mkdirSync(decoy, { recursive: true });
|
||||
fs.mkdirSync(foreign, { recursive: true });
|
||||
fs.writeFileSync(path.join(foreign, 'SKILL.md'), FOREIGN);
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
|
||||
fs.symlinkSync(path.join(decoy, '..', 'foreign', 'SKILL.md'), path.join(skillsDir, 'gstack-qa', 'SKILL.md'));
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'))).toBe(true);
|
||||
expect(out).toContain('skipped');
|
||||
});
|
||||
|
||||
test('a byte-identical copy of our source SKILL.md is ours even without marker or header', () => {
|
||||
setupMockInstall(['qa']);
|
||||
setPrefix('false');
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
|
||||
fs.copyFileSync(path.join(installDir, 'qa', 'SKILL.md'), path.join(skillsDir, 'gstack-qa', 'SKILL.md'));
|
||||
const out = relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
|
||||
expect(out).not.toContain('skipped');
|
||||
});
|
||||
|
||||
test('an entry linked into a SIBLING gstack checkout (path segment `gstack`) is ours, like setup and uninstall treat it', () => {
|
||||
setupMockInstall(['qa']);
|
||||
setPrefix('false');
|
||||
const sibling = path.join(tmpDir, 'worktrees', 'gstack', 'qa');
|
||||
fs.mkdirSync(sibling, { recursive: true });
|
||||
fs.writeFileSync(path.join(sibling, 'SKILL.md'), '---\nname: qa\n---\n');
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
|
||||
fs.symlinkSync(path.join(sibling, 'SKILL.md'), path.join(skillsDir, 'gstack-qa', 'SKILL.md'));
|
||||
const out = relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
|
||||
expect(out).not.toContain('skipped');
|
||||
});
|
||||
|
||||
test('a fork under a `gstack-fork` directory is NOT ours (segment must be exactly gstack)', () => {
|
||||
setupMockInstall(['qa']);
|
||||
const fork = path.join(tmpDir, 'tools', 'gstack-fork', 'qa');
|
||||
fs.mkdirSync(fork, { recursive: true });
|
||||
fs.writeFileSync(path.join(fork, 'SKILL.md'), FOREIGN);
|
||||
fs.mkdirSync(path.join(skillsDir, 'qa'));
|
||||
fs.symlinkSync(path.join(fork, 'SKILL.md'), path.join(skillsDir, 'qa', 'SKILL.md'));
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(fs.readlinkSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(path.join(fork, 'SKILL.md'));
|
||||
expect(out).toContain('skipped');
|
||||
});
|
||||
|
||||
test('a DANGLING link from a moved checkout is healed, not reported foreign', () => {
|
||||
setupMockInstall(['qa']);
|
||||
fs.mkdirSync(path.join(skillsDir, 'qa'));
|
||||
fs.symlinkSync(path.join(tmpDir, 'old-checkout', 'gstack', 'qa', 'SKILL.md'), path.join(skillsDir, 'qa', 'SKILL.md'));
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(out).not.toContain('skipped');
|
||||
expect(fs.readlinkSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(path.join(installDir, 'qa', 'SKILL.md'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-relink root alias (_gstack-command) ownership gate', () => {
|
||||
const ROOT_SKILL = '---\nname: gstack\ndescription: root\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n# gstack root\n';
|
||||
function relink(): string {
|
||||
return run(`${path.join(installDir, 'bin', 'gstack-relink')} 2>&1`, {
|
||||
GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, GSTACK_HOME: path.join(tmpDir, 'home'), GSTACK_USER_RENDER_DIR: path.join(tmpDir, 'no-render'),
|
||||
});
|
||||
}
|
||||
|
||||
test('a user-owned _gstack-command skill is reported and left byte-identical', () => {
|
||||
setupMockInstall(['qa']);
|
||||
fs.writeFileSync(path.join(installDir, 'SKILL.md'), ROOT_SKILL);
|
||||
const mine = '---\nname: _gstack-command\ndescription: my own command runner\n---\n# mine\n';
|
||||
fs.mkdirSync(path.join(skillsDir, '_gstack-command'));
|
||||
fs.writeFileSync(path.join(skillsDir, '_gstack-command', 'SKILL.md'), mine);
|
||||
const out = relink();
|
||||
expect(fs.readFileSync(path.join(skillsDir, '_gstack-command', 'SKILL.md'), 'utf-8')).toBe(mine);
|
||||
expect(fs.existsSync(path.join(skillsDir, '_gstack-command', '.gstack-owned'))).toBe(false);
|
||||
expect(out).toContain('skipped');
|
||||
expect(out).toContain('_gstack-command');
|
||||
});
|
||||
|
||||
test('our own rewritten alias copy is refreshed and stamped with the ownership marker', () => {
|
||||
setupMockInstall(['qa']);
|
||||
fs.writeFileSync(path.join(installDir, 'SKILL.md'), ROOT_SKILL);
|
||||
const first = relink();
|
||||
expect(first).not.toContain('skipped');
|
||||
const alias = path.join(skillsDir, '_gstack-command');
|
||||
expect(fs.readFileSync(path.join(alias, 'SKILL.md'), 'utf-8')).toContain('name: _gstack-command');
|
||||
expect(fs.existsSync(path.join(alias, '.gstack-owned'))).toBe(true);
|
||||
// Stale copy (older render) with the marker: refreshed, not reported.
|
||||
fs.writeFileSync(path.join(alias, 'SKILL.md'), '---\nname: _gstack-command\n---\n# stale\n');
|
||||
const second = relink();
|
||||
expect(second).not.toContain('skipped');
|
||||
expect(fs.readFileSync(path.join(alias, 'SKILL.md'), 'utf-8')).toContain('# gstack root');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-relink weak proof is file-scoped; differing files are moved aside (#2119 review)', () => {
|
||||
const BANNER = '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->';
|
||||
const env = () => ({
|
||||
GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, GSTACK_HOME: path.join(tmpDir, 'home'), GSTACK_USER_RENDER_DIR: path.join(tmpDir, 'no-render'),
|
||||
GSTACK_HOME: path.join(tmpDir, 'home'), GSTACK_USER_RENDER_DIR: path.join(tmpDir, 'no-render'),
|
||||
});
|
||||
const relink = () => run(`${path.join(installDir, 'bin', 'gstack-relink')} 2>&1`, env());
|
||||
const setPrefix = (v: 'true' | 'false') => run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix ${v}`, env());
|
||||
const backups = () => {
|
||||
const root = path.join(tmpDir, 'home', 'backups', 'skills');
|
||||
if (!fs.existsSync(root)) return [] as string[];
|
||||
return fs.readdirSync(root).flatMap((ts) => fs.readdirSync(path.join(root, ts)).map((n) => path.join(root, ts, n, 'SKILL.md')));
|
||||
};
|
||||
|
||||
test('flip cleanup on a banner-only copy removes SKILL.md and keeps the user\'s other files and the directory', () => {
|
||||
setupMockInstall(['qa']);
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa', 'my-templates'), { recursive: true });
|
||||
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), `---\nname: gstack-qa\n---\n${BANNER}\n# started from gstack\n`);
|
||||
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'my-templates', 'checklist.md'), '- mine\n');
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(skillsDir, 'gstack-qa', 'my-templates', 'checklist.md'), 'utf-8')).toBe('- mine\n');
|
||||
expect(out).not.toContain('skipped');
|
||||
});
|
||||
|
||||
test('a marker-proven directory (we created it) is still removed whole on a flip', () => {
|
||||
setupMockInstall(['qa']);
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa', 'sections'), { recursive: true });
|
||||
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), '# stale copy\n');
|
||||
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'sections', 'a.md'), 'a\n');
|
||||
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', '.gstack-owned'), installDir + '\n');
|
||||
setPrefix('false');
|
||||
relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
|
||||
});
|
||||
|
||||
test('linking over a CUSTOMIZED banner copy moves it to the backup root first; the link then lands', () => {
|
||||
setupMockInstall(['qa']);
|
||||
const custom = `---\nname: qa\n---\n${BANNER}\n# my qa, started from gstack\n`;
|
||||
fs.mkdirSync(path.join(skillsDir, 'qa'));
|
||||
fs.writeFileSync(path.join(skillsDir, 'qa', 'SKILL.md'), custom);
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(fs.lstatSync(path.join(skillsDir, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(true);
|
||||
const saved = backups();
|
||||
expect(saved.length).toBe(1);
|
||||
expect(fs.readFileSync(saved[0], 'utf-8')).toBe(custom);
|
||||
expect(out).not.toContain('skipped');
|
||||
});
|
||||
|
||||
test('linking over a byte-identical copy makes no backup', () => {
|
||||
setupMockInstall(['qa']);
|
||||
fs.mkdirSync(path.join(skillsDir, 'qa'));
|
||||
fs.copyFileSync(path.join(installDir, 'qa', 'SKILL.md'), path.join(skillsDir, 'qa', 'SKILL.md'));
|
||||
setPrefix('false');
|
||||
relink();
|
||||
expect(fs.lstatSync(path.join(skillsDir, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(true);
|
||||
expect(backups()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-relink: checkout naming, legacy linked dirs, markers (#2119 review)', () => {
|
||||
const env = () => ({
|
||||
GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir,
|
||||
GSTACK_HOME: path.join(tmpDir, 'home'), GSTACK_USER_RENDER_DIR: path.join(tmpDir, 'no-render'),
|
||||
});
|
||||
const relink = () => run(`${path.join(installDir, 'bin', 'gstack-relink')} 2>&1`, env());
|
||||
const setPrefix = (v: 'true' | 'false') => run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix ${v}`, env());
|
||||
|
||||
test('an entry linked into a checkout whose path has no `gstack` segment (git worktree add ../gs-feature) is ours when that tree carries setup + VERSION + bin/', () => {
|
||||
setupMockInstall(['qa']);
|
||||
const other = path.join(tmpDir, 'gs-feature');
|
||||
fs.mkdirSync(path.join(other, 'qa'), { recursive: true });
|
||||
fs.mkdirSync(path.join(other, 'bin'));
|
||||
fs.writeFileSync(path.join(other, 'VERSION'), '1.0.0.0\n');
|
||||
fs.writeFileSync(path.join(other, 'setup'), '#!/bin/bash\n');
|
||||
fs.writeFileSync(path.join(other, 'bin', 'gstack-relink'), '#!/bin/bash\n');
|
||||
fs.writeFileSync(path.join(other, 'qa', 'SKILL.md'), '---\nname: qa\n---\n');
|
||||
fs.mkdirSync(path.join(skillsDir, 'qa'));
|
||||
fs.symlinkSync(path.join(other, 'qa', 'SKILL.md'), path.join(skillsDir, 'qa', 'SKILL.md'));
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(out).not.toContain('skipped');
|
||||
expect(fs.readlinkSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(path.join(installDir, 'qa', 'SKILL.md'));
|
||||
// ...but a hand-written skill repo with VERSION + setup + bin/ (no gstack-relink) is not a gstack tree.
|
||||
const plain = path.join(tmpDir, 'plain-tools');
|
||||
fs.mkdirSync(path.join(plain, 'ship'), { recursive: true });
|
||||
fs.mkdirSync(path.join(plain, 'bin'));
|
||||
fs.writeFileSync(path.join(plain, 'VERSION'), '0.1\n');
|
||||
fs.writeFileSync(path.join(plain, 'setup'), '#!/bin/bash\n');
|
||||
fs.writeFileSync(path.join(plain, 'ship', 'SKILL.md'), '---\nname: ship\n---\n');
|
||||
fs.mkdirSync(path.join(installDir, 'ship'));
|
||||
fs.writeFileSync(path.join(installDir, 'ship', 'SKILL.md'), '---\nname: ship\n---\n');
|
||||
fs.mkdirSync(path.join(skillsDir, 'ship'));
|
||||
fs.symlinkSync(path.join(plain, 'ship', 'SKILL.md'), path.join(skillsDir, 'ship', 'SKILL.md'));
|
||||
const out2 = relink();
|
||||
expect(out2).toContain('skipped ship');
|
||||
});
|
||||
|
||||
test('a directory relink creates gets the marker on Linux; a pre-existing unclaimed directory does not', () => {
|
||||
setupMockInstall(['qa', 'ship']);
|
||||
fs.mkdirSync(path.join(skillsDir, 'ship'));
|
||||
fs.writeFileSync(path.join(skillsDir, 'ship', 'notes.md'), 'mine\n');
|
||||
setPrefix('false');
|
||||
relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'qa', '.gstack-owned'))).toBe(true);
|
||||
expect(fs.lstatSync(path.join(skillsDir, 'ship', 'SKILL.md')).isSymbolicLink()).toBe(true);
|
||||
expect(fs.existsSync(path.join(skillsDir, 'ship', '.gstack-owned'))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(skillsDir, 'ship', 'notes.md'), 'utf-8')).toBe('mine\n');
|
||||
});
|
||||
|
||||
test('flip on a legacy linked dir (no marker): all-links dir is removed whole; a dir with a user file keeps the file and drops our links', () => {
|
||||
setupMockInstall(['qa', 'ship']);
|
||||
fs.mkdirSync(path.join(installDir, 'qa', 'sections'));
|
||||
for (const name of ['gstack-qa', 'gstack-ship']) {
|
||||
fs.mkdirSync(path.join(skillsDir, name));
|
||||
fs.symlinkSync(path.join(installDir, 'qa', 'SKILL.md'), path.join(skillsDir, name, 'SKILL.md'));
|
||||
fs.symlinkSync(path.join(installDir, 'qa', 'sections'), path.join(skillsDir, name, 'sections'));
|
||||
}
|
||||
fs.writeFileSync(path.join(skillsDir, 'gstack-ship', 'my-notes.md'), 'keep\n');
|
||||
// gstack-config `set` auto-relinks, so the flip cleanup runs there; capture both outputs.
|
||||
const out = setPrefix('false') + relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-ship', 'SKILL.md'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-ship', 'sections'))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(skillsDir, 'gstack-ship', 'my-notes.md'), 'utf-8')).toBe('keep\n');
|
||||
expect(out).toContain('cleaned gstack-ship/SKILL.md');
|
||||
expect(out).not.toContain('skipped');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-relink cycle-3 hardening: foreign dir links, failed backups, flip backups, mixed dirs (#2119 review)', () => {
|
||||
const BANNER = '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->';
|
||||
const env = (extra: Record<string, string> = {}) => ({
|
||||
GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir,
|
||||
GSTACK_HOME: path.join(tmpDir, 'home'), GSTACK_USER_RENDER_DIR: path.join(tmpDir, 'no-render'), ...extra,
|
||||
});
|
||||
const relink = (extra: Record<string, string> = {}) => run(`${path.join(installDir, 'bin', 'gstack-relink')} 2>&1`, env(extra));
|
||||
const setPrefix = (v: 'true' | 'false') => run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix ${v}`, env());
|
||||
const backups = (home = path.join(tmpDir, 'home')) => {
|
||||
const root = path.join(home, 'backups', 'skills');
|
||||
if (!fs.existsSync(root)) return [] as string[];
|
||||
return fs.readdirSync(root).flatMap((ts) => fs.readdirSync(path.join(root, ts)).map((n) => path.join(root, ts, n, 'SKILL.md')));
|
||||
};
|
||||
|
||||
test('a foreign DIRECTORY symlink whose target has no SKILL.md is foreign, not unclaimed', () => {
|
||||
setupMockInstall(['qa']);
|
||||
const userdir = path.join(tmpDir, 'userdir');
|
||||
fs.mkdirSync(userdir);
|
||||
fs.writeFileSync(path.join(userdir, 'notes.md'), 'mine\n');
|
||||
fs.symlinkSync(userdir, path.join(skillsDir, 'qa'));
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(out).toContain('skipped qa');
|
||||
expect(fs.readlinkSync(path.join(skillsDir, 'qa'))).toBe(userdir);
|
||||
expect(fs.existsSync(path.join(userdir, 'SKILL.md'))).toBe(false);
|
||||
});
|
||||
|
||||
test('flip cleanup moves a CUSTOMIZED banner copy to the backup root instead of deleting it', () => {
|
||||
setupMockInstall(['qa']);
|
||||
const custom = `---\nname: gstack-qa\n---\n${BANNER}\n# customized\n`;
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
|
||||
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), custom);
|
||||
setPrefix('false');
|
||||
relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
|
||||
const saved = backups();
|
||||
expect(saved.length).toBe(1);
|
||||
expect(fs.readFileSync(saved[0], 'utf-8')).toBe(custom);
|
||||
});
|
||||
|
||||
test('when the backup root cannot be created, the customized file stays and the entry is reported', () => {
|
||||
setupMockInstall(['qa']);
|
||||
const custom = `---\nname: qa\n---\n${BANNER}\n# customized\n`;
|
||||
fs.mkdirSync(path.join(skillsDir, 'qa'));
|
||||
fs.writeFileSync(path.join(skillsDir, 'qa', 'SKILL.md'), custom);
|
||||
fs.mkdirSync(path.join(tmpDir, 'home'));
|
||||
fs.writeFileSync(path.join(tmpDir, 'home', 'backups'), 'not a dir');
|
||||
setPrefix('false');
|
||||
const out = relink();
|
||||
expect(out).toContain('could not back up');
|
||||
expect(fs.lstatSync(path.join(skillsDir, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(false);
|
||||
expect(fs.readFileSync(path.join(skillsDir, 'qa', 'SKILL.md'), 'utf-8')).toBe(custom);
|
||||
});
|
||||
|
||||
test('a legacy linked dir holding the user\'s OWN symlink is mixed: our links go, theirs stays', () => {
|
||||
setupMockInstall(['qa']);
|
||||
fs.mkdirSync(path.join(installDir, 'qa', 'sections'));
|
||||
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
|
||||
fs.symlinkSync(path.join(installDir, 'qa', 'SKILL.md'), path.join(skillsDir, 'gstack-qa', 'SKILL.md'));
|
||||
fs.symlinkSync(path.join(installDir, 'qa', 'sections'), path.join(skillsDir, 'gstack-qa', 'sections'));
|
||||
fs.writeFileSync(path.join(tmpDir, 'my-notes.md'), 'mine\n');
|
||||
fs.symlinkSync(path.join(tmpDir, 'my-notes.md'), path.join(skillsDir, 'gstack-qa', 'notes.md'));
|
||||
setPrefix('false');
|
||||
relink();
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa', 'sections'))).toBe(false);
|
||||
expect(fs.readlinkSync(path.join(skillsDir, 'gstack-qa', 'notes.md'))).toBe(path.join(tmpDir, 'my-notes.md'));
|
||||
});
|
||||
|
||||
test('the root alias marker is written only for a directory relink creates', () => {
|
||||
setupMockInstall(['qa']);
|
||||
fs.writeFileSync(path.join(installDir, 'SKILL.md'), `---\nname: gstack\n---\n${BANNER}\n# root\n`);
|
||||
fs.mkdirSync(path.join(skillsDir, '_gstack-command'));
|
||||
fs.writeFileSync(path.join(skillsDir, '_gstack-command', 'notes.md'), 'mine\n');
|
||||
setPrefix('false');
|
||||
relink();
|
||||
expect(fs.readFileSync(path.join(skillsDir, '_gstack-command', 'SKILL.md'), 'utf-8')).toContain('name: _gstack-command');
|
||||
expect(fs.existsSync(path.join(skillsDir, '_gstack-command', '.gstack-owned'))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(skillsDir, '_gstack-command', 'notes.md'), 'utf-8')).toBe('mine\n');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* corrupted the generated SKILL.md — the source files must stay byte-intact.
|
||||
*/
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { runBashScript } from './helpers/bash-script';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
@@ -52,7 +52,18 @@ beforeAll(() => {
|
||||
'SKILL_PREFIX=0',
|
||||
'QUIET=1',
|
||||
'_WINDOWS_COPY_NOTE_PRINTED=1',
|
||||
'_FOREIGN_SKIPPED_ENTRIES=()',
|
||||
`SOURCE_GSTACK_DIR="${ROOT}"`,
|
||||
extractFn('_link_or_copy'),
|
||||
extractFn('_gstack_link_target_abs'),
|
||||
extractFn('_gstack_target_is_ours'),
|
||||
extractFn('_gstack_generated_header'),
|
||||
extractFn('_claude_entry_is_ours'),
|
||||
extractFn('_claude_entry_owned_strongly'),
|
||||
extractFn('_backup_skill_md'),
|
||||
'_BACKED_UP_SKILL_MDS=()',
|
||||
`_SKILL_BACKUP_ROOT="${os.tmpdir()}/gstack-alias-test-backups"`,
|
||||
extractFn('_write_owned_marker'),
|
||||
extractFn('_print_windows_copy_note_once'),
|
||||
extractFn('_link_skill_runtime_assets'),
|
||||
extractFn('link_claude_skill_dirs'),
|
||||
@@ -63,7 +74,7 @@ beforeAll(() => {
|
||||
installOnce,
|
||||
installOnce,
|
||||
].join('\n');
|
||||
const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 60_000 });
|
||||
const result = runBashScript(script, { timeout: 60_000 });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`alias install failed: ${result.stderr}\n${result.stdout}`);
|
||||
}
|
||||
@@ -144,12 +155,20 @@ describe('alias installs are rewritten copies (#2511, #2201)', () => {
|
||||
const script = [
|
||||
'set -e',
|
||||
'IS_WINDOWS=0',
|
||||
'_FOREIGN_SKIPPED_ENTRIES=()',
|
||||
`SOURCE_GSTACK_DIR="${ROOT}"`,
|
||||
extractFn('_link_or_copy'),
|
||||
extractFn('_gstack_link_target_abs'),
|
||||
extractFn('_gstack_target_is_ours'),
|
||||
extractFn('_gstack_generated_header'),
|
||||
extractFn('_claude_entry_is_ours'),
|
||||
extractFn('_claude_entry_owned_strongly'),
|
||||
extractFn('_write_owned_marker'),
|
||||
extractFn('_install_alias_skill_md'),
|
||||
extractFn('link_claude_root_skill_alias'),
|
||||
`link_claude_root_skill_alias "${ROOT}" "${legacyDir}"`,
|
||||
].join('\n');
|
||||
const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 });
|
||||
const result = runBashScript(script, { timeout: 30_000 });
|
||||
expect(result.status).toBe(0);
|
||||
|
||||
const aliasSkill = path.join(aliasDir, 'SKILL.md');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { runBashScript } from './helpers/bash-script';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
@@ -9,7 +9,7 @@ const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
|
||||
|
||||
// Run a bash snippet, return {stdout, stderr, status}.
|
||||
function runBash(script: string): { stdout: string; stderr: string; status: number } {
|
||||
const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 });
|
||||
const r = runBashScript(script, { timeout: 30_000 });
|
||||
return { stdout: r.stdout || '', stderr: r.stderr || '', status: r.status ?? -1 };
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* down to uselessness.
|
||||
*/
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { runBashScript } from './helpers/bash-script';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
@@ -66,10 +66,23 @@ beforeAll(() => {
|
||||
extractFn('_link_or_copy'),
|
||||
extractFn('_print_windows_copy_note_once'),
|
||||
extractFn('_link_skill_runtime_assets'),
|
||||
extractFn('_gstack_link_target_abs'),
|
||||
extractFn('_gstack_target_is_ours'),
|
||||
extractFn('_gstack_generated_header'),
|
||||
extractFn('_claude_entry_owned_strongly'),
|
||||
extractFn('_claude_entry_is_ours'),
|
||||
extractFn('_write_owned_marker'),
|
||||
extractFn('_backup_skill_md'),
|
||||
extractFn('_cleanup_weak_dir'),
|
||||
extractFn('_gstack_dir_only_links'),
|
||||
extractFn('_cleanup_linked_dir'),
|
||||
'_FOREIGN_SKIPPED_ENTRIES=()',
|
||||
'_BACKED_UP_SKILL_MDS=()',
|
||||
`_SKILL_BACKUP_ROOT="${os.tmpdir()}/gstack-harness-backups-${process.pid}"`,
|
||||
extractFn('link_claude_skill_dirs'),
|
||||
`link_claude_skill_dirs "${ROOT}" "${installDir}"`,
|
||||
].join('\n');
|
||||
const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 60_000 });
|
||||
const result = runBashScript(script, { timeout: 60_000 });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`installer functions failed: ${result.stderr}\n${result.stdout}`);
|
||||
}
|
||||
@@ -138,11 +151,14 @@ describe('link_claude_skill_dirs installs every runtime asset (#2317, #2454)', (
|
||||
}
|
||||
});
|
||||
|
||||
test('hidden files are not installed', () => {
|
||||
test('hidden files are not installed (gstack\'s own provenance marker is the one allowed dotfile)', () => {
|
||||
for (const skill of installedSkillDirs()) {
|
||||
const hidden = fs
|
||||
.readdirSync(path.join(installDir, skill))
|
||||
.filter((e) => e.startsWith('.'));
|
||||
.filter((e) => e.startsWith('.'))
|
||||
// .gstack-owned is written by the linker for directories it creates (#2119),
|
||||
// not copied from the skill source; every other dotfile must stay out.
|
||||
.filter((e) => e !== '.gstack-owned');
|
||||
expect(hidden).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* skills must stay.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { runBashScript } from './helpers/bash-script';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
@@ -24,7 +24,7 @@ function extractFn(name: string): string {
|
||||
}
|
||||
|
||||
function cleanupBody(): string {
|
||||
return extractFn('cleanup_old_claude_symlinks');
|
||||
return [extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_gstack_dir_only_links'), extractFn('_cleanup_linked_dir'), extractFn('_gstack_generated_header'), extractFn('_cleanup_weak_dir'), extractFn('_backup_skill_md'), '_BACKED_UP_SKILL_MDS=()', `_SKILL_BACKUP_ROOT="${os.tmpdir()}/cleanup-orphans-backups-${process.pid}"`, extractFn('cleanup_old_claude_symlinks')].join('\n');
|
||||
}
|
||||
|
||||
describe('setup: cleanup_old_claude_symlinks — static (#2204)', () => {
|
||||
@@ -68,13 +68,11 @@ describe.skipIf(process.platform === 'win32')('setup: cleanup_old_claude_symlink
|
||||
const script = [
|
||||
'set -e',
|
||||
`IS_WINDOWS=${opts.isWindows ?? '0'}`,
|
||||
extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_gstack_dir_only_links'), extractFn('_cleanup_linked_dir'), extractFn('_gstack_generated_header'), extractFn('_cleanup_weak_dir'), extractFn('_backup_skill_md'), '_BACKED_UP_SKILL_MDS=()', `_SKILL_BACKUP_ROOT="${tmp}/backups"`,
|
||||
extractFn('cleanup_old_claude_symlinks'),
|
||||
`cleanup_old_claude_symlinks "${gstackArg}" "${skills}"`,
|
||||
].join('\n');
|
||||
const result = spawnSync('bash', ['-c', script], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
});
|
||||
const result = runBashScript(script, { timeout: 5000 });
|
||||
const names = fs.existsSync(skills)
|
||||
? fs.readdirSync(skills).sort()
|
||||
: [];
|
||||
@@ -259,21 +257,65 @@ describe.skipIf(process.platform === 'win32')('setup: cleanup_old_claude_symlink
|
||||
}
|
||||
});
|
||||
|
||||
test('Windows real-file leftover is removed when the payload still names it', () => {
|
||||
// #2119: a bare name match used to delete a USER's own skill that happened to
|
||||
// share a gstack skill name. Provenance must be proven: the .gstack-owned
|
||||
// marker, a byte-identical copy of the payload source, or gen-skill-docs'
|
||||
// AUTO-GENERATED header (legacy copies made before the marker existed).
|
||||
test('Windows real-file leftover is removed only when provably gstack-owned', () => {
|
||||
const generated = '---\nname: ship\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n# ship\n';
|
||||
const r = runCleanup({
|
||||
isWindows: '1',
|
||||
payload: true,
|
||||
plant(skills, payload) {
|
||||
const src = path.join(payload, 'qa');
|
||||
fs.mkdirSync(src);
|
||||
fs.writeFileSync(path.join(src, 'SKILL.md'), '---\nname: qa\n---\n');
|
||||
plantUserSkill(skills, 'qa');
|
||||
for (const name of ['qa', 'ship', 'review', 'browse']) {
|
||||
fs.mkdirSync(path.join(payload, name));
|
||||
fs.writeFileSync(path.join(payload, name, 'SKILL.md'), name === 'ship' ? generated : `---\nname: ${name}\n---\n`);
|
||||
}
|
||||
// Byte-identical copy of the payload source → ours.
|
||||
fs.mkdirSync(path.join(skills, 'qa'));
|
||||
fs.copyFileSync(path.join(payload, 'qa', 'SKILL.md'), path.join(skills, 'qa', 'SKILL.md'));
|
||||
// Legacy copy carrying the generated header but drifted from source → ours.
|
||||
fs.mkdirSync(path.join(skills, 'ship'));
|
||||
fs.writeFileSync(path.join(skills, 'ship', 'SKILL.md'), generated.replace('# ship', '# ship (older render)'));
|
||||
// Marker-carrying copy with arbitrary content → ours.
|
||||
fs.mkdirSync(path.join(skills, 'browse'));
|
||||
fs.writeFileSync(path.join(skills, 'browse', 'SKILL.md'), '---\nname: browse\n---\n# stale copy\n');
|
||||
fs.writeFileSync(path.join(skills, 'browse', '.gstack-owned'), '');
|
||||
// The user's OWN skill that shares a gstack name → foreign, must survive.
|
||||
plantUserSkill(skills, 'review');
|
||||
plantUserSkill(skills, 'my-own');
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.names).toEqual(['gstack', 'my-own']);
|
||||
expect(r.names).toEqual(['gstack', 'my-own', 'review']);
|
||||
expect(fs.readFileSync(path.join(r.tmp, 'skills', 'review', 'SKILL.md'), 'utf-8')).toContain('user-owned');
|
||||
} finally {
|
||||
fs.rmSync(r.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// The Windows arm is the ONLY path that touches a real-file SKILL.md. On
|
||||
// Unix a same-name real-file skill must survive even when the payload names
|
||||
// it and even when its bytes are identical to the payload source.
|
||||
test('Unix (IS_WINDOWS=0): a same-name real-file skill is never reaped, even if byte-identical to the payload', () => {
|
||||
const r = runCleanup({
|
||||
isWindows: '0',
|
||||
payload: true,
|
||||
plant(skills, payload) {
|
||||
fs.mkdirSync(path.join(payload, 'qa'));
|
||||
fs.writeFileSync(path.join(payload, 'qa', 'SKILL.md'), '---\nname: qa\n---\n');
|
||||
fs.mkdirSync(path.join(skills, 'qa'));
|
||||
fs.copyFileSync(path.join(payload, 'qa', 'SKILL.md'), path.join(skills, 'qa', 'SKILL.md'));
|
||||
fs.mkdirSync(path.join(skills, 'ship'));
|
||||
fs.writeFileSync(path.join(skills, 'ship', 'SKILL.md'), '---\nname: ship\n---\n');
|
||||
fs.writeFileSync(path.join(skills, 'ship', '.gstack-owned'), '');
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe('');
|
||||
expect(r.names).toEqual(['gstack', 'qa', 'ship']);
|
||||
} finally {
|
||||
fs.rmSync(r.tmp, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { runBashScript } from './helpers/bash-script';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
@@ -85,7 +86,7 @@ describe('setup: Conductor worktree guard', () => {
|
||||
echo "LINKED"
|
||||
fi
|
||||
`;
|
||||
const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 });
|
||||
const result = runBashScript(script, { timeout: 30_000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toBe('SKIP');
|
||||
// No child symlink leaked.
|
||||
@@ -120,7 +121,7 @@ describe('setup: Conductor worktree guard', () => {
|
||||
echo "LINKED"
|
||||
fi
|
||||
`;
|
||||
const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 });
|
||||
const result = runBashScript(script, { timeout: 30_000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toBe('LINKED');
|
||||
expect(fs.lstatSync(dest).isSymbolicLink()).toBe(true);
|
||||
@@ -159,7 +160,7 @@ describe('setup: Conductor worktree guard', () => {
|
||||
echo "LINKED"
|
||||
fi
|
||||
`;
|
||||
const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 });
|
||||
const result = runBashScript(script, { timeout: 30_000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toBe('LINKED');
|
||||
expect(fs.readlinkSync(dest)).toBe(source);
|
||||
@@ -191,7 +192,7 @@ describe('setup: Conductor worktree guard', () => {
|
||||
fi
|
||||
echo "skip=$_SKIP_CLAUDE_REGISTER"
|
||||
`;
|
||||
const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 });
|
||||
const result = runBashScript(script, { timeout: 30_000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toBe('skip=0');
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { runBashScript } from './helpers/bash-script';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
@@ -137,11 +137,7 @@ describe.skipIf(process.platform === 'win32')('setup: ensure_emoji_font behavior
|
||||
'echo "INSTALLED=$EMOJI_FONT_INSTALLED"',
|
||||
].join('\n');
|
||||
|
||||
const result = spawnSync('bash', ['-c', script], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
env: { ...process.env, PATH: `${bin}:${process.env.PATH}` },
|
||||
});
|
||||
const result = runBashScript(script, { timeout: 10000, env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } });
|
||||
const out = result.stdout ?? '';
|
||||
return {
|
||||
exit: Number((out.match(/EXIT=(\d+)/) ?? [])[1] ?? -1),
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
/**
|
||||
* setup never links over, copies over, or deletes a skill it does not own
|
||||
* (#2119). The relink gate alone was not enough: link_claude_skill_dirs runs
|
||||
* BEFORE relink on every ./setup, and on Linux `ln -snf` replaces a user's
|
||||
* real SKILL.md with a symlink into gstack (on Windows: rm -rf + cp, then a
|
||||
* marker that makes the user's dir "ours" on the next flip). The reverse
|
||||
* mode-flip cleanup (cleanup_prefixed_claude_symlinks) kept a bare name-match
|
||||
* deletion and a `*gstack*` substring match. Same anchor-sliced convention as
|
||||
* test/setup-cleanup-orphans.test.ts.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { runBashScript } from './helpers/bash-script';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
|
||||
|
||||
function extractFn(name: string): string {
|
||||
const start = SETUP_SRC.indexOf(`${name}() {`);
|
||||
const end = SETUP_SRC.indexOf('\n}\n', start);
|
||||
if (start < 0 || end < 0) throw new Error(`function not found: ${name}`);
|
||||
return SETUP_SRC.slice(start, end + 2);
|
||||
}
|
||||
const HELPERS = [
|
||||
'_FOREIGN_SKIPPED_ENTRIES=()',
|
||||
'_link_skill_runtime_assets() { :; }',
|
||||
'_print_windows_copy_note_once() { :; }',
|
||||
extractFn('_link_or_copy'),
|
||||
extractFn('_gstack_link_target_abs'),
|
||||
extractFn('_gstack_target_is_ours'),
|
||||
extractFn('_claude_entry_is_ours'),
|
||||
extractFn('_write_owned_marker'),
|
||||
extractFn('_gstack_generated_header'),
|
||||
extractFn('_claude_entry_owned_strongly'),
|
||||
extractFn('_backup_skill_md'),
|
||||
extractFn('_cleanup_weak_dir'),
|
||||
extractFn('_gstack_dir_only_links'),
|
||||
extractFn('_cleanup_linked_dir'),
|
||||
'_BACKED_UP_SKILL_MDS=()',
|
||||
'_SKILL_BACKUP_ROOT="$HOME/.gstack/backups/skills/test"',
|
||||
].join('\n');
|
||||
|
||||
const FOREIGN = '---\nname: qa\ndescription: mine\n---\n# not gstack\n';
|
||||
const GENERATED = (name: string) => `---\nname: ${name}\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n# ${name}\n`;
|
||||
|
||||
function mkTree(): { tmp: string; skills: string; payload: string } {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-link-own-'));
|
||||
const skills = path.join(tmp, 'skills');
|
||||
const payload = path.join(skills, 'gstack');
|
||||
fs.mkdirSync(payload, { recursive: true });
|
||||
return { tmp, skills, payload };
|
||||
}
|
||||
function bash(lines: string[], tmp: string) {
|
||||
const r = runBashScript(lines.join('\n'), { timeout: 10_000, env: { PATH: process.env.PATH ?? '', HOME: tmp, GSTACK_USER_RENDER_DIR: path.join(tmp, 'no-render') } });
|
||||
// An extracted function calling a helper this harness forgot to extract must
|
||||
// fail loudly, not degrade into "foreign, skipped".
|
||||
if (/command not found/.test(r.stderr ?? '')) throw new Error(`harness drift (missing extracted helper):\n${r.stderr}`);
|
||||
return { status: r.status ?? -1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('setup: link_claude_skill_dirs never links over a foreign skill (#2119)', () => {
|
||||
for (const isWindows of ['0', '1'] as const) {
|
||||
test(`IS_WINDOWS=${isWindows}: a foreign real SKILL.md survives byte-identical, gets no marker, is reported and counted`, () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
fs.mkdirSync(path.join(t.payload, 'qa'));
|
||||
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
|
||||
fs.mkdirSync(path.join(t.payload, 'ship'));
|
||||
fs.writeFileSync(path.join(t.payload, 'ship', 'SKILL.md'), GENERATED('ship'));
|
||||
fs.mkdirSync(path.join(t.skills, 'qa'));
|
||||
fs.writeFileSync(path.join(t.skills, 'qa', 'SKILL.md'), FOREIGN);
|
||||
const r = bash(['set -e', `IS_WINDOWS=${isWindows}`, 'SKILL_PREFIX=0', HELPERS,
|
||||
extractFn('link_claude_skill_dirs'),
|
||||
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`,
|
||||
'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
const md = path.join(t.skills, 'qa', 'SKILL.md');
|
||||
expect(fs.lstatSync(md).isSymbolicLink()).toBe(false);
|
||||
expect(fs.readFileSync(md, 'utf-8')).toBe(FOREIGN);
|
||||
expect(fs.existsSync(path.join(t.skills, 'qa', '.gstack-owned'))).toBe(false);
|
||||
expect(r.stderr).toContain('skipped qa');
|
||||
expect(r.stdout).toContain('FOREIGN=qa');
|
||||
// The other skill still links normally.
|
||||
expect(fs.existsSync(path.join(t.skills, 'ship', 'SKILL.md'))).toBe(true);
|
||||
expect(r.stdout).toContain('linked skills: ship');
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('our own previous entry (symlink into the payload) is refreshed, not skipped', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
fs.mkdirSync(path.join(t.payload, 'qa'));
|
||||
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
|
||||
fs.mkdirSync(path.join(t.skills, 'qa'));
|
||||
fs.symlinkSync(path.join(t.payload, 'qa', 'SKILL.md'), path.join(t.skills, 'qa', 'SKILL.md'));
|
||||
const r = bash(['set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', HELPERS, extractFn('link_claude_skill_dirs'),
|
||||
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`, 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('FOREIGN=\n');
|
||||
expect(fs.lstatSync(path.join(t.skills, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('the Windows marker records the owning payload path and a marked copy is ours on the next run', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
fs.mkdirSync(path.join(t.payload, 'qa'));
|
||||
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
|
||||
const first = bash(['set -e', 'IS_WINDOWS=1', 'SKILL_PREFIX=0', HELPERS, extractFn('link_claude_skill_dirs'),
|
||||
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`], t.tmp);
|
||||
expect(first.status).toBe(0);
|
||||
const marker = path.join(t.skills, 'qa', '.gstack-owned');
|
||||
expect(fs.readFileSync(marker, 'utf-8').trim()).toBe(fs.realpathSync(t.payload));
|
||||
// Second run over our own copy: refreshed, not reported.
|
||||
const second = bash(['set -e', 'IS_WINDOWS=1', 'SKILL_PREFIX=0', HELPERS, extractFn('link_claude_skill_dirs'),
|
||||
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`, 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
|
||||
expect(second.stdout).toContain('FOREIGN=\n');
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('setup: _install_alias_skill_md never overwrites a foreign alias-named skill (#2119)', () => {
|
||||
test('a user skill named connect-chrome survives; a generated alias copy is refreshed', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
fs.mkdirSync(path.join(t.payload, 'open-gstack-browser'));
|
||||
fs.writeFileSync(path.join(t.payload, 'open-gstack-browser', 'SKILL.md'), GENERATED('open-gstack-browser'));
|
||||
fs.mkdirSync(path.join(t.skills, 'connect-chrome'));
|
||||
fs.writeFileSync(path.join(t.skills, 'connect-chrome', 'SKILL.md'), FOREIGN);
|
||||
fs.mkdirSync(path.join(t.skills, 'gstack-connect-chrome'));
|
||||
fs.writeFileSync(path.join(t.skills, 'gstack-connect-chrome', 'SKILL.md'), GENERATED('gstack-connect-chrome').replace('# gstack-connect-chrome', '# old alias copy'));
|
||||
const r = bash(['set -e', 'IS_WINDOWS=0', `SOURCE_GSTACK_DIR="${t.payload}"`, HELPERS, extractFn('_install_alias_skill_md'),
|
||||
`_install_alias_skill_md "${t.payload}/open-gstack-browser/SKILL.md" "${t.skills}/connect-chrome" connect-chrome`,
|
||||
`_install_alias_skill_md "${t.payload}/open-gstack-browser/SKILL.md" "${t.skills}/gstack-connect-chrome" gstack-connect-chrome`,
|
||||
'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.readFileSync(path.join(t.skills, 'connect-chrome', 'SKILL.md'), 'utf-8')).toBe(FOREIGN);
|
||||
expect(r.stdout).toContain('FOREIGN=connect-chrome');
|
||||
expect(fs.readFileSync(path.join(t.skills, 'gstack-connect-chrome', 'SKILL.md'), 'utf-8')).toContain('name: gstack-connect-chrome');
|
||||
expect(fs.readFileSync(path.join(t.skills, 'gstack-connect-chrome', 'SKILL.md'), 'utf-8')).not.toContain('old alias copy');
|
||||
// A pre-existing alias directory is never stamped (we did not create it); a created one is.
|
||||
expect(fs.existsSync(path.join(t.skills, 'gstack-connect-chrome', '.gstack-owned'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(t.skills, 'connect-chrome', '.gstack-owned'))).toBe(false);
|
||||
const created = bash(['set -e', 'IS_WINDOWS=0', `SOURCE_GSTACK_DIR="${t.payload}"`, HELPERS, extractFn('_install_alias_skill_md'),
|
||||
`_install_alias_skill_md "${t.payload}/open-gstack-browser/SKILL.md" "${t.skills}/fresh-alias" fresh-alias`], t.tmp);
|
||||
expect(created.status).toBe(0);
|
||||
expect(fs.readFileSync(path.join(t.skills, 'fresh-alias', '.gstack-owned'), 'utf-8').trim()).toBe(fs.realpathSync(t.payload));
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('setup: cleanup_prefixed_claude_symlinks proves provenance (#2119)', () => {
|
||||
function runFlip(isWindows: '0' | '1', plant: (skills: string, payload: string) => void) {
|
||||
const t = mkTree();
|
||||
fs.mkdirSync(path.join(t.payload, 'qa'));
|
||||
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
|
||||
plant(t.skills, t.payload);
|
||||
const r = bash(['set -e', `IS_WINDOWS=${isWindows}`, extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_gstack_dir_only_links'), extractFn('_cleanup_linked_dir'), extractFn('_gstack_generated_header'), extractFn('_cleanup_weak_dir'), extractFn('_backup_skill_md'), '_BACKED_UP_SKILL_MDS=()', '_SKILL_BACKUP_ROOT="$HOME/.gstack/backups/skills/test"', extractFn('cleanup_prefixed_claude_symlinks'),
|
||||
`cleanup_prefixed_claude_symlinks "${t.payload}" "${t.skills}"`], t.tmp);
|
||||
const names = fs.readdirSync(t.skills).sort();
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
return { ...r, names };
|
||||
}
|
||||
|
||||
test('Windows: a user-owned gstack-qa (no marker, not identical, no header) survives the prefix→flat flip', () => {
|
||||
const r = runFlip('1', (skills) => {
|
||||
fs.mkdirSync(path.join(skills, 'gstack-qa'));
|
||||
fs.writeFileSync(path.join(skills, 'gstack-qa', 'SKILL.md'), '---\nname: gstack-qa\n---\n# user-owned\n');
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.names).toEqual(['gstack', 'gstack-qa']);
|
||||
expect(r.stdout).toBe('');
|
||||
});
|
||||
|
||||
test('Windows: marker, byte-identical, and generated-header copies are reaped', () => {
|
||||
const r = runFlip('1', (skills, payload) => {
|
||||
fs.mkdirSync(path.join(skills, 'gstack-qa'));
|
||||
fs.copyFileSync(path.join(payload, 'qa', 'SKILL.md'), path.join(skills, 'gstack-qa', 'SKILL.md'));
|
||||
});
|
||||
expect(r.names).toEqual(['gstack']);
|
||||
const m = runFlip('1', (skills) => {
|
||||
fs.mkdirSync(path.join(skills, 'gstack-qa'));
|
||||
fs.writeFileSync(path.join(skills, 'gstack-qa', 'SKILL.md'), '---\nname: gstack-qa\n---\n# stale\n');
|
||||
fs.writeFileSync(path.join(skills, 'gstack-qa', '.gstack-owned'), '');
|
||||
});
|
||||
expect(m.names).toEqual(['gstack']);
|
||||
const h = runFlip('1', (skills) => {
|
||||
fs.mkdirSync(path.join(skills, 'gstack-qa'));
|
||||
fs.writeFileSync(path.join(skills, 'gstack-qa', 'SKILL.md'), GENERATED('gstack-qa').replace('# gstack-qa', '# older render'));
|
||||
});
|
||||
expect(h.names).toEqual(['gstack']);
|
||||
});
|
||||
|
||||
test('Windows: weak proof (banner, no marker) removes only SKILL.md; the user\'s other files and the directory survive', () => {
|
||||
const r = runFlip('1', (skills) => {
|
||||
fs.mkdirSync(path.join(skills, 'gstack-qa', 'my-templates'), { recursive: true });
|
||||
fs.writeFileSync(path.join(skills, 'gstack-qa', 'SKILL.md'), GENERATED('gstack-qa').replace('# gstack-qa', '# started from gstack, then customized'));
|
||||
fs.writeFileSync(path.join(skills, 'gstack-qa', 'my-templates', 'checklist.md'), '- mine\n');
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.names).toEqual(['gstack', 'gstack-qa']);
|
||||
expect(r.stdout).toContain('cleaned gstack-qa/SKILL.md');
|
||||
// Strong proof (marker) still removes the directory we created.
|
||||
const m = runFlip('1', (skills) => {
|
||||
fs.mkdirSync(path.join(skills, 'gstack-qa', 'sections'), { recursive: true });
|
||||
fs.writeFileSync(path.join(skills, 'gstack-qa', 'SKILL.md'), '# stale\n');
|
||||
fs.writeFileSync(path.join(skills, 'gstack-qa', 'sections', 'x.md'), 'x\n');
|
||||
fs.writeFileSync(path.join(skills, 'gstack-qa', '.gstack-owned'), '/payload\n');
|
||||
});
|
||||
expect(m.names).toEqual(['gstack']);
|
||||
});
|
||||
|
||||
test('Windows: a ONE-line AUTO-GENERATED substring from another generator is not provenance — the entry survives', () => {
|
||||
const r = runFlip('1', (skills) => {
|
||||
fs.mkdirSync(path.join(skills, 'gstack-qa'));
|
||||
fs.writeFileSync(path.join(skills, 'gstack-qa', 'SKILL.md'), '---\nname: gstack-qa\n---\n<!-- AUTO-GENERATED from my-skill-builder -->\n# theirs\n');
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.names).toEqual(['gstack', 'gstack-qa']);
|
||||
});
|
||||
|
||||
test('a SKILL.md symlink whose target merely CONTAINS the substring gstack is not reaped; an anchored gstack/ segment is', () => {
|
||||
const keep = runFlip('0', (skills) => {
|
||||
fs.mkdirSync(path.join(skills, 'gstack-qa'));
|
||||
fs.symlinkSync('../../archive/my-gstack-backup/SKILL.md', path.join(skills, 'gstack-qa', 'SKILL.md'));
|
||||
});
|
||||
expect(keep.names).toEqual(['gstack', 'gstack-qa']);
|
||||
const reap = runFlip('0', (skills) => {
|
||||
fs.mkdirSync(path.join(skills, 'gstack-qa'));
|
||||
fs.symlinkSync('../gstack/qa/SKILL.md', path.join(skills, 'gstack-qa', 'SKILL.md'));
|
||||
});
|
||||
expect(reap.names).toEqual(['gstack']);
|
||||
expect(reap.stdout).toContain('cleaned up prefixed entries: gstack-qa');
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('setup: weakly-proven files are moved aside, never overwritten (#2119 review)', () => {
|
||||
test('Linux linker: a customized banner copy is moved to the backup root before the symlink lands; an identical copy is not', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
fs.mkdirSync(path.join(t.payload, 'qa'));
|
||||
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
|
||||
fs.mkdirSync(path.join(t.payload, 'ship'));
|
||||
fs.writeFileSync(path.join(t.payload, 'ship', 'SKILL.md'), GENERATED('ship'));
|
||||
const custom = GENERATED('qa').replace('# qa', '# my qa, started from gstack');
|
||||
fs.mkdirSync(path.join(t.skills, 'qa'));
|
||||
fs.writeFileSync(path.join(t.skills, 'qa', 'SKILL.md'), custom);
|
||||
fs.mkdirSync(path.join(t.skills, 'ship'));
|
||||
fs.copyFileSync(path.join(t.payload, 'ship', 'SKILL.md'), path.join(t.skills, 'ship', 'SKILL.md'));
|
||||
const r = bash(['set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', HELPERS, extractFn('link_claude_skill_dirs'),
|
||||
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`,
|
||||
'echo "BACKED=${_BACKED_UP_SKILL_MDS[*]:-}"'], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.lstatSync(path.join(t.skills, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(true);
|
||||
expect(fs.lstatSync(path.join(t.skills, 'ship', 'SKILL.md')).isSymbolicLink()).toBe(true);
|
||||
expect(fs.readFileSync(path.join(t.tmp, '.gstack', 'backups', 'skills', 'test', 'qa', 'SKILL.md'), 'utf-8')).toBe(custom);
|
||||
// Weakly-proven, pre-existing: no marker (it must never become deletable whole). Created dirs do get one.
|
||||
expect(fs.existsSync(path.join(t.skills, 'qa', '.gstack-owned'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(t.tmp, '.gstack', 'backups', 'skills', 'test', 'ship'))).toBe(false);
|
||||
expect(r.stdout).toContain('BACKED=qa\n');
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Windows flip: weak proof leaves the user\'s files in place', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
fs.mkdirSync(path.join(t.payload, 'qa'));
|
||||
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
|
||||
fs.mkdirSync(path.join(t.skills, 'gstack-qa', 'my-templates'), { recursive: true });
|
||||
fs.writeFileSync(path.join(t.skills, 'gstack-qa', 'SKILL.md'), GENERATED('gstack-qa'));
|
||||
fs.writeFileSync(path.join(t.skills, 'gstack-qa', 'my-templates', 'checklist.md'), '- mine\n');
|
||||
const r = bash(['set -e', 'IS_WINDOWS=1', extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_gstack_dir_only_links'), extractFn('_cleanup_linked_dir'), extractFn('_gstack_generated_header'), extractFn('_cleanup_weak_dir'), extractFn('_backup_skill_md'), '_BACKED_UP_SKILL_MDS=()', '_SKILL_BACKUP_ROOT="$HOME/.gstack/backups/skills/test"', extractFn('cleanup_prefixed_claude_symlinks'),
|
||||
`cleanup_prefixed_claude_symlinks "${t.payload}" "${t.skills}"`], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.existsSync(path.join(t.skills, 'gstack-qa', 'SKILL.md'))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(t.skills, 'gstack-qa', 'my-templates', 'checklist.md'), 'utf-8')).toBe('- mine\n');
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('_run_relink_quiet forwards relink\'s skipped and moved lines once, deduped against setup\'s own report', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
const fake = path.join(t.tmp, 'fake-relink');
|
||||
fs.writeFileSync(fake, '#!/usr/bin/env bash\necho "linked 3 skills"\necho " skipped qa: existing entry is not gstack-managed (foreign skill with the same name) — left untouched" >&2\necho " skipped ship: existing entry is not gstack-managed (foreign skill with the same name) — left untouched" >&2\necho "Moved 1 pre-existing SKILL.md file(s) to /x before linking gstack\'s: review"\n');
|
||||
fs.chmodSync(fake, 0o755);
|
||||
const r = bash(['set -e', '_FOREIGN_SKIPPED_ENTRIES=(qa)', `GSTACK_RELINK="${fake}"`, 'INSTALL_SKILLS_DIR=/dev/null', 'SOURCE_GSTACK_DIR=/dev/null',
|
||||
extractFn('_run_relink_quiet'), '_run_relink_quiet', 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr.match(/skipped ship/g)?.length).toBe(1);
|
||||
expect(r.stderr).not.toContain('skipped qa');
|
||||
expect(r.stderr).toContain('Moved 1 pre-existing');
|
||||
expect(r.stderr).not.toContain('linked 3 skills');
|
||||
expect(r.stdout).toContain('FOREIGN=qa ship\n');
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('setup: banner census, checkout naming, legacy linked dirs, markers (#2119 review)', () => {
|
||||
test('every generated SKILL.md in this tree passes _gstack_generated_header (four carry the banner past line 40)', () => {
|
||||
const files = fs.readdirSync(ROOT, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => path.join(ROOT, d.name, 'SKILL.md'))
|
||||
.concat([path.join(ROOT, 'SKILL.md')])
|
||||
.filter((f) => fs.existsSync(f) && fs.readFileSync(f, 'utf-8').includes('<!-- AUTO-GENERATED from'));
|
||||
expect(files.length).toBeGreaterThan(30);
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-banner-census-'));
|
||||
try {
|
||||
const r = bash(['set -e', extractFn('_gstack_generated_header'),
|
||||
`for f in ${files.map((f) => `"${f}"`).join(' ')}; do _gstack_generated_header "$f" || echo "MISS $f"; done`], tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe('');
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a link into a checkout named without a gstack segment is ours when the tree carries setup + VERSION + bin/', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
fs.mkdirSync(path.join(t.payload, 'qa'));
|
||||
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
|
||||
const other = path.join(t.tmp, 'gs-feature');
|
||||
fs.mkdirSync(path.join(other, 'qa'), { recursive: true });
|
||||
fs.mkdirSync(path.join(other, 'bin'));
|
||||
fs.writeFileSync(path.join(other, 'VERSION'), '1.0.0.0\n');
|
||||
fs.writeFileSync(path.join(other, 'setup'), '#!/bin/bash\n');
|
||||
fs.writeFileSync(path.join(other, 'bin', 'gstack-relink'), '#!/bin/bash\n');
|
||||
fs.writeFileSync(path.join(other, 'qa', 'SKILL.md'), GENERATED('qa'));
|
||||
fs.mkdirSync(path.join(t.skills, 'qa'));
|
||||
fs.symlinkSync(path.join(other, 'qa', 'SKILL.md'), path.join(t.skills, 'qa', 'SKILL.md'));
|
||||
// A hand-written skill repo that happens to carry VERSION + setup + bin/ is NOT a gstack tree.
|
||||
const mine = path.join(t.tmp, 'myskills');
|
||||
fs.mkdirSync(path.join(mine, 'ship'), { recursive: true });
|
||||
fs.mkdirSync(path.join(mine, 'bin'));
|
||||
fs.writeFileSync(path.join(mine, 'VERSION'), '0.1\n');
|
||||
fs.writeFileSync(path.join(mine, 'setup'), '#!/bin/bash\n');
|
||||
fs.writeFileSync(path.join(mine, 'ship', 'SKILL.md'), FOREIGN);
|
||||
fs.mkdirSync(path.join(t.payload, 'ship'));
|
||||
fs.writeFileSync(path.join(t.payload, 'ship', 'SKILL.md'), GENERATED('ship'));
|
||||
fs.mkdirSync(path.join(t.skills, 'ship'));
|
||||
fs.symlinkSync(path.join(mine, 'ship', 'SKILL.md'), path.join(t.skills, 'ship', 'SKILL.md'));
|
||||
const r = bash(['set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', HELPERS, extractFn('link_claude_skill_dirs'),
|
||||
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`, 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('FOREIGN=ship\n');
|
||||
expect(fs.readlinkSync(path.join(t.skills, 'ship', 'SKILL.md'))).toBe(path.join(mine, 'ship', 'SKILL.md'));
|
||||
expect(fs.readlinkSync(path.join(t.skills, 'qa', 'SKILL.md'))).toBe(path.join(t.payload, 'qa', 'SKILL.md'));
|
||||
// Re-pointed, but the directory pre-existed: no marker (only directories we create get one).
|
||||
expect(fs.existsSync(path.join(t.skills, 'qa', '.gstack-owned'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Linux linker: a created directory gets the marker; a pre-existing unclaimed directory (user files, no SKILL.md) is linked into without one', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
for (const n of ['qa', 'ship']) { fs.mkdirSync(path.join(t.payload, n)); fs.writeFileSync(path.join(t.payload, n, 'SKILL.md'), GENERATED(n)); }
|
||||
fs.mkdirSync(path.join(t.skills, 'ship'));
|
||||
fs.writeFileSync(path.join(t.skills, 'ship', 'notes.md'), 'mine\n');
|
||||
const r = bash(['set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', HELPERS, extractFn('link_claude_skill_dirs'),
|
||||
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`, 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('FOREIGN=\n');
|
||||
expect(fs.existsSync(path.join(t.skills, 'qa', '.gstack-owned'))).toBe(true);
|
||||
expect(fs.lstatSync(path.join(t.skills, 'ship', 'SKILL.md')).isSymbolicLink()).toBe(true);
|
||||
expect(fs.existsSync(path.join(t.skills, 'ship', '.gstack-owned'))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(t.skills, 'ship', 'notes.md'), 'utf-8')).toBe('mine\n');
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('flip on a legacy linked dir (no marker): all-links dir removed whole; a dir with a user file keeps the file, loses our links', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
fs.mkdirSync(path.join(t.payload, 'qa', 'sections'), { recursive: true });
|
||||
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
|
||||
fs.mkdirSync(path.join(t.payload, 'ship'));
|
||||
fs.writeFileSync(path.join(t.payload, 'ship', 'SKILL.md'), GENERATED('ship'));
|
||||
for (const [name, src] of [['gstack-qa', 'qa'], ['gstack-ship', 'ship']] as const) {
|
||||
fs.mkdirSync(path.join(t.skills, name));
|
||||
fs.symlinkSync(path.join(t.payload, src, 'SKILL.md'), path.join(t.skills, name, 'SKILL.md'));
|
||||
fs.symlinkSync(path.join(t.payload, 'qa', 'sections'), path.join(t.skills, name, 'sections'));
|
||||
}
|
||||
fs.writeFileSync(path.join(t.skills, 'gstack-ship', 'my-notes.md'), 'keep\n');
|
||||
const r = bash(['set -e', 'IS_WINDOWS=0', extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_gstack_dir_only_links'), extractFn('_cleanup_linked_dir'), extractFn('_gstack_generated_header'), extractFn('_cleanup_weak_dir'), extractFn('_backup_skill_md'), '_BACKED_UP_SKILL_MDS=()', '_SKILL_BACKUP_ROOT="$HOME/.gstack/backups/skills/test"', extractFn('cleanup_prefixed_claude_symlinks'),
|
||||
`cleanup_prefixed_claude_symlinks "${t.payload}" "${t.skills}"`], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.existsSync(path.join(t.skills, 'gstack-qa'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(t.skills, 'gstack-ship', 'SKILL.md'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(t.skills, 'gstack-ship', 'sections'))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(t.skills, 'gstack-ship', 'my-notes.md'), 'utf-8')).toBe('keep\n');
|
||||
expect(r.stdout).toContain('cleaned gstack-ship/SKILL.md');
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('setup: cycle-3 hardening — assets, foreign dir links, failed backups, flip backups (#2119 review)', () => {
|
||||
const REAL_ASSETS = extractFn('_link_skill_runtime_assets');
|
||||
|
||||
test('an unclaimed or weakly-owned directory keeps the user\'s same-named real assets; a created directory gets ours', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
for (const n of ['qa', 'ship', 'review']) {
|
||||
fs.mkdirSync(path.join(t.payload, n, 'templates'), { recursive: true });
|
||||
fs.writeFileSync(path.join(t.payload, n, 'SKILL.md'), GENERATED(n));
|
||||
fs.writeFileSync(path.join(t.payload, n, 'templates', 'ours.md'), 'ours\n');
|
||||
fs.writeFileSync(path.join(t.payload, n, 'checklist.md'), 'ours\n');
|
||||
}
|
||||
// qa: unclaimed (no SKILL.md) with the user's templates/ and checklist.md
|
||||
fs.mkdirSync(path.join(t.skills, 'qa', 'templates'), { recursive: true });
|
||||
fs.writeFileSync(path.join(t.skills, 'qa', 'templates', 'mine.md'), 'mine\n');
|
||||
fs.writeFileSync(path.join(t.skills, 'qa', 'checklist.md'), 'my checklist\n');
|
||||
// ship: weakly owned (customized banner copy) with the user's templates/
|
||||
fs.mkdirSync(path.join(t.skills, 'ship', 'templates'), { recursive: true });
|
||||
fs.writeFileSync(path.join(t.skills, 'ship', 'SKILL.md'), GENERATED('ship').replace('# ship', '# customized'));
|
||||
fs.writeFileSync(path.join(t.skills, 'ship', 'templates', 'mine.md'), 'mine\n');
|
||||
const r = bash(['set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', HELPERS, REAL_ASSETS, extractFn('link_claude_skill_dirs'),
|
||||
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.readFileSync(path.join(t.skills, 'qa', 'templates', 'mine.md'), 'utf-8')).toBe('mine\n');
|
||||
expect(fs.readFileSync(path.join(t.skills, 'qa', 'checklist.md'), 'utf-8')).toBe('my checklist\n');
|
||||
expect(fs.lstatSync(path.join(t.skills, 'qa', 'checklist.md')).isSymbolicLink()).toBe(false);
|
||||
expect(fs.readFileSync(path.join(t.skills, 'ship', 'templates', 'mine.md'), 'utf-8')).toBe('mine\n');
|
||||
expect(r.stderr).toContain('kept qa/templates');
|
||||
expect(r.stderr).toContain('kept qa/checklist.md');
|
||||
expect(r.stderr).toContain('kept ship/templates');
|
||||
// review: created by us → assets linked
|
||||
expect(fs.lstatSync(path.join(t.skills, 'review', 'templates')).isSymbolicLink()).toBe(true);
|
||||
expect(fs.lstatSync(path.join(t.skills, 'review', 'checklist.md')).isSymbolicLink()).toBe(true);
|
||||
// ship's customized SKILL.md was backed up, its assets kept, its checklist (absent before) linked
|
||||
expect(fs.existsSync(path.join(t.tmp, '.gstack', 'backups', 'skills', 'test', 'ship', 'SKILL.md'))).toBe(true);
|
||||
expect(fs.lstatSync(path.join(t.skills, 'ship', 'checklist.md')).isSymbolicLink()).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a foreign DIRECTORY symlink (target has no SKILL.md) is foreign, not unclaimed: the user\'s link survives', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
fs.mkdirSync(path.join(t.payload, 'qa'));
|
||||
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
|
||||
const userdir = path.join(t.tmp, 'userdir');
|
||||
fs.mkdirSync(userdir);
|
||||
fs.writeFileSync(path.join(userdir, 'notes.md'), 'mine\n');
|
||||
fs.symlinkSync(userdir, path.join(t.skills, 'qa'));
|
||||
const r = bash(['set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', HELPERS, extractFn('link_claude_skill_dirs'),
|
||||
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`, 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('FOREIGN=qa\n');
|
||||
expect(fs.lstatSync(path.join(t.skills, 'qa')).isSymbolicLink()).toBe(true);
|
||||
expect(fs.readlinkSync(path.join(t.skills, 'qa'))).toBe(userdir);
|
||||
expect(fs.existsSync(path.join(userdir, 'SKILL.md'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('when the backup cannot be written, the customized file is left untouched and the entry is reported, never overwritten', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
fs.mkdirSync(path.join(t.payload, 'qa'));
|
||||
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
|
||||
const custom = GENERATED('qa').replace('# qa', '# customized');
|
||||
fs.mkdirSync(path.join(t.skills, 'qa'));
|
||||
fs.writeFileSync(path.join(t.skills, 'qa', 'SKILL.md'), custom);
|
||||
fs.writeFileSync(path.join(t.tmp, 'not-a-dir'), 'x');
|
||||
const r = bash(['set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', HELPERS, `_SKILL_BACKUP_ROOT="${t.tmp}/not-a-dir/backups"`, extractFn('link_claude_skill_dirs'),
|
||||
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`, 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.lstatSync(path.join(t.skills, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(false);
|
||||
expect(fs.readFileSync(path.join(t.skills, 'qa', 'SKILL.md'), 'utf-8')).toBe(custom);
|
||||
expect(r.stderr).toContain('could not back up');
|
||||
expect(r.stdout).toContain('FOREIGN=qa\n');
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Windows flip: a customized banner copy is moved to the backup root, not deleted; an alias-shaped copy (only name: differs) is just removed', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
fs.mkdirSync(path.join(t.payload, 'qa'));
|
||||
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
|
||||
fs.mkdirSync(path.join(t.payload, 'ship'));
|
||||
fs.writeFileSync(path.join(t.payload, 'ship', 'SKILL.md'), GENERATED('ship'));
|
||||
const custom = GENERATED('gstack-qa').replace('# gstack-qa', '# customized on windows');
|
||||
fs.mkdirSync(path.join(t.skills, 'gstack-qa'));
|
||||
fs.writeFileSync(path.join(t.skills, 'gstack-qa', 'SKILL.md'), custom);
|
||||
fs.mkdirSync(path.join(t.skills, 'gstack-ship'));
|
||||
fs.writeFileSync(path.join(t.skills, 'gstack-ship', 'SKILL.md'), GENERATED('ship').replace('name: ship', 'name: gstack-ship'));
|
||||
const r = bash(['set -e', 'IS_WINDOWS=1', HELPERS, extractFn('cleanup_prefixed_claude_symlinks'),
|
||||
`cleanup_prefixed_claude_symlinks "${t.payload}" "${t.skills}"`], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.existsSync(path.join(t.skills, 'gstack-qa'))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(t.tmp, '.gstack', 'backups', 'skills', 'test', 'gstack-qa', 'SKILL.md'), 'utf-8')).toBe(custom);
|
||||
expect(fs.existsSync(path.join(t.skills, 'gstack-ship'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(t.tmp, '.gstack', 'backups', 'skills', 'test', 'gstack-ship'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a legacy linked dir holding the user\'s OWN symlink is mixed: our links go, theirs stays, the dir stays', () => {
|
||||
const t = mkTree();
|
||||
try {
|
||||
fs.mkdirSync(path.join(t.payload, 'qa', 'sections'), { recursive: true });
|
||||
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
|
||||
fs.mkdirSync(path.join(t.skills, 'gstack-qa'));
|
||||
fs.symlinkSync(path.join(t.payload, 'qa', 'SKILL.md'), path.join(t.skills, 'gstack-qa', 'SKILL.md'));
|
||||
fs.symlinkSync(path.join(t.payload, 'qa', 'sections'), path.join(t.skills, 'gstack-qa', 'sections'));
|
||||
fs.writeFileSync(path.join(t.tmp, 'my-notes.md'), 'mine\n');
|
||||
fs.symlinkSync(path.join(t.tmp, 'my-notes.md'), path.join(t.skills, 'gstack-qa', 'notes.md'));
|
||||
const r = bash(['set -e', 'IS_WINDOWS=0', HELPERS, extractFn('cleanup_prefixed_claude_symlinks'),
|
||||
`cleanup_prefixed_claude_symlinks "${t.payload}" "${t.skills}"`], t.tmp);
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.existsSync(path.join(t.skills, 'gstack-qa', 'SKILL.md'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(t.skills, 'gstack-qa', 'sections'))).toBe(false);
|
||||
expect(fs.readlinkSync(path.join(t.skills, 'gstack-qa', 'notes.md'))).toBe(path.join(t.tmp, 'my-notes.md'));
|
||||
} finally {
|
||||
fs.rmSync(t.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,649 @@
|
||||
/**
|
||||
* setup: Chromium bootstrap is best-effort and bounded (#1900, #1901, #1902,
|
||||
* #913, #2233).
|
||||
*
|
||||
* Before: `set -e` plus a bare `bunx playwright install chromium` (and an
|
||||
* explicit `exit 1` after the post-install probe) sat in section "# 2", ahead
|
||||
* of "# 4. Install for Claude". An offline, proxied, or AppArmor-restricted
|
||||
* box ended with ZERO skills registered, and a wedged download hung setup
|
||||
* forever. Now every failure records a reason code in _PW_FAIL_REASON, the
|
||||
* install is deadline-bounded, lock contention is a reason (not a fatal), and
|
||||
* skill registration always runs.
|
||||
*
|
||||
* Two layers, following test/setup-emoji-font.test.ts's convention:
|
||||
* 1. static invariants over the anchor-sliced block (line-number agnostic);
|
||||
* 2. an integration harness that executes the REAL block with stubbed
|
||||
* probe/installer so the exit path and reason codes are exercised.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { runBashScript } from './helpers/bash-script';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
|
||||
|
||||
const BLOCK_START = "# 2. Ensure Playwright's Chromium is available";
|
||||
const BLOCK_END = '# 2b. Ensure a color-emoji font';
|
||||
|
||||
function slice(startAnchor: string, endAnchor: string): string {
|
||||
const start = SETUP_SRC.indexOf(startAnchor);
|
||||
const end = SETUP_SRC.indexOf(endAnchor, start);
|
||||
if (start < 0 || end < 0) throw new Error(`anchor not found: ${startAnchor} .. ${endAnchor}`);
|
||||
return SETUP_SRC.slice(start, end);
|
||||
}
|
||||
|
||||
function extractFn(name: string): string {
|
||||
const start = SETUP_SRC.indexOf(`${name}() {`);
|
||||
const end = SETUP_SRC.indexOf('\n}\n', start);
|
||||
if (start < 0 || end < 0) throw new Error(`function not found: ${name}`);
|
||||
return SETUP_SRC.slice(start, end + 2);
|
||||
}
|
||||
|
||||
const block = slice(BLOCK_START, BLOCK_END);
|
||||
const codeLines = block.split('\n').filter((l) => !l.trim().startsWith('#')).join('\n');
|
||||
|
||||
describe('setup: Chromium bootstrap static invariants', () => {
|
||||
test('no exit inside the bootstrap block (skills must always register)', () => {
|
||||
// The only `exit` allowed is inside the INT/TERM trap string (Ctrl-C must
|
||||
// still terminate setup after killing the installer); a bare statement is not.
|
||||
const statements = codeLines.split('\n').filter((l) => !/^\s*trap /.test(l)).join('\n');
|
||||
expect(statements).not.toMatch(/\bexit 1\b/);
|
||||
expect(statements).not.toMatch(/\bexit\b/);
|
||||
expect(codeLines).toMatch(/trap '_kill_tree "\$_PW_PID".*exit 130' INT TERM/);
|
||||
expect(codeLines).toContain('trap - INT TERM');
|
||||
});
|
||||
|
||||
test('every failure arm records a reason code', () => {
|
||||
for (const code of [
|
||||
'skipped', 'chromium-install', 'chromium-install-timeout', 'chromium-install-locked',
|
||||
'windows-no-node', 'windows-node-modules', 'post-install-launch',
|
||||
]) {
|
||||
expect(codeLines).toContain(`_pw_fail ${code} `);
|
||||
}
|
||||
});
|
||||
|
||||
test('the download is deadline-bounded through the shared helper and env knob', () => {
|
||||
expect(codeLines).toContain('_wait_with_deadline "$_PW_PID" "$_PW_INSTALL_TIMEOUT"');
|
||||
expect(codeLines).toContain('GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT');
|
||||
// Non-numeric and 0 fall back to the default instead of breaking arithmetic
|
||||
// or killing the install on the first poll.
|
||||
expect(codeLines).toMatch(/case "\$_PW_INSTALL_TIMEOUT" in ''\|\*\[!0-9\]\*\)/);
|
||||
expect(codeLines).toContain('_PW_INSTALL_TIMEOUT=$((10#$_PW_INSTALL_TIMEOUT))');
|
||||
expect(codeLines).toContain('[ "$_PW_INSTALL_TIMEOUT" -gt 0 ] || _PW_INSTALL_TIMEOUT=600');
|
||||
expect(codeLines).toContain('[ "${#_PW_INSTALL_TIMEOUT}" -le 9 ] || _PW_INSTALL_TIMEOUT=600');
|
||||
});
|
||||
|
||||
test('lock contention is a reason code, not a fatal', () => {
|
||||
expect(codeLines).toContain('_pw_fail chromium-install-locked');
|
||||
expect(block).toContain('GSTACK_SKIP_PLAYWRIGHT');
|
||||
});
|
||||
|
||||
test('the lock EXIT trap still chains cleanup_copied_bun and is restored', () => {
|
||||
expect(codeLines).toContain("trap 'rm -rf \"$_PW_LOCK\" 2>/dev/null || true; cleanup_copied_bun' EXIT");
|
||||
expect(codeLines).toContain('trap cleanup_copied_bun EXIT');
|
||||
});
|
||||
|
||||
test('the daemon font refresh is skipped when Chromium is unavailable', () => {
|
||||
const emoji = slice(BLOCK_END, '# 3. Ensure ~/.gstack global state directory exists');
|
||||
expect(emoji).toContain('elif [ -z "$_PW_FAIL_REASON" ]; then');
|
||||
expect(emoji).toContain('refresh_browse_daemon_for_fonts');
|
||||
});
|
||||
|
||||
test('the final summary names the affected skills and the reason', () => {
|
||||
const tail = SETUP_SRC.slice(SETUP_SRC.indexOf('Chromium bootstrap summary'));
|
||||
expect(tail).toContain('Browser unavailable');
|
||||
for (const skill of ['/qa', '/design-review', '/browse', 'make-pdf', '/pair-agent']) {
|
||||
expect(tail).toContain(skill);
|
||||
}
|
||||
expect(tail).toContain('$_PW_FAIL_REASON');
|
||||
expect(tail).toContain('GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=1800');
|
||||
expect(tail).toContain('GSTACK_CHROMIUM_NO_SANDBOX=1');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Integration harness: the real block + the real deadline helpers, with the
|
||||
* probe and installer stubbed. `bunx` is a shell function, so the block's
|
||||
* subshell inherits the stub. Emits REASON=... and REACHED_END=1 so the test
|
||||
* can prove setup continued past the block.
|
||||
*/
|
||||
function runBlock(opts: {
|
||||
probe: 'ok' | 'fail' | 'fail-then-ok'; // fail-then-ok = fresh install: probe fails, install runs, probe passes
|
||||
bunx: string; // body of the bunx stub
|
||||
env?: Record<string, string>;
|
||||
preLockPid?: string; // pre-create the install lock held by this pid
|
||||
preLockNoPidAgeMin?: number; // pre-create a lock dir with NO pid file, this many minutes old
|
||||
preLockAgeMin?: number; // with preLockPid: age the lock dir this many minutes
|
||||
markKill?: boolean; // record _kill_tree invocations to $MARK
|
||||
isWindows?: '0' | '1';
|
||||
prelude?: string; // extra shell lines (node/npm stubs) injected before the block
|
||||
platformOverride?: string; // value for _PLAYWRIGHT_PLATFORM_OVERRIDE (Ubuntu 26.04 path)
|
||||
}): { stdout: string; stderr: string; status: number; elapsedMs: number; tmp: string } {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pw-block-'));
|
||||
const mark = path.join(tmp, 'mark');
|
||||
const killTree = opts.markKill
|
||||
? extractFn('_kill_tree').replace('_kill_tree() {', '_kill_tree_orig() {') +
|
||||
`\n_kill_tree() { echo killed >> "${mark}"; _kill_tree_orig "$1"; }\n`
|
||||
: extractFn('_kill_tree');
|
||||
if (opts.preLockPid) {
|
||||
const lock = path.join(tmp, 'gstack-playwright-install.lock');
|
||||
fs.mkdirSync(lock);
|
||||
fs.writeFileSync(path.join(lock, 'pid'), opts.preLockPid);
|
||||
if (opts.preLockAgeMin !== undefined) {
|
||||
const t = new Date(Date.now() - opts.preLockAgeMin * 60_000);
|
||||
fs.utimesSync(lock, t, t);
|
||||
}
|
||||
}
|
||||
if (opts.preLockNoPidAgeMin !== undefined) {
|
||||
const lock = path.join(tmp, 'gstack-playwright-install.lock');
|
||||
fs.mkdirSync(lock);
|
||||
const t = new Date(Date.now() - opts.preLockNoPidAgeMin * 60_000);
|
||||
fs.utimesSync(lock, t, t);
|
||||
}
|
||||
const probeFn = opts.probe === 'fail-then-ok'
|
||||
? 'ensure_playwright_browser() { if [ -f "$MARK.probed" ]; then return 0; fi; : > "$MARK.probed"; return 1; }'
|
||||
: `ensure_playwright_browser() { ${opts.probe === 'ok' ? 'return 0' : 'return 1'}; }`;
|
||||
const script = [
|
||||
'set -e',
|
||||
`IS_WINDOWS=${opts.isWindows ?? '0'}`,
|
||||
`SOURCE_GSTACK_DIR="${tmp}"`,
|
||||
`TMPDIR="${tmp}"`,
|
||||
`MARK="${mark}"`,
|
||||
`_PLAYWRIGHT_PLATFORM_OVERRIDE="${opts.platformOverride ?? ''}"`,
|
||||
// EXIT-trap witness: the block chains its lock trap onto cleanup_copied_bun
|
||||
// and must leave cleanup_copied_bun installed when it is done.
|
||||
'cleanup_copied_bun() { echo cleanup >> "$MARK.exit"; }',
|
||||
'trap cleanup_copied_bun EXIT',
|
||||
'_clear_playwright_quarantine() { :; }',
|
||||
probeFn,
|
||||
opts.prelude ?? '',
|
||||
`bunx() { echo "bunx-called override=${'$'}{PLAYWRIGHT_HOST_PLATFORM_OVERRIDE:-unset}" >> "$MARK"; ${opts.bunx}; }`,
|
||||
killTree,
|
||||
extractFn('_wait_with_deadline'),
|
||||
// The block opens with the /etc/os-release probe that RESETS the override
|
||||
// variable; a test that injects an override must start after that probe.
|
||||
opts.platformOverride !== undefined ? slice('# Chromium is BEST-EFFORT', BLOCK_END) : block,
|
||||
'echo "REASON=$_PW_FAIL_REASON"',
|
||||
'echo "REACHED_END=1"',
|
||||
].join('\n');
|
||||
const scriptPath = path.join(tmp, 'block.sh');
|
||||
fs.writeFileSync(scriptPath, script);
|
||||
const t0 = Date.now();
|
||||
const r = spawnSync('bash', [scriptPath], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 60_000,
|
||||
env: { PATH: process.env.PATH ?? '', HOME: tmp, ...(opts.env ?? {}) },
|
||||
});
|
||||
return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1, elapsedMs: Date.now() - t0, tmp };
|
||||
}
|
||||
|
||||
describe('setup: Chromium bootstrap block executes best-effort', () => {
|
||||
test('probe ok: no reason recorded, no install attempted', () => {
|
||||
const r = runBlock({ probe: 'ok', bunx: 'exit 0' });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=\n');
|
||||
expect(r.stdout).toContain('REACHED_END=1');
|
||||
expect(fs.existsSync(path.join(r.tmp, 'mark'))).toBe(false);
|
||||
});
|
||||
|
||||
test('install exits non-zero: reason chromium-install, setup continues (was: exit 1 before any skill registered)', () => {
|
||||
const r = runBlock({ probe: 'fail', bunx: 'exit 7' });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=chromium-install\n');
|
||||
expect(r.stdout).toContain('REACHED_END=1');
|
||||
expect(r.stderr).toContain('chromium-install');
|
||||
expect(r.stderr).toContain('exited 7');
|
||||
});
|
||||
|
||||
test('install hangs: killed at the deadline with reason chromium-install-timeout, tree kill recorded', () => {
|
||||
const r = runBlock({
|
||||
probe: 'fail', bunx: 'sleep 30', markKill: true,
|
||||
env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: '1' },
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=chromium-install-timeout\n');
|
||||
expect(r.stdout).toContain('REACHED_END=1');
|
||||
expect(r.elapsedMs).toBeLessThan(20_000);
|
||||
expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('killed');
|
||||
}, 15_000);
|
||||
|
||||
test('non-numeric timeout knob falls back to the default instead of erroring', () => {
|
||||
const r = runBlock({ probe: 'fail', bunx: 'exit 3', env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: 'soon' } });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=chromium-install\n');
|
||||
});
|
||||
|
||||
test('lock held by a live process: reason chromium-install-locked, setup continues (was: exit 1)', () => {
|
||||
const r = runBlock({ probe: 'fail', bunx: 'exit 0', preLockPid: String(process.pid) });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=chromium-install-locked\n');
|
||||
expect(r.stdout).toContain('REACHED_END=1');
|
||||
// The installer must not have run under a foreign lock.
|
||||
expect(fs.existsSync(path.join(r.tmp, 'mark'))).toBe(false);
|
||||
// And the foreign lock is left for its owner.
|
||||
expect(fs.existsSync(path.join(r.tmp, 'gstack-playwright-install.lock'))).toBe(true);
|
||||
});
|
||||
|
||||
test('stale lock (dead pid) is reclaimed and the install proceeds', () => {
|
||||
const r = runBlock({ probe: 'fail', bunx: 'exit 0', preLockPid: '4194305' /* above Linux's largest pid_max: never a live pid */ });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toContain('reclaiming stale Chromium-install lock');
|
||||
expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('bunx-called');
|
||||
});
|
||||
|
||||
test('a lock whose pid file is garbage (-1, abc, 0) is stale, not "locked": kill -0 -1 would signal everything and "succeed"', () => {
|
||||
for (const pid of ['-1', 'abc', '0']) {
|
||||
const r = runBlock({ probe: 'fail', bunx: 'exit 0', preLockPid: pid });
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('bunx-called');
|
||||
expect(r.stdout).not.toContain('chromium-install-locked');
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
test('a lock held by a LIVE pid but older than the install bound is reclaimed (holder past its deadline, or a recycled pid)', () => {
|
||||
const r = runBlock({ probe: 'fail', bunx: 'exit 0', preLockPid: String(process.pid), preLockAgeMin: 30, env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: '60' } });
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('bunx-called');
|
||||
expect(r.stdout).not.toContain('chromium-install-locked');
|
||||
expect(r.stderr).toContain('past the install bound');
|
||||
}, 15_000);
|
||||
|
||||
test('a lock dir with NO pid file is reclaimed once older than the install bound, and honored while fresh', () => {
|
||||
const old = runBlock({ probe: 'fail', bunx: 'exit 0', preLockNoPidAgeMin: 30, env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: '60' } });
|
||||
expect(old.status).toBe(0);
|
||||
expect(fs.readFileSync(path.join(old.tmp, 'mark'), 'utf-8')).toContain('bunx-called');
|
||||
expect(old.stdout).not.toContain('chromium-install-locked');
|
||||
const fresh = runBlock({ probe: 'fail', bunx: 'exit 0', preLockNoPidAgeMin: 0, env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: '600' } });
|
||||
expect(fresh.status).toBe(0);
|
||||
expect(fresh.stdout).toContain('REASON=chromium-install-locked\n');
|
||||
}, 15_000);
|
||||
|
||||
test('_kill_tree without pgrep on PATH still kills the grandchild (walks /proc)', () => {
|
||||
if (!fs.existsSync('/proc')) return;
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-killtree-'));
|
||||
try {
|
||||
const bin = path.join(tmp, 'bin');
|
||||
fs.mkdirSync(bin);
|
||||
for (const name of ['bash', 'sleep', 'awk']) {
|
||||
const real = (spawnSync('which', [name], { encoding: 'utf-8', timeout: 10_000 }).stdout ?? '').trim();
|
||||
if (real) fs.symlinkSync(real, path.join(bin, name));
|
||||
}
|
||||
const script = [
|
||||
`export PATH="${bin}"`,
|
||||
'hash -r',
|
||||
'command -v pgrep >/dev/null 2>&1 && { echo "PGREP_PRESENT"; exit 0; }',
|
||||
extractFn('_kill_tree'),
|
||||
// two commands so bash forks a real subshell instead of exec-ing sleep directly
|
||||
'( sleep 30; true ) & pid=$!',
|
||||
'sleep 0.3',
|
||||
// find the sleep grandchild via /proc, the same way the fallback does
|
||||
'child=$(awk -v p="$pid" \'{ s=$0; sub(/^[^)]*\\) /, "", s); split(s, f, " "); if (f[2]==p) { print $1; exit } }\' /proc/[0-9]*/stat 2>/dev/null)',
|
||||
'[ -n "$child" ] || { echo "NO_CHILD"; exit 0; }',
|
||||
'_kill_tree "$pid"',
|
||||
'sleep 0.3',
|
||||
'if kill -0 "$child" 2>/dev/null; then echo "CHILD_ALIVE"; else echo "CHILD_DEAD"; fi',
|
||||
].join('\n');
|
||||
const r = spawnSync('/bin/bash', ['-c', script], { encoding: 'utf-8', timeout: 20_000 });
|
||||
expect(r.stdout).toContain('CHILD_DEAD');
|
||||
expect(r.stdout).not.toContain('PGREP_PRESENT');
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test('install succeeds but the post-install probe fails: reason post-install-launch with the userns hint', () => {
|
||||
const r = runBlock({ probe: 'fail', bunx: 'exit 0' });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=post-install-launch\n');
|
||||
expect(r.stderr).toContain('GSTACK_CHROMIUM_NO_SANDBOX=1');
|
||||
});
|
||||
|
||||
test('GSTACK_SKIP_PLAYWRIGHT=1: reason skipped, installer never invoked (#913)', () => {
|
||||
const r = runBlock({ probe: 'fail', bunx: 'exit 0', env: { GSTACK_SKIP_PLAYWRIGHT: '1' } });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=skipped\n');
|
||||
expect(fs.existsSync(path.join(r.tmp, 'mark'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/** A PATH that carries the tools the block needs but NO node — `command -v`
|
||||
* also finds shell functions, so hiding node from PATH is the only faithful
|
||||
* way to stand in for a Windows box without Node.js. */
|
||||
function pathWithoutNode(): { bin: string; cleanup: () => void } {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-nonode-'));
|
||||
const bin = path.join(dir, 'bin');
|
||||
fs.mkdirSync(bin);
|
||||
for (const name of ['bash', 'mkdir', 'rm', 'sleep', 'cat', 'pgrep', 'grep', 'cut', 'tr', 'dirname', 'basename']) {
|
||||
const real = (spawnSync('which', [name], { encoding: 'utf-8', timeout: 10_000 }).stdout ?? '').trim();
|
||||
if (real) fs.symlinkSync(real, path.join(bin, name));
|
||||
}
|
||||
return { bin, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
describe('setup: Chromium bootstrap block — fresh install, lock hygiene, override, Windows arms', () => {
|
||||
test('fresh install happy path: probe fails, install succeeds, re-probe passes → no reason, lock released, EXIT trap restored', () => {
|
||||
const r = runBlock({ probe: 'fail-then-ok', bunx: 'exit 0' });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=\n');
|
||||
expect(r.stdout).toContain('REACHED_END=1');
|
||||
// Installer ran exactly once (no override on a non-26.04 path).
|
||||
const mark = fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8');
|
||||
expect(mark.split('\n').filter((l) => l.startsWith('bunx-called')).length).toBe(1);
|
||||
expect(mark).toContain('override=unset');
|
||||
// The mkdir-mutex is released for the next setup...
|
||||
expect(fs.existsSync(path.join(r.tmp, 'gstack-playwright-install.lock'))).toBe(false);
|
||||
// ...and the chained trap was restored, so cleanup_copied_bun still fires at exit.
|
||||
expect(fs.readFileSync(path.join(r.tmp, 'mark.exit'), 'utf-8')).toContain('cleanup');
|
||||
});
|
||||
|
||||
test('failed install still releases the lock and keeps cleanup_copied_bun on the EXIT trap', () => {
|
||||
const r = runBlock({ probe: 'fail', bunx: 'exit 7' });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=chromium-install\n');
|
||||
expect(fs.existsSync(path.join(r.tmp, 'gstack-playwright-install.lock'))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(r.tmp, 'mark.exit'), 'utf-8')).toContain('cleanup');
|
||||
});
|
||||
|
||||
test('Ubuntu 26.04 override reaches the installer as PLAYWRIGHT_HOST_PLATFORM_OVERRIDE (#2101)', () => {
|
||||
const r = runBlock({ probe: 'fail-then-ok', bunx: 'exit 0', platformOverride: 'ubuntu24.04-x64' });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=\n');
|
||||
expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('override=ubuntu24.04-x64');
|
||||
});
|
||||
|
||||
test('Windows without Node.js: reason windows-no-node, setup continues (was: exit 1), post-install probe skipped', () => {
|
||||
const { bin, cleanup } = pathWithoutNode();
|
||||
try {
|
||||
const r = runBlock({ probe: 'fail', bunx: 'exit 0', isWindows: '1', env: { PATH: bin } });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=windows-no-node\n');
|
||||
expect(r.stdout).toContain('REACHED_END=1');
|
||||
expect(r.stderr).toContain('nodejs.org');
|
||||
// The install itself ran; only the Node verification failed.
|
||||
expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('bunx-called');
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('Windows with Node.js but npm cannot install playwright/@ngrok: reason windows-node-modules, setup continues', () => {
|
||||
const r = runBlock({
|
||||
probe: 'fail', bunx: 'exit 0', isWindows: '1',
|
||||
prelude: 'node() { return 1; }\nnpm() { echo "npm $*" >> "$MARK"; return 1; }',
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=windows-node-modules\n');
|
||||
expect(r.stdout).toContain('REACHED_END=1');
|
||||
expect(r.stdout).toContain('Windows detected');
|
||||
expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('npm install --no-save playwright');
|
||||
});
|
||||
|
||||
test('Windows with Node.js loading Playwright but the launch probe failing: Windows-specific post-install-launch hint', () => {
|
||||
const r = runBlock({ probe: 'fail', bunx: 'exit 0', isWindows: '1', prelude: 'node() { return 0; }' });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=post-install-launch\n');
|
||||
expect(r.stderr).toContain('via Node.js');
|
||||
expect(r.stderr).toContain('oven-sh/bun#4253');
|
||||
// The Linux userns hint belongs to the other arm.
|
||||
expect(r.stderr).not.toContain('GSTACK_CHROMIUM_NO_SANDBOX');
|
||||
});
|
||||
});
|
||||
|
||||
/** The 2b emoji-font step: the daemon font refresh must only run when the
|
||||
* browser actually works — otherwise setup prints a second failure line. */
|
||||
function runEmojiStep(reason: string, fontOk: boolean): { stdout: string; stderr: string; status: number } {
|
||||
const emoji = slice(BLOCK_END, '# 3. Ensure ~/.gstack global state directory exists');
|
||||
const script = [
|
||||
'set -e',
|
||||
`_PW_FAIL_REASON="${reason}"`,
|
||||
`ensure_emoji_font() { return ${fontOk ? 0 : 1}; }`,
|
||||
'refresh_browse_daemon_for_fonts() { echo REFRESHED; }',
|
||||
emoji,
|
||||
'echo "REACHED_END=1"',
|
||||
].join('\n');
|
||||
const r = runBashScript(script, { timeout: 10_000 });
|
||||
if (/command not found/.test(r.stderr ?? '')) throw new Error(`harness drift (missing extracted helper):\n${r.stderr}`);
|
||||
return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1 };
|
||||
}
|
||||
|
||||
describe('setup: emoji-font daemon refresh is gated on Chromium availability', () => {
|
||||
test('font installed + Chromium usable → daemon refreshed; font installed + Chromium unavailable → no refresh; font missing → note, no refresh', () => {
|
||||
const usable = runEmojiStep('', true);
|
||||
expect(usable.status).toBe(0);
|
||||
expect(usable.stdout).toContain('REFRESHED');
|
||||
expect(usable.stdout).toContain('REACHED_END=1');
|
||||
|
||||
const unavailable = runEmojiStep('chromium-install', true);
|
||||
expect(unavailable.status).toBe(0);
|
||||
expect(unavailable.stdout).not.toContain('REFRESHED');
|
||||
expect(unavailable.stdout).toContain('REACHED_END=1');
|
||||
|
||||
const noFont = runEmojiStep('', false);
|
||||
expect(noFont.status).toBe(0);
|
||||
expect(noFont.stdout).not.toContain('REFRESHED');
|
||||
expect(noFont.stderr).toContain('could not auto-install a color-emoji font');
|
||||
expect(noFont.stdout).toContain('REACHED_END=1');
|
||||
});
|
||||
});
|
||||
|
||||
/** The final summary block, executed with a recording telemetry stub. */
|
||||
function runSummary(reason: string, telemetry: 'ok' | 'fail' | 'missing', prelude: string[] = []): { stdout: string; stderr: string; status: number; argv: string } {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pw-summary-'));
|
||||
try {
|
||||
const argvFile = path.join(tmp, 'telemetry.argv');
|
||||
if (telemetry !== 'missing') {
|
||||
fs.mkdirSync(path.join(tmp, 'bin'));
|
||||
const stub = path.join(tmp, 'bin', 'gstack-telemetry-log');
|
||||
fs.writeFileSync(stub, `#!/usr/bin/env bash\necho "$@" >> "${argvFile}"\nexit ${telemetry === 'ok' ? 0 : 1}\n`);
|
||||
fs.chmodSync(stub, 0o755);
|
||||
}
|
||||
const tail = SETUP_SRC.slice(SETUP_SRC.indexOf('# ─── Chromium bootstrap summary'));
|
||||
const script = [
|
||||
'set -e',
|
||||
'QUIET=0',
|
||||
'log() { [ "$QUIET" -eq 0 ] && echo "$@" || true; }',
|
||||
`SOURCE_GSTACK_DIR="${tmp}"`,
|
||||
`_PW_FAIL_REASON="${reason}"`,
|
||||
...prelude,
|
||||
tail,
|
||||
'echo "REACHED_END=1"',
|
||||
].join('\n');
|
||||
const r = runBashScript(script, { timeout: 10_000 });
|
||||
if (/command not found/.test(r.stderr ?? '')) throw new Error(`harness drift (missing extracted helper):\n${r.stderr}`);
|
||||
const argv = fs.existsSync(argvFile) ? fs.readFileSync(argvFile, 'utf-8') : '';
|
||||
return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1, argv };
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('setup: Chromium bootstrap summary block executes', () => {
|
||||
test('names the reason and the affected skills; hints are reason-specific; telemetry gets the reason code only', () => {
|
||||
const timeout = runSummary('chromium-install-timeout', 'ok');
|
||||
expect(timeout.status).toBe(0);
|
||||
expect(timeout.stdout).toContain('Browser unavailable: Chromium bootstrap did not complete (chromium-install-timeout)');
|
||||
for (const skill of ['/qa', '/qa-only', '/design-review', '/browse', 'make-pdf', '/pair-agent']) {
|
||||
expect(timeout.stdout).toContain(skill);
|
||||
}
|
||||
expect(timeout.stdout).toContain('GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=1800');
|
||||
expect(timeout.stdout).not.toContain('GSTACK_CHROMIUM_NO_SANDBOX');
|
||||
// Reason code only, never a path or command line; --no-sweep keeps the
|
||||
// one-shot event from finalizing other sessions' pending markers.
|
||||
expect(timeout.argv.trim()).toBe('--event-type onboarding --skill _setup_playwright --outcome chromium-install-timeout --no-sweep');
|
||||
|
||||
const launch = runSummary('post-install-launch', 'ok');
|
||||
expect(launch.stdout).toContain('GSTACK_CHROMIUM_NO_SANDBOX=1');
|
||||
expect(launch.stdout).not.toContain('GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=1800');
|
||||
expect(launch.argv).toContain('--outcome post-install-launch');
|
||||
|
||||
const offline = runSummary('chromium-install', 'ok');
|
||||
expect(offline.stdout).toContain('Browser unavailable');
|
||||
expect(offline.stdout).not.toContain('GSTACK_CHROMIUM_NO_SANDBOX');
|
||||
expect(offline.stdout).not.toContain('GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=1800');
|
||||
});
|
||||
|
||||
test('silent when Chromium is fine; a missing or failing telemetry binary never breaks the tail of setup', () => {
|
||||
const fine = runSummary('', 'ok');
|
||||
expect(fine.status).toBe(0);
|
||||
expect(fine.stdout).toBe('REACHED_END=1\n');
|
||||
expect(fine.argv).toBe('');
|
||||
|
||||
const missing = runSummary('chromium-install', 'missing');
|
||||
expect(missing.status).toBe(0);
|
||||
expect(missing.stdout).toContain('Browser unavailable');
|
||||
expect(missing.stdout).toContain('REACHED_END=1');
|
||||
|
||||
const failing = runSummary('chromium-install', 'fail');
|
||||
expect(failing.status).toBe(0);
|
||||
expect(failing.stdout).toContain('REACHED_END=1');
|
||||
expect(failing.argv).toContain('--outcome chromium-install');
|
||||
});
|
||||
|
||||
test('the summary names foreign entries left untouched and customized SKILL.md files moved to the backup root', () => {
|
||||
const r = runSummary('', 'missing', ['_FOREIGN_SKIPPED_ENTRIES=(qa)', '_BACKED_UP_SKILL_MDS=(ship review)', '_SKILL_BACKUP_ROOT="/tmp/gstack-bk/20260904"']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('Not registered (a skill you own already uses the name; left untouched): qa');
|
||||
expect(r.stdout).toContain("Moved 2 customized SKILL.md file(s) to /tmp/gstack-bk/20260904 before installing gstack's: ship review");
|
||||
expect(r.stdout).not.toContain('Browser unavailable');
|
||||
expect(r.stdout).toContain('REACHED_END=1');
|
||||
// Nothing to report → neither line.
|
||||
const quiet = runSummary('', 'missing');
|
||||
expect(quiet.stdout).not.toContain('Not registered');
|
||||
expect(quiet.stdout).not.toContain('Moved ');
|
||||
});
|
||||
|
||||
test('an explicit opt-out (skipped) is reported as a choice, not a failure, and sends no telemetry', () => {
|
||||
const skipped = runSummary('skipped', 'ok');
|
||||
expect(skipped.status).toBe(0);
|
||||
expect(skipped.stdout).toContain('Chromium install skipped by request (GSTACK_SKIP_PLAYWRIGHT=1)');
|
||||
expect(skipped.stdout).not.toContain('Browser unavailable');
|
||||
expect(skipped.stdout).not.toContain('Fix the cause');
|
||||
expect(skipped.argv).toBe('');
|
||||
});
|
||||
|
||||
test('GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=0, 000, or a value past nine digits means the default, not kill-on-first-poll or unbounded', () => {
|
||||
for (const v of ['0', '000', '99999999999999999999', 'abc', '']) {
|
||||
const r = runBlock({ probe: 'fail', bunx: 'exit 3', env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: v } });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('REASON=chromium-install\n');
|
||||
}
|
||||
// A wedged installer under "000" is still bounded by the DEFAULT, so with a
|
||||
// 1s override written as "0001" it is killed and classified as a timeout.
|
||||
const bounded = runBlock({ probe: 'fail', bunx: 'sleep 30', env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: '0001' } });
|
||||
expect(bounded.stdout).toContain('REASON=chromium-install-timeout\n');
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
/**
|
||||
* .gstack-owned marker (#2119): Windows installs COPY SKILL.md, so there is no
|
||||
* symlink to readlink. link_claude_skill_dirs writes the marker; gstack-relink
|
||||
* and cleanup_old_claude_symlinks prove provenance by it instead of by name.
|
||||
*/
|
||||
function runLinker(opts: {
|
||||
isWindows: '0' | '1';
|
||||
payload: string[];
|
||||
plant?: (skills: string, payload: string) => void;
|
||||
}): { status: number; stdout: string; stderr: string; skills: string; payload: string; tmp: string } {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-marker-'));
|
||||
const skills = path.join(tmp, 'skills');
|
||||
const payload = path.join(skills, 'gstack');
|
||||
fs.mkdirSync(payload, { recursive: true });
|
||||
for (const name of opts.payload) {
|
||||
fs.mkdirSync(path.join(payload, name));
|
||||
fs.writeFileSync(path.join(payload, name, 'SKILL.md'), `---\nname: ${name}\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n# ${name}\n`);
|
||||
}
|
||||
opts.plant?.(skills, payload);
|
||||
const script = [
|
||||
'set -e',
|
||||
`IS_WINDOWS=${opts.isWindows}`,
|
||||
'SKILL_PREFIX=0',
|
||||
// Keep the operator's real render dir out of the picture.
|
||||
`GSTACK_USER_RENDER_DIR="${path.join(tmp, 'no-render')}"`,
|
||||
'_link_skill_runtime_assets() { :; }',
|
||||
'_print_windows_copy_note_once() { :; }',
|
||||
'_FOREIGN_SKIPPED_ENTRIES=()',
|
||||
extractFn('_link_or_copy'),
|
||||
extractFn('_gstack_link_target_abs'),
|
||||
extractFn('_gstack_target_is_ours'),
|
||||
extractFn('_claude_entry_is_ours'),
|
||||
extractFn('_write_owned_marker'),
|
||||
extractFn('_gstack_generated_header'),
|
||||
extractFn('_claude_entry_owned_strongly'),
|
||||
extractFn('_backup_skill_md'),
|
||||
extractFn('_cleanup_weak_dir'),
|
||||
extractFn('_gstack_dir_only_links'),
|
||||
extractFn('_cleanup_linked_dir'),
|
||||
'_BACKED_UP_SKILL_MDS=()',
|
||||
'_SKILL_BACKUP_ROOT="$HOME/.gstack/backups/skills/test"',
|
||||
extractFn('link_claude_skill_dirs'),
|
||||
extractFn('cleanup_old_claude_symlinks'),
|
||||
`link_claude_skill_dirs "${payload}" "${skills}"`,
|
||||
'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"',
|
||||
].join('\n');
|
||||
const r = runBashScript(script, { timeout: 10_000, env: { PATH: process.env.PATH ?? '', HOME: tmp } });
|
||||
if (/command not found/.test(r.stderr ?? '')) throw new Error(`harness drift (missing extracted helper):\n${r.stderr}`);
|
||||
return { status: r.status ?? -1, stdout: r.stdout ?? '', stderr: r.stderr ?? '', skills, payload, tmp };
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('setup: .gstack-owned ownership marker for Windows copy installs (#2119)', () => {
|
||||
test('the marker is written beside a COPIED SKILL.md (IS_WINDOWS=1) and beside a symlinked one (IS_WINDOWS=0): a directory we created is ours on every platform', () => {
|
||||
const win = runLinker({ isWindows: '1', payload: ['qa', 'ship'] });
|
||||
try {
|
||||
expect(win.status).toBe(0);
|
||||
for (const name of ['qa', 'ship']) {
|
||||
const md = path.join(win.skills, name, 'SKILL.md');
|
||||
expect(fs.lstatSync(md).isSymbolicLink()).toBe(false);
|
||||
expect(fs.existsSync(path.join(win.skills, name, '.gstack-owned'))).toBe(true);
|
||||
}
|
||||
expect(win.stdout).toContain('linked skills: qa ship');
|
||||
} finally {
|
||||
fs.rmSync(win.tmp, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const unix = runLinker({ isWindows: '0', payload: ['qa'] });
|
||||
try {
|
||||
expect(unix.status).toBe(0);
|
||||
expect(fs.lstatSync(path.join(unix.skills, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(true);
|
||||
expect(fs.existsSync(path.join(unix.skills, 'qa', '.gstack-owned'))).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(unix.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('marker writer → cleanup reader: copies the Windows linker marked are reaped on a mode flip; an unmarked same-name skill survives', () => {
|
||||
// Phase 1: a Windows install links qa + ship (copies + markers).
|
||||
const r = runLinker({ isWindows: '1', payload: ['qa', 'ship'] });
|
||||
try {
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.existsSync(path.join(r.skills, 'qa', '.gstack-owned'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(r.skills, 'ship', '.gstack-owned'))).toBe(true);
|
||||
// Phase 2: the payload now also ships `review`, and the user has their
|
||||
// OWN hand-written `review` (no marker, not byte-identical, no generated
|
||||
// header) plus an unrelated `my-own`. A mode flip runs the cleanup.
|
||||
fs.mkdirSync(path.join(r.payload, 'review'));
|
||||
fs.writeFileSync(path.join(r.payload, 'review', 'SKILL.md'), '---\nname: review\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n# review\n');
|
||||
fs.mkdirSync(path.join(r.skills, 'review'));
|
||||
fs.writeFileSync(path.join(r.skills, 'review', 'SKILL.md'), '---\nname: review\n---\n# mine, hand-written\n');
|
||||
fs.mkdirSync(path.join(r.skills, 'my-own'));
|
||||
fs.writeFileSync(path.join(r.skills, 'my-own', 'SKILL.md'), '---\nname: my-own\n---\n');
|
||||
const flip = runBashScript([
|
||||
'set -e', 'IS_WINDOWS=1',
|
||||
extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_gstack_dir_only_links'), extractFn('_cleanup_linked_dir'), extractFn('_gstack_generated_header'), extractFn('_cleanup_weak_dir'), extractFn('_backup_skill_md'), '_BACKED_UP_SKILL_MDS=()', '_SKILL_BACKUP_ROOT="$HOME/.gstack/backups/skills/test"',
|
||||
extractFn('cleanup_old_claude_symlinks'),
|
||||
`cleanup_old_claude_symlinks "${r.payload}" "${r.skills}"`,
|
||||
].join('\n'), { timeout: 10_000 });
|
||||
expect(flip.status).toBe(0);
|
||||
expect(flip.stdout).toContain('cleaned up old entries:');
|
||||
expect(flip.stdout).toContain('qa');
|
||||
expect(flip.stdout).toContain('ship');
|
||||
expect(flip.stdout).not.toContain('review');
|
||||
expect(fs.readdirSync(r.skills).sort()).toEqual(['gstack', 'my-own', 'review']);
|
||||
expect(fs.readFileSync(path.join(r.skills, 'review', 'SKILL.md'), 'utf-8')).toContain('mine, hand-written');
|
||||
} finally {
|
||||
fs.rmSync(r.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { runBashScript } from './helpers/bash-script';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
@@ -95,10 +95,7 @@ describe.skipIf(process.platform === 'win32')('setup: _link_or_copy helper — b
|
||||
const helper = extractHelper();
|
||||
// IS_WINDOWS must exist as a shell-readable var before sourcing.
|
||||
const script = `IS_WINDOWS=${isWindows}\n${helper}\n_link_or_copy "${src}" "${dst}"\n`;
|
||||
const result = spawnSync('bash', ['-c', script], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
});
|
||||
const result = runBashScript(script, { timeout: 5000 });
|
||||
const lst = fs.lstatSync(dst, { throwIfNoEntry: false });
|
||||
return {
|
||||
ok: result.status === 0,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* factory/opencode can't silently regress.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { runBashScript } from './helpers/bash-script';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
@@ -165,7 +165,7 @@ function runInstaller(
|
||||
...fns.map(extractFn),
|
||||
invocation,
|
||||
].join('\n');
|
||||
const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 15_000 });
|
||||
const r = runBashScript(script, { timeout: 15_000 });
|
||||
return { status: r.status, stdout: r.stdout, stderr: r.stderr };
|
||||
}
|
||||
|
||||
|
||||
@@ -332,6 +332,20 @@ describe('gstack-telemetry-log', () => {
|
||||
});
|
||||
|
||||
describe('.pending marker', () => {
|
||||
test('--no-sweep (one-shot setup events) leaves other sessions\' in-flight markers untouched', () => {
|
||||
setConfig('telemetry', 'anonymous');
|
||||
const analyticsDir = path.join(tmpDir, 'analytics');
|
||||
fs.mkdirSync(analyticsDir, { recursive: true });
|
||||
const marker = path.join(analyticsDir, '.pending-live-456');
|
||||
fs.writeFileSync(marker, '{"skill":"ship","ts":"2026-09-04T00:00:00Z","session_id":"live-456","gstack_version":"1.79.0.0"}');
|
||||
run(`${BIN}/gstack-telemetry-log --event-type onboarding --skill _setup_playwright --outcome chromium-install --no-sweep`);
|
||||
expect(fs.existsSync(marker)).toBe(true);
|
||||
const events = parseJsonl();
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].event_type).toBe('onboarding');
|
||||
expect(events[0].outcome).toBe('chromium-install');
|
||||
});
|
||||
|
||||
test('finalizes stale .pending from another session as outcome:unknown', () => {
|
||||
setConfig('telemetry', 'anonymous');
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { runBashScript } from './helpers/bash-script';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
@@ -86,7 +87,7 @@ describe(':user render targets the out-dir, never the checkout (#2569)', () => {
|
||||
extractFn(src, '_swap_in_render'),
|
||||
`_swap_in_render "${live}" "${fresh}"`,
|
||||
].join('\n');
|
||||
const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 15_000 });
|
||||
const r = runBashScript(script, { timeout: 15_000 });
|
||||
expect(r.status).toBe(0);
|
||||
// Live dir now serves the fresh render at the SAME path (links into
|
||||
// it stay valid), tmp and .old are gone.
|
||||
@@ -126,7 +127,7 @@ describe(':user render targets the out-dir, never the checkout (#2569)', () => {
|
||||
' echo "render failed — previous render left in place" >&2',
|
||||
'fi',
|
||||
].join('\n');
|
||||
const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 15_000 });
|
||||
const r = runBashScript(script, { timeout: 15_000 });
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.readFileSync(path.join(live, 'ship', 'SKILL.md'), 'utf-8')).toBe('previous-render\n');
|
||||
// The installed symlink still resolves — the skill set did not vanish.
|
||||
@@ -175,10 +176,23 @@ describe('link_claude_skill_dirs prefers rendered SKILL.md (behavior)', () => {
|
||||
extractFn(SETUP_SRC, '_link_or_copy'),
|
||||
extractFn(SETUP_SRC, '_print_windows_copy_note_once'),
|
||||
extractFn(SETUP_SRC, '_link_skill_runtime_assets'),
|
||||
extractFn(SETUP_SRC, '_gstack_link_target_abs'),
|
||||
extractFn(SETUP_SRC, '_gstack_target_is_ours'),
|
||||
extractFn(SETUP_SRC, '_gstack_generated_header'),
|
||||
extractFn(SETUP_SRC, '_claude_entry_owned_strongly'),
|
||||
extractFn(SETUP_SRC, '_claude_entry_is_ours'),
|
||||
extractFn(SETUP_SRC, '_write_owned_marker'),
|
||||
extractFn(SETUP_SRC, '_backup_skill_md'),
|
||||
extractFn(SETUP_SRC, '_cleanup_weak_dir'),
|
||||
extractFn(SETUP_SRC, '_gstack_dir_only_links'),
|
||||
extractFn(SETUP_SRC, '_cleanup_linked_dir'),
|
||||
'_FOREIGN_SKIPPED_ENTRIES=()',
|
||||
'_BACKED_UP_SKILL_MDS=()',
|
||||
`_SKILL_BACKUP_ROOT="${os.tmpdir()}/gstack-harness-backups-${process.pid}"`,
|
||||
extractFn(SETUP_SRC, 'link_claude_skill_dirs'),
|
||||
`link_claude_skill_dirs "${src}" "${skills}"`,
|
||||
].join('\n');
|
||||
const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 15_000 });
|
||||
const r = runBashScript(script, { timeout: 15_000 });
|
||||
expect(r.status).toBe(0);
|
||||
|
||||
expect(fs.readFileSync(path.join(skills, 'alpha', 'SKILL.md'), 'utf-8')).toContain('rendered-alpha');
|
||||
|
||||
Reference in New Issue
Block a user