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>
This commit is contained in:
Garry Tan
2026-09-04 18:14:31 +00:00
co-authored by Claude Fable 5.1
parent 35805ad3c9
commit a8bc93eb2c
5 changed files with 360 additions and 46 deletions
+87 -1
View File
@@ -1096,6 +1096,7 @@ describe('gstack-relink: checkout naming, legacy linked dirs, markers (#2119 rev
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'));
@@ -1103,9 +1104,12 @@ describe('gstack-relink: checkout naming, legacy linked dirs, markers (#2119 rev
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 plain directory that merely holds a SKILL.md is not a gstack tree.
// ...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');
@@ -1146,3 +1150,85 @@ describe('gstack-relink: checkout naming, legacy linked dirs, markers (#2119 rev
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');
});
});
+2 -2
View File
@@ -24,7 +24,7 @@ function extractFn(name: string): string {
}
function cleanupBody(): string {
return [extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_gstack_dir_only_links'), extractFn('_cleanup_linked_dir'), extractFn('_gstack_generated_header'), extractFn('_cleanup_weak_dir'), extractFn('cleanup_old_claude_symlinks')].join('\n');
return [extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_gstack_dir_only_links'), extractFn('_cleanup_linked_dir'), extractFn('_gstack_generated_header'), extractFn('_cleanup_weak_dir'), extractFn('_backup_skill_md'), '_BACKED_UP_SKILL_MDS=()', `_SKILL_BACKUP_ROOT="${os.tmpdir()}/cleanup-orphans-backups-${process.pid}"`, extractFn('cleanup_old_claude_symlinks')].join('\n');
}
describe('setup: cleanup_old_claude_symlinks — static (#2204)', () => {
@@ -68,7 +68,7 @@ describe.skipIf(process.platform === 'win32')('setup: cleanup_old_claude_symlink
const script = [
'set -e',
`IS_WINDOWS=${opts.isWindows ?? '0'}`,
extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_gstack_dir_only_links'), extractFn('_cleanup_linked_dir'), extractFn('_gstack_generated_header'), extractFn('_cleanup_weak_dir'),
extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_gstack_dir_only_links'), extractFn('_cleanup_linked_dir'), extractFn('_gstack_generated_header'), extractFn('_cleanup_weak_dir'), extractFn('_backup_skill_md'), '_BACKED_UP_SKILL_MDS=()', `_SKILL_BACKUP_ROOT="${tmp}/backups"`,
extractFn('cleanup_old_claude_symlinks'),
`cleanup_old_claude_symlinks "${gstackArg}" "${skills}"`,
].join('\n');
+152 -6
View File
@@ -150,9 +150,13 @@ describe.skipIf(process.platform === 'win32')('setup: _install_alias_skill_md ne
expect(r.stdout).toContain('FOREIGN=connect-chrome');
expect(fs.readFileSync(path.join(t.skills, 'gstack-connect-chrome', 'SKILL.md'), 'utf-8')).toContain('name: gstack-connect-chrome');
expect(fs.readFileSync(path.join(t.skills, 'gstack-connect-chrome', 'SKILL.md'), 'utf-8')).not.toContain('old alias copy');
// The refreshed copy is stamped, so next run's provenance is the marker, not the banner.
expect(fs.readFileSync(path.join(t.skills, 'gstack-connect-chrome', '.gstack-owned'), 'utf-8').trim()).toBe(fs.realpathSync(t.payload));
// A pre-existing alias directory is never stamped (we did not create it); a created one is.
expect(fs.existsSync(path.join(t.skills, 'gstack-connect-chrome', '.gstack-owned'))).toBe(false);
expect(fs.existsSync(path.join(t.skills, 'connect-chrome', '.gstack-owned'))).toBe(false);
const created = bash(['set -e', 'IS_WINDOWS=0', `SOURCE_GSTACK_DIR="${t.payload}"`, HELPERS, extractFn('_install_alias_skill_md'),
`_install_alias_skill_md "${t.payload}/open-gstack-browser/SKILL.md" "${t.skills}/fresh-alias" fresh-alias`], t.tmp);
expect(created.status).toBe(0);
expect(fs.readFileSync(path.join(t.skills, 'fresh-alias', '.gstack-owned'), 'utf-8').trim()).toBe(fs.realpathSync(t.payload));
} finally {
fs.rmSync(t.tmp, { recursive: true, force: true });
}
@@ -165,7 +169,7 @@ describe.skipIf(process.platform === 'win32')('setup: cleanup_prefixed_claude_sy
fs.mkdirSync(path.join(t.payload, 'qa'));
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
plant(t.skills, t.payload);
const r = bash(['set -e', `IS_WINDOWS=${isWindows}`, extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_gstack_dir_only_links'), extractFn('_cleanup_linked_dir'), extractFn('_gstack_generated_header'), extractFn('_cleanup_weak_dir'), extractFn('cleanup_prefixed_claude_symlinks'),
const r = bash(['set -e', `IS_WINDOWS=${isWindows}`, extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_gstack_dir_only_links'), extractFn('_cleanup_linked_dir'), extractFn('_gstack_generated_header'), extractFn('_cleanup_weak_dir'), extractFn('_backup_skill_md'), '_BACKED_UP_SKILL_MDS=()', '_SKILL_BACKUP_ROOT="$HOME/.gstack/backups/skills/test"', extractFn('cleanup_prefixed_claude_symlinks'),
`cleanup_prefixed_claude_symlinks "${t.payload}" "${t.skills}"`], t.tmp);
const names = fs.readdirSync(t.skills).sort();
fs.rmSync(t.tmp, { recursive: true, force: true });
@@ -281,7 +285,7 @@ describe.skipIf(process.platform === 'win32')('setup: weakly-proven files are mo
fs.mkdirSync(path.join(t.skills, 'gstack-qa', 'my-templates'), { recursive: true });
fs.writeFileSync(path.join(t.skills, 'gstack-qa', 'SKILL.md'), GENERATED('gstack-qa'));
fs.writeFileSync(path.join(t.skills, 'gstack-qa', 'my-templates', 'checklist.md'), '- mine\n');
const r = bash(['set -e', 'IS_WINDOWS=1', extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_gstack_dir_only_links'), extractFn('_cleanup_linked_dir'), extractFn('_gstack_generated_header'), extractFn('_cleanup_weak_dir'), extractFn('cleanup_prefixed_claude_symlinks'),
const r = bash(['set -e', 'IS_WINDOWS=1', extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_gstack_dir_only_links'), extractFn('_cleanup_linked_dir'), extractFn('_gstack_generated_header'), extractFn('_cleanup_weak_dir'), extractFn('_backup_skill_md'), '_BACKED_UP_SKILL_MDS=()', '_SKILL_BACKUP_ROOT="$HOME/.gstack/backups/skills/test"', extractFn('cleanup_prefixed_claude_symlinks'),
`cleanup_prefixed_claude_symlinks "${t.payload}" "${t.skills}"`], t.tmp);
expect(r.status).toBe(0);
expect(fs.existsSync(path.join(t.skills, 'gstack-qa', 'SKILL.md'))).toBe(false);
@@ -340,13 +344,26 @@ describe.skipIf(process.platform === 'win32')('setup: banner census, checkout na
fs.mkdirSync(path.join(other, 'bin'));
fs.writeFileSync(path.join(other, 'VERSION'), '1.0.0.0\n');
fs.writeFileSync(path.join(other, 'setup'), '#!/bin/bash\n');
fs.writeFileSync(path.join(other, 'bin', 'gstack-relink'), '#!/bin/bash\n');
fs.writeFileSync(path.join(other, 'qa', 'SKILL.md'), GENERATED('qa'));
fs.mkdirSync(path.join(t.skills, 'qa'));
fs.symlinkSync(path.join(other, 'qa', 'SKILL.md'), path.join(t.skills, 'qa', 'SKILL.md'));
// A hand-written skill repo that happens to carry VERSION + setup + bin/ is NOT a gstack tree.
const mine = path.join(t.tmp, 'myskills');
fs.mkdirSync(path.join(mine, 'ship'), { recursive: true });
fs.mkdirSync(path.join(mine, 'bin'));
fs.writeFileSync(path.join(mine, 'VERSION'), '0.1\n');
fs.writeFileSync(path.join(mine, 'setup'), '#!/bin/bash\n');
fs.writeFileSync(path.join(mine, 'ship', 'SKILL.md'), FOREIGN);
fs.mkdirSync(path.join(t.payload, 'ship'));
fs.writeFileSync(path.join(t.payload, 'ship', 'SKILL.md'), GENERATED('ship'));
fs.mkdirSync(path.join(t.skills, 'ship'));
fs.symlinkSync(path.join(mine, 'ship', 'SKILL.md'), path.join(t.skills, 'ship', 'SKILL.md'));
const r = bash(['set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', HELPERS, extractFn('link_claude_skill_dirs'),
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`, 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
expect(r.status).toBe(0);
expect(r.stdout).toContain('FOREIGN=\n');
expect(r.stdout).toContain('FOREIGN=ship\n');
expect(fs.readlinkSync(path.join(t.skills, 'ship', 'SKILL.md'))).toBe(path.join(mine, 'ship', 'SKILL.md'));
expect(fs.readlinkSync(path.join(t.skills, 'qa', 'SKILL.md'))).toBe(path.join(t.payload, 'qa', 'SKILL.md'));
// Re-pointed, but the directory pre-existed: no marker (only directories we create get one).
expect(fs.existsSync(path.join(t.skills, 'qa', '.gstack-owned'))).toBe(false);
@@ -387,7 +404,7 @@ describe.skipIf(process.platform === 'win32')('setup: banner census, checkout na
fs.symlinkSync(path.join(t.payload, 'qa', 'sections'), path.join(t.skills, name, 'sections'));
}
fs.writeFileSync(path.join(t.skills, 'gstack-ship', 'my-notes.md'), 'keep\n');
const r = bash(['set -e', 'IS_WINDOWS=0', extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_gstack_dir_only_links'), extractFn('_cleanup_linked_dir'), extractFn('_gstack_generated_header'), extractFn('_cleanup_weak_dir'), extractFn('cleanup_prefixed_claude_symlinks'),
const r = bash(['set -e', 'IS_WINDOWS=0', extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_gstack_dir_only_links'), extractFn('_cleanup_linked_dir'), extractFn('_gstack_generated_header'), extractFn('_cleanup_weak_dir'), extractFn('_backup_skill_md'), '_BACKED_UP_SKILL_MDS=()', '_SKILL_BACKUP_ROOT="$HOME/.gstack/backups/skills/test"', extractFn('cleanup_prefixed_claude_symlinks'),
`cleanup_prefixed_claude_symlinks "${t.payload}" "${t.skills}"`], t.tmp);
expect(r.status).toBe(0);
expect(fs.existsSync(path.join(t.skills, 'gstack-qa'))).toBe(false);
@@ -400,3 +417,132 @@ describe.skipIf(process.platform === 'win32')('setup: banner census, checkout na
}
});
});
describe.skipIf(process.platform === 'win32')('setup: cycle-3 hardening — assets, foreign dir links, failed backups, flip backups (#2119 review)', () => {
const REAL_ASSETS = extractFn('_link_skill_runtime_assets');
test('an unclaimed or weakly-owned directory keeps the user\'s same-named real assets; a created directory gets ours', () => {
const t = mkTree();
try {
for (const n of ['qa', 'ship', 'review']) {
fs.mkdirSync(path.join(t.payload, n, 'templates'), { recursive: true });
fs.writeFileSync(path.join(t.payload, n, 'SKILL.md'), GENERATED(n));
fs.writeFileSync(path.join(t.payload, n, 'templates', 'ours.md'), 'ours\n');
fs.writeFileSync(path.join(t.payload, n, 'checklist.md'), 'ours\n');
}
// qa: unclaimed (no SKILL.md) with the user's templates/ and checklist.md
fs.mkdirSync(path.join(t.skills, 'qa', 'templates'), { recursive: true });
fs.writeFileSync(path.join(t.skills, 'qa', 'templates', 'mine.md'), 'mine\n');
fs.writeFileSync(path.join(t.skills, 'qa', 'checklist.md'), 'my checklist\n');
// ship: weakly owned (customized banner copy) with the user's templates/
fs.mkdirSync(path.join(t.skills, 'ship', 'templates'), { recursive: true });
fs.writeFileSync(path.join(t.skills, 'ship', 'SKILL.md'), GENERATED('ship').replace('# ship', '# customized'));
fs.writeFileSync(path.join(t.skills, 'ship', 'templates', 'mine.md'), 'mine\n');
const r = bash(['set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', HELPERS, REAL_ASSETS, extractFn('link_claude_skill_dirs'),
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`], t.tmp);
expect(r.status).toBe(0);
expect(fs.readFileSync(path.join(t.skills, 'qa', 'templates', 'mine.md'), 'utf-8')).toBe('mine\n');
expect(fs.readFileSync(path.join(t.skills, 'qa', 'checklist.md'), 'utf-8')).toBe('my checklist\n');
expect(fs.lstatSync(path.join(t.skills, 'qa', 'checklist.md')).isSymbolicLink()).toBe(false);
expect(fs.readFileSync(path.join(t.skills, 'ship', 'templates', 'mine.md'), 'utf-8')).toBe('mine\n');
expect(r.stderr).toContain('kept qa/templates');
expect(r.stderr).toContain('kept qa/checklist.md');
expect(r.stderr).toContain('kept ship/templates');
// review: created by us → assets linked
expect(fs.lstatSync(path.join(t.skills, 'review', 'templates')).isSymbolicLink()).toBe(true);
expect(fs.lstatSync(path.join(t.skills, 'review', 'checklist.md')).isSymbolicLink()).toBe(true);
// ship's customized SKILL.md was backed up, its assets kept, its checklist (absent before) linked
expect(fs.existsSync(path.join(t.tmp, '.gstack', 'backups', 'skills', 'test', 'ship', 'SKILL.md'))).toBe(true);
expect(fs.lstatSync(path.join(t.skills, 'ship', 'checklist.md')).isSymbolicLink()).toBe(true);
} finally {
fs.rmSync(t.tmp, { recursive: true, force: true });
}
});
test('a foreign DIRECTORY symlink (target has no SKILL.md) is foreign, not unclaimed: the user\'s link survives', () => {
const t = mkTree();
try {
fs.mkdirSync(path.join(t.payload, 'qa'));
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
const userdir = path.join(t.tmp, 'userdir');
fs.mkdirSync(userdir);
fs.writeFileSync(path.join(userdir, 'notes.md'), 'mine\n');
fs.symlinkSync(userdir, path.join(t.skills, 'qa'));
const r = bash(['set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', HELPERS, extractFn('link_claude_skill_dirs'),
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`, 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
expect(r.status).toBe(0);
expect(r.stdout).toContain('FOREIGN=qa\n');
expect(fs.lstatSync(path.join(t.skills, 'qa')).isSymbolicLink()).toBe(true);
expect(fs.readlinkSync(path.join(t.skills, 'qa'))).toBe(userdir);
expect(fs.existsSync(path.join(userdir, 'SKILL.md'))).toBe(false);
} finally {
fs.rmSync(t.tmp, { recursive: true, force: true });
}
});
test('when the backup cannot be written, the customized file is left untouched and the entry is reported, never overwritten', () => {
const t = mkTree();
try {
fs.mkdirSync(path.join(t.payload, 'qa'));
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
const custom = GENERATED('qa').replace('# qa', '# customized');
fs.mkdirSync(path.join(t.skills, 'qa'));
fs.writeFileSync(path.join(t.skills, 'qa', 'SKILL.md'), custom);
fs.writeFileSync(path.join(t.tmp, 'not-a-dir'), 'x');
const r = bash(['set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', HELPERS, `_SKILL_BACKUP_ROOT="${t.tmp}/not-a-dir/backups"`, extractFn('link_claude_skill_dirs'),
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`, 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
expect(r.status).toBe(0);
expect(fs.lstatSync(path.join(t.skills, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(false);
expect(fs.readFileSync(path.join(t.skills, 'qa', 'SKILL.md'), 'utf-8')).toBe(custom);
expect(r.stderr).toContain('could not back up');
expect(r.stdout).toContain('FOREIGN=qa\n');
} finally {
fs.rmSync(t.tmp, { recursive: true, force: true });
}
});
test('Windows flip: a customized banner copy is moved to the backup root, not deleted; an alias-shaped copy (only name: differs) is just removed', () => {
const t = mkTree();
try {
fs.mkdirSync(path.join(t.payload, 'qa'));
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
fs.mkdirSync(path.join(t.payload, 'ship'));
fs.writeFileSync(path.join(t.payload, 'ship', 'SKILL.md'), GENERATED('ship'));
const custom = GENERATED('gstack-qa').replace('# gstack-qa', '# customized on windows');
fs.mkdirSync(path.join(t.skills, 'gstack-qa'));
fs.writeFileSync(path.join(t.skills, 'gstack-qa', 'SKILL.md'), custom);
fs.mkdirSync(path.join(t.skills, 'gstack-ship'));
fs.writeFileSync(path.join(t.skills, 'gstack-ship', 'SKILL.md'), GENERATED('ship').replace('name: ship', 'name: gstack-ship'));
const r = bash(['set -e', 'IS_WINDOWS=1', HELPERS, extractFn('cleanup_prefixed_claude_symlinks'),
`cleanup_prefixed_claude_symlinks "${t.payload}" "${t.skills}"`], t.tmp);
expect(r.status).toBe(0);
expect(fs.existsSync(path.join(t.skills, 'gstack-qa'))).toBe(false);
expect(fs.readFileSync(path.join(t.tmp, '.gstack', 'backups', 'skills', 'test', 'gstack-qa', 'SKILL.md'), 'utf-8')).toBe(custom);
expect(fs.existsSync(path.join(t.skills, 'gstack-ship'))).toBe(false);
expect(fs.existsSync(path.join(t.tmp, '.gstack', 'backups', 'skills', 'test', 'gstack-ship'))).toBe(false);
} finally {
fs.rmSync(t.tmp, { recursive: true, force: true });
}
});
test('a legacy linked dir holding the user\'s OWN symlink is mixed: our links go, theirs stays, the dir stays', () => {
const t = mkTree();
try {
fs.mkdirSync(path.join(t.payload, 'qa', 'sections'), { recursive: true });
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
fs.mkdirSync(path.join(t.skills, 'gstack-qa'));
fs.symlinkSync(path.join(t.payload, 'qa', 'SKILL.md'), path.join(t.skills, 'gstack-qa', 'SKILL.md'));
fs.symlinkSync(path.join(t.payload, 'qa', 'sections'), path.join(t.skills, 'gstack-qa', 'sections'));
fs.writeFileSync(path.join(t.tmp, 'my-notes.md'), 'mine\n');
fs.symlinkSync(path.join(t.tmp, 'my-notes.md'), path.join(t.skills, 'gstack-qa', 'notes.md'));
const r = bash(['set -e', 'IS_WINDOWS=0', HELPERS, extractFn('cleanup_prefixed_claude_symlinks'),
`cleanup_prefixed_claude_symlinks "${t.payload}" "${t.skills}"`], t.tmp);
expect(r.status).toBe(0);
expect(fs.existsSync(path.join(t.skills, 'gstack-qa', 'SKILL.md'))).toBe(false);
expect(fs.existsSync(path.join(t.skills, 'gstack-qa', 'sections'))).toBe(false);
expect(fs.readlinkSync(path.join(t.skills, 'gstack-qa', 'notes.md'))).toBe(path.join(t.tmp, 'my-notes.md'));
} finally {
fs.rmSync(t.tmp, { recursive: true, force: true });
}
});
});