diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index b09013753..833e7277b 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -333,7 +333,7 @@ async function main() { // filesystem traversal on every invocation. const SKIP_ROOT_RESOLUTION = new Set([ 'generate-slug', 'current-timestamp', 'verify-path-exists', - 'verify-summary', 'template', 'frontmatter', + 'verify-summary', 'template', 'frontmatter', 'detect-custom-files', ]); if (!SKIP_ROOT_RESOLUTION.has(command)) { cwd = findProjectRoot(cwd); @@ -1142,6 +1142,98 @@ async function runCommand(command, args, cwd, raw, defaultValue) { break; } + // ─── detect-custom-files ─────────────────────────────────────────────── + // Detect user-added files inside GSD-managed directories that are not + // tracked in gsd-file-manifest.json. Used by the update workflow to back + // up custom files before the installer wipes those directories. + // + // This replaces the fragile bash pattern: + // MANIFEST_FILES=$(node -e "require('$RUNTIME_DIR/...')" 2>/dev/null) + // ${filepath#$RUNTIME_DIR/} # unreliable path stripping + // which silently returns CUSTOM_COUNT=0 when $RUNTIME_DIR is unset or + // when the stripped path does not match the manifest key format (#1997). + + case 'detect-custom-files': { + const configDirIdx = args.indexOf('--config-dir'); + const configDir = configDirIdx !== -1 ? args[configDirIdx + 1] : null; + if (!configDir) { + error('Usage: gsd-tools detect-custom-files --config-dir '); + } + const resolvedConfigDir = path.resolve(configDir); + if (!fs.existsSync(resolvedConfigDir)) { + error(`Config directory not found: ${resolvedConfigDir}`); + } + + const manifestPath = path.join(resolvedConfigDir, 'gsd-file-manifest.json'); + if (!fs.existsSync(manifestPath)) { + // No manifest — cannot determine what is custom. Return empty list + // (same behaviour as saveLocalPatches in install.js when no manifest). + const out = { custom_files: [], custom_count: 0, manifest_found: false }; + process.stdout.write(JSON.stringify(out, null, 2)); + break; + } + + let manifest; + try { + manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + } catch { + const out = { custom_files: [], custom_count: 0, manifest_found: false, error: 'manifest parse error' }; + process.stdout.write(JSON.stringify(out, null, 2)); + break; + } + + const manifestKeys = new Set(Object.keys(manifest.files || {})); + + // GSD-managed directories to scan for user-added files. + // These are the directories the installer wipes on update. + const GSD_MANAGED_DIRS = [ + 'get-shit-done', + 'agents', + path.join('commands', 'gsd'), + 'hooks', + // OpenCode/Kilo flat command dir + 'command', + // Codex/Copilot skills dir + 'skills', + ]; + + function walkDir(dir, baseDir) { + const results = []; + if (!fs.existsSync(dir)) return results; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...walkDir(fullPath, baseDir)); + } else { + // Use forward slashes for cross-platform manifest key compatibility + const relPath = path.relative(baseDir, fullPath).replace(/\\/g, '/'); + results.push(relPath); + } + } + return results; + } + + const customFiles = []; + for (const managedDir of GSD_MANAGED_DIRS) { + const absDir = path.join(resolvedConfigDir, managedDir); + if (!fs.existsSync(absDir)) continue; + for (const relPath of walkDir(absDir, resolvedConfigDir)) { + if (!manifestKeys.has(relPath)) { + customFiles.push(relPath); + } + } + } + + const out = { + custom_files: customFiles, + custom_count: customFiles.length, + manifest_found: true, + manifest_version: manifest.version || null, + }; + process.stdout.write(JSON.stringify(out, null, 2)); + break; + } + // ─── GSD-2 Reverse Migration ─────────────────────────────────────────── case 'from-gsd2': { diff --git a/get-shit-done/workflows/update.md b/get-shit-done/workflows/update.md index a28575f71..f008da045 100644 --- a/get-shit-done/workflows/update.md +++ b/get-shit-done/workflows/update.md @@ -361,6 +361,88 @@ Use AskUserQuestion: **If user cancels:** Exit. + +Before running the installer, detect and back up any user-added files inside +GSD-managed directories. These are files that exist on disk but are NOT listed +in `gsd-file-manifest.json` — i.e., files the user added themselves that the +installer does not know about and will delete during the wipe. + +**Do not use bash path-stripping (`${filepath#$RUNTIME_DIR/}`) or `node -e require()` +inline** — those patterns fail when `$RUNTIME_DIR` is unset and the stripped +relative path may not match manifest key format, which causes CUSTOM_COUNT=0 +even when custom files exist (bug #1997). Use `gsd-tools detect-custom-files` +instead, which resolves paths reliably with Node.js `path.relative()`. + +First, resolve the config directory (`RUNTIME_DIR`) from the install scope +detected in `get_installed_version`: + +```bash +# RUNTIME_DIR is the resolved config directory (e.g. ~/.claude, ~/.config/opencode) +# It should already be set from get_installed_version as GLOBAL_DIR or LOCAL_DIR. +# Use the appropriate variable based on INSTALL_SCOPE. +if [ "$INSTALL_SCOPE" = "LOCAL" ]; then + RUNTIME_DIR="$LOCAL_DIR" +elif [ "$INSTALL_SCOPE" = "GLOBAL" ]; then + RUNTIME_DIR="$GLOBAL_DIR" +else + RUNTIME_DIR="" +fi +``` + +If `RUNTIME_DIR` is empty or does not exist, skip this step (no config dir to +inspect). + +Otherwise, resolve the path to `gsd-tools.cjs` and run: + +```bash +GSD_TOOLS="$RUNTIME_DIR/get-shit-done/bin/gsd-tools.cjs" +if [ -f "$GSD_TOOLS" ] && [ -n "$RUNTIME_DIR" ]; then + CUSTOM_JSON=$(node "$GSD_TOOLS" detect-custom-files --config-dir "$RUNTIME_DIR" 2>/dev/null) + CUSTOM_COUNT=$(echo "$CUSTOM_JSON" | node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{console.log(JSON.parse(d).custom_count);}catch{console.log(0);}})" 2>/dev/null || echo "0") +else + CUSTOM_COUNT=0 + CUSTOM_JSON='{"custom_files":[],"custom_count":0}' +fi +``` + +**If `CUSTOM_COUNT` > 0:** + +Back up each custom file to `$RUNTIME_DIR/gsd-user-files-backup/` before the +installer wipes the directories: + +```bash +BACKUP_DIR="$RUNTIME_DIR/gsd-user-files-backup" +mkdir -p "$BACKUP_DIR" + +# Parse custom_files array from CUSTOM_JSON and copy each file +node - "$RUNTIME_DIR" "$BACKUP_DIR" "$CUSTOM_JSON" <<'JSEOF' +const [,, runtimeDir, backupDir, customJson] = process.argv; +const { custom_files } = JSON.parse(customJson); +const fs = require('fs'); +const path = require('path'); +for (const relPath of custom_files) { + const src = path.join(runtimeDir, relPath); + const dst = path.join(backupDir, relPath); + if (fs.existsSync(src)) { + fs.mkdirSync(path.dirname(dst), { recursive: true }); + fs.copyFileSync(src, dst); + console.log(' Backed up: ' + relPath); + } +} +JSEOF +``` + +Then inform the user: + +``` +⚠️ Found N custom file(s) inside GSD-managed directories. + These have been backed up to gsd-user-files-backup/ before the update. + Restore them after the update if needed. +``` + +**If `CUSTOM_COUNT` == 0:** No user-added files detected. Continue to install. + + Run the update using the install type detected in step 1: diff --git a/tests/update-custom-backup.test.cjs b/tests/update-custom-backup.test.cjs new file mode 100644 index 000000000..065ef6627 --- /dev/null +++ b/tests/update-custom-backup.test.cjs @@ -0,0 +1,228 @@ +/** + * GSD Tools Tests — update workflow custom file backup detection (#1997) + * + * The update workflow must detect user-added files inside GSD-managed + * directories (get-shit-done/, agents/, commands/gsd/, hooks/) before the + * installer wipes those directories. + * + * This tests the `detect-custom-files` subcommand of gsd-tools.cjs, which is + * the correct fix for the bash path-stripping failure described in #1997. + * + * The bash pattern `${filepath#$RUNTIME_DIR/}` is unreliable because + * $RUNTIME_DIR may not be set and the stripped relative path may not match + * manifest key format. Moving the logic into gsd-tools.cjs eliminates the + * shell variable expansion failure entirely. + * + * Closes: #1997 + */ + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { runGsdTools, createTempDir, cleanup } = require('./helpers.cjs'); + +function sha256(content) { + return crypto.createHash('sha256').update(content).digest('hex'); +} + +/** + * Write a fake gsd-file-manifest.json into configDir with the given file entries. + */ +function writeManifest(configDir, files) { + const manifest = { + version: '1.32.0', + timestamp: new Date().toISOString(), + files: {} + }; + for (const [relPath, content] of Object.entries(files)) { + const fullPath = path.join(configDir, relPath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content); + manifest.files[relPath] = sha256(content); + } + fs.writeFileSync( + path.join(configDir, 'gsd-file-manifest.json'), + JSON.stringify(manifest, null, 2) + ); +} + +describe('detect-custom-files — update workflow backup detection (#1997)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-custom-detect-'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('detects a custom file added inside get-shit-done/workflows/', () => { + writeManifest(tmpDir, { + 'get-shit-done/workflows/execute-phase.md': '# Execute Phase\n', + 'get-shit-done/workflows/plan-phase.md': '# Plan Phase\n', + }); + + // Add a custom file NOT in the manifest + const customFile = path.join(tmpDir, 'get-shit-done/workflows/my-custom-workflow.md'); + fs.writeFileSync(customFile, '# My Custom Workflow\n'); + + const result = runGsdTools( + ['detect-custom-files', '--config-dir', tmpDir], + tmpDir + ); + + assert.ok(result.success, `Command failed: ${result.error}`); + + const json = JSON.parse(result.output); + assert.ok(Array.isArray(json.custom_files), 'should return custom_files array'); + assert.ok(json.custom_files.length > 0, 'should detect at least one custom file'); + assert.ok( + json.custom_files.includes('get-shit-done/workflows/my-custom-workflow.md'), + `custom file should be listed; got: ${JSON.stringify(json.custom_files)}` + ); + }); + + test('detects custom files added inside agents/', () => { + writeManifest(tmpDir, { + 'agents/gsd-executor.md': '# GSD Executor\n', + }); + + // Add a user's custom agent (not prefixed with gsd-) + const customAgent = path.join(tmpDir, 'agents/my-custom-agent.md'); + fs.mkdirSync(path.dirname(customAgent), { recursive: true }); + fs.writeFileSync(customAgent, '# My Custom Agent\n'); + + const result = runGsdTools( + ['detect-custom-files', '--config-dir', tmpDir], + tmpDir + ); + + assert.ok(result.success, `Command failed: ${result.error}`); + + const json = JSON.parse(result.output); + assert.ok(json.custom_files.includes('agents/my-custom-agent.md'), + `custom agent should be detected; got: ${JSON.stringify(json.custom_files)}`); + }); + + test('reports zero custom files when all files are in manifest', () => { + writeManifest(tmpDir, { + 'get-shit-done/workflows/execute-phase.md': '# Execute Phase\n', + 'get-shit-done/references/gates.md': '# Gates\n', + 'agents/gsd-executor.md': '# Executor\n', + }); + // No extra files added + + const result = runGsdTools( + ['detect-custom-files', '--config-dir', tmpDir], + tmpDir + ); + + assert.ok(result.success, `Command failed: ${result.error}`); + + const json = JSON.parse(result.output); + assert.ok(Array.isArray(json.custom_files), 'should return custom_files array'); + assert.strictEqual(json.custom_files.length, 0, 'no custom files should be detected'); + assert.strictEqual(json.custom_count, 0, 'custom_count should be 0'); + }); + + test('returns custom_count equal to custom_files length', () => { + writeManifest(tmpDir, { + 'get-shit-done/workflows/execute-phase.md': '# Execute Phase\n', + }); + + // Add two custom files + fs.writeFileSync( + path.join(tmpDir, 'get-shit-done/workflows/custom-a.md'), + '# Custom A\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'get-shit-done/workflows/custom-b.md'), + '# Custom B\n' + ); + + const result = runGsdTools( + ['detect-custom-files', '--config-dir', tmpDir], + tmpDir + ); + + assert.ok(result.success, `Command failed: ${result.error}`); + + const json = JSON.parse(result.output); + assert.strictEqual(json.custom_count, json.custom_files.length, + 'custom_count should equal custom_files.length'); + assert.strictEqual(json.custom_count, 2, 'should detect exactly 2 custom files'); + }); + + test('does not flag manifest files as custom even if content was modified', () => { + writeManifest(tmpDir, { + 'get-shit-done/workflows/execute-phase.md': '# Execute Phase\nOriginal\n', + }); + + // Modify the content of an existing manifest file + fs.writeFileSync( + path.join(tmpDir, 'get-shit-done/workflows/execute-phase.md'), + '# Execute Phase\nModified by user\n' + ); + + const result = runGsdTools( + ['detect-custom-files', '--config-dir', tmpDir], + tmpDir + ); + + assert.ok(result.success, `Command failed: ${result.error}`); + + const json = JSON.parse(result.output); + // Modified manifest files are handled by saveLocalPatches (in install.js). + // detect-custom-files only finds files NOT in the manifest at all. + assert.ok( + !json.custom_files.includes('get-shit-done/workflows/execute-phase.md'), + 'modified manifest files should NOT be listed as custom (that is saveLocalPatches territory)' + ); + }); + + test('handles missing manifest gracefully — treats all GSD-dir files as custom', () => { + // No manifest. Add a file in a GSD-managed dir. + const workflowDir = path.join(tmpDir, 'get-shit-done/workflows'); + fs.mkdirSync(workflowDir, { recursive: true }); + fs.writeFileSync(path.join(workflowDir, 'my-workflow.md'), '# My Workflow\n'); + + const result = runGsdTools( + ['detect-custom-files', '--config-dir', tmpDir], + tmpDir + ); + + assert.ok(result.success, `Command failed: ${result.error}`); + + const json = JSON.parse(result.output); + // Without a manifest, we cannot determine what is custom vs GSD-owned. + // The command should return an empty list (no manifest = skip detection, + // which is safe since saveLocalPatches also does nothing without a manifest). + assert.ok(Array.isArray(json.custom_files), 'should return custom_files array'); + assert.ok(typeof json.custom_count === 'number', 'should return numeric custom_count'); + }); + + test('detects custom files inside get-shit-done/references/', () => { + writeManifest(tmpDir, { + 'get-shit-done/references/gates.md': '# Gates\n', + }); + + const customRef = path.join(tmpDir, 'get-shit-done/references/my-domain-probes.md'); + fs.writeFileSync(customRef, '# My Domain Probes\n'); + + const result = runGsdTools( + ['detect-custom-files', '--config-dir', tmpDir], + tmpDir + ); + + assert.ok(result.success, `Command failed: ${result.error}`); + + const json = JSON.parse(result.output); + assert.ok( + json.custom_files.includes('get-shit-done/references/my-domain-probes.md'), + `should detect custom reference; got: ${JSON.stringify(json.custom_files)}` + ); + }); +});