diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 1317bbbf5..f511b4376 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -3456,6 +3456,125 @@ async function runTests() { console.log(''); + // ============================================================ + // Test Suite 47: WSL shell using Windows Node guard + // ============================================================ + console.log(`${colors.yellow}Test Suite 47: WSL Windows Node guard${colors.reset}\n`); + + try { + const wslNodeCheck = require('../tools/installer/core/wsl-node-check'); + + let detection = wslNodeCheck.detectWindowsNodeFromWsl({ + platform: 'win32', + env: { WSL_DISTRO_NAME: 'Ubuntu-26.04' }, + cwd: String.raw`C:\Windows`, + execPath: String.raw`C:\Program Files\nodejs\node.exe`, + }); + assert(detection.isMismatch === true, 'detects Windows Node launched from WSL via WSL_DISTRO_NAME'); + + detection = wslNodeCheck.detectWindowsNodeFromWsl({ + platform: 'win32', + env: { PWD: '/home/devuser/projects/md2pdf' }, + cwd: String.raw`\\wsl.localhost\Ubuntu-26.04\home\devuser\projects\md2pdf`, + execPath: String.raw`C:\Program Files\nodejs\node.exe`, + }); + assert(detection.isMismatch === true, 'detects Windows Node launched from WSL via Linux PWD / WSL UNC cwd'); + + detection = wslNodeCheck.detectWindowsNodeFromWsl({ + platform: 'win32', + env: {}, + cwd: String.raw`\\wsl$\Ubuntu-26.04\home\devuser\projects\md2pdf`, + execPath: String.raw`C:\Program Files\nodejs\node.exe`, + }); + assert(detection.isMismatch === true, 'detects Windows Node launched from WSL via legacy WSL UNC cwd'); + + detection = wslNodeCheck.detectWindowsNodeFromWsl({ + platform: 'linux', + env: { WSL_DISTRO_NAME: 'Ubuntu-26.04', PWD: '/home/devuser/projects/md2pdf' }, + cwd: '/home/devuser/projects/md2pdf', + execPath: '/usr/bin/node', + }); + assert(detection.isMismatch === false, 'allows native Linux Node inside WSL'); + + detection = wslNodeCheck.detectWindowsNodeFromWsl({ + platform: 'win32', + env: { PWD: String.raw`C:\Users\devuser\project` }, + cwd: String.raw`C:\Users\devuser\project`, + execPath: String.raw`C:\Program Files\nodejs\node.exe`, + }); + assert(detection.isMismatch === false, 'allows normal Windows Node outside WSL'); + + detection = wslNodeCheck.detectWindowsNodeFromWsl({ + platform: 'win32', + env: { PWD: '/c/Users/devuser/project' }, + cwd: String.raw`C:\Users\devuser\project`, + execPath: String.raw`C:\Program Files\nodejs\node.exe`, + }); + assert(detection.isMismatch === false, 'allows Git Bash Windows-drive PWD outside WSL'); + + detection = wslNodeCheck.detectWindowsNodeFromWsl({ + platform: 'win32', + env: { PWD: '/cygdrive/c/Users/devuser/project' }, + cwd: String.raw`C:\Users\devuser\project`, + execPath: String.raw`C:\Program Files\nodejs\node.exe`, + }); + assert(detection.isMismatch === false, 'allows Cygwin Windows-drive PWD outside WSL'); + + const message = wslNodeCheck.formatWindowsNodeFromWslMessage({ + isMismatch: true, + reason: 'WSL_DISTRO_NAME is set', + execPath: String.raw`C:\Program Files\nodejs\node.exe`, + }); + assert(message.includes('Install Node.js inside WSL'), 'guard message tells user to install Node.js inside WSL'); + assert(message.includes(String.raw`C:\Program Files\nodejs\node.exe`), 'guard message includes detected Windows Node path'); + + const promptsModule = require('../tools/installer/prompts'); + const real = { + detectWindowsNodeFromWsl: wslNodeCheck.detectWindowsNodeFromWsl, + log: promptsModule.log, + exit: process.exit, + }; + const seen = { errors: [], exit: [] }; + wslNodeCheck.detectWindowsNodeFromWsl = () => ({ + isMismatch: true, + reason: 'WSL_INTEROP is set', + execPath: String.raw`C:\Program Files\nodejs\node.exe`, + }); + promptsModule.log = { + error: async (m) => void seen.errors.push(m), + info: async () => {}, + success: async () => {}, + warn: async () => {}, + message: async () => {}, + step: async () => {}, + }; + process.exit = (code) => { + seen.exit.push(code); + throw new Error('__stub_exit__'); + }; + + try { + let threw = false; + try { + await wslNodeCheck.checkWindowsNodeFromWsl(); + } catch (error) { + threw = error.message === '__stub_exit__'; + } + assert(threw && seen.exit[0] === 1, 'guard exits with code 1 when Windows Node is launched from WSL'); + assert(seen.errors[0].includes('Windows Node.js was launched from a WSL shell'), 'guard logs the mismatch explanation'); + } finally { + wslNodeCheck.detectWindowsNodeFromWsl = real.detectWindowsNodeFromWsl; + promptsModule.log = real.log; + process.exit = real.exit; + } + } catch (error) { + console.log(`${colors.red}Test Suite 47 setup failed: ${error.message}${colors.reset}`); + console.log(error.stack); + failed++; + } + + console.log(''); + // ============================================================ // Summary // ============================================================ diff --git a/tools/installer/commands/install.js b/tools/installer/commands/install.js index 1dfe6fb70..42f6213de 100644 --- a/tools/installer/commands/install.js +++ b/tools/installer/commands/install.js @@ -75,6 +75,9 @@ module.exports = { return; } + const { checkWindowsNodeFromWsl } = require('../core/wsl-node-check'); + await checkWindowsNodeFromWsl(); + // Set debug flag as environment variable for all components if (options.debug) { process.env.BMAD_DEBUG_MANIFEST = 'true'; diff --git a/tools/installer/core/wsl-node-check.js b/tools/installer/core/wsl-node-check.js new file mode 100644 index 000000000..261ebf2d7 --- /dev/null +++ b/tools/installer/core/wsl-node-check.js @@ -0,0 +1,109 @@ +const prompts = require('../prompts'); + +const WSL_UNC_PATTERN = /^\\\\wsl(?:\.localhost|\$)?\\/i; + +function normalizePath(value) { + return typeof value === 'string' ? value.replaceAll('/', '\\').toLowerCase() : ''; +} + +function isLinuxStylePath(value) { + return ( + typeof value === 'string' && + value.startsWith('/') && + !value.startsWith('//') && + !/^\/[a-z](?:\/|$)/i.test(value) && + !/^\/cygdrive\/[a-z](?:\/|$)/i.test(value) + ); +} + +function isWslUncPath(value) { + return WSL_UNC_PATTERN.test(value || ''); +} + +/** + * Detect the broken interop case where WSL resolved node/npx to Windows. + * @param {Object} [runtime] + * @param {string} [runtime.platform] + * @param {Object} [runtime.env] + * @param {string} [runtime.cwd] + * @param {string} [runtime.execPath] + * @returns {{isMismatch: boolean, reason: string|null, execPath: string}} + */ +function detectWindowsNodeFromWsl(runtime = {}) { + const platform = runtime.platform || process.platform; + const env = runtime.env || process.env; + const cwd = runtime.cwd || safeCwd(); + const execPath = runtime.execPath || process.execPath || ''; + + if (platform !== 'win32') { + return { isMismatch: false, reason: null, execPath }; + } + + if (env.WSL_DISTRO_NAME) { + return { isMismatch: true, reason: 'WSL_DISTRO_NAME is set', execPath }; + } + + if (env.WSL_INTEROP) { + return { isMismatch: true, reason: 'WSL_INTEROP is set', execPath }; + } + + if (isLinuxStylePath(env.PWD)) { + return { isMismatch: true, reason: 'PWD is a Linux path', execPath }; + } + + if (isWslUncPath(cwd)) { + return { isMismatch: true, reason: 'current directory is a WSL UNC path', execPath }; + } + + const normalizedExecPath = normalizePath(execPath); + if (normalizedExecPath.includes('\\wsl$\\') || normalizedExecPath.includes('\\wsl.localhost\\')) { + return { isMismatch: true, reason: 'Node executable path is under a WSL UNC path', execPath }; + } + + return { isMismatch: false, reason: null, execPath }; +} + +function safeCwd() { + try { + return process.cwd(); + } catch { + return ''; + } +} + +function formatWindowsNodeFromWslMessage(detection) { + const lines = [ + 'Windows Node.js was launched from a WSL shell.', + '', + 'This usually means Node.js is not installed inside the WSL distro, so WSL resolved `node`/`npx` to Windows.', + 'The installer cannot safely continue because Linux paths may be interpreted as Windows paths.', + '', + 'Install Node.js inside WSL, then rerun the same command from the WSL terminal.', + ]; + + if (detection.execPath) { + lines.push('', `Detected Node executable: ${detection.execPath}`); + } + + if (detection.reason) { + lines.push(`Detection signal: ${detection.reason}`); + } + + return lines.join('\n'); +} + +async function checkWindowsNodeFromWsl() { + const detection = module.exports.detectWindowsNodeFromWsl(); + if (!detection.isMismatch) { + return detection; + } + + await prompts.log.error(formatWindowsNodeFromWslMessage(detection)); + process.exit(1); +} + +module.exports = { + checkWindowsNodeFromWsl, + detectWindowsNodeFromWsl, + formatWindowsNodeFromWslMessage, +};