From 13faf66132b3bd2b84be68593d286d7d64d71c80 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 7 Apr 2026 17:28:23 -0400 Subject: [PATCH] fix(installer): preserve USER-PROFILE.md and dev-preferences.md on re-install (#1945) Running gsd-update (re-running the installer) silently deleted two user-generated files: - get-shit-done/USER-PROFILE.md (created by /gsd-profile-user) - commands/gsd/dev-preferences.md (created by /gsd-profile-user) Root causes: 1. copyWithPathReplacement() calls fs.rmSync(destDir, {recursive:true}) before copying, wiping USER-PROFILE.md with no preserve allowlist. 2. The legacy commands/gsd/ cleanup at ~line 5211 rmSync'd the entire directory, wiping dev-preferences.md. 3. The backup path in profile-user.md pointed to the same directory that gets wiped, so the backup was also lost. Fix: - Add preserveUserArtifacts(destDir, fileNames) and restoreUserArtifacts() helpers that save/restore listed files around destructive wipes. - Call them in install() before the get-shit-done/ copy (preserves USER-PROFILE.md) and before the legacy commands/gsd/ cleanup (preserves dev-preferences.md). - Fix profile-user.md backup path from ~/.claude/get-shit-done/USER-PROFILE.backup.md to ~/.claude/USER-PROFILE.backup.md (outside the wiped directory). Closes #1924 Co-authored-by: Claude Sonnet 4.6 --- bin/install.js | 44 +++ get-shit-done/workflows/profile-user.md | 4 +- .../bug-1924-preserve-user-artifacts.test.cjs | 264 ++++++++++++++++++ 3 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 tests/bug-1924-preserve-user-artifacts.test.cjs diff --git a/bin/install.js b/bin/install.js index bfc820213..32ce68ece 100755 --- a/bin/install.js +++ b/bin/install.js @@ -3809,6 +3809,42 @@ function copyCommandsAsAntigravitySkills(srcDir, skillsDir, prefix, isGlobal = f recurse(srcDir, prefix); } +/** + * Save user-generated files from destDir to an in-memory map before a wipe. + * + * @param {string} destDir - Directory that is about to be wiped + * @param {string[]} fileNames - Relative file names (e.g. ['USER-PROFILE.md']) to preserve + * @returns {Map} Map of fileName → file content (only entries that existed) + */ +function preserveUserArtifacts(destDir, fileNames) { + const saved = new Map(); + for (const name of fileNames) { + const fullPath = path.join(destDir, name); + if (fs.existsSync(fullPath)) { + try { + saved.set(name, fs.readFileSync(fullPath, 'utf8')); + } catch { /* skip unreadable files */ } + } + } + return saved; +} + +/** + * Restore user-generated files saved by preserveUserArtifacts after a wipe. + * + * @param {string} destDir - Directory that was wiped and recreated + * @param {Map} saved - Map returned by preserveUserArtifacts + */ +function restoreUserArtifacts(destDir, saved) { + for (const [name, content] of saved) { + const fullPath = path.join(destDir, name); + try { + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content, 'utf8'); + } catch { /* skip unwritable paths */ } + } +} + /** * Recursively copy directory, replacing paths in .md files * Deletes existing destDir first to remove orphaned files from previous versions @@ -5217,10 +5253,13 @@ function install(isGlobal, runtime = 'claude') { } // Clean up legacy commands/gsd/ from previous global installs + // Preserve user-generated files (dev-preferences.md) before wiping the directory const legacyCommandsDir = path.join(targetDir, 'commands', 'gsd'); if (fs.existsSync(legacyCommandsDir)) { + const savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsDir, ['dev-preferences.md']); fs.rmSync(legacyCommandsDir, { recursive: true }); console.log(` ${green}✓${reset} Removed legacy commands/gsd/ directory`); + restoreUserArtifacts(legacyCommandsDir, savedLegacyArtifacts); } } else { // Claude Code local: commands/gsd/ format — Claude Code reads local project @@ -5252,9 +5291,12 @@ function install(isGlobal, runtime = 'claude') { } // Copy get-shit-done skill with path replacement + // Preserve user-generated files before the wipe-and-copy so they survive re-install const skillSrc = path.join(src, 'get-shit-done'); const skillDest = path.join(targetDir, 'get-shit-done'); + const savedGsdArtifacts = preserveUserArtifacts(skillDest, ['USER-PROFILE.md']); copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal); + restoreUserArtifacts(skillDest, savedGsdArtifacts); if (verifyInstalled(skillDest, 'get-shit-done')) { console.log(` ${green}✓${reset} Installed get-shit-done`); } else { @@ -6172,6 +6214,8 @@ if (process.env.GSD_TEST_MODE) { writeManifest, reportLocalPatches, validateHookFields, + preserveUserArtifacts, + restoreUserArtifacts, }; } else { diff --git a/get-shit-done/workflows/profile-user.md b/get-shit-done/workflows/profile-user.md index 056277402..dc143786b 100644 --- a/get-shit-done/workflows/profile-user.md +++ b/get-shit-done/workflows/profile-user.md @@ -46,7 +46,7 @@ If "Cancel": Display "No changes made." and exit. Backup existing profile: ```bash -cp "$HOME/.claude/get-shit-done/USER-PROFILE.md" "$HOME/.claude/get-shit-done/USER-PROFILE.backup.md" +cp "$HOME/.claude/get-shit-done/USER-PROFILE.md" "$HOME/.claude/USER-PROFILE.backup.md" ``` Display: "Re-analyzing your sessions to update your profile." @@ -381,7 +381,7 @@ Read both old backup and new analysis to compare dimension ratings/confidence. Read the backed-up profile: ```bash -BACKUP_PATH="$HOME/.claude/get-shit-done/USER-PROFILE.backup.md" +BACKUP_PATH="$HOME/.claude/USER-PROFILE.backup.md" ``` Compare each dimension's rating and confidence between old and new. Display diff table showing only changed dimensions: diff --git a/tests/bug-1924-preserve-user-artifacts.test.cjs b/tests/bug-1924-preserve-user-artifacts.test.cjs new file mode 100644 index 000000000..438dc9c5e --- /dev/null +++ b/tests/bug-1924-preserve-user-artifacts.test.cjs @@ -0,0 +1,264 @@ +/** + * Regression tests for bug #1924: gsd-update silently deletes user-generated files + * + * Running the installer (gsd-update / re-install) must not delete: + * - get-shit-done/USER-PROFILE.md (created by /gsd-profile-user) + * - commands/gsd/dev-preferences.md (created by /gsd-profile-user) + * + * Root cause: + * 1. copyWithPathReplacement() calls fs.rmSync(destDir, {recursive:true}) before + * copying — no preserve allowlist. This wipes USER-PROFILE.md. + * 2. ~line 5211 explicitly rmSync's commands/gsd/ during global install legacy + * cleanup — no preserve. This wipes dev-preferences.md. + * + * Fix requirement: + * - install() must preserve USER-PROFILE.md across the get-shit-done/ wipe + * - install() must preserve dev-preferences.md across the commands/gsd/ wipe + * + * Closes: #1924 + */ + +'use strict'; + +const { describe, test, beforeEach, afterEach, before } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execFileSync } = require('child_process'); + +const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js'); +const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); + +// ─── Ensure hooks/dist/ is populated before any install test ───────────────── + +before(() => { + execFileSync(process.execPath, [BUILD_SCRIPT], { + encoding: 'utf-8', + stdio: 'pipe', + }); +}); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function createTempDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function cleanup(dir) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } +} + +/** + * Run the installer with CLAUDE_CONFIG_DIR redirected to a temp directory. + * Explicitly removes GSD_TEST_MODE so the subprocess actually runs the installer + * (not just the export block). Uses --yes to suppress interactive prompts. + */ +function runInstaller(configDir) { + const env = { ...process.env, CLAUDE_CONFIG_DIR: configDir }; + delete env.GSD_TEST_MODE; + execFileSync(process.execPath, [INSTALL_SCRIPT, '--claude', '--global', '--yes'], { + encoding: 'utf-8', + stdio: 'pipe', + env, + }); +} + +// ─── Test 1: USER-PROFILE.md is preserved across re-install ───────────────── + +describe('#1924: USER-PROFILE.md preserved across re-install (global Claude)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-1924-userprofile-'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('USER-PROFILE.md exists after initial install + user creation', () => { + runInstaller(tmpDir); + + // Simulate /gsd-profile-user creating USER-PROFILE.md inside get-shit-done/ + const profilePath = path.join(tmpDir, 'get-shit-done', 'USER-PROFILE.md'); + fs.writeFileSync(profilePath, '# My Profile\n\nCustom user content.\n'); + + assert.ok( + fs.existsSync(profilePath), + 'USER-PROFILE.md should exist after being created by /gsd-profile-user' + ); + }); + + test('USER-PROFILE.md is preserved after re-install', () => { + // First install + runInstaller(tmpDir); + + // User runs /gsd-profile-user, creating USER-PROFILE.md + const profilePath = path.join(tmpDir, 'get-shit-done', 'USER-PROFILE.md'); + const originalContent = '# My Profile\n\nThis is my custom user profile content.\n'; + fs.writeFileSync(profilePath, originalContent); + + // Re-run installer (simulating gsd-update) + runInstaller(tmpDir); + + assert.ok( + fs.existsSync(profilePath), + 'USER-PROFILE.md must survive re-install — gsd-update must not delete user-generated profiles' + ); + + const afterContent = fs.readFileSync(profilePath, 'utf8'); + assert.strictEqual( + afterContent, + originalContent, + 'USER-PROFILE.md content must be identical after re-install' + ); + }); + + test('USER-PROFILE.md is preserved even when get-shit-done/ is wiped and recreated', () => { + runInstaller(tmpDir); + + const gsdDir = path.join(tmpDir, 'get-shit-done'); + const profilePath = path.join(gsdDir, 'USER-PROFILE.md'); + + // Confirm get-shit-done/ was created by install + assert.ok(fs.existsSync(gsdDir), 'get-shit-done/ must exist after install'); + + // Write profile + fs.writeFileSync(profilePath, '# Profile\n\nMy coding style preferences.\n'); + + // Re-install + runInstaller(tmpDir); + + // get-shit-done/ must still exist AND profile must be intact + assert.ok(fs.existsSync(gsdDir), 'get-shit-done/ must still exist after re-install'); + assert.ok( + fs.existsSync(profilePath), + 'USER-PROFILE.md must still exist after get-shit-done/ was wiped and recreated' + ); + }); +}); + +// ─── Test 2: dev-preferences.md is preserved across re-install ─────────────── + +describe('#1924: dev-preferences.md preserved across re-install (global Claude)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-1924-devprefs-'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('dev-preferences.md is preserved when commands/gsd/ is cleaned up during re-install', () => { + // First install (creates skills/ structure for global Claude) + runInstaller(tmpDir); + + // User runs /gsd-profile-user — it creates dev-preferences.md in commands/gsd/ + const commandsGsdDir = path.join(tmpDir, 'commands', 'gsd'); + fs.mkdirSync(commandsGsdDir, { recursive: true }); + const devPrefsPath = path.join(commandsGsdDir, 'dev-preferences.md'); + const originalContent = '# Dev Preferences\n\nI prefer TDD. I like short functions.\n'; + fs.writeFileSync(devPrefsPath, originalContent); + + // Re-run installer (simulating gsd-update) + // Bug: this triggers legacy cleanup that rmSync's commands/gsd/ entirely, + // deleting dev-preferences.md + runInstaller(tmpDir); + + assert.ok( + fs.existsSync(devPrefsPath), + 'dev-preferences.md must survive re-install — gsd-update legacy cleanup must not delete user-generated files' + ); + + const afterContent = fs.readFileSync(devPrefsPath, 'utf8'); + assert.strictEqual( + afterContent, + originalContent, + 'dev-preferences.md content must be identical after re-install' + ); + }); + + test('legacy non-user GSD commands are still cleaned up during re-install', () => { + // First install + runInstaller(tmpDir); + + // Simulate a legacy GSD command file being left in commands/gsd/ + const commandsGsdDir = path.join(tmpDir, 'commands', 'gsd'); + fs.mkdirSync(commandsGsdDir, { recursive: true }); + const legacyFile = path.join(commandsGsdDir, 'next.md'); + fs.writeFileSync(legacyFile, '---\nname: gsd:next\n---\n\nLegacy content.'); + + // But dev-preferences.md is also there (user-generated) + const devPrefsPath = path.join(commandsGsdDir, 'dev-preferences.md'); + fs.writeFileSync(devPrefsPath, '# Dev Preferences\n\nMy preferences.\n'); + + // Re-install + runInstaller(tmpDir); + + // dev-preferences.md must be preserved + assert.ok( + fs.existsSync(devPrefsPath), + 'dev-preferences.md must be preserved while legacy commands/gsd/ is cleaned up' + ); + + // The legacy GSD command (next.md) is NOT user-generated, should be removed + // (it would exist only as a skill now in skills/gsd-next/SKILL.md) + assert.ok( + !fs.existsSync(legacyFile), + 'legacy GSD command next.md in commands/gsd/ must be removed during cleanup' + ); + }); +}); + +// ─── Test 3: profile-user.md backup path is outside get-shit-done/ ─────────── + +describe('#1924: profile-user.md backup path must be outside get-shit-done/', () => { + test('profile-user.md backup uses ~/.claude/USER-PROFILE.backup.md not ~/.claude/get-shit-done/USER-PROFILE.backup.md', () => { + const workflowPath = path.join( + __dirname, '..', 'get-shit-done', 'workflows', 'profile-user.md' + ); + const content = fs.readFileSync(workflowPath, 'utf8'); + + // The backup must NOT be inside get-shit-done/ because that directory is wiped on update + assert.ok( + !content.includes('get-shit-done/USER-PROFILE.backup.md'), + 'backup path must NOT be inside get-shit-done/ — that directory is wiped on gsd-update' + ); + + // The backup should be at ~/.claude/USER-PROFILE.backup.md (outside get-shit-done/) + assert.ok( + content.includes('USER-PROFILE.backup.md') && + !content.includes('/get-shit-done/USER-PROFILE.backup.md'), + 'backup path must be outside get-shit-done/ (e.g. ~/.claude/USER-PROFILE.backup.md)' + ); + }); +}); + +// ─── Test 4: preserveUserArtifacts helper exported from install.js ──────────── + +describe('#1924: preserveUserArtifacts helper exists in install.js', () => { + test('install.js exports preserveUserArtifacts function', () => { + // Set GSD_TEST_MODE so require() reaches the module.exports block + const origMode = process.env.GSD_TEST_MODE; + process.env.GSD_TEST_MODE = '1'; + let mod; + try { + mod = require(INSTALL_SCRIPT); + } finally { + if (origMode === undefined) { + delete process.env.GSD_TEST_MODE; + } else { + process.env.GSD_TEST_MODE = origMode; + } + } + + assert.strictEqual( + typeof mod.preserveUserArtifacts, + 'function', + 'install.js must export preserveUserArtifacts helper for testability' + ); + }); +});