From eb96876d1146d898f75d04a01320960f4e3b7fbc Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:07:03 +0900 Subject: [PATCH] fix(install): run npm from Windows paths with spaces Quote the npm.cmd path before cmd.exe runs install steps, and query the npm global prefix through the same shell so Node no longer rejects the .cmd file with EINVAL. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 ++ server/test/backend-install.test.mjs | 47 ++++++++++++++++++++++++++++ shared/backend/install.mjs | 26 ++++++++++++--- 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03e3544e..e43e74d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,9 @@ 会生成下一周期的独立任务,并按客户端时区保留本地时间。 - 新增 MiniMax Code 后台支持:通过官方 `mcode acp` 原生 ACP 接入并支持一键安装; 登录、模型与 Provider 配置仍由 MiniMax Code 自己管理。 +- 修复 Windows 上 Node.js 位于含空格目录(如默认的 `C:\Program Files\nodejs`)时, + 一键安装后台的 npm 步骤被 cmd.exe 截断命令路径而失败的问题;安装后也会正确读取 + npm 全局前缀。 - 后台 Session 模型覆盖统一使用 ACP `configOptions` 与 `session/set_config_option`,不再调用私有模型接口或生成后台配置文件;未声明标准 模型选项的 Agent 将沿用自身配置。OpenCode/OpenClaw 一键托管初始化保持不变。 diff --git a/server/test/backend-install.test.mjs b/server/test/backend-install.test.mjs index 67c72ae5..a0f9db04 100644 --- a/server/test/backend-install.test.mjs +++ b/server/test/backend-install.test.mjs @@ -1,5 +1,8 @@ import assert from 'node:assert/strict' import { EventEmitter } from 'node:events' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import test from 'node:test' import { backendNames } from '../../shared/backend/catalog.mjs' import { @@ -485,6 +488,50 @@ test('resolves npm.cmd on Windows', async () => { assert.equal(found[0], 'npm.cmd') }) +test('runs npm.cmd from a Windows directory containing spaces', { + skip: process.platform !== 'win32', +}, async () => { + const root = mkdtempSync(join(tmpdir(), 'qwen-audio-install-')) + try { + const directory = join(root, 'Program Files', 'nodejs') + mkdirSync(directory, { recursive: true }) + const npm = join(directory, 'npm.cmd') + writeFileSync(npm, [ + '@echo off', + 'if "%1"=="config" (', + ' echo %~dp0prefix', + ' exit /b 0', + ')', + 'echo %*> "%~dp0install-args.txt"', + 'exit /b 0', + '', + ].join('\r\n')) + const inspectedPaths = [] + const result = await installBackend('opencode', { + env: { ...process.env }, + platform: 'win32', + find: () => npm, + inspect: async options => { + inspectedPaths.push(options.env.PATH) + return inspectedPaths.length > 1 + ? readyReport('opencode') + : { backends: [{ id: 'opencode', ready: false }] } + }, + }) + assert.equal(result.ok, true, JSON.stringify(result.error)) + assert.match( + readFileSync(join(directory, 'install-args.txt'), 'utf8'), + /^install -g opencode-ai@/, + ) + assert.ok( + inspectedPaths[1].toLowerCase().includes(`${directory}\\prefix`.toLowerCase()), + '安装后应使用 npm 报告的全局前缀更新 PATH', + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + test('normalizes a Windows Path key without borrowing the host PATH', async () => { const observed = [] let inspection = 0 diff --git a/shared/backend/install.mjs b/shared/backend/install.mjs index 476ac278..2d012576 100644 --- a/shared/backend/install.mjs +++ b/shared/backend/install.mjs @@ -43,6 +43,15 @@ function environmentPath(env) { return key ? String(env[key] || '') : '' } +// Windows 上 npm.cmd 必须经 cmd.exe 执行;cmd.exe 会在第一个空格处截断未加 +// 引号的命令路径(如 C:\Program Files\nodejs\npm.cmd)。 +function windowsShellCommand(command, platform) { + const value = String(command || '') + return platform === 'win32' && /\s/.test(value) && !/^".*"$/.test(value) + ? `"${value}"` + : value +} + function specSteps(id, platform) { const onboarding = backendOnboardingAdapter(id, { platform }) const spec = onboarding.installation @@ -248,7 +257,7 @@ function runStep(command, args, { let child let output = '' try { - child = spawnImpl(command, args, { + child = spawnImpl(windowsShellCommand(command, platform), args, { env: { ...env }, windowsHide: true, shell: platform === 'win32', @@ -623,10 +632,17 @@ export async function installBackend(id, { if (step.kind === 'npm' && npmCommand) { // npm install -g 成功后,把全局 bin 目录加入 resolvedEnv.PATH, // 否则后续验证步骤找不到刚安装的二进制。 - const prefixResult = spawnSync(npmCommand, ['config', 'get', 'prefix'], { - env: npmRunEnv(resolvedEnv, npmCommand, platform), - encoding: 'utf8', - }) + const prefixResult = spawnSync( + windowsShellCommand(npmCommand, platform), + ['config', 'get', 'prefix'], + { + env: npmRunEnv(resolvedEnv, npmCommand, platform), + encoding: 'utf8', + // 与安装步骤一致:Node 拒绝不经 shell 直接启动 .cmd(EINVAL)。 + shell: platform === 'win32', + windowsHide: true, + }, + ) const rawPrefix = (prefixResult.stdout || '').trim() const npmGlobalBin = rawPrefix ? platform === 'win32'