diff --git a/.changeset/calm-tigers-click.md b/.changeset/calm-tigers-click.md new file mode 100644 index 000000000..8fa6c8e95 --- /dev/null +++ b/.changeset/calm-tigers-click.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 515 +--- +Remove dead SDK-shim verification subsystem (installSdkIfNeeded and 17 transitively-called functions) from bin/install.js. Post-ADR-0174 sdk/ no longer ships; the subsystem had no live callers and could produce misleading 'tarball is missing sdk/dist/' errors. detectStaleStandaloneSdk and formatStaleStandaloneSdkWarning are kept (handle real stale-global-SDK condition). diff --git a/bin/install.js b/bin/install.js index e33bb4c81..2eecb8e6f 100755 --- a/bin/install.js +++ b/bin/install.js @@ -10692,341 +10692,6 @@ function maybeSuggestPathExport(globalBin, homeDir) { console.log(''); } -/** - * Verify the prebuilt SDK dist is present and the gsd-sdk shim is wired up. - * - * As of fix/2441-sdk-decouple, sdk/dist/ is shipped prebuilt inside the - * @opengsd/get-shit-done-redux npm tarball. The parent package declares a bin entry - * "gsd-sdk": "bin/gsd-sdk.js" so npm chmods the shim correctly when - * installing from a packed tarball — eliminating the mode-644 failure - * (issue #2453) and the build-from-source failure modes (#2439, #2441). - * - * This function verifies the invariant: sdk/dist/cli.js exists and is - * executable. If the execute bit is missing (possible in dev/clone setups - * where sdk/dist was committed without +x), we fix it in-place. - * - * --no-sdk skips the check entirely (back-compat). - * --sdk forces the check even if it would otherwise be skipped. - */ -/** - * Classify the install context for the SDK directory. - * - * Distinguishes three shapes the installer must handle differently when - * `sdk/dist/` is missing: - * - * - `tarball` + `npxCache: true` - * User ran `npx @opengsd/get-shit-done-redux@latest`. sdk/ lives under - * `/_npx//node_modules/@opengsd/get-shit-done-redux/sdk` which - * is treated as read-only by npm/npx on Windows (#2649). We MUST - * NOT attempt a nested `npm install` there — it will fail with - * EACCES/EPERM and produce the misleading "Failed to npm install - * in sdk/" error the user reported. Point at the global upgrade. - * - * - `tarball` + `npxCache: false` - * User ran a global install (`npm i -g @opengsd/get-shit-done-redux`). sdk/dist - * ships in the published tarball; if it's missing, the published - * artifact itself is broken (see #2647). Same user-facing fix: - * upgrade to latest. - * - * - `dev-clone` - * Developer running from a git clone. Keep the existing "cd sdk && - * npm install && npm run build" hint — the user is expected to run - * that themselves. The installer itself never shells out to npm. - * - * Detection heuristics are path-based and side-effect-free: we look for - * `_npx` and `node_modules` segments that indicate a packaged install, - * and for a `.git` directory nearby that indicates a clone. A best-effort - * write probe detects read-only filesystems (tmpfile create + unlink); - * probe failures are treated as read-only. - */ -function classifySdkInstall(sdkDir) { - const path = require('path'); - const fs = require('fs'); - const segments = sdkDir.split(/[\\/]+/); - const npxCache = segments.includes('_npx'); - const inNodeModules = segments.includes('node_modules'); - const parent = path.dirname(sdkDir); - const hasGitNearby = fs.existsSync(path.join(parent, '.git')); - - let mode; - if (hasGitNearby && !npxCache && !inNodeModules) { - mode = 'dev-clone'; - } else if (npxCache || inNodeModules) { - mode = 'tarball'; - } else { - mode = 'dev-clone'; - } - - let readOnly = npxCache; // assume true for npx cache - if (!readOnly) { - try { - const probe = path.join(sdkDir, `.gsd-write-probe-${process.pid}`); - fs.writeFileSync(probe, ''); - fs.unlinkSync(probe); - } catch { - readOnly = true; - } - } - - return { mode, npxCache, readOnly }; -} - -/** - * #2974: pure builder for the SDK fail-fast report. Returns a structured IR - * with everything the renderer needs PLUS everything tests need to assert - * on. Tests can call `buildSdkFailFastReport(sdkDir, sdkCliPath)` directly - * and assert on `report.reason`, `report.context`, `report.fix_command` - * etc. without intercepting console.error or matching against rendered - * text. - * - * Shape (frozen contract — extending requires a new test): - * { - * ok: false, - * reason: 'sdk_fail_fast', // ERROR_REASON.SDK_FAIL_FAST - * context: 'npx-cache' | 'tarball' | 'dev-clone', - * missing_path: '/sdk/dist/cli.js', - * missing_artifact: 'sdk/dist', - * fix_command: 'npm install -g @opengsd/get-shit-done-redux@latest' | 'cd sdk && npm install && npm run build', - * attempted_nested_install: false, // contract: never true - * } - */ -function buildSdkFailFastReport(sdkDir, sdkCliPath) { - const ctx = classifySdkInstall(sdkDir); - let context, fix_command; - if (ctx.mode === 'tarball') { - context = ctx.npxCache ? 'npx-cache' : 'tarball'; - fix_command = 'npm install -g @opengsd/get-shit-done-redux@latest'; - } else { - context = 'dev-clone'; - fix_command = 'cd sdk && npm install && npm run build'; - } - return { - ok: false, - reason: 'sdk_fail_fast', - context, - missing_path: sdkCliPath, - missing_artifact: 'sdk/dist', - fix_command, - attempted_nested_install: false, - }; -} - -/** - * Renderer for the structured fail-fast report. Text formatting only — - * tests never call this. Splits the IR fields back into the same human- - * readable lines the previous shape produced. - */ -function renderSdkFailFastReport(ir) { - const bar = '━'.repeat(72); - const redBold = `${red}${bold}`; - console.error(''); - console.error(`${redBold}${bar}${reset}`); - console.error(`${redBold} ✗ GSD SDK dist not found — /gsd-* commands will not work${reset}`); - console.error(`${redBold}${bar}${reset}`); - console.error(` ${red}Reason:${reset} ${ir.missing_artifact}/cli.js not found at ${ir.missing_path}`); - console.error(''); - if (ir.context === 'npx-cache') { - console.error(` Detected read-only npx cache install (${dim}${path.dirname(ir.missing_path).replace(/\/dist$/, '')}${reset}).`); - console.error(` The installer will ${bold}not${reset} attempt \`npm install\` inside the npx cache.`); - console.error(''); - console.error(` Fix: install a version that ships sdk/dist/ globally:`); - console.error(` ${cyan}${ir.fix_command}${reset}`); - console.error(` Or, if you prefer a one-shot run, clear the npx cache first:`); - console.error(` ${cyan}npx --yes @opengsd/get-shit-done-redux@latest${reset}`); - console.error(` Or build from source (git clone):`); - console.error(` ${cyan}git clone https://github.com/open-gsd/get-shit-done-redux && cd get-shit-done/sdk && npm install && npm run build${reset}`); - } else if (ir.context === 'tarball') { - console.error(` The published tarball appears to be missing sdk/dist/ (see #2647).`); - console.error(''); - console.error(` Fix: install a version that ships sdk/dist/ globally:`); - console.error(` ${cyan}${ir.fix_command}${reset}`); - console.error(` Or build from source (git clone):`); - console.error(` ${cyan}git clone https://github.com/open-gsd/get-shit-done-redux && cd get-shit-done/sdk && npm install && npm run build${reset}`); - } else { - console.error(` Running from a git clone — build the SDK first:`); - console.error(` ${cyan}${ir.fix_command}${reset}`); - } - console.error(`${redBold}${bar}${reset}`); - console.error(''); -} - -function installSdkIfNeeded(opts) { - opts = opts || {}; - if (hasNoSdk && !opts.sdkDir) { - console.log(`\n ${dim}Skipping GSD SDK check (--no-sdk)${reset}`); - return; - } - - const path = require('path'); - const fs = require('fs'); - - const sdkDir = opts.sdkDir || path.resolve(__dirname, '..', 'sdk'); - const sdkCliPath = path.join(sdkDir, 'dist', 'cli.js'); - - // #2678 / #2829: local installs do not write to global node_modules, so we - // cannot fall through to the global-install error path. But the parent - // package (which carries bin/gsd-sdk.js and sdk/dist/cli.js) IS available - // wherever the installer is running from — npx cache, npm-global, or git - // clone. The shim resolves sdk/dist/cli.js relative to its own __dirname, - // so a self-link into a user-writable PATH dir makes `gsd-sdk` callable - // from local-mode installs too. Only when the dist is genuinely missing - // do we bail out with a non-fatal warning. - // - // #3033: --sdk (opts.forceSdk) overrides the local-install early-return — - // the user explicitly requested SDK deployment, so treat the missing-dist - // case like a global install (fail fast with an actionable diagnostic) - // instead of silently skipping. - if (opts.isLocal && !opts.forceSdk && !fs.existsSync(sdkCliPath)) { - console.warn(`\n ${yellow}⚠${reset} Skipping SDK check for local install — sdk/dist/cli.js not found at ${sdkCliPath}.`); - return; - } - - if (!fs.existsSync(sdkCliPath)) { - const ir = buildSdkFailFastReport(sdkDir, sdkCliPath); - renderSdkFailFastReport(ir); - if (opts.throwOnFailure) { - const error = new Error(`GSD SDK prebuilt artifact missing: ${sdkCliPath}`); - error.code = 'GSD_SDK_MISSING_DIST'; - error.exitCode = 1; - throw error; - } - process.exit(1); - } - - // Ensure execute bit is set. tsc emits files at 0o644; git clone preserves - // whatever mode was committed. Fix in-place so node-invoked paths work too. - try { - const stat = fs.statSync(sdkCliPath); - const isExecutable = !!(stat.mode & 0o111); - if (!isExecutable) { - fs.chmodSync(sdkCliPath, stat.mode | 0o111); - } - } catch { - // Non-fatal: if chmod fails (e.g. read-only fs) the shim still works via - // `node sdkCliPath` invocation in bin/gsd-sdk.js. - } - - // #2775: do not assert "GSD SDK ready" until `gsd-sdk` actually resolves on - // PATH. `npx @opengsd/get-shit-done-redux` only links the package's primary bin; the - // secondary `gsd-sdk` shim is left dangling under the npx cache and is NOT - // callable as a bare command. The previous file-presence-only check was a - // strictly weaker invariant than the one workflows depend on - // (`command -v gsd-sdk` resolving), and led to a false ✓ in npx-cache - // installs (issue #2775). - // - // #3231: strip transient npx-injected PATH segments before checking. The - // installer subprocess PATH includes `~/.npm/_npx//node_modules/.bin` - // which is ephemeral — it is NOT reachable from the user's interactive - // shell. A gsd-sdk found there must NOT count as "on PATH". - const shimSrc = path.resolve(__dirname, 'gsd-sdk.js'); - const persistentPath = filterNpxFromPath(process.env.PATH || ''); - let resolvedSdkPath = findGsdSdkOnPath(persistentPath); - let onPath = !!resolvedSdkPath; - - // Track WHERE we wrote the shim so the diagnostic can be specific even - // when isGsdSdkOnPath() returns false because the write target isn't on - // PATH (#3011: Windows users hit this when npm's global bin dir is - // populated but not on every shell's PATH — Git Bash vs PowerShell vs - // cmd.exe each read PATH from different sources). - let shimDir = null; - if (!onPath) { - // Try to materialize the shim into a user-writable PATH location so the - // installer can deliver on the success message without requiring the user - // to run `npm install -g` separately. Picks the first PATH entry that - // looks like a user-owned bin dir; falls back to ~/.local/bin even if - // it's not on PATH (then a follow-up suggestion is printed). - const linked = trySelfLinkGsdSdk(shimSrc); - if (linked) { - shimDir = path.dirname(linked); - resolvedSdkPath = findGsdSdkOnPath(persistentPath); - onPath = !!resolvedSdkPath; - if (onPath) { - console.log(` ${dim}↪ linked gsd-sdk → ${linked}${reset}`); - } - } - } - - // #3020: cross-shell PATH verification. Even when the install-time - // process.env.PATH walk found the shim, the user's later interactive - // shells may have a different PATH — Windows cross-shell .cmd/no-ext - // mismatch, POSIX ~/.local/bin missing from login shell, or node- - // version-manager PATH shims. Probe the user's login shell PATH and - // require the shim to be reachable there too before claiming ✓. - // - // #3211 (Windows): getUserShellWindowsPersistentPath() reads the user-level - // 'Path' registry key via PowerShell — the correct cross-shell source on - // Windows (Git Bash, PowerShell, and cmd.exe all inherit it). Returns null - // when PowerShell is unavailable or the probe times out. - // - // #3231: when getUserShellPath() / getUserShellWindowsPersistentPath() - // returns null (probe failed or unavailable), we cannot confirm persistent - // reachability. Since we already filtered npx dirs from persistentPath above, - // onPath=true means a non-transient dir has the shim — that is the best - // available invariant and is sufficient to claim ✓. - const userShellPath = process.platform === 'win32' - ? getUserShellWindowsPersistentPath() - : getUserShellPath(); - if (onPath && userShellPath !== null) { - // filterNpxFromPath is applied inside getUserShellWindowsPersistentPath - // (Windows) and here for the POSIX case. - const persistentUserShellPath = process.platform === 'win32' - ? userShellPath // already filtered by getUserShellWindowsPersistentPath - : filterNpxFromPath(userShellPath); - const userSdkPath = findGsdSdkOnPath(persistentUserShellPath); - if (!userSdkPath) { - onPath = false; - resolvedSdkPath = null; - } else { - resolvedSdkPath = userSdkPath; - } - } - // If userShellPath is null (probe failed or unavailable), onPath reflects - // the persistent-PATH check — that is the best available invariant. - - if (onPath) { - const versionReport = buildGsdSdkVersionMismatchReport(resolvedSdkPath, pkg.version, { isLocal: !!opts.isLocal }); - if (versionReport) { - renderGsdSdkVersionMismatchReport(versionReport); - } else { - console.log(` ${green}✓${reset} GSD SDK ready (sdk/dist/cli.js)`); - } - } else { - // #3011: actionable diagnostic. The previous shape printed a generic - // "not on your PATH" message that didn't tell the user where to look. - // formatSdkPathDiagnostic produces a typed IR that we then render to - // stdout; tests assert on the IR (no source-grep, no console capture). - const ir = formatSdkPathDiagnostic({ - shimDir, - platform: process.platform, - runDir: __dirname, - }); - console.log(''); - console.log(` ${yellow}⚠${reset} GSD SDK files are present but ${bold}gsd-sdk${reset} is not on your PATH.`); - console.log(` Workflows that call ${cyan}gsd-sdk query …${reset} will fail with "command not found".`); - if (ir.shimLocationLine) console.log(` ${ir.shimLocationLine}`); - for (const line of ir.actionLines) console.log(` ${line}`); - if (ir.npxNoteLines.length > 0) { - for (const line of ir.npxNoteLines) console.log(` ${line}`); - } - console.log(''); - } - - // #2620: warn if npm's global bin is not on PATH, suppressing the - // absolute-path suggestion when the user's rc already covers it via - // a HOME-relative entry (e.g. `export PATH="$HOME/.npm-global/bin:$PATH"`). - try { - const cp = require('child_process'); - const npmPrefix = cp.execSync('npm prefix -g', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); - if (npmPrefix) { - // On Windows npm prefix IS the bin dir; on POSIX it's `${prefix}/bin`. - const globalBin = process.platform === 'win32' ? npmPrefix : path.join(npmPrefix, 'bin'); - maybeSuggestPathExport(globalBin, os.homedir()); - } - } catch { - // npm not available / exec failed — silently skip the PATH advice. - } -} - /** * #3406 helper: detect a stale globally-installed `@opengsd/gsd-sdk` package * shadowing the `gsd-sdk` shim that `@opengsd/get-shit-done-redux` installs. @@ -11101,483 +10766,20 @@ function formatStaleStandaloneSdkWarning(info) { ].join('\n'); } -/** - * #3231 helper: detect whether a `gsd-sdk` binary is the legacy deprecated - * shim pointing at `gsd-tools.cjs`. - * - * Reads the first 512 bytes of the file and looks for the `@deprecated` - * marker alongside a `gsd-tools.cjs` reference — the fingerprint that - * distinguishes the old binary from the modern SDK. Treats any I/O error - * (missing file, EACCES) as "not legacy" so callers do not need to guard. - * - * This is intentionally a plain-text sniff of the file header, not a - * semantic parse — the marker is a stable, human-authored string that we - * own. Returns false conservatively (prefer false positives to false - * negatives: a non-legacy binary reported as legacy triggers a harmless - * replacement; a legacy binary reported as non-legacy would keep the broken - * shim in place). - */ -function isLegacyGsdSdkShim(filePath) { - const fs = require('fs'); - try { - const fd = fs.openSync(filePath, 'r'); - let header; - try { - const buf = Buffer.alloc(512); - const bytesRead = fs.readSync(fd, buf, 0, 512, 0); - header = buf.slice(0, bytesRead).toString('utf8'); - } finally { - try { fs.closeSync(fd); } catch {} - } - // The legacy binary contains "@deprecated" AND "gsd-tools.cjs" within - // its first 512 bytes. - return header.includes('@deprecated') && header.includes('gsd-tools.cjs'); - } catch { - return false; - } -} - -/** - * #3231 helper: strip transient npx-injected PATH segments. - * - * npm/npx injects `~/.npm/_npx//node_modules/.bin` (and equivalents) - * into the installer subprocess PATH. Those directories are ephemeral — they - * exist only for the duration of the `npx` run — and MUST NOT be treated as - * evidence that `gsd-sdk` is durably reachable. - * - * Strips any segment whose absolute form contains `/_npx/` or `\\_npx\\` - * as a proper path-component boundary. A user-named directory that merely - * contains the substring "npx" (e.g. `/home/user/my-npx-scripts/bin`) is - * preserved: we require the boundary characters (`/` or `\`) on both sides. - * - * Returns the filtered PATH string (may be empty if all segments were npx). - */ -function filterNpxFromPath(pathString) { - const path = require('path'); - const input = typeof pathString === 'string' ? pathString : (process.env.PATH || ''); - return input - .split(path.delimiter) - .filter((seg) => { - if (!seg) return false; - // Normalize to forward-slash form for the pattern check so both - // POSIX and Windows paths match a single expression. The sep-anchored - // pattern avoids matching "my-npx-scripts" etc. - const norm = seg.replace(/\\/g, '/'); - // Must have /_npx/ as a real path component, not just a substring. - return !norm.includes('/_npx/'); - }) - .join(path.delimiter); -} - -/** - * #2775 helper: find a callable `gsd-sdk` on a PATH. - * - * Pure PATH walk (no spawn) — we look for a regular file or symlink named - * `gsd-sdk` (or `gsd-sdk.cmd`/`.exe` on Windows) in any directory on PATH and - * verify it carries the execute bit on POSIX. Avoids paying spawn cost and - * avoids the chicken-and-egg of needing to run the not-yet-installed binary. - * - * #3020: accepts an optional explicit PATH string. The install subprocess's - * process.env.PATH is not the same set the user's later interactive shells - * see (Windows cross-shell, POSIX ~/.local/bin, node-version-manager - * shims). Callers can pass the user-shell PATH from getUserShellPath() to - * verify the shim is reachable from the runtime shell, not just the - * install context. Zero-arg form preserves existing behavior. - * - * #3231: a candidate that passes the file/exec check is further tested via - * isLegacyGsdSdkShim — a symlink pointing at the deprecated gsd-tools.cjs - * binary must NOT be treated as "on PATH" even if it is executable. - */ -function findGsdSdkOnPath(pathString) { - const path = require('path'); - const fs = require('fs'); - // Type-guard the explicit input (#3028 CR): callers may pass null - // (getUserShellPath() can return null), and `null.split()` throws. - // Only honor pathString when it's a string; fall back otherwise. - const pathEnv = typeof pathString === 'string' ? pathString : (process.env.PATH || ''); - const exts = process.platform === 'win32' ? ['.cmd', '.exe', '.bat', ''] : ['']; - for (const seg of pathEnv.split(path.delimiter)) { - if (!seg) continue; - for (const ext of exts) { - const candidate = path.join(seg, `gsd-sdk${ext}`); - try { - const st = fs.statSync(candidate); - if (st.isFile()) { - if (process.platform === 'win32') { - if (!isLegacyGsdSdkShim(candidate)) return candidate; - } else if ((st.mode & 0o111) !== 0) { - // #3231: resolve symlink before sniffing, so we detect legacy - // through any level of indirection. - let target = candidate; - try { target = fs.realpathSync(candidate); } catch {} - if (!isLegacyGsdSdkShim(target)) return candidate; - } - } - } catch { - // missing / EACCES on dir — keep scanning. - } - } - } - return null; -} - -function isGsdSdkOnPath(pathString) { - return !!findGsdSdkOnPath(pathString); -} - -function parseGsdSdkVersion(text) { - const match = String(text || '').match(/\bv?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/); - return match ? match[1] : null; -} - -function readGsdSdkVersion(sdkPath) { - if (!sdkPath) return null; - const cp = require('child_process'); - try { - const isWindowsCommandShim = process.platform === 'win32' && /\.(cmd|bat)$/i.test(String(sdkPath)); - const result = cp.spawnSync(isWindowsCommandShim ? 'cmd.exe' : sdkPath, isWindowsCommandShim ? ['/c', sdkPath, '--version'] : ['--version'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - timeout: 2000, - env: process.env, - }); - if (result.error || result.status !== 0) return null; - return parseGsdSdkVersion(`${result.stdout || ''}\n${result.stderr || ''}`); - } catch { - return null; - } -} - -function buildGsdSdkVersionMismatchReport(sdkPath, expectedVersion, opts) { - const actualVersion = readGsdSdkVersion(sdkPath); - if (!actualVersion || !expectedVersion) return null; - if (actualVersion === expectedVersion) return null; - // #3668: local installs should NOT be told to run `npm install -g` — - // that contradicts the intent of --local (self-contained, no global dep). - // For local installs the correct remediation is to re-run the local - // installer (e.g. `npx get-shit-done-cc@latest --claude --local`). - const isLocal = opts && opts.isLocal; - const fix_command = isLocal - ? 'npx @opengsd/get-shit-done-redux@latest --claude --local' - : 'npm install -g @opengsd/get-shit-done-redux@latest'; - return { - ok: false, - reason: 'gsd_sdk_version_mismatch', - sdk_path: sdkPath, - actual_version: actualVersion, - expected_version: expectedVersion, - fix_command, - is_local: !!isLocal, - }; -} - -function renderGsdSdkVersionMismatchReport(ir) { - console.log(''); - console.log(` ${yellow}⚠${reset} ${bold}gsd-sdk version mismatch${reset} — PATH resolves a stale SDK.`); - console.log(` Resolved gsd-sdk: ${ir.sdk_path}`); - console.log(` Resolved version: ${ir.actual_version}`); - console.log(` Installer version: ${ir.expected_version}`); - console.log(` Workflows that call ${cyan}gsd-sdk query …${reset} will use the stale executable first.`); - console.log(` Fix: ${cyan}${ir.fix_command}${reset}`); - console.log(` Or remove the stale global install / adjust PATH so the current shim is first.`); - console.log(''); -} - -/** - * #3020: probe the user's login shell to learn the PATH that will be - * visible at workflow runtime. - * - * The install subprocess inherits process.env.PATH from npm/npx, which - * may include directories the user's interactive shells do not (e.g. - * ~/.local/bin auto-injected by npm-prefix tooling, or nvm-shimmed - * paths). Asserting `gsd-sdk` is on the install-subprocess PATH is a - * weaker invariant than the runtime contract — workflows shell out via - * `bash -c "gsd-sdk …"`, and that bash inherits PATH from the user's - * login shell. - * - * Uses `$SHELL -lc 'printf %s "$PATH"'` on POSIX. Returns null on Windows - * (the Windows counterpart is getUserShellWindowsPersistentPath, which reads - * the user-level 'Path' registry key via PowerShell). Returns null - * when $SHELL is unset, when the spawn fails, or when the result is - * empty — callers must fall back to process.env.PATH in those cases. - * - * Synchronous so it can be called from the existing post-install check - * without restructuring the whole flow as async. - */ -function getUserShellPath() { - if (process.platform === 'win32') return null; - const shellEnv = typeof process.env.SHELL === 'string' ? process.env.SHELL : ''; - if (!shellEnv) return null; - const cp = require('child_process'); - try { - const out = cp.execFileSync(shellEnv, ['-lc', 'printf %s "$PATH"'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - // 2-second cap so a misconfigured rc file (e.g. interactive prompt) - // can't hang the install. The probe is best-effort — null on timeout - // is the safe fallback. - timeout: 2000, - }); - // #3028 CR: login startup scripts can print banners / motd / stale - // log lines BEFORE the printf, polluting stdout. Take the LAST - // non-empty line as the PATH candidate so noise doesn't flip the - // cross-shell check to false. PATH itself is single-line. - const lines = String(out || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean); - const candidate = lines.length > 0 ? lines[lines.length - 1] : ''; - return candidate.length > 0 ? candidate : null; - } catch { - return null; - } -} - -/** - * #3211: Windows counterpart to getUserShellPath(). Probes the effective - * persistent Path from the Windows registry via PowerShell by merging - * Machine-level + User-level entries: - * - * $m=[Environment]::GetEnvironmentVariable('Path','Machine') - * $u=[Environment]::GetEnvironmentVariable('Path','User') - * ($m + ';' + $u).Trim(';') - * - * This is the correct primitive for Windows cross-shell PATH verification — - * Git Bash, PowerShell, and cmd.exe all inherit the effective (Machine;User) - * registry Path, while the install-subprocess process.env.PATH is polluted - * with transient npx entries and may not include directories added by the - * user post-install. Reading only User-level Path would produce a false - * warning when gsd-sdk is in a machine-level bin dir (e.g. C:\Program Files\nodejs). - * - * Returns the filtered persistent Path string (npx segments stripped) or null - * on any failure (non-Windows, PowerShell not available, spawn timeout, empty - * result). Callers must treat null as "check unavailable — trust install-time - * filtered PATH". - * - * Synchronous, 2-second timeout, best-effort — safe to call from - * installSdkIfNeeded without restructuring to async. - */ -function getUserShellWindowsPersistentPath() { - if (process.platform !== 'win32') return null; - const cp = require('child_process'); - // Use the same execFileSync form as getUserShellPath() above — static - // literal args, no user input, no injection vector. - const execFile = cp.execFileSync.bind(cp); - try { - // Read Machine + User Path and merge them — the effective PATH that - // PowerShell, cmd.exe, and Git Bash inherit is Machine;User (machine - // entries first). Reading only User-level Path would produce a false - // warning when gsd-sdk is installed in a machine-level bin dir - // (e.g. C:\Program Files\nodejs). - const out = execFile( - 'powershell.exe', - [ - '-NoProfile', - '-Command', - "$u=[Environment]::GetEnvironmentVariable('Path','User');" + - "$m=[Environment]::GetEnvironmentVariable('Path','Machine');" + - "[Console]::Out.Write(($m + ';' + $u).Trim(';'))", - ], - { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - // 2-second cap — a locked registry or slow profile can't hang the install. - timeout: 2000, - }, - ); - // Take the last non-empty line so any motd/banner noise before the output - // doesn't corrupt the result — same defensive pattern as getUserShellPath. - const lines = String(out || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean); - const candidate = lines.length > 0 ? lines[lines.length - 1] : ''; - if (!candidate) return null; - // Strip transient npx dirs from the persistent Path before returning — - // the registry can accumulate stale _npx entries from prior runs. - const filtered = filterNpxFromPath(candidate); - return filtered.length > 0 ? filtered : null; - } catch { - return null; - } -} - -/** - * #2775 helper: attempt to materialize the `gsd-sdk` shim at a user-writable - * PATH location. Returns the absolute path created on success, or null if no - * suitable location was usable. - * - * Strategy (POSIX): prefer ~/.local/bin (creating it if absent — many distros - * already have it on PATH via .profile). Fall back to the first PATH entry - * under HOME we can write to. Skip on Windows (npm install -g is the right - * primitive there; we don't try to fabricate a .cmd shim). - */ -function trySelfLinkGsdSdk(shimSrc) { - if (process.platform === 'win32') { - return trySelfLinkGsdSdkWindows(shimSrc); - } - const path = require('path'); - const fs = require('fs'); - const home = os.homedir(); - if (!home) return null; - - const localBin = path.join(home, '.local', 'bin'); - const pathCandidates = []; - const pathEnv = process.env.PATH || ''; - for (const seg of pathEnv.split(path.delimiter)) { - if (!seg) continue; - const abs = path.resolve(seg); - if (abs.startsWith(home + path.sep) && !pathCandidates.includes(abs)) { - pathCandidates.push(abs); - } - } - // If ~/.local/bin is already on PATH, keep it first (preserves existing UX - // for the common case). Otherwise prefer PATH-backed HOME dirs first so we - // self-link somewhere actually on PATH, falling back to ~/.local/bin only - // when no on-PATH HOME dir is writable. (#2775 CodeRabbit follow-up) - const candidates = pathCandidates.includes(localBin) - ? [localBin, ...pathCandidates.filter((dir) => dir !== localBin)] - : [...pathCandidates, localBin]; - - for (const dir of candidates) { - try { - fs.mkdirSync(dir, { recursive: true }); - const target = path.join(dir, 'gsd-sdk'); - // Replace any existing entry — it may be stale (prior install of an - // older version pointing at a now-absent shim). - try { fs.unlinkSync(target); } catch {} - try { - fs.symlinkSync(shimSrc, target); - } catch { - // Filesystems that don't support symlinks (some FUSE mounts): write a - // tiny wrapper that `require()`s the real shim by absolute path. We - // cannot copyFileSync(shimSrc, target) — bin/gsd-sdk.js resolves the - // CLI via `path.resolve(__dirname, '..', 'sdk', 'dist', 'cli.js')`, - // and after a copy `__dirname` would be the link directory (e.g. - // ~/.local/bin), causing the resolved CLI path to be broken - // (~/.local/sdk/dist/cli.js). Wrapping via require() preserves - // __dirname resolution because the require runs against shimSrc's - // own location. (#2775 CodeRabbit follow-up) - fs.writeFileSync( - target, - `#!/usr/bin/env node\nrequire(${JSON.stringify(shimSrc)});\n`, - ); - try { fs.chmodSync(target, 0o755); } catch {} - } - return target; - } catch { - // permission / EROFS — try next candidate. - } - } - return null; -} - -/** - * #2962: Windows counterpart to trySelfLinkGsdSdk. Prior to this, the function - * unconditionally returned null on Windows ("we don't try to fabricate a .cmd - * shim there"), which left `--sdk --global` installs without a callable - * `gsd-sdk` on PATH despite the installer reporting success. - * - * Strategy: discover npm's global bin directory via `npm prefix -g` (which on - * Windows IS the bin dir, no `bin/` suffix — see line 8721) and write the same - * three-file shim set npm itself emits: `gsd-sdk.cmd` (cmd.exe), `gsd-sdk.ps1` - * (PowerShell), and a Bash wrapper named `gsd-sdk` (for Cygwin/MSYS/Git-Bash). - * Each shim invokes `node ""` with passed - * args so the shim location is decoupled from the SDK location — same logical - * structure as the POSIX wrapper-via-require() fallback above. - * - * Returns the .cmd file path on success (the primary handle the installer's - * onPath check looks for), null otherwise. - */ -/** - * Pure builder: compute the structured Windows shim triple from a shimSrc path. - * No filesystem I/O, no spawn — produces the IR that `trySelfLinkGsdSdkWindows` - * then renders to disk. Exposed for tests so assertions can run against typed - * fields (interpreter, shimAbs, eol, fileNames) instead of substring matches - * over rendered shim text. - */ +// Projection-contract surfaces (drift guards: tests/bug-3441, tests/bug-3442). +// These have no production caller after the #505 dead-SDK-verification removal, +// but they are the install.js side of the shell-command projection contract: +// the drift-guard tests assert install.js delegates to +// shell-command-projection.cjs rather than hand-rolling the projection. Kept +// exported so that contract stays verifiable. function buildWindowsShimTriple(shimSrc) { return buildWindowsShimTripleFromProjection(shimSrc); } -/** - * #3011: pure builder for the SDK-not-on-PATH diagnostic. Takes the - * resolved shim directory (or null if write failed), the current platform, - * and the install.js __dirname (used to detect npx-cache invocation). - * Returns a typed IR with: - * - shimLocationLine: prose mentioning where the shim is (or empty if no - * write happened) - * - actionLines: ordered list of commands the user can run to add the - * shim dir to their PATH (platform-specific shells), or fallback to - * `npm install -g` advice when no shim was written - * - npxNoteLines: ordered list of lines warning about npx persistence - * when runDir is under an `_npx` cache segment - * - * Tests assert on the typed fields (paths/commands), not on rendered - * console output. Pure function — no fs, no spawn, no console. - */ function formatSdkPathDiagnostic({ shimDir, platform, runDir }) { return formatSdkPathDiagnosticFromProjection({ shimDir, platform, runDir }); } -function trySelfLinkGsdSdkWindows(shimSrc) { - const path = require('path'); - const fs = require('fs'); - const cp = require('child_process'); - - let npmPrefix; - try { - // On Windows, `npm` is `npm.cmd` — Node's child_process docs explicitly - // call out that .cmd/.bat files cannot be spawned via execFile/execFileSync - // without a shell ("Spawning .bat and .cmd files on Windows" section). - // Match the existing convention at line ~8718 which uses execSync for the - // same `npm prefix -g` lookup. Inputs here are static literals, so shell - // interpolation is not an injection vector. - npmPrefix = cp - .execSync('npm prefix -g', { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }) - .trim(); - } catch { - return null; - } - if (!npmPrefix || !fs.existsSync(npmPrefix)) return null; - - // Verify writability before producing partial shim sets. - try { - fs.mkdirSync(npmPrefix, { recursive: true }); - const probe = path.join(npmPrefix, '.gsd-sdk-write-probe'); - fs.writeFileSync(probe, ''); - fs.unlinkSync(probe); - } catch { - return null; - } - - const triple = buildWindowsShimTriple(shimSrc); - const targets = { - cmd: path.join(npmPrefix, triple.fileNames.cmd), - ps1: path.join(npmPrefix, triple.fileNames.ps1), - sh: path.join(npmPrefix, triple.fileNames.sh), - }; - - try { - // Replace any existing shims — they may be stale (prior install of an - // older version pointing at a now-absent shim path). - for (const target of Object.values(targets)) { - try { fs.unlinkSync(target); } catch {} - } - fs.writeFileSync(targets.cmd, triple.render.cmd()); - fs.writeFileSync(targets.ps1, triple.render.ps1()); - fs.writeFileSync(targets.sh, triple.render.sh()); - // chmod is a no-op on Windows-native node but harmless; sets exec bit on - // WSL-mounted filesystems where Bash users live. - try { fs.chmodSync(targets.sh, 0o755); } catch {} - return targets.cmd; - } catch { - // Partial-write on permission flap — best-effort cleanup so the next run - // starts from a clean slate. - for (const target of Object.values(targets)) { - try { fs.unlinkSync(target); } catch {} - } - return null; - } -} - /** * Install GSD for all selected runtimes */ @@ -11729,15 +10931,10 @@ module.exports = { install, installAllRuntimes, uninstall, - installSdkIfNeeded, detectStaleStandaloneSdk, formatStaleStandaloneSdkWarning, - buildSdkFailFastReport, - renderSdkFailFastReport, - buildGsdSdkVersionMismatchReport, - renderGsdSdkVersionMismatchReport, - classifySdkInstall, - readGsdSdkVersion, + buildWindowsShimTriple, + formatSdkPathDiagnostic, convertClaudeCommandToCodexSkill, convertClaudeToOpencodeFrontmatter, convertClaudeToKiloFrontmatter, @@ -11788,15 +10985,6 @@ module.exports = { populatePristineDir, USER_OWNED_ARTIFACTS, finishInstall, - trySelfLinkGsdSdk, - trySelfLinkGsdSdkWindows, - buildWindowsShimTriple, - formatSdkPathDiagnostic, - filterNpxFromPath, - isLegacyGsdSdkShim, - isGsdSdkOnPath, - getUserShellPath, - getUserShellWindowsPersistentPath, homePathCoveredByRc, maybeSuggestPathExport, runtimeMap, diff --git a/tests/bug-3020-install-shell-path-probe.test.cjs b/tests/bug-3020-install-shell-path-probe.test.cjs deleted file mode 100644 index 96bf6229f..000000000 --- a/tests/bug-3020-install-shell-path-probe.test.cjs +++ /dev/null @@ -1,169 +0,0 @@ -/** - * Regression test for bug #3020. - * - * The installer prints `✓ GSD SDK ready (sdk/dist/cli.js)` whenever - * isGsdSdkOnPath() — which reads process.env.PATH from the install - * subprocess — finds the shim. That set is not the same as the user's - * later interactive shell PATH: - * - * - Windows cross-shell: gsd-sdk.cmd resolves under PowerShell/cmd - * (PATHEXT) but bare `gsd-sdk` does not resolve under Git Bash / - * MSYS / WSL bash. - * - POSIX ~/.local/bin: install subprocess inherits npm/npx-injected - * PATH containing ~/.local/bin; user's login shell may not. - * - Node version managers (nvm/fnm/volta) shim PATH per-shell. - * - * Result: green ✓ at install time, "command not found" at workflow - * runtime (#3011 originals + @x0rk + @stefanoginella). - * - * Fix: introduce two helpers and use them at install time. - * - * isGsdSdkOnPath(pathString?: string) - * - Now accepts an optional explicit PATH string. When omitted, - * falls back to process.env.PATH (preserves existing behavior). - * - Pure: no spawn, no I/O beyond fs.statSync on candidates. - * - * getUserShellPath() → string | null - * - Probes the user's login shell ($SHELL -lc 'printf %s "$PATH"') on - * POSIX so we can predict the runtime shell PATH. - * - Returns null on Windows or when the probe fails (caller falls - * back to process.env.PATH). - * - * Tests are typed-IR / structural — no console capture, no source grep. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); - -const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); -const { isGsdSdkOnPath, getUserShellPath } = INSTALL; - -describe('bug #3020: isGsdSdkOnPath accepts an explicit PATH string', () => { - test('exported as a function', () => { - assert.equal(typeof isGsdSdkOnPath, 'function'); - }); - - test('returns true when an executable gsd-sdk exists in the supplied PATH', () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3020-')); - try { - // Create a fake `gsd-sdk` shim with the executable bit set. - const shimName = process.platform === 'win32' ? 'gsd-sdk.cmd' : 'gsd-sdk'; - const shimPath = path.join(tmp, shimName); - fs.writeFileSync(shimPath, process.platform === 'win32' ? '@echo off\nexit 0\n' : '#!/bin/sh\nexit 0\n'); - if (process.platform !== 'win32') fs.chmodSync(shimPath, 0o755); - const result = isGsdSdkOnPath(tmp); - assert.equal(result, true, `expected true for PATH=${tmp}, got ${result}`); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); - - test('returns false when the supplied PATH has no gsd-sdk', () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3020-')); - try { - const result = isGsdSdkOnPath(tmp); - assert.equal(result, false); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); - - test('zero-arg form preserves existing behavior (reads process.env.PATH)', () => { - // Just call it — it shouldn't throw and should return a boolean. - const result = isGsdSdkOnPath(); - assert.equal(typeof result, 'boolean'); - }); - - test('treats an empty PATH string as no segments to scan', () => { - const result = isGsdSdkOnPath(''); - assert.equal(result, false); - }); - - test('null pathString is type-guarded — falls back to process.env.PATH (#3028 CR)', () => { - // Pre-fix: isGsdSdkOnPath(null) threw "Cannot read properties of null - // (reading 'split')". Post-fix: typeof check falls back to process.env.PATH. - let threw = null; - let result; - try { - result = isGsdSdkOnPath(null); - } catch (e) { - threw = e; - } - assert.equal(threw, null, `must not throw on null input, got: ${threw && threw.message}`); - assert.equal(typeof result, 'boolean', 'must return a boolean'); - }); - - test('non-string pathString (number, object) falls back to process.env.PATH (#3028 CR)', () => { - // Defensive: any non-string argument should fall back, not throw. - assert.equal(typeof isGsdSdkOnPath(0), 'boolean'); - assert.equal(typeof isGsdSdkOnPath({}), 'boolean'); - assert.equal(typeof isGsdSdkOnPath([]), 'boolean'); - }); -}); - -describe('bug #3020: getUserShellPath probes the user login shell PATH', () => { - test('exported as a function', () => { - assert.equal(typeof getUserShellPath, 'function'); - }); - - test('returns a string with at least one segment OR null', () => { - const result = getUserShellPath(); - if (result === null) { - // Acceptable on Windows or when probing fails — caller must fall back. - return; - } - assert.equal(typeof result, 'string'); - // PATH must have segments separated by the platform delimiter. - assert.ok(result.length > 0, 'non-null result must be non-empty'); - }); - - test('returns null on Windows (POSIX shell probe is not portable)', () => { - if (process.platform !== 'win32') return; - const result = getUserShellPath(); - assert.equal(result, null); - }); - - test('returns null when SHELL env var is unset', () => { - if (process.platform === 'win32') return; - const original = process.env.SHELL; - delete process.env.SHELL; - try { - const result = getUserShellPath(); - assert.equal(result, null, 'must return null when $SHELL is unset (POSIX caller falls back to process.env.PATH)'); - } finally { - if (original !== undefined) process.env.SHELL = original; - } - }); -}); - -describe('bug #3020: cross-shell PATH mismatch is detectable via the new helpers', () => { - test('install-time PATH has shim, user-shell PATH does not → mismatch detected', () => { - const installDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3020-install-')); - const userDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3020-user-')); - try { - const shimName = process.platform === 'win32' ? 'gsd-sdk.cmd' : 'gsd-sdk'; - const shimPath = path.join(installDir, shimName); - fs.writeFileSync(shimPath, process.platform === 'win32' ? '@echo off\nexit 0\n' : '#!/bin/sh\nexit 0\n'); - if (process.platform !== 'win32') fs.chmodSync(shimPath, 0o755); - - const installSees = isGsdSdkOnPath(installDir); - const userSees = isGsdSdkOnPath(userDir); - - assert.equal(installSees, true, 'install-time PATH sees the shim'); - assert.equal(userSees, false, 'user-shell PATH does not see the shim'); - // The mismatch is what the post-install check must detect to avoid - // the false ✓. - assert.notEqual(installSees, userSees, 'shim presence differs between install-time PATH and user-shell PATH'); - } finally { - fs.rmSync(installDir, { recursive: true, force: true }); - fs.rmSync(userDir, { recursive: true, force: true }); - } - }); -}); diff --git a/tests/bug-505-remove-dead-sdk-verification.test.cjs b/tests/bug-505-remove-dead-sdk-verification.test.cjs new file mode 100644 index 000000000..2bc760c45 --- /dev/null +++ b/tests/bug-505-remove-dead-sdk-verification.test.cjs @@ -0,0 +1,86 @@ +/** + * Regression guard for #505: dead SDK-shim verification subsystem removed. + * + * Post-ADR-0174 the `@opengsd/gsd-sdk` package was retired; `sdk/` no longer + * ships. `installSdkIfNeeded` and all functions it transitively called are + * dead code with no live callers. This test asserts: + * + * 1. All removed symbols are NO LONGER exported from bin/install.js. + * 2. The two live stale-standalone-SDK helpers (detectStaleStandaloneSdk, + * formatStaleStandaloneSdkWarning) are STILL exported as functions — they + * handle a real user-facing condition (#3406) and MUST NOT be removed. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); + +const inst = require('../bin/install.js'); + +describe('bug #505: dead SDK verification subsystem removed from bin/install.js', () => { + // ---------------------------------------------------------------- + // Dead symbols — must NOT be exported after removal + // ---------------------------------------------------------------- + const deadSymbols = [ + 'installSdkIfNeeded', + 'classifySdkInstall', + 'buildSdkFailFastReport', + 'renderSdkFailFastReport', + 'buildGsdSdkVersionMismatchReport', + 'renderGsdSdkVersionMismatchReport', + 'readGsdSdkVersion', + 'parseGsdSdkVersion', + 'findGsdSdkOnPath', + 'isGsdSdkOnPath', + 'isLegacyGsdSdkShim', + 'trySelfLinkGsdSdk', + 'trySelfLinkGsdSdkWindows', + 'filterNpxFromPath', + 'getUserShellPath', + 'getUserShellWindowsPersistentPath', + ]; + + for (const sym of deadSymbols) { + test(`dead symbol '${sym}' is not exported`, () => { + assert.equal( + typeof inst[sym], + 'undefined', + `'${sym}' should have been removed (post #505 dead-code removal) but is still exported as ${typeof inst[sym]}`, + ); + }); + } + + // ---------------------------------------------------------------- + // Live symbols — MUST still be exported as functions + // ---------------------------------------------------------------- + test('live function detectStaleStandaloneSdk is still exported', () => { + assert.equal( + typeof inst.detectStaleStandaloneSdk, + 'function', + 'detectStaleStandaloneSdk handles real stale-SDK condition (#3406) and must not be removed', + ); + }); + + test('live function formatStaleStandaloneSdkWarning is still exported', () => { + assert.equal( + typeof inst.formatStaleStandaloneSdkWarning, + 'function', + 'formatStaleStandaloneSdkWarning handles real stale-SDK condition (#3406) and must not be removed', + ); + }); + + // buildWindowsShimTriple / formatSdkPathDiagnostic have no production caller + // after this removal, but they are the install.js side of a projection-contract + // drift guard (tests/bug-3441, tests/bug-3442) that assert install.js delegates + // to shell-command-projection.cjs. They are deliberately kept exported. + test('contract surface buildWindowsShimTriple is still exported', () => { + assert.equal(typeof inst.buildWindowsShimTriple, 'function'); + }); + + test('contract surface formatSdkPathDiagnostic is still exported', () => { + assert.equal(typeof inst.formatSdkPathDiagnostic, 'function'); + }); +}); diff --git a/tests/no-unconditional-win32-skip.test.cjs b/tests/no-unconditional-win32-skip.test.cjs deleted file mode 100644 index ce1a5ea43..000000000 --- a/tests/no-unconditional-win32-skip.test.cjs +++ /dev/null @@ -1,70 +0,0 @@ -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -/** - * Behavior-based regression guard for #2962-class bugs. - * - * "Nothing for Windows should be deferred — if it wasn't in, it was missed - * not deferred." (maintainer guidance, 2026-05-01.) - * - * Specifically guards against trySelfLinkGsdSdk silently no-op'ing on - * Windows. Rather than regex-scanning bin/install.js source (which would - * fail on harmless refactors and conflicts with the repo's no-source-grep - * testing standard), this test exercises the function under a simulated - * `process.platform === 'win32'` and asserts shim files actually land on - * disk — i.e., the Windows branch dispatches, doesn't early-return null. - */ - -const { test, describe, before, after } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); -const cp = require('node:child_process'); - -const ROOT = path.join(__dirname, '..'); -const installModule = require(path.join(ROOT, 'bin', 'install.js')); - -describe('Windows parity guard for trySelfLinkGsdSdk (#2962)', () => { - let tmpDir; - let origPlatform; - let origExecSync; - - before(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-win32-guard-')); - origPlatform = process.platform; - origExecSync = cp.execSync; - // Override process.platform to simulate Windows. process.platform is a - // configurable property in Node — Object.defineProperty can swap it. - Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); - cp.execSync = (cmd) => { - if (typeof cmd === 'string' && cmd.trim() === 'npm prefix -g') { - return tmpDir + '\n'; - } - throw new Error(`unexpected execSync: ${cmd}`); - }; - }); - - after(() => { - Object.defineProperty(process, 'platform', { value: origPlatform, configurable: true }); - cp.execSync = origExecSync; - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - test('trySelfLinkGsdSdk dispatches to the Windows handler and writes shims (does NOT silently return null)', () => { - const shimSrc = path.join(ROOT, 'bin', 'gsd-sdk.js'); - const result = installModule.trySelfLinkGsdSdk(shimSrc); - - assert.notEqual( - result, - null, - 'trySelfLinkGsdSdk must not silently return null on Windows; ' + - 'a no-op skip is a missed-parity regression (see #2962, #2775).', - ); - assert.ok( - fs.existsSync(path.join(tmpDir, 'gsd-sdk.cmd')), - 'Windows dispatch must materialize gsd-sdk.cmd in the npm global bin', - ); - }); -});