diff --git a/.chronus/changes/init-template-source-2026-7-14-21-30-0.md b/.chronus/changes/init-template-source-2026-7-14-21-30-0.md new file mode 100644 index 00000000000..372a355607c --- /dev/null +++ b/.chronus/changes/init-template-source-2026-7-14-21-30-0.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - "@typespec/compiler" +--- + +Refactor `tsp init` template loading around a URI-based `TemplateSource` abstraction. A `UriTemplateSource` handles local and remote templates (paths and URLs), while built-in ("core") templates are addressed through an `internal:` scheme that resolves to an injectable provider. This lets alternative hosts (e.g. an offline single-executable compiler) serve bundled templates via an `InMemoryTemplateSource` without coupling template loading to the `CompilerHost` filesystem. diff --git a/.chronus/changes/reuse-template-source-loader-vscode-2026-7-18-14-55-0.md b/.chronus/changes/reuse-template-source-loader-vscode-2026-7-18-14-55-0.md new file mode 100644 index 00000000000..1ebe1e40453 --- /dev/null +++ b/.chronus/changes/reuse-template-source-loader-vscode-2026-7-18-14-55-0.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - "typespec-vscode" +--- + +Reuse the compiler's `UriTemplateSource.loadIndex()` to load `tsp init` template indexes instead of the extension's own file/URL reading and JSON parsing, unifying how core and configured templates are loaded. 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/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/init/core-templates.ts b/packages/compiler/src/init/core-templates.ts index 8e9f3ab45cb..1aa30bc2df8 100644 --- a/packages/compiler/src/init/core-templates.ts +++ b/packages/compiler/src/init/core-templates.ts @@ -1,8 +1,6 @@ -import { CompilerPackageRoot } from "../core/node-host.js"; -import { resolvePath } from "../core/path-utils.js"; import type { SystemHost } from "../core/types.js"; +import { defaultInternalTemplateSource } from "./template-source/index.js"; -export const templatesDir = resolvePath(CompilerPackageRoot, "templates"); export interface LoadedCoreTemplates { readonly baseUri: string; readonly templates: Record; @@ -11,12 +9,8 @@ export interface LoadedCoreTemplates { let typeSpecCoreTemplates: LoadedCoreTemplates | undefined; export async function getTypeSpecCoreTemplates(host: SystemHost): Promise { if (typeSpecCoreTemplates === undefined) { - const file = await host.readFile(resolvePath(templatesDir, "scaffolding.json")); - const content = JSON.parse(file.text); - typeSpecCoreTemplates = { - baseUri: templatesDir, - templates: content, - }; + const { templates, baseUri } = await defaultInternalTemplateSource(host).loadIndex(); + typeSpecCoreTemplates = { baseUri, templates }; } return typeSpecCoreTemplates; } diff --git a/packages/compiler/src/init/init.ts b/packages/compiler/src/init/init.ts index a3d716838d7..27bc648d9fc 100644 --- a/packages/compiler/src/init/init.ts +++ b/packages/compiler/src/init/init.ts @@ -5,16 +5,20 @@ import * as semver from "semver"; import { CliCompilerHost } from "../core/cli/types.js"; import { parseCliArgsArgOption } from "../core/cli/utils.js"; import { createDiagnostic } from "../core/messages.js"; -import { getBaseFileName, getDirectoryPath } from "../core/path-utils.js"; -import { CompilerHost, Diagnostic, NoTarget, SourceFile } from "../core/types.js"; +import { getBaseFileName } from "../core/path-utils.js"; +import { Diagnostic, NoTarget } from "../core/types.js"; import { installTypeSpecDependencies } from "../install/install.js"; import { MANIFEST } from "../manifest.js"; -import { readUrlOrPath } from "../utils/misc.js"; -import { getTypeSpecCoreTemplates } from "./core-templates.js"; import { validateTemplateDefinitions, ValidationResult } from "./init-template-validate.js"; import { EmitterTemplate, InitTemplate, InitTemplateInput } from "./init-template.js"; import { checkbox } from "./prompts.js"; import { isFileSkipGeneration, makeScaffoldingConfig, scaffoldNewProject } from "./scaffold.js"; +import { + defaultInternalTemplateSource, + UriTemplateSource, + type LoadedTemplateIndex, + type TemplateSource, +} from "./template-source/index.js"; export interface InitTypeSpecProjectOptions { readonly templatesUrl?: string; @@ -23,6 +27,12 @@ export interface InitTypeSpecProjectOptions { readonly args?: string[]; readonly "project-name"?: string; readonly emitters?: string[]; + /** + * Provider that the `internal:` scheme resolves to (the built-in templates). Defaults to the + * compiler's on-disk `templates/` directory; the standalone single-executable injects an in-memory + * bundle instead. + */ + readonly internalTemplateSource?: TemplateSource; } export async function initTypeSpecProject( @@ -55,45 +65,54 @@ export async function initTypeSpecProjectWorker( const folderName = getBaseFileName(directory); - // Download template configuration and prompt user to select a template - // No validation is done until one has been selected - const typeSpecCoreTemplates = await getTypeSpecCoreTemplates(host); - const result = - options.templatesUrl === undefined - ? (typeSpecCoreTemplates as LoadedTemplate) - : await downloadTemplates(host, options.templatesUrl, skipPrompts); + const isRemote = options.templatesUrl !== undefined; + // A `templatesUrl` points at a filesystem path or URL; otherwise use the built-in ("internal") + // templates, either an injected provider (e.g. bundled in the standalone executable) or the + // compiler's on-disk `templates/` directory. + const source: TemplateSource = + options.templatesUrl !== undefined + ? new UriTemplateSource(host, options.templatesUrl) + : (options.internalTemplateSource ?? defaultInternalTemplateSource(host)); + + if (isRemote) { + warning( + `Downloading or using an untrusted template may contain malicious packages that can compromise your system and data. Proceed with caution and verify the source.`, + ); + if (!skipPrompts && !(await confirm("Continue"))) { + process.exit(1); + } + } + + // No validation is done until a template has been selected. + const index = await loadTemplateIndex(source, options.templatesUrl); if (skipPrompts && !options.template) { // A template has to be defined if we're skipping prompts throw new Error( `A template must be specified when --no-prompt is used. Specify one of the following templates via --template: ${Object.keys( - result.templates, + index.templates, ) .map((t) => `"${t}"`) .join(", ")}`, ); } - const templateName = options.template ?? (await promptTemplateSelection(result.templates)); + const templateName = options.template ?? (await promptTemplateSelection(index.templates)); - if (!result.templates[templateName]) { + if (!index.templates[templateName]) { throw new Error(`Unexpected error: Cannot find template ${templateName}`); } // Validate minimum compiler version for non built-in templates - if ( - !skipPrompts && - result !== typeSpecCoreTemplates && - !(await validateTemplate(result.templates[templateName], result)) - ) { + if (!skipPrompts && isRemote && !(await validateTemplate(index.templates[templateName], index))) { return; } - const template = result.templates[templateName] as InitTemplate; + const template = index.templates[templateName] as InitTemplate; const name = await resolveProjectName(folderName, options); const emitters = await selectEmitters(template, options); const parameters = await promptCustomParameters(template, options); const scaffoldingConfig = makeScaffoldingConfig(template, { - baseUri: result.baseUri, + source, name, directory, parameters, @@ -246,49 +265,26 @@ async function confirm(message: string): Promise { }); } -export interface LoadedTemplate { - readonly baseUri: string; - readonly templates: Record; - readonly file: SourceFile; -} -async function downloadTemplates( - host: CompilerHost, - url: string, - skipCheck: boolean, -): Promise { - warning( - `Downloading or using an untrusted template may contain malicious packages that can compromise your system and data. Proceed with caution and verify the source.`, - ); - if (!skipCheck && !(await confirm("Continue"))) { - process.exit(1); - } - let file: SourceFile; +async function loadTemplateIndex( + source: TemplateSource, + url: string | undefined, +): Promise { try { - file = await readUrlOrPath(host, url); - } catch (e: any) { - throw new InitTemplateError([ - createDiagnostic({ - code: "init-template-download-failed", - target: NoTarget, - format: { url: url, message: e.message }, - }), - ]); - } - - let json: unknown; - try { - json = JSON.parse(file.text); + return await source.loadIndex(); } catch (e: any) { + if (url === undefined) { + throw e; + } + const code = + e instanceof SyntaxError ? "init-template-invalid-json" : "init-template-download-failed"; throw new InitTemplateError([ createDiagnostic({ - code: "init-template-invalid-json", + code, target: NoTarget, - format: { url: url, message: e.message }, + format: { url, message: e.message }, }), ]); } - - return { templates: json as any, baseUri: getDirectoryPath(file.path), file }; } function getTemplateName(template: InitTemplate) { @@ -331,17 +327,17 @@ function isTemplateCompatibleWithTspVersion(template: InitTemplate): boolean { ); } -async function validateTemplate(template: any, loaded: LoadedTemplate): Promise { +async function validateTemplate(template: any, index: LoadedTemplateIndex): Promise { // After selection, validate the template definition const currentCompilerVersion = MANIFEST.version; let validationResult: ValidationResult; // 1. If current version > compilerVersion, proceed with strict validation if (isTemplateCompatibleWithTspVersion(template)) { - validationResult = validateTemplateDefinitions(template, loaded.file, true); + validationResult = validateTemplateDefinitions(template, index.indexFile, true); // 1.1 If strict validation fails, try relaxed validation if (!validationResult.valid) { - validationResult = validateTemplateDefinitions(template, loaded.file, false); + validationResult = validateTemplateDefinitions(template, index.indexFile, false); } } else { // 2. if version mis-match or none specified, warn and prompt user to continue or not @@ -352,7 +348,7 @@ async function validateTemplate(template: any, loaded: LoadedTemplate): Promise< ) ) { // 2.1 If user choose to continue, proceed with relaxed validation - validationResult = validateTemplateDefinitions(template, loaded.file, false); + validationResult = validateTemplateDefinitions(template, index.indexFile, false); } else { return false; } diff --git a/packages/compiler/src/init/scaffold.ts b/packages/compiler/src/init/scaffold.ts index eb369ccb20b..d3ce68b2123 100644 --- a/packages/compiler/src/init/scaffold.ts +++ b/packages/compiler/src/init/scaffold.ts @@ -4,7 +4,6 @@ import { getDirectoryPath, joinPaths } from "../core/path-utils.js"; import type { SystemHost } from "../core/types.js"; import { fetchLatestPackageManifest } from "../package-manger/npm-registry.js"; import type { PackageJson } from "../types/package-json.js"; -import { readUrlOrPath, resolveRelativeUrlOrPath } from "../utils/misc.js"; import { createFileTemplatingContext, type FileTemplatingContext, @@ -16,6 +15,7 @@ import type { InitTemplateLibrary, InitTemplateLibrarySpec, } from "./init-template.js"; +import type { TemplateSource } from "./template-source/index.js"; export const TypeSpecConfigFilename = "tspconfig.yaml"; @@ -24,9 +24,10 @@ export interface ScaffoldingConfig { template: InitTemplate; /** - * Path where this template was loaded from. + * Source the template was loaded from. Used to read the template's files during scaffolding. + * Optional: templates that declare no `files` never need it. */ - baseUri: string; + source?: TemplateSource; /** * Directory full path where the project should be initialized. @@ -73,7 +74,6 @@ export function makeScaffoldingConfig( return { template, libraries: config.libraries ?? template.libraries?.map(normalizeLibrary) ?? [], - baseUri: config.baseUri ?? ".", name: config.name ?? "", directory: config.directory ?? "", parameters: config.parameters ?? {}, @@ -248,8 +248,12 @@ async function writeFile( context: FileTemplatingContext, file: InitTemplateFile, ) { - const baseDir = config.baseUri + "/"; - const template = await readUrlOrPath(host, resolveRelativeUrlOrPath(baseDir, file.path)); + if (config.source === undefined) { + throw new Error( + `Cannot resolve template file "${file.path}": template was loaded without a source.`, + ); + } + const template = await config.source.readFile(file.path); const content = render(template.text, context); const destinationFilePath = joinPaths(config.directory, file.destination); // create folders in case they don't exist diff --git a/packages/compiler/src/init/template-source/default-core-templates.ts b/packages/compiler/src/init/template-source/default-core-templates.ts new file mode 100644 index 00000000000..3bf76b30398 --- /dev/null +++ b/packages/compiler/src/init/template-source/default-core-templates.ts @@ -0,0 +1,14 @@ +import { CompilerPackageRoot } from "../../core/node-host.js"; +import { resolvePath } from "../../core/path-utils.js"; +import type { SystemHost } from "../../core/types.js"; +import type { TemplateSource } from "./types.js"; +import { UriTemplateSource } from "./uri-template-source.js"; + +/** + * The default source of built-in ("internal") templates: the compiler package's on-disk + * `templates/` directory, read through the given {@link SystemHost}. The standalone single-executable + * overrides this by injecting its own {@link InMemoryTemplateSource} bundle. + */ +export function defaultInternalTemplateSource(host: SystemHost): TemplateSource { + return UriTemplateSource.fromDirectory(host, resolvePath(CompilerPackageRoot, "templates")); +} diff --git a/packages/compiler/src/init/template-source/in-memory-template-source.ts b/packages/compiler/src/init/template-source/in-memory-template-source.ts new file mode 100644 index 00000000000..0867cbec15b --- /dev/null +++ b/packages/compiler/src/init/template-source/in-memory-template-source.ts @@ -0,0 +1,55 @@ +import { normalizePath } from "../../core/path-utils.js"; +import { createSourceFile } from "../../core/source-file.js"; +import type { SourceFile } from "../../core/types.js"; +import type { LoadedTemplateIndex, TemplateSource } from "./types.js"; +import { SCAFFOLDING_FILENAME } from "./uri-template-source.js"; + +/** Prefix used to label the virtual files this source serves in diagnostics. */ +const INTERNAL_URI_PREFIX = "internal:"; + +/** + * A {@link TemplateSource} serving an index and its template files from an in-memory map keyed by + * relative path. Used for built-in templates that are bundled rather than on disk — e.g. the + * standalone single-executable injects one built from its embedded assets. Keys and lookups are + * normalized so callers can use `./`-relative or backslash paths interchangeably. + */ +export class InMemoryTemplateSource implements TemplateSource { + #files: ReadonlyMap; + #indexPath: string; + + /** + * @param files Template files keyed by their relative path (including the index). + * @param indexPath Relative path of the index within {@link files}. + */ + constructor(files: ReadonlyMap, indexPath: string = SCAFFOLDING_FILENAME) { + this.#indexPath = indexPath; + this.#files = new Map([...files].map(([key, value]) => [normalizeKey(key), value])); + } + + async loadIndex(): Promise { + const indexFile = this.#read(this.#indexPath); + const templates = JSON.parse(indexFile.text); + return { templates, indexFile, baseUri: INTERNAL_URI_PREFIX }; + } + + async readFile(relativePath: string): Promise { + return this.#read(relativePath); + } + + #read(relativePath: string): SourceFile { + const key = normalizeKey(relativePath); + const content = this.#files.get(key); + if (content === undefined) { + const error: NodeJS.ErrnoException = new Error( + `ENOENT: bundled template file not found, '${relativePath}'`, + ); + error.code = "ENOENT"; + throw error; + } + return createSourceFile(content, `${INTERNAL_URI_PREFIX}${key}`); + } +} + +function normalizeKey(path: string): string { + return normalizePath(path).replace(/^\/+/, ""); +} diff --git a/packages/compiler/src/init/template-source/index.ts b/packages/compiler/src/init/template-source/index.ts new file mode 100644 index 00000000000..b89038435e3 --- /dev/null +++ b/packages/compiler/src/init/template-source/index.ts @@ -0,0 +1,4 @@ +export { defaultInternalTemplateSource } from "./default-core-templates.js"; +export { InMemoryTemplateSource } from "./in-memory-template-source.js"; +export type { LoadedTemplateIndex, TemplateSource } from "./types.js"; +export { SCAFFOLDING_FILENAME, UriTemplateSource } from "./uri-template-source.js"; diff --git a/packages/compiler/src/init/template-source/types.ts b/packages/compiler/src/init/template-source/types.ts new file mode 100644 index 00000000000..c8154dfe2fd --- /dev/null +++ b/packages/compiler/src/init/template-source/types.ts @@ -0,0 +1,22 @@ +import type { SourceFile } from "../../core/types.js"; +import type { InitTemplate } from "../init-template.js"; + +/** Result of loading a template index (the set of templates offered by a {@link TemplateSource}). */ +export interface LoadedTemplateIndex { + /** Templates keyed by their id. */ + readonly templates: Record; + /** Source file the index was parsed from. Used to position validation diagnostics. */ + readonly indexFile: SourceFile; + /** Location the index was loaded from (a directory path or URL). Informational only. */ + readonly baseUri: string; +} + +/** + * A source of `tsp init` templates. Decouples template loading from the host filesystem: each context + * supplies its own implementation — {@link UriTemplateSource} for a path or URL, or + * {@link InMemoryTemplateSource} for a bundle addressed through the `internal:` scheme. + */ +export interface TemplateSource { + loadIndex(): Promise; + readFile(relativePath: string): Promise; +} diff --git a/packages/compiler/src/init/template-source/uri-template-source.ts b/packages/compiler/src/init/template-source/uri-template-source.ts new file mode 100644 index 00000000000..7b42275d07d --- /dev/null +++ b/packages/compiler/src/init/template-source/uri-template-source.ts @@ -0,0 +1,55 @@ +import { getDirectoryPath } from "../../core/path-utils.js"; +import type { SourceFile, SystemHost } from "../../core/types.js"; +import { readUrlOrPath, resolveRelativeUrlOrPath } from "../../utils/misc.js"; +import type { LoadedTemplateIndex, TemplateSource } from "./types.js"; + +/** File name of the template index within a template source directory. */ +export const SCAFFOLDING_FILENAME = "scaffolding.json"; + +/** + * A {@link TemplateSource} backed by a location: a filesystem path or a URL. The index and every + * template file are read through a {@link SystemHost} via {@link readUrlOrPath}, so this handles both + * a local directory and a remote `tsp init ` uniformly. Built-in templates are served through + * {@link defaultInternalTemplateSource} (or an injected provider) instead. + * + * Loading an untrusted remote template can execute arbitrary code during scaffolding; callers are + * responsible for confirming the source with the user before using it. + */ +export class UriTemplateSource implements TemplateSource { + #host: SystemHost; + #indexLocation: string; + /** Directory (path or URL) that template file paths are resolved against. */ + #baseUri: string; + + /** + * @param host Host used to read the index and template files. + * @param indexLocation Location of the index file (a path or URL). Template files are resolved + * relative to its containing directory. + */ + constructor(host: SystemHost, indexLocation: string) { + this.#host = host; + this.#indexLocation = indexLocation; + this.#baseUri = getDirectoryPath(indexLocation); + } + + /** + * Create a source for a template root directory (path or URL) whose index is a + * `scaffolding.json` at its root. + */ + static fromDirectory(host: SystemHost, directory: string): UriTemplateSource { + return new UriTemplateSource( + host, + resolveRelativeUrlOrPath(directory + "/", SCAFFOLDING_FILENAME), + ); + } + + async loadIndex(): Promise { + const indexFile = await readUrlOrPath(this.#host, this.#indexLocation); + const templates = JSON.parse(indexFile.text); + return { templates, indexFile, baseUri: this.#baseUri }; + } + + async readFile(relativePath: string): Promise { + return readUrlOrPath(this.#host, resolveRelativeUrlOrPath(this.#baseUri + "/", relativePath)); + } +} diff --git a/packages/compiler/src/internals/index.ts b/packages/compiler/src/internals/index.ts index a709fe35c0d..a393d3b44a9 100644 --- a/packages/compiler/src/internals/index.ts +++ b/packages/compiler/src/internals/index.ts @@ -11,5 +11,8 @@ export { resolveCompilerOptions } from "../config/config-to-options.js"; export { NodeSystemHost } from "../core/node-system-host.js"; export { InitTemplateSchema } from "../init/init-template.js"; export { makeScaffoldingConfig, scaffoldNewProject } from "../init/scaffold.js"; +// From uri-template-source directly (not the barrel) to avoid pulling node-host's top-level await. +export { UriTemplateSource } from "../init/template-source/uri-template-source.js"; +export type { TemplateSource } from "../init/template-source/types.js"; export { resolveEntrypointFile } from "../server/entrypoint-resolver.js"; export { InternalCompileResult, ServerDiagnostic } from "../server/index.js"; 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/test/cli/init.test.ts b/packages/compiler/test/cli/init.test.ts index 064ef45d318..e12e98d1ae6 100644 --- a/packages/compiler/test/cli/init.test.ts +++ b/packages/compiler/test/cli/init.test.ts @@ -1,9 +1,9 @@ import { afterAll, afterEach, beforeEach, expect, it, vi } from "vitest"; import { CliCompilerHost } from "../../src/core/cli/types.js"; -import { CompilerPackageRoot } from "../../src/core/node-host.js"; import { resolvePath } from "../../src/core/path-utils.js"; import { LogSink } from "../../src/index.js"; -import { initTypeSpecProject } from "../../src/init/init.js"; +import { initTypeSpecProject, InitTypeSpecProjectOptions } from "../../src/init/init.js"; +import { UriTemplateSource } from "../../src/init/template-source/index.js"; import { createTestFileSystem } from "../../src/testing/fs.js"; import { TestFileSystem } from "../../src/testing/types.js"; import { parseYaml as coreParseYaml } from "../../src/yaml/parser.js"; @@ -88,17 +88,19 @@ const TEST_SCAFFOLDING = { }; async function createTestFSWithCliCompilerHost(): Promise< - TestFileSystem & { compilerHost: CliCompilerHost } + TestFileSystem & { + compilerHost: CliCompilerHost; + init: (directory: string, options?: InitTypeSpecProjectOptions) => Promise; + } > { const testHost = await createTestFileSystem(); - testHost.fs.set( - resolvePath(CompilerPackageRoot, "templates", "scaffolding.json"), - JSON.stringify(TEST_SCAFFOLDING), - ); + const executionRoot = testHost.compilerHost.getExecutionRoot(); + const templatesRoot = resolvePath(executionRoot, "templates"); + testHost.fs.set(resolvePath(templatesRoot, "scaffolding.json"), JSON.stringify(TEST_SCAFFOLDING)); testHost.fs.set( - resolvePath(CompilerPackageRoot, "templates", "withParams", "tspconfig.yaml"), + resolvePath(templatesRoot, "withParams", "tspconfig.yaml"), ` emit: @typespec/openapi3 options: @@ -125,7 +127,12 @@ parameters: }; Object.assign(testHost.compilerHost as CliCompilerHost, { logSink }); - return testHost as TestFileSystem & { compilerHost: CliCompilerHost }; + const compilerHost = testHost.compilerHost as CliCompilerHost; + // Serve the built-in templates from the test file system rather than the real compiler package. + const internalTemplateSource = UriTemplateSource.fromDirectory(compilerHost, templatesRoot); + const init = (directory: string, options: InitTypeSpecProjectOptions = {}) => + initTypeSpecProject(compilerHost, directory, { ...options, internalTemplateSource }); + return Object.assign(testHost as TestFileSystem & { compilerHost: CliCompilerHost }, { init }); } function parseJson(host: CliCompilerHost, path: string): Promise> { @@ -142,9 +149,9 @@ afterAll(() => { }); it("should create a new project with the specified template", async () => { - const { compilerHost } = await createTestFSWithCliCompilerHost(); + const { compilerHost, init } = await createTestFSWithCliCompilerHost(); - await initTypeSpecProject(compilerHost, "/tmp/test-project", { + await init("/tmp/test-project", { template: "foo", "no-prompt": true, }); @@ -157,10 +164,10 @@ it("should create a new project with the specified template", async () => { }); it("does not ask for permission if directory has files", async () => { - const { fs, compilerHost } = await createTestFSWithCliCompilerHost(); + const { fs, compilerHost, init } = await createTestFSWithCliCompilerHost(); fs.set("/tmp/test-project/some-file.txt", "content"); - await initTypeSpecProject(compilerHost, "/tmp/test-project", { + await init("/tmp/test-project", { template: "foo", "no-prompt": true, }); @@ -169,9 +176,9 @@ it("does not ask for permission if directory has files", async () => { }); it("should support overriding project name", async () => { - const { compilerHost } = await createTestFSWithCliCompilerHost(); + const { compilerHost, init } = await createTestFSWithCliCompilerHost(); - await initTypeSpecProject(compilerHost, "/tmp/test-project", { + await init("/tmp/test-project", { template: "foo", "no-prompt": true, "project-name": "custom-project-name", @@ -182,9 +189,9 @@ it("should support overriding project name", async () => { }); it("should support overriding emitters", async () => { - const { compilerHost } = await createTestFSWithCliCompilerHost(); + const { compilerHost, init } = await createTestFSWithCliCompilerHost(); - await initTypeSpecProject(compilerHost, "/tmp/test-project", { + await init("/tmp/test-project", { template: "foo", "no-prompt": true, emitters: ["@typespec/openapi3", "@typespec/http-client-csharp"], @@ -195,8 +202,8 @@ it("should support overriding emitters", async () => { }); it("defaults to initialValue for parameters", async () => { - const { compilerHost } = await createTestFSWithCliCompilerHost(); - await initTypeSpecProject(compilerHost, "/tmp/test-project", { + const { compilerHost, init } = await createTestFSWithCliCompilerHost(); + await init("/tmp/test-project", { template: "withParams", "no-prompt": true, args: ["param1=value1"], @@ -211,8 +218,8 @@ it("defaults to initialValue for parameters", async () => { }); it("should support passing in arguments", async () => { - const { compilerHost } = await createTestFSWithCliCompilerHost(); - await initTypeSpecProject(compilerHost, "/tmp/test-project", { + const { compilerHost, init } = await createTestFSWithCliCompilerHost(); + await init("/tmp/test-project", { template: "withParams", "no-prompt": true, args: ["param1=value1", "param2=value2", "param3=value3"], @@ -227,9 +234,9 @@ it("should support passing in arguments", async () => { }); it("can't add emitters not specified by the template", async () => { - const { compilerHost } = await createTestFSWithCliCompilerHost(); + const { compilerHost, init } = await createTestFSWithCliCompilerHost(); - await initTypeSpecProject(compilerHost, "/tmp/test-project", { + await init("/tmp/test-project", { template: "foo", "no-prompt": true, emitters: ["@typespec/openapi3", "@typespec/http-client-csharp", "my-fake-emitter"], @@ -240,10 +247,10 @@ it("can't add emitters not specified by the template", async () => { }); it("should throw an error if no template is specified with no-prompt", async () => { - const { compilerHost } = await createTestFSWithCliCompilerHost(); + const { init } = await createTestFSWithCliCompilerHost(); await expect( - initTypeSpecProject(compilerHost, "/tmp/test-project", { + init("/tmp/test-project", { "no-prompt": true, }), ).rejects.toThrowError( @@ -252,10 +259,10 @@ it("should throw an error if no template is specified with no-prompt", async () }); it("should throw an error if the specified template does not exist", async () => { - const { compilerHost } = await createTestFSWithCliCompilerHost(); + const { init } = await createTestFSWithCliCompilerHost(); await expect( - initTypeSpecProject(compilerHost, "/tmp/test-project", { + init("/tmp/test-project", { template: "non-existent-template", "no-prompt": true, }), @@ -263,10 +270,10 @@ it("should throw an error if the specified template does not exist", async () => }); it("should throw an error if a required argument is not provided", async () => { - const { compilerHost } = await createTestFSWithCliCompilerHost(); + const { init } = await createTestFSWithCliCompilerHost(); await expect( - initTypeSpecProject(compilerHost, "/tmp/test-project", { + init("/tmp/test-project", { template: "withParams", "no-prompt": true, }), @@ -276,9 +283,9 @@ it("should throw an error if a required argument is not provided", async () => { }); it("should resolve package versions from npm registry", async () => { - const { compilerHost } = await createTestFSWithCliCompilerHost(); + const { compilerHost, init } = await createTestFSWithCliCompilerHost(); - await initTypeSpecProject(compilerHost, "/tmp/test-project", { + await init("/tmp/test-project", { template: "foo", "no-prompt": true, }); @@ -292,8 +299,8 @@ it("should resolve package versions from npm registry", async () => { it("should fallback to 'latest' when npm registry is unreachable", async () => { fetchMock.mockRejectedValue(new Error("Network error")); - const { compilerHost } = await createTestFSWithCliCompilerHost(); - await initTypeSpecProject(compilerHost, "/tmp/test-project", { + const { compilerHost, init } = await createTestFSWithCliCompilerHost(); + await init("/tmp/test-project", { template: "foo", "no-prompt": true, }); diff --git a/packages/compiler/test/e2e/init-templates.e2e.ts b/packages/compiler/test/e2e/init-templates.e2e.ts index c9400af6491..bc663760bb4 100644 --- a/packages/compiler/test/e2e/init-templates.e2e.ts +++ b/packages/compiler/test/e2e/init-templates.e2e.ts @@ -7,6 +7,7 @@ import { afterAll, beforeAll, describe, it, vi } from "vitest"; import { NodeHost } from "../../src/index.js"; import { getTypeSpecCoreTemplates } from "../../src/init/core-templates.js"; import { makeScaffoldingConfig, scaffoldNewProject } from "../../src/init/scaffold.js"; +import { defaultInternalTemplateSource } from "../../src/init/template-source/index.js"; const fetchMock = vi.fn().mockResolvedValue({ json: () => Promise.resolve({ name: "mock-pkg", version: "1.0.0" }), @@ -122,7 +123,7 @@ describe("Init templates e2e tests", () => { makeScaffoldingConfig(template, { name, directory: targetFolder, - baseUri: typeSpecCoreTemplates.baseUri, + source: defaultInternalTemplateSource(NodeHost), }), ); } diff --git a/packages/compiler/test/init/template-source.test.ts b/packages/compiler/test/init/template-source.test.ts new file mode 100644 index 00000000000..60648cf5e45 --- /dev/null +++ b/packages/compiler/test/init/template-source.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { resolvePath } from "../../src/core/path-utils.js"; +import type { CompilerHost } from "../../src/core/types.js"; +import { InMemoryTemplateSource, UriTemplateSource } from "../../src/init/template-source/index.js"; +import { createTestFileSystem } from "../../src/testing/fs.js"; +import type { TestFileSystem } from "../../src/testing/types.js"; + +const scaffolding = { + sample: { title: "Sample", description: "A sample template", files: [] }, +}; + +describe("UriTemplateSource", () => { + let testFs: TestFileSystem; + const root = resolvePath("/root/templates"); + + beforeEach(async () => { + testFs = await createTestFileSystem(); + testFs.fs.set(resolvePath(root, "scaffolding.json"), JSON.stringify(scaffolding)); + testFs.fs.set(resolvePath(root, "sample", "main.tsp"), "op ping(): void;"); + }); + + it("loads the index from the given index location", async () => { + const source = new UriTemplateSource( + testFs.compilerHost, + resolvePath(root, "scaffolding.json"), + ); + const index = await source.loadIndex(); + expect(index.templates.sample.title).toBe("Sample"); + expect(index.baseUri).toBe(root); + }); + + it("reads template files relative to the index directory", async () => { + const source = new UriTemplateSource( + testFs.compilerHost, + resolvePath(root, "scaffolding.json"), + ); + const file = await source.readFile("sample/main.tsp"); + expect(file.text).toBe("op ping(): void;"); + }); + + it("fromDirectory resolves scaffolding.json at the directory root", async () => { + const source = UriTemplateSource.fromDirectory(testFs.compilerHost, root); + const index = await source.loadIndex(); + expect(index.templates.sample.title).toBe("Sample"); + expect((await source.readFile("sample/main.tsp")).text).toBe("op ping(): void;"); + }); + + it("resolves remote template files relative to the index URL", async () => { + const reads: string[] = []; + const host = { + readUrl: async (url: string) => { + reads.push(url); + return { path: url, text: url.endsWith("index.json") ? JSON.stringify(scaffolding) : "hi" }; + }, + } as unknown as CompilerHost; + + const source = new UriTemplateSource(host, "https://example.com/tpl/index.json"); + const index = await source.loadIndex(); + expect(index.templates.sample.title).toBe("Sample"); + + await source.readFile("sample/main.tsp"); + expect(reads).toContain("https://example.com/tpl/sample/main.tsp"); + }); +}); + +describe("InMemoryTemplateSource", () => { + const files = new Map([ + ["scaffolding.json", JSON.stringify(scaffolding)], + ["sample/main.tsp", "op ping(): void;"], + ]); + + it("loads the index from the in-memory bundle", async () => { + const source = new InMemoryTemplateSource(files); + const index = await source.loadIndex(); + expect(index.templates.sample.title).toBe("Sample"); + expect(index.baseUri).toBe("internal:"); + }); + + it("reads template files from the in-memory bundle", async () => { + const source = new InMemoryTemplateSource(files); + expect((await source.readFile("sample/main.tsp")).text).toBe("op ping(): void;"); + }); + + it("throws a helpful error for a missing file", async () => { + const source = new InMemoryTemplateSource(files); + await expect(source.readFile("missing.tsp")).rejects.toThrow(/missing\.tsp/); + }); +}); 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