* test: reproduce Windows SDK not found after fresh npx install (#3211) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(install): Windows persistent Path probe + npx-PATH filter on Windows (#3211) Add getUserShellWindowsPersistentPath() — the Windows counterpart to getUserShellPath(). Probes the user-level 'Path' registry key via powershell.exe so the installer can verify gsd-sdk is reachable from PowerShell/cmd.exe/Git Bash post-install, not just in the transient npx subprocess PATH. Wire it into installSdkIfNeeded: on Windows, use the registry-derived persistent Path (with npx dirs stripped) as the cross-shell reachability gate, instead of skipping the check entirely. This is the Windows sibling of the Linux fix in #3249/#3231. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * changeset: pr=3282 for #3211 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(install): include Machine+User Path in Windows persistent probe (#3211) getUserShellWindowsPersistentPath now merges Machine-level and User-level registry Path entries (matching the effective PATH that PowerShell, cmd.exe, and Git Bash inherit), instead of reading only User-level. Reading User-only would produce a false warning when gsd-sdk is installed in a machine-level bin dir (e.g. C:\Program Files\nodejs). Addresses CodeRabbit finding on PR #3282. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/silly-foxes-sing.md
Normal file
5
.changeset/silly-foxes-sing.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3282
|
||||
---
|
||||
**`gsd-sdk` now installs reliably on Windows** — the Windows installer now probes the user-level registry Path via PowerShell (the same source PowerShell, cmd.exe, and Git Bash inherit) to verify persistent reachability, instead of skipping the cross-shell check entirely. Applies the same npx-PATH filter as the Linux fix from #3249, and replaces stale `gsd-sdk.cmd` shims pointing at the deprecated `gsd-tools.cjs`. Emits an actionable warning instead of a false-positive ready signal when the npm-prefix bin dir is not on the user's persistent Path.
|
||||
102
bin/install.js
102
bin/install.js
@@ -9803,24 +9803,32 @@ function installSdkIfNeeded(opts) {
|
||||
// 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 ✓.
|
||||
// POSIX-only probe; on Windows getUserShellPath() returns null and
|
||||
// we trust the existing check (Windows-specific fix is separate).
|
||||
//
|
||||
// #3231: when getUserShellPath() returns null (e.g. $SHELL unset on
|
||||
// Linux, rc-file timeout), we cannot confirm persistent reachability.
|
||||
// In that case, do NOT preserve a true onPath — require the initial
|
||||
// check (on persistentPath) to have found the shim in a persistent
|
||||
// location. Since we already filtered npx dirs above, onPath=true here
|
||||
// means a non-transient dir has the shim, which is sufficient.
|
||||
const userShellPath = getUserShellPath();
|
||||
// #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) {
|
||||
const persistentUserShellPath = filterNpxFromPath(userShellPath);
|
||||
// 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 userSees = isGsdSdkOnPath(persistentUserShellPath);
|
||||
if (!userSees) {
|
||||
onPath = false;
|
||||
}
|
||||
}
|
||||
// If userShellPath is null (POSIX probe failed), onPath reflects
|
||||
// If userShellPath is null (probe failed or unavailable), onPath reflects
|
||||
// the persistent-PATH check — that is the best available invariant.
|
||||
|
||||
if (onPath) {
|
||||
@@ -9995,9 +10003,8 @@ function isGsdSdkOnPath(pathString) {
|
||||
* login shell.
|
||||
*
|
||||
* Uses `$SHELL -lc 'printf %s "$PATH"'` on POSIX. Returns null on Windows
|
||||
* (cross-shell PATH probing requires a different strategy — Git Bash
|
||||
* vs PowerShell vs cmd.exe each read PATH from different sources, and
|
||||
* a future revision can build a Windows-aware probe). Returns null
|
||||
* (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.
|
||||
*
|
||||
@@ -10030,6 +10037,72 @@ function getUserShellPath() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #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
|
||||
@@ -10468,6 +10541,7 @@ if (process.env.GSD_TEST_MODE) {
|
||||
isLegacyGsdSdkShim,
|
||||
isGsdSdkOnPath,
|
||||
getUserShellPath,
|
||||
getUserShellWindowsPersistentPath,
|
||||
homePathCoveredByRc,
|
||||
maybeSuggestPathExport,
|
||||
runtimeMap,
|
||||
|
||||
447
tests/bug-3211-windows-sdk-not-found.test.cjs
Normal file
447
tests/bug-3211-windows-sdk-not-found.test.cjs
Normal file
@@ -0,0 +1,447 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Regression tests for bug #3211.
|
||||
*
|
||||
* Windows 11 + PowerShell 7 + Node v22.22.1, fresh
|
||||
* `npx get-shit-done-cc@latest --global --claude`:
|
||||
* gsd-sdk: The term 'gsd-sdk' is not recognized
|
||||
*
|
||||
* Root causes (Windows sibling of #3231):
|
||||
*
|
||||
* A. filterNpxFromPath must handle Windows-style backslash paths (e.g.
|
||||
* C:\Users\user\AppData\Local\npm-cache\_npx\abc123\node_modules\.bin).
|
||||
* After replace(/\\/g, '/') the norm contains /_npx/ and should be
|
||||
* stripped. We verify this explicitly because the Linux tests only exercised
|
||||
* POSIX-style paths.
|
||||
*
|
||||
* B. isGsdSdkOnPath (zero-arg fallback) reads `process.env.PATH || ''`. On
|
||||
* Windows, Node.js normalises PATH case so `process.env.PATH` always
|
||||
* returns the right value in production. But in a cross-platform test
|
||||
* running on macOS/Linux that simulates Windows by writing to
|
||||
* `process.env.PATH`, the filter must still strip `_npx` dirs expressed
|
||||
* with Windows backslash separators so the helper returns false when only
|
||||
* transient dirs are present.
|
||||
*
|
||||
* C. getUserShellWindowsPersistentPath() — new Windows equivalent of
|
||||
* getUserShellPath(). Probes the user's persistent 'Path' from the Windows
|
||||
* registry via:
|
||||
* powershell.exe -NoProfile -Command
|
||||
* "[Environment]::GetEnvironmentVariable('Path', 'User')"
|
||||
* Returns the persistent Path string or null on failure. Must be exported
|
||||
* and must apply filterNpxFromPath before returning.
|
||||
*
|
||||
* D. installSdkIfNeeded on Windows must invoke getUserShellWindowsPersistentPath
|
||||
* (instead of the always-null getUserShellPath) for cross-shell verification
|
||||
* — parallel to the POSIX userShellPath guard.
|
||||
*
|
||||
* All assertions use typed-IR / behavioral testing — no source-grep, no
|
||||
* readFileSync on install.js source.
|
||||
*/
|
||||
|
||||
const { describe, test, beforeEach, afterEach, 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'));
|
||||
|
||||
const {
|
||||
filterNpxFromPath,
|
||||
isLegacyGsdSdkShim,
|
||||
isGsdSdkOnPath,
|
||||
installSdkIfNeeded,
|
||||
getUserShellWindowsPersistentPath,
|
||||
} = installModule;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A. filterNpxFromPath — Windows backslash paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('bug #3211-A: filterNpxFromPath handles Windows backslash _npx paths', () => {
|
||||
test('strips a Windows-style _npx dir expressed with backslashes', () => {
|
||||
assert.equal(typeof filterNpxFromPath, 'function', 'filterNpxFromPath must be exported');
|
||||
|
||||
// Windows npm-cache path with backslash separators, semicolon delimiter
|
||||
const winNpxDir = 'C:\\Users\\user\\AppData\\Local\\npm-cache\\_npx\\abc123\\node_modules\\.bin';
|
||||
const winPersistentDir = 'C:\\Users\\user\\AppData\\Roaming\\npm';
|
||||
const winSystemDir = 'C:\\Windows\\System32';
|
||||
|
||||
// On macOS path.delimiter is ':', not ';'. We pass an explicit string
|
||||
// so the test validates the normalize logic, not the local path.delimiter.
|
||||
const inputPath = [winNpxDir, winPersistentDir, winSystemDir].join(';');
|
||||
const result = filterNpxFromPath(inputPath);
|
||||
|
||||
// Regardless of delimiter used, the _npx segment must be stripped
|
||||
assert.ok(
|
||||
!result.includes('_npx'),
|
||||
'filterNpxFromPath must strip Windows _npx dirs. Got: ' + result,
|
||||
);
|
||||
assert.ok(
|
||||
result.includes('Roaming\\npm') || result.includes('Roaming/npm'),
|
||||
'filterNpxFromPath must preserve the persistent npm dir. Got: ' + result,
|
||||
);
|
||||
});
|
||||
|
||||
test('strips a mixed-separator Windows _npx path (forward + backward slashes)', () => {
|
||||
const mixedNpxDir = 'C:/Users/user/AppData/Local/npm-cache/_npx/abc/node_modules/.bin';
|
||||
const persistentDir = 'C:/Users/user/AppData/Roaming/npm';
|
||||
const result = filterNpxFromPath([mixedNpxDir, persistentDir].join(';'));
|
||||
assert.ok(!result.includes('_npx'), 'must strip mixed-separator _npx dir. Got: ' + result);
|
||||
assert.ok(result.includes('Roaming/npm'), 'must keep persistent dir. Got: ' + result);
|
||||
});
|
||||
|
||||
test('does NOT strip a Windows user dir that merely contains "npx" as a substring', () => {
|
||||
// A user-named dir like C:\my-npx-tools\bin must NOT be filtered.
|
||||
const userNpxLikeDir = 'C:\\Users\\user\\my-npx-tools\\bin';
|
||||
const realNpxDir = 'C:\\Users\\user\\AppData\\Local\\npm-cache\\_npx\\abc\\node_modules\\.bin';
|
||||
const result = filterNpxFromPath([userNpxLikeDir, realNpxDir].join(';'));
|
||||
assert.ok(
|
||||
result.includes('my-npx-tools'),
|
||||
'must not strip user dirs that merely contain "npx". Got: ' + result,
|
||||
);
|
||||
assert.ok(!result.includes('_npx'), 'must strip the real _npx dir. Got: ' + result);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// B. isGsdSdkOnPath — does not return true when only a Windows _npx dir has
|
||||
// gsd-sdk.cmd (using filterNpxFromPath on the passed pathString)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('bug #3211-B: isGsdSdkOnPath rejects Windows _npx-only transient PATH', () => {
|
||||
let tmpRoot;
|
||||
|
||||
before(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3211-b-'));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch {}
|
||||
});
|
||||
|
||||
test('returns false when a gsd-sdk.cmd exists only in an _npx-style transient dir', () => {
|
||||
// On POSIX we name the dir with _npx/ to match the filter pattern.
|
||||
// We can't set process.platform, but we CAN call isGsdSdkOnPath with an
|
||||
// explicit pathString that contains an _npx segment — the fix must
|
||||
// ensure callers pre-filter via filterNpxFromPath before calling
|
||||
// isGsdSdkOnPath. We test filterNpxFromPath(pathString) produces an
|
||||
// empty result, which means isGsdSdkOnPath of the filtered path returns false.
|
||||
const npxBinDir = path.join(tmpRoot, '_npx', 'abc123', 'node_modules', '.bin');
|
||||
fs.mkdirSync(npxBinDir, { recursive: true });
|
||||
|
||||
// Write a gsd-sdk shim (named .cmd for the Windows scenario — on POSIX
|
||||
// isGsdSdkOnPath won't find .cmd; we validate the filter, not the exec check).
|
||||
const shimPath = path.join(npxBinDir, 'gsd-sdk.cmd');
|
||||
fs.writeFileSync(
|
||||
shimPath,
|
||||
['@ECHO OFF', '@node "C:\\path\\to\\gsd-sdk.js" %*', ''].join('\r\n'),
|
||||
);
|
||||
|
||||
// The raw pathString contains an _npx segment — it MUST be filtered.
|
||||
const rawPath = npxBinDir;
|
||||
const persistentPath = filterNpxFromPath(rawPath);
|
||||
|
||||
// After filtering, the _npx dir must be gone so isGsdSdkOnPath returns false.
|
||||
const onPath = isGsdSdkOnPath(persistentPath);
|
||||
assert.equal(
|
||||
onPath,
|
||||
false,
|
||||
'isGsdSdkOnPath(filterNpxFromPath(path)) must return false when only _npx dir has gsd-sdk. persistentPath=' + persistentPath,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C. getUserShellWindowsPersistentPath — new export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('bug #3211-C: getUserShellWindowsPersistentPath export', () => {
|
||||
test('is exported from install.js', () => {
|
||||
assert.equal(
|
||||
typeof getUserShellWindowsPersistentPath,
|
||||
'function',
|
||||
'getUserShellWindowsPersistentPath must be exported from install.js',
|
||||
);
|
||||
});
|
||||
|
||||
test('returns a string or null — never throws', () => {
|
||||
// We can call it on macOS/Linux; it must handle non-Windows gracefully
|
||||
// (return null or a string). Must never throw.
|
||||
let result;
|
||||
let threw = null;
|
||||
try {
|
||||
result = getUserShellWindowsPersistentPath();
|
||||
} catch (e) {
|
||||
threw = e;
|
||||
}
|
||||
assert.equal(threw, null, 'getUserShellWindowsPersistentPath must not throw. Error: ' + threw);
|
||||
assert.ok(
|
||||
result === null || typeof result === 'string',
|
||||
'must return string or null. Got: ' + typeof result,
|
||||
);
|
||||
});
|
||||
|
||||
test('on non-Windows, returns null (Windows-only probe)', () => {
|
||||
if (process.platform === 'win32') {
|
||||
// On actual Windows, any non-null string is acceptable.
|
||||
const result = getUserShellWindowsPersistentPath();
|
||||
assert.ok(
|
||||
result === null || typeof result === 'string',
|
||||
'on Windows must return string or null',
|
||||
);
|
||||
} else {
|
||||
// On POSIX, the Windows probe is meaningless — must return null.
|
||||
const result = getUserShellWindowsPersistentPath();
|
||||
assert.equal(result, null, 'on POSIX, getUserShellWindowsPersistentPath must return null');
|
||||
}
|
||||
});
|
||||
|
||||
test('when the PowerShell probe is mocked to return a path, strips _npx dirs', () => {
|
||||
// Mock execFileSync to return a merged Machine;User Windows Path that
|
||||
// includes both persistent and transient _npx dirs.
|
||||
const savedExecFileSync = cp.execFileSync;
|
||||
const winPersistentDir = 'C:\\Users\\user\\AppData\\Roaming\\npm';
|
||||
const winNpxDir = 'C:\\Users\\user\\AppData\\Local\\npm-cache\\_npx\\abc\\node_modules\\.bin';
|
||||
// Merged Machine;User result (as the new probe emits)
|
||||
const mockPath = [winPersistentDir, winNpxDir].join(';');
|
||||
|
||||
cp.execFileSync = (file, args, opts) => {
|
||||
if (typeof file === 'string' && file.includes('powershell') && Array.isArray(args) &&
|
||||
args.some((a) => typeof a === 'string' && a.includes('GetEnvironmentVariable'))) {
|
||||
return mockPath + '\n';
|
||||
}
|
||||
return savedExecFileSync.call(cp, file, args, opts);
|
||||
};
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = getUserShellWindowsPersistentPath();
|
||||
} finally {
|
||||
cp.execFileSync = savedExecFileSync;
|
||||
}
|
||||
|
||||
// On non-Windows this returns null (the function guards on process.platform).
|
||||
// On Windows it returns the filtered path.
|
||||
if (result !== null) {
|
||||
assert.ok(
|
||||
!result.includes('_npx'),
|
||||
'getUserShellWindowsPersistentPath must filter _npx dirs from the returned Path. Got: ' + result,
|
||||
);
|
||||
assert.ok(
|
||||
result.includes('Roaming\\npm') || result.includes('Roaming/npm'),
|
||||
'must keep persistent npm dir. Got: ' + result,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('probe command includes both Machine and User Path sources', () => {
|
||||
// The PowerShell command that the function invokes must request BOTH
|
||||
// Machine-level and User-level Path. Verify by inspecting what args
|
||||
// the mock receives. This is a behavioral assertion on the call shape,
|
||||
// not a source-grep.
|
||||
const savedExecFileSync = cp.execFileSync;
|
||||
let capturedArgs = null;
|
||||
|
||||
cp.execFileSync = (file, args, opts) => {
|
||||
if (typeof file === 'string' && file.includes('powershell')) {
|
||||
capturedArgs = args;
|
||||
return 'C:\\Windows\\System32\n';
|
||||
}
|
||||
return savedExecFileSync.call(cp, file, args, opts);
|
||||
};
|
||||
|
||||
try {
|
||||
// On non-Windows this never calls execFileSync — skip assertion.
|
||||
getUserShellWindowsPersistentPath();
|
||||
} finally {
|
||||
cp.execFileSync = savedExecFileSync;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32' && capturedArgs !== null) {
|
||||
// The command string must reference both 'Machine' and 'User' so both
|
||||
// registry hives contribute to the returned Path.
|
||||
const cmdStr = capturedArgs.join(' ');
|
||||
assert.ok(
|
||||
cmdStr.includes('Machine'),
|
||||
'PowerShell command must read Machine-level Path. Got: ' + cmdStr,
|
||||
);
|
||||
assert.ok(
|
||||
cmdStr.includes('User'),
|
||||
'PowerShell command must read User-level Path. Got: ' + cmdStr,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// D. installSdkIfNeeded Windows false-positive: transient _npx + npm-prefix
|
||||
// NOT on PATH → must NOT print "GSD SDK ready"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('bug #3211-D: installSdkIfNeeded — Windows _npx false-positive', () => {
|
||||
let tmpRoot;
|
||||
let sdkDir;
|
||||
let savedEnv;
|
||||
let origExecSync;
|
||||
|
||||
function captureConsole(fn) {
|
||||
const stdout = [];
|
||||
const stderr = [];
|
||||
const origLog = console.log;
|
||||
const origWarn = console.warn;
|
||||
const origError = console.error;
|
||||
console.log = (...a) => stdout.push(a.join(' '));
|
||||
console.warn = (...a) => stderr.push(a.join(' '));
|
||||
console.error = (...a) => stderr.push(a.join(' '));
|
||||
let threw = null;
|
||||
try { fn(); } catch (e) { threw = e; }
|
||||
finally {
|
||||
console.log = origLog;
|
||||
console.warn = origWarn;
|
||||
console.error = origError;
|
||||
}
|
||||
if (threw) throw threw;
|
||||
const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
return { stdout: stdout.map(strip).join('\n'), stderr: stderr.map(strip).join('\n') };
|
||||
}
|
||||
|
||||
function makeSdkDir(root) {
|
||||
const dir = path.join(root, 'sdk');
|
||||
fs.mkdirSync(path.join(dir, 'dist'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'dist', 'cli.js'),
|
||||
['#!/usr/bin/env node', "console.log('0.0.0-test');", ''].join('\n'),
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
return dir;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3211-d-'));
|
||||
sdkDir = makeSdkDir(tmpRoot);
|
||||
|
||||
// Simulate: install-time PATH contains only a transient _npx dir
|
||||
// with a gsd-sdk shim. The persistent npm prefix dir is separate and
|
||||
// NOT in process.env.PATH during the npx run.
|
||||
const npxBinDir = path.join(tmpRoot, '_npx', 'abc123', 'node_modules', '.bin');
|
||||
fs.mkdirSync(npxBinDir, { recursive: true });
|
||||
// Write a gsd-sdk shim in the transient dir (executable on POSIX)
|
||||
fs.writeFileSync(
|
||||
path.join(npxBinDir, 'gsd-sdk'),
|
||||
['#!/bin/sh', 'exec node /path/to/gsd-sdk.js "$@"', ''].join('\n'),
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
|
||||
const homeDir = path.join(tmpRoot, 'home');
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
|
||||
savedEnv = {
|
||||
PATH: process.env.PATH,
|
||||
HOME: process.env.HOME,
|
||||
SHELL: process.env.SHELL,
|
||||
};
|
||||
|
||||
// Only the transient _npx dir is on PATH — nothing persistent.
|
||||
process.env.PATH = npxBinDir;
|
||||
process.env.HOME = homeDir;
|
||||
delete process.env.SHELL;
|
||||
|
||||
// Mock cp.execSync for npm prefix -g — return a separate dir that is
|
||||
// NOT in process.env.PATH (simulating Windows: npm prefix is on the
|
||||
// user's registry Path but not on the npx-injected subprocess PATH).
|
||||
const npmPrefixDir = path.join(tmpRoot, 'npm-global');
|
||||
fs.mkdirSync(npmPrefixDir, { recursive: true });
|
||||
|
||||
origExecSync = cp.execSync;
|
||||
cp.execSync = (cmd, opts) => {
|
||||
if (typeof cmd === 'string' && cmd.trim() === 'npm prefix -g') {
|
||||
return npmPrefixDir + '\n';
|
||||
}
|
||||
return origExecSync.call(cp, cmd, opts);
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cp.execSync = origExecSync;
|
||||
if (savedEnv.PATH == null) delete process.env.PATH;
|
||||
else process.env.PATH = savedEnv.PATH;
|
||||
if (savedEnv.HOME == null) delete process.env.HOME;
|
||||
else process.env.HOME = savedEnv.HOME;
|
||||
if (savedEnv.SHELL == null) delete process.env.SHELL;
|
||||
else process.env.SHELL = savedEnv.SHELL;
|
||||
try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch {}
|
||||
});
|
||||
|
||||
test('does NOT print "GSD SDK ready" when the only gsd-sdk entry is in a transient _npx dir and npm-prefix is off-PATH', () => {
|
||||
const { stdout, stderr } = captureConsole(() => {
|
||||
installSdkIfNeeded({ sdkDir });
|
||||
});
|
||||
const combined = `${stdout}\n${stderr}`;
|
||||
|
||||
assert.ok(
|
||||
!/GSD SDK ready/.test(combined),
|
||||
'installer must NOT print "GSD SDK ready" when gsd-sdk is only in a transient _npx dir. Got:\n' + combined,
|
||||
);
|
||||
// Must emit SOME output (warning or diagnostic), not silently succeed.
|
||||
assert.ok(
|
||||
combined.trim().length > 0,
|
||||
'installer must emit a diagnostic when self-link fails or shim is in transient dir. Got empty output.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// E. isLegacyGsdSdkShim — Windows .cmd shim detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('bug #3211-E: isLegacyGsdSdkShim detects legacy marker in .cmd files', () => {
|
||||
let tmpRoot;
|
||||
|
||||
before(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3211-e-'));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch {}
|
||||
});
|
||||
|
||||
test('detects @deprecated + gsd-tools.cjs in a .cmd shim', () => {
|
||||
assert.equal(typeof isLegacyGsdSdkShim, 'function');
|
||||
|
||||
const legacyCmd = path.join(tmpRoot, 'gsd-sdk.cmd');
|
||||
fs.writeFileSync(
|
||||
legacyCmd,
|
||||
[
|
||||
'@ECHO OFF',
|
||||
':: @deprecated — use gsd-tools.cjs directly',
|
||||
'@node "C:\\path\\to\\gsd-tools.cjs" %*',
|
||||
'',
|
||||
].join('\r\n'),
|
||||
);
|
||||
assert.equal(isLegacyGsdSdkShim(legacyCmd), true, 'must detect @deprecated gsd-tools.cjs in .cmd shim');
|
||||
});
|
||||
|
||||
test('returns false for a modern .cmd shim pointing at gsd-sdk.js', () => {
|
||||
const modernCmd = path.join(tmpRoot, 'gsd-sdk-modern.cmd');
|
||||
fs.writeFileSync(
|
||||
modernCmd,
|
||||
[
|
||||
'@ECHO OFF',
|
||||
'@SETLOCAL',
|
||||
'@node "C:\\path\\to\\get-shit-done-cc\\bin\\gsd-sdk.js" %*',
|
||||
'',
|
||||
].join('\r\n'),
|
||||
);
|
||||
assert.equal(isLegacyGsdSdkShim(modernCmd), false, 'must not flag modern .cmd shim as legacy');
|
||||
});
|
||||
|
||||
test('returns false for a missing file', () => {
|
||||
assert.equal(isLegacyGsdSdkShim(path.join(tmpRoot, 'no-such-file.cmd')), false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user