mirror of https://github.com/garrytan/gstack.git
Merge 33b3913800 into a3259400a3
This commit is contained in:
commit
debdc1b431
42
setup
42
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/<name>
|
||||
# with <name> != 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 <skills_dir>/gstack -> <install_dir> 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.
|
||||
|
|
|
|||
|
|
@ -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/<name> with <name> != gstack, those references break
|
||||
// silently at skill-invocation time. `setup` reconciles this with a
|
||||
// compatibility alias (<skills_dir>/gstack -> <install_dir>) 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$/);
|
||||
});
|
||||
},
|
||||
);
|
||||
Loading…
Reference in New Issue