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
12 changes: 11 additions & 1 deletion packages/tui/src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,17 @@ async function formatTuiCliError(error: unknown): Promise<string> {
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.',
Expand Down
6 changes: 5 additions & 1 deletion packages/tui/src/update/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
73 changes: 62 additions & 11 deletions packages/tui/src/update/install-source.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -44,6 +38,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;
Expand Down Expand Up @@ -354,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;
Expand Down Expand Up @@ -587,6 +590,7 @@ function readNpmPrefixReceipt(
distTag?: unknown;
prefix?: unknown;
npmExecutable?: unknown;
nodeExecutable?: unknown;
layoutVersion?: unknown;
releasesDirectory?: unknown;
currentFile?: unknown;
Expand Down Expand Up @@ -639,17 +643,64 @@ function readNpmPrefixReceipt(
return undefined;
}
}
const nodeExecutable =
typeof value.nodeExecutable === 'string' && platformPath.isAbsolute(value.nodeExecutable)
? canonicalReceiptNodeExecutable(value.nodeExecutable)
: undefined;
return {
executable: value.npmExecutable,
packageName,
prefix,
registry: distribution.registry,
...(nodeExecutable ? { nodeExecutable } : {}),
};
} catch {
return undefined;
}
}

// Homebrew deletes versioned Cellar kegs on `brew upgrade` while the
// formula-managed opt/<formula> symlink keeps tracking the installed version,
// and keeps the update PATH prefix scoped to the Node formula instead of all
// of <brew>/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 =
/^(?<prefix>.+)\/Cellar\/(?<formula>node(?:@\d+(?:\.\d+)*)?)\/[^/]+\/bin\/node$/u;
const HOMEBREW_MANAGED_NODE_PATH =
/^(?<prefix>\/(?:opt\/homebrew|usr\/local|home\/linuxbrew\/\.linuxbrew))\/(?:opt\/(?<formula>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,
Expand Down
2 changes: 1 addition & 1 deletion packages/tui/src/update/versioned-prefix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading