From 8c1b224474ebe06ca685e32b788618b4629f1231 Mon Sep 17 00:00:00 2001 From: Matteo Michele Bianchini Date: Wed, 18 Mar 2026 09:57:33 +0000 Subject: [PATCH] fix: use ~/ instead of resolved absolute paths in global install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, install.js resolves $HOME to the absolute path (e.g. C:/Users/matte/.claude/) in all workflow .md files. This breaks when ~/.claude is mounted into a Docker container where the path doesn't exist — Node interprets the Windows path as relative to CWD, producing paths like: /workspace/project/C:/Users/matte/.claude/get-shit-done/bin/gsd-tools.cjs For global installs, replace os.homedir() with ~ in pathPrefix so that paths like ~/.claude/get-shit-done/bin/gsd-tools.cjs work correctly across all environments. Local installs keep using resolved absolute paths since they may be outside $HOME. Co-Authored-By: Claude Opus 4.6 --- bin/install.js | 9 ++- tests/path-replacement.test.cjs | 100 ++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 tests/path-replacement.test.cjs diff --git a/bin/install.js b/bin/install.js index cff9bb5f8..c14fed0ec 100755 --- a/bin/install.js +++ b/bin/install.js @@ -2411,9 +2411,12 @@ 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/... - // Always use absolute path so: (1) local installs work when GSD is outside $HOME, - // (2) spawned subagents with empty $HOME still resolve the path (fixes #820). - const pathPrefix = `${path.resolve(targetDir).replace(/\\/g, '/')}/`; + // For global installs: use ~/ so paths work across environments (e.g. Docker + // containers mounting ~/.claude from a Windows host where os.homedir() differs). + // 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, '/')}/`; let runtimeLabel = 'Claude Code'; if (isOpencode) runtimeLabel = 'OpenCode'; diff --git a/tests/path-replacement.test.cjs b/tests/path-replacement.test.cjs new file mode 100644 index 000000000..db925cc47 --- /dev/null +++ b/tests/path-replacement.test.cjs @@ -0,0 +1,100 @@ +/** + * 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. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +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, '/') + '/'; +} + +describe('pathPrefix computation', () => { + test('default Claude global install uses ~/', () => { + const homedir = os.homedir(); + const targetDir = path.join(homedir, '.claude'); + const prefix = computePathPrefix(homedir, targetDir); + assert.strictEqual(prefix, '~/.claude/'); + }); + + test('default Gemini global install uses ~/', () => { + const homedir = os.homedir(); + const targetDir = path.join(homedir, '.gemini'); + const prefix = computePathPrefix(homedir, targetDir); + assert.strictEqual(prefix, '~/.gemini/'); + }); + + test('custom config dir under home uses ~/', () => { + 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.includes(homedir), `Should not contain homedir: ${homedir}`); + }); + + test('Windows-style paths produce ~/ 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). + 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/'); + assert.ok(!prefix.includes('C:'), `Should not contain drive letter, 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 + 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('source .md files exist', () => { + assert.ok(mdFiles.length > 0, `Expected .md files, found ${mdFiles.length}`); + }); + + 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 !== '~') { + failures.push(path.relative(repoRoot, file)); + } + } + assert.deepStrictEqual(failures, [], `Files with resolved absolute paths: ${failures.join(', ')}`); + }); +});