authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-13 21:24:24-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 21:24:24-07:00
log91cd91c0872bd52be3b45bea70a9539d1ce6adaa
tree0e4a2d8b944bad2f764d00158d2ad138764ea8f4
parent2f7616ef1743de52d24581f7e5724f8f674d9152
signature Signed by PGP key B5690EEEBB952194

feat(release): publish npx t3 as a launcher over per-platform executable packages (#11607)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

9 files changed, 820 insertions(+), 262 deletions(-)

.github/workflows/release.yml+39-42
...@@ -150,10 +150,11 @@ jobs:...@@ -150,10 +150,11 @@ jobs:
150 # Manual-only test train: exercises the whole release flow for a150 # Manual-only test train: exercises the whole release flow for a
151 # commit end users must never receive. Never scheduled.151 # commit end users must never receive. Never scheduled.
152 # Same versioning as nightly under its own prerelease identifier.152 # Same versioning as nightly under its own prerelease identifier.
153 # A preview release is reachable only by downloading it by hand:153 # A preview release is reachable only by asking for it: npm gets it
154 # it is never published to npm, its desktop builds carry no update154 # under the `preview` dist-tag, which nothing resolves by default,
155 # feed, and no updater manifest is attached to the release, so155 # its desktop builds carry no update feed, and no updater manifest
156 # neither stable nor nightly installs can ever be offered one.156 # is attached to the release, so neither stable nor nightly
157 # installs can ever be offered one.
157 nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)"158 nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)"
158159
159 node scripts/resolve-nightly-release.ts \160 node scripts/resolve-nightly-release.ts \
...@@ -164,7 +165,7 @@ jobs:...@@ -164,7 +165,7 @@ jobs:
164 --github-output165 --github-output
165166
166 echo "release_channel=preview" >> "$GITHUB_OUTPUT"167 echo "release_channel=preview" >> "$GITHUB_OUTPUT"
167 echo "cli_dist_tag=" >> "$GITHUB_OUTPUT"168 echo "cli_dist_tag=preview" >> "$GITHUB_OUTPUT"
168 echo "is_prerelease=true" >> "$GITHUB_OUTPUT"169 echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
169 echo "make_latest=false" >> "$GITHUB_OUTPUT"170 echo "make_latest=false" >> "$GITHUB_OUTPUT"
170 else171 else
...@@ -591,22 +592,28 @@ jobs:...@@ -591,22 +592,28 @@ jobs:
591 resource_key: win32-arm64592 resource_key: win32-arm64
592 cli_archive: true593 cli_archive: true
593594
594 # Preview releases never reach npm: the archive on the GitHub Release is the595 # npm gets the same bytes as the GitHub Release: the launcher plus one
595 # only way to obtain one, so no dist-tag can ever resolve to a preview build.596 # package per CLI archive. Preview publishes too, under the `preview`
597 # dist-tag, which nothing resolves unless asked for by name.
596 publish_cli:598 publish_cli:
597 name: Publish CLI to npm599 name: Publish CLI to npm
598 needs: [preflight, relay_public_config, quality, build_bundle]600 needs:
599 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.build_bundle.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }}601 [
602 preflight,
603 relay_public_config,
604 quality,
605 desktop_mac_arm64,
606 desktop_linux_x64,
607 desktop_linux_arm64,
608 desktop_win_x64,
609 desktop_win_arm64,
610 ]
611 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }}
600 runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404612 runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404
601 timeout-minutes: 10613 timeout-minutes: 15
602 permissions:614 permissions:
603 contents: read615 contents: read
604 id-token: write616 id-token: write
605 env:
606 T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }}
607 T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }}
608 T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }}
609 T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }}
610 steps:617 steps:
611 - name: Checkout618 - name: Checkout
612 uses: actions/checkout@v6619 uses: actions/checkout@v6
...@@ -627,38 +634,28 @@ jobs:...@@ -627,38 +634,28 @@ jobs:
627 - --filter=t3...634 - --filter=t3...
628 - --filter=@t3tools/scripts...635 - --filter=@t3tools/scripts...
629636
630 - name: Align package versions to release version637 - name: Download all CLI archives
631 run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"
632
633 # The artifact root is `apps/` (upload-artifact keeps the least common
634 # ancestor of its paths), so extracting into `apps` restores
635 # apps/server/dist and apps/desktop/dist-electron at their build paths.
636 - name: Download JS bundle
637 uses: actions/download-artifact@v8638 uses: actions/download-artifact@v8
638 with:639 with:
639 name: js-bundle640 pattern: cli-*
640 path: apps641 merge-multiple: true
642 path: release-cli
641643
642 - name: Download resource monitors644 - name: Build npm packages from CLI archives
643 uses: actions/download-artifact@v8645 run: node scripts/build-npm-platform-packages.ts --archives-dir release-cli --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages
644 with:
645 pattern: resource-monitor-*
646 path: ${{ runner.temp }}/resource-monitors
647646
648 - name: Bundle resource monitors into CLI package647 # A dry run of every package first: an auth or scope error here (the
649 shell: bash648 # @t3code org missing, a package without a trusted publisher) fails
649 # before anything is live, instead of after some platforms already are.
650 - name: Check npm publish access (dry run)
650 run: |651 run: |
651 set -euo pipefail652 if ! node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --dry-run --verbose; then
652 for artifact_dir in "$RUNNER_TEMP"/resource-monitors/resource-monitor-*; do653 echo "::error::npm publish --dry-run failed. Make sure the @t3code npm org exists and that t3 and every @t3code/t3-<platform> package has a trusted publisher registered for .github/workflows/release.yml (see docs/operations/release.md)." >&2
653 resource_key="${artifact_dir##*/resource-monitor-}"654 exit 1
654 target_dir="apps/server/dist/resource-monitor/${resource_key}"655 fi
655 mkdir -p "$target_dir"
656 cp "$artifact_dir"/t3-resource-monitor* "$target_dir/"
657 chmod +x "$target_dir"/t3-resource-monitor 2>/dev/null || true
658 done
659656
660 - name: Publish CLI package657 - name: Publish CLI packages
661 run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose658 run: node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --verbose
662659
663 release:660 release:
664 name: Publish GitHub Release661 name: Publish GitHub Release
...@@ -673,7 +670,7 @@ jobs:...@@ -673,7 +670,7 @@ jobs:
673 desktop_win_arm64,670 desktop_win_arm64,
674 publish_cli,671 publish_cli,
675 ]672 ]
676 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' && (needs.publish_cli.result == 'success' || (needs.preflight.outputs.release_channel == 'preview' && needs.publish_cli.result == 'skipped')) }}673 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' && needs.publish_cli.result == 'success' }}
677 runs-on: blacksmith-8vcpu-ubuntu-2404674 runs-on: blacksmith-8vcpu-ubuntu-2404
678 timeout-minutes: 30675 timeout-minutes: 30
679 permissions:676 permissions:
apps/server/scripts/cli.ts+49-178
...@@ -6,69 +6,24 @@ import * as FileSystem from "effect/FileSystem";...@@ -6,69 +6,24 @@ import * as FileSystem from "effect/FileSystem";
6import * as Logger from "effect/Logger";6import * as Logger from "effect/Logger";
7import * as Option from "effect/Option";7import * as Option from "effect/Option";
8import * as Path from "effect/Path";8import * as Path from "effect/Path";
9import * as Schema from "effect/Schema";
10import { Command, Flag } from "effect/unstable/cli";9import { Command, Flag } from "effect/unstable/cli";
11import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1211
13import {12import { DEVELOPMENT_ICON_OVERRIDES } from "../../../scripts/lib/brand-assets.ts";
14 DEVELOPMENT_ICON_OVERRIDES,
15 resolveWebAssetBrandForPackageVersion,
16 resolveWebIconOverrides,
17} from "../../../scripts/lib/brand-assets.ts";
18import { findEsmImportsOfExternalPackages } from "../../../scripts/lib/cli-external-packages.ts";13import { findEsmImportsOfExternalPackages } from "../../../scripts/lib/cli-external-packages.ts";
19import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts";
20import { fromJsonStringPretty } from "@t3tools/shared/schemaJson";
21import { fromYaml } from "@t3tools/shared/schemaYaml";
22import { resolveSpawnCommand } from "@t3tools/shared/shell";14import { resolveSpawnCommand } from "@t3tools/shared/shell";
23import serverPackageJson from "../package.json" with { type: "json" };
24import {15import {
25 ServerCliBuildAssetMissingError,16 ServerCliBuildAssetMissingError,
26 ServerCliCommandExitError,17 ServerCliCommandExitError,
27 ServerCliDevelopmentIconSourceMissingError,18 ServerCliDevelopmentIconSourceMissingError,
28 ServerCliDevelopmentIconTargetMissingError,19 ServerCliDevelopmentIconTargetMissingError,
29 ServerCliExecutableImportError,20 ServerCliExecutableImportError,
30 ServerCliPublishIconSourceMissingError,
31 ServerCliPublishIconTargetMissingError,
32} from "./cliErrors.ts";21} from "./cliErrors.ts";
3322
34interface PackageJson {
35 name: string;
36 repository: {
37 type: string;
38 url: string;
39 directory: string;
40 };
41 bin: Record<string, string>;
42 type: string;
43 version: string;
44 engines: Record<string, string>;
45 files: string[];
46 dependencies: Record<string, string>;
47 overrides: Record<string, string>;
48}
49
50const PackageJsonPrettyJson = fromJsonStringPretty(Schema.Unknown);
51const encodePackageJson = Schema.encodeEffect(PackageJsonPrettyJson);
52
53const WorkspaceConfig = Schema.Struct({
54 catalog: Schema.optional(Schema.Record(Schema.String, Schema.String)),
55 overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)),
56});
57type WorkspaceConfig = typeof WorkspaceConfig.Type;
58const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig));
59
60const RepoRoot = Effect.service(Path.Path).pipe(23const RepoRoot = Effect.service(Path.Path).pipe(
61 Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))),24 Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))),
62);25);
6326
64const readWorkspaceConfig = Effect.fn("readWorkspaceConfig")(function* () {
65 const path = yield* Path.Path;
66 const fs = yield* FileSystem.FileSystem;
67 const repoRoot = yield* RepoRoot;
68 const workspaceYaml = yield* fs.readFileString(path.join(repoRoot, "pnpm-workspace.yaml"));
69 return yield* decodeWorkspaceConfig(workspaceYaml);
70});
71
72const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.StandardCommand) {27const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.StandardCommand) {
73 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;28 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
74 const child = yield* spawner.spawn(command);29 const child = yield* spawner.spawn(command);
...@@ -84,36 +39,6 @@ const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.Stan...@@ -84,36 +39,6 @@ const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.Stan
84 }39 }
85});40});
8641
87const preparePublishIcons = Effect.fn("preparePublishIcons")(function* (
88 repoRoot: string,
89 serverDir: string,
90 version: string,
91) {
92 const path = yield* Path.Path;
93 const fs = yield* FileSystem.FileSystem;
94 const brand = resolveWebAssetBrandForPackageVersion(version);
95 const icons = resolveWebIconOverrides(brand, "dist/client").map((override) => ({
96 sourcePath: path.join(repoRoot, override.sourceRelativePath),
97 targetPath: path.join(serverDir, override.targetRelativePath),
98 }));
99
100 for (const icon of icons) {
101 if (!(yield* fs.exists(icon.sourcePath))) {
102 return yield* new ServerCliPublishIconSourceMissingError({ sourcePath: icon.sourcePath });
103 }
104 if (!(yield* fs.exists(icon.targetPath))) {
105 return yield* new ServerCliPublishIconTargetMissingError({ targetPath: icon.targetPath });
106 }
107 }
108
109 return yield* Effect.forEach(icons, (icon) =>
110 Effect.all({
111 original: fs.readFile(icon.targetPath),
112 publish: fs.readFile(icon.sourcePath),
113 }).pipe(Effect.map((contents) => ({ ...icon, ...contents }))),
114 );
115});
116
117const applyDevelopmentIconOverrides = Effect.fn("applyDevelopmentIconOverrides")(function* (42const applyDevelopmentIconOverrides = Effect.fn("applyDevelopmentIconOverrides")(function* (
118 repoRoot: string,43 repoRoot: string,
119 serverDir: string,44 serverDir: string,
...@@ -240,37 +165,21 @@ const buildExeCmd = Command.make(...@@ -240,37 +165,21 @@ const buildExeCmd = Command.make(
240// publish subcommand165// publish subcommand
241// ---------------------------------------------------------------------------166// ---------------------------------------------------------------------------
242167
243interface PublishCommandConfig {168/**
244 readonly access: string;169 * Publishes the tarballs scripts/build-npm-platform-packages.ts produced:
245 readonly tag: string;170 * every `@t3code/t3-<platform>.tgz` first, `t3.tgz` (the launcher) last, so
246 readonly provenance: boolean;171 * the launcher is never installable before the executables it depends on.
247 readonly dryRun: boolean;172 * Tarballs rather than directories because `npm publish <dir>` strips the
248}173 * `node_modules/` the executable loads its native addons from.
249174 */
250const createVpPmPublishArgs = (config: PublishCommandConfig): ReadonlyArray<string> => {
251 const args = [
252 "publish",
253 "--filter",
254 "t3",
255 "--access",
256 config.access,
257 "--tag",
258 config.tag,
259 "--no-git-checks",
260 ];
261
262 if (config.provenance) args.push("--provenance");
263 if (config.dryRun) args.push("--dry-run");
264
265 return args;
266};
267
268const publishCmd = Command.make(175const publishCmd = Command.make(
269 "publish",176 "publish",
270 {177 {
178 packagesDir: Flag.string("packages-dir").pipe(
179 Flag.withDescription("Output dir of scripts/build-npm-platform-packages.ts."),
180 ),
271 tag: Flag.string("tag").pipe(Flag.withDefault("latest")),181 tag: Flag.string("tag").pipe(Flag.withDefault("latest")),
272 access: Flag.string("access").pipe(Flag.withDefault("public")),182 access: Flag.string("access").pipe(Flag.withDefault("public")),
273 appVersion: Flag.string("app-version").pipe(Flag.optional),
274 provenance: Flag.boolean("provenance").pipe(Flag.withDefault(false)),183 provenance: Flag.boolean("provenance").pipe(Flag.withDefault(false)),
275 dryRun: Flag.boolean("dry-run").pipe(Flag.withDefault(false)),184 dryRun: Flag.boolean("dry-run").pipe(Flag.withDefault(false)),
276 verbose: Flag.boolean("verbose").pipe(Flag.withDefault(false)),185 verbose: Flag.boolean("verbose").pipe(Flag.withDefault(false)),
...@@ -279,86 +188,48 @@ const publishCmd = Command.make(...@@ -279,86 +188,48 @@ const publishCmd = Command.make(
279 Effect.gen(function* () {188 Effect.gen(function* () {
280 const path = yield* Path.Path;189 const path = yield* Path.Path;
281 const fs = yield* FileSystem.FileSystem;190 const fs = yield* FileSystem.FileSystem;
282 const repoRoot = yield* RepoRoot;191 // npm runs with cwd set to the packages dir below, so tarball paths are
283 const serverDir = path.join(repoRoot, "apps/server");192 // resolved once here rather than joined twice.
284 const packageJsonPath = path.join(serverDir, "package.json");193 const packagesDir = path.resolve(config.packagesDir);
285194 const scopeDir = path.join(packagesDir, "@t3code");
286 // Assert build assets exist195 const launcherTarball = path.join(packagesDir, "t3.tgz");
287 for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) {196 const platformTarballs = (yield* fs
288 const abs = path.join(serverDir, relPath);197 .readDirectory(scopeDir)
289 if (!(yield* fs.exists(abs))) {198 .pipe(Effect.orElseSucceed((): ReadonlyArray<string> => [])))
290 return yield* new ServerCliBuildAssetMissingError({ assetPath: abs });199 .filter((entry) => entry.startsWith("t3-") && entry.endsWith(".tgz"))
291 }200 .sort()
201 .map((entry) => path.join(scopeDir, entry));
202 if (platformTarballs.length === 0) {
203 return yield* new ServerCliBuildAssetMissingError({
204 assetPath: path.join(scopeDir, "t3-<platform>.tgz"),
205 });
206 }
207 if (!(yield* fs.exists(launcherTarball))) {
208 return yield* new ServerCliBuildAssetMissingError({ assetPath: launcherTarball });
292 }209 }
293210
294 yield* Effect.acquireUseRelease(211 const args = ["publish", "--access", config.access, "--tag", config.tag];
295 // Acquire: resolve publish metadata and read every original before mutation.212 if (config.provenance) args.push("--provenance");
296 Effect.gen(function* () {213 if (config.dryRun) args.push("--dry-run");
297 const version = Option.getOrElse(config.appVersion, () => serverPackageJson.version);214
298 const workspaceConfig = yield* readWorkspaceConfig();215 for (const tarball of [...platformTarballs, launcherTarball]) {
299 const workspaceCatalog = workspaceConfig.catalog ?? {};216 const spawnCommand = yield* resolveSpawnCommand("npm", [...args, tarball]);
300 const workspaceOverrides = workspaceConfig.overrides ?? {};217 yield* Effect.log(`[cli] npm ${args.join(" ")} ${path.basename(tarball)}`);
301 const pkg: PackageJson = {218 yield* runCommand(
302 name: serverPackageJson.name,219 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
303 repository: serverPackageJson.repository,220 cwd: packagesDir,
304 bin: serverPackageJson.bin,221 stdout: config.verbose ? "inherit" : "ignore",
305 type: serverPackageJson.type,222 stderr: "inherit",
306 version,223 shell: spawnCommand.shell,
307 engines: serverPackageJson.engines,
308 files: serverPackageJson.files,
309 dependencies: resolveCatalogDependencies(
310 serverPackageJson.dependencies,
311 workspaceCatalog,
312 "apps/server",
313 ),
314 overrides: resolveCatalogDependencies(
315 workspaceOverrides,
316 workspaceCatalog,
317 "apps/server",
318 ),
319 };
320
321 return {
322 packageJsonString: yield* encodePackageJson(pkg),
323 originalPackageJson: yield* fs.readFile(packageJsonPath),
324 icons: yield* preparePublishIcons(repoRoot, serverDir, version),
325 };
326 }),
327 // Use: pnpm publish from the workspace root so pnpm-only workspace
328 // config, including override selectors, is interpreted correctly.
329 (resource) =>
330 Effect.gen(function* () {
331 yield* fs.writeFileString(packageJsonPath, `${resource.packageJsonString}\n`);
332 for (const icon of resource.icons) {
333 yield* fs.writeFile(icon.targetPath, icon.publish);
334 }
335 yield* Effect.log("[cli] Applied package metadata and publish icon overrides");
336
337 const args = createVpPmPublishArgs(config);
338 const spawnCommand = yield* resolveSpawnCommand("vp", ["pm", ...args]);
339
340 yield* Effect.log(`[cli] Running: vp pm ${args.join(" ")}`);
341 yield* runCommand(
342 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
343 cwd: repoRoot,
344 stdout: config.verbose ? "inherit" : "ignore",
345 stderr: "inherit",
346 shell: spawnCommand.shell,
347 }),
348 );
349 }),224 }),
350 // Release: restore every file even if applying overrides or publishing fails.225 );
351 (resource) =>226 }
352 Effect.gen(function* () {
353 yield* fs.writeFile(packageJsonPath, resource.originalPackageJson);
354 for (const icon of resource.icons) {
355 yield* fs.writeFile(icon.targetPath, icon.original);
356 }
357 if (config.verbose) yield* Effect.log("[cli] Restored original publish assets");
358 }),
359 );
360 }),227 }),
361).pipe(Command.withDescription("Publish the server package to npm."));228).pipe(
229 Command.withDescription(
230 "Publish the @t3code/t3-<platform> tarballs and then the t3 launcher to npm.",
231 ),
232);
362233
363// ---------------------------------------------------------------------------234// ---------------------------------------------------------------------------
364// root command235// root command
apps/server/scripts/cliErrors.ts-22
...@@ -14,28 +14,6 @@ export class ServerCliCommandExitError extends Schema.TaggedError<ServerCliComma...@@ -14,28 +14,6 @@ export class ServerCliCommandExitError extends Schema.TaggedError<ServerCliComma
14 }14 }
15}15}
1616
17export class ServerCliPublishIconSourceMissingError extends Schema.TaggedError<ServerCliPublishIconSourceMissingError>()(
18 "ServerCliPublishIconSourceMissingError",
19 {
20 sourcePath: Schema.String,
21 },
22) {
23 override get message(): string {
24 return `Missing publish icon source: ${this.sourcePath}`;
25 }
26}
27
28export class ServerCliPublishIconTargetMissingError extends Schema.TaggedError<ServerCliPublishIconTargetMissingError>()(
29 "ServerCliPublishIconTargetMissingError",
30 {
31 targetPath: Schema.String,
32 },
33) {
34 override get message(): string {
35 return `Missing publish icon target: ${this.targetPath}. Run the build subcommand first.`;
36 }
37}
38
39export class ServerCliDevelopmentIconSourceMissingError extends Schema.TaggedError<ServerCliDevelopmentIconSourceMissingError>()(17export class ServerCliDevelopmentIconSourceMissingError extends Schema.TaggedError<ServerCliDevelopmentIconSourceMissingError>()(
40 "ServerCliDevelopmentIconSourceMissingError",18 "ServerCliDevelopmentIconSourceMissingError",
41 {19 {
docs/operations/release.md+26-16
...@@ -12,7 +12,7 @@ This document covers the unified release workflow for stable and nightly desktop...@@ -12,7 +12,7 @@ This document covers the unified release workflow for stable and nightly desktop
12 - push tag matching `v*.*.*` for a stable release of an explicit commit12 - push tag matching `v*.*.*` for a stable release of an explicit commit
13 - scheduled nightly check every 30 minutes13 - scheduled nightly check every 30 minutes
14 - manual `workflow_dispatch` with `channel=nightly`14 - manual `workflow_dispatch` with `channel=nightly`
15 - manual `workflow_dispatch` with `channel=preview`, the maintainers' test train. It exercises the whole release flow (build, sign, notarize, smoke, publish) for a commit that end users must never receive, which is how an unmerged branch or a risky change gets a real release run before it lands. It builds the triggering commit with nightly's versioning under the `preview` prerelease identifier (`0.0.41-preview.<date>.<run>`) and publishes only a GitHub prerelease. Nothing ever selects preview on its own: it is not on the schedule, not published to npm, its desktop builds carry no update feed, and no updater manifest (`latest*.yml`, `nightly*.yml`, blockmaps) is attached, so a stable or nightly install cannot be offered one. The only ways onto it are downloading the release by hand, `T3CODE_CHANNEL=preview` for the install scripts, or `t3 update --channel preview` from a terminal; each prints a warning, and the CLI asks for confirmation when the running build is not itself a preview. The release itself is named as a maintainer test build and its body is a warning rather than generated notes: a changelog of unmerged branch history is not a changelog, and nightly and stable notes are unaffected because each series resolves its previous tag within its own channel. The hosted web app, AUR, and Discord announcements are skipped. Keep it; it costs nothing when idle.15 - manual `workflow_dispatch` with `channel=preview`, the maintainers' test train. It exercises the whole release flow (build, sign, notarize, smoke, publish) for a commit that end users must never receive, which is how an unmerged branch or a risky change gets a real release run before it lands. It builds the triggering commit with nightly's versioning under the `preview` prerelease identifier (`0.0.41-preview.<date>.<run>`) and publishes a GitHub prerelease plus the npm packages under the `preview` dist-tag. Nothing ever selects preview on its own: it is not on the schedule, no default npm dist-tag points at it, its desktop builds carry no update feed, and no updater manifest (`latest*.yml`, `nightly*.yml`, blockmaps) is attached, so a stable or nightly install cannot be offered one. The only ways onto it are downloading the release by hand, `npx t3@preview`, `T3CODE_CHANNEL=preview` for the install scripts, or `t3 update --channel preview` from a terminal; each prints a warning, and the CLI asks for confirmation when the running build is not itself a preview. The release itself is named as a maintainer test build and its body is a warning rather than generated notes: a changelog of unmerged branch history is not a changelog, and nightly and stable notes are unaffected because each series resolves its previous tag within its own channel. The hosted web app, AUR, and Discord announcements are skipped. Keep it; it costs nothing when idle.
16- A manual stable release builds the commit of the latest published nightly, not `main` HEAD.16- A manual stable release builds the commit of the latest published nightly, not `main` HEAD.
17 Nightly is the release candidate: verify the nightly, then promote it. Merges to `main` keep17 Nightly is the release candidate: verify the nightly, then promote it. Merges to `main` keep
18 landing while you verify and never leak into the stable build.18 landing while you verify and never leak into the stable build.
...@@ -36,14 +36,15 @@ This document covers the unified release workflow for stable and nightly desktop...@@ -36,14 +36,15 @@ This document covers the unified release workflow for stable and nightly desktop
36 - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag.36 - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag.
37- Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets.37- Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets.
38- Builds a self-contained CLI archive per platform (`t3-<version>-<platform>-<arch>.tar.gz`, `.zip` on Windows) in the same job as that target's desktop artifact and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel, for five targets: macOS arm64, Linux x64 and arm64, Windows x64 and arm64. Every archive is built, signed, and smoke-tested on hardware of its own architecture. There is no macOS x64 archive: Node single-executables are unsupported on x64 macOS (the SEA docs list macOS as arm64 only) and the binary segfaults on start; the x64 desktop app is Electron and unaffected.38- Builds a self-contained CLI archive per platform (`t3-<version>-<platform>-<arch>.tar.gz`, `.zip` on Windows) in the same job as that target's desktop artifact and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel, for five targets: macOS arm64, Linux x64 and arm64, Windows x64 and arm64. Every archive is built, signed, and smoke-tested on hardware of its own architecture. There is no macOS x64 archive: Node single-executables are unsupported on x64 macOS (the SEA docs list macOS as arm64 only) and the binary segfaults on start; the x64 desktop app is Electron and unaffected.
39 - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the only form in which T3 Code manages a runtime: the desktop's SSH environments, the boot service, `t3 update`, and the install scripts all download and verify this archive against `SHA256SUMS`. The npm package exists for people who run `npx t3` or `npm install -g t3` themselves; nothing in the product installs from npm. The `curl | sh` installers are `scripts/install.sh` and `scripts/install.ps1`; the marketing site copies them into its `public/` at build time (`apps/marketing/scripts/stage-install-scripts.mjs`) and serves them at `t3.codes/install.sh` and `/install.ps1`.39 - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the only form in which T3 Code manages a runtime: the desktop's SSH environments, the boot service, `t3 update`, and the install scripts all download and verify this archive against `SHA256SUMS`. The npm packages exist for people who run `npx t3` or `npm install -g t3` themselves and carry the same archive contents; nothing in the product installs from npm. The `curl | sh` installers are `scripts/install.sh` and `scripts/install.ps1`; the marketing site copies them into its `public/` at build time (`apps/marketing/scripts/stage-install-scripts.mjs`) and serves them at `t3.codes/install.sh` and `/install.ps1`.
40 - The executable is built with a Node that supports `--build-sea` (`VP_NODE_VERSION=26.8.2`, kept in step with `SEA_NODE_VERSION` in `apps/server/vite.config.ts`), while the repo stays on `engines.node`.40 - The executable is built with a Node that supports `--build-sea` (`VP_NODE_VERSION=26.8.2`, kept in step with `SEA_NODE_VERSION` in `apps/server/vite.config.ts`), while the repo stays on `engines.node`.
41 - macOS archives are signed with the Developer ID certificate and notarized when the Apple secrets are present (ad hoc otherwise, which still runs from `curl`/`tar` installs). Windows executables use the same Azure Trusted Signing setup as the installer. Every native addon in the macOS archive is signed too, since the hardened runtime refuses unsigned libraries.41 - macOS archives are signed with the Developer ID certificate and notarized when the Apple secrets are present (ad hoc otherwise, which still runs from `curl`/`tar` installs). Windows executables use the same Azure Trusted Signing setup as the installer. Every native addon in the macOS archive is signed too, since the hardened runtime refuses unsigned libraries.
42 - Each archive is extracted and executed on its build runner (`scripts/smoke-cli-archive.ts`) before it is uploaded.42 - Each archive is extracted and executed on its build runner (`scripts/smoke-cli-archive.ts`) before it is uploaded.
43- Publishes the CLI package (`apps/server`, npm package `t3`) with OIDC trusted publishing from the same workflow file:43- Publishes the CLI to npm with OIDC trusted publishing from the same workflow file, as the same bytes the GitHub Release carries: `scripts/build-npm-platform-packages.ts` unpacks the five CLI archives into `@t3code/t3-<platform>-<arch>` packages (each with `os`/`cpu` set so npm installs only the matching one) and generates the `t3` launcher, whose `bin/t3.js` lists them as `optionalDependencies` and execs the installed executable. `npx t3` therefore needs Node only to run the launcher, never to run the server. `node apps/server/scripts/cli.ts publish` publishes the platform packages first and the launcher last, after a `--dry-run` pass over all of them so an auth or scope error fails before anything is live.
44 - stable releases publish npm dist-tag `latest`44 - stable releases publish npm dist-tag `latest`
45 - nightly releases publish npm dist-tag `nightly`45 - nightly releases publish npm dist-tag `nightly`
46 - preview releases are not published to npm46 - preview releases publish npm dist-tag `preview`, which nothing resolves unless asked for by name
47 - one-time setup: the `@t3code` npm scope (org) must exist, and `t3` and each `@t3code/t3-<platform>-<arch>` package needs a trusted publisher registered for this workflow file (see below).
47- Deploys the hosted web app to Vercel only after a release is published:48- Deploys the hosted web app to Vercel only after a release is published:
48 - stable releases are aliased to the `latest` hosted app channel49 - stable releases are aliased to the `latest` hosted app channel
49 - nightly releases are aliased to the `nightly` hosted app channel50 - nightly releases are aliased to the `nightly` hosted app channel
...@@ -198,7 +199,7 @@ One-time Vercel dashboard setup:...@@ -198,7 +199,7 @@ One-time Vercel dashboard setup:
198 - `make_latest` is always `false`199 - `make_latest` is always `false`
199- Uses the next stable patch version as the nightly base. For example, `0.0.17` produces nightlies on `0.0.18-nightly.*`.200- Uses the next stable patch version as the nightly base. For example, `0.0.17` produces nightlies on `0.0.18-nightly.*`.
200- Publishes Electron auto-update metadata to the dedicated `nightly` updater channel, so desktop users can opt into that track independently from stable.201- Publishes Electron auto-update metadata to the dedicated `nightly` updater channel, so desktop users can opt into that track independently from stable.
201- Publishes the CLI package (`apps/server`, npm package `t3`) to the `nightly` npm dist-tag using the same nightly version.202- Publishes the CLI npm packages (`t3` and `@t3code/t3-<platform>-<arch>`) to the `nightly` npm dist-tag using the same nightly version.
202- Does not commit version bumps back to `main`.203- Does not commit version bumps back to `main`.
203204
204## Server self-update release invariant205## Server self-update release invariant
...@@ -209,7 +210,7 @@ npm before users can receive that client....@@ -209,7 +210,7 @@ npm before users can receive that client.
209210
210The workflow enforces this ordering:211The workflow enforces this ordering:
211212
2121. `publish_cli` publishes the exact stable or nightly version to npm.2131. `publish_cli` publishes the exact release version to npm, on every channel.
2132. `release` depends on `publish_cli` before exposing desktop artifacts in GitHub Releases.2142. `release` depends on `publish_cli` before exposing desktop artifacts in GitHub Releases.
2143. `deploy_web` depends on `release` before moving the hosted channel to the new client.2153. `deploy_web` depends on `release` before moving the hosted channel to the new client.
215216
...@@ -294,24 +295,33 @@ blockmaps, with a 60 MB maximum for a representative sidecar-to-sidecar update....@@ -294,24 +295,33 @@ blockmaps, with a 60 MB maximum for a representative sidecar-to-sidecar update.
294295
295## 0) npm OIDC trusted publishing setup (CLI)296## 0) npm OIDC trusted publishing setup (CLI)
296297
297The workflow invokes `node apps/server/scripts/cli.ts publish` after aligning package versions. That298The workflow runs `node scripts/build-npm-platform-packages.ts` on the downloaded CLI archives, then
298script temporarily prepares the `t3` package, then runs `vp pm publish --filter t3 ...` from the299`node apps/server/scripts/cli.ts publish --packages-dir npm-packages`, which runs `npm publish` on
299repository root so workspace publish configuration is applied correctly.300each `@t3code/t3-<platform>-<arch>.tgz` and finally on `t3.tgz`, the launcher. The script publishes
301tarballs it built itself rather than directories: `npm publish <dir>` strips `node_modules/` from the
302tarball no matter what `files` says, and the executable loads its native addons from there. Seven
303packages are published per release: `t3`, `@t3code/t3-darwin-arm64`, `@t3code/t3-darwin-x64`,
304`@t3code/t3-linux-arm64`, `@t3code/t3-linux-x64`, `@t3code/t3-win32-arm64`,
305`@t3code/t3-win32-x64`.
300306
301Checklist:307Checklist:
302308
3031. Confirm npm org/user owns package `t3` (or rename package first if needed).3091. Confirm the npm org owns package `t3` and the `@t3code` scope exists on npm (create the org if
3042. In npm package settings, configure Trusted Publisher:310 it does not).
3112. For `t3` and each `@t3code/t3-<platform>-<arch>` package, configure a Trusted Publisher in the
312 npm package settings (a package that has never been published needs a first publish or a
313 placeholder before the setting exists; the `--dry-run` step in `publish_cli` reports which
314 names are still rejected):
305 - Provider: GitHub Actions315 - Provider: GitHub Actions
306 - Repository: this repo316 - Repository: this repo
307 - Workflow file: `.github/workflows/release.yml`317 - Workflow file: `.github/workflows/release.yml`
308 - Environment (if used): match your npm trusted publishing config318 - Environment (if used): match your npm trusted publishing config
3093. Ensure npm account and org policies allow trusted publishing for the package.3193. Ensure npm account and org policies allow trusted publishing for every package.
3104. Create release tag `vX.Y.Z` and push; workflow will:3204. Create release tag `vX.Y.Z` and push; workflow will:
311 - align the release package versions to `X.Y.Z`321 - build and smoke-test the five CLI archives
312 - build web + server322 - build the npm packages from those archives
313 - invoke the CLI publish script with npm dist-tag `latest`323 - publish them with npm dist-tag `latest`
3145. Nightly runs invoke the same publish script with npm dist-tag `nightly`.3245. Nightly runs publish with npm dist-tag `nightly`; preview runs with `preview`.
315325
316## 1) Release validation and unsigned builds326## 1) Release validation and unsigned builds
317327
docs/user/install.md+20-2
...@@ -5,8 +5,10 @@ desktop, web, or mobile app. Set up the machine where the agents will work first...@@ -5,8 +5,10 @@ desktop, web, or mobile app. Set up the machine where the agents will work first
55
6## Requirements6## Requirements
77
8Command-line use, SSH hosts, and WSL backends need Node.js 22.16+ (22.x), 23.11+8`npx t3` needs Node.js only to run npm itself; the CLI it installs is a
9(23.x), or 24.10 and later. The native desktop app includes its server runtime.9self-contained executable. SSH hosts and WSL backends need Node.js 22.16+
10(22.x), 23.11+ (23.x), or 24.10 and later. The native desktop app includes its
11server runtime.
1012
11You need an installed, authenticated provider before starting a thread. You can13You need an installed, authenticated provider before starting a thread. You can
12launch T3 Code and configure providers afterwards.14launch T3 Code and configure providers afterwards.
...@@ -20,6 +22,22 @@ npx t3@latest...@@ -20,6 +22,22 @@ npx t3@latest
20This starts the server and opens the local web app. Run22This starts the server and opens the local web app. Run
21`npx t3@latest --help` for command-line options.23`npx t3@latest --help` for command-line options.
2224
25The executable is built for Apple Silicon Macs, Linux, and Windows. There is
26no Intel Mac build of it, because Node cannot produce a single executable for
27that platform; the Intel desktop app is unaffected. To run a standalone server
28on an Intel Mac, build it from source. You need Node.js 24 and `vp` (see
29[Install vp](https://github.com/pingdotgg/t3code#install-vp)):
30
31```bash
32git clone https://github.com/pingdotgg/t3code
33cd t3code && vp i && vp run build:desktop
34node apps/server/dist/bin.mjs
35```
36
37A server run this way is a plain Node program: `t3 update` and the background
38service do not apply, so update it with `git pull` and a rebuild, and start it
39however you run other Node processes.
40
23## Desktop app41## Desktop app
2442
25Download a release from [GitHub Releases](https://github.com/pingdotgg/t3code/releases),43Download a release from [GitHub Releases](https://github.com/pingdotgg/t3code/releases),
packages/shared/src/cliRelease.ts+1-1
...@@ -19,7 +19,7 @@ export const CLI_RELEASE_BASE_URL_ENV = "T3CODE_RELEASE_BASE_URL";...@@ -19,7 +19,7 @@ export const CLI_RELEASE_BASE_URL_ENV = "T3CODE_RELEASE_BASE_URL";
19 */19 */
20// No darwin-x64: Node single-executables are unsupported on x64 macOS (the20// No darwin-x64: Node single-executables are unsupported on x64 macOS (the
21// SEA docs list macOS as arm64 only) and the binary segfaults on start.21// SEA docs list macOS as arm64 only) and the binary segfaults on start.
22const CLI_ARCHIVE_PLATFORM_KEYS = [22export const CLI_ARCHIVE_PLATFORM_KEYS = [
23 "darwin-arm64",23 "darwin-arm64",
24 "linux-arm64",24 "linux-arm64",
25 "linux-x64",25 "linux-x64",
scripts/build-cli-archive.ts+39-1
...@@ -23,6 +23,7 @@ import * as Layer from "effect/Layer";...@@ -23,6 +23,7 @@ import * as Layer from "effect/Layer";
23import * as Logger from "effect/Logger";23import * as Logger from "effect/Logger";
24import * as Option from "effect/Option";24import * as Option from "effect/Option";
25import * as Path from "effect/Path";25import * as Path from "effect/Path";
26import * as PlatformError from "effect/PlatformError";
26import * as Schema from "effect/Schema";27import * as Schema from "effect/Schema";
27import { Command, Flag } from "effect/unstable/cli";28import { Command, Flag } from "effect/unstable/cli";
28import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";29import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
...@@ -241,8 +242,33 @@ const stageRuntimeExternals = Effect.fn("stageRuntimeExternals")(function* (inpu...@@ -241,8 +242,33 @@ const stageRuntimeExternals = Effect.fn("stageRuntimeExternals")(function* (inpu
241 ]) {242 ]) {
242 yield* fs.remove(path.join(input.stageDir, entry), { recursive: true, force: true });243 yield* fs.remove(path.join(input.stageDir, entry), { recursive: true, force: true });
243 }244 }
245 // A hoisted install still leaves nested `node_modules/.bin` shim directories
246 // inside packages that declare bins (msgpackr-extract's). They are symlinks
247 // nothing runs, and the npm registry refuses a tarball that contains any
248 // symlink, so strip every `.bin` directory below node_modules.
249 yield* removeNestedBinDirectories(fs, path, path.join(input.stageDir, "node_modules"));
244});250});
245251
252const removeNestedBinDirectories = (
253 fs: FileSystem.FileSystem,
254 path: Path.Path,
255 root: string,
256): Effect.Effect<void, PlatformError.PlatformError> =>
257 Effect.gen(function* () {
258 const entries = yield* fs.readDirectory(root).pipe(Effect.orElseSucceed(() => []));
259 for (const entry of entries) {
260 const child = path.join(root, entry);
261 if (entry === ".bin") {
262 yield* fs.remove(child, { recursive: true, force: true });
263 continue;
264 }
265 const info = yield* fs.stat(child).pipe(Effect.option);
266 if (Option.isSome(info) && info.value.type === "Directory") {
267 yield* removeNestedBinDirectories(fs, path, child);
268 }
269 }
270 });
271
246/** Copies the web client without its sourcemaps, which nothing serves. */272/** Copies the web client without its sourcemaps, which nothing serves. */
247const stageWebClient = Effect.fn("stageWebClient")(function* (source: string, target: string) {273const stageWebClient = Effect.fn("stageWebClient")(function* (source: string, target: string) {
248 const fs = yield* FileSystem.FileSystem;274 const fs = yield* FileSystem.FileSystem;
...@@ -516,8 +542,20 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: {...@@ -516,8 +542,20 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: {
516 "tar (zip)",542 "tar (zip)",
517 );543 );
518 } else {544 } else {
545 // On Linux, pnpm hard-links identical files out of its store and node-gyp
546 // hard-links build outputs, and GNU tar records those as link entries.
547 // The npm registry rejects a tarball containing any, and the npm platform
548 // packages are re-packed from this archive's contents, so store every
549 // file as a file. macOS's bsdtar has no such flag; pnpm clones there.
519 yield* runCommand(550 yield* runCommand(
520 ChildProcess.make("tar", ["-czf", archivePath, "-C", stageRoot, stem]),551 ChildProcess.make("tar", [
552 ...(input.platform === "linux" ? ["--hard-dereference"] : []),
553 "-czf",
554 archivePath,
555 "-C",
556 stageRoot,
557 stem,
558 ]),
521 "tar (gzip)",559 "tar (gzip)",
522 );560 );
523 }561 }
scripts/build-npm-platform-packages.test.ts created+203
...@@ -0,0 +1,203 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as Schema from "effect/Schema";
7import * as Stream from "effect/Stream";
8import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
9
10import {
11 buildNpmPlatformPackages,
12 NpmPackagesArchivesMissingError,
13} from "./build-npm-platform-packages.ts";
14
15const VERSION = "1.2.3";
16const decodeManifest = Schema.decodeEffect(
17 Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)),
18);
19const KEYS = ["linux-x64", "darwin-arm64"] as const;
20
21const collect = <E>(stream: Stream.Stream<Uint8Array, E>) =>
22 stream.pipe(
23 Stream.decodeText(),
24 Stream.runFold(
25 () => "",
26 (acc, chunk) => acc + chunk,
27 ),
28 );
29
30const run = Effect.fn("test.run")(function* (
31 command: string,
32 args: ReadonlyArray<string>,
33 options: { readonly cwd: string; readonly env?: Record<string, string> },
34) {
35 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
36 const child = yield* spawner.spawn(
37 ChildProcess.make(command, args, { cwd: options.cwd, env: options.env ?? {} }),
38 );
39 const [stdout, stderr, exitCode] = yield* Effect.all(
40 [collect(child.stdout), collect(child.stderr), child.exitCode.pipe(Effect.map(Number))],
41 { concurrency: "unbounded" },
42 );
43 return { stdout, stderr, exitCode };
44});
45
46/** A tar.gz laid out like build-cli-archive.ts writes, with a stub `t3` that echoes its args. */
47const makeFakeArchives = Effect.fn("test.makeFakeArchives")(function* () {
48 const fs = yield* FileSystem.FileSystem;
49 const path = yield* Path.Path;
50 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-npm-packages-test-" });
51 const archivesDir = path.join(root, "archives");
52 yield* fs.makeDirectory(archivesDir);
53 for (const key of KEYS) {
54 const stem = `t3-${VERSION}-${key}`;
55 const stage = path.join(root, "stage", key);
56 const contentDir = path.join(stage, stem);
57 for (const dir of ["client", "resource-monitor", "node_modules/node-pty"]) {
58 yield* fs.makeDirectory(path.join(contentDir, dir), { recursive: true });
59 }
60 yield* fs.writeFileString(path.join(contentDir, "client/index.html"), "<html></html>\n");
61 yield* fs.writeFileString(
62 path.join(contentDir, "t3"),
63 `#!/bin/sh\necho "stub ${key} $*"\nexit 7\n`,
64 );
65 yield* fs.chmod(path.join(contentDir, "t3"), 0o755);
66 const exit = yield* run("tar", ["-czf", path.join(archivesDir, `${stem}.tar.gz`), stem], {
67 cwd: stage,
68 });
69 assert.equal(exit.exitCode, 0, exit.stderr);
70 }
71 yield* fs.writeFileString(path.join(archivesDir, "SHA256SUMS"), "");
72 return { root, archivesDir, outputDir: path.join(root, "out") };
73});
74
75it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => {
76 it.effect("refuses a partial release unless --allow-missing is passed", () =>
77 Effect.gen(function* () {
78 const fixture = yield* makeFakeArchives();
79 const error = yield* buildNpmPlatformPackages({
80 ...fixture,
81 version: VERSION,
82 allowMissing: false,
83 }).pipe(Effect.flip);
84 assert.instanceOf(error, NpmPackagesArchivesMissingError);
85 assert.deepStrictEqual((error as NpmPackagesArchivesMissingError).missing, [
86 "linux-arm64",
87 "win32-arm64",
88 "win32-x64",
89 ]);
90 }),
91 );
92
93 it.effect("builds platform packages and a launcher that execs the installed one", () =>
94 Effect.gen(function* () {
95 const fs = yield* FileSystem.FileSystem;
96 const path = yield* Path.Path;
97 const fixture = yield* makeFakeArchives();
98 const outputs = yield* buildNpmPlatformPackages({
99 ...fixture,
100 version: VERSION,
101 allowMissing: true,
102 });
103 // Platform packages in CLI_ARCHIVE_PLATFORM_KEYS order, launcher last.
104 assert.deepStrictEqual(
105 outputs.map((output) => output.name),
106 ["@t3code/t3-darwin-arm64", "@t3code/t3-linux-x64", "t3"],
107 );
108 for (const output of outputs) {
109 assert.isTrue(yield* fs.exists(output.tarball), output.tarball);
110 }
111
112 const linuxDir = path.join(fixture.outputDir, "@t3code/t3-linux-x64");
113 const linuxManifest = yield* decodeManifest(
114 yield* fs.readFileString(path.join(linuxDir, "package.json")),
115 );
116 assert.equal(linuxManifest.name, "@t3code/t3-linux-x64");
117 assert.equal(linuxManifest.version, VERSION);
118 assert.deepStrictEqual(linuxManifest.os, ["linux"]);
119 assert.deepStrictEqual(linuxManifest.cpu, ["x64"]);
120 assert.deepStrictEqual(linuxManifest.files, [
121 "t3",
122 "t3.exe",
123 "client",
124 "resource-monitor",
125 "node_modules",
126 ]);
127 assert.equal(linuxManifest.preferUnplugged, true);
128 assert.isUndefined(linuxManifest.bin);
129 // Archive contents sit at the package root, not under the archive stem.
130 assert.isTrue(yield* fs.exists(path.join(linuxDir, "client/index.html")));
131 // A root README, or npm would display a bundled dependency's.
132 assert.include(
133 yield* fs.readFileString(path.join(linuxDir, "README.md")),
134 "# @t3code/t3-linux-x64",
135 );
136 assert.isTrue(yield* fs.exists(path.join(linuxDir, "node_modules/node-pty")));
137 assert.equal(Number((yield* fs.stat(path.join(linuxDir, "t3"))).mode) & 0o111, 0o111);
138
139 const darwinManifest = yield* decodeManifest(
140 yield* fs.readFileString(
141 path.join(fixture.outputDir, "@t3code/t3-darwin-arm64/package.json"),
142 ),
143 );
144 assert.deepStrictEqual(darwinManifest.os, ["darwin"]);
145 assert.deepStrictEqual(darwinManifest.cpu, ["arm64"]);
146
147 const launcherDir = path.join(fixture.outputDir, "t3");
148 const launcherManifest = yield* decodeManifest(
149 yield* fs.readFileString(path.join(launcherDir, "package.json")),
150 );
151 assert.equal(launcherManifest.name, "t3");
152 assert.equal(launcherManifest.version, VERSION);
153 assert.deepStrictEqual(launcherManifest.bin, { t3: "./bin/t3.js" });
154 assert.deepStrictEqual(launcherManifest.files, ["bin"]);
155 assert.deepStrictEqual(launcherManifest.optionalDependencies, {
156 "@t3code/t3-darwin-arm64": VERSION,
157 "@t3code/t3-linux-x64": VERSION,
158 });
159 assert.isUndefined(launcherManifest.engines);
160 assert.isTrue(yield* fs.exists(path.join(launcherDir, "bin/t3.js")));
161
162 // The scratch dirs must not be left behind next to the packages.
163 const outputEntries = yield* fs.readDirectory(fixture.outputDir);
164 assert.deepStrictEqual(outputEntries.sort(), ["@t3code", "t3", "t3.tgz"]);
165
166 // The tarball is what gets published: it must carry node_modules (which
167 // `npm publish <dir>` would strip) under npm's `package/` root, with the
168 // executable bit intact.
169 const listing = yield* run(
170 "tar",
171 ["-tzvf", path.join(fixture.outputDir, "@t3code/t3-linux-x64.tgz")],
172 { cwd: fixture.outputDir },
173 );
174 assert.equal(listing.exitCode, 0, listing.stderr);
175 const lines = listing.stdout.split("\n");
176 assert.isTrue(lines.some((line) => line.endsWith(" package/node_modules/node-pty/")));
177 assert.isTrue(lines.some((line) => line.endsWith(" package/package.json")));
178 assert.isTrue(
179 lines.some((line) => /^-rwxr-xr-x .* package\/t3$/.test(line)),
180 listing.stdout,
181 );
182
183 // NODE_PATH stands in for node_modules: require.resolve finds the
184 // platform package there exactly as it would after `npm install`.
185 const env = { ...process.env, NODE_PATH: fixture.outputDir } as Record<string, string>;
186 const passthrough = yield* run(process.execPath, ["bin/t3.js", "serve", "--port", "1234"], {
187 cwd: launcherDir,
188 env,
189 });
190 assert.equal(passthrough.stdout.trim(), "stub linux-x64 serve --port 1234");
191 assert.equal(passthrough.exitCode, 7);
192
193 const unsupported = yield* run(process.execPath, ["bin/t3.js", "--version"], {
194 cwd: launcherDir,
195 env: { ...env, NODE_PATH: path.join(fixture.root, "nowhere") },
196 });
197 assert.equal(unsupported.exitCode, 1);
198 assert.include(unsupported.stderr, "linux-x64");
199 assert.include(unsupported.stderr, "win32-arm64");
200 assert.include(unsupported.stderr, "https://github.com/pingdotgg/t3code/releases");
201 }),
202 );
203});
scripts/build-npm-platform-packages.ts created+443
...@@ -0,0 +1,443 @@
1#!/usr/bin/env node
2/**
3 * Turns the per-platform CLI archives of one release into the npm packages
4 * behind `npx t3` / `npm i -g t3`: one `@t3code/t3-<platformKey>` package per
5 * archive holding the archive's contents verbatim, plus the `t3` launcher
6 * that lists them as optionalDependencies and execs the one npm installed.
7 * The bytes a user gets from npm are therefore the release archive's, and
8 * running them needs neither a Node runtime, npm, nor a native build.
9 *
10 * Output layout under `--output-dir`:
11 *
12 * @t3code/t3-<platformKey>/ archive contents flattened + package.json
13 * @t3code/t3-<platformKey>.tgz the same tree as an npm tarball
14 * t3/ launcher: package.json, bin/t3.js, README.md
15 * t3.tgz the launcher as an npm tarball
16 *
17 * The tarballs are what gets published. `npm publish <dir>` always drops
18 * `node_modules/` (npm-packlist ignores it whatever `files` says, and
19 * bundleDependencies needs an arborist tree these flattened installs are
20 * not), whereas `npm publish <tarball>` uploads the bytes as given.
21 */
22import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
23import * as NodeServices from "@effect/platform-node/NodeServices";
24import * as Effect from "effect/Effect";
25import * as FileSystem from "effect/FileSystem";
26import * as Layer from "effect/Layer";
27import * as Logger from "effect/Logger";
28import * as Path from "effect/Path";
29import * as Schema from "effect/Schema";
30import { Command, Flag } from "effect/unstable/cli";
31import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
32
33import {
34 CLI_ARCHIVE_PLATFORM_KEYS,
35 cliArchiveFileName,
36 type CliArchivePlatformKey,
37} from "@t3tools/shared/cliRelease";
38import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
39import { fromJsonStringPretty } from "@t3tools/shared/schemaJson";
40import { isCommandAvailable } from "@t3tools/shared/shell";
41import serverPackageJson from "../apps/server/package.json" with { type: "json" };
42
43import { windowsSystemTar } from "./build-cli-archive.ts";
44
45export const NPM_PLATFORM_PACKAGE_SCOPE = "@t3code";
46export const NPM_LAUNCHER_PACKAGE_NAME = "t3";
47
48const encodePackageJson = Schema.encodeEffect(fromJsonStringPretty(Schema.Unknown));
49
50export class NpmPackagesCommandFailedError extends Schema.TaggedError<NpmPackagesCommandFailedError>()(
51 "NpmPackagesCommandFailedError",
52 { command: Schema.String, exitCode: Schema.Int },
53) {
54 override get message(): string {
55 return `${this.command} exited with code ${this.exitCode}.`;
56 }
57}
58
59export class NpmPackagesToolMissingError extends Schema.TaggedError<NpmPackagesToolMissingError>()(
60 "NpmPackagesToolMissingError",
61 { tool: Schema.String, purpose: Schema.String },
62) {
63 override get message(): string {
64 return `\`${this.tool}\` is not on PATH; it is needed to ${this.purpose}.`;
65 }
66}
67
68export class NpmPackagesArchivesMissingError extends Schema.TaggedError<NpmPackagesArchivesMissingError>()(
69 "NpmPackagesArchivesMissingError",
70 { archivesDir: Schema.String, missing: Schema.Array(Schema.String) },
71) {
72 override get message(): string {
73 return `${this.archivesDir} lacks archives for ${this.missing.join(", ")}. A launcher published without them would silently skip those platforms; pass --allow-missing for a deliberately partial build.`;
74 }
75}
76
77export class NpmPackagesArchiveLayoutError extends Schema.TaggedError<NpmPackagesArchiveLayoutError>()(
78 "NpmPackagesArchiveLayoutError",
79 { archive: Schema.String, detail: Schema.String },
80) {
81 override get message(): string {
82 return `${this.archive}: ${this.detail}`;
83 }
84}
85
86export function npmPlatformPackageName(platformKey: CliArchivePlatformKey): string {
87 return `${NPM_PLATFORM_PACKAGE_SCOPE}/t3-${platformKey}`;
88}
89
90/** package.json for one platform package; `os`/`cpu` let npm skip the other five. */
91export function npmPlatformPackageManifest(platformKey: CliArchivePlatformKey, version: string) {
92 const [os, cpu] = platformKey.split("-") as [string, string];
93 return {
94 name: npmPlatformPackageName(platformKey),
95 version,
96 description: `T3 Code CLI executable for ${platformKey}`,
97 license: serverPackageJson.license,
98 repository: serverPackageJson.repository,
99 os: [os],
100 cpu: [cpu],
101 files: ["t3", "t3.exe", "client", "resource-monitor", "node_modules"],
102 preferUnplugged: true,
103 };
104}
105
106/**
107 * README for one platform package. Without one at the package root, npm
108 * shows the first README it finds in the tarball, which is a bundled
109 * dependency's (ffi-rs).
110 */
111export function npmPlatformPackageReadme(platformKey: CliArchivePlatformKey): string {
112 return [
113 `# ${npmPlatformPackageName(platformKey)}`,
114 "",
115 `The T3 Code CLI executable for ${platformKey}. Do not install this package directly:`,
116 `it is an optional dependency of \`${NPM_LAUNCHER_PACKAGE_NAME}\`, which picks the package for the`,
117 "current platform and runs the executable inside it.",
118 "",
119 "```sh",
120 `npx ${NPM_LAUNCHER_PACKAGE_NAME}@latest`,
121 "```",
122 "",
123 "Source and documentation: https://github.com/pingdotgg/t3code",
124 "",
125 ].join("\n");
126}
127
128/** package.json for the `t3` launcher. No engines: bin/t3.js is trivial CJS. */
129export function npmLauncherPackageManifest(
130 version: string,
131 platformKeys: ReadonlyArray<CliArchivePlatformKey>,
132) {
133 return {
134 name: NPM_LAUNCHER_PACKAGE_NAME,
135 version,
136 description: "T3 Code CLI. Installs the self-contained executable for this platform.",
137 license: serverPackageJson.license,
138 repository: serverPackageJson.repository,
139 bin: { t3: "./bin/t3.js" },
140 files: ["bin"],
141 optionalDependencies: Object.fromEntries(
142 platformKeys.map((key) => [npmPlatformPackageName(key), version]),
143 ),
144 };
145}
146
147/**
148 * The launcher every `npx t3` runs. Plain CommonJS with no dependencies so it
149 * loads on any Node that npm itself runs on; the real work happens in the
150 * single-executable it execs.
151 */
152export const NPM_LAUNCHER_SCRIPT = `#!/usr/bin/env node
153"use strict";
154const { spawnSync } = require("node:child_process");
155const { constants } = require("node:os");
156const { dirname, join } = require("node:path");
157
158const SUPPORTED = [${CLI_ARCHIVE_PLATFORM_KEYS.map((key) => `"${key}"`).join(", ")}];
159const key = process.platform + "-" + process.arch;
160
161let packageDir;
162try {
163 packageDir = dirname(require.resolve("${NPM_PLATFORM_PACKAGE_SCOPE}/t3-" + key + "/package.json"));
164} catch {
165 process.stderr.write(
166 [
167 "t3: no T3 Code CLI build is available for this platform (" + key + ").",
168 "Supported platforms: " + SUPPORTED.join(", ") + ".",
169 "If yours is listed, reinstall t3 so npm fetches its optional dependency.",
170 "The desktop app and release archives are at https://github.com/pingdotgg/t3code/releases",
171 "",
172 ].join("\\n"),
173 );
174 process.exit(1);
175}
176
177const executable = join(packageDir, process.platform === "win32" ? "t3.exe" : "t3");
178const result = spawnSync(executable, process.argv.slice(2), { stdio: "inherit" });
179if (result.error) {
180 process.stderr.write("t3: failed to start " + executable + ": " + result.error.message + "\\n");
181 process.exit(1);
182}
183// A child killed by a signal has no status; report it the way a shell would.
184process.exit(result.status ?? 128 + (constants.signals[result.signal] || 1));
185`;
186
187const runCommand = Effect.fn("runCommand")(function* (
188 command: ChildProcess.StandardCommand,
189 label: string,
190) {
191 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
192 const child = yield* spawner.spawn(
193 ChildProcess.make(command.command, command.args, {
194 ...command.options,
195 stdout: "inherit",
196 stderr: "inherit",
197 }),
198 );
199 const exitCode = Number(yield* child.exitCode);
200 if (exitCode !== 0) {
201 return yield* new NpmPackagesCommandFailedError({ command: label, exitCode });
202 }
203});
204
205/**
206 * Extracts an archive and returns its single top-level directory. `.tar.gz`
207 * goes through tar everywhere; `.zip` through the bsdtar Windows ships or,
208 * elsewhere, `unzip`, since GNU tar cannot read zip.
209 */
210const extractArchive = Effect.fn("extractArchive")(function* (archive: string, into: string) {
211 const fs = yield* FileSystem.FileSystem;
212 const path = yield* Path.Path;
213 const platform = yield* HostProcessPlatform;
214 if (!archive.endsWith(".zip")) {
215 yield* runCommand(ChildProcess.make("tar", ["-xf", archive, "-C", into]), "tar -xf");
216 } else if (platform === "win32") {
217 yield* runCommand(
218 ChildProcess.make(windowsSystemTar(), ["-xf", archive, "-C", into]),
219 "tar.exe -xf (zip)",
220 );
221 } else {
222 if (!(yield* isCommandAvailable("unzip"))) {
223 return yield* new NpmPackagesToolMissingError({
224 tool: "unzip",
225 purpose: `extract ${path.basename(archive)} (GNU tar cannot read zip)`,
226 });
227 }
228 yield* runCommand(ChildProcess.make("unzip", ["-q", archive, "-d", into]), "unzip");
229 }
230 const entries = yield* fs.readDirectory(into);
231 const [root] = entries;
232 if (root === undefined || entries.length !== 1) {
233 return yield* new NpmPackagesArchiveLayoutError({
234 archive: path.basename(archive),
235 detail: `expected exactly one top-level directory, found ${String(entries.length)} entries`,
236 });
237 }
238 return path.join(into, root);
239});
240
241/** Tar to build npm tarballs with; see build-cli-archive.ts for why Windows names bsdtar by path. */
242const hostTar = Effect.map(HostProcessPlatform, (platform) =>
243 platform === "win32" ? windowsSystemTar() : "tar",
244);
245
246/**
247 * Writes `stageDir/package` as a gzipped npm tarball and then moves the tree
248 * to `packageDir` so the contents stay inspectable beside the tarball.
249 */
250const packAndPlace = Effect.fn("packAndPlace")(function* (input: {
251 readonly stageDir: string;
252 readonly packageDir: string;
253 readonly tarball: string;
254}) {
255 const fs = yield* FileSystem.FileSystem;
256 yield* fs.remove(input.tarball, { force: true });
257 yield* runCommand(
258 ChildProcess.make(yield* hostTar, ["-czf", input.tarball, "-C", input.stageDir, "package"]),
259 `tar (${input.tarball})`,
260 );
261 yield* fs.remove(input.packageDir, { recursive: true, force: true });
262 yield* fs.rename(`${input.stageDir}/package`, input.packageDir);
263});
264
265export interface NpmPackageOutput {
266 readonly name: string;
267 readonly packageDir: string;
268 readonly tarball: string;
269}
270
271/**
272 * Extracts one archive, adds its package.json, and emits the package dir and
273 * tarball. The scratch dir lives inside the output dir so the extracted tree
274 * is renamed into place rather than copied across filesystems.
275 */
276const stagePlatformPackage = Effect.fn("stagePlatformPackage")(function* (input: {
277 readonly key: CliArchivePlatformKey;
278 readonly archive: string;
279 readonly outputDir: string;
280 readonly version: string;
281}) {
282 const fs = yield* FileSystem.FileSystem;
283 const path = yield* Path.Path;
284 yield* Effect.log(`[npm-packages] Extracting ${path.basename(input.archive)}...`);
285 const scratch = yield* fs.makeTempDirectoryScoped({
286 directory: input.outputDir,
287 prefix: ".extract-",
288 });
289 const extractDir = path.join(scratch, "extract");
290 yield* fs.makeDirectory(extractDir);
291 const contentDir = yield* extractArchive(input.archive, extractDir);
292 const executableName = input.key.startsWith("win32") ? "t3.exe" : "t3";
293 const executable = path.join(contentDir, executableName);
294 if (!(yield* fs.exists(executable))) {
295 return yield* new NpmPackagesArchiveLayoutError({
296 archive: path.basename(input.archive),
297 detail: `missing ${executableName} at the archive root`,
298 });
299 }
300 // The tarball carries the on-disk mode, so the bit must be set before packing.
301 if (executableName === "t3") {
302 yield* fs.chmod(executable, 0o755);
303 }
304 yield* fs.writeFileString(
305 path.join(contentDir, "package.json"),
306 `${yield* encodePackageJson(npmPlatformPackageManifest(input.key, input.version))}\n`,
307 );
308 yield* fs.writeFileString(
309 path.join(contentDir, "README.md"),
310 npmPlatformPackageReadme(input.key),
311 );
312 // npm tarballs root everything under `package/`.
313 yield* fs.rename(contentDir, path.join(scratch, "package"));
314 const name = npmPlatformPackageName(input.key);
315 const output: NpmPackageOutput = {
316 name,
317 packageDir: path.join(input.outputDir, name),
318 tarball: path.join(input.outputDir, `${name}.tgz`),
319 };
320 yield* packAndPlace({ stageDir: scratch, ...output });
321 return output;
322}, Effect.scoped);
323
324/** Writes the launcher package (package.json, bin/t3.js, README) and its tarball. */
325const stageLauncherPackage = Effect.fn("stageLauncherPackage")(function* (input: {
326 readonly outputDir: string;
327 readonly version: string;
328 readonly platformKeys: ReadonlyArray<CliArchivePlatformKey>;
329}) {
330 const fs = yield* FileSystem.FileSystem;
331 const path = yield* Path.Path;
332 const scratch = yield* fs.makeTempDirectoryScoped({
333 directory: input.outputDir,
334 prefix: ".launcher-",
335 });
336 const stageDir = path.join(scratch, "package");
337 yield* fs.makeDirectory(path.join(stageDir, "bin"), { recursive: true });
338 yield* fs.writeFileString(
339 path.join(stageDir, "package.json"),
340 `${yield* encodePackageJson(npmLauncherPackageManifest(input.version, input.platformKeys))}\n`,
341 );
342 const launcherScript = path.join(stageDir, "bin/t3.js");
343 yield* fs.writeFileString(launcherScript, NPM_LAUNCHER_SCRIPT);
344 yield* fs.chmod(launcherScript, 0o755);
345 const readme = yield* path.fromFileUrl(new URL("../apps/server/README.md", import.meta.url));
346 if (yield* fs.exists(readme)) {
347 yield* fs.copyFile(readme, path.join(stageDir, "README.md"));
348 }
349 const output: NpmPackageOutput = {
350 name: NPM_LAUNCHER_PACKAGE_NAME,
351 packageDir: path.join(input.outputDir, NPM_LAUNCHER_PACKAGE_NAME),
352 tarball: path.join(input.outputDir, `${NPM_LAUNCHER_PACKAGE_NAME}.tgz`),
353 };
354 yield* packAndPlace({ stageDir: scratch, ...output });
355 return output;
356}, Effect.scoped);
357
358export const buildNpmPlatformPackages = Effect.fn("buildNpmPlatformPackages")(function* (input: {
359 readonly archivesDir: string;
360 readonly version: string;
361 readonly outputDir: string;
362 readonly allowMissing: boolean;
363}) {
364 const fs = yield* FileSystem.FileSystem;
365 const path = yield* Path.Path;
366
367 const present = yield* fs.readDirectory(input.archivesDir);
368 const archives = CLI_ARCHIVE_PLATFORM_KEYS.flatMap((key) => {
369 const fileName = cliArchiveFileName(input.version, key);
370 return present.includes(fileName)
371 ? [{ key, archive: path.join(input.archivesDir, fileName) }]
372 : [];
373 });
374 const missing = CLI_ARCHIVE_PLATFORM_KEYS.filter(
375 (key) => !archives.some((entry) => entry.key === key),
376 );
377 if (missing.length > 0 && (!input.allowMissing || archives.length === 0)) {
378 return yield* new NpmPackagesArchivesMissingError({ archivesDir: input.archivesDir, missing });
379 }
380
381 yield* fs.makeDirectory(path.join(input.outputDir, NPM_PLATFORM_PACKAGE_SCOPE), {
382 recursive: true,
383 });
384 const outputs: Array<NpmPackageOutput> = [];
385 for (const { key, archive } of archives) {
386 outputs.push(
387 yield* stagePlatformPackage({
388 key,
389 archive,
390 outputDir: input.outputDir,
391 version: input.version,
392 }),
393 );
394 }
395 outputs.push(
396 yield* stageLauncherPackage({
397 outputDir: input.outputDir,
398 version: input.version,
399 platformKeys: archives.map((entry) => entry.key),
400 }),
401 );
402
403 for (const output of outputs) {
404 yield* Effect.log(`[npm-packages] Wrote ${output.packageDir} and ${output.tarball}`);
405 }
406 if (missing.length > 0) {
407 yield* Effect.logWarning(
408 `[npm-packages] Launcher omits ${missing.join(", ")} (--allow-missing).`,
409 );
410 }
411 return outputs;
412});
413
414const command = Command.make(
415 "build-npm-platform-packages",
416 {
417 archivesDir: Flag.string("archives-dir").pipe(
418 Flag.withDescription("Directory holding the release's t3-<version>-<platform> archives."),
419 ),
420 version: Flag.string("version").pipe(
421 Flag.withDescription(
422 "Exact release version; selects the archives and versions the packages.",
423 ),
424 ),
425 outputDir: Flag.string("output-dir").pipe(Flag.withDefault("npm-packages")),
426 allowMissing: Flag.boolean("allow-missing").pipe(
427 Flag.withDefault(false),
428 Flag.withDescription("Build a launcher that lists only the platforms present."),
429 ),
430 },
431 buildNpmPlatformPackages,
432).pipe(
433 Command.withDescription(
434 "Build the t3 launcher and @t3code/t3-<platform> npm packages from CLI release archives.",
435 ),
436);
437
438if (import.meta.main) {
439 Command.run(command, { version: "0.0.0" }).pipe(
440 Effect.provide(Layer.mergeAll(Logger.layer([Logger.consolePretty()]), NodeServices.layer)),
441 NodeRuntime.runMain,
442 );
443}