diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/checks/win-launcher.check.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/win-launcher.check.mjs new file mode 100644 index 00000000..14e06c66 --- /dev/null +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/checks/win-launcher.check.mjs @@ -0,0 +1,120 @@ +import test from 'node:test';import assert from 'node:assert/strict'; +import {mkdir,writeFile,rm,readFile,chmod} from 'node:fs/promises'; +import {tmpdir} from 'node:os';import {join} from 'node:path'; +import {resolveMcode} from '../src/mcode-location.mjs'; +// Windows launcher resolution is exercised from POSIX by faking the on-disk +// layout and passing platform:'win32' + a controlled PATH/PATHEXT: resolution +// is pure filesystem probing, so the real bug (mixed-install layouts) is +// reproducible without a Windows host. Real-Windows behavior is covered by the +// repository's windows CI job. +async function layout(){ + const root=await mkdir(join(tmpdir(),`wf-winloc-${crypto.randomUUID()}`),{recursive:true}); + const shim=join(root,'shim'),official=join(root,'official'); + const mk=async(dir,rel,content)=>{const file=join(dir,...rel);await mkdir(join(file,'..'),{recursive:true});await writeFile(file,content);return file;}; + return {root,shim,official,mk,cleanup:async()=>{await rm(root,{recursive:true,force:true});}}; +} +const pkg=version=>JSON.stringify({name:'@minimax-ai/code',version}); +async function install(dir,{version,withCmd=true,withPs1=true,npmLayout=false}={}){ + await mkdir(dir,{recursive:true}); + if(withCmd){await writeFile(join(dir,'mcode.cmd'),'@echo off');await writeFile(join(dir,'powershell.exe'),'fake');} + if(withPs1)await writeFile(join(dir,'mcode.ps1'),'# launcher'); + if(version){const rel=npmLayout?['node_modules','@minimax-ai','code']:['lib','node_modules','@minimax-ai','code']; + await mkdir(join(dir,...rel),{recursive:true}); + await writeFile(join(dir,...rel,'cli.js'),'#!/usr/bin/env node'); + await writeFile(join(dir,...rel,'package.json'),pkg(version));} +} +const winEnv=(shim)=>({PATH:`${shim};C:\\Windows\\System32`,PATHEXT:'.cmd;.bat',SystemRoot:'C:\\Windows'}); +test('mixed installs: ps1 present, old npm-shim beside a newer official install -> direct node entry of the newest',async()=>{ + const f=await layout();try{ + await install(f.shim,{version:'0.2.7',npmLayout:true}); // PATH shim with old sibling cli.js + await install(join(f.official,'.minimax-code'),{version:'0.4.12'}); // official layout, newer + const r=await resolveMcode('mcode',{env:winEnv(f.shim),home:f.official,platform:'win32'}); + assert.ok(r,'resolves');assert.equal(r.command,process.execPath,'no powershell -File hop'); + assert.match(r.args[0],/lib[\\/]node_modules.*cli\.js$/);assert.match(r.args[0],/0?4\.12|^.*official/); + assert.equal(JSON.parse(await readFile(join(r.args[0],'..','package.json'),'utf8')).version,'0.4.12','newest wins'); + }finally{await f.cleanup();} +}); +test('ps1 renamed away, old npm-shim sibling remains -> newest official entry, never the stale sibling',async()=>{ + const f=await layout();try{ + await install(f.shim,{version:'0.2.7',npmLayout:true,withPs1:false}); + await install(join(f.official,'.minimax-code'),{version:'0.4.12'}); + const r=await resolveMcode('mcode',{env:winEnv(f.shim),home:f.official,platform:'win32'}); + assert.equal(JSON.parse(await readFile(join(r.args[0],'..','package.json'),'utf8')).version,'0.4.12'); + }finally{await f.cleanup();} +}); +test('newest wins regardless of origin: newer npm-shim sibling beats older official',async()=>{ + const f=await layout();try{ + await install(f.shim,{version:'0.5.0',npmLayout:true}); + await install(join(f.official,'.minimax-code'),{version:'0.4.12'}); + const r=await resolveMcode('mcode',{env:winEnv(f.shim),home:f.official,platform:'win32'}); + assert.equal(JSON.parse(await readFile(join(r.args[0],'..','package.json'),'utf8')).version,'0.5.0'); + }finally{await f.cleanup();} +}); +test('single npm layout without official install -> sibling entry via direct node',async()=>{ + const f=await layout();try{ + await install(f.shim,{version:'0.4.12',npmLayout:true}); + const r=await resolveMcode('mcode',{env:winEnv(f.shim),home:f.official,platform:'win32'}); + assert.equal(r.command,process.execPath);assert.match(r.args[0],/node_modules.*cli\.js$/); + }finally{await f.cleanup();} +}); +test('no node entry anywhere but ps1 exists -> documented powershell -File last resort',async()=>{ + const f=await layout();try{ + await install(f.shim,{}); // cmd + ps1, no cli.js anywhere + const r=await resolveMcode('mcode',{env:winEnv(f.shim),home:f.official,platform:'win32'}); + assert.match(r.command,/powershell|pwsh/i);assert.deepEqual(r.args.slice(0,3),['-NoProfile','-File',join(f.shim,'mcode.ps1')]); + }finally{await f.cleanup();} +}); +test('cmd without any entry and without ps1 still fails with the repair hint',async()=>{ + const f=await layout();try{ + await install(f.shim,{withPs1:false}); + await assert.rejects(resolveMcode('mcode',{env:winEnv(f.shim),home:f.official,platform:'win32'}),/cli\.js|修复/); + }finally{await f.cleanup();} +}); +test('posix resolution is unchanged',async()=>{ + const f=await layout();try{ + await mkdir(f.shim,{recursive:true});await writeFile(join(f.shim,'mcode'),'#!/bin/sh');await chmod(join(f.shim,'mcode'),0o755); + const r=await resolveMcode('mcode',{env:{PATH:f.shim},home:f.official,platform:'linux'}); + assert.equal(r.command,join(f.shim,'mcode'));assert.deepEqual(r.args,[]); + }finally{await f.cleanup();} +}); + +test('field layout: extensionless mcode + mcode.cmd + ps1 + stale sibling + releases/0.4.12 -> newest via node, never the bash script',async()=>{ + const f=await layout();try{ + const root=join(f.official,'.minimax-code'); + await install(f.shim,{}); // provides fake powershell.exe + pwsh? (powershell only) + await writeFile(join(f.shim,'pwsh.exe'),'fake'); // pwsh preferred on the last-resort path + await mkdir(root,{recursive:true}); + await writeFile(join(root,'mcode'),'#!/bin/sh'); // extensionless bash script — must never be spawned + await install(root,{version:'0.2.7',npmLayout:true,withCmd:true,withPs1:true}); + const rel=join(root,'releases','0.4.12','node_modules','@minimax-ai','code'); + await mkdir(rel,{recursive:true}); + await writeFile(join(rel,'cli.js'),'#!/usr/bin/env node'); + await writeFile(join(rel,'package.json'),pkg('0.4.12')); + // PATHEXT case must match the fixture files: real Windows filesystems are + // case-insensitive, but the POSIX runners simulating win32 are not — probing + // mcode.CMD against a lowercase mcode.cmd would miss on Linux. + const env={PATH:`${root};${f.shim}`,PATHEXT:'.cmd;.bat',SystemRoot:'C:\\Windows'}; + const r=await resolveMcode('mcode',{env,home:f.official,platform:'win32'}); + assert.ok(r,'resolution must succeed on the field layout'); + assert.equal(r.command,process.execPath,'extensionless bash script must not be selected'); + assert.equal(JSON.parse(await readFile(join(r.args[0],'..','package.json'),'utf8')).version,'0.4.12','releases layout wins over stale sibling'); + }finally{await f.cleanup();} +}); +test('ps1 renamed, sibling 0.2.7, releases/0.4.12 present -> releases entry (not the sibling)',async()=>{ + const f=await layout();try{ + const root=join(f.official,'.minimax-code'); + await install(root,{version:'0.2.7',npmLayout:true,withPs1:false}); + const rel=join(root,'releases','0.4.12','node_modules','@minimax-ai','code'); + await mkdir(rel,{recursive:true}); + await writeFile(join(rel,'cli.js'),'x');await writeFile(join(rel,'package.json'),pkg('0.4.12')); + const r=await resolveMcode('mcode',{env:winEnv(root),home:f.official,platform:'win32'}); + assert.equal(JSON.parse(await readFile(join(r.args[0],'..','package.json'),'utf8')).version,'0.4.12'); + }finally{await f.cleanup();} +}); +test('last-resort PS hop prefers pwsh (PS7) over powershell (PS5.1 -File is broken in the field)',async()=>{ + const f=await layout();try{ + await install(f.shim,{});await writeFile(join(f.shim,'pwsh.exe'),'fake'); + const r=await resolveMcode('mcode',{env:winEnv(f.shim),home:f.official,platform:'win32'}); + assert.match(r.command,/pwsh\.exe$/i); + }finally{await f.cleanup();} +}); diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs index fd6a0739..a7bcc64a 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/dist/main.mjs @@ -7707,7 +7707,7 @@ import { parseArgs } from "node:util"; import { resolve as resolve3, join as join4 } from "node:path"; import { fileURLToPath } from "node:url"; import { homedir as homedir2 } from "node:os"; -import { readFile as readFile2, writeFile, mkdir, open as open3, rename } from "node:fs/promises"; +import { readFile as readFile3, writeFile, mkdir, open as open3, rename } from "node:fs/promises"; import { spawn as spawn3 } from "node:child_process"; // src/store.mjs @@ -13939,7 +13939,7 @@ import { constants as constants2 } from "node:fs"; import { resolve as resolve2, relative, isAbsolute, sep } from "node:path"; // src/mcode-location.mjs -import { access, stat } from "node:fs/promises"; +import { access, stat, readFile, readdir } from "node:fs/promises"; import { constants } from "node:fs"; import { delimiter, dirname, join as join2, resolve } from "node:path"; import { homedir } from "node:os"; @@ -13958,7 +13958,8 @@ async function executablePath(command, env = process.env, platform = process.pla if (typeof command !== "string" || !command) return null; const direct = /[\\/]/.test(command); const dirs = direct ? [""] : (env.PATH ?? env.Path ?? "").split(platform === "win32" ? ";" : delimiter).filter(Boolean); - const extensions = platform === "win32" ? ["", ...(env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";")] : [""]; + const dotted = /\.[a-z0-9]+$/i.test(command); + const extensions = platform === "win32" ? [...dotted ? [""] : [], ...(env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean)] : [""]; for (const dir of dirs) for (const ext of extensions) { const file = direct ? resolve(command + ext) : resolve(join2(dir, command + ext)); if (await fileExists(file, true, platform)) return file; @@ -13974,15 +13975,43 @@ async function resolveMcode(command = "mcode", { env = process.env, home = homed } if (path) { if (platform === "win32" && /\.(cmd|bat)$/i.test(path)) { + const root = officialRoot(home, env); + const candidates = [ + join2(dirname(path), "node_modules", "@minimax-ai", "code", "cli.js"), + join2(root, "lib", "node_modules", "@minimax-ai", "code", "cli.js"), + join2(root, "node_modules", "@minimax-ai", "code", "cli.js") + ]; + try { + for (const entry of await readdir(join2(root, "releases"), { withFileTypes: true })) + if (entry.isDirectory()) candidates.push(join2(root, "releases", entry.name, "node_modules", "@minimax-ai", "code", "cli.js")); + } catch { + } + const versionOf = async (entry) => { + try { + const pkg = JSON.parse(await readFile(join2(entry, "..", "package.json"), "utf8")); + return String(pkg.version ?? "0.0.0").split(".").map((n) => Number.parseInt(n, 10) || 0); + } catch { + return [0, 0, 0]; + } + }; + const cmp = (a, b2) => a[0] - b2[0] || (a[1] ?? 0) - (b2[1] ?? 0) || (a[2] ?? 0) - (b2[2] ?? 0) || b2.length - a.length; + let best = null, bestVersion = null; + for (const entry of candidates) { + if (!await fileExists(entry)) continue; + const version3 = await versionOf(entry); + if (!best || cmp(version3, bestVersion) > 0) { + best = entry; + bestVersion = version3; + } + } + if (best) return { command: process.execPath, args: [best], source }; const launcher = join2(dirname(path), "mcode.ps1"); if (await fileExists(launcher)) { - const powershell = await executablePath("powershell.exe", env, platform) ?? await executablePath("pwsh.exe", env, platform); + const powershell = await executablePath("pwsh.exe", env, platform) ?? await executablePath("powershell.exe", env, platform); if (!powershell) throw new Error("\u53D1\u73B0 MCode PowerShell \u542F\u52A8\u5668\uFF0C\u4F46\u627E\u4E0D\u5230 PowerShell\u3002"); return { command: powershell, args: ["-NoProfile", "-File", launcher], source }; } - const entry = join2(dirname(path), "node_modules", "@minimax-ai", "code", "cli.js"); - if (!await fileExists(entry)) throw new Error(`\u53D1\u73B0 ${path}\uFF0C\u4F46\u627E\u4E0D\u5230\u53EF\u76F4\u63A5\u6267\u884C\u7684 cli.js\uFF1B\u8BF7\u4FEE\u590D\u8BE5 CLI \u5B89\u88C5\u3002`); - return { command: process.execPath, args: [entry], source }; + throw new Error(`\u53D1\u73B0 ${path}\uFF0C\u4F46\u627E\u4E0D\u5230\u53EF\u76F4\u63A5\u6267\u884C\u7684 cli.js\uFF1B\u8BF7\u4FEE\u590D\u8BE5 CLI \u5B89\u88C5\u3002`); } return { command: path, args: [], source }; } @@ -16697,7 +16726,7 @@ var REPORT_STYLES = contentStyles + reportStyles; // src/http.mjs import http from "node:http"; -import { readFile } from "node:fs/promises"; +import { readFile as readFile2 } from "node:fs/promises"; // node_modules/zod/v4/core/util.js var util_exports = {}; @@ -26531,7 +26560,7 @@ async function startHTTP(engine, { port = 0, webRoot = new URL("../web/", import const url = new URL(req.url, origin); if (url.pathname.startsWith("/api/")) { if (req.headers["x-workflow-client"] !== "1" || ["cross-site", "same-site"].includes(req.headers["sec-fetch-site"])) return json({ error: "\u8BF7\u4ECE\u672C\u5730 Workflow Studio \u9762\u677F\u8BBF\u95EE\u3002" }, 403); - if (req.method === "GET" && url.pathname === "/api/config") return json({ serviceProtocol: 2, features: { workflowRepair: true }, pid: process.pid, workspace: engine.options.workspace, executor: engine.options.command, defaults: engine.defaults, scheduler: engine.schedulerStatus(), mcodeAvailable: !!await resolveMcode(engine.options.command ?? "mcode"), example: await readFile(new URL("audit.js", exampleRoot), "utf8") }); + if (req.method === "GET" && url.pathname === "/api/config") return json({ serviceProtocol: 2, features: { workflowRepair: true }, pid: process.pid, workspace: engine.options.workspace, executor: engine.options.command, defaults: engine.defaults, scheduler: engine.schedulerStatus(), mcodeAvailable: !!await resolveMcode(engine.options.command ?? "mcode"), example: await readFile2(new URL("audit.js", exampleRoot), "utf8") }); if (req.method === "GET" && url.pathname === "/api/templates") return json(engine.store.templates().map(({ definition, ...t }) => ({ ...t, objective: definition.metadata?.objective ?? "" }))); const template = url.pathname.match(/^\/api\/templates\/([a-f0-9-]+)$/); if (template && req.method === "GET") { @@ -26586,7 +26615,7 @@ async function startHTTP(engine, { port = 0, webRoot = new URL("../web/", import res.writeHead(404); return res.end(); } - const data2 = await readFile(new URL(name, webRoot)); + const data2 = await readFile2(new URL(name, webRoot)); res.writeHead(200, { "Content-Type": name.endsWith(".js") ? "text/javascript; charset=utf-8" : name.endsWith(".css") ? "text/css; charset=utf-8" : "text/html; charset=utf-8", "Content-Security-Policy": `default-src 'self'; script-src 'self'; style-src 'self' 'sha256-${reportStyleHash}'; connect-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'`, "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff", "Cache-Control": "no-store" }); res.end(data2); } catch (e) { @@ -27522,7 +27551,7 @@ function createWorkspaceRouter({ binary, pluginRoot, dataRoot, extraArgs = [] }) // src/main.mjs var { values } = parseArgs({ options: { stdio: { type: "boolean" }, "stop-service": { type: "boolean" }, settings: { type: "string" }, workspace: { type: "string" }, "data-dir": { type: "string" }, port: { type: "string" }, "mcode-script": { type: "string" }, "worker-config": { type: "string" } } }); -var settings = values.settings ? JSON.parse(await readFile2(resolve3(values.settings), "utf8")) : {}; +var settings = values.settings ? JSON.parse(await readFile3(resolve3(values.settings), "utf8")) : {}; for (const key of Object.keys(settings)) if (!["workspace", "dataDir"].includes(key) || typeof settings[key] !== "string") throw Error("settings \u53EA\u5141\u8BB8 workspace/dataDir \u5B57\u7B26\u4E32"); if (values.port !== void 0 && (!/^\d+$/.test(values.port) || Number(values.port) > 65535)) throw Error("port \u5FC5\u987B\u662F 0\u201365535 \u7684\u6574\u6570"); var delay3 = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -27537,7 +27566,7 @@ var alive = (pid) => { }; async function readJSON(path) { try { - return JSON.parse(await readFile2(path, "utf8")); + return JSON.parse(await readFile3(path, "utf8")); } catch (e) { if (e.code === "ENOENT") return null; throw e; diff --git a/plugins/hetaoBackend/mcode-dynamic-workflows/src/mcode-location.mjs b/plugins/hetaoBackend/mcode-dynamic-workflows/src/mcode-location.mjs index 9f9db453..73ae01b5 100644 --- a/plugins/hetaoBackend/mcode-dynamic-workflows/src/mcode-location.mjs +++ b/plugins/hetaoBackend/mcode-dynamic-workflows/src/mcode-location.mjs @@ -1,4 +1,4 @@ -import { access, stat } from 'node:fs/promises'; +import { access, stat, readFile, readdir } from 'node:fs/promises'; import { constants } from 'node:fs'; import { delimiter, dirname, join, resolve } from 'node:path'; import { homedir } from 'node:os'; @@ -13,7 +13,15 @@ export async function executablePath(command, env = process.env, platform = proc if (typeof command !== 'string' || !command) return null; const direct = /[\\/]/.test(command); const dirs = direct ? [''] : (env.PATH ?? env.Path ?? '').split(platform === 'win32' ? ';' : delimiter).filter(Boolean); - const extensions = platform === 'win32' ? ['', ...(env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';')] : ['']; + // win32: an extensionless probe is only valid when the command itself already + // carries a PATHEXT extension (pwsh.exe). For bare names (mcode) install roots + // ship an extensionless POSIX shim beside mcode.cmd; matching '' first resolves + // to a script Windows cannot spawn (ENOENT), so bare names match PATHEXT + // variants only. POSIX keeps the bare name as the only form. + const dotted = /\.[a-z0-9]+$/i.test(command); + const extensions = platform === 'win32' + ? [...(dotted ? [''] : []), ...(env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean)] + : ['']; for (const dir of dirs) for (const ext of extensions) { const file = direct ? resolve(command + ext) : resolve(join(dir, command + ext)); if (await fileExists(file, true, platform)) return file; @@ -30,15 +38,47 @@ export async function resolveMcode(command = 'mcode', { env = process.env, home } if (path) { if (platform === 'win32' && /\.(cmd|bat)$/i.test(path)) { + // Prefer a directly spawnable node entry over the .ps1 hop: PowerShell 5.1 + // binds flag-shaped tokens (-input, --cwd ...) as its own named parameters + // under -File, which breaks the exec argv on real installs. When several + // installs coexist (PATH shim with an old sibling, newer official root), + // resolve the NEWEST cli.js across layouts instead of whatever sits next to + // the resolved shim — a stale 0.2.x entry lacks current exec flags. + const root = officialRoot(home, env); + const candidates = [ + join(dirname(path), 'node_modules', '@minimax-ai', 'code', 'cli.js'), + join(root, 'lib', 'node_modules', '@minimax-ai', 'code', 'cli.js'), + join(root, 'node_modules', '@minimax-ai', 'code', 'cli.js'), + ]; + // The staged-installer layout keeps the current CLI under + // releases//node_modules — the same entry .mcode-launcher.cmd + // targets. Older roots can leave a stale flat node_modules behind, so these + // compete on version like every other candidate. + try { + for (const entry of await readdir(join(root, 'releases'), { withFileTypes: true })) + if (entry.isDirectory()) candidates.push(join(root, 'releases', entry.name, 'node_modules', '@minimax-ai', 'code', 'cli.js')); + } catch { /* no releases directory */ } + const versionOf = async entry => { try { + const pkg = JSON.parse(await readFile(join(entry, '..', 'package.json'), 'utf8')); + return String(pkg.version ?? '0.0.0').split('.').map(n => Number.parseInt(n, 10) || 0); + } catch { return [0, 0, 0]; } }; + const cmp = (a, b) => a[0] - b[0] || (a[1] ?? 0) - (b[1] ?? 0) || (a[2] ?? 0) - (b[2] ?? 0) || b.length - a.length; + let best = null, bestVersion = null; + for (const entry of candidates) { + if (!await fileExists(entry)) continue; + const version = await versionOf(entry); + if (!best || cmp(version, bestVersion) > 0) { best = entry; bestVersion = version; } + } + if (best) return { command: process.execPath, args: [best], source }; const launcher = join(dirname(path), 'mcode.ps1'); if (await fileExists(launcher)) { - const powershell = await executablePath('powershell.exe', env, platform) ?? await executablePath('pwsh.exe', env, platform); + // pwsh (PS7) first: PS 5.1 binds flag-shaped argv as its own named parameters + // under -File and does not forward piped stdin through the nested invocation. + const powershell = await executablePath('pwsh.exe', env, platform) ?? await executablePath('powershell.exe', env, platform); if (!powershell) throw new Error('发现 MCode PowerShell 启动器,但找不到 PowerShell。'); return { command: powershell, args: ['-NoProfile', '-File', launcher], source }; } - const entry = join(dirname(path), 'node_modules', '@minimax-ai', 'code', 'cli.js'); - if (!await fileExists(entry)) throw new Error(`发现 ${path},但找不到可直接执行的 cli.js;请修复该 CLI 安装。`); - return { command: process.execPath, args: [entry], source }; + throw new Error(`发现 ${path},但找不到可直接执行的 cli.js;请修复该 CLI 安装。`); } return { command: path, args: [], source }; }