diff --git a/setup b/setup index 275236cd3..f30350c41 100755 --- a/setup +++ b/setup @@ -606,6 +606,45 @@ link_claude_root_skill_alias() { _print_windows_copy_note_once } +# ─── Helper: compatibility alias for installs not named "gstack" ────────────── +# Every generated SKILL.md preamble hard-references the absolute self-path +# ~/.claude/skills/gstack/bin/... (config, telemetry, update-check, and every +# other bin/asset call). When gstack is installed at ~/.claude/skills/ +# with != gstack, the per-skill symlinks resolve but those in-file +# references point at a non-existent ~/.claude/skills/gstack/ and fail SILENTLY +# at skill-invocation time (the preamble swallows the 127 via `|| echo default`). +# See issue #1882. +# +# Fix: plant a sibling alias /gstack -> so the +# hardcoded paths resolve with zero file edits. Claude Code already skips the +# repo-shaped gstack directory when building the slash-command list (see +# link_claude_root_skill_alias above), so this alias is a path-resolution +# target only, not a duplicate skill. When the install is already named gstack +# the references resolve natively and this is a no-op. +link_gstack_compat_alias() { + local install_dir="$1" # actual gstack checkout (INSTALL_GSTACK_DIR) + local skills_dir="$2" # skills parent (INSTALL_SKILLS_DIR) + + # Already named gstack — nothing to reconcile. + [ "$(basename "$install_dir")" = "gstack" ] && return 0 + + local alias="$skills_dir/gstack" + + # Never clobber a real (non-symlink) gstack install already living here — its + # own bin/ already satisfies the hardcoded references, and it may be a + # separate global install we must not destroy. + if [ -e "$alias" ] && [ ! -L "$alias" ]; then + return 0 + fi + + # Idempotently (re)point a name-only/self alias. On Windows _link_or_copy + # falls back to a directory copy (no symlinks without Developer Mode), which + # is heavier but keeps the hardcoded paths resolvable there too. + _link_or_copy "$install_dir" "$alias" + echo " linked compatibility alias: gstack -> $(basename "$install_dir") (install dir not named 'gstack', issue #1882)" + _print_windows_copy_note_once +} + # ─── Helper: remove old unprefixed Claude skill entries ─────────────────────── # Migration: when switching from flat names to gstack- prefixed names, # clean up stale symlinks or directories that point into the gstack directory. @@ -993,6 +1032,9 @@ if [ "$INSTALL_CLAUDE" -eq 1 ]; then "$SOURCE_GSTACK_DIR/bin/gstack-patch-names" "$SOURCE_GSTACK_DIR" "$SKILL_PREFIX" link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" link_claude_root_skill_alias "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" + # Reconcile hardcoded ~/.claude/skills/gstack/ self-paths when the install + # dir isn't literally named "gstack" (issue #1882). No-op when it is. + link_gstack_compat_alias "$INSTALL_GSTACK_DIR" "$INSTALL_SKILLS_DIR" # Self-healing: re-run gstack-relink to ensure name: fields and directory # names are consistent with the config. This catches cases where an interrupted # setup, stale git state, or gen:skill-docs left name: fields out of sync. diff --git a/test/setup-gstack-compat-alias.test.ts b/test/setup-gstack-compat-alias.test.ts new file mode 100644 index 000000000..46c49f84d --- /dev/null +++ b/test/setup-gstack-compat-alias.test.ts @@ -0,0 +1,151 @@ +import { describe, test, expect } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// Regression coverage for issue #1882: every generated SKILL.md preamble +// hard-references ~/.claude/skills/gstack/bin/... When gstack is installed at +// ~/.claude/skills/ with != gstack, those references break +// silently at skill-invocation time. `setup` reconciles this with a +// compatibility alias (/gstack -> ) planted by +// link_gstack_compat_alias. + +const SETUP_SRC = fs.readFileSync(path.join(import.meta.dir, '..', 'setup'), 'utf-8'); + +// Pull the bodies of the helpers we need to exercise in isolation. Anchors are +// the function-definition lines; each body ends at the first line that is a +// lone `}` at column 0. +function extractFn(name: string): string { + const start = SETUP_SRC.indexOf(`${name}() {`); + if (start < 0) throw new Error(`Could not locate ${name}() in setup`); + const end = SETUP_SRC.indexOf('\n}\n', start); + if (end < 0) throw new Error(`Could not locate end of ${name}() in setup`); + return SETUP_SRC.slice(start, end + 3); +} + +describe('setup: link_gstack_compat_alias — static invariants (#1882)', () => { + test('helper is defined', () => { + expect(SETUP_SRC).toContain('link_gstack_compat_alias() {'); + }); + + test('helper is called in the Claude install path with install + skills dirs', () => { + expect(SETUP_SRC).toContain( + 'link_gstack_compat_alias "$INSTALL_GSTACK_DIR" "$INSTALL_SKILLS_DIR"', + ); + }); + + test('helper no-ops when the install dir is already named gstack', () => { + const body = extractFn('link_gstack_compat_alias'); + expect(body).toContain('= "gstack" ] && return 0'); + }); + + test('helper never clobbers a real (non-symlink) gstack install', () => { + const body = extractFn('link_gstack_compat_alias'); + // Must bail before writing when a real dir already occupies the alias slot. + expect(body).toMatch(/\[ ! -L "\$alias" \][\s\S]*return 0/); + }); + + test('helper routes through _link_or_copy (no raw ln, Windows-safe)', () => { + const body = extractFn('link_gstack_compat_alias'); + expect(body).toContain('_link_or_copy "$install_dir" "$alias"'); + }); +}); + +// Behavioral matrix: source the three helpers into a Unix shell and drive each +// case. Symlink semantics only make sense off Windows. +describe.skipIf(process.platform === 'win32')( + 'setup: link_gstack_compat_alias — behavior', + () => { + function run( + installBasename: string, + preexisting?: 'real-dir' | 'symlink', + ): { + ok: boolean; + aliasExists: boolean; + aliasIsSymlink: boolean; + aliasTarget: string | null; + aliasRealContent: string | null; + stdout: string; + } { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-1882-')); + try { + const skillsDir = path.join(tmp, 'skills'); + const installDir = path.join(skillsDir, installBasename); + // A minimal install dir with a recognisable bin/ marker. + fs.mkdirSync(path.join(installDir, 'bin'), { recursive: true }); + fs.writeFileSync(path.join(installDir, 'bin', 'gstack-config'), 'echo real-install\n'); + + const alias = path.join(skillsDir, 'gstack'); + if (preexisting === 'real-dir') { + fs.mkdirSync(path.join(alias, 'bin'), { recursive: true }); + fs.writeFileSync(path.join(alias, 'bin', 'gstack-config'), 'echo pre-existing\n'); + } else if (preexisting === 'symlink') { + // stale self-link pointing somewhere else; helper should refresh it + const stale = path.join(tmp, 'stale'); + fs.mkdirSync(stale, { recursive: true }); + fs.symlinkSync(stale, alias); + } + + const helpers = [ + extractFn('_link_or_copy'), + extractFn('_print_windows_copy_note_once'), + extractFn('link_gstack_compat_alias'), + ].join('\n'); + const script = `IS_WINDOWS=0\n_WINDOWS_COPY_NOTE_PRINTED=0\n${helpers}\nlink_gstack_compat_alias "${installDir}" "${skillsDir}"\n`; + const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 5000 }); + + const lst = fs.lstatSync(alias, { throwIfNoEntry: false }); + let target: string | null = null; + let realContent: string | null = null; + if (lst?.isSymbolicLink()) target = fs.readlinkSync(alias); + const cfg = path.join(alias, 'bin', 'gstack-config'); + if (fs.existsSync(cfg)) realContent = fs.readFileSync(cfg, 'utf-8').trim(); + return { + ok: result.status === 0, + aliasExists: lst !== undefined, + aliasIsSymlink: lst?.isSymbolicLink() ?? false, + aliasTarget: target, + aliasRealContent: realContent, + stdout: result.stdout, + }; + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + } + + test('non-gstack install dir → alias symlink planted at skills/gstack', () => { + const r = run('i-gstack'); + expect(r.ok).toBe(true); + expect(r.aliasIsSymlink).toBe(true); + expect(r.aliasTarget).toMatch(/i-gstack$/); + // The alias resolves the hardcoded ~/.claude/skills/gstack/bin path. + expect(r.aliasRealContent).toBe('echo real-install'); + expect(r.stdout).toContain('compatibility alias: gstack -> i-gstack'); + }); + + test('install dir already named gstack → no alias planted (no-op)', () => { + const r = run('gstack'); + expect(r.ok).toBe(true); + // The alias slot IS the install dir here, so it exists — but the helper + // must NOT plant a symlink or announce a compatibility alias. + expect(r.aliasIsSymlink).toBe(false); + expect(r.aliasRealContent).toBe('echo real-install'); + expect(r.stdout).not.toContain('compatibility alias'); + }); + + test('pre-existing REAL gstack dir → not clobbered', () => { + const r = run('i-gstack', 'real-dir'); + expect(r.ok).toBe(true); + expect(r.aliasIsSymlink).toBe(false); + expect(r.aliasRealContent).toBe('echo pre-existing'); + }); + + test('stale gstack symlink → refreshed to this install (idempotent)', () => { + const r = run('i-gstack', 'symlink'); + expect(r.ok).toBe(true); + expect(r.aliasIsSymlink).toBe(true); + expect(r.aliasTarget).toMatch(/i-gstack$/); + }); + }, +);