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:
150150 # Manual-only test train: exercises the whole release flow for a
151151 # commit end users must never receive. Never scheduled.
152152 # Same versioning as nightly under its own prerelease identifier.
153 # A preview release is reachable only by downloading it by hand:
154 # it is never published to npm, its desktop builds carry no update
155 # feed, and no updater manifest is attached to the release, so
156 # neither stable nor nightly installs can ever be offered one.
153 # A preview release is reachable only by asking for it: npm gets it
154 # under the `preview` dist-tag, which nothing resolves by default,
155 # its desktop builds carry no update feed, and no updater manifest
156 # is attached to the release, so neither stable nor nightly
157 # installs can ever be offered one.
157158 nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)"
158159
159160 node scripts/resolve-nightly-release.ts \
......@@ -164,7 +165,7 @@ jobs:
164165 --github-output
165166
166167 echo "release_channel=preview" >> "$GITHUB_OUTPUT"
167 echo "cli_dist_tag=" >> "$GITHUB_OUTPUT"
168 echo "cli_dist_tag=preview" >> "$GITHUB_OUTPUT"
168169 echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
169170 echo "make_latest=false" >> "$GITHUB_OUTPUT"
170171 else
......@@ -591,22 +592,28 @@ jobs:
591592 resource_key: win32-arm64
592593 cli_archive: true
593594
594 # Preview releases never reach npm: the archive on the GitHub Release is the
595 # only way to obtain one, so no dist-tag can ever resolve to a preview build.
595 # npm gets the same bytes as the GitHub Release: the launcher plus one
596 # package per CLI archive. Preview publishes too, under the `preview`
597 # dist-tag, which nothing resolves unless asked for by name.
596598 publish_cli:
597599 name: Publish CLI to npm
598 needs: [preflight, relay_public_config, quality, build_bundle]
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' }}
600 needs:
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' }}
600612 runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404
601 timeout-minutes: 10
613 timeout-minutes: 15
602614 permissions:
603615 contents: read
604616 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 }}
610617 steps:
611618 - name: Checkout
612619 uses: actions/checkout@v6
......@@ -627,38 +634,28 @@ jobs:
627634 - --filter=t3...
628635 - --filter=@t3tools/scripts...
629636
630 - name: Align package versions to release version
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 - name: Download all CLI archives
637638 uses: actions/download-artifact@v8
638639 with:
639 name: js-bundle
640 path: apps
640 pattern: cli-*
641 merge-multiple: true
642 path: release-cli
641643
642 - name: Download resource monitors
643 uses: actions/download-artifact@v8
644 with:
645 pattern: resource-monitor-*
646 path: ${{ runner.temp }}/resource-monitors
644 - name: Build npm packages from CLI archives
645 run: node scripts/build-npm-platform-packages.ts --archives-dir release-cli --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages
647646
648 - name: Bundle resource monitors into CLI package
649 shell: bash
647 # A dry run of every package first: an auth or scope error here (the
648 # @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)
650651 run: |
651 set -euo pipefail
652 for artifact_dir in "$RUNNER_TEMP"/resource-monitors/resource-monitor-*; do
653 resource_key="${artifact_dir##*/resource-monitor-}"
654 target_dir="apps/server/dist/resource-monitor/${resource_key}"
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
652 if ! node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --dry-run --verbose; then
653 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
654 exit 1
655 fi
659656
660 - name: Publish CLI package
661 run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose
657 - name: Publish CLI packages
658 run: node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --verbose
662659
663660 release:
664661 name: Publish GitHub Release
......@@ -673,7 +670,7 @@ jobs:
673670 desktop_win_arm64,
674671 publish_cli,
675672 ]
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' }}
677674 runs-on: blacksmith-8vcpu-ubuntu-2404
678675 timeout-minutes: 30
679676 permissions:
apps/server/scripts/cli.ts+49-178
......@@ -6,69 +6,24 @@ import * as FileSystem from "effect/FileSystem";
66import * as Logger from "effect/Logger";
77import * as Option from "effect/Option";
88import * as Path from "effect/Path";
9import * as Schema from "effect/Schema";
109import { Command, Flag } from "effect/unstable/cli";
1110import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1211
13import {
14 DEVELOPMENT_ICON_OVERRIDES,
15 resolveWebAssetBrandForPackageVersion,
16 resolveWebIconOverrides,
17} from "../../../scripts/lib/brand-assets.ts";
12import { DEVELOPMENT_ICON_OVERRIDES } from "../../../scripts/lib/brand-assets.ts";
1813import { 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";
2214import { resolveSpawnCommand } from "@t3tools/shared/shell";
23import serverPackageJson from "../package.json" with { type: "json" };
2415import {
2516 ServerCliBuildAssetMissingError,
2617 ServerCliCommandExitError,
2718 ServerCliDevelopmentIconSourceMissingError,
2819 ServerCliDevelopmentIconTargetMissingError,
2920 ServerCliExecutableImportError,
30 ServerCliPublishIconSourceMissingError,
31 ServerCliPublishIconTargetMissingError,
3221} 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
6023const RepoRoot = Effect.service(Path.Path).pipe(
6124 Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))),
6225);
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
7227const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.StandardCommand) {
7328 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
7429 const child = yield* spawner.spawn(command);
......@@ -84,36 +39,6 @@ const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.Stan
8439 }
8540});
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
11742const applyDevelopmentIconOverrides = Effect.fn("applyDevelopmentIconOverrides")(function* (
11843 repoRoot: string,
11944 serverDir: string,
......@@ -240,37 +165,21 @@ const buildExeCmd = Command.make(
240165// publish subcommand
241166// ---------------------------------------------------------------------------
242167
243interface PublishCommandConfig {
244 readonly access: string;
245 readonly tag: string;
246 readonly provenance: boolean;
247 readonly dryRun: boolean;
248}
249
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
168/**
169 * Publishes the tarballs scripts/build-npm-platform-packages.ts produced:
170 * every `@t3code/t3-<platform>.tgz` first, `t3.tgz` (the launcher) last, so
171 * the launcher is never installable before the executables it depends on.
172 * Tarballs rather than directories because `npm publish <dir>` strips the
173 * `node_modules/` the executable loads its native addons from.
174 */
268175const publishCmd = Command.make(
269176 "publish",
270177 {
178 packagesDir: Flag.string("packages-dir").pipe(
179 Flag.withDescription("Output dir of scripts/build-npm-platform-packages.ts."),
180 ),
271181 tag: Flag.string("tag").pipe(Flag.withDefault("latest")),
272182 access: Flag.string("access").pipe(Flag.withDefault("public")),
273 appVersion: Flag.string("app-version").pipe(Flag.optional),
274183 provenance: Flag.boolean("provenance").pipe(Flag.withDefault(false)),
275184 dryRun: Flag.boolean("dry-run").pipe(Flag.withDefault(false)),
276185 verbose: Flag.boolean("verbose").pipe(Flag.withDefault(false)),
......@@ -279,86 +188,48 @@ const publishCmd = Command.make(
279188 Effect.gen(function* () {
280189 const path = yield* Path.Path;
281190 const fs = yield* FileSystem.FileSystem;
282 const repoRoot = yield* RepoRoot;
283 const serverDir = path.join(repoRoot, "apps/server");
284 const packageJsonPath = path.join(serverDir, "package.json");
285
286 // Assert build assets exist
287 for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) {
288 const abs = path.join(serverDir, relPath);
289 if (!(yield* fs.exists(abs))) {
290 return yield* new ServerCliBuildAssetMissingError({ assetPath: abs });
291 }
191 // npm runs with cwd set to the packages dir below, so tarball paths are
192 // resolved once here rather than joined twice.
193 const packagesDir = path.resolve(config.packagesDir);
194 const scopeDir = path.join(packagesDir, "@t3code");
195 const launcherTarball = path.join(packagesDir, "t3.tgz");
196 const platformTarballs = (yield* fs
197 .readDirectory(scopeDir)
198 .pipe(Effect.orElseSucceed((): ReadonlyArray<string> => [])))
199 .filter((entry) => entry.startsWith("t3-") && entry.endsWith(".tgz"))
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 });
292209 }
293210
294 yield* Effect.acquireUseRelease(
295 // Acquire: resolve publish metadata and read every original before mutation.
296 Effect.gen(function* () {
297 const version = Option.getOrElse(config.appVersion, () => serverPackageJson.version);
298 const workspaceConfig = yield* readWorkspaceConfig();
299 const workspaceCatalog = workspaceConfig.catalog ?? {};
300 const workspaceOverrides = workspaceConfig.overrides ?? {};
301 const pkg: PackageJson = {
302 name: serverPackageJson.name,
303 repository: serverPackageJson.repository,
304 bin: serverPackageJson.bin,
305 type: serverPackageJson.type,
306 version,
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 );
211 const args = ["publish", "--access", config.access, "--tag", config.tag];
212 if (config.provenance) args.push("--provenance");
213 if (config.dryRun) args.push("--dry-run");
214
215 for (const tarball of [...platformTarballs, launcherTarball]) {
216 const spawnCommand = yield* resolveSpawnCommand("npm", [...args, tarball]);
217 yield* Effect.log(`[cli] npm ${args.join(" ")} ${path.basename(tarball)}`);
218 yield* runCommand(
219 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
220 cwd: packagesDir,
221 stdout: config.verbose ? "inherit" : "ignore",
222 stderr: "inherit",
223 shell: spawnCommand.shell,
349224 }),
350 // Release: restore every file even if applying overrides or publishing fails.
351 (resource) =>
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 );
225 );
226 }
360227 }),
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
363234// ---------------------------------------------------------------------------
364235// root command
apps/server/scripts/cliErrors.ts-22
......@@ -14,28 +14,6 @@ export class ServerCliCommandExitError extends Schema.TaggedError<ServerCliComma
1414 }
1515}
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
3917export class ServerCliDevelopmentIconSourceMissingError extends Schema.TaggedError<ServerCliDevelopmentIconSourceMissingError>()(
4018 "ServerCliDevelopmentIconSourceMissingError",
4119 {
docs/operations/release.md+26-16
......@@ -12,7 +12,7 @@ This document covers the unified release workflow for stable and nightly desktop
1212 - push tag matching `v*.*.*` for a stable release of an explicit commit
1313 - scheduled nightly check every 30 minutes
1414 - 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.
1616- A manual stable release builds the commit of the latest published nightly, not `main` HEAD.
1717 Nightly is the release candidate: verify the nightly, then promote it. Merges to `main` keep
1818 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
3636 - 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.
3737- Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets.
3838- 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`.
4040 - 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`.
4141 - 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.
4242 - 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.
4444 - stable releases publish npm dist-tag `latest`
4545 - nightly releases publish npm dist-tag `nightly`
46 - preview releases are not published to npm
46 - 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).
4748- Deploys the hosted web app to Vercel only after a release is published:
4849 - stable releases are aliased to the `latest` hosted app channel
4950 - nightly releases are aliased to the `nightly` hosted app channel
......@@ -198,7 +199,7 @@ One-time Vercel dashboard setup:
198199 - `make_latest` is always `false`
199200- Uses the next stable patch version as the nightly base. For example, `0.0.17` produces nightlies on `0.0.18-nightly.*`.
200201- 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.
202203- Does not commit version bumps back to `main`.
203204
204205## Server self-update release invariant
......@@ -209,7 +210,7 @@ npm before users can receive that client.
209210
210211The 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.
2132142. `release` depends on `publish_cli` before exposing desktop artifacts in GitHub Releases.
2142153. `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.
294295
295296## 0) npm OIDC trusted publishing setup (CLI)
296297
297The workflow invokes `node apps/server/scripts/cli.ts publish` after aligning package versions. That
298script temporarily prepares the `t3` package, then runs `vp pm publish --filter t3 ...` from the
299repository root so workspace publish configuration is applied correctly.
298The workflow runs `node scripts/build-npm-platform-packages.ts` on the downloaded CLI archives, then
299`node apps/server/scripts/cli.ts publish --packages-dir npm-packages`, which runs `npm publish` on
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
301307Checklist:
302308
3031. Confirm npm org/user owns package `t3` (or rename package first if needed).
3042. In npm package settings, configure Trusted Publisher:
3091. Confirm the npm org owns package `t3` and the `@t3code` scope exists on npm (create the org if
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):
305315 - Provider: GitHub Actions
306316 - Repository: this repo
307317 - Workflow file: `.github/workflows/release.yml`
308318 - 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.
3103204. Create release tag `vX.Y.Z` and push; workflow will:
311 - align the release package versions to `X.Y.Z`
312 - build web + server
313 - invoke the CLI publish script with npm dist-tag `latest`
3145. Nightly runs invoke the same publish script with npm dist-tag `nightly`.
321 - build and smoke-test the five CLI archives
322 - build the npm packages from those archives
323 - publish them with npm dist-tag `latest`
3245. Nightly runs publish with npm dist-tag `nightly`; preview runs with `preview`.
315325
316326## 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
55
66## Requirements
77
8Command-line use, SSH hosts, and WSL backends need Node.js 22.16+ (22.x), 23.11+
9(23.x), or 24.10 and later. The native desktop app includes its server runtime.
8`npx t3` needs Node.js only to run npm itself; the CLI it installs is a
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
1113You need an installed, authenticated provider before starting a thread. You can
1214launch T3 Code and configure providers afterwards.
......@@ -20,6 +22,22 @@ npx t3@latest
2022This starts the server and opens the local web app. Run
2123`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
2341## Desktop app
2442
2543Download 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";
1919 */
2020// No darwin-x64: Node single-executables are unsupported on x64 macOS (the
2121// SEA docs list macOS as arm64 only) and the binary segfaults on start.
22const CLI_ARCHIVE_PLATFORM_KEYS = [
22export const CLI_ARCHIVE_PLATFORM_KEYS = [
2323 "darwin-arm64",
2424 "linux-arm64",
2525 "linux-x64",
scripts/build-cli-archive.ts+39-1
......@@ -23,6 +23,7 @@ import * as Layer from "effect/Layer";
2323import * as Logger from "effect/Logger";
2424import * as Option from "effect/Option";
2525import * as Path from "effect/Path";
26import * as PlatformError from "effect/PlatformError";
2627import * as Schema from "effect/Schema";
2728import { Command, Flag } from "effect/unstable/cli";
2829import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
......@@ -241,8 +242,33 @@ const stageRuntimeExternals = Effect.fn("stageRuntimeExternals")(function* (inpu
241242 ]) {
242243 yield* fs.remove(path.join(input.stageDir, entry), { recursive: true, force: true });
243244 }
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"));
244250});
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
246272/** Copies the web client without its sourcemaps, which nothing serves. */
247273const stageWebClient = Effect.fn("stageWebClient")(function* (source: string, target: string) {
248274 const fs = yield* FileSystem.FileSystem;
......@@ -516,8 +542,20 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: {
516542 "tar (zip)",
517543 );
518544 } 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.
519550 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 ]),
521559 "tar (gzip)",
522560 );
523561 }
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}