diff --git a/bin/install.js b/bin/install.js index 2de04b471..2a5237016 100755 --- a/bin/install.js +++ b/bin/install.js @@ -4565,6 +4565,15 @@ function uninstall(isGlobal, runtime = 'claude') { } } + // Remove the file manifest that the installer wrote at install time. + // Without this step the metadata file persists after uninstall (#1908). + const manifestPath = path.join(targetDir, MANIFEST_NAME); + if (fs.existsSync(manifestPath)) { + fs.rmSync(manifestPath, { force: true }); + removedCount++; + console.log(` ${green}✓${reset} Removed ${MANIFEST_NAME}`); + } + if (removedCount === 0) { console.log(` ${yellow}⚠${reset} No GSD files found to remove.`); } diff --git a/tests/bug-1908-uninstall-manifest.test.cjs b/tests/bug-1908-uninstall-manifest.test.cjs new file mode 100644 index 000000000..85a144546 --- /dev/null +++ b/tests/bug-1908-uninstall-manifest.test.cjs @@ -0,0 +1,133 @@ +/** + * Regression test for bug #1908 + * + * `--uninstall` did not remove `gsd-file-manifest.json` from the target + * directory, leaving a stale metadata file after uninstall. + * + * Fix: `uninstall()` must call + * fs.rmSync(path.join(targetDir, MANIFEST_NAME), { force: true }) + * after cleaning up the rest of the GSD artefacts. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const { uninstall } = require('../bin/install.js'); + +const MANIFEST_NAME = 'gsd-file-manifest.json'; + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function createFakeInstall(prefix = 'gsd-uninstall-test-') { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + + // Simulate the minimum directory/file layout produced by the installer: + // get-shit-done/ directory, agents/ directory, and the manifest file. + fs.mkdirSync(path.join(dir, 'get-shit-done', 'workflows'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'get-shit-done', 'workflows', 'execute-phase.md'), '# stub'); + + fs.mkdirSync(path.join(dir, 'agents'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'agents', 'gsd-executor.md'), '# stub'); + + const manifest = { + version: '1.34.0', + timestamp: new Date().toISOString(), + files: { + 'get-shit-done/workflows/execute-phase.md': 'abc123', + 'agents/gsd-executor.md': 'def456', + }, + }; + fs.writeFileSync(path.join(dir, MANIFEST_NAME), JSON.stringify(manifest, null, 2)); + + return dir; +} + +function cleanup(dir) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} +} + +// ─── tests ──────────────────────────────────────────────────────────────────── + +describe('uninstall — manifest cleanup (#1908)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createFakeInstall(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('gsd-file-manifest.json is removed after global uninstall', () => { + const manifestPath = path.join(tmpDir, MANIFEST_NAME); + + // Pre-condition: manifest exists before uninstall + assert.ok( + fs.existsSync(manifestPath), + 'Test setup failure: manifest file should exist before uninstall' + ); + + // Run uninstall against tmpDir (pass it via CLAUDE_CONFIG_DIR so getGlobalDir() + // resolves to our temp directory; pass isGlobal=true) + const savedEnv = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = tmpDir; + try { + uninstall(true, 'claude'); + } finally { + if (savedEnv === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = savedEnv; + } + } + + assert.ok( + !fs.existsSync(manifestPath), + [ + `${MANIFEST_NAME} must be removed by uninstall() but still exists at`, + manifestPath, + ].join(' ') + ); + }); + + test('gsd-file-manifest.json is removed after local uninstall', () => { + const manifestPath = path.join(tmpDir, MANIFEST_NAME); + + assert.ok( + fs.existsSync(manifestPath), + 'Test setup failure: manifest file should exist before uninstall' + ); + + // For a local install, getGlobalDir is not called — targetDir = cwd + dirName. + // Simulate by creating .claude/ inside tmpDir and placing artefacts there. + const localDir = path.join(tmpDir, '.claude'); + fs.mkdirSync(path.join(localDir, 'get-shit-done', 'workflows'), { recursive: true }); + fs.writeFileSync(path.join(localDir, 'get-shit-done', 'workflows', 'execute-phase.md'), '# stub'); + const localManifestPath = path.join(localDir, MANIFEST_NAME); + fs.writeFileSync(localManifestPath, JSON.stringify({ version: '1.34.0', files: {} }, null, 2)); + + const savedCwd = process.cwd(); + process.chdir(tmpDir); + try { + uninstall(false, 'claude'); + } finally { + process.chdir(savedCwd); + } + + assert.ok( + !fs.existsSync(localManifestPath), + [ + `${MANIFEST_NAME} must be removed by uninstall() (local) but still exists at`, + localManifestPath, + ].join(' ') + ); + }); +});