feat: deepen shell command projection seam (#3445)
* feat: deepen shell command projection seam * chore: add changeset for shell projection seam * feat: centralize local and portable hook path projection * docs: avoid prompt-scan false positive in installer migrations * docs(adr): fix path-style token punctuation
This commit is contained in:
174
bin/install.js
174
bin/install.js
@@ -6,8 +6,14 @@ const os = require('os');
|
||||
const readline = require('readline');
|
||||
const crypto = require('crypto');
|
||||
const {
|
||||
formatHookCommandForRuntime: formatHookCommandForShell,
|
||||
formatManagedHookScriptToken,
|
||||
buildWindowsShimTriple: buildWindowsShimTripleFromProjection,
|
||||
formatSdkPathDiagnostic: formatSdkPathDiagnosticFromProjection,
|
||||
isManagedHookBasename,
|
||||
projectLocalHookPrefix,
|
||||
projectLegacySettingsHookCommand,
|
||||
projectManagedHookCommand,
|
||||
projectPortableHookBaseDir,
|
||||
projectShellCommandText,
|
||||
projectCodexHookTomlCommand,
|
||||
} = require('../get-shit-done/bin/lib/shell-command-projection.cjs');
|
||||
|
||||
@@ -634,16 +640,6 @@ function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
|
||||
if (!settings || !settings.hooks || !absoluteRunner) return false;
|
||||
if (!opts) opts = {};
|
||||
const platform = opts.platform || process.platform;
|
||||
const MANAGED_HOOK_FILES = new Set([
|
||||
'gsd-check-update.js',
|
||||
'gsd-statusline.js',
|
||||
'gsd-context-monitor.js',
|
||||
'gsd-prompt-guard.js',
|
||||
'gsd-read-guard.js',
|
||||
'gsd-read-injection-scanner.js',
|
||||
'gsd-update-banner.js',
|
||||
'gsd-workflow-guard.js',
|
||||
]);
|
||||
let changed = false;
|
||||
for (const entries of Object.values(settings.hooks)) {
|
||||
if (!Array.isArray(entries)) continue;
|
||||
@@ -696,11 +692,16 @@ function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
|
||||
// Take the basename — match against MANAGED_HOOK_FILES by exact
|
||||
// equality, not substring containment. Handles both forward and
|
||||
// backslash separators (Windows).
|
||||
const scriptBase = scriptPath.split(/[\\/]/).pop() || '';
|
||||
if (!MANAGED_HOOK_FILES.has(scriptBase)) continue;
|
||||
if (!isManagedHookBasename(scriptPath, { surface: 'settings-json' })) continue;
|
||||
|
||||
const safeScriptToken = formatManagedHookScriptToken(scriptPath, opts) || scriptToken;
|
||||
const projectedCommand = formatHookCommandForShell(`${absoluteRunner} ${safeScriptToken}`, opts);
|
||||
const projectedCommand = projectLegacySettingsHookCommand({
|
||||
absoluteRunner,
|
||||
scriptPath,
|
||||
scriptToken,
|
||||
runtime: opts.runtime || 'generic',
|
||||
platform,
|
||||
});
|
||||
if (!projectedCommand) continue;
|
||||
|
||||
// Skip only when the existing managed command already matches the
|
||||
// desired runtime-aware projected shape. This preserves Gemini's
|
||||
@@ -716,15 +717,6 @@ function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex managed-hook filenames eligible for legacy-bare-node migration.
|
||||
* Mirrors the settings.json allowlist in rewriteLegacyManagedNodeHookCommands.
|
||||
* Centralized so the codex toml branch and the settings.json branch can't drift.
|
||||
*/
|
||||
const CODEX_MANAGED_HOOK_BASENAMES = new Set([
|
||||
'gsd-check-update.js',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Build the GSD-managed Codex SessionStart hook block for config.toml.
|
||||
*
|
||||
@@ -806,8 +798,7 @@ function rewriteLegacyCodexHookBlock(content, absoluteRunner, opts) {
|
||||
scriptPath = quoted[1];
|
||||
}
|
||||
}
|
||||
const base = scriptPath.split(/[\\/]/).pop() || '';
|
||||
if (!CODEX_MANAGED_HOOK_BASENAMES.has(base)) return full;
|
||||
if (!isManagedHookBasename(scriptPath, { surface: 'codex-toml' })) return full;
|
||||
const desiredCommand = projectCodexHookTomlCommand({
|
||||
absoluteRunner,
|
||||
scriptPath,
|
||||
@@ -853,19 +844,26 @@ function buildHookCommand(configDir, hookName, opts) {
|
||||
if (runner === null) return null;
|
||||
|
||||
if (opts.portableHooks) {
|
||||
// Replace the home directory prefix with $HOME so the path works when
|
||||
// ~/.claude is bind-mounted into a container at a different absolute path.
|
||||
const home = os.homedir().replace(/\\/g, '/');
|
||||
const normalized = configDir.replace(/\\/g, '/');
|
||||
const relative = normalized.startsWith(home)
|
||||
? '$HOME' + normalized.slice(home.length)
|
||||
: normalized;
|
||||
return formatHookCommandForShell(`${runner} "${relative}/hooks/${hookName}"`, opts);
|
||||
const portableBaseDir = projectPortableHookBaseDir({
|
||||
configDir,
|
||||
homeDir: os.homedir(),
|
||||
});
|
||||
return projectManagedHookCommand({
|
||||
absoluteRunner: runner,
|
||||
scriptPath: `${portableBaseDir}/hooks/${hookName}`,
|
||||
runtime: opts.runtime || 'generic',
|
||||
platform: opts.platform || process.platform,
|
||||
});
|
||||
}
|
||||
|
||||
// Default: absolute path with forward slashes (Windows-safe, fixes #2045/#2046).
|
||||
const hooksPath = configDir.replace(/\\/g, '/') + '/hooks/' + hookName;
|
||||
return formatHookCommandForShell(`${runner} "${hooksPath}"`, opts);
|
||||
return projectManagedHookCommand({
|
||||
absoluteRunner: runner,
|
||||
scriptPath: hooksPath,
|
||||
runtime: opts.runtime || 'generic',
|
||||
platform: opts.platform || process.platform,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -8851,21 +8849,33 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
||||
// Claude Code sets $CLAUDE_PROJECT_DIR; Gemini/Antigravity do not — and on
|
||||
// Windows their own substitution logic doubles the path (#2557). Those runtimes
|
||||
// run project hooks with the project dir as cwd, so bare relative paths work.
|
||||
const localPrefix = (runtime === 'gemini' || runtime === 'antigravity')
|
||||
? dirName
|
||||
: '"$CLAUDE_PROJECT_DIR"/' + dirName;
|
||||
const localPrefix = projectLocalHookPrefix({ runtime, dirName });
|
||||
const hookOpts = { portableHooks: hasPortableHooks, runtime };
|
||||
// #2979: local-install hook commands also use the absolute node path so
|
||||
// GUI/minimal-PATH runtimes can resolve them. Bare `node` fails when the
|
||||
// host launches the runtime with a stripped PATH (Finder/Antigravity/etc).
|
||||
const localNodeRunner = resolveNodeRunner();
|
||||
const localBashRunner = resolveBashRunner({ platform: process.platform });
|
||||
// If we cannot resolve an absolute node path AND this is a local install,
|
||||
// skip managed-hook registration. Returning null from buildHookCommand on
|
||||
// global installs has the same effect. Better to skip than to emit a bare
|
||||
// `node` command that recreates the #2979 failure.
|
||||
const localCmd = (hookFile) => localNodeRunner === null
|
||||
? null
|
||||
: formatHookCommandForShell(localNodeRunner + ' ' + localPrefix + '/hooks/' + hookFile, hookOpts);
|
||||
: projectShellCommandText({
|
||||
runnerToken: localNodeRunner,
|
||||
argTokens: [`${localPrefix}/hooks/${hookFile}`],
|
||||
runtime,
|
||||
platform: process.platform,
|
||||
});
|
||||
const localShellCmd = (hookFile) => localBashRunner === null
|
||||
? null
|
||||
: projectShellCommandText({
|
||||
runnerToken: localBashRunner,
|
||||
argTokens: [`${localPrefix}/hooks/${hookFile}`],
|
||||
runtime,
|
||||
platform: process.platform,
|
||||
});
|
||||
const statuslineCommand = isGlobal
|
||||
? buildHookCommand(targetDir, 'gsd-statusline.js', hookOpts)
|
||||
: localCmd('gsd-statusline.js');
|
||||
@@ -9100,7 +9110,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
||||
// Configure commit validation hook (Conventional Commits enforcement, opt-in)
|
||||
const validateCommitCommand = isGlobal
|
||||
? buildHookCommand(targetDir, 'gsd-validate-commit.sh', hookOpts)
|
||||
: 'bash ' + localPrefix + '/hooks/gsd-validate-commit.sh';
|
||||
: localShellCmd('gsd-validate-commit.sh');
|
||||
const hasValidateCommitHook = settings.hooks[preToolEvent].some(entry =>
|
||||
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-validate-commit'))
|
||||
);
|
||||
@@ -9129,7 +9139,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
||||
// Configure session state orientation hook (opt-in)
|
||||
const sessionStateCommand = isGlobal
|
||||
? buildHookCommand(targetDir, 'gsd-session-state.sh', hookOpts)
|
||||
: 'bash ' + localPrefix + '/hooks/gsd-session-state.sh';
|
||||
: localShellCmd('gsd-session-state.sh');
|
||||
const hasSessionStateHook = settings.hooks.SessionStart.some(entry =>
|
||||
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-session-state'))
|
||||
);
|
||||
@@ -9153,7 +9163,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
||||
// Configure phase boundary detection hook (opt-in)
|
||||
const phaseBoundaryCommand = isGlobal
|
||||
? buildHookCommand(targetDir, 'gsd-phase-boundary.sh', hookOpts)
|
||||
: 'bash ' + localPrefix + '/hooks/gsd-phase-boundary.sh';
|
||||
: localShellCmd('gsd-phase-boundary.sh');
|
||||
const hasPhaseBoundaryHook = settings.hooks[postToolEvent].some(entry =>
|
||||
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-phase-boundary'))
|
||||
);
|
||||
@@ -10496,32 +10506,7 @@ function trySelfLinkGsdSdk(shimSrc) {
|
||||
* over rendered shim text.
|
||||
*/
|
||||
function buildWindowsShimTriple(shimSrc) {
|
||||
const path = require('path');
|
||||
const shimAbs = path.resolve(shimSrc);
|
||||
// JSON.stringify produces a double-quoted string with backslash+quote
|
||||
// escaping — the safe quoting form for cmd.exe and PowerShell paths alike.
|
||||
const shimQuoted = JSON.stringify(shimAbs);
|
||||
|
||||
const invocation = {
|
||||
interpreter: 'node',
|
||||
target: shimAbs,
|
||||
};
|
||||
|
||||
// Renderers are template literals — the only place text is constructed.
|
||||
// Tests do not parse these strings; they assert on the typed fields above.
|
||||
const renderCmd = () =>
|
||||
'@ECHO OFF\r\n@SETLOCAL\r\n@node ' + shimQuoted + ' %*\r\n';
|
||||
const renderPs1 = () =>
|
||||
'#!/usr/bin/env pwsh\n& node ' + shimQuoted + ' $args\nexit $LASTEXITCODE\n';
|
||||
const renderSh = () =>
|
||||
'#!/usr/bin/env sh\nexec node ' + shimQuoted + ' "$@"\n';
|
||||
|
||||
return {
|
||||
invocation,
|
||||
eol: { cmd: '\r\n', ps1: '\n', sh: '\n' },
|
||||
fileNames: { cmd: 'gsd-sdk.cmd', ps1: 'gsd-sdk.ps1', sh: 'gsd-sdk' },
|
||||
render: { cmd: renderCmd, ps1: renderPs1, sh: renderSh },
|
||||
};
|
||||
return buildWindowsShimTripleFromProjection(shimSrc);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -10541,56 +10526,7 @@ function buildWindowsShimTriple(shimSrc) {
|
||||
* console output. Pure function — no fs, no spawn, no console.
|
||||
*/
|
||||
function formatSdkPathDiagnostic({ shimDir, platform, runDir }) {
|
||||
const path = require('path');
|
||||
const isWin32 = platform === 'win32';
|
||||
// Detect either path separator — the test fixtures pass Windows-style
|
||||
// paths while running on POSIX, and real users hit either depending on
|
||||
// their npm/npx setup. Anchor on `_npx` between separators.
|
||||
const isNpx = typeof runDir === 'string' &&
|
||||
(runDir.includes('/_npx/') || runDir.includes('\\_npx\\'));
|
||||
|
||||
const shimLocationLine = shimDir ? `Shim written to: ${shimDir}` : '';
|
||||
const actionLines = [];
|
||||
|
||||
if (shimDir) {
|
||||
// Escape shimDir for each shell context. A path containing a single
|
||||
// quote (e.g. C:\Users\O'Neil\AppData\...) would otherwise generate
|
||||
// broken commands the user can't paste:
|
||||
// - PowerShell single-quoted string: '' escapes a literal single quote
|
||||
// - bash inside outer single quotes: '\'' (close, escaped quote, reopen)
|
||||
// - POSIX export inside double quotes: escape \ $ " ` so the path is
|
||||
// copied verbatim and $PATH (which is OUTSIDE the escaped substring)
|
||||
// still expands at paste time.
|
||||
const psShimDir = shimDir.replace(/'/g, "''");
|
||||
const bashShimDir = shimDir.replace(/\\/g, '/').replace(/'/g, "'\\''");
|
||||
const posixShimDir = shimDir.replace(/[\\$"`]/g, '\\$&');
|
||||
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')`);
|
||||
// setx PATH "...;%PATH%" silently truncates above 1024 chars and
|
||||
// expands %PATH% / %SystemRoot% to literals (turning REG_EXPAND_SZ
|
||||
// into REG_SZ), permanently breaking lazy variable references.
|
||||
// Invoke PowerShell from cmd.exe with the same SetEnvironmentVariable
|
||||
// call as the PowerShell line so cmd.exe users get a safe command.
|
||||
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"`);
|
||||
}
|
||||
} else {
|
||||
actionLines.push('Could not locate a writable PATH directory to install the shim.');
|
||||
actionLines.push('Install globally to materialize the bin symlink:');
|
||||
actionLines.push('npm install -g get-shit-done-cc');
|
||||
}
|
||||
|
||||
const npxNoteLines = isNpx
|
||||
? [
|
||||
"Note: you're running via npx. For a persistent shim,",
|
||||
'install globally instead: npm install -g get-shit-done-cc',
|
||||
]
|
||||
: [];
|
||||
|
||||
return { shimLocationLine, actionLines, npxNoteLines, isNpx, isWin32 };
|
||||
return formatSdkPathDiagnosticFromProjection({ shimDir, platform, runDir });
|
||||
}
|
||||
|
||||
function trySelfLinkGsdSdkWindows(shimSrc) {
|
||||
|
||||
Reference in New Issue
Block a user