Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion packages/tui/src/update/install-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/';
Expand Down Expand Up @@ -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<string, unknown>;
return metadata.product === 'minimax-code' && metadata.updateOwner === 'mcode-installer';
} catch {
return false;
}
}

export async function detectMcodeInstallSource(
dependencies: Partial<DetectMcodeInstallSourceDependencies> & { installRoot: string },
): Promise<McodeInstallSource> {
Expand Down
2 changes: 1 addition & 1 deletion packages/tui/src/update/prefix-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 21 additions & 16 deletions packages/tui/src/update/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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<string, unknown>;
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');
Expand Down Expand Up @@ -374,11 +368,22 @@ async function defaultInstallArtifact(input: {
}

async function defaultValidateInstalledVersion(prefix: string, version: string): Promise<void> {
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()}`);
}
Expand Down Expand Up @@ -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'));
Expand Down
98 changes: 97 additions & 1 deletion test/windows-contract.test.mjs
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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);
});
Loading