From 129e3e82831a7360bacbe9f8de3a09b6bef589bf Mon Sep 17 00:00:00 2001 From: jiachen Date: Thu, 8 Oct 2026 11:46:50 +0800 Subject: [PATCH 1/3] fix(tui): keep npm-prefix updates on the Node pinned by the receipt The versioned npm-prefix update flow pins every release launcher to one Node executable so the better-sqlite3 binding stays ABI-consistent, and records that executable in install.json. The launcher runtime, however, was taken from process.execPath: on macOS, execPath resolves Homebrew's stable bin/node symlink into a versioned Cellar keg such as Cellar/node/26.7.0/bin/node. After `brew upgrade` removes the old keg, self-updated launchers exec a path that no longer exists and the CLI fails to start with a bare "not found". Reuse the nodeExecutable recorded in the install receipt when it is still present, so launcher activation and the npm/npm validation child processes stay on the pinned executable instead of re-resolving it through process.execPath. Receipts without a usable nodeExecutable keep the previous process.execPath behavior. Versioned launchers now also print an actionable repair hint and exit 127 when their pinned Node is missing, instead of a bare exec failure. Homebrew same-major upgrades keep the ABI stable, so following the formula-managed symlink is safe; a cross-major upgrade surfaces through the existing SQLite validation and installer repair flow. Assisted-by: pi --- packages/tui/src/update/application.ts | 6 +- packages/tui/src/update/install-source.ts | 14 ++ packages/tui/src/update/versioned-prefix.ts | 2 +- packages/tui/test/unit/update-runtime.test.ts | 228 ++++++++++++++++++ release/public-source.json | 1 + test/vitest-suites.json | 1 + 6 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 packages/tui/test/unit/update-runtime.test.ts diff --git a/packages/tui/src/update/application.ts b/packages/tui/src/update/application.ts index b1f8f4a92..2d11c5a52 100644 --- a/packages/tui/src/update/application.ts +++ b/packages/tui/src/update/application.ts @@ -160,9 +160,13 @@ export class McodeUpdateApplication { this.platform = platform; this.environment = environment; this.entryFile = options.entryFile ?? process.argv[1]; - this.runtimeExecutable = options.runtimeExecutable ?? process.execPath; this.prefixInstall = options.prefixInstall ?? resolveMcodeNpmPrefixInstall(this.entryFile, platform); + // Keep updates on the Node executable recorded in the install receipt. On + // macOS process.execPath resolves Homebrew's stable bin/node symlink into a + // versioned Cellar path that `brew upgrade` removes. + this.runtimeExecutable = + options.runtimeExecutable ?? this.prefixInstall?.nodeExecutable ?? process.execPath; const packageManagerEnvironment = this.prefixInstall ? createMcodeNpmRuntimeEnvironment(environment, this.runtimeExecutable, platform) : environment; diff --git a/packages/tui/src/update/install-source.ts b/packages/tui/src/update/install-source.ts index 7882498d3..2f0d5693f 100644 --- a/packages/tui/src/update/install-source.ts +++ b/packages/tui/src/update/install-source.ts @@ -44,6 +44,12 @@ export interface McodeNpmPrefixInstall { readonly packageName: McodeNpmPackageName; readonly prefix: string; readonly registry: string; + /** + * Node executable pinned by the install receipt. `process.execPath` resolves + * Homebrew's stable bin/node symlink into a versioned Cellar path that + * `brew upgrade` removes, so updates reuse the recorded path when present. + */ + readonly nodeExecutable?: string; } export type McodePackageManagerRunOptions = McodeUpdateOperationOptions; @@ -587,6 +593,7 @@ function readNpmPrefixReceipt( distTag?: unknown; prefix?: unknown; npmExecutable?: unknown; + nodeExecutable?: unknown; layoutVersion?: unknown; releasesDirectory?: unknown; currentFile?: unknown; @@ -639,11 +646,18 @@ function readNpmPrefixReceipt( return undefined; } } + const nodeExecutable = + typeof value.nodeExecutable === 'string' && + platformPath.isAbsolute(value.nodeExecutable) && + existsSync(value.nodeExecutable) + ? value.nodeExecutable + : undefined; return { executable: value.npmExecutable, packageName, prefix, registry: distribution.registry, + ...(nodeExecutable ? { nodeExecutable } : {}), }; } catch { return undefined; diff --git a/packages/tui/src/update/versioned-prefix.ts b/packages/tui/src/update/versioned-prefix.ts index a19eafffc..68491f98e 100644 --- a/packages/tui/src/update/versioned-prefix.ts +++ b/packages/tui/src/update/versioned-prefix.ts @@ -215,7 +215,7 @@ function versionedReleaseLauncherContents( launchers.set( platformPath.join(releasePrefix, command.releaseLauncher), - `#!/bin/sh\nset -eu\nroot=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)\nexec ${quoteShell(runtimeExecutable)} "$root/${escapeDoubleQuoted(relativeEntry)}" "$@"\n`, + `#!/bin/sh\nset -eu\nroot=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)\nnode=${quoteShell(runtimeExecutable)}\nif [ ! -x "$node" ]; then\n printf "%s\\n" "MCode: Node runtime is missing: $node" "MCode: a system upgrade such as brew upgrade may have removed it." "MCode: re-run the MCode installer to repair this installation." >&2\n exit 127\nfi\nexec "$node" "$root/${escapeDoubleQuoted(relativeEntry)}" "$@"\n`, ); } return launchers; diff --git a/packages/tui/test/unit/update-runtime.test.ts b/packages/tui/test/unit/update-runtime.test.ts new file mode 100644 index 000000000..6b3d2c951 --- /dev/null +++ b/packages/tui/test/unit/update-runtime.test.ts @@ -0,0 +1,228 @@ +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { McodeUpdateApplication } from '../../src/update/application.js'; +import { createMcodeNpmRuntimeEnvironment } from '../../src/update/install-source.js'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe('npm-prefix runtime consistency', () => { + it('normalizes Windows PATH aliases without mutating the parent environment', () => { + const environment = { Path: 'C:\\system;C:\\tools', PATH: 'C:\\other', TOKEN: 'keep' }; + const updated = createMcodeNpmRuntimeEnvironment( + environment, + 'C:\\mcode runtime\\node.exe', + 'win32', + ); + expect(updated).toEqual({ PATH: 'C:\\mcode runtime;C:\\other', TOKEN: 'keep' }); + expect(environment.Path).toBe('C:\\system;C:\\tools'); + expect(environment.PATH).toBe('C:\\other'); + expect( + createMcodeNpmRuntimeEnvironment({ Path: 'C:\\system' }, 'C:\\runtime\\node.exe', 'win32'), + ).toEqual({ PATH: 'C:\\runtime;C:\\system' }); + }); + + it('preserves non-PATH variables and supports an absent Unix PATH', () => { + expect( + createMcodeNpmRuntimeEnvironment( + { Path: 'case-sensitive' }, + '/private/node/bin/node', + 'darwin', + ), + ).toEqual({ Path: 'case-sensitive', PATH: '/private/node/bin' }); + }); + + it.each([ + [false, 'npm'], + [true, 'npm'], + [false, 'npm.cmd'], + ] as const)( + 'runs npm with the launcher Node and gates activation on SQLite (broken=%s, shim=%s)', + async (broken, shim) => { + const root = mkdtempSync(path.join(os.tmpdir(), 'mcode-update-runtime-')); + roots.push(root); + const prefix = path.join(root, 'install'); + const npm = path.join(root, shim); + const npmCli = path.join(root, 'node_modules/npm/bin/npm-cli.js'); + mkdirSync(path.dirname(npmCli), { recursive: true }); + // Neither a Unix shim nor npm.cmd may choose its own runtime. + writeFileSync(npm, '#!/bin/sh\nexit 42\n', { mode: 0o755 }); + const shadowBin = path.join(root, 'shadow'); + mkdirSync(shadowBin); + mkdirSync(prefix); + writeFileSync(path.join(prefix, 'current'), '1.2.3\n'); + // If npm resolves Node from the inherited PATH, it fails before installing. + writeFileSync(path.join(shadowBin, 'node'), '#!/bin/sh\nexit 42\n', { mode: 0o755 }); + const sqlite = broken + ? 'module.exports = class { constructor() { throw new Error("NODE_MODULE_VERSION mismatch"); } };' + : `module.exports = class { + constructor(file) { if (file !== ':memory:') throw new Error('Unexpected database'); } + prepare(sql) { if (sql !== 'SELECT 1 AS value') throw new Error('Unexpected query'); return { get: () => ({ value: 1 }) }; } + close() {} + };`; + writeFileSync( + npmCli, + `#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +if (process.execPath !== ${JSON.stringify(process.execPath)}) throw new Error('Wrong npm Node'); +const lifecycle = require('node:child_process').spawnSync('node', ['-p', 'process.execPath'], {encoding:'utf8'}); +if (lifecycle.status !== 0 || lifecycle.stdout.trim() !== process.execPath) throw new Error('Wrong lifecycle Node'); +if (process.argv.includes('view')) { console.log(JSON.stringify('1.2.4')); process.exit(0); } +const prefix = process.argv[process.argv.indexOf('--prefix') + 1]; +const root = path.join(prefix, process.platform === 'win32' ? 'node_modules/@minimax-ai/code' : 'lib/node_modules/@minimax-ai/code'); +fs.mkdirSync(path.join(root, 'node_modules/better-sqlite3'), { recursive: true }); +fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({name:'@minimax-ai/code',version:'1.2.4',bin:{mcode:'cli.js','mcode-tools':'tools.js'}})); +fs.writeFileSync(path.join(root, 'cli.js'), 'console.log("1.2.4");'); +fs.writeFileSync(path.join(root, 'tools.js'), 'console.log("mcode-tools 1.2.4");'); +fs.writeFileSync(path.join(root, 'node_modules/better-sqlite3/index.js'), ${JSON.stringify(sqlite)}); +`, + { mode: 0o755 }, + ); + chmodSync(npm, 0o755); + const environment = { + ...process.env, + PATH: `${shadowBin}${path.delimiter}${process.env.PATH ?? ''}`, + }; + const application = new McodeUpdateApplication( + { + currentVersion: '1.2.3', + entryFile: path.join(prefix, 'missing-old-entry.js'), + runtimeExecutable: process.execPath, + environment, + prefixInstall: { + executable: npm, + prefix, + packageName: '@minimax-ai/code', + registry: 'https://registry.npmjs.org/', + }, + }, + { + detectInstallSource: async () => 'npm-prefix', + }, + ); + const plan = await application.inspect(); + if (broken) { + await expect(application.apply(plan)).rejects.toThrow('Staged SQLite validation failed'); + expect(readFileSync(path.join(prefix, 'current'), 'utf8')).toBe('1.2.3\n'); + } else { + await expect(application.apply(plan)).resolves.toMatchObject({ applied: true }); + expect(readFileSync(path.join(prefix, 'current'), 'utf8')).toBe('1.2.4\n'); + expect( + JSON.parse(readFileSync(path.join(prefix, 'install.json'), 'utf8')).nodeExecutable, + ).toBe(process.execPath); + } + expect(environment.PATH.startsWith(shadowBin)).toBe(true); + }, + ); + + it('pins npm-prefix updates to the receipt Node across Homebrew-style upgrades', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'mcode-update-runtime-')); + roots.push(root); + const prefix = path.join(root, 'install'); + const npm = path.join(root, 'npm'); + const npmCli = path.join(root, 'node_modules/npm/bin/npm-cli.js'); + const brewBin = path.join(root, 'homebrew', 'bin'); + const pinnedNode = path.join(brewBin, 'node'); + mkdirSync(path.dirname(npmCli), { recursive: true }); + mkdirSync(brewBin, { recursive: true }); + writeFileSync(npm, '#!/bin/sh\nexit 42\n', { mode: 0o755 }); + symlinkSync(process.execPath, pinnedNode); + const packageRoot = path.join( + prefix, + 'releases', + '1.2.3', + 'lib', + 'node_modules', + '@minimax', + 'code', + ); + mkdirSync(packageRoot, { recursive: true }); + writeFileSync( + path.join(packageRoot, 'package.json'), + JSON.stringify({ + name: '@minimax-ai/code', + version: '1.2.3', + bin: { mcode: 'cli.js', 'mcode-tools': 'tools.js' }, + }), + ); + writeFileSync(path.join(packageRoot, 'cli.js'), 'console.log("1.2.3");'); + writeFileSync(path.join(prefix, 'current'), '1.2.3\n'); + writeFileSync( + path.join(prefix, 'install.json'), + JSON.stringify({ + schemaVersion: 2, + product: 'minimax-code', + updateOwner: 'npm-prefix', + packageManager: 'npm', + packageName: '@minimax-ai/code', + registry: 'https://registry.npmjs.org/', + distTag: 'latest', + npmExecutable: npm, + nodeExecutable: pinnedNode, + prefix, + layoutVersion: 2, + releasesDirectory: 'releases', + currentFile: 'current', + }), + ); + writeFileSync( + npmCli, + `#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +if (process.argv.includes('view')) { console.log(JSON.stringify('1.2.4')); process.exit(0); } +const prefix = process.argv[process.argv.indexOf('--prefix') + 1]; +const root = path.join(prefix, 'lib/node_modules/@minimax-ai/code'); +fs.mkdirSync(path.join(root, 'node_modules/better-sqlite3'), { recursive: true }); +fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({name:'@minimax-ai/code',version:'1.2.4',bin:{mcode:'cli.js','mcode-tools':'tools.js'}})); +fs.writeFileSync(path.join(root, 'cli.js'), 'console.log("1.2.4");'); +fs.writeFileSync(path.join(root, 'tools.js'), 'console.log("mcode-tools 1.2.4");'); +fs.writeFileSync(path.join(root, 'node_modules/better-sqlite3/index.js'), 'module.exports = class { constructor() {} prepare() { return { get: () => ({ value: 1 }) }; } close() {} };'); +`, + { mode: 0o755 }, + ); + + const application = new McodeUpdateApplication( + { + currentVersion: '1.2.3', + entryFile: path.join(packageRoot, 'cli.js'), + environment: { ...process.env }, + }, + { + detectInstallSource: async () => 'npm-prefix', + }, + ); + await expect(application.apply(await application.inspect())).resolves.toMatchObject({ + applied: true, + }); + expect(readFileSync(path.join(prefix, 'current'), 'utf8')).toBe('1.2.4\n'); + expect(JSON.parse(readFileSync(path.join(prefix, 'install.json'), 'utf8')).nodeExecutable).toBe( + pinnedNode, + ); + const releaseLauncher = path.join(prefix, 'releases', '1.2.4', '.mcode-launcher'); + const launcherContents = readFileSync(releaseLauncher, 'utf8'); + expect(launcherContents).toContain(`node='${pinnedNode}'`); + expect(launcherContents).not.toContain(process.execPath); + expect(spawnSync(releaseLauncher, ['--version']).stdout.toString()).toBe('1.2.4\n'); + + rmSync(pinnedNode); + const missing = spawnSync(releaseLauncher, ['--version']); + expect(missing.status).toBe(127); + expect(missing.stderr.toString()).toMatch(/Node runtime is missing/u); + expect(missing.stderr.toString()).toMatch(/re-run the MCode installer/u); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index 28bc3882f..7539f8cd2 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3397,6 +3397,7 @@ "packages/tui/test/unit/tui/transcript/presentation/literal-text.test.ts", "packages/tui/test/unit/tui/widgets/editor/editor-behavior.test.ts", "packages/tui/test/unit/update-application.test.ts", + "packages/tui/test/unit/update-runtime.test.ts", "packages/tui/test/unit/update-service.test.ts", "packages/tui/test/unit/wsl-attachment-paths.test.ts", "pnpm-lock.yaml", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 8b038ad20..a30ece859 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -33,6 +33,7 @@ "packages/tui/test/unit/runtime-feedback-diagnostic-upload.test.ts", "packages/tui/test/unit/update-service.test.ts", "packages/tui/test/unit/update-application.test.ts", + "packages/tui/test/unit/update-runtime.test.ts", "packages/tui/test/unit/acp-agent.test.ts", "packages/mcode-tools-host/test/unit/lease-broker.test.ts", "packages/mcode-tools-host/test/unit/resource.test.ts", From 386b7cecca76a734fb9b50774267d89036258257 Mon Sep 17 00:00:00 2001 From: jiachen Date: Thu, 8 Oct 2026 14:58:09 +0800 Subject: [PATCH 2/3] fix(tui): heal Cellar-pinned Node receipts to the Homebrew opt path Review follow-up for npm-prefix updates on Homebrew-managed Node. Receipts written by earlier installers record the resolved Cellar keg path, so simply preferring the receipt's nodeExecutable still breaks after `brew upgrade` removes the keg. Canonicalize the recorded path at receipt parse time: when it points into a versioned Cellar/node keg (or a Homebrew-managed bin/node) and the formula's stable opt//bin/node resolves to the same executable, use the opt path instead. The opt directory also keeps the update PATH prefix scoped to the Node formula rather than all of /bin. Unusable entries (missing, relative, non-file, non-executable) fall back to process.execPath as before. Also point the native SQLite startup diagnostic at rerunning the installer for npm-prefix installs instead of an npm-global reinstall command. Assisted-by: pi --- packages/tui/src/cli/main.ts | 12 +- packages/tui/src/update/install-source.ts | 67 ++++-- packages/tui/test/unit/update-runtime.test.ts | 205 +++++++++++++++++- 3 files changed, 267 insertions(+), 17 deletions(-) diff --git a/packages/tui/src/cli/main.ts b/packages/tui/src/cli/main.ts index cb2cf2adf..4b2fbf634 100644 --- a/packages/tui/src/cli/main.ts +++ b/packages/tui/src/cli/main.ts @@ -221,7 +221,17 @@ async function formatTuiCliError(error: unknown): Promise { return diagnostic; } - const { buildMcodePackageManagerCommand } = await import('../update/install-source.js'); + const { buildMcodePackageManagerCommand, resolveMcodeNpmPrefixInstall } = + await import('../update/install-source.js'); + if (resolveMcodeNpmPrefixInstall()) { + return [ + 'MCode could not load its native SQLite dependency.', + 'The Node runtime recorded by this installation no longer loads it (for example after a Homebrew Node upgrade).', + 'Reinstall with the original installer to repair this installation.', + '', + `Original error: ${diagnostic}`, + ].join('\n'); + } const command = buildMcodePackageManagerCommand('npm-global', MINIMAX_CODE_VERSION); return [ 'MCode could not load its native SQLite dependency.', diff --git a/packages/tui/src/update/install-source.ts b/packages/tui/src/update/install-source.ts index 2f0d5693f..6961878b0 100644 --- a/packages/tui/src/update/install-source.ts +++ b/packages/tui/src/update/install-source.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'; +import { accessSync, constants, existsSync, readFileSync, realpathSync, statSync } from 'node:fs'; import path from 'node:path'; import spawn from 'cross-spawn'; import { parseMcodeVersion } from './release.js'; @@ -22,16 +22,10 @@ declare const __TUI_BUILD_ENV__: TuiBuildEnvironment | undefined; declare const __TUI_NPM_DIST_TAG__: McodeNpmDistTag | undefined; export type McodePackageManagerInstallSource = - | 'npm-global' - | 'npm-prefix' - | 'pnpm-global' - | 'yarn-global' - | 'bun-global'; + 'npm-global' | 'npm-prefix' | 'pnpm-global' | 'yarn-global' | 'bun-global'; export type McodeInstallSource = - | 'managed-installer' - | McodePackageManagerInstallSource - | 'unsupported'; + 'managed-installer' | McodePackageManagerInstallSource | 'unsupported'; export interface McodePackageManagerCommand { readonly executable: string; @@ -360,8 +354,11 @@ function resolvePnpmNpmShim(npmExecutable: string): string | undefined { if (lines[0] !== '#!/bin/sh') return undefined; const commands = lines.map((line) => line.trim()).filter((line) => /^exec\s/u.test(line)); if (commands.length === 0) return undefined; - const targets = commands.map((line) => - /^exec (?:"\$basedir\/node"|node) "\$basedir\/(nodejs\/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\/lib\/node_modules\/npm\/bin\/npm-cli\.js)" "\$@"$/u.exec(line)?.[1], + const targets = commands.map( + (line) => + /^exec (?:"\$basedir\/node"|node) "\$basedir\/(nodejs\/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\/lib\/node_modules\/npm\/bin\/npm-cli\.js)" "\$@"$/u.exec( + line, + )?.[1], ); const target = targets[0]; if (!target || targets.some((candidate) => candidate !== target)) return undefined; @@ -647,10 +644,8 @@ function readNpmPrefixReceipt( } } const nodeExecutable = - typeof value.nodeExecutable === 'string' && - platformPath.isAbsolute(value.nodeExecutable) && - existsSync(value.nodeExecutable) - ? value.nodeExecutable + typeof value.nodeExecutable === 'string' && platformPath.isAbsolute(value.nodeExecutable) + ? canonicalReceiptNodeExecutable(value.nodeExecutable) : undefined; return { executable: value.npmExecutable, @@ -664,6 +659,48 @@ function readNpmPrefixReceipt( } } +// Homebrew deletes versioned Cellar kegs on `brew upgrade` while the +// formula-managed opt/ symlink keeps tracking the installed version, +// and keeps the update PATH prefix scoped to the Node formula instead of all +// of /bin. Receipts that recorded a Cellar keg (or the linked bin/node) +// therefore heal to the stable opt path when both resolve to the same +// executable; everything else keeps the recorded path unchanged. +const HOMEBREW_CELLAR_NODE_PATH = + /^(?.+)\/Cellar\/(?node(?:@\d+(?:\.\d+)*)?)\/[^/]+\/bin\/node$/u; +const HOMEBREW_MANAGED_NODE_PATH = + /^(?\/(?:opt\/homebrew|usr\/local|home\/linuxbrew\/.linuxbrew))\/(?:(?node(?:@\d+(?:\.\d+)*)?)\/bin\/node|bin\/node)$/u; + +function canonicalReceiptNodeExecutable(nodeExecutable: string): string | undefined { + const canonical = canonicalHomebrewNodeExecutable(nodeExecutable); + return usableNodeExecutable(canonical) ?? usableNodeExecutable(nodeExecutable); +} + +function canonicalHomebrewNodeExecutable(nodeExecutable: string): string { + const cellar = HOMEBREW_CELLAR_NODE_PATH.exec(nodeExecutable); + const managed = HOMEBREW_MANAGED_NODE_PATH.exec(nodeExecutable); + const prefix = cellar?.groups?.prefix ?? managed?.groups?.prefix; + if (!prefix) return nodeExecutable; + const formula = cellar?.groups?.formula ?? managed?.groups?.formula ?? 'node'; + const candidate = `${prefix}/opt/${formula}/bin/node`; + if (candidate === nodeExecutable) return nodeExecutable; + try { + if (realpathSync(candidate) === realpathSync(nodeExecutable)) return candidate; + } catch { + // Keep the recorded path when either side does not resolve. + } + return nodeExecutable; +} + +function usableNodeExecutable(nodeExecutable: string): string | undefined { + try { + if (!statSync(nodeExecutable).isFile()) return undefined; + accessSync(nodeExecutable, constants.X_OK); + return nodeExecutable; + } catch { + return undefined; + } +} + function isResolvedPathInside( root: string, candidate: string, diff --git a/packages/tui/test/unit/update-runtime.test.ts b/packages/tui/test/unit/update-runtime.test.ts index 6b3d2c951..9a2bb39e7 100644 --- a/packages/tui/test/unit/update-runtime.test.ts +++ b/packages/tui/test/unit/update-runtime.test.ts @@ -1,5 +1,7 @@ import { chmodSync, + copyFileSync, + linkSync, mkdirSync, mkdtempSync, readFileSync, @@ -12,7 +14,10 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { McodeUpdateApplication } from '../../src/update/application.js'; -import { createMcodeNpmRuntimeEnvironment } from '../../src/update/install-source.js'; +import { + createMcodeNpmRuntimeEnvironment, + resolveMcodeNpmPrefixInstall, +} from '../../src/update/install-source.js'; const roots: string[] = []; afterEach(() => { @@ -225,4 +230,202 @@ fs.writeFileSync(path.join(root, 'node_modules/better-sqlite3/index.js'), 'modul expect(missing.stderr.toString()).toMatch(/Node runtime is missing/u); expect(missing.stderr.toString()).toMatch(/re-run the MCode installer/u); }); + + it('heals receipts pinned to a Homebrew Cellar keg via the stable opt path', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'mcode-update-runtime-')); + roots.push(root); + const brewRoot = path.join(root, 'homebrew'); + const kegNode = path.join(brewRoot, 'Cellar', 'node', '26.7.0', 'bin', 'node'); + placeNodeBinaryAt(kegNode); + const optLink = path.join(brewRoot, 'opt', 'node'); + mkdirSync(path.dirname(optLink), { recursive: true }); + symlinkSync(path.join(brewRoot, 'Cellar', 'node', '26.7.0'), optLink); + const optNode = path.join(optLink, 'bin', 'node'); + + const result = await runNpmPrefixUpdate(root, kegNode); + expect(result.outcome).toMatchObject({ applied: true }); + expect(result.receiptNode).toBe(optNode); + expect(result.launcher).toContain(`node='${optNode}'`); + expect(result.launcher).not.toContain('/Cellar/'); + const releaseLauncher = path.join(result.prefix, 'releases', '1.2.4', '.mcode-launcher'); + expect(spawnSync(releaseLauncher, ['--version']).stdout.toString()).toBe('1.2.4\n'); + }); + + it('falls back to the running Node when the receipt Node is unusable', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'mcode-update-runtime-')); + roots.push(root); + + const result = await runNpmPrefixUpdate(root, path.join(root, 'removed', 'bin', 'node')); + expect(result.outcome).toMatchObject({ applied: true }); + expect(result.receiptNode).toBe(process.execPath); + expect(result.launcher).toContain(`node='${process.execPath}'`); + }); + + it('ignores unusable receipt Node executables and legacy receipts', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'mcode-update-runtime-')); + roots.push(root); + const npm = path.join(root, 'npm'); + writeFileSync(npm, '#!/bin/sh\nexit 42\n', { mode: 0o755 }); + const prefix = path.join(root, 'install'); + const legacyEntry = path.join(prefix, 'lib', 'node_modules', '@minimax-ai', 'code', 'cli.js'); + const versionedEntry = path.join( + prefix, + 'releases', + '1.0.0', + 'lib', + 'node_modules', + '@minimax-ai', + 'code', + 'cli.js', + ); + for (const entry of [legacyEntry, versionedEntry]) { + mkdirSync(path.dirname(entry), { recursive: true }); + writeFileSync( + path.join(path.dirname(entry), 'package.json'), + JSON.stringify({ name: '@minimax-ai/code', version: '1.0.0' }), + ); + writeFileSync(entry, 'console.log("1.0.0");'); + } + const directory = path.join(root, 'directory'); + mkdirSync(directory); + const writeReceipt = (extra: Record = {}) => + writeFileSync( + path.join(prefix, 'install.json'), + JSON.stringify({ + schemaVersion: 2, + product: 'minimax-code', + updateOwner: 'npm-prefix', + packageManager: 'npm', + packageName: '@minimax-ai/code', + registry: 'https://registry.npmjs.org/', + distTag: 'latest', + npmExecutable: npm, + prefix, + layoutVersion: 2, + releasesDirectory: 'releases', + currentFile: 'current', + ...extra, + }), + ); + + writeReceipt({ nodeExecutable: 'bin/node' }); + expect(resolveMcodeNpmPrefixInstall(versionedEntry)?.nodeExecutable).toBeUndefined(); + writeReceipt({ nodeExecutable: path.join(root, 'missing', 'node') }); + expect(resolveMcodeNpmPrefixInstall(versionedEntry)?.nodeExecutable).toBeUndefined(); + writeReceipt({ nodeExecutable: directory }); + expect(resolveMcodeNpmPrefixInstall(versionedEntry)?.nodeExecutable).toBeUndefined(); + + writeFileSync( + path.join(prefix, 'install.json'), + JSON.stringify({ + schemaVersion: 1, + product: 'minimax-code', + updateOwner: 'npm-prefix', + packageManager: 'npm', + packageName: '@minimax-ai/code', + registry: 'https://registry.npmjs.org/', + distTag: 'latest', + npmExecutable: npm, + prefix, + }), + ); + const legacyInstall = resolveMcodeNpmPrefixInstall(legacyEntry); + expect(legacyInstall).toBeDefined(); + expect(legacyInstall?.nodeExecutable).toBeUndefined(); + }); }); + +// Homebrew's Cellar/node//bin/node is the real binary, so the fixture +// places the running Node there via hardlink (copy fallback) to make +// process.execPath resolve inside the fake keg. +function placeNodeBinaryAt(target: string) { + mkdirSync(path.dirname(target), { recursive: true }); + try { + linkSync(process.execPath, target); + } catch { + copyFileSync(process.execPath, target); + } + chmodSync(target, 0o755); +} + +async function runNpmPrefixUpdate( + root: string, + nodeExecutable: string | undefined, +): Promise<{ prefix: string; receiptNode: unknown; launcher: string; outcome: unknown }> { + const prefix = path.join(root, 'install'); + const npm = path.join(root, 'npm'); + const npmCli = path.join(root, 'node_modules/npm/bin/npm-cli.js'); + mkdirSync(path.dirname(npmCli), { recursive: true }); + writeFileSync(npm, '#!/bin/sh\nexit 42\n', { mode: 0o755 }); + const packageRoot = path.join( + prefix, + 'releases', + '1.2.3', + 'lib', + 'node_modules', + '@minimax-ai', + 'code', + ); + mkdirSync(packageRoot, { recursive: true }); + writeFileSync( + path.join(packageRoot, 'package.json'), + JSON.stringify({ + name: '@minimax-ai/code', + version: '1.2.3', + bin: { mcode: 'cli.js', 'mcode-tools': 'tools.js' }, + }), + ); + writeFileSync(path.join(packageRoot, 'cli.js'), 'console.log("1.2.3");'); + writeFileSync(path.join(prefix, 'current'), '1.2.3\n'); + writeFileSync( + path.join(prefix, 'install.json'), + JSON.stringify({ + schemaVersion: 2, + product: 'minimax-code', + updateOwner: 'npm-prefix', + packageManager: 'npm', + packageName: '@minimax-ai/code', + registry: 'https://registry.npmjs.org/', + distTag: 'latest', + npmExecutable: npm, + ...(nodeExecutable ? { nodeExecutable } : {}), + prefix, + layoutVersion: 2, + releasesDirectory: 'releases', + currentFile: 'current', + }), + ); + writeFileSync( + npmCli, + `#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +if (process.argv.includes('view')) { console.log(JSON.stringify('1.2.4')); process.exit(0); } +const prefix = process.argv[process.argv.indexOf('--prefix') + 1]; +const root = path.join(prefix, 'lib/node_modules/@minimax-ai/code'); +fs.mkdirSync(path.join(root, 'node_modules/better-sqlite3'), { recursive: true }); +fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({name:'@minimax-ai/code',version:'1.2.4',bin:{mcode:'cli.js','mcode-tools':'tools.js'}})); +fs.writeFileSync(path.join(root, 'cli.js'), 'console.log("1.2.4");'); +fs.writeFileSync(path.join(root, 'tools.js'), 'console.log("mcode-tools 1.2.4");'); +fs.writeFileSync(path.join(root, 'node_modules/better-sqlite3/index.js'), 'module.exports = class { constructor() {} prepare() { return { get: () => ({ value: 1 }) }; } close() {} };'); +`, + { mode: 0o755 }, + ); + + const application = new McodeUpdateApplication( + { + currentVersion: '1.2.3', + entryFile: path.join(packageRoot, 'cli.js'), + environment: { ...process.env }, + }, + { + detectInstallSource: async () => 'npm-prefix', + }, + ); + const outcome = await application.apply(await application.inspect()); + const receiptNode = JSON.parse( + readFileSync(path.join(prefix, 'install.json'), 'utf8'), + ).nodeExecutable; + const launcher = readFileSync(path.join(prefix, 'releases', '1.2.4', '.mcode-launcher'), 'utf8'); + return { prefix, receiptNode, launcher, outcome }; +} From 299822a3225d9f9ec205c914ff2f4cb39a0785e1 Mon Sep 17 00:00:00 2001 From: jiachen Date: Fri, 9 Oct 2026 11:32:34 +0800 Subject: [PATCH 3/3] fix(tui): match Homebrew opt node receipts explicitly Review follow-up: the formula branch of HOMEBREW_MANAGED_NODE_PATH was missing the opt/ literal, so it matched the nonexistent /node/bin/node while real opt//bin/node receipts never matched. opt receipts are still returned unchanged either way, but the pattern now recognizes them explicitly and the escaped linuxbrew segment stops matching lookalike prefixes. Adds a pass-through test for opt receipts. Assisted-by: pi --- packages/tui/src/update/install-source.ts | 2 +- packages/tui/test/unit/update-runtime.test.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/update/install-source.ts b/packages/tui/src/update/install-source.ts index 6961878b0..842e5640f 100644 --- a/packages/tui/src/update/install-source.ts +++ b/packages/tui/src/update/install-source.ts @@ -668,7 +668,7 @@ function readNpmPrefixReceipt( const HOMEBREW_CELLAR_NODE_PATH = /^(?.+)\/Cellar\/(?node(?:@\d+(?:\.\d+)*)?)\/[^/]+\/bin\/node$/u; const HOMEBREW_MANAGED_NODE_PATH = - /^(?\/(?:opt\/homebrew|usr\/local|home\/linuxbrew\/.linuxbrew))\/(?:(?node(?:@\d+(?:\.\d+)*)?)\/bin\/node|bin\/node)$/u; + /^(?\/(?:opt\/homebrew|usr\/local|home\/linuxbrew\/\.linuxbrew))\/(?:opt\/(?node(?:@\d+(?:\.\d+)*)?)\/bin\/node|bin\/node)$/u; function canonicalReceiptNodeExecutable(nodeExecutable: string): string | undefined { const canonical = canonicalHomebrewNodeExecutable(nodeExecutable); diff --git a/packages/tui/test/unit/update-runtime.test.ts b/packages/tui/test/unit/update-runtime.test.ts index 9a2bb39e7..438cf0e14 100644 --- a/packages/tui/test/unit/update-runtime.test.ts +++ b/packages/tui/test/unit/update-runtime.test.ts @@ -332,6 +332,17 @@ fs.writeFileSync(path.join(root, 'node_modules/better-sqlite3/index.js'), 'modul const legacyInstall = resolveMcodeNpmPrefixInstall(legacyEntry); expect(legacyInstall).toBeDefined(); expect(legacyInstall?.nodeExecutable).toBeUndefined(); + + const brewRoot = path.join(root, 'homebrew'); + const kegNode = path.join(brewRoot, 'Cellar', 'node', '26.7.0', 'bin', 'node'); + placeNodeBinaryAt(kegNode); + const optLink = path.join(brewRoot, 'opt', 'node'); + mkdirSync(path.dirname(optLink), { recursive: true }); + symlinkSync(path.join(brewRoot, 'Cellar', 'node', '26.7.0'), optLink); + writeReceipt({ nodeExecutable: path.join(optLink, 'bin', 'node') }); + expect(resolveMcodeNpmPrefixInstall(versionedEntry)?.nodeExecutable).toBe( + path.join(optLink, 'bin', 'node'), + ); }); });