fix(uninstall): provenance-gate the shape-2 and cursor sweeps; document the alias-name coupling

Three ways gstack-uninstall could touch a user's own skills:

- Shape 2 (real dir + symlinked SKILL.md) matched the link target against a
  bare *gstack* substring, so a skill symlinked from ~/tools/gstack-fork/ was
  wiped on uninstall. The gate now requires "gstack" as an anchored path
  segment (gstack/*|*/gstack/*, same pattern as shape 1) AND the dir name in
  gstack's skill inventory (parity with shape 3); anything else is listed to
  stderr, never deleted.
- The new Cursor removals (~/.cursor/skills/gstack* and repo-local
  .cursor/skills/gstack*) rm -rf'd any glob match with no provenance check,
  so a hand-written ~/.cursor/skills/gstack-fork-notes was swept. Real dirs
  now require the AUTO-GENERATED banner in SKILL.md; non-matching dirs are
  kept and listed. Legacy codex/factory/kiro globs are untouched (tracked in
  TODOS as a follow-up).
- The _INVENTORY seed list hardcodes alias names created by setup's
  _install_alias_skill_md; both sites now carry mirrored keep-in-sync
  comments so a renamed alias can't silently strand its dir.

The skipped-entry report moves to the end of the run so cursor skips are
listed alongside the Claude ones.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-16 14:13:14 -07:00
co-authored by Claude Fable 5
parent 166ac2ceee
commit 4a95ce61a0
4 changed files with 143 additions and 22 deletions
+38 -3
View File
@@ -163,15 +163,18 @@ describe('gstack-uninstall', () => {
});
test('--force removes Cursor gstack skills and leaves other Cursor skills', () => {
// Cursor installs are rendered real dirs, so removal is gated on the
// generated banner in SKILL.md (S5) — the managed fixtures carry it.
const banner = '<!-- AUTO-GENERATED from SKILL.md.tmpl - DO NOT EDIT DIRECTLY -->\n# x\n';
fs.mkdirSync(path.join(mockHome, '.cursor', 'skills', 'gstack'), { recursive: true });
fs.writeFileSync(path.join(mockHome, '.cursor', 'skills', 'gstack', 'SKILL.md'), 'test');
fs.writeFileSync(path.join(mockHome, '.cursor', 'skills', 'gstack', 'SKILL.md'), banner);
fs.mkdirSync(path.join(mockHome, '.cursor', 'skills', 'gstack-review'), { recursive: true });
fs.writeFileSync(path.join(mockHome, '.cursor', 'skills', 'gstack-review', 'SKILL.md'), 'test');
fs.writeFileSync(path.join(mockHome, '.cursor', 'skills', 'gstack-review', 'SKILL.md'), banner);
fs.mkdirSync(path.join(mockHome, '.cursor', 'skills', 'frontend-design'), { recursive: true });
fs.writeFileSync(path.join(mockHome, '.cursor', 'skills', 'frontend-design', 'SKILL.md'), 'keep');
fs.mkdirSync(path.join(mockGitRoot, '.cursor', 'skills', 'gstack-ship'), { recursive: true });
fs.writeFileSync(path.join(mockGitRoot, '.cursor', 'skills', 'gstack-ship', 'SKILL.md'), 'test');
fs.writeFileSync(path.join(mockGitRoot, '.cursor', 'skills', 'gstack-ship', 'SKILL.md'), banner);
fs.mkdirSync(path.join(mockGitRoot, '.cursor', 'rules'), { recursive: true });
fs.writeFileSync(path.join(mockGitRoot, '.cursor', 'rules', 'keep.md'), 'keep');
@@ -194,5 +197,37 @@ describe('gstack-uninstall', () => {
expect(fs.existsSync(path.join(mockGitRoot, '.cursor', 'skills', 'gstack-ship'))).toBe(false);
expect(fs.existsSync(path.join(mockGitRoot, '.cursor', 'rules', 'keep.md'))).toBe(true);
});
test("a user's own gstack-prefixed Cursor dir (no banner) survives and is listed", () => {
// S5: the bare gstack* glob must not sweep a dir that merely starts
// with "gstack" — provenance comes from the generated banner, and a
// hand-written SKILL.md never carries it.
const foreign = path.join(mockHome, '.cursor', 'skills', 'gstack-fork-notes');
fs.mkdirSync(foreign, { recursive: true });
fs.writeFileSync(path.join(foreign, 'SKILL.md'), '# my own notes\n');
const foreignLocal = path.join(mockGitRoot, '.cursor', 'skills', 'gstack-my-rules');
fs.mkdirSync(foreignLocal, { recursive: true });
fs.writeFileSync(path.join(foreignLocal, 'SKILL.md'), '# hand-written\n');
const result = spawnSync('bash', [UNINSTALL, '--force'], {
stdio: 'pipe',
env: {
...process.env,
HOME: mockHome,
GSTACK_DIR: path.join(mockHome, '.claude', 'skills', 'gstack'),
GSTACK_STATE_DIR: path.join(mockHome, '.gstack'),
},
cwd: mockGitRoot,
});
expect(result.status).toBe(0);
expect(fs.existsSync(foreign)).toBe(true);
expect(fs.existsSync(foreignLocal)).toBe(true);
const stderr = result.stderr.toString();
expect(stderr).toContain('left in place');
expect(stderr).toContain('gstack-fork-notes');
expect(stderr).toContain('gstack-my-rules');
});
});
});