From 01c7a7fdf2a1a8ce007dd13018a8ff4fe78cf337 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Sat, 18 Jul 2026 15:16:12 -0400 Subject: [PATCH] feat(standalone): bundle a pinned compiler into the single-executable CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the standalone `tsp` executable with Node's `node --build-sea` (Node >= 25.5) instead of postject, dropping the `@yarnpkg/*` and `postject` bundling dependencies. The pinned compiler is bundled as assets and loaded through a createRequire bridge / module-loader workaround for embedder-only-builtins. Adds an internal `runTypeSpecCli` API (`@typespec/compiler/internals/standalone`) that runs the compiler CLI with an injected `internalTemplateSource`, so the standalone CLI serves the bundled `init` templates from an in-memory asset map — running `tsp init` offline without a package manager, network access, or writing files to disk. `tsp compile` is disallowed without a local project. Also wires up macOS/Windows code-signing for the SEA in the CLI pipelines. --- ...ndalone-bundle-compiler-2026-5-30-0-0-0.md | 7 + .github/workflows/core-ci.yml | 2 + .../pipelines/jobs/cli/build-tsp-cli-all.yml | 6 +- .../pipelines/jobs/cli/build-tsp-cli.yml | 7 +- .../pipelines/jobs/cli/publish-artifacts.yml | 1 - .../pipelines/jobs/cli/sign-macos.yml | 10 - eng/tsp-core/pipelines/jobs/e2e.yml | 2 + .../pipelines/stages/sign-publish-tsp-cli.yml | 6 - packages/bundler/src/bundler.ts | 9 +- packages/compiler/cmd/tsp-server.js | 2 +- packages/compiler/cmd/tsp.js | 2 +- packages/compiler/entrypoints/cli.js | 4 +- packages/compiler/package.json | 6 +- .../compiler/src/core/cli/actions/init.ts | 2 + packages/compiler/src/core/cli/cli.ts | 71 +- packages/compiler/src/core/cli/utils.ts | 15 +- packages/compiler/src/internals/standalone.ts | 14 + packages/compiler/src/runner.ts | 15 +- packages/standalone/README.md | 53 +- packages/standalone/install.sh | 7 +- packages/standalone/package.json | 7 +- packages/standalone/scripts/build.ts | 165 +- packages/standalone/scripts/check.ts | 122 +- .../standalone/scripts/compiler-assets.ts | 44 + .../standalone/scripts/osx-entitlements.plist | 26 +- packages/standalone/scripts/sea-config.ts | 5 + packages/standalone/src/cli.ts | 175 +- packages/standalone/src/compiler-entry.ts | 34 + packages/standalone/src/import-workaround.ts | 37 + packages/standalone/src/module-loader.ts | 50 + pnpm-lock.yaml | 1531 +---------------- pnpm-workspace.yaml | 6 - 32 files changed, 647 insertions(+), 1796 deletions(-) create mode 100644 .chronus/changes/standalone-bundle-compiler-2026-5-30-0-0-0.md create mode 100644 packages/compiler/src/internals/standalone.ts create mode 100644 packages/standalone/scripts/compiler-assets.ts create mode 100644 packages/standalone/src/compiler-entry.ts create mode 100644 packages/standalone/src/import-workaround.ts create mode 100644 packages/standalone/src/module-loader.ts diff --git a/.chronus/changes/standalone-bundle-compiler-2026-5-30-0-0-0.md b/.chronus/changes/standalone-bundle-compiler-2026-5-30-0-0-0.md new file mode 100644 index 00000000000..f160cd72ead --- /dev/null +++ b/.chronus/changes/standalone-bundle-compiler-2026-5-30-0-0-0.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - "@typespec/compiler" +--- + +Add internal `runTypeSpecCli` API (behind `@typespec/compiler/internals/standalone`) so a self-contained CLI can run the compiler CLI with a custom `CompilerHost` that serves the compiler's `init` templates from an in-memory asset map, running `tsp init` offline without a package manager, network access, or writing files to disk. diff --git a/.github/workflows/core-ci.yml b/.github/workflows/core-ci.yml index 92800c7d323..5a4ed96610b 100644 --- a/.github/workflows/core-ci.yml +++ b/.github/workflows/core-ci.yml @@ -94,6 +94,8 @@ jobs: - uses: actions/checkout@v7 - uses: ./.github/actions/setup + with: + node-version: 26.x - name: Install dependencies run: pnpm install diff --git a/eng/tsp-core/pipelines/jobs/cli/build-tsp-cli-all.yml b/eng/tsp-core/pipelines/jobs/cli/build-tsp-cli-all.yml index d7d2551a686..c9e64d170c8 100644 --- a/eng/tsp-core/pipelines/jobs/cli/build-tsp-cli-all.yml +++ b/eng/tsp-core/pipelines/jobs/cli/build-tsp-cli-all.yml @@ -7,10 +7,8 @@ jobs: parameters: platform: linux-arm64 - - template: /eng/tsp-core/pipelines/jobs/cli/build-tsp-cli.yml - parameters: - platform: macos-x64 - + # macos-x64 is intentionally not built: Node.js SEA does not support x64 macOS and the produced + # binary segfaults at startup (nodejs/node#62893). - template: /eng/tsp-core/pipelines/jobs/cli/build-tsp-cli.yml parameters: platform: macos-arm64 diff --git a/eng/tsp-core/pipelines/jobs/cli/build-tsp-cli.yml b/eng/tsp-core/pipelines/jobs/cli/build-tsp-cli.yml index 94666bd5e23..f987d664241 100644 --- a/eng/tsp-core/pipelines/jobs/cli/build-tsp-cli.yml +++ b/eng/tsp-core/pipelines/jobs/cli/build-tsp-cli.yml @@ -37,7 +37,7 @@ jobs: displayName: Install Node.js retryCountOnTaskFailure: 3 inputs: - version: 22.x + version: 26.x # - script: npm install -g bun # displayName: Install bun @@ -46,11 +46,10 @@ jobs: - template: /eng/common/pipelines/templates/steps/install-pnpm.yml - - script: pnpm install --filter "@typespec/standalone-cli" + - script: pnpm install --filter "@typespec/standalone-cli..." displayName: Install JavaScript Dependencies - - script: pnpm build - workingDirectory: packages/standalone + - script: pnpm --filter "@typespec/standalone-cli..." build displayName: Build - script: pnpm check smoke --smoke-only diff --git a/eng/tsp-core/pipelines/jobs/cli/publish-artifacts.yml b/eng/tsp-core/pipelines/jobs/cli/publish-artifacts.yml index f4cadc6b746..bdb152f98be 100644 --- a/eng/tsp-core/pipelines/jobs/cli/publish-artifacts.yml +++ b/eng/tsp-core/pipelines/jobs/cli/publish-artifacts.yml @@ -61,7 +61,6 @@ jobs: set -e mkdir release tar -cvzf release/tsp-darwin-arm64.tar.gz -C $(Pipeline.Workspace)/standalone-macos-signed/standalone-macos-arm64 tsp - tar -cvzf release/tsp-darwin-x64.tar.gz -C $(Pipeline.Workspace)/standalone-macos-signed/standalone-macos-x64 tsp tar -cvzf release/tsp-linux-arm64.tar.gz -C $(Pipeline.Workspace)/standalone-linux-arm64 tsp tar -cvzf release/tsp-linux-x64.tar.gz -C $(Pipeline.Workspace)/standalone-linux-x64 tsp diff --git a/eng/tsp-core/pipelines/jobs/cli/sign-macos.yml b/eng/tsp-core/pipelines/jobs/cli/sign-macos.yml index d0be0f063f5..b3abfb4b444 100644 --- a/eng/tsp-core/pipelines/jobs/cli/sign-macos.yml +++ b/eng/tsp-core/pipelines/jobs/cli/sign-macos.yml @@ -6,10 +6,6 @@ jobs: os: windows steps: - - download: current - artifact: standalone-macos-x64 - patterns: tsp - displayName: Download x64 binary - download: current artifact: standalone-macos-arm64 patterns: tsp @@ -18,10 +14,6 @@ jobs: - pwsh: | New-Item -ItemType Directory -Path macos - Compress-Archive ` - -Path $(Pipeline.Workspace)/standalone-macos-x64 ` - -DestinationPath macos/standalone-macos-x64.zip - Compress-Archive ` -Path $(Pipeline.Workspace)/standalone-macos-arm64 ` -DestinationPath macos/standalone-macos-arm64.zip @@ -32,10 +24,8 @@ jobs: path: macos - pwsh: | - Expand-Archive -Path macos/standalone-macos-x64.zip -DestinationPath signed-macos/ Expand-Archive -Path macos/standalone-macos-arm64.zip -DestinationPath signed-macos/ - Remove-Item macos/standalone-macos-x64.zip Remove-Item macos/standalone-macos-arm64.zip displayName: Extract signed binaries diff --git a/eng/tsp-core/pipelines/jobs/e2e.yml b/eng/tsp-core/pipelines/jobs/e2e.yml index 9b97c9c2a89..47a11acf192 100644 --- a/eng/tsp-core/pipelines/jobs/e2e.yml +++ b/eng/tsp-core/pipelines/jobs/e2e.yml @@ -18,6 +18,8 @@ jobs: steps: - template: /eng/tsp-core/pipelines/templates/install.yml + parameters: + nodeVersion: 26.x - template: /eng/tsp-core/pipelines/templates/install-browsers.yml - template: /eng/tsp-core/pipelines/templates/setup-linux-ui.yml - template: /eng/tsp-core/pipelines/templates/build.yml diff --git a/eng/tsp-core/pipelines/stages/sign-publish-tsp-cli.yml b/eng/tsp-core/pipelines/stages/sign-publish-tsp-cli.yml index 3b775e7b041..d226a7a81df 100644 --- a/eng/tsp-core/pipelines/stages/sign-publish-tsp-cli.yml +++ b/eng/tsp-core/pipelines/stages/sign-publish-tsp-cli.yml @@ -23,12 +23,6 @@ stages: artifactName: standalone-linux-arm64 exePath: tsp - - template: /eng/tsp-core/pipelines/jobs/cli/verify-tsp-cli.yml - parameters: - platform: macos-x64 - artifactName: standalone-macos-signed - exePath: standalone-macos-x64/tsp - - template: /eng/tsp-core/pipelines/jobs/cli/verify-tsp-cli.yml parameters: platform: macos-arm64 diff --git a/packages/bundler/src/bundler.ts b/packages/bundler/src/bundler.ts index 367c42ad482..08768940467 100644 --- a/packages/bundler/src/bundler.ts +++ b/packages/bundler/src/bundler.ts @@ -143,10 +143,17 @@ async function resolveTypeSpecBundleDefinition( libraryPath = normalizePath(await realpath(libraryPath)); const pkg = await readLibraryPackageJson(libraryPath); + // Only browser-safe exports are bundled for the playground/web. The `./internals` barrel and its + // node-only sub-entrypoints (e.g. `./internals/standalone`, which pulls in the CLI runner and Node + // built-ins) must be excluded; `./internals/prettier-formatter` is browser-safe and kept so the + // prettier plugin can load it in the browser. const exports = pkg.exports ? Object.fromEntries( Object.entries(pkg.exports).filter( - ([k, v]) => k !== "." && k !== "./testing" && k !== "./internals", + ([k, v]) => + k !== "." && + k !== "./testing" && + (!k.startsWith("./internals") || k === "./internals/prettier-formatter"), ), ) : {}; diff --git a/packages/compiler/cmd/tsp-server.js b/packages/compiler/cmd/tsp-server.js index 971b2bd53e4..ba7e6517f0d 100755 --- a/packages/compiler/cmd/tsp-server.js +++ b/packages/compiler/cmd/tsp-server.js @@ -1,3 +1,3 @@ #!/usr/bin/env node import { runScript } from "../dist/src/runner.js"; -await runScript("entrypoints/server.js", "dist/server/server.js"); +await runScript("entrypoints/server.js"); diff --git a/packages/compiler/cmd/tsp.js b/packages/compiler/cmd/tsp.js index 4f4b95ecccb..528a587a778 100755 --- a/packages/compiler/cmd/tsp.js +++ b/packages/compiler/cmd/tsp.js @@ -1,3 +1,3 @@ #!/usr/bin/env node import { runScript } from "../dist/src/runner.js"; -await runScript("entrypoints/cli.js", "dist/core/cli/cli.js"); +await runScript("entrypoints/cli.js"); diff --git a/packages/compiler/entrypoints/cli.js b/packages/compiler/entrypoints/cli.js index 69c1dc69dcf..95c11bc7f37 100644 --- a/packages/compiler/entrypoints/cli.js +++ b/packages/compiler/entrypoints/cli.js @@ -2,4 +2,6 @@ * File serving as an entrypoint to resolve a local tsp install from a global install. * DO NOT MOVE or this will create a breaking change for user of global cli. */ -import "../dist/src/core/cli/cli.js"; +import { runTypeSpecCli } from "../dist/src/core/cli/cli.js"; + +await runTypeSpecCli(); diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 523cd2b2862..063a20c8ee7 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -60,9 +60,13 @@ "./internals/prettier-formatter": { "import": "./dist/src/internals/prettier-formatter.js" }, + "./internals/standalone": { + "import": "./dist/src/internals/standalone.js" + }, "./casing": { "import": "./dist/src/casing/index.js" - } + }, + "./package.json": "./package.json" }, "browser": { "./dist/src/core/node-host.js": "./dist/src/core/node-host.browser.js", diff --git a/packages/compiler/src/core/cli/actions/init.ts b/packages/compiler/src/core/cli/actions/init.ts index 425cc9f8b71..c54e0b5011d 100644 --- a/packages/compiler/src/core/cli/actions/init.ts +++ b/packages/compiler/src/core/cli/actions/init.ts @@ -1,4 +1,5 @@ import { InitTemplateError, initTypeSpecProject } from "../../../init/init.js"; +import type { TemplateSource } from "../../../init/template-source/index.js"; import { resolvePath } from "../../path-utils.js"; import { Diagnostic } from "../../types.js"; import { CliCompilerHost } from "../types.js"; @@ -11,6 +12,7 @@ export interface InitArgs { "project-name"?: string; emitters?: string[]; outputDir?: string; + internalTemplateSource?: TemplateSource; } export async function initAction( diff --git a/packages/compiler/src/core/cli/cli.ts b/packages/compiler/src/core/cli/cli.ts index 4a6d92c752f..1ca77f6403b 100644 --- a/packages/compiler/src/core/cli/cli.ts +++ b/packages/compiler/src/core/cli/cli.ts @@ -1,10 +1,11 @@ -process.setSourceMapsEnabled(true); - import yargs from "yargs"; +import type { TemplateSource } from "../../init/template-source/index.js"; import { installTypeSpecDependencies } from "../../install/install.js"; import { typespecVersion } from "../../manifest.js"; import { logDiagnostics } from "../diagnostics.js"; import { getTypeSpecEngine } from "../engine.js"; +import { NodeHost } from "../node-host.js"; +import { CompilerHost, Diagnostic } from "../types.js"; import { compileAction } from "./actions/compile/compile.js"; import { formatAction } from "./actions/format.js"; import { printInfoAction } from "./actions/info.js"; @@ -17,20 +18,21 @@ import { installVSCodeExtension, uninstallVSCodeExtension, } from "./actions/vscode.js"; +import { CliCompilerHost } from "./types.js"; import { CliHostArgs, createCLICompilerHost, handleInternalCompilerError, logDiagnosticCount, - withCliHost, - withCliHostAndDiagnostics, + withCliHostAndDiagnostics as withCliHostAndDiagnosticsBase, + withCliHost as withCliHostBase, } from "./utils.js"; /** * Intercept `tsp compile --emit --help` to display emitter options. * Returns true if the interception was handled. */ -async function handleCompileEmitHelp(argv: string[]): Promise { +async function handleCompileEmitHelp(argv: string[], baseHost: CompilerHost): Promise { if (argv[0] !== "compile" || !argv.includes("--help")) { return false; } @@ -43,7 +45,7 @@ async function handleCompileEmitHelp(argv: string[]): Promise { return false; } - const host = createCLICompilerHost({ pretty: true }); + const host = createCLICompilerHost({ pretty: true }, baseHost); const diagnostics = await printEmitterOptionsAction(host, emitterName); if (diagnostics.length > 0) { logDiagnostics(diagnostics, host.logSink); @@ -55,11 +57,55 @@ async function handleCompileEmitHelp(argv: string[]): Promise { return true; } -async function main() { +/** + * Run the TypeSpec CLI (`tsp`). + * + * @param options.baseHost {@link CompilerHost} that every CLI action's host is composed from. + * Defaults to the real file-system {@link NodeHost}. + * @param options.internalTemplateSource Source used for the built-in `tsp init` templates. + * Defaults to reading the compiler's `templates/` directory off disk. + * The standalone/self-contained CLI passes an in-memory source built from its bundled template assets. + */ +export async function runTypeSpecCli( + options: { baseHost?: CompilerHost; internalTemplateSource?: TemplateSource } = {}, +): Promise { + process.setSourceMapsEnabled(true); + + const baseHost = options.baseHost ?? NodeHost; + + process.on("unhandledRejection", (error: unknown) => { + // eslint-disable-next-line no-console + console.error("Unhandled promise rejection!"); + handleInternalCompilerError(error); + }); + + // Route any error that escapes the command handlers (e.g. yargs parsing or `--emit --help`) to the + // internal-error reporter, so callers can `await runTypeSpecCli()` without their own catch. + try { + await runCli(baseHost, options.internalTemplateSource); + } catch (error) { + handleInternalCompilerError(error); + } +} + +/** + * Build and run the `tsp` command line, composing every action's host from `baseHost`. + */ +async function runCli( + baseHost: CompilerHost, + internalTemplateSource?: TemplateSource, +): Promise { + const withCliHost = ( + fn: (host: CliCompilerHost, args: T) => Promise, + ) => withCliHostBase(baseHost, fn); + const withCliHostAndDiagnostics = ( + fn: (host: CliCompilerHost, args: T) => readonly Diagnostic[] | Promise, + ) => withCliHostAndDiagnosticsBase(baseHost, fn); + const argv = process.argv.slice(2); // Handle `tsp compile --emit --help` early before yargs intercepts --help - if (await handleCompileEmitHelp(argv)) { + if (await handleCompileEmitHelp(argv, baseHost)) { return; } @@ -294,6 +340,7 @@ async function main() { ...args, emitters: args["template-emitters"], outputDir: args["output-dir"], + internalTemplateSource, }), ), ) @@ -326,11 +373,3 @@ async function main() { .version(getTypeSpecEngine() === "tsp" ? `${typespecVersion} standalone` : typespecVersion) .demandCommand(1, "You must use one of the supported commands.").argv; } - -process.on("unhandledRejection", (error: unknown) => { - // eslint-disable-next-line no-console - console.error("Unhandled promise rejection!"); - handleInternalCompilerError(error); -}); - -main().catch(handleInternalCompilerError); diff --git a/packages/compiler/src/core/cli/utils.ts b/packages/compiler/src/core/cli/utils.ts index 46791348876..9b005c06293 100644 --- a/packages/compiler/src/core/cli/utils.ts +++ b/packages/compiler/src/core/cli/utils.ts @@ -9,7 +9,7 @@ import { createTracer } from "../logger/tracer.js"; import { createDiagnostic } from "../messages.js"; import { NodeHost } from "../node-host.js"; import { getBaseFileName } from "../path-utils.js"; -import { Diagnostic, NoTarget } from "../types.js"; +import { CompilerHost, Diagnostic, NoTarget } from "../types.js"; import { CliCompilerHost } from "./types.js"; // ENOENT checking and handles spaces poorly in some cases. @@ -48,10 +48,11 @@ export interface CliHostArgs { } export function withCliHost( + baseHost: CompilerHost, fn: (host: CliCompilerHost, args: T) => Promise, ): (args: T) => Promise { return withFailsafe((args: T) => { - const host = createCLICompilerHost(args); + const host = createCLICompilerHost(args, baseHost); return fn(host, args); }); } @@ -72,10 +73,11 @@ function withFailsafe( * Resolve Cli host automatically using cli args and handle diagnostics returned by the action. */ export function withCliHostAndDiagnostics( + baseHost: CompilerHost, fn: (host: CliCompilerHost, args: T) => readonly Diagnostic[] | Promise, ): (args: T) => void | Promise { return withFailsafe(async (args: T) => { - const host = createCLICompilerHost(args); + const host = createCLICompilerHost(args, baseHost); const diagnostics = await fn(host, args); logDiagnostics(diagnostics, host.logSink); logDiagnosticCount(diagnostics); @@ -85,7 +87,10 @@ export function withCliHostAndDiagnostics( }); } -export function createCLICompilerHost(options: CliHostArgs): CliCompilerHost { +export function createCLICompilerHost( + options: CliHostArgs, + baseHost: CompilerHost = NodeHost, +): CliCompilerHost { const logSink = createConsoleSink({ pretty: options.pretty, pathRelativeTo: process.cwd(), @@ -96,7 +101,7 @@ export function createCLICompilerHost(options: CliHostArgs): CliCompilerHost { filter: options.trace ?? (options.debug ? ["*"] : undefined), }); tracer.trace("cli.args", `CLI args: ${inspect(options, { depth: null })}`); - return { ...NodeHost, logSink, logger, tracer, debug: options.debug ?? false }; + return { ...baseHost, logSink, logger, tracer, debug: options.debug ?? false }; } export function run( diff --git a/packages/compiler/src/internals/standalone.ts b/packages/compiler/src/internals/standalone.ts new file mode 100644 index 00000000000..065f864f953 --- /dev/null +++ b/packages/compiler/src/internals/standalone.ts @@ -0,0 +1,14 @@ +/** + * Internal API used to build a self-contained ("standalone") TypeSpec CLI that bundles the compiler + * into a single executable. + * + * This is a separate entrypoint from `@typespec/compiler/internals` on purpose: `runTypeSpecCli` + * reaches into the CLI runner, which transitively loads `node-host` (top-level `await`) and Node + * built-ins. Keeping it out of the general `internals` barrel avoids forcing those onto every + * browser/CommonJS consumer of `internals` (e.g. the VS Code extension and the playground). + * + * DO NOT USE. Not part of the public API and may change at any time with no warning. + */ +export { runTypeSpecCli } from "../core/cli/cli.js"; +export { InMemoryTemplateSource } from "../init/template-source/index.js"; +export type { LoadedTemplateIndex, TemplateSource } from "../init/template-source/index.js"; diff --git a/packages/compiler/src/runner.ts b/packages/compiler/src/runner.ts index 6c7039d42f8..7dc98b48b7b 100644 --- a/packages/compiler/src/runner.ts +++ b/packages/compiler/src/runner.ts @@ -1,4 +1,4 @@ -import { access, readFile, realpath, stat } from "fs/promises"; +import { readFile, realpath, stat } from "fs/promises"; import { join, resolve } from "path"; import { fileURLToPath, pathToFileURL } from "url"; import { ResolveModuleHost, resolveModule } from "./module-resolver/index.js"; @@ -10,14 +10,11 @@ import { ResolveModuleHost, resolveModule } from "./module-resolver/index.js"; * Prevents loading two conflicting copies of TypeSpec modules from global and * local package locations. */ -export async function runScript(relativePath: string, backupPath: string): Promise { +export async function runScript(relativePath: string): Promise { const packageRoot = await resolvePackageRoot(); if (packageRoot) { - let script = join(packageRoot, relativePath); - if (!(await checkFileExists(script)) && backupPath) { - script = join(packageRoot, backupPath); - } + const script = join(packageRoot, relativePath); const scriptUrl = pathToFileURL(script).toString(); await import(scriptUrl); } else { @@ -27,12 +24,6 @@ export async function runScript(relativePath: string, backupPath: string): Promi } } -function checkFileExists(file: string) { - return access(file) - .then(() => true) - .catch(() => false); -} - async function resolvePackageRoot(): Promise { if (process.env.TYPESPEC_SKIP_COMPILER_RESOLVE === "1") { return await getThisPackageRoot(); diff --git a/packages/standalone/README.md b/packages/standalone/README.md index b2754827cfe..5bec82361a1 100644 --- a/packages/standalone/README.md +++ b/packages/standalone/README.md @@ -2,6 +2,55 @@ This package contains the logic for building and bundling the TypeSpec CLI as a standalone executable. -It requires node 22+ to use node single executable. https://nodejs.org/api/single-executable-applications.html +It requires Node.js 25.5+ to build: the executable is produced in one step with +[`node --build-sea`](https://nodejs.org/api/single-executable-applications.html). This package only +targets the latest Node.js — supporting older versions is a non-goal. The end user runs the +self-contained executable and does not need any Node.js version installed. -It is also possible to build with bun (code is there) but there is some issue due to signing. Goal will be to migrate to bun when those are resolved. +## Bundled compiler + +The executable ships with a **pinned** `@typespec/compiler` (the version built alongside it) +embedded directly inside the single-executable. The compiler's `tsp init` templates are embedded as +executable assets and served from memory, so running commands such as `tsp init` outside of an +installed project needs **no network access, no package manager, and no files written to disk**. + +The standard library is **not** bundled. The bundled compiler is a bootstrapper: it runs `init`, +`--version`, `--help`, and `format` offline, while `compile` always uses a project-local +`@typespec/compiler`. Running `tsp compile` with no local compiler installed fails with guidance to +run `tsp install` / `tsp init` first. + +Compiler resolution at runtime: + +1. `--server ` / `TYPESPEC_COMPILER_PATH` — used if provided. +2. A `@typespec/compiler` resolvable from the current working directory (a project-local install) + always takes precedence over the bundled one. +3. Otherwise the pinned compiler bundled into the executable is used. + +To upgrade the compiler used outside of a project, install a newer executable; there is no runtime +self-update. + +## Implementation notes: long-term design vs. temporary workarounds + +**Long-term design** (inherent to shipping a self-contained executable): + +- The bundled compiler is a **bootstrapper**: it bundles only the `tsp init` templates, not the + standard library. `src/cli.ts` (`requireLocalCompilerForCompile`) fails fast with actionable + guidance if `compile` is run with no project-local compiler installed. +- The `tsp init` templates are embedded as a single-executable asset (a map of template-relative + path to file contents). `compiler-entry.ts` builds an `InMemoryTemplateSource` from that asset and + passes it to the CLI via `runTypeSpecCli({ coreTemplateSource })` (from + `@typespec/compiler/internals/standalone`), so `init` serves templates fully from memory while + everything else uses the real `NodeHost`. No global state is shared between the CJS and ESM + entrypoints, and the compiler's host contract (`getExecutionRoot`) is left untouched. +- In-memory module serving via `module.registerHooks` (`ensureHooks`/`serveFromMemory` in + `src/module-loader.ts`): SEA assets are strings in memory, so hooks map synthetic URLs to those + sources. +- The compiler is bundled as a separate ESM asset because it uses top-level `await`, which a + CommonJS single-executable main cannot. + +**Temporary workarounds** (remove when the linked upstream issue is fixed — each is tagged with a +`WORKAROUND(...)` comment in the code): + +| Where | Issue | What / removal | +| ------------------------------------------------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/import-workaround.ts` (the `createRequire` bridge, `importExternal`) | [nodejs/node#62726](https://github.com/nodejs/node/issues/62726) | A SEA main can only import _builtin_ modules. Once fixed, delete this file and replace `importExternal(specifier)` calls with `import(specifier)`. | diff --git a/packages/standalone/install.sh b/packages/standalone/install.sh index 31e2c9e6577..71477fd60e9 100755 --- a/packages/standalone/install.sh +++ b/packages/standalone/install.sh @@ -74,11 +74,12 @@ case $platform in esac if [[ $target = darwin-x64 ]]; then - # Is this process running in Rosetta? - # redirect stderr to devnull to avoid error message when not running in Rosetta + # Is this process running in Rosetta? (redirect stderr to avoid an error when not in Rosetta) if [[ $(sysctl -n sysctl.proc_translated 2> /dev/null) = 1 ]]; then - target=darwin-aarch64 + target=darwin-arm64 info "Your shell is running in Rosetta 2. Downloading tsp for $target instead" + else + error "The standalone tsp executable is not available for Intel (x64) macOS because Node.js does not support single-executable applications on this platform (nodejs/node#62893).\nInstall the TypeSpec compiler from npm instead: npm install -g @typespec/compiler" fi fi diff --git a/packages/standalone/package.json b/packages/standalone/package.json index 492bc9617b9..7c6f13c405f 100644 --- a/packages/standalone/package.json +++ b/packages/standalone/package.json @@ -45,17 +45,12 @@ "esbuild": "catalog:", "execa": "catalog:", "ora": "catalog:", - "postject": "catalog:", "rimraf": "catalog:", "tsx": "catalog:", "typescript": "catalog:", "vitest": "catalog:" }, "dependencies": { - "@yarnpkg/core": "catalog:", - "@yarnpkg/fslib": "catalog:", - "@yarnpkg/plugin-nm": "catalog:", - "@yarnpkg/plugin-npm": "catalog:", - "@yarnpkg/plugin-pnp": "catalog:" + "@typespec/compiler": "workspace:^" } } diff --git a/packages/standalone/scripts/build.ts b/packages/standalone/scripts/build.ts index 5a345fd5094..9eb500da54f 100644 --- a/packages/standalone/scripts/build.ts +++ b/packages/standalone/scripts/build.ts @@ -1,15 +1,17 @@ import * as esbuild from "esbuild"; import { execa } from "execa"; import { execFileSync } from "node:child_process"; -import { copyFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { copyFile, mkdir, readdir } from "node:fs/promises"; import ora from "ora"; import { dirname, join } from "path"; +import { resolveCompilerRoot, writeCompilerAssets } from "./compiler-assets.js"; import { writeSeaConfig } from "./sea-config.js"; -// cspell:ignore postject -const [major, minor, patch] = process.versions.node.split(".").map(Number); -if (major < 22) { - console.error("Cannot build standalone cli on node under 22"); +const [major, minor] = process.versions.node.split(".").map(Number); +// The executable is produced with `node --build-sea`, which was added in Node.js 25.5.0. This +// package only targets the latest Node.js, so building on anything older is unsupported. +if (major < 25 || (major === 25 && minor < 5)) { + console.error("Cannot build standalone cli on node under 25.5 (`node --build-sea` is required)"); process.exit(0); } @@ -17,113 +19,133 @@ const projectRoot = dirname(import.meta.dirname); const distDir = join(projectRoot, "dist"); const tempDir = join(projectRoot, "temp"); const seaConfigPath = join(tempDir, "sea-config.json"); -const blobPath = join(tempDir, "sea-prep.blob"); +const compilerBundlePath = join(tempDir, "compiler.mjs"); +const compilerAssetsPath = join(tempDir, "compiler-assets.json"); const exeName = process.platform === "win32" ? "tsp.exe" : "tsp"; const exePath = join(distDir, exeName); +// The bundle runs inside the single-executable, whose embedded Node.js is the latest release we +// build with (`node --build-sea`, Node.js 26.x), so there is no need to downlevel further. +const nodeTarget = "node26"; + await buildCurrent(); async function buildCurrent() { await bundle(); console.log(""); await buildWithNodeSea(); - // Cannot codesign on osx with bun https://github.com/oven-sh/bun/issues/7208 so we need to use node-sea - // await buildWithBun(); } async function bundle() { - await action(`Bundling js code`, async () => { + await mkdir(tempDir, { recursive: true }); + await bundleCli(); + await bundleCompiler(); + await collectAssets(); +} + +async function bundleCli() { + await action(`Bundling CLI entry`, async () => { await esbuild.build({ entryPoints: ["src/cli.ts"], bundle: true, outfile: "temp/bundle.cjs", platform: "node", - target: "node22", + target: nodeTarget, format: "cjs", }); }); - - // js-yaml dynamically tries to import esprima which then creates a warning for node sea that can't import anything but built in module even though it is optional - // https://github.com/nodejs/node/issues/50547 - const content = await readFile(join(tempDir, "bundle.cjs"), "utf-8"); - const updated = content - .toString() - .replace(`_require("esprima")`, "undefined") - .replace("var realRequire = eval(`require`)", "var realRequire = undefined"); // bun issue https://github.com/oven-sh/bun/issues/16440 - await writeFile(join(tempDir, "bundle.cjs"), updated); } -async function buildWithBun() { - action(`Build with bun`, async () => { - execa`bun build --compile temp/bundle.cjs --outfile dist/tsp`; +async function bundleCompiler() { + await action(`Bundling compiler`, async () => { + // LONG TERM: the compiler uses top-level `await`, which a CommonJS single-executable main + // cannot, so it is bundled separately as ESM and loaded from memory at runtime (see + // `runBundledCompiler` in `src/cli.ts`). The CJS main loads it via a bridge — a TEMPORARY + // workaround for nodejs/node#62726 documented in `src/import-workaround.ts`. + await esbuild.build({ + entryPoints: ["src/compiler-entry.ts"], + bundle: true, + outfile: "temp/compiler.mjs", + platform: "node", + target: nodeTarget, + format: "esm", + // Re-provide `require` for the bundled dependencies' built-in requires. The module is loaded + // from memory under a synthetic `file://` URL, so `import.meta.url` is a valid file URL. + banner: { + js: "import { createRequire as __tspCreateRequire } from 'node:module'; const require = __tspCreateRequire(process.execPath);", + }, + }); }); +} - if (process.platform === "darwin") { - // This should get sent to ESRP for official signing - await action(`Set entitlements for ${exePath}`, async () => { - // execa`codesign --sign - ${exePath}`; - const entitlementsPath = join(projectRoot, "scripts", "osx-entitlements.plist"); - execa`codesign --deep -s - -f --options runtime --entitlements ${entitlementsPath} ${exePath}`; - }); - } +async function collectAssets() { + await action(`Collecting compiler assets`, async () => { + const compilerRoot = resolveCompilerRoot(); + const count = await writeCompilerAssets(compilerRoot, compilerAssetsPath); + return count; + }); } async function buildWithNodeSea() { await mkdir(distDir, { recursive: true }); await mkdir(tempDir, { recursive: true }); - await createSeaConfig(); - - await action(`Copying executable`, async () => { - // get the node executable - const nodeExe = process.execPath; - // copy the executable as the output executable - await copyFile(nodeExe, exePath); - }); - await action(`Remove signature`, async () => { - if (process.platform === "darwin") { - execa`codesign --remove-signature ${exePath}`; - } else if (process.platform === "win32") { - const signToolPath = await findSigntool(); - if (signToolPath) { - execFileSync(signToolPath, [`remove`, `/s`, exePath]); - } else { - console.log("Ignore signtool removal on windows, missing."); - if (process.env.CI) { - throw new Error("Cannot find signtool.exe in CI"); - } - } - } - }); - await action(`Creating blob ${seaConfigPath}`, async () => { - await execa`node --experimental-sea-config ${seaConfigPath}`; - }); - await action(`Injecting blob into ${exePath}`, async () => { - if (process.platform === "darwin") { - await execa`npx postject ${exePath} NODE_SEA_BLOB ${blobPath} --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 --macho-segment-name NODE_SEA --overwrite`; - } else { - await execa`npx postject ${exePath} NODE_SEA_BLOB ${blobPath} --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 --overwrite`; - } + // `node --build-sea` injects the blob into a copy of node.exe. On windows that copy inherits + // node.exe's Authenticode signature, which the injection invalidates, breaking ESRP signing. + // Strip the signature from a pristine copy first and build from it so the output is cleanly + // unsigned. + const baseExecutable = await prepareBaseExecutable(); + await createSeaConfig(baseExecutable); + + await action(`Building single executable ${exePath}`, async () => { + // `node --build-sea` produces the executable, embedding the Node.js runtime, the SEA blob and + // the assets in one step. + await execa`node --build-sea ${seaConfigPath}`; }); - // On osx we need to register some entitlements for the app to run. + // On osx the freshly built executable is unsigned, so it needs an (ad-hoc) signature to run. + // Official signing is handled separately (ESRP). if (process.platform === "darwin") { - // This should get sent to ESRP for official signing await action(`Sign executable ${exePath}`, async () => { const entitlementsPath = join(projectRoot, "scripts", "osx-entitlements.plist"); - execa`codesign --deep -s - -f --options runtime --entitlements ${entitlementsPath} ${exePath}`; + await execa`codesign --deep -s - -f --options runtime --entitlements ${entitlementsPath} ${exePath}`; }); } } -async function createSeaConfig() { +async function prepareBaseExecutable(): Promise { + if (process.platform !== "win32") { + return undefined; + } + const baseExePath = join(tempDir, "node-base.exe"); + await action(`Removing signature from base executable`, async () => { + await copyFile(process.execPath, baseExePath); + const signToolPath = await findSigntool(); + if (signToolPath) { + execFileSync(signToolPath, [`remove`, `/s`, baseExePath]); + } else { + console.log("Ignore signtool removal on windows, missing."); + if (process.env.CI) { + throw new Error("Cannot find signtool.exe in CI"); + } + } + }); + return baseExePath; +} + +async function createSeaConfig(executable?: string) { await action(`Creating sea config ${seaConfigPath}`, async () => { await writeSeaConfig(seaConfigPath, { main: join(projectRoot, "temp/bundle.cjs"), - output: blobPath, + output: exePath, + executable, disableExperimentalSEAWarning: true, useCodeCache: false, + assets: { + "compiler.mjs": compilerBundlePath, + "compiler-assets.json": compilerAssetsPath, + }, }); }); } @@ -142,19 +164,12 @@ async function action(message: string, callback: () => Promise) { async function findSigntool() { try { const base = "C:/Program Files (x86)/Windows Kits/10/bin/"; - const files = await readdir(base); - console.log("Installed", files); const latest = files .filter((f) => f.startsWith("1")) .sort() .reverse()[0]; - - const resolved = join(base, latest, "x64/signtool.exe"); - console.log("Picking latest", latest); - console.log("Signtool path: ", resolved); - - return resolved; + return join(base, latest, "x64/signtool.exe"); } catch { return undefined; } diff --git a/packages/standalone/scripts/check.ts b/packages/standalone/scripts/check.ts index 6f3e8b9d14d..31aebf9caa3 100644 --- a/packages/standalone/scripts/check.ts +++ b/packages/standalone/scripts/check.ts @@ -1,6 +1,7 @@ import { execa } from "execa"; -import { mkdir, rm, writeFile } from "fs/promises"; -import { resolve } from "path"; +import { mkdir, mkdtemp, rm, writeFile } from "fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "path"; const projectDir = resolve(import.meta.dirname, ".."); const distDir = resolve(projectDir, "dist"); @@ -13,11 +14,25 @@ async function main() { if (process.argv.includes("--smoke-only")) { return; } - await installWorks(); + await bundledInitWorks(); + await compileRequiresLocalCompiler(); + await localCompilerTakesPrecedence(); } -async function run(args: string[], options: { cwd?: string; env?: Record } = {}) { - return await execa(exePath, args, options); +async function run( + args: string[], + options: { cwd?: string; env?: Record } = {}, +) { + return await execa(exePath, args, { + ...options, + // Strip `NODE_PATH` so the executable resolves modules like a real end-user install would. The + // harness that runs these checks (`npx tsx`) injects a `NODE_PATH` pointing at the monorepo's + // pnpm store, where `@typespec/compiler` is resolvable. If we left it in place, the bundled + // compiler would always "find" a local compiler via global paths and the bundled/guard code + // paths would never be exercised. + env: { ...options.env, NODE_PATH: undefined }, + reject: false, + }); } async function commandRuns() { @@ -26,6 +41,9 @@ async function commandRuns() { console.log("✅ command working!"); } else { console.error("Executable is not working"); + console.error(`exitCode: ${result.exitCode}, signal: ${result.signal ?? "none"}`); + console.error(`message: ${result.shortMessage ?? result.message ?? "none"}`); + console.error("Std out----------------"); console.error(result.stdout); console.error("Std err----------------"); console.error(result.stderr); @@ -33,20 +51,90 @@ async function commandRuns() { } } -async function installWorks() { - const testDir = resolve(projectDir, "temp", "install-test"); +/** + * Verify that the `init` templates bundled into the executable are served from memory, so `tsp init` + * can list templates fully offline with no project-local compiler installed. + * + * Runs in a directory OUTSIDE the repo so that no ancestor `node_modules/@typespec/compiler` is + * resolvable; otherwise the local compiler would take precedence and the bundled path would never be + * exercised. + */ +async function bundledInitWorks() { + const testDir = await mkdtemp(join(tmpdir(), "tsp-bundled-init-")); + try { + // `--no-prompt` without `--template` makes `init` list the bundled templates and then error, + // which exercises reading `templates/scaffolding.json` from the embedded asset without touching + // the network or scaffolding any files. + const result = await run(["init", "--no-prompt"], { cwd: testDir }); + if (result.stderr.includes("emitter-ts") || result.stdout.includes("emitter-ts")) { + console.log("✅ bundled init templates working!"); + } else { + console.error("Bundled init templates were not served from the executable"); + console.error(result.stdout); + console.error("Std err----------------"); + console.error(result.stderr); + process.exit(1); + } + } finally { + await rm(testDir, { recursive: true, force: true }); + } +} + +/** + * Verify that `tsp compile` with the bare executable and no project-local `@typespec/compiler` fails + * with actionable guidance (the standalone CLI does not bundle the standard library). + * + * Runs in a directory OUTSIDE the repo so no ancestor `node_modules/@typespec/compiler` is + * resolvable (which would otherwise take precedence and actually compile). + */ +async function compileRequiresLocalCompiler() { + const testDir = await mkdtemp(join(tmpdir(), "tsp-compile-requires-local-")); + try { + await writeFile(resolve(testDir, "main.tsp"), "op ping(): void;\n"); + const result = await run(["compile", "main.tsp", "--no-emit"], { cwd: testDir }); + if (result.exitCode !== 0 && result.stderr.includes("tsp install")) { + console.log("✅ compile requires a local compiler!"); + } else { + console.error("Expected `tsp compile` to fail asking for a local compiler"); + console.error(`exit code: ${result.exitCode}`); + console.error(result.stdout); + console.error("Std err----------------"); + console.error(result.stderr); + process.exit(1); + } + } finally { + await rm(testDir, { recursive: true, force: true }); + } +} + +/** + * Verify that a `@typespec/compiler` installed in the current project takes precedence over the + * compiler bundled into the executable. + */ +async function localCompilerTakesPrecedence() { + const sentinel = "LOCAL_COMPILER_SENTINEL"; + const testDir = resolve(projectDir, "temp", "local-precedence-test"); + const compilerDir = resolve(testDir, "node_modules", "@typespec", "compiler"); await rm(testDir, { recursive: true, force: true }); - await mkdir(testDir, { recursive: true }); + await mkdir(resolve(compilerDir, "cmd"), { recursive: true }); + // A fake local compiler with no `exports` field so the subpath `package.json` stays resolvable. await writeFile( - resolve(testDir, "package.json"), - JSON.stringify({ name: "test", dependencies: { "@typespec/compiler": "latest" } }), + resolve(compilerDir, "package.json"), + JSON.stringify({ name: "@typespec/compiler", version: "0.0.0-fake" }), + ); + await writeFile( + resolve(compilerDir, "cmd", "tsp.js"), + `console.log(${JSON.stringify(sentinel)});\nprocess.exit(0);\n`, ); - await run(["install"], { - cwd: testDir, - env: { - TYPESPEC_COMPILER_PATH: resolve(projectDir, "..", "compiler", "cmd", "tsp.js"), - }, - }); - console.log("✅ install working!"); + const result = await run(["--version"], { cwd: testDir }); + if (result.stdout.includes(sentinel)) { + console.log("✅ local compiler precedence working!"); + } else { + console.error("Local compiler was not used; the bundled compiler ran instead"); + console.error(result.stdout); + console.error("Std err----------------"); + console.error(result.stderr); + process.exit(1); + } } diff --git a/packages/standalone/scripts/compiler-assets.ts b/packages/standalone/scripts/compiler-assets.ts new file mode 100644 index 00000000000..c55d2e27326 --- /dev/null +++ b/packages/standalone/scripts/compiler-assets.ts @@ -0,0 +1,44 @@ +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join, relative } from "node:path"; + +/** Resolve the root directory of the `@typespec/compiler` package used by this build. */ +export function resolveCompilerRoot(): string { + const require = createRequire(import.meta.url); + return dirname(require.resolve("@typespec/compiler/package.json")); +} + +async function collectFiles(dir: string, root: string, out: Record) { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const abs = join(dir, entry.name); + if (entry.isDirectory()) { + await collectFiles(abs, root, out); + } else { + // Keys are POSIX paths relative to the templates root, as InMemoryTemplateSource expects. + out[relative(root, abs).split("\\").join("/")] = await readFile(abs, "utf8"); + } + } +} + +/** + * Collect the compiler's `init` template files into a map of POSIX-relative-path -> file contents, + * suitable for embedding as a single-executable asset. + * + * Only the `tsp init` templates are bundled. The compiler's standard library is intentionally NOT + * bundled: the bundled compiler is a bootstrapper (`init`, `--version`, `--help`, `format`), and any + * command that needs the standard library (i.e. `compile`) always runs through a project-local + * `@typespec/compiler` install instead. + */ +export async function collectCompilerAssets(compilerRoot: string): Promise> { + const files: Record = {}; + const templatesRoot = join(compilerRoot, "templates"); + await collectFiles(templatesRoot, templatesRoot, files); + return files; +} + +/** Write the collected compiler assets to a JSON file. */ +export async function writeCompilerAssets(compilerRoot: string, outFile: string): Promise { + const files = await collectCompilerAssets(compilerRoot); + await writeFile(outFile, JSON.stringify(files), "utf8"); + return Object.keys(files).length; +} diff --git a/packages/standalone/scripts/osx-entitlements.plist b/packages/standalone/scripts/osx-entitlements.plist index 040a3839ab3..53caf535353 100644 --- a/packages/standalone/scripts/osx-entitlements.plist +++ b/packages/standalone/scripts/osx-entitlements.plist @@ -1,12 +1,16 @@ - - - - com.apple.security.cs.allow-dyld-environment-variables - com.apple.security.cs.allow-jit - com.apple.security.cs.debugger - com.apple.security.cs.disable-library-validation - com.apple.security.get-task-allow - - - + + + + com.apple.security.cs.allow-dyld-environment-variables + + com.apple.security.cs.allow-jit + + com.apple.security.cs.debugger + + com.apple.security.cs.disable-library-validation + + com.apple.security.get-task-allow + + + diff --git a/packages/standalone/scripts/sea-config.ts b/packages/standalone/scripts/sea-config.ts index 63ac7f68699..6ca858faca8 100644 --- a/packages/standalone/scripts/sea-config.ts +++ b/packages/standalone/scripts/sea-config.ts @@ -2,9 +2,14 @@ import { writeFile } from "fs/promises"; interface SeaConfig { readonly main: string; + /** Path of the single-executable to produce (`node --build-sea` writes the exe here). */ readonly output: string; + /** Base node binary to inject into. Defaults to the current node binary when omitted. */ + readonly executable?: string; readonly disableExperimentalSEAWarning?: boolean; readonly useCodeCache?: boolean; + /** Map of asset key to the file path whose contents should be embedded in the executable. */ + readonly assets?: Readonly>; } export async function writeSeaConfig(path: string, config: SeaConfig) { diff --git a/packages/standalone/src/cli.ts b/packages/standalone/src/cli.ts index 53c4f456ef8..4da62c3a0c6 100644 --- a/packages/standalone/src/cli.ts +++ b/packages/standalone/src/cli.ts @@ -1,59 +1,49 @@ -import { - Cache, - Configuration, - LightReport, - MessageName, - Project, - stringifyMessageName, -} from "@yarnpkg/core"; -import { npath } from "@yarnpkg/fslib"; -import nmPlugin from "@yarnpkg/plugin-nm"; -import npmPlugin from "@yarnpkg/plugin-npm"; -import pnpPlugin from "@yarnpkg/plugin-pnp"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; import { pathToFileURL } from "node:url"; import { parseArgs } from "node:util"; +import { importExternal } from "./import-workaround.js"; +import { serveFromMemory } from "./module-loader.js"; + +const COMPILER_SPECIFIER = "typespec:bundled-compiler"; +const COMPILER_URL = pathToFileURL( + join(dirname(process.execPath), "__typespec_bundled__", "compiler.mjs"), +).href; if (process.env.TYPESPEC_CLI_PASSTHROUGH === "1") { + // The compiler's `tsp install` command forks a package manager (npm) using this executable as + // the Node runtime. In that case behave like plain `node