diff --git a/packages/tui/src/update/install-source.ts b/packages/tui/src/update/install-source.ts index 356fd1b1..7882498d 100644 --- a/packages/tui/src/update/install-source.ts +++ b/packages/tui/src/update/install-source.ts @@ -2,7 +2,6 @@ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'; import path from 'node:path'; import spawn from 'cross-spawn'; import { parseMcodeVersion } from './release.js'; -import { isManagedMcodeInstallRoot } from './service.js'; import type { McodeUpdateOperationOptions } from './progress.js'; export const MCODE_INTERNAL_NPM_REGISTRY = 'https://npmmirror.example.invalid/'; @@ -72,6 +71,16 @@ export interface DetectMcodeInstallSourceDependencies { readonly prefixInstall: () => McodeNpmPrefixInstall | undefined; } +export function isManagedMcodeInstallRoot(installRoot: string): boolean { + const metadataFile = path.join(installRoot, 'install.json'); + try { + const metadata = JSON.parse(readFileSync(metadataFile, 'utf8')) as Record; + return metadata.product === 'minimax-code' && metadata.updateOwner === 'mcode-installer'; + } catch { + return false; + } +} + export async function detectMcodeInstallSource( dependencies: Partial & { installRoot: string }, ): Promise { diff --git a/packages/tui/src/update/prefix-update.ts b/packages/tui/src/update/prefix-update.ts index 5b98e215..ae1435fd 100644 --- a/packages/tui/src/update/prefix-update.ts +++ b/packages/tui/src/update/prefix-update.ts @@ -704,7 +704,7 @@ function platformPathFor(platform: NodeJS.Platform): McodePlatformPath { return platform === 'win32' ? path.win32 : path.posix; } -function readMcodeBinEntry(value: unknown, name: 'mcode' | 'mcode-tools'): string | undefined { +export function readMcodeBinEntry(value: unknown, name: 'mcode' | 'mcode-tools'): string | undefined { const entry = typeof value === 'string' && name === 'mcode' ? value diff --git a/packages/tui/src/update/service.ts b/packages/tui/src/update/service.ts index 0cb1e52c..baf2b817 100644 --- a/packages/tui/src/update/service.ts +++ b/packages/tui/src/update/service.ts @@ -5,6 +5,8 @@ import path from 'node:path'; import spawn from 'cross-spawn'; import { EnvHttpProxyAgent, fetch } from 'undici'; import { retryWindowsFileSystemOperation } from '@mavis/shared'; +import { resolveMcodeNpmDistribution } from './install-source.js'; +import { readMcodeBinEntry, resolveMcodePrefixPackageRoot } from './prefix-update.js'; import { McodeUpdateCancelledError, reportMcodeUpdatePhase, @@ -22,6 +24,8 @@ import { type McodeUpdateChannel, } from './release.js'; +export { isManagedMcodeInstallRoot } from './install-source.js'; + const DEFAULT_RELEASE_BASE_URL = 'https://algeng-ali-shanghai-agent-02.oss-cn-shanghai.aliyuncs.com/' + 'minimax-dialogue/data/env/.npm-global/mcode'; @@ -289,16 +293,6 @@ export function readMcodeUpdateChannel(installRoot: string): McodeUpdateChannel return parseMcodeUpdateChannel(parsed.channel); } -export function isManagedMcodeInstallRoot(installRoot: string): boolean { - const metadataFile = path.join(installRoot, 'install.json'); - try { - const metadata = JSON.parse(readFileSync(metadataFile, 'utf8')) as Record; - return metadata.product === 'minimax-code' && metadata.updateOwner === 'mcode-installer'; - } catch { - return false; - } -} - function readInstalledPublicKey(installRoot: string, environment: NodeJS.ProcessEnv): string { const explicitFile = environment.MCODE_RELEASE_PUBLIC_KEY_FILE; if (explicitFile) return readFileSync(path.resolve(explicitFile), 'utf8'); @@ -374,11 +368,22 @@ async function defaultInstallArtifact(input: { } async function defaultValidateInstalledVersion(prefix: string, version: string): Promise { - const executable = - process.platform === 'win32' - ? path.join(prefix, 'mcode.cmd') - : path.join(prefix, 'bin', 'mcode'); - const output = await runMcodeUpdateCommand(executable, ['--version'], process.env, true); + let executable = path.join(prefix, 'bin', 'mcode'); + let args = ['--version']; + if (process.platform === 'win32') { + const packageRoot = resolveMcodePrefixPackageRoot( + prefix, + resolveMcodeNpmDistribution().packageName, + ); + const manifest = JSON.parse(readFileSync(path.join(packageRoot, 'package.json'), 'utf8')); + const binEntry = readMcodeBinEntry(manifest.bin, 'mcode'); + if (!binEntry || !existsSync(path.join(prefix, 'mcode.cmd'))) { + throw new Error(`Installed MCode launcher is missing or invalid at ${prefix}.`); + } + executable = process.execPath; + args = [path.join(packageRoot, binEntry), '--version']; + } + const output = await runMcodeUpdateCommand(executable, args, process.env, true); if (output.trim() !== version) { throw new Error(`Installed MCode version mismatch: expected ${version}, got ${output.trim()}`); } @@ -407,7 +412,7 @@ export function runMcodeUpdateCommand( settled = true; reject(error); }); - child.once('exit', (code, exitSignal) => { + child.once('close', (code, exitSignal) => { if (settled) return; settled = true; if (code === 0) return resolve(Buffer.concat(stdout).toString('utf8')); diff --git a/test/windows-contract.test.mjs b/test/windows-contract.test.mjs index f2005805..517e0c0f 100644 --- a/test/windows-contract.test.mjs +++ b/test/windows-contract.test.mjs @@ -1,9 +1,22 @@ import assert from "node:assert/strict"; -import { describe, it } from "vitest"; +import { execFileSync } from "node:child_process"; +import { createHash, generateKeyPairSync, sign } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, it } from "vitest"; import { checkWindowsSourceLocation } from "../scripts/check-windows-source-location.mjs"; import { resolveWslPath } from "../packages/tui/src/host/wsl-path.js"; +import { McodeUpdateService } from "../packages/tui/src/update/service.js"; const windowsPath = String.raw`D:\Users\demo\Documents\Screen shots\截图.png`; +const temporaryRoots = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true, maxRetries: 4 }); + } +}); describe.skipIf(process.platform !== "win32")("Windows source contract", () => { it("accepts the Windows checkout on a local NTFS volume", () => { @@ -16,4 +29,87 @@ describe.skipIf(process.platform !== "win32")("Windows source contract", () => { it("preserves Windows path syntax on the native host", async () => { assert.equal(await resolveWslPath(windowsPath), windowsPath); }); + + it.each(["standard npm", "custom npm wrapper", "custom npm wrapper with adjacent CLI"])("installs and validates a managed update in a complex path with %s", async (npmLayout) => { + const root = mkdtempSync(path.join(tmpdir(), "mcode-update-")); + temporaryRoots.push(root); + const fixtureRoot = path.join(root, "package"); + mkdirSync(fixtureRoot); + writeFileSync(path.join(fixtureRoot, "package.json"), JSON.stringify({ + name: "@minimax-ai/code", + version: "1.2.4", + bin: { mcode: "cli.cjs" }, + })); + writeFileSync(path.join(fixtureRoot, "cli.cjs"), "#!/usr/bin/env node\nconsole.log('1.2.4');\n"); + const environment = { + ...process.env, + npm_config_offline: "true", + npm_config_update_notifier: "false", + npm_config_bin_links: "true", + npm_config_cache: path.join(root, "cache"), + npm_config_userconfig: path.join(root, "npmrc"), + }; + writeFileSync(environment.npm_config_userconfig, ""); + const npmCli = path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"); + const packed = JSON.parse(execFileSync(process.execPath, [ + npmCli, "pack", "--json", "--ignore-scripts", "--pack-destination", root, + ], { cwd: fixtureRoot, env: environment, encoding: "utf8", timeout: 30_000 })); + if (npmLayout !== "standard npm") { + const wrapperRoot = path.join(root, "npm-wrapper"); + mkdirSync(wrapperRoot); + let wrapperCli = npmCli; + if (npmLayout === "custom npm wrapper with adjacent CLI") { + wrapperCli = path.join(wrapperRoot, "node_modules", "npm", "bin", "npm-cli.js"); + mkdirSync(path.dirname(wrapperCli), { recursive: true }); + writeFileSync(wrapperCli, `require(${JSON.stringify(npmCli)});\n`); + } + environment.npm_config_bin_links = "false"; + writeFileSync(path.join(wrapperRoot, "npm.cmd"), `@echo off\r\nset "npm_config_bin_links=true"\r\necho used> "%~dp0invoked"\r\n"${process.execPath}" "${wrapperCli}" %*\r\n`); + const pathKey = Object.keys(environment).filter((key) => key.toLowerCase() === "path").sort()[0]; + const inheritedPath = environment[pathKey]; + for (const key of Object.keys(environment)) { + if (key.toLowerCase() === "path") delete environment[key]; + } + environment.PATH = [wrapperRoot, inheritedPath].filter(Boolean).join(path.delimiter); + } + const artifact = readFileSync(path.join(root, packed[0].filename)); + const { privateKey, publicKey } = generateKeyPairSync("ed25519"); + const manifest = Buffer.from(JSON.stringify({ + schemaVersion: 1, + product: "minimax-code", + channel: "stable", + version: "1.2.4", + publishedAt: "2026-09-24T00:00:00.000Z", + minNodeVersion: "22.19.0", + registry: "https://registry.npmjs.org/", + installArtifact: { + url: "https://updates.example.invalid/mcode.tgz", + sha256: createHash("sha256").update(artifact).digest("hex"), + size: artifact.length, + }, + targets: Object.fromEntries([ + "darwin-arm64", "darwin-x64", "linux-x64", "windows-x64", "windows-arm64", + ].map((target) => [target, { sha256: "a".repeat(64), size: 1 }])), + })); + const signature = Buffer.from(sign(null, manifest, privateKey).toString("base64")); + const installRoot = path.join(root, "用户 files & (test)"); + mkdirSync(installRoot); + writeFileSync(path.join(installRoot, "current"), "1.2.3\n"); + const service = new McodeUpdateService({ + currentVersion: "1.2.3", + installRoot, + environment, + publicKey: publicKey.export({ format: "pem", type: "spki" }).toString(), + releaseBaseUrl: "https://updates.example.invalid", + dependencies: { + fetchBytes: async (url) => url.endsWith(".sig") ? signature : url.endsWith(".tgz") ? artifact : manifest, + }, + }); + const result = await service.apply({ channel: "stable" }); + assert.equal(result.applied, true); + assert.equal(readFileSync(path.join(installRoot, "current"), "utf8"), "1.2.4\n"); + if (npmLayout !== "standard npm") { + assert.match(readFileSync(path.join(root, "npm-wrapper", "invoked"), "utf8"), /^used/); + } + }, 60_000); });