From f9cb02e005cf00318df30c1c34482571c9e4d84b Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 21 Mar 2026 09:22:28 -0400 Subject: [PATCH] fix: use $HOME instead of ~ in installed shell command paths (#1284) The installer pathPrefix for global installs replaced os.homedir() with ~, which does NOT expand inside double-quoted shell commands in POSIX shells. This caused MODULE_NOT_FOUND errors when executing commands like: node "~/.claude/get-shit-done/bin/gsd-tools.cjs" Changed pathPrefix to use $HOME instead of ~, which correctly expands inside double quotes. Also fixed a quoted-tilde instance in do.md. - bin/install.js: $HOME prefix instead of ~ for global installs - get-shit-done/workflows/do.md: node "$HOME/..." instead of "~/" - tests/path-replacement.test.cjs: updated + 3 new regression tests Closes #1284 Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/install.js | 12 ++-- get-shit-done/workflows/do.md | 2 +- tests/path-replacement.test.cjs | 116 +++++++++++++++++++++++++------- 3 files changed, 98 insertions(+), 32 deletions(-) diff --git a/bin/install.js b/bin/install.js index d4ded0b27..e93577b09 100755 --- a/bin/install.js +++ b/bin/install.js @@ -3668,12 +3668,14 @@ function install(isGlobal, runtime = 'claude') { // Path prefix for file references in markdown content (e.g. gsd-tools.cjs). // Replaces $HOME/.claude/ or ~/.claude/ so the result is get-shit-done/bin/... - // For global installs: use ~/ so paths work across environments (e.g. Docker - // containers mounting ~/.claude from a Windows host where os.homedir() differs). + // For global installs: use $HOME/ so paths expand correctly inside double-quoted + // shell commands (~ does NOT expand inside double quotes, causing MODULE_NOT_FOUND). // For local installs: use resolved absolute path (may be outside $HOME). - const pathPrefix = isGlobal - ? path.resolve(targetDir).replace(os.homedir(), '~').replace(/\\/g, '/') + '/' - : `${path.resolve(targetDir).replace(/\\/g, '/')}/`; + const resolvedTarget = path.resolve(targetDir).replace(/\\/g, '/'); + const homeDir = os.homedir().replace(/\\/g, '/'); + const pathPrefix = isGlobal && resolvedTarget.startsWith(homeDir) + ? '$HOME' + resolvedTarget.slice(homeDir.length) + '/' + : `${resolvedTarget}/`; let runtimeLabel = 'Claude Code'; if (isOpencode) runtimeLabel = 'OpenCode'; diff --git a/get-shit-done/workflows/do.md b/get-shit-done/workflows/do.md index 1e25d2ef1..8042fe309 100644 --- a/get-shit-done/workflows/do.md +++ b/get-shit-done/workflows/do.md @@ -24,7 +24,7 @@ Wait for response before continuing. **Check if project exists.** ```bash -INIT=$(node "~/.claude/get-shit-done/bin/gsd-tools.cjs" state load 2>/dev/null) +INIT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" state load 2>/dev/null) ``` Track whether `.planning/` exists — some routes require it, others don't. diff --git a/tests/path-replacement.test.cjs b/tests/path-replacement.test.cjs index db925cc47..614273f3b 100644 --- a/tests/path-replacement.test.cjs +++ b/tests/path-replacement.test.cjs @@ -1,10 +1,10 @@ /** * GSD Tests - path replacement in install.js * - * Verifies that global installs produce ~/ paths in .md files, - * never resolved absolute paths containing os.homedir(). - * Reproduces the bug where Windows installs write C:/Users/... - * paths that break in Docker containers. + * Verifies that global installs produce $HOME/ paths in .md files, + * so that shell commands expand correctly inside double quotes. + * ~ does NOT expand inside double quotes in POSIX shells, causing + * MODULE_NOT_FOUND errors (see #1284). */ const { test, describe } = require('node:test'); @@ -17,53 +17,79 @@ const repoRoot = path.join(__dirname, '..'); // Simulate the pathPrefix computation from install.js (global install) function computePathPrefix(homedir, targetDir) { - return path.resolve(targetDir).replace(homedir, '~').replace(/\\/g, '/') + '/'; + const resolvedTarget = path.resolve(targetDir).replace(/\\/g, '/'); + const homeDir = homedir.replace(/\\/g, '/'); + if (resolvedTarget.startsWith(homeDir)) { + return '$HOME' + resolvedTarget.slice(homeDir.length) + '/'; + } + return resolvedTarget + '/'; } describe('pathPrefix computation', () => { - test('default Claude global install uses ~/', () => { + test('default Claude global install uses $HOME/', () => { const homedir = os.homedir(); const targetDir = path.join(homedir, '.claude'); const prefix = computePathPrefix(homedir, targetDir); - assert.strictEqual(prefix, '~/.claude/'); + assert.strictEqual(prefix, '$HOME/.claude/'); }); - test('default Gemini global install uses ~/', () => { + test('default Gemini global install uses $HOME/', () => { const homedir = os.homedir(); const targetDir = path.join(homedir, '.gemini'); const prefix = computePathPrefix(homedir, targetDir); - assert.strictEqual(prefix, '~/.gemini/'); + assert.strictEqual(prefix, '$HOME/.gemini/'); }); - test('custom config dir under home uses ~/', () => { + test('custom config dir under home uses $HOME/', () => { const homedir = os.homedir(); const targetDir = path.join(homedir, '.config', 'claude'); const prefix = computePathPrefix(homedir, targetDir); - assert.ok(prefix.startsWith('~/'), `Expected ~/ prefix, got: ${prefix}`); + assert.ok(prefix.startsWith('$HOME/'), `Expected $HOME/ prefix, got: ${prefix}`); assert.ok(!prefix.includes(homedir), `Should not contain homedir: ${homedir}`); }); - test('Windows-style paths produce ~/ not C:/', () => { + test('Windows-style paths produce $HOME/ not C:/', () => { // On Windows, path.resolve returns the input unchanged when it's already absolute. - // Simulate the string operation directly (can't use path.resolve for Windows paths on Linux). + // Simulate the string operation directly (can't use path.resolve for Windows paths on macOS/Linux). const winHomedir = 'C:\\Users\\matte'; const winTargetDir = 'C:\\Users\\matte\\.claude'; - // This is what the fix does: targetDir.replace(homedir, '~').replace(/\\/g, '/') + '/' - const prefix = winTargetDir.replace(winHomedir, '~').replace(/\\/g, '/') + '/'; - assert.strictEqual(prefix, '~/.claude/'); + const resolvedTarget = winTargetDir.replace(/\\/g, '/'); + const homeDir = winHomedir.replace(/\\/g, '/'); + const prefix = resolvedTarget.startsWith(homeDir) + ? '$HOME' + resolvedTarget.slice(homeDir.length) + '/' + : resolvedTarget + '/'; + assert.strictEqual(prefix, '$HOME/.claude/'); assert.ok(!prefix.includes('C:'), `Should not contain drive letter, got: ${prefix}`); }); + + test('target outside home uses absolute path', () => { + const homedir = '/home/user'; + const targetDir = '/opt/gsd/.claude'; + // path.resolve won't change an already-absolute path on the same OS, + // so simulate the string operation directly + const resolvedTarget = targetDir.replace(/\\/g, '/'); + const homeDir = homedir.replace(/\\/g, '/'); + const prefix = resolvedTarget.startsWith(homeDir) + ? '$HOME' + resolvedTarget.slice(homeDir.length) + '/' + : resolvedTarget + '/'; + assert.strictEqual(prefix, '/opt/gsd/.claude/'); + assert.ok(!prefix.includes('$HOME'), `Should not contain $HOME for non-home paths`); + }); + + test('$HOME expands inside double-quoted shell commands', () => { + // This is the core regression test for #1284: + // ~ does NOT expand inside double quotes in POSIX shells, + // but $HOME does expand inside double quotes. + const homedir = os.homedir(); + const targetDir = path.join(homedir, '.claude'); + const prefix = computePathPrefix(homedir, targetDir); + // Verify the prefix uses $HOME, not ~ + assert.ok(!prefix.startsWith('~/'), `pathPrefix must not use ~ (breaks in double-quoted shell commands), got: ${prefix}`); + assert.ok(prefix.startsWith('$HOME/'), `pathPrefix must use $HOME for shell expansion, got: ${prefix}`); + }); }); -describe('installed .md files contain no resolved absolute paths', () => { - const homedir = os.homedir(); - const targetDir = path.join(homedir, '.claude'); - const pathPrefix = computePathPrefix(homedir, targetDir); - const claudeDirRegex = /~\/\.claude\//g; - const claudeHomeRegex = /\$HOME\/\.claude\//g; - const normalizedHomedir = homedir.replace(/\\/g, '/'); - - // Collect all .md files from source directories +describe('source .md files have no quoted-tilde shell patterns', () => { function collectMdFiles(dir) { const results = []; if (!fs.existsSync(dir)) return results; @@ -85,13 +111,51 @@ describe('installed .md files contain no resolved absolute paths', () => { assert.ok(mdFiles.length > 0, `Expected .md files, found ${mdFiles.length}`); }); + test('no .md file contains node "~/ pattern (quoted tilde breaks shell expansion)', () => { + const quotedTildePattern = /node\s+"~\//; + const failures = []; + for (const file of mdFiles) { + const content = fs.readFileSync(file, 'utf8'); + if (quotedTildePattern.test(content)) { + failures.push(path.relative(repoRoot, file)); + } + } + assert.deepStrictEqual(failures, [], `Files with quoted-tilde node paths: ${failures.join(', ')}`); + }); +}); + +describe('installed .md files contain no resolved absolute paths', () => { + const homedir = os.homedir(); + const targetDir = path.join(homedir, '.claude'); + const pathPrefix = computePathPrefix(homedir, targetDir); + const claudeDirRegex = /~\/\.claude\//g; + const claudeHomeRegex = /\$HOME\/\.claude\//g; + const normalizedHomedir = homedir.replace(/\\/g, '/'); + + function collectMdFiles(dir) { + 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(...collectMdFiles(fullPath)); + } else if (entry.name.endsWith('.md')) { + results.push(fullPath); + } + } + return results; + } + + const dirsToCheck = ['commands', 'get-shit-done', 'agents'].map(d => path.join(repoRoot, d)); + const mdFiles = dirsToCheck.flatMap(collectMdFiles); + test('after replacement, no .md file contains os.homedir()', () => { const failures = []; for (const file of mdFiles) { let content = fs.readFileSync(file, 'utf8'); content = content.replace(claudeDirRegex, pathPrefix); content = content.replace(claudeHomeRegex, pathPrefix); - if (content.includes(normalizedHomedir) && normalizedHomedir !== '~') { + if (content.includes(normalizedHomedir) && normalizedHomedir !== '$HOME') { failures.push(path.relative(repoRoot, file)); } }