diff --git a/.changeset/plucky-lemurs-dance.md b/.changeset/plucky-lemurs-dance.md new file mode 100644 index 000000000..0647ed29d --- /dev/null +++ b/.changeset/plucky-lemurs-dance.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 3447 +--- +**Installer PATH guidance now routes through the Shell Command Projection Module** — PATH repair/setup commands are projected from typed shell action IR (PowerShell/cmd/Git Bash/POSIX) to keep escaping and path-style policy consistent from one seam. diff --git a/bin/install.js b/bin/install.js index 69f56fb9c..84f213a5b 100755 --- a/bin/install.js +++ b/bin/install.js @@ -12,6 +12,7 @@ const { projectLocalHookPrefix, projectLegacySettingsHookCommand, projectManagedHookCommand, + projectPathActionProjection, projectPortableHookBaseDir, projectShellCommandText, projectCodexHookTomlCommand, @@ -9785,8 +9786,15 @@ function maybeSuggestPathExport(globalBin, homeDir) { console.log(''); console.log(` ${yellow}⚠${reset} ${bold}${globalBin}${reset} is not on your PATH.`); console.log(` Add it with one of:`); - console.log(` ${cyan}echo 'export PATH="${globalBin}:$PATH"' >> ~/.zshrc${reset}`); - console.log(` ${cyan}echo 'export PATH="${globalBin}:$PATH"' >> ~/.bashrc${reset}`); + const projected = projectPathActionProjection({ + mode: 'persist', + targetDir: globalBin, + platform: process.platform, + }); + for (const action of projected.shellActions) { + const label = action.label ? `${action.label}: ` : ''; + console.log(` ${cyan}${label}${action.command}${reset}`); + } console.log(''); } diff --git a/get-shit-done/bin/lib/shell-command-projection.cjs b/get-shit-done/bin/lib/shell-command-projection.cjs index 9696a7dc4..5f37be37b 100644 --- a/get-shit-done/bin/lib/shell-command-projection.cjs +++ b/get-shit-done/bin/lib/shell-command-projection.cjs @@ -151,6 +151,86 @@ function projectCodexHookTomlCommand({ absoluteRunner, scriptPath, platform = pr return command === null ? null : escapeTomlDoubleQuotedString(command); } +function escapePowerShellSingleQuoted(value) { + return String(value).replace(/'/g, "''"); +} + +function escapePosixDoubleQuoted(value) { + return String(value).replace(/[\\$"`]/g, '\\$&'); +} + +function escapeSingleQuotedShellLiteral(value) { + return String(value).replace(/'/g, "'\\''"); +} + +function renderShellActionLines(shellActions = []) { + return shellActions.map((action) => { + if (!action || !action.command) return ''; + return action.label ? `${action.label}: ${action.command}` : action.command; + }).filter(Boolean); +} + +function projectPathActionProjection({ + mode = 'repair', + targetDir, + platform = process.platform, +}) { + if (!targetDir) return { shellActions: [], actionLines: [] }; + + const isWin32 = platform === 'win32'; + + let shellActions; + if (isWin32) { + const psTargetDir = escapePowerShellSingleQuoted(targetDir); + const bashTargetDir = escapeSingleQuotedShellLiteral(String(targetDir).replace(/\\/g, '/')); + shellActions = [ + { + label: 'PowerShell', + shell: 'powershell', + command: `[Environment]::SetEnvironmentVariable('PATH', '${psTargetDir};' + [Environment]::GetEnvironmentVariable('PATH', 'User'), 'User')`, + }, + { + label: 'cmd.exe', + shell: 'cmd', + command: `powershell -Command "[Environment]::SetEnvironmentVariable('PATH', '${psTargetDir};' + [Environment]::GetEnvironmentVariable('PATH', 'User'), 'User')"`, + }, + { + label: 'Git Bash', + shell: 'bash', + command: `echo 'export PATH="${bashTargetDir}:$PATH"' >> ~/.bashrc`, + }, + ]; + } else if (mode === 'persist') { + const bashTargetDir = escapeSingleQuotedShellLiteral(String(targetDir)); + shellActions = [ + { + label: 'zsh', + shell: 'zsh', + command: `echo 'export PATH="${bashTargetDir}:$PATH"' >> ~/.zshrc`, + }, + { + label: 'bash', + shell: 'bash', + command: `echo 'export PATH="${bashTargetDir}:$PATH"' >> ~/.bashrc`, + }, + ]; + } else { + const posixTargetDir = escapePosixDoubleQuoted(targetDir); + shellActions = [ + { + label: null, + shell: 'posix', + command: `export PATH="${posixTargetDir}:$PATH"`, + }, + ]; + } + + return { + shellActions, + actionLines: renderShellActionLines(shellActions), + }; +} + function buildWindowsShimTriple(shimSrc) { const path = require('path'); const shimAbs = path.resolve(shimSrc); @@ -179,18 +259,16 @@ function formatSdkPathDiagnostic({ shimDir, platform, runDir }) { (runDir.includes('/_npx/') || runDir.includes('\\_npx\\')); const shimLocationLine = shimDir ? `Shim written to: ${shimDir}` : ''; const actionLines = []; + let shellActions = []; if (shimDir) { - const psShimDir = shimDir.replace(/'/g, "''"); - const bashShimDir = shimDir.replace(/\\/g, '/').replace(/'/g, "'\\''"); - const posixShimDir = shimDir.replace(/[\\$"`]/g, '\\$&'); + const projected = projectPathActionProjection({ + mode: 'repair', + targetDir: shimDir, + platform, + }); + shellActions = projected.shellActions; actionLines.push('Add that directory to your PATH and restart your shell.'); - if (isWin32) { - actionLines.push(`PowerShell: [Environment]::SetEnvironmentVariable('PATH', '${psShimDir};' + [Environment]::GetEnvironmentVariable('PATH', 'User'), 'User')`); - actionLines.push(`cmd.exe : powershell -Command "[Environment]::SetEnvironmentVariable('PATH', '${psShimDir};' + [Environment]::GetEnvironmentVariable('PATH', 'User'), 'User')"`); - actionLines.push(`Git Bash : echo 'export PATH="${bashShimDir}:$PATH"' >> ~/.bashrc`); - } else { - actionLines.push(`export PATH="${posixShimDir}:$PATH"`); - } + actionLines.push(...projected.actionLines); } else { actionLines.push('Could not locate a writable PATH directory to install the shim.'); actionLines.push('Install globally to materialize the bin symlink:'); @@ -202,7 +280,7 @@ function formatSdkPathDiagnostic({ shimDir, platform, runDir }) { 'install globally instead: npm install -g get-shit-done-cc', ] : []; - return { shimLocationLine, actionLines, npxNoteLines, isNpx, isWin32 }; + return { shimLocationLine, actionLines, shellActions, npxNoteLines, isNpx, isWin32 }; } module.exports = { @@ -217,6 +295,8 @@ module.exports = { projectLegacySettingsHookCommand, escapeTomlDoubleQuotedString, projectCodexHookTomlCommand, + projectPathActionProjection, + renderShellActionLines, buildWindowsShimTriple, formatSdkPathDiagnostic, }; diff --git a/tests/bug-3441-path-action-projection.test.cjs b/tests/bug-3441-path-action-projection.test.cjs new file mode 100644 index 000000000..8ed8dbeaf --- /dev/null +++ b/tests/bug-3441-path-action-projection.test.cjs @@ -0,0 +1,126 @@ +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const fs = require('node:fs'); +const os = require('node:os'); + +const projection = require(path.join( + __dirname, + '..', + 'get-shit-done', + 'bin', + 'lib', + 'shell-command-projection.cjs', +)); +const install = require(path.join(__dirname, '..', 'bin', 'install.js')); + +function createTempHome() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-home-3441-')); +} + +function cleanup(dir) { + fs.rmSync(dir, { recursive: true, force: true }); +} + +describe('bug #3441: PATH guidance is projected from typed shell action IR', () => { + test('projection module exports PATH action projection helper', () => { + assert.equal(typeof projection.projectPathActionProjection, 'function'); + }); + + test('formatSdkPathDiagnostic exposes structured shellActions alongside rendered actionLines', () => { + const ir = install.formatSdkPathDiagnostic({ + shimDir: 'C:\\Users\\me\\AppData\\Roaming\\npm', + platform: 'win32', + runDir: 'C:\\some\\path', + }); + + assert.ok(Array.isArray(ir.shellActions), 'shellActions must be an array'); + assert.ok(ir.shellActions.length >= 3, `expected 3+ shell actions, got ${ir.shellActions.length}`); + assert.equal(ir.shellActions[0].label, 'PowerShell'); + assert.equal(typeof ir.shellActions[0].command, 'string'); + assert.equal( + ir.actionLines.some((line) => line.startsWith('PowerShell:')), + true, + `rendered action lines should include shell labels: ${JSON.stringify(ir.actionLines)}`, + ); + }); + + test('persistent PATH export guidance is projected via the same seam', () => { + const posix = projection.projectPathActionProjection({ + mode: 'persist', + targetDir: '/tmp/with quote', + platform: 'linux', + }); + assert.ok(Array.isArray(posix.shellActions)); + assert.equal(posix.shellActions.length, 2); + assert.equal(posix.shellActions[0].label, 'zsh'); + assert.equal(posix.shellActions[1].label, 'bash'); + assert.ok(posix.shellActions[0].command.includes('~/.zshrc')); + assert.ok(posix.shellActions[1].command.includes('~/.bashrc')); + }); + + test('POSIX repair mode escapes double-quoted shell metacharacters', () => { + const projected = projection.projectPathActionProjection({ + mode: 'repair', + targetDir: '/tmp/qa\\"$HOME`tick', + platform: 'linux', + }); + assert.equal(projected.shellActions.length, 1); + assert.equal( + projected.shellActions[0].command, + 'export PATH="/tmp/qa\\\\\\"\\$HOME\\`tick:$PATH"', + ); + }); + + test('POSIX persist mode escapes single quotes for rc-file echo commands', () => { + const projected = projection.projectPathActionProjection({ + mode: 'persist', + targetDir: "/tmp/O'Neil/bin", + platform: 'linux', + }); + assert.equal(projected.shellActions[0].command.includes("/tmp/O'\\''Neil/bin"), true); + assert.equal(projected.shellActions[1].command.includes("/tmp/O'\\''Neil/bin"), true); + }); + + test('maybeSuggestPathExport renders commands projected by path-action seam', () => { + const home = createTempHome(); + const originalPath = process.env.PATH; + try { + const globalBin = path.join(home, '.npm-global', 'bin'); + fs.mkdirSync(globalBin, { recursive: true }); + fs.writeFileSync(path.join(home, '.zshrc'), 'export PATH="$HOME/.cargo/bin:$PATH"\n'); + process.env.PATH = ''; + + const expected = projection.projectPathActionProjection({ + mode: 'persist', + targetDir: globalBin, + platform: process.platform, + }); + + const logs = []; + const originalLog = console.log; + console.log = (...args) => logs.push(args.join(' ')); + try { + install.maybeSuggestPathExport(globalBin, home); + } finally { + console.log = originalLog; + } + + const joined = logs.join('\n'); + for (const action of expected.shellActions) { + assert.ok( + joined.includes(action.command), + `expected installer output to include projected command: ${action.command}\nOutput:\n${joined}`, + ); + } + } finally { + if (originalPath == null) delete process.env.PATH; + else process.env.PATH = originalPath; + cleanup(home); + } + }); +});