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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions .github/workflows/core-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ jobs:
- uses: actions/checkout@v7

- uses: ./.github/actions/setup
with:
node-version: 26.x

- name: Install dependencies
run: pnpm install
Expand Down
6 changes: 2 additions & 4 deletions eng/tsp-core/pipelines/jobs/cli/build-tsp-cli-all.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 3 additions & 4 deletions eng/tsp-core/pipelines/jobs/cli/build-tsp-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 0 additions & 1 deletion eng/tsp-core/pipelines/jobs/cli/publish-artifacts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 0 additions & 10 deletions eng/tsp-core/pipelines/jobs/cli/sign-macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down
2 changes: 2 additions & 0 deletions eng/tsp-core/pipelines/jobs/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 0 additions & 6 deletions eng/tsp-core/pipelines/stages/sign-publish-tsp-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion packages/bundler/src/bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
),
)
: {};
Expand Down
2 changes: 1 addition & 1 deletion packages/compiler/cmd/tsp-server.js
Original file line number Diff line number Diff line change
@@ -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");
2 changes: 1 addition & 1 deletion packages/compiler/cmd/tsp.js
Original file line number Diff line number Diff line change
@@ -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");
4 changes: 3 additions & 1 deletion packages/compiler/entrypoints/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
6 changes: 5 additions & 1 deletion packages/compiler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/compiler/src/core/cli/actions/init.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -11,6 +12,7 @@ export interface InitArgs {
"project-name"?: string;
emitters?: string[];
outputDir?: string;
internalTemplateSource?: TemplateSource;
}

export async function initAction(
Expand Down
71 changes: 55 additions & 16 deletions packages/compiler/src/core/cli/cli.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 <emitter> --help` to display emitter options.
* Returns true if the interception was handled.
*/
async function handleCompileEmitHelp(argv: string[]): Promise<boolean> {
async function handleCompileEmitHelp(argv: string[], baseHost: CompilerHost): Promise<boolean> {
if (argv[0] !== "compile" || !argv.includes("--help")) {
return false;
}
Expand All @@ -43,7 +45,7 @@ async function handleCompileEmitHelp(argv: string[]): Promise<boolean> {
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);
Expand All @@ -55,11 +57,55 @@ async function handleCompileEmitHelp(argv: string[]): Promise<boolean> {
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<void> {
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<void> {
const withCliHost = <T extends CliHostArgs>(
fn: (host: CliCompilerHost, args: T) => Promise<void>,
) => withCliHostBase<T>(baseHost, fn);
const withCliHostAndDiagnostics = <T extends CliHostArgs>(
fn: (host: CliCompilerHost, args: T) => readonly Diagnostic[] | Promise<readonly Diagnostic[]>,
) => withCliHostAndDiagnosticsBase<T>(baseHost, fn);

const argv = process.argv.slice(2);

// Handle `tsp compile --emit <emitter> --help` early before yargs intercepts --help
if (await handleCompileEmitHelp(argv)) {
if (await handleCompileEmitHelp(argv, baseHost)) {
return;
}

Expand Down Expand Up @@ -294,6 +340,7 @@ async function main() {
...args,
emitters: args["template-emitters"],
outputDir: args["output-dir"],
internalTemplateSource,
}),
),
)
Expand Down Expand Up @@ -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);
15 changes: 10 additions & 5 deletions packages/compiler/src/core/cli/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -48,10 +48,11 @@ export interface CliHostArgs {
}

export function withCliHost<T extends CliHostArgs>(
baseHost: CompilerHost,
fn: (host: CliCompilerHost, args: T) => Promise<void>,
): (args: T) => Promise<void> {
return withFailsafe((args: T) => {
const host = createCLICompilerHost(args);
const host = createCLICompilerHost(args, baseHost);
return fn(host, args);
});
}
Expand All @@ -72,10 +73,11 @@ function withFailsafe<T extends unknown[], R>(
* Resolve Cli host automatically using cli args and handle diagnostics returned by the action.
*/
export function withCliHostAndDiagnostics<T extends CliHostArgs>(
baseHost: CompilerHost,
fn: (host: CliCompilerHost, args: T) => readonly Diagnostic[] | Promise<readonly Diagnostic[]>,
): (args: T) => void | Promise<void> {
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);
Expand All @@ -85,7 +87,10 @@ export function withCliHostAndDiagnostics<T extends CliHostArgs>(
});
}

export function createCLICompilerHost(options: CliHostArgs): CliCompilerHost {
export function createCLICompilerHost(
options: CliHostArgs,
baseHost: CompilerHost = NodeHost,
): CliCompilerHost {
const logSink = createConsoleSink({
pretty: options.pretty,
pathRelativeTo: process.cwd(),
Expand All @@ -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(
Expand Down
14 changes: 14 additions & 0 deletions packages/compiler/src/internals/standalone.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading