mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-16 09:55:29 +02:00
fix(setup): alias skills install as rewritten copies, never symlinks
The two back-compat alias dirs — _gstack-command (root router) and connect-chrome (→ open-gstack-browser) — symlinked the canonical SKILL.md verbatim, so each alias re-served the canonical frontmatter name:. Claude Code keys skills on that name and requires global uniqueness: the connect-chrome duplicate silently shadowed /open-gstack-browser (whichever readdir returned first won), and the _gstack-command duplicate could drop the ENTIRE personal-skills set — every /gstack command vanished until the user hand-deleted the alias dirs, and the next setup re-broke it. Fix: copy-then-rewrite. A shared _install_alias_skill_md helper reads the SOURCE SKILL.md and writes a fresh copy with name: rewritten to the alias dir's own name (_gstack-command / connect-chrome / gstack-connect-chrome). sed never edits in place: on Unix the old install was a symlink into the repo, and an in-place rewrite through it would have corrupted the generated source (eng review E2). bin/gstack-relink gets the same treatment for its root-alias helper, and its discovery loop now skips symlinked source dirs so the connect-chrome repo symlink can't re-mint the duplicate. Tests assert: installed aliases are NOT symlinks, carry their own unique names, all installed frontmatter names are globally unique, re-runs refresh cleanly, legacy symlinked aliases are replaced not written through, and the source files stay byte-intact. Fixes #2511 Fixes #2201 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
0f38feee78
commit
663aca3b05
+43
-3
@@ -215,9 +215,15 @@ describe('gstack-relink (#578)', () => {
|
||||
const aliasSkill = path.join(aliasDir, 'SKILL.md');
|
||||
expect(fs.lstatSync(aliasDir).isDirectory()).toBe(true);
|
||||
expect(fs.lstatSync(aliasDir).isSymbolicLink()).toBe(false);
|
||||
expect(fs.lstatSync(aliasSkill).isSymbolicLink()).toBe(true);
|
||||
expect(fs.readlinkSync(aliasSkill)).toBe(path.join(installDir, 'SKILL.md'));
|
||||
expect(fs.readFileSync(aliasSkill, 'utf-8')).toContain('name: gstack');
|
||||
// #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,
|
||||
@@ -226,6 +232,40 @@ describe('gstack-relink (#578)', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
// 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']);
|
||||
|
||||
Reference in New Issue
Block a user