mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 22:48:57 +02:00
* 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>
1235 lines
59 KiB
TypeScript
1235 lines
59 KiB
TypeScript
import { describe, test as _bunTest, expect, beforeEach, afterEach } from 'bun:test';
|
|
import { execSync } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
|
|
// Every test in this file shells out to gstack-config + gstack-relink (bash scripts
|
|
// invoking subprocess work). Under parallel bun test load, subprocess spawn contends
|
|
// with other suites and each test can drift ~200ms past the 5s default. Bump to 15s.
|
|
// Object.assign preserves test.only / test.skip / test.each / test.todo sub-APIs.
|
|
const test = Object.assign(
|
|
((name: any, fn: any, timeout?: number) =>
|
|
_bunTest(name, fn, timeout ?? 15_000)) as typeof _bunTest,
|
|
_bunTest,
|
|
);
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const BIN = path.join(ROOT, 'bin');
|
|
|
|
let tmpDir: string;
|
|
let skillsDir: string;
|
|
let installDir: string;
|
|
|
|
function run(cmd: string, env: Record<string, string> = {}, expectFail = false): string {
|
|
try {
|
|
return execSync(cmd, {
|
|
cwd: ROOT,
|
|
// A sibling test file in the same shard PROCESS can leave GSTACK_HOME
|
|
// set on process.env; relink/config children must resolve state ONLY
|
|
// via the dirs this test passes (observed: 'fresh install' test saw a
|
|
// neighbor's skill_prefix and produced prefixed names).
|
|
env: (() => {
|
|
const child: Record<string, string | undefined> = { ...process.env, GSTACK_STATE_DIR: tmpDir, ...env };
|
|
if (!('GSTACK_HOME' in env)) delete child.GSTACK_HOME;
|
|
return child;
|
|
})(),
|
|
encoding: 'utf-8',
|
|
timeout: 10000,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
}).trim();
|
|
} catch (e: any) {
|
|
if (expectFail) return (e.stderr || e.stdout || '').toString().trim();
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// Create a mock gstack install directory with skill subdirs
|
|
function setupMockInstall(skills: string[]): void {
|
|
installDir = path.join(tmpDir, 'gstack-install');
|
|
skillsDir = path.join(tmpDir, 'skills');
|
|
fs.mkdirSync(installDir, { recursive: true });
|
|
fs.mkdirSync(skillsDir, { recursive: true });
|
|
|
|
// Copy the real gstack-config and gstack-relink to the mock install
|
|
const mockBin = path.join(installDir, 'bin');
|
|
fs.mkdirSync(mockBin, { recursive: true });
|
|
fs.copyFileSync(path.join(BIN, 'gstack-config'), path.join(mockBin, 'gstack-config'));
|
|
fs.chmodSync(path.join(mockBin, 'gstack-config'), 0o755);
|
|
if (fs.existsSync(path.join(BIN, 'gstack-relink'))) {
|
|
fs.copyFileSync(path.join(BIN, 'gstack-relink'), path.join(mockBin, 'gstack-relink'));
|
|
fs.chmodSync(path.join(mockBin, 'gstack-relink'), 0o755);
|
|
}
|
|
if (fs.existsSync(path.join(BIN, 'gstack-patch-names'))) {
|
|
fs.copyFileSync(path.join(BIN, 'gstack-patch-names'), path.join(mockBin, 'gstack-patch-names'));
|
|
fs.chmodSync(path.join(mockBin, 'gstack-patch-names'), 0o755);
|
|
}
|
|
|
|
// Create mock skill directories with proper frontmatter
|
|
for (const skill of skills) {
|
|
fs.mkdirSync(path.join(installDir, skill), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(installDir, skill, 'SKILL.md'),
|
|
`---\nname: ${skill}\ndescription: test\n---\n# ${skill}`
|
|
);
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-relink-test-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('gstack-relink (#578)', () => {
|
|
// Test 11: prefixed symlinks when skill_prefix=true
|
|
test('creates gstack-* symlinks when skill_prefix=true', () => {
|
|
setupMockInstall(['qa', 'ship', 'review']);
|
|
// Set config to prefix mode (pass install/skills env so auto-relink uses mock install)
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
// Run relink with env pointing to the mock install
|
|
const output = run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
// Verify gstack-* symlinks exist
|
|
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(true);
|
|
expect(fs.existsSync(path.join(skillsDir, 'gstack-ship'))).toBe(true);
|
|
expect(fs.existsSync(path.join(skillsDir, 'gstack-review'))).toBe(true);
|
|
expect(output).toContain('gstack-');
|
|
});
|
|
|
|
// Test 12: flat symlinks when skill_prefix=false
|
|
test('creates flat symlinks when skill_prefix=false', () => {
|
|
setupMockInstall(['qa', 'ship', 'review']);
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
const output = run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
expect(fs.existsSync(path.join(skillsDir, 'qa'))).toBe(true);
|
|
expect(fs.existsSync(path.join(skillsDir, 'ship'))).toBe(true);
|
|
expect(fs.existsSync(path.join(skillsDir, 'review'))).toBe(true);
|
|
expect(output).toContain('flat');
|
|
});
|
|
|
|
// REGRESSION: unprefixed skills must be real directories, not symlinks (#761)
|
|
// Claude Code auto-prefixes skills nested under a parent dir symlink.
|
|
// e.g., `qa -> gstack/qa` gets discovered as "gstack-qa", not "qa".
|
|
// The fix: create real directories with SKILL.md symlinks inside.
|
|
test('unprefixed skills are real directories with SKILL.md symlinks, not dir symlinks', () => {
|
|
setupMockInstall(['qa', 'ship', 'review', 'plan-ceo-review']);
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
for (const skill of ['qa', 'ship', 'review', 'plan-ceo-review']) {
|
|
const skillPath = path.join(skillsDir, skill);
|
|
const skillMdPath = path.join(skillPath, 'SKILL.md');
|
|
// Must be a real directory, NOT a symlink
|
|
expect(fs.lstatSync(skillPath).isDirectory()).toBe(true);
|
|
expect(fs.lstatSync(skillPath).isSymbolicLink()).toBe(false);
|
|
// Must contain a SKILL.md that IS a symlink
|
|
expect(fs.existsSync(skillMdPath)).toBe(true);
|
|
expect(fs.lstatSync(skillMdPath).isSymbolicLink()).toBe(true);
|
|
// The SKILL.md symlink must point to the source skill's SKILL.md
|
|
const target = fs.readlinkSync(skillMdPath);
|
|
expect(target).toContain(skill);
|
|
expect(target).toEndWith('/SKILL.md');
|
|
}
|
|
});
|
|
|
|
// Same invariant for prefixed mode
|
|
test('prefixed skills are real directories with SKILL.md symlinks, not dir symlinks', () => {
|
|
setupMockInstall(['qa', 'ship']);
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
for (const skill of ['gstack-qa', 'gstack-ship']) {
|
|
const skillPath = path.join(skillsDir, skill);
|
|
const skillMdPath = path.join(skillPath, 'SKILL.md');
|
|
expect(fs.lstatSync(skillPath).isDirectory()).toBe(true);
|
|
expect(fs.lstatSync(skillPath).isSymbolicLink()).toBe(false);
|
|
expect(fs.lstatSync(skillMdPath).isSymbolicLink()).toBe(true);
|
|
}
|
|
});
|
|
|
|
// Upgrade: old directory symlinks get replaced with real directories
|
|
test('upgrades old directory symlinks to real directories', () => {
|
|
setupMockInstall(['qa', 'ship']);
|
|
// Simulate old behavior: create directory symlinks (the old pattern)
|
|
fs.symlinkSync(path.join(installDir, 'qa'), path.join(skillsDir, 'qa'));
|
|
fs.symlinkSync(path.join(installDir, 'ship'), path.join(skillsDir, 'ship'));
|
|
// Verify they start as symlinks
|
|
expect(fs.lstatSync(path.join(skillsDir, 'qa')).isSymbolicLink()).toBe(true);
|
|
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
|
|
// After relink: must be real directories, not symlinks
|
|
expect(fs.lstatSync(path.join(skillsDir, 'qa')).isSymbolicLink()).toBe(false);
|
|
expect(fs.lstatSync(path.join(skillsDir, 'qa')).isDirectory()).toBe(true);
|
|
expect(fs.lstatSync(path.join(skillsDir, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(true);
|
|
});
|
|
|
|
test('creates a thin root alias wrapper for the /gstack slash command', () => {
|
|
setupMockInstall(['qa']);
|
|
fs.writeFileSync(
|
|
path.join(installDir, 'SKILL.md'),
|
|
'---\nname: gstack\ndescription: root\n---\n# gstack',
|
|
);
|
|
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
|
|
const aliasDir = path.join(skillsDir, '_gstack-command');
|
|
const aliasSkill = path.join(aliasDir, 'SKILL.md');
|
|
expect(fs.lstatSync(aliasDir).isDirectory()).toBe(true);
|
|
expect(fs.lstatSync(aliasDir).isSymbolicLink()).toBe(false);
|
|
// #2511: the alias is a rewritten COPY, never a symlink. A symlinked
|
|
// alias re-serves the canonical `name: gstack`; Claude Code refuses
|
|
// duplicate skill names and drops the entire personal-skills set.
|
|
expect(fs.lstatSync(aliasSkill).isSymbolicLink()).toBe(false);
|
|
const aliasContent = fs.readFileSync(aliasSkill, 'utf-8');
|
|
expect(aliasContent).toContain('name: _gstack-command');
|
|
expect(aliasContent).not.toContain('name: gstack\n');
|
|
// The rewrite happened on the COPY: the canonical source keeps its name.
|
|
expect(fs.readFileSync(path.join(installDir, 'SKILL.md'), 'utf-8')).toContain('name: gstack');
|
|
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
expect(fs.existsSync(aliasSkill)).toBe(true);
|
|
});
|
|
|
|
// #2201: connect-chrome ships as a dir SYMLINK to open-gstack-browser. The
|
|
// discovery loop used to link it under its own basename while its SKILL.md
|
|
// carried `name: open-gstack-browser` — a duplicate name that silently
|
|
// shadows the real skill (readdir-order roulette). Symlinked source dirs
|
|
// must be skipped; setup owns the rewritten-copy alias.
|
|
test('symlinked skill dirs are skipped, so no duplicate frontmatter names (#2201)', () => {
|
|
setupMockInstall(['open-gstack-browser', 'qa']);
|
|
fs.symlinkSync(
|
|
path.join(installDir, 'open-gstack-browser'),
|
|
path.join(installDir, 'connect-chrome'),
|
|
);
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
|
|
expect(fs.existsSync(path.join(skillsDir, 'open-gstack-browser'))).toBe(true);
|
|
expect(fs.existsSync(path.join(skillsDir, 'connect-chrome'))).toBe(false);
|
|
|
|
// No two installed SKILL.md files may share a frontmatter name.
|
|
const names: string[] = [];
|
|
for (const entry of fs.readdirSync(skillsDir)) {
|
|
const skillMd = path.join(skillsDir, entry, 'SKILL.md');
|
|
if (!fs.existsSync(skillMd)) continue;
|
|
const m = fs.readFileSync(skillMd, 'utf-8').match(/^name:\s*(\S+)/m);
|
|
if (m) names.push(m[1]);
|
|
}
|
|
expect(new Set(names).size).toBe(names.length);
|
|
});
|
|
|
|
// #2569: rendered :user variants live in ${GSTACK_HOME}/render/claude.
|
|
// relink must serve the render when present — otherwise any config change
|
|
// silently flips every skill back to the canonical (blockless) source.
|
|
test('prefers a rendered SKILL.md from GSTACK_HOME/render/claude (#2569)', () => {
|
|
setupMockInstall(['qa', 'ship']);
|
|
const renderDir = path.join(tmpDir, 'render', 'claude', 'qa');
|
|
fs.mkdirSync(renderDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(renderDir, 'SKILL.md'),
|
|
'---\nname: qa\ndescription: test\n---\nrendered brain-aware qa',
|
|
);
|
|
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
GSTACK_HOME: tmpDir,
|
|
});
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
GSTACK_HOME: tmpDir,
|
|
});
|
|
|
|
const qaLink = path.join(skillsDir, 'qa', 'SKILL.md');
|
|
expect(fs.readlinkSync(qaLink)).toBe(path.join(renderDir, 'SKILL.md'));
|
|
expect(fs.readFileSync(qaLink, 'utf-8')).toContain('rendered brain-aware qa');
|
|
// ship has no render — canonical source link.
|
|
expect(fs.readlinkSync(path.join(skillsDir, 'ship', 'SKILL.md'))).toBe(
|
|
path.join(installDir, 'ship', 'SKILL.md'),
|
|
);
|
|
});
|
|
|
|
// #2738: with skill_prefix=true AND an active gbrain render, the symlink
|
|
// target is the RENDER copy — so the render's `name:` must get the gstack-
|
|
// prefix too, or the served frontmatter stays unprefixed and skill_prefix
|
|
// silently no-ops for every brain-aware skill.
|
|
test('skill_prefix=true patches the rendered SKILL.md name too (#2738)', () => {
|
|
setupMockInstall(['qa']);
|
|
const renderDir = path.join(tmpDir, 'render', 'claude', 'qa');
|
|
fs.mkdirSync(renderDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(renderDir, 'SKILL.md'),
|
|
'---\nname: qa\ndescription: test\n---\nrendered brain-aware qa',
|
|
);
|
|
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
GSTACK_HOME: tmpDir,
|
|
});
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
GSTACK_HOME: tmpDir,
|
|
});
|
|
|
|
const served = path.join(skillsDir, 'gstack-qa', 'SKILL.md');
|
|
expect(fs.readlinkSync(served)).toBe(path.join(renderDir, 'SKILL.md'));
|
|
// The SERVED file (the render) carries the prefixed name.
|
|
expect(fs.readFileSync(served, 'utf-8')).toContain('name: gstack-qa');
|
|
// Idempotent: a second relink must not double-prefix.
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
GSTACK_HOME: tmpDir,
|
|
});
|
|
expect(fs.readFileSync(served, 'utf-8')).toContain('name: gstack-qa');
|
|
expect(fs.readFileSync(served, 'utf-8')).not.toContain('gstack-gstack-');
|
|
});
|
|
|
|
// FIRST INSTALL: --no-prefix must create ONLY flat names, zero gstack-* pollution
|
|
test('first install --no-prefix: only flat names exist, zero gstack-* entries', () => {
|
|
setupMockInstall(['qa', 'ship', 'review', 'plan-ceo-review', 'gstack-upgrade']);
|
|
// Simulate first install: no saved config, pass --no-prefix equivalent
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
// Enumerate everything in skills dir
|
|
const entries = fs.readdirSync(skillsDir);
|
|
// Expected: qa, ship, review, plan-ceo-review, gstack-upgrade (its real name)
|
|
expect(entries.sort()).toEqual(['gstack-upgrade', 'plan-ceo-review', 'qa', 'review', 'ship']);
|
|
// No gstack-qa, gstack-ship, gstack-review, gstack-plan-ceo-review
|
|
const leaked = entries.filter(e => e.startsWith('gstack-') && e !== 'gstack-upgrade');
|
|
expect(leaked).toEqual([]);
|
|
});
|
|
|
|
// FIRST INSTALL: --prefix must create ONLY gstack-* names, zero flat-name pollution
|
|
test('first install --prefix: only gstack-* entries exist, zero flat names', () => {
|
|
setupMockInstall(['qa', 'ship', 'review', 'plan-ceo-review', 'gstack-upgrade']);
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
const entries = fs.readdirSync(skillsDir);
|
|
// Expected: gstack-qa, gstack-ship, gstack-review, gstack-plan-ceo-review, gstack-upgrade
|
|
expect(entries.sort()).toEqual([
|
|
'gstack-plan-ceo-review', 'gstack-qa', 'gstack-review', 'gstack-ship', 'gstack-upgrade',
|
|
]);
|
|
// No unprefixed qa, ship, review, plan-ceo-review
|
|
const leaked = entries.filter(e => !e.startsWith('gstack-'));
|
|
expect(leaked).toEqual([]);
|
|
});
|
|
|
|
// FIRST INSTALL: non-TTY (no saved config, piped stdin) defaults to flat names
|
|
test('non-TTY first install defaults to flat names via relink', () => {
|
|
setupMockInstall(['qa', 'ship']);
|
|
// Don't set any config — simulate fresh install
|
|
// gstack-relink reads config; on fresh install config returns empty → defaults to false
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
const entries = fs.readdirSync(skillsDir);
|
|
// Should be flat names (relink defaults to false when config returns empty)
|
|
expect(entries.sort()).toEqual(['qa', 'ship']);
|
|
});
|
|
|
|
// SWITCH: prefix → no-prefix must clean up ALL gstack-* entries
|
|
test('switching prefix to no-prefix removes all gstack-* entries completely', () => {
|
|
setupMockInstall(['qa', 'ship', 'review', 'plan-ceo-review', 'gstack-upgrade']);
|
|
// Start in prefix mode
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
let entries = fs.readdirSync(skillsDir);
|
|
expect(entries.filter(e => !e.startsWith('gstack-'))).toEqual([]);
|
|
|
|
// Switch to no-prefix
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
entries = fs.readdirSync(skillsDir);
|
|
// Only flat names + gstack-upgrade (its real name)
|
|
expect(entries.sort()).toEqual(['gstack-upgrade', 'plan-ceo-review', 'qa', 'review', 'ship']);
|
|
const leaked = entries.filter(e => e.startsWith('gstack-') && e !== 'gstack-upgrade');
|
|
expect(leaked).toEqual([]);
|
|
});
|
|
|
|
// SWITCH: no-prefix → prefix must clean up ALL flat entries
|
|
test('switching no-prefix to prefix removes all flat entries completely', () => {
|
|
setupMockInstall(['qa', 'ship', 'review', 'gstack-upgrade']);
|
|
// Start in no-prefix mode
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
let entries = fs.readdirSync(skillsDir);
|
|
expect(entries.filter(e => e.startsWith('gstack-') && e !== 'gstack-upgrade')).toEqual([]);
|
|
|
|
// Switch to prefix
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
entries = fs.readdirSync(skillsDir);
|
|
// Only gstack-* names
|
|
expect(entries.sort()).toEqual([
|
|
'gstack-qa', 'gstack-review', 'gstack-ship', 'gstack-upgrade',
|
|
]);
|
|
const leaked = entries.filter(e => !e.startsWith('gstack-'));
|
|
expect(leaked).toEqual([]);
|
|
});
|
|
|
|
// Test 13: cleans stale symlinks from opposite mode
|
|
test('cleans up stale symlinks from opposite mode', () => {
|
|
setupMockInstall(['qa', 'ship']);
|
|
// Create prefixed symlinks first
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(true);
|
|
|
|
// Switch to flat mode
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
|
|
// Flat symlinks should exist, prefixed should be gone
|
|
expect(fs.existsSync(path.join(skillsDir, 'qa'))).toBe(true);
|
|
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
|
|
});
|
|
|
|
// Test 14: error when install dir missing
|
|
test('prints error when install dir missing', () => {
|
|
const output = run(`${BIN}/gstack-relink`, {
|
|
GSTACK_INSTALL_DIR: '/nonexistent/path/gstack',
|
|
GSTACK_SKILLS_DIR: '/nonexistent/path/skills',
|
|
}, true);
|
|
expect(output).toContain('setup');
|
|
});
|
|
|
|
// Test: gstack-upgrade does NOT get double-prefixed
|
|
test('does not double-prefix gstack-upgrade directory', () => {
|
|
setupMockInstall(['qa', 'ship', 'gstack-upgrade']);
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
// gstack-upgrade should keep its name, NOT become gstack-gstack-upgrade
|
|
expect(fs.existsSync(path.join(skillsDir, 'gstack-upgrade'))).toBe(true);
|
|
expect(fs.existsSync(path.join(skillsDir, 'gstack-gstack-upgrade'))).toBe(false);
|
|
// Regular skills still get prefixed
|
|
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(true);
|
|
});
|
|
|
|
// Test 15: gstack-config set skill_prefix triggers relink
|
|
test('gstack-config set skill_prefix triggers relink', () => {
|
|
setupMockInstall(['qa', 'ship']);
|
|
// Run gstack-config set which should auto-trigger relink
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
// If relink was triggered, symlinks should exist
|
|
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(true);
|
|
expect(fs.existsSync(path.join(skillsDir, 'gstack-ship'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('upgrade migrations', () => {
|
|
const MIGRATIONS_DIR = path.join(ROOT, 'gstack-upgrade', 'migrations');
|
|
|
|
test('migrations directory exists', () => {
|
|
expect(fs.existsSync(MIGRATIONS_DIR)).toBe(true);
|
|
});
|
|
|
|
test('all migration scripts are executable and parse without syntax errors', () => {
|
|
const scripts = fs.readdirSync(MIGRATIONS_DIR).filter(f => f.endsWith('.sh'));
|
|
expect(scripts.length).toBeGreaterThan(0);
|
|
for (const script of scripts) {
|
|
const fullPath = path.join(MIGRATIONS_DIR, script);
|
|
// Must be executable
|
|
const stat = fs.statSync(fullPath);
|
|
expect(stat.mode & 0o111).toBeGreaterThan(0);
|
|
// Must parse without syntax errors (bash -n is a syntax check, doesn't execute)
|
|
const result = execSync(`bash -n "${fullPath}" 2>&1`, { encoding: 'utf-8', timeout: 5000 });
|
|
// bash -n outputs nothing on success
|
|
}
|
|
});
|
|
|
|
test('migration filenames follow v{VERSION}.sh pattern', () => {
|
|
const scripts = fs.readdirSync(MIGRATIONS_DIR).filter(f => f.endsWith('.sh'));
|
|
for (const script of scripts) {
|
|
expect(script).toMatch(/^v\d+\.\d+\.\d+\.\d+\.sh$/);
|
|
}
|
|
});
|
|
|
|
test('v0.15.2.0 migration runs gstack-relink', () => {
|
|
const content = fs.readFileSync(path.join(MIGRATIONS_DIR, 'v0.15.2.0.sh'), 'utf-8');
|
|
expect(content).toContain('gstack-relink');
|
|
});
|
|
|
|
test('v0.15.2.0 migration fixes stale directory symlinks', () => {
|
|
setupMockInstall(['qa', 'ship', 'review']);
|
|
// Simulate old state: directory symlinks (pre-v0.15.2.0 pattern)
|
|
fs.symlinkSync(path.join(installDir, 'qa'), path.join(skillsDir, 'qa'));
|
|
fs.symlinkSync(path.join(installDir, 'ship'), path.join(skillsDir, 'ship'));
|
|
fs.symlinkSync(path.join(installDir, 'review'), path.join(skillsDir, 'review'));
|
|
// Set no-prefix mode (suppress auto-relink so symlinks stay intact for the test)
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, {
|
|
GSTACK_SETUP_RUNNING: '1',
|
|
});
|
|
// Verify old state: symlinks
|
|
expect(fs.lstatSync(path.join(skillsDir, 'qa')).isSymbolicLink()).toBe(true);
|
|
|
|
// Run the migration (it calls gstack-relink internally)
|
|
run(`bash ${path.join(MIGRATIONS_DIR, 'v0.15.2.0.sh')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
|
|
// After migration: real directories with SKILL.md symlinks
|
|
for (const skill of ['qa', 'ship', 'review']) {
|
|
const skillPath = path.join(skillsDir, skill);
|
|
expect(fs.lstatSync(skillPath).isSymbolicLink()).toBe(false);
|
|
expect(fs.lstatSync(skillPath).isDirectory()).toBe(true);
|
|
expect(fs.lstatSync(path.join(skillPath, 'SKILL.md')).isSymbolicLink()).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('gstack-patch-names (#620/#578)', () => {
|
|
// Helper to read name: from SKILL.md frontmatter
|
|
function readSkillName(skillDir: string): string | null {
|
|
const content = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf-8');
|
|
const match = content.match(/^name:\s*(.+)$/m);
|
|
return match ? match[1].trim() : null;
|
|
}
|
|
|
|
test('prefix=true patches name: field in SKILL.md', () => {
|
|
setupMockInstall(['qa', 'ship', 'review']);
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
// Verify name: field is patched with gstack- prefix
|
|
expect(readSkillName(path.join(installDir, 'qa'))).toBe('gstack-qa');
|
|
expect(readSkillName(path.join(installDir, 'ship'))).toBe('gstack-ship');
|
|
expect(readSkillName(path.join(installDir, 'review'))).toBe('gstack-review');
|
|
});
|
|
|
|
test('prefix=false restores name: field in SKILL.md', () => {
|
|
setupMockInstall(['qa', 'ship']);
|
|
// First, prefix them
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
expect(readSkillName(path.join(installDir, 'qa'))).toBe('gstack-qa');
|
|
// Now switch to flat mode
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
// Verify name: field is restored to unprefixed
|
|
expect(readSkillName(path.join(installDir, 'qa'))).toBe('qa');
|
|
expect(readSkillName(path.join(installDir, 'ship'))).toBe('ship');
|
|
});
|
|
|
|
test('gstack-upgrade name: not double-prefixed', () => {
|
|
setupMockInstall(['qa', 'gstack-upgrade']);
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
// gstack-upgrade should keep its name, NOT become gstack-gstack-upgrade
|
|
expect(readSkillName(path.join(installDir, 'gstack-upgrade'))).toBe('gstack-upgrade');
|
|
// Regular skill should be prefixed
|
|
expect(readSkillName(path.join(installDir, 'qa'))).toBe('gstack-qa');
|
|
});
|
|
|
|
test('SKILL.md without frontmatter is a no-op', () => {
|
|
setupMockInstall(['qa']);
|
|
// Overwrite qa SKILL.md with no frontmatter
|
|
fs.writeFileSync(path.join(installDir, 'qa', 'SKILL.md'), '# qa\nSome content.');
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
// Should not crash
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
// Content should be unchanged (no name: to patch)
|
|
const content = fs.readFileSync(path.join(installDir, 'qa', 'SKILL.md'), 'utf-8');
|
|
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');
|
|
});
|
|
});
|