mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-16 01:45:29 +02:00
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>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
17733302cc
commit
584c2a44fb
@@ -259,21 +259,39 @@ describe.skipIf(process.platform === 'win32')('setup: cleanup_old_claude_symlink
|
||||
}
|
||||
});
|
||||
|
||||
test('Windows real-file leftover is removed when the payload still names it', () => {
|
||||
// #2119: a bare name match used to delete a USER's own skill that happened to
|
||||
// share a gstack skill name. Provenance must be proven: the .gstack-owned
|
||||
// marker, a byte-identical copy of the payload source, or gen-skill-docs'
|
||||
// AUTO-GENERATED header (legacy copies made before the marker existed).
|
||||
test('Windows real-file leftover is removed only when provably gstack-owned', () => {
|
||||
const generated = '---\nname: ship\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n# ship\n';
|
||||
const r = runCleanup({
|
||||
isWindows: '1',
|
||||
payload: true,
|
||||
plant(skills, payload) {
|
||||
const src = path.join(payload, 'qa');
|
||||
fs.mkdirSync(src);
|
||||
fs.writeFileSync(path.join(src, 'SKILL.md'), '---\nname: qa\n---\n');
|
||||
plantUserSkill(skills, 'qa');
|
||||
for (const name of ['qa', 'ship', 'review', 'browse']) {
|
||||
fs.mkdirSync(path.join(payload, name));
|
||||
fs.writeFileSync(path.join(payload, name, 'SKILL.md'), name === 'ship' ? generated : `---\nname: ${name}\n---\n`);
|
||||
}
|
||||
// Byte-identical copy of the payload source → ours.
|
||||
fs.mkdirSync(path.join(skills, 'qa'));
|
||||
fs.copyFileSync(path.join(payload, 'qa', 'SKILL.md'), path.join(skills, 'qa', 'SKILL.md'));
|
||||
// Legacy copy carrying the generated header but drifted from source → ours.
|
||||
fs.mkdirSync(path.join(skills, 'ship'));
|
||||
fs.writeFileSync(path.join(skills, 'ship', 'SKILL.md'), generated.replace('# ship', '# ship (older render)'));
|
||||
// Marker-carrying copy with arbitrary content → ours.
|
||||
fs.mkdirSync(path.join(skills, 'browse'));
|
||||
fs.writeFileSync(path.join(skills, 'browse', 'SKILL.md'), '---\nname: browse\n---\n# stale copy\n');
|
||||
fs.writeFileSync(path.join(skills, 'browse', '.gstack-owned'), '');
|
||||
// The user's OWN skill that shares a gstack name → foreign, must survive.
|
||||
plantUserSkill(skills, 'review');
|
||||
plantUserSkill(skills, 'my-own');
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.names).toEqual(['gstack', 'my-own']);
|
||||
expect(r.names).toEqual(['gstack', 'my-own', 'review']);
|
||||
expect(fs.readFileSync(path.join(r.tmp, 'skills', 'review', 'SKILL.md'), 'utf-8')).toContain('user-owned');
|
||||
} finally {
|
||||
fs.rmSync(r.tmp, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user