From 14632b3255215b93ded3d475b0ff8c5b23838785 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:25:22 -0700 Subject: [PATCH 1/7] feat(release): publish npx t3 as a launcher over per-platform executable packages The t3 npm package was still the JS bundle that needed Node and a native build on the user's machine. It is now a thin launcher whose optionalDependencies are @t3tools/t3-- packages built from the release archives, so npx t3 resolves to the same executable the desktop app, the archives, and the install scripts use. scripts/build-npm-platform-packages.ts turns each archive into a platform package and writes the launcher; both are packed as tarballs because npm publish silently strips node_modules from the payload and the executable dlopens its natives from there. The publish command uploads those tarballs, platforms first and the launcher last, so the launcher is never live before what it depends on. The workflow's npm job now fans in after every archive producer and runs on every channel; preview publishes under the preview dist-tag, which nothing resolves unless asked for. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 81 ++-- apps/server/scripts/cli.ts | 224 +++-------- apps/server/scripts/cliErrors.ts | 22 -- docs/operations/release.md | 42 +- docs/user/install.md | 6 +- packages/shared/src/cliRelease.ts | 2 +- scripts/build-npm-platform-packages.test.ts | 198 ++++++++++ scripts/build-npm-platform-packages.ts | 417 ++++++++++++++++++++ 8 files changed, 731 insertions(+), 261 deletions(-) create mode 100644 scripts/build-npm-platform-packages.test.ts create mode 100644 scripts/build-npm-platform-packages.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aff55e16c6d2..5451e917ca48 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -150,10 +150,11 @@ jobs: # Manual-only test train: exercises the whole release flow for a # commit end users must never receive. Never scheduled. # Same versioning as nightly under its own prerelease identifier. - # A preview release is reachable only by downloading it by hand: - # it is never published to npm, its desktop builds carry no update - # feed, and no updater manifest is attached to the release, so - # neither stable nor nightly installs can ever be offered one. + # A preview release is reachable only by asking for it: npm gets it + # under the `preview` dist-tag, which nothing resolves by default, + # its desktop builds carry no update feed, and no updater manifest + # is attached to the release, so neither stable nor nightly + # installs can ever be offered one. nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" node scripts/resolve-nightly-release.ts \ @@ -164,7 +165,7 @@ jobs: --github-output echo "release_channel=preview" >> "$GITHUB_OUTPUT" - echo "cli_dist_tag=" >> "$GITHUB_OUTPUT" + echo "cli_dist_tag=preview" >> "$GITHUB_OUTPUT" echo "is_prerelease=true" >> "$GITHUB_OUTPUT" echo "make_latest=false" >> "$GITHUB_OUTPUT" else @@ -591,22 +592,28 @@ jobs: resource_key: win32-arm64 cli_archive: true - # Preview releases never reach npm: the archive on the GitHub Release is the - # only way to obtain one, so no dist-tag can ever resolve to a preview build. + # npm gets the same bytes as the GitHub Release: the launcher plus one + # package per CLI archive. Preview publishes too, under the `preview` + # dist-tag, which nothing resolves unless asked for by name. publish_cli: name: Publish CLI to npm - needs: [preflight, relay_public_config, quality, build_bundle] - 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' }} + needs: + [ + preflight, + relay_public_config, + quality, + desktop_mac_arm64, + desktop_linux_x64, + desktop_linux_arm64, + desktop_win_x64, + desktop_win_arm64, + ] + 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' }} runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 + timeout-minutes: 15 permissions: contents: read id-token: write - env: - T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} - T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} - T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} - T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} steps: - name: Checkout uses: actions/checkout@v6 @@ -627,38 +634,28 @@ jobs: - --filter=t3... - --filter=@t3tools/scripts... - - name: Align package versions to release version - run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - - # The artifact root is `apps/` (upload-artifact keeps the least common - # ancestor of its paths), so extracting into `apps` restores - # apps/server/dist and apps/desktop/dist-electron at their build paths. - - name: Download JS bundle + - name: Download all CLI archives uses: actions/download-artifact@v8 with: - name: js-bundle - path: apps + pattern: cli-* + merge-multiple: true + path: release-cli - - name: Download resource monitors - uses: actions/download-artifact@v8 - with: - pattern: resource-monitor-* - path: ${{ runner.temp }}/resource-monitors + - name: Build npm packages from CLI archives + run: node scripts/build-npm-platform-packages.ts --archives-dir release-cli --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages - - name: Bundle resource monitors into CLI package - shell: bash + # A dry run of every package first: an auth or scope error here (the + # @t3tools org missing, a package without a trusted publisher) fails + # before anything is live, instead of after some platforms already are. + - name: Check npm publish access (dry run) run: | - set -euo pipefail - for artifact_dir in "$RUNNER_TEMP"/resource-monitors/resource-monitor-*; do - resource_key="${artifact_dir##*/resource-monitor-}" - target_dir="apps/server/dist/resource-monitor/${resource_key}" - mkdir -p "$target_dir" - cp "$artifact_dir"/t3-resource-monitor* "$target_dir/" - chmod +x "$target_dir"/t3-resource-monitor 2>/dev/null || true - done + if ! node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --dry-run --verbose; then + echo "::error::npm publish --dry-run failed. Make sure the @t3tools npm org exists and that t3 and every @t3tools/t3- package has a trusted publisher registered for .github/workflows/release.yml (see docs/operations/release.md)." >&2 + exit 1 + fi - - name: Publish CLI package - run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose + - name: Publish CLI packages + run: node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --verbose release: name: Publish GitHub Release @@ -673,7 +670,7 @@ jobs: desktop_win_arm64, publish_cli, ] - 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')) }} + 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' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 30 permissions: diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 06c22738853e..eb13ba28afc2 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -6,69 +6,24 @@ import * as FileSystem from "effect/FileSystem"; import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; import { Command, Flag } from "effect/unstable/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { - DEVELOPMENT_ICON_OVERRIDES, - resolveWebAssetBrandForPackageVersion, - resolveWebIconOverrides, -} from "../../../scripts/lib/brand-assets.ts"; +import { DEVELOPMENT_ICON_OVERRIDES } from "../../../scripts/lib/brand-assets.ts"; import { findEsmImportsOfExternalPackages } from "../../../scripts/lib/cli-external-packages.ts"; -import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts"; -import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; -import { fromYaml } from "@t3tools/shared/schemaYaml"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import serverPackageJson from "../package.json" with { type: "json" }; import { ServerCliBuildAssetMissingError, ServerCliCommandExitError, ServerCliDevelopmentIconSourceMissingError, ServerCliDevelopmentIconTargetMissingError, ServerCliExecutableImportError, - ServerCliPublishIconSourceMissingError, - ServerCliPublishIconTargetMissingError, } from "./cliErrors.ts"; -interface PackageJson { - name: string; - repository: { - type: string; - url: string; - directory: string; - }; - bin: Record; - type: string; - version: string; - engines: Record; - files: string[]; - dependencies: Record; - overrides: Record; -} - -const PackageJsonPrettyJson = fromJsonStringPretty(Schema.Unknown); -const encodePackageJson = Schema.encodeEffect(PackageJsonPrettyJson); - -const WorkspaceConfig = Schema.Struct({ - catalog: Schema.optional(Schema.Record(Schema.String, Schema.String)), - overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)), -}); -type WorkspaceConfig = typeof WorkspaceConfig.Type; -const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig)); - const RepoRoot = Effect.service(Path.Path).pipe( Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))), ); -const readWorkspaceConfig = Effect.fn("readWorkspaceConfig")(function* () { - const path = yield* Path.Path; - const fs = yield* FileSystem.FileSystem; - const repoRoot = yield* RepoRoot; - const workspaceYaml = yield* fs.readFileString(path.join(repoRoot, "pnpm-workspace.yaml")); - return yield* decodeWorkspaceConfig(workspaceYaml); -}); - const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.StandardCommand) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const child = yield* spawner.spawn(command); @@ -84,36 +39,6 @@ const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.Stan } }); -const preparePublishIcons = Effect.fn("preparePublishIcons")(function* ( - repoRoot: string, - serverDir: string, - version: string, -) { - const path = yield* Path.Path; - const fs = yield* FileSystem.FileSystem; - const brand = resolveWebAssetBrandForPackageVersion(version); - const icons = resolveWebIconOverrides(brand, "dist/client").map((override) => ({ - sourcePath: path.join(repoRoot, override.sourceRelativePath), - targetPath: path.join(serverDir, override.targetRelativePath), - })); - - for (const icon of icons) { - if (!(yield* fs.exists(icon.sourcePath))) { - return yield* new ServerCliPublishIconSourceMissingError({ sourcePath: icon.sourcePath }); - } - if (!(yield* fs.exists(icon.targetPath))) { - return yield* new ServerCliPublishIconTargetMissingError({ targetPath: icon.targetPath }); - } - } - - return yield* Effect.forEach(icons, (icon) => - Effect.all({ - original: fs.readFile(icon.targetPath), - publish: fs.readFile(icon.sourcePath), - }).pipe(Effect.map((contents) => ({ ...icon, ...contents }))), - ); -}); - const applyDevelopmentIconOverrides = Effect.fn("applyDevelopmentIconOverrides")(function* ( repoRoot: string, serverDir: string, @@ -240,37 +165,21 @@ const buildExeCmd = Command.make( // publish subcommand // --------------------------------------------------------------------------- -interface PublishCommandConfig { - readonly access: string; - readonly tag: string; - readonly provenance: boolean; - readonly dryRun: boolean; -} - -const createVpPmPublishArgs = (config: PublishCommandConfig): ReadonlyArray => { - const args = [ - "publish", - "--filter", - "t3", - "--access", - config.access, - "--tag", - config.tag, - "--no-git-checks", - ]; - - if (config.provenance) args.push("--provenance"); - if (config.dryRun) args.push("--dry-run"); - - return args; -}; - +/** + * Publishes the tarballs scripts/build-npm-platform-packages.ts produced: + * every `@t3tools/t3-.tgz` first, `t3.tgz` (the launcher) last, so + * the launcher is never installable before the executables it depends on. + * Tarballs rather than directories because `npm publish ` strips the + * `node_modules/` the executable loads its native addons from. + */ const publishCmd = Command.make( "publish", { + packagesDir: Flag.string("packages-dir").pipe( + Flag.withDescription("Output dir of scripts/build-npm-platform-packages.ts."), + ), tag: Flag.string("tag").pipe(Flag.withDefault("latest")), access: Flag.string("access").pipe(Flag.withDefault("public")), - appVersion: Flag.string("app-version").pipe(Flag.optional), provenance: Flag.boolean("provenance").pipe(Flag.withDefault(false)), dryRun: Flag.boolean("dry-run").pipe(Flag.withDefault(false)), verbose: Flag.boolean("verbose").pipe(Flag.withDefault(false)), @@ -279,86 +188,45 @@ const publishCmd = Command.make( Effect.gen(function* () { const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; - const repoRoot = yield* RepoRoot; - const serverDir = path.join(repoRoot, "apps/server"); - const packageJsonPath = path.join(serverDir, "package.json"); - - // Assert build assets exist - for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) { - const abs = path.join(serverDir, relPath); - if (!(yield* fs.exists(abs))) { - return yield* new ServerCliBuildAssetMissingError({ assetPath: abs }); - } + const scopeDir = path.join(config.packagesDir, "@t3tools"); + const launcherTarball = path.join(config.packagesDir, "t3.tgz"); + const platformTarballs = (yield* fs + .readDirectory(scopeDir) + .pipe(Effect.orElseSucceed((): ReadonlyArray => []))) + .filter((entry) => entry.startsWith("t3-") && entry.endsWith(".tgz")) + .sort() + .map((entry) => path.join(scopeDir, entry)); + if (platformTarballs.length === 0) { + return yield* new ServerCliBuildAssetMissingError({ + assetPath: path.join(scopeDir, "t3-.tgz"), + }); + } + if (!(yield* fs.exists(launcherTarball))) { + return yield* new ServerCliBuildAssetMissingError({ assetPath: launcherTarball }); } - yield* Effect.acquireUseRelease( - // Acquire: resolve publish metadata and read every original before mutation. - Effect.gen(function* () { - const version = Option.getOrElse(config.appVersion, () => serverPackageJson.version); - const workspaceConfig = yield* readWorkspaceConfig(); - const workspaceCatalog = workspaceConfig.catalog ?? {}; - const workspaceOverrides = workspaceConfig.overrides ?? {}; - const pkg: PackageJson = { - name: serverPackageJson.name, - repository: serverPackageJson.repository, - bin: serverPackageJson.bin, - type: serverPackageJson.type, - version, - engines: serverPackageJson.engines, - files: serverPackageJson.files, - dependencies: resolveCatalogDependencies( - serverPackageJson.dependencies, - workspaceCatalog, - "apps/server", - ), - overrides: resolveCatalogDependencies( - workspaceOverrides, - workspaceCatalog, - "apps/server", - ), - }; - - return { - packageJsonString: yield* encodePackageJson(pkg), - originalPackageJson: yield* fs.readFile(packageJsonPath), - icons: yield* preparePublishIcons(repoRoot, serverDir, version), - }; - }), - // Use: pnpm publish from the workspace root so pnpm-only workspace - // config, including override selectors, is interpreted correctly. - (resource) => - Effect.gen(function* () { - yield* fs.writeFileString(packageJsonPath, `${resource.packageJsonString}\n`); - for (const icon of resource.icons) { - yield* fs.writeFile(icon.targetPath, icon.publish); - } - yield* Effect.log("[cli] Applied package metadata and publish icon overrides"); - - const args = createVpPmPublishArgs(config); - const spawnCommand = yield* resolveSpawnCommand("vp", ["pm", ...args]); - - yield* Effect.log(`[cli] Running: vp pm ${args.join(" ")}`); - yield* runCommand( - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - cwd: repoRoot, - stdout: config.verbose ? "inherit" : "ignore", - stderr: "inherit", - shell: spawnCommand.shell, - }), - ); + const args = ["publish", "--access", config.access, "--tag", config.tag]; + if (config.provenance) args.push("--provenance"); + if (config.dryRun) args.push("--dry-run"); + + for (const tarball of [...platformTarballs, launcherTarball]) { + const spawnCommand = yield* resolveSpawnCommand("npm", [...args, tarball]); + yield* Effect.log(`[cli] npm ${args.join(" ")} ${path.basename(tarball)}`); + yield* runCommand( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd: config.packagesDir, + stdout: config.verbose ? "inherit" : "ignore", + stderr: "inherit", + shell: spawnCommand.shell, }), - // Release: restore every file even if applying overrides or publishing fails. - (resource) => - Effect.gen(function* () { - yield* fs.writeFile(packageJsonPath, resource.originalPackageJson); - for (const icon of resource.icons) { - yield* fs.writeFile(icon.targetPath, icon.original); - } - if (config.verbose) yield* Effect.log("[cli] Restored original publish assets"); - }), - ); + ); + } }), -).pipe(Command.withDescription("Publish the server package to npm.")); +).pipe( + Command.withDescription( + "Publish the @t3tools/t3- tarballs and then the t3 launcher to npm.", + ), +); // --------------------------------------------------------------------------- // root command diff --git a/apps/server/scripts/cliErrors.ts b/apps/server/scripts/cliErrors.ts index ce4bb6c2f8eb..5c02281aabb1 100644 --- a/apps/server/scripts/cliErrors.ts +++ b/apps/server/scripts/cliErrors.ts @@ -14,28 +14,6 @@ export class ServerCliCommandExitError extends Schema.TaggedError()( - "ServerCliPublishIconSourceMissingError", - { - sourcePath: Schema.String, - }, -) { - override get message(): string { - return `Missing publish icon source: ${this.sourcePath}`; - } -} - -export class ServerCliPublishIconTargetMissingError extends Schema.TaggedError()( - "ServerCliPublishIconTargetMissingError", - { - targetPath: Schema.String, - }, -) { - override get message(): string { - return `Missing publish icon target: ${this.targetPath}. Run the build subcommand first.`; - } -} - export class ServerCliDevelopmentIconSourceMissingError extends Schema.TaggedError()( "ServerCliDevelopmentIconSourceMissingError", { diff --git a/docs/operations/release.md b/docs/operations/release.md index 1b74c27b903f..d6a0c75d25c5 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -12,7 +12,7 @@ This document covers the unified release workflow for stable and nightly desktop - push tag matching `v*.*.*` for a stable release of an explicit commit - scheduled nightly check every 30 minutes - manual `workflow_dispatch` with `channel=nightly` - - 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..`) 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. + - 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..`) 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. - A manual stable release builds the commit of the latest published nightly, not `main` HEAD. Nightly is the release candidate: verify the nightly, then promote it. Merges to `main` keep 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 - 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. - Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets. - Builds a self-contained CLI archive per platform (`t3---.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. - - 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`. + - 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`. - 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`. - 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. - Each archive is extracted and executed on its build runner (`scripts/smoke-cli-archive.ts`) before it is uploaded. -- Publishes the CLI package (`apps/server`, npm package `t3`) with OIDC trusted publishing from the same workflow file: +- 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 `@t3tools/t3--` 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. - stable releases publish npm dist-tag `latest` - nightly releases publish npm dist-tag `nightly` - - preview releases are not published to npm + - preview releases publish npm dist-tag `preview`, which nothing resolves unless asked for by name + - one-time setup: the `@t3tools` npm scope (org) must exist, and `t3` and each `@t3tools/t3--` package needs a trusted publisher registered for this workflow file (see below). - Deploys the hosted web app to Vercel only after a release is published: - stable releases are aliased to the `latest` hosted app channel - nightly releases are aliased to the `nightly` hosted app channel @@ -198,7 +199,7 @@ One-time Vercel dashboard setup: - `make_latest` is always `false` - Uses the next stable patch version as the nightly base. For example, `0.0.17` produces nightlies on `0.0.18-nightly.*`. - Publishes Electron auto-update metadata to the dedicated `nightly` updater channel, so desktop users can opt into that track independently from stable. -- Publishes the CLI package (`apps/server`, npm package `t3`) to the `nightly` npm dist-tag using the same nightly version. +- Publishes the CLI npm packages (`t3` and `@t3tools/t3--`) to the `nightly` npm dist-tag using the same nightly version. - Does not commit version bumps back to `main`. ## Server self-update release invariant @@ -209,7 +210,7 @@ npm before users can receive that client. The workflow enforces this ordering: -1. `publish_cli` publishes the exact stable or nightly version to npm. +1. `publish_cli` publishes the exact release version to npm, on every channel. 2. `release` depends on `publish_cli` before exposing desktop artifacts in GitHub Releases. 3. `deploy_web` depends on `release` before moving the hosted channel to the new client. @@ -294,24 +295,33 @@ blockmaps, with a 60 MB maximum for a representative sidecar-to-sidecar update. ## 0) npm OIDC trusted publishing setup (CLI) -The workflow invokes `node apps/server/scripts/cli.ts publish` after aligning package versions. That -script temporarily prepares the `t3` package, then runs `vp pm publish --filter t3 ...` from the -repository root so workspace publish configuration is applied correctly. +The workflow runs `node scripts/build-npm-platform-packages.ts` on the downloaded CLI archives, then +`node apps/server/scripts/cli.ts publish --packages-dir npm-packages`, which runs `npm publish` on +each `@t3tools/t3--.tgz` and finally on `t3.tgz`, the launcher. The script publishes +tarballs it built itself rather than directories: `npm publish ` strips `node_modules/` from the +tarball no matter what `files` says, and the executable loads its native addons from there. Seven +packages are published per release: `t3`, `@t3tools/t3-darwin-arm64`, `@t3tools/t3-darwin-x64`, +`@t3tools/t3-linux-arm64`, `@t3tools/t3-linux-x64`, `@t3tools/t3-win32-arm64`, +`@t3tools/t3-win32-x64`. Checklist: -1. Confirm npm org/user owns package `t3` (or rename package first if needed). -2. In npm package settings, configure Trusted Publisher: +1. Confirm the npm org owns package `t3` and the `@t3tools` scope exists on npm (create the org if + it does not). +2. For `t3` and each `@t3tools/t3--` package, configure a Trusted Publisher in the + npm package settings (a package that has never been published needs a first publish or a + placeholder before the setting exists; the `--dry-run` step in `publish_cli` reports which + names are still rejected): - Provider: GitHub Actions - Repository: this repo - Workflow file: `.github/workflows/release.yml` - Environment (if used): match your npm trusted publishing config -3. Ensure npm account and org policies allow trusted publishing for the package. +3. Ensure npm account and org policies allow trusted publishing for every package. 4. Create release tag `vX.Y.Z` and push; workflow will: - - align the release package versions to `X.Y.Z` - - build web + server - - invoke the CLI publish script with npm dist-tag `latest` -5. Nightly runs invoke the same publish script with npm dist-tag `nightly`. + - build and smoke-test the five CLI archives + - build the npm packages from those archives + - publish them with npm dist-tag `latest` +5. Nightly runs publish with npm dist-tag `nightly`; preview runs with `preview`. ## 1) Release validation and unsigned builds diff --git a/docs/user/install.md b/docs/user/install.md index e8045099fe67..a03e6b9c99a3 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -5,8 +5,10 @@ desktop, web, or mobile app. Set up the machine where the agents will work first ## Requirements -Command-line use, SSH hosts, and WSL backends need Node.js 22.16+ (22.x), 23.11+ -(23.x), or 24.10 and later. The native desktop app includes its server runtime. +`npx t3` needs Node.js only to run npm itself; the CLI it installs is a +self-contained executable. SSH hosts and WSL backends need Node.js 22.16+ +(22.x), 23.11+ (23.x), or 24.10 and later. The native desktop app includes its +server runtime. You need an installed, authenticated provider before starting a thread. You can launch T3 Code and configure providers afterwards. diff --git a/packages/shared/src/cliRelease.ts b/packages/shared/src/cliRelease.ts index 99339ba395a7..28f0d530bb29 100644 --- a/packages/shared/src/cliRelease.ts +++ b/packages/shared/src/cliRelease.ts @@ -19,7 +19,7 @@ export const CLI_RELEASE_BASE_URL_ENV = "T3CODE_RELEASE_BASE_URL"; */ // No darwin-x64: Node single-executables are unsupported on x64 macOS (the // SEA docs list macOS as arm64 only) and the binary segfaults on start. -const CLI_ARCHIVE_PLATFORM_KEYS = [ +export const CLI_ARCHIVE_PLATFORM_KEYS = [ "darwin-arm64", "linux-arm64", "linux-x64", diff --git a/scripts/build-npm-platform-packages.test.ts b/scripts/build-npm-platform-packages.test.ts new file mode 100644 index 000000000000..8879d3e67618 --- /dev/null +++ b/scripts/build-npm-platform-packages.test.ts @@ -0,0 +1,198 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + buildNpmPlatformPackages, + NpmPackagesArchivesMissingError, +} from "./build-npm-platform-packages.ts"; + +const VERSION = "1.2.3"; +const decodeManifest = Schema.decodeEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); +const KEYS = ["linux-x64", "darwin-arm64"] as const; + +const collect = (stream: Stream.Stream) => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ); + +const run = Effect.fn("test.run")(function* ( + command: string, + args: ReadonlyArray, + options: { readonly cwd: string; readonly env?: Record }, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make(command, args, { cwd: options.cwd, env: options.env ?? {} }), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [collect(child.stdout), collect(child.stderr), child.exitCode.pipe(Effect.map(Number))], + { concurrency: "unbounded" }, + ); + return { stdout, stderr, exitCode }; +}); + +/** A tar.gz laid out like build-cli-archive.ts writes, with a stub `t3` that echoes its args. */ +const makeFakeArchives = Effect.fn("test.makeFakeArchives")(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-npm-packages-test-" }); + const archivesDir = path.join(root, "archives"); + yield* fs.makeDirectory(archivesDir); + for (const key of KEYS) { + const stem = `t3-${VERSION}-${key}`; + const stage = path.join(root, "stage", key); + const contentDir = path.join(stage, stem); + for (const dir of ["client", "resource-monitor", "node_modules/node-pty"]) { + yield* fs.makeDirectory(path.join(contentDir, dir), { recursive: true }); + } + yield* fs.writeFileString(path.join(contentDir, "client/index.html"), "\n"); + yield* fs.writeFileString( + path.join(contentDir, "t3"), + `#!/bin/sh\necho "stub ${key} $*"\nexit 7\n`, + ); + yield* fs.chmod(path.join(contentDir, "t3"), 0o755); + const exit = yield* run("tar", ["-czf", path.join(archivesDir, `${stem}.tar.gz`), stem], { + cwd: stage, + }); + assert.equal(exit.exitCode, 0, exit.stderr); + } + yield* fs.writeFileString(path.join(archivesDir, "SHA256SUMS"), ""); + return { root, archivesDir, outputDir: path.join(root, "out") }; +}); + +it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { + it.effect("refuses a partial release unless --allow-missing is passed", () => + Effect.gen(function* () { + const fixture = yield* makeFakeArchives(); + const error = yield* buildNpmPlatformPackages({ + ...fixture, + version: VERSION, + allowMissing: false, + }).pipe(Effect.flip); + assert.instanceOf(error, NpmPackagesArchivesMissingError); + assert.deepStrictEqual((error as NpmPackagesArchivesMissingError).missing, [ + "linux-arm64", + "win32-arm64", + "win32-x64", + ]); + }), + ); + + it.effect("builds platform packages and a launcher that execs the installed one", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = yield* makeFakeArchives(); + const outputs = yield* buildNpmPlatformPackages({ + ...fixture, + version: VERSION, + allowMissing: true, + }); + // Platform packages in CLI_ARCHIVE_PLATFORM_KEYS order, launcher last. + assert.deepStrictEqual( + outputs.map((output) => output.name), + ["@t3tools/t3-darwin-arm64", "@t3tools/t3-linux-x64", "t3"], + ); + for (const output of outputs) { + assert.isTrue(yield* fs.exists(output.tarball), output.tarball); + } + + const linuxDir = path.join(fixture.outputDir, "@t3tools/t3-linux-x64"); + const linuxManifest = yield* decodeManifest( + yield* fs.readFileString(path.join(linuxDir, "package.json")), + ); + assert.equal(linuxManifest.name, "@t3tools/t3-linux-x64"); + assert.equal(linuxManifest.version, VERSION); + assert.deepStrictEqual(linuxManifest.os, ["linux"]); + assert.deepStrictEqual(linuxManifest.cpu, ["x64"]); + assert.deepStrictEqual(linuxManifest.files, [ + "t3", + "t3.exe", + "client", + "resource-monitor", + "node_modules", + ]); + assert.equal(linuxManifest.preferUnplugged, true); + assert.isUndefined(linuxManifest.bin); + // Archive contents sit at the package root, not under the archive stem. + assert.isTrue(yield* fs.exists(path.join(linuxDir, "client/index.html"))); + assert.isTrue(yield* fs.exists(path.join(linuxDir, "node_modules/node-pty"))); + assert.equal(Number((yield* fs.stat(path.join(linuxDir, "t3"))).mode) & 0o111, 0o111); + + const darwinManifest = yield* decodeManifest( + yield* fs.readFileString( + path.join(fixture.outputDir, "@t3tools/t3-darwin-arm64/package.json"), + ), + ); + assert.deepStrictEqual(darwinManifest.os, ["darwin"]); + assert.deepStrictEqual(darwinManifest.cpu, ["arm64"]); + + const launcherDir = path.join(fixture.outputDir, "t3"); + const launcherManifest = yield* decodeManifest( + yield* fs.readFileString(path.join(launcherDir, "package.json")), + ); + assert.equal(launcherManifest.name, "t3"); + assert.equal(launcherManifest.version, VERSION); + assert.deepStrictEqual(launcherManifest.bin, { t3: "./bin/t3.js" }); + assert.deepStrictEqual(launcherManifest.files, ["bin"]); + assert.deepStrictEqual(launcherManifest.optionalDependencies, { + "@t3tools/t3-darwin-arm64": VERSION, + "@t3tools/t3-linux-x64": VERSION, + }); + assert.isUndefined(launcherManifest.engines); + assert.isTrue(yield* fs.exists(path.join(launcherDir, "bin/t3.js"))); + + // The scratch dirs must not be left behind next to the packages. + const outputEntries = yield* fs.readDirectory(fixture.outputDir); + assert.deepStrictEqual(outputEntries.sort(), ["@t3tools", "t3", "t3.tgz"]); + + // The tarball is what gets published: it must carry node_modules (which + // `npm publish ` would strip) under npm's `package/` root, with the + // executable bit intact. + const listing = yield* run( + "tar", + ["-tzvf", path.join(fixture.outputDir, "@t3tools/t3-linux-x64.tgz")], + { cwd: fixture.outputDir }, + ); + assert.equal(listing.exitCode, 0, listing.stderr); + const lines = listing.stdout.split("\n"); + assert.isTrue(lines.some((line) => line.endsWith(" package/node_modules/node-pty/"))); + assert.isTrue(lines.some((line) => line.endsWith(" package/package.json"))); + assert.isTrue( + lines.some((line) => /^-rwxr-xr-x .* package\/t3$/.test(line)), + listing.stdout, + ); + + // NODE_PATH stands in for node_modules: require.resolve finds the + // platform package there exactly as it would after `npm install`. + const env = { ...process.env, NODE_PATH: fixture.outputDir } as Record; + const passthrough = yield* run(process.execPath, ["bin/t3.js", "serve", "--port", "1234"], { + cwd: launcherDir, + env, + }); + assert.equal(passthrough.stdout.trim(), "stub linux-x64 serve --port 1234"); + assert.equal(passthrough.exitCode, 7); + + const unsupported = yield* run(process.execPath, ["bin/t3.js", "--version"], { + cwd: launcherDir, + env: { ...env, NODE_PATH: path.join(fixture.root, "nowhere") }, + }); + assert.equal(unsupported.exitCode, 1); + assert.include(unsupported.stderr, "linux-x64"); + assert.include(unsupported.stderr, "win32-arm64"); + assert.include(unsupported.stderr, "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/pingdotgg/t3code/releases"); + }), + ); +}); diff --git a/scripts/build-npm-platform-packages.ts b/scripts/build-npm-platform-packages.ts new file mode 100644 index 000000000000..c5b7ff43fa27 --- /dev/null +++ b/scripts/build-npm-platform-packages.ts @@ -0,0 +1,417 @@ +#!/usr/bin/env node +/** + * Turns the per-platform CLI archives of one release into the npm packages + * behind `npx t3` / `npm i -g t3`: one `@t3tools/t3-` package per + * archive holding the archive's contents verbatim, plus the `t3` launcher + * that lists them as optionalDependencies and execs the one npm installed. + * The bytes a user gets from npm are therefore the release archive's, and + * running them needs neither a Node runtime, npm, nor a native build. + * + * Output layout under `--output-dir`: + * + * @t3tools/t3-/ archive contents flattened + package.json + * @t3tools/t3-.tgz the same tree as an npm tarball + * t3/ launcher: package.json, bin/t3.js, README.md + * t3.tgz the launcher as an npm tarball + * + * The tarballs are what gets published. `npm publish ` always drops + * `node_modules/` (npm-packlist ignores it whatever `files` says, and + * bundleDependencies needs an arborist tree these flattened installs are + * not), whereas `npm publish ` uploads the bytes as given. + */ +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { Command, Flag } from "effect/unstable/cli"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + CLI_ARCHIVE_PLATFORM_KEYS, + cliArchiveFileName, + type CliArchivePlatformKey, +} from "@t3tools/shared/cliRelease"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; +import { isCommandAvailable } from "@t3tools/shared/shell"; +import serverPackageJson from "../apps/server/package.json" with { type: "json" }; + +import { windowsSystemTar } from "./build-cli-archive.ts"; + +export const NPM_PLATFORM_PACKAGE_SCOPE = "@t3tools"; +export const NPM_LAUNCHER_PACKAGE_NAME = "t3"; + +const encodePackageJson = Schema.encodeEffect(fromJsonStringPretty(Schema.Unknown)); + +export class NpmPackagesCommandFailedError extends Schema.TaggedError()( + "NpmPackagesCommandFailedError", + { command: Schema.String, exitCode: Schema.Int }, +) { + override get message(): string { + return `${this.command} exited with code ${this.exitCode}.`; + } +} + +export class NpmPackagesToolMissingError extends Schema.TaggedError()( + "NpmPackagesToolMissingError", + { tool: Schema.String, purpose: Schema.String }, +) { + override get message(): string { + return `\`${this.tool}\` is not on PATH; it is needed to ${this.purpose}.`; + } +} + +export class NpmPackagesArchivesMissingError extends Schema.TaggedError()( + "NpmPackagesArchivesMissingError", + { archivesDir: Schema.String, missing: Schema.Array(Schema.String) }, +) { + override get message(): string { + 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.`; + } +} + +export class NpmPackagesArchiveLayoutError extends Schema.TaggedError()( + "NpmPackagesArchiveLayoutError", + { archive: Schema.String, detail: Schema.String }, +) { + override get message(): string { + return `${this.archive}: ${this.detail}`; + } +} + +export function npmPlatformPackageName(platformKey: CliArchivePlatformKey): string { + return `${NPM_PLATFORM_PACKAGE_SCOPE}/t3-${platformKey}`; +} + +/** package.json for one platform package; `os`/`cpu` let npm skip the other five. */ +export function npmPlatformPackageManifest(platformKey: CliArchivePlatformKey, version: string) { + const [os, cpu] = platformKey.split("-") as [string, string]; + return { + name: npmPlatformPackageName(platformKey), + version, + description: `T3 Code CLI executable for ${platformKey}`, + license: serverPackageJson.license, + repository: serverPackageJson.repository, + os: [os], + cpu: [cpu], + files: ["t3", "t3.exe", "client", "resource-monitor", "node_modules"], + preferUnplugged: true, + }; +} + +/** package.json for the `t3` launcher. No engines: bin/t3.js is trivial CJS. */ +export function npmLauncherPackageManifest( + version: string, + platformKeys: ReadonlyArray, +) { + return { + name: NPM_LAUNCHER_PACKAGE_NAME, + version, + description: "T3 Code CLI. Installs the self-contained executable for this platform.", + license: serverPackageJson.license, + repository: serverPackageJson.repository, + bin: { t3: "./bin/t3.js" }, + files: ["bin"], + optionalDependencies: Object.fromEntries( + platformKeys.map((key) => [npmPlatformPackageName(key), version]), + ), + }; +} + +/** + * The launcher every `npx t3` runs. Plain CommonJS with no dependencies so it + * loads on any Node that npm itself runs on; the real work happens in the + * single-executable it execs. + */ +export const NPM_LAUNCHER_SCRIPT = `#!/usr/bin/env node +"use strict"; +const { spawnSync } = require("node:child_process"); +const { constants } = require("node:os"); +const { dirname, join } = require("node:path"); + +const SUPPORTED = [${CLI_ARCHIVE_PLATFORM_KEYS.map((key) => `"${key}"`).join(", ")}]; +const key = process.platform + "-" + process.arch; + +let packageDir; +try { + packageDir = dirname(require.resolve("${NPM_PLATFORM_PACKAGE_SCOPE}/t3-" + key + "/package.json")); +} catch { + process.stderr.write( + [ + "t3: no T3 Code CLI build is available for this platform (" + key + ").", + "Supported platforms: " + SUPPORTED.join(", ") + ".", + "If yours is listed, reinstall t3 so npm fetches its optional dependency.", + "The desktop app and release archives are at https://github.com/pingdotgg/t3code/releases", + "", + ].join("\\n"), + ); + process.exit(1); +} + +const executable = join(packageDir, process.platform === "win32" ? "t3.exe" : "t3"); +const result = spawnSync(executable, process.argv.slice(2), { stdio: "inherit" }); +if (result.error) { + process.stderr.write("t3: failed to start " + executable + ": " + result.error.message + "\\n"); + process.exit(1); +} +// A child killed by a signal has no status; report it the way a shell would. +process.exit(result.status ?? 128 + (constants.signals[result.signal] || 1)); +`; + +const runCommand = Effect.fn("runCommand")(function* ( + command: ChildProcess.StandardCommand, + label: string, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make(command.command, command.args, { + ...command.options, + stdout: "inherit", + stderr: "inherit", + }), + ); + const exitCode = Number(yield* child.exitCode); + if (exitCode !== 0) { + return yield* new NpmPackagesCommandFailedError({ command: label, exitCode }); + } +}); + +/** + * Extracts an archive and returns its single top-level directory. `.tar.gz` + * goes through tar everywhere; `.zip` through the bsdtar Windows ships or, + * elsewhere, `unzip`, since GNU tar cannot read zip. + */ +const extractArchive = Effect.fn("extractArchive")(function* (archive: string, into: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + if (!archive.endsWith(".zip")) { + yield* runCommand(ChildProcess.make("tar", ["-xf", archive, "-C", into]), "tar -xf"); + } else if (platform === "win32") { + yield* runCommand( + ChildProcess.make(windowsSystemTar(), ["-xf", archive, "-C", into]), + "tar.exe -xf (zip)", + ); + } else { + if (!(yield* isCommandAvailable("unzip"))) { + return yield* new NpmPackagesToolMissingError({ + tool: "unzip", + purpose: `extract ${path.basename(archive)} (GNU tar cannot read zip)`, + }); + } + yield* runCommand(ChildProcess.make("unzip", ["-q", archive, "-d", into]), "unzip"); + } + const entries = yield* fs.readDirectory(into); + const [root] = entries; + if (root === undefined || entries.length !== 1) { + return yield* new NpmPackagesArchiveLayoutError({ + archive: path.basename(archive), + detail: `expected exactly one top-level directory, found ${String(entries.length)} entries`, + }); + } + return path.join(into, root); +}); + +/** Tar to build npm tarballs with; see build-cli-archive.ts for why Windows names bsdtar by path. */ +const hostTar = Effect.map(HostProcessPlatform, (platform) => + platform === "win32" ? windowsSystemTar() : "tar", +); + +/** + * Writes `stageDir/package` as a gzipped npm tarball and then moves the tree + * to `packageDir` so the contents stay inspectable beside the tarball. + */ +const packAndPlace = Effect.fn("packAndPlace")(function* (input: { + readonly stageDir: string; + readonly packageDir: string; + readonly tarball: string; +}) { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(input.tarball, { force: true }); + yield* runCommand( + ChildProcess.make(yield* hostTar, ["-czf", input.tarball, "-C", input.stageDir, "package"]), + `tar (${input.tarball})`, + ); + yield* fs.remove(input.packageDir, { recursive: true, force: true }); + yield* fs.rename(`${input.stageDir}/package`, input.packageDir); +}); + +export interface NpmPackageOutput { + readonly name: string; + readonly packageDir: string; + readonly tarball: string; +} + +/** + * Extracts one archive, adds its package.json, and emits the package dir and + * tarball. The scratch dir lives inside the output dir so the extracted tree + * is renamed into place rather than copied across filesystems. + */ +const stagePlatformPackage = Effect.fn("stagePlatformPackage")(function* (input: { + readonly key: CliArchivePlatformKey; + readonly archive: string; + readonly outputDir: string; + readonly version: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* Effect.log(`[npm-packages] Extracting ${path.basename(input.archive)}...`); + const scratch = yield* fs.makeTempDirectoryScoped({ + directory: input.outputDir, + prefix: ".extract-", + }); + const extractDir = path.join(scratch, "extract"); + yield* fs.makeDirectory(extractDir); + const contentDir = yield* extractArchive(input.archive, extractDir); + const executableName = input.key.startsWith("win32") ? "t3.exe" : "t3"; + const executable = path.join(contentDir, executableName); + if (!(yield* fs.exists(executable))) { + return yield* new NpmPackagesArchiveLayoutError({ + archive: path.basename(input.archive), + detail: `missing ${executableName} at the archive root`, + }); + } + // The tarball carries the on-disk mode, so the bit must be set before packing. + if (executableName === "t3") { + yield* fs.chmod(executable, 0o755); + } + yield* fs.writeFileString( + path.join(contentDir, "package.json"), + `${yield* encodePackageJson(npmPlatformPackageManifest(input.key, input.version))}\n`, + ); + // npm tarballs root everything under `package/`. + yield* fs.rename(contentDir, path.join(scratch, "package")); + const name = npmPlatformPackageName(input.key); + const output: NpmPackageOutput = { + name, + packageDir: path.join(input.outputDir, name), + tarball: path.join(input.outputDir, `${name}.tgz`), + }; + yield* packAndPlace({ stageDir: scratch, ...output }); + return output; +}, Effect.scoped); + +/** Writes the launcher package (package.json, bin/t3.js, README) and its tarball. */ +const stageLauncherPackage = Effect.fn("stageLauncherPackage")(function* (input: { + readonly outputDir: string; + readonly version: string; + readonly platformKeys: ReadonlyArray; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const scratch = yield* fs.makeTempDirectoryScoped({ + directory: input.outputDir, + prefix: ".launcher-", + }); + const stageDir = path.join(scratch, "package"); + yield* fs.makeDirectory(path.join(stageDir, "bin"), { recursive: true }); + yield* fs.writeFileString( + path.join(stageDir, "package.json"), + `${yield* encodePackageJson(npmLauncherPackageManifest(input.version, input.platformKeys))}\n`, + ); + const launcherScript = path.join(stageDir, "bin/t3.js"); + yield* fs.writeFileString(launcherScript, NPM_LAUNCHER_SCRIPT); + yield* fs.chmod(launcherScript, 0o755); + const readme = yield* path.fromFileUrl(new URL("../apps/server/README.md", import.meta.url)); + if (yield* fs.exists(readme)) { + yield* fs.copyFile(readme, path.join(stageDir, "README.md")); + } + const output: NpmPackageOutput = { + name: NPM_LAUNCHER_PACKAGE_NAME, + packageDir: path.join(input.outputDir, NPM_LAUNCHER_PACKAGE_NAME), + tarball: path.join(input.outputDir, `${NPM_LAUNCHER_PACKAGE_NAME}.tgz`), + }; + yield* packAndPlace({ stageDir: scratch, ...output }); + return output; +}, Effect.scoped); + +export const buildNpmPlatformPackages = Effect.fn("buildNpmPlatformPackages")(function* (input: { + readonly archivesDir: string; + readonly version: string; + readonly outputDir: string; + readonly allowMissing: boolean; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const present = yield* fs.readDirectory(input.archivesDir); + const archives = CLI_ARCHIVE_PLATFORM_KEYS.flatMap((key) => { + const fileName = cliArchiveFileName(input.version, key); + return present.includes(fileName) + ? [{ key, archive: path.join(input.archivesDir, fileName) }] + : []; + }); + const missing = CLI_ARCHIVE_PLATFORM_KEYS.filter( + (key) => !archives.some((entry) => entry.key === key), + ); + if (missing.length > 0 && (!input.allowMissing || archives.length === 0)) { + return yield* new NpmPackagesArchivesMissingError({ archivesDir: input.archivesDir, missing }); + } + + yield* fs.makeDirectory(path.join(input.outputDir, NPM_PLATFORM_PACKAGE_SCOPE), { + recursive: true, + }); + const outputs: Array = []; + for (const { key, archive } of archives) { + outputs.push( + yield* stagePlatformPackage({ + key, + archive, + outputDir: input.outputDir, + version: input.version, + }), + ); + } + outputs.push( + yield* stageLauncherPackage({ + outputDir: input.outputDir, + version: input.version, + platformKeys: archives.map((entry) => entry.key), + }), + ); + + for (const output of outputs) { + yield* Effect.log(`[npm-packages] Wrote ${output.packageDir} and ${output.tarball}`); + } + if (missing.length > 0) { + yield* Effect.logWarning( + `[npm-packages] Launcher omits ${missing.join(", ")} (--allow-missing).`, + ); + } + return outputs; +}); + +const command = Command.make( + "build-npm-platform-packages", + { + archivesDir: Flag.string("archives-dir").pipe( + Flag.withDescription("Directory holding the release's t3-- archives."), + ), + version: Flag.string("version").pipe( + Flag.withDescription( + "Exact release version; selects the archives and versions the packages.", + ), + ), + outputDir: Flag.string("output-dir").pipe(Flag.withDefault("npm-packages")), + allowMissing: Flag.boolean("allow-missing").pipe( + Flag.withDefault(false), + Flag.withDescription("Build a launcher that lists only the platforms present."), + ), + }, + buildNpmPlatformPackages, +).pipe( + Command.withDescription( + "Build the t3 launcher and @t3tools/t3- npm packages from CLI release archives.", + ), +); + +if (import.meta.main) { + Command.run(command, { version: "0.0.0" }).pipe( + Effect.provide(Layer.mergeAll(Logger.layer([Logger.consolePretty()]), NodeServices.layer)), + NodeRuntime.runMain, + ); +} From fc60da3ec32caad0101c73f2358d2a00abbdb68e Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:05:24 -0700 Subject: [PATCH 2/7] fix(release): resolve npm tarball paths once before publishing The publish command joined the packages dir into each tarball path and then also ran npm with that dir as cwd, so a relative --packages-dir produced npm-packages/npm-packages/... and ENOENT in CI. Co-Authored-By: Claude Fable 5 --- apps/server/scripts/cli.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index eb13ba28afc2..42e60d04057d 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -188,8 +188,11 @@ const publishCmd = Command.make( Effect.gen(function* () { const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; - const scopeDir = path.join(config.packagesDir, "@t3tools"); - const launcherTarball = path.join(config.packagesDir, "t3.tgz"); + // npm runs with cwd set to the packages dir below, so tarball paths are + // resolved once here rather than joined twice. + const packagesDir = path.resolve(config.packagesDir); + const scopeDir = path.join(packagesDir, "@t3tools"); + const launcherTarball = path.join(packagesDir, "t3.tgz"); const platformTarballs = (yield* fs .readDirectory(scopeDir) .pipe(Effect.orElseSucceed((): ReadonlyArray => []))) @@ -214,7 +217,7 @@ const publishCmd = Command.make( yield* Effect.log(`[cli] npm ${args.join(" ")} ${path.basename(tarball)}`); yield* runCommand( ChildProcess.make(spawnCommand.command, spawnCommand.args, { - cwd: config.packagesDir, + cwd: packagesDir, stdout: config.verbose ? "inherit" : "ignore", stderr: "inherit", shell: spawnCommand.shell, From 5c245839c844babbf781869fae3dc6b143833760 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 13 Sep 2026 09:30:55 -0700 Subject: [PATCH 3/7] chore(release): publish platform packages under the @t3code npm scope Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 4 ++-- apps/server/scripts/cli.ts | 6 +++--- docs/operations/release.md | 18 +++++++++--------- scripts/build-npm-platform-packages.test.ts | 16 ++++++++-------- scripts/build-npm-platform-packages.ts | 10 +++++----- 5 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5451e917ca48..998e5fd7eacb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -645,12 +645,12 @@ jobs: run: node scripts/build-npm-platform-packages.ts --archives-dir release-cli --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages # A dry run of every package first: an auth or scope error here (the - # @t3tools org missing, a package without a trusted publisher) fails + # @t3code org missing, a package without a trusted publisher) fails # before anything is live, instead of after some platforms already are. - name: Check npm publish access (dry run) run: | if ! node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --dry-run --verbose; then - echo "::error::npm publish --dry-run failed. Make sure the @t3tools npm org exists and that t3 and every @t3tools/t3- package has a trusted publisher registered for .github/workflows/release.yml (see docs/operations/release.md)." >&2 + echo "::error::npm publish --dry-run failed. Make sure the @t3code npm org exists and that t3 and every @t3code/t3- package has a trusted publisher registered for .github/workflows/release.yml (see docs/operations/release.md)." >&2 exit 1 fi diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 42e60d04057d..cc59e47e0a40 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -167,7 +167,7 @@ const buildExeCmd = Command.make( /** * Publishes the tarballs scripts/build-npm-platform-packages.ts produced: - * every `@t3tools/t3-.tgz` first, `t3.tgz` (the launcher) last, so + * every `@t3code/t3-.tgz` first, `t3.tgz` (the launcher) last, so * the launcher is never installable before the executables it depends on. * Tarballs rather than directories because `npm publish ` strips the * `node_modules/` the executable loads its native addons from. @@ -191,7 +191,7 @@ const publishCmd = Command.make( // npm runs with cwd set to the packages dir below, so tarball paths are // resolved once here rather than joined twice. const packagesDir = path.resolve(config.packagesDir); - const scopeDir = path.join(packagesDir, "@t3tools"); + const scopeDir = path.join(packagesDir, "@t3code"); const launcherTarball = path.join(packagesDir, "t3.tgz"); const platformTarballs = (yield* fs .readDirectory(scopeDir) @@ -227,7 +227,7 @@ const publishCmd = Command.make( }), ).pipe( Command.withDescription( - "Publish the @t3tools/t3- tarballs and then the t3 launcher to npm.", + "Publish the @t3code/t3- tarballs and then the t3 launcher to npm.", ), ); diff --git a/docs/operations/release.md b/docs/operations/release.md index d6a0c75d25c5..a143411c4bf4 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -40,11 +40,11 @@ This document covers the unified release workflow for stable and nightly desktop - 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`. - 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. - Each archive is extracted and executed on its build runner (`scripts/smoke-cli-archive.ts`) before it is uploaded. -- 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 `@t3tools/t3--` 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. +- 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--` 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. - stable releases publish npm dist-tag `latest` - nightly releases publish npm dist-tag `nightly` - preview releases publish npm dist-tag `preview`, which nothing resolves unless asked for by name - - one-time setup: the `@t3tools` npm scope (org) must exist, and `t3` and each `@t3tools/t3--` package needs a trusted publisher registered for this workflow file (see below). + - one-time setup: the `@t3code` npm scope (org) must exist, and `t3` and each `@t3code/t3--` package needs a trusted publisher registered for this workflow file (see below). - Deploys the hosted web app to Vercel only after a release is published: - stable releases are aliased to the `latest` hosted app channel - nightly releases are aliased to the `nightly` hosted app channel @@ -199,7 +199,7 @@ One-time Vercel dashboard setup: - `make_latest` is always `false` - Uses the next stable patch version as the nightly base. For example, `0.0.17` produces nightlies on `0.0.18-nightly.*`. - Publishes Electron auto-update metadata to the dedicated `nightly` updater channel, so desktop users can opt into that track independently from stable. -- Publishes the CLI npm packages (`t3` and `@t3tools/t3--`) to the `nightly` npm dist-tag using the same nightly version. +- Publishes the CLI npm packages (`t3` and `@t3code/t3--`) to the `nightly` npm dist-tag using the same nightly version. - Does not commit version bumps back to `main`. ## Server self-update release invariant @@ -297,18 +297,18 @@ blockmaps, with a 60 MB maximum for a representative sidecar-to-sidecar update. The workflow runs `node scripts/build-npm-platform-packages.ts` on the downloaded CLI archives, then `node apps/server/scripts/cli.ts publish --packages-dir npm-packages`, which runs `npm publish` on -each `@t3tools/t3--.tgz` and finally on `t3.tgz`, the launcher. The script publishes +each `@t3code/t3--.tgz` and finally on `t3.tgz`, the launcher. The script publishes tarballs it built itself rather than directories: `npm publish ` strips `node_modules/` from the tarball no matter what `files` says, and the executable loads its native addons from there. Seven -packages are published per release: `t3`, `@t3tools/t3-darwin-arm64`, `@t3tools/t3-darwin-x64`, -`@t3tools/t3-linux-arm64`, `@t3tools/t3-linux-x64`, `@t3tools/t3-win32-arm64`, -`@t3tools/t3-win32-x64`. +packages are published per release: `t3`, `@t3code/t3-darwin-arm64`, `@t3code/t3-darwin-x64`, +`@t3code/t3-linux-arm64`, `@t3code/t3-linux-x64`, `@t3code/t3-win32-arm64`, +`@t3code/t3-win32-x64`. Checklist: -1. Confirm the npm org owns package `t3` and the `@t3tools` scope exists on npm (create the org if +1. Confirm the npm org owns package `t3` and the `@t3code` scope exists on npm (create the org if it does not). -2. For `t3` and each `@t3tools/t3--` package, configure a Trusted Publisher in the +2. For `t3` and each `@t3code/t3--` package, configure a Trusted Publisher in the npm package settings (a package that has never been published needs a first publish or a placeholder before the setting exists; the `--dry-run` step in `publish_cli` reports which names are still rejected): diff --git a/scripts/build-npm-platform-packages.test.ts b/scripts/build-npm-platform-packages.test.ts index 8879d3e67618..4590b091699a 100644 --- a/scripts/build-npm-platform-packages.test.ts +++ b/scripts/build-npm-platform-packages.test.ts @@ -103,17 +103,17 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { // Platform packages in CLI_ARCHIVE_PLATFORM_KEYS order, launcher last. assert.deepStrictEqual( outputs.map((output) => output.name), - ["@t3tools/t3-darwin-arm64", "@t3tools/t3-linux-x64", "t3"], + ["@t3code/t3-darwin-arm64", "@t3code/t3-linux-x64", "t3"], ); for (const output of outputs) { assert.isTrue(yield* fs.exists(output.tarball), output.tarball); } - const linuxDir = path.join(fixture.outputDir, "@t3tools/t3-linux-x64"); + const linuxDir = path.join(fixture.outputDir, "@t3code/t3-linux-x64"); const linuxManifest = yield* decodeManifest( yield* fs.readFileString(path.join(linuxDir, "package.json")), ); - assert.equal(linuxManifest.name, "@t3tools/t3-linux-x64"); + assert.equal(linuxManifest.name, "@t3code/t3-linux-x64"); assert.equal(linuxManifest.version, VERSION); assert.deepStrictEqual(linuxManifest.os, ["linux"]); assert.deepStrictEqual(linuxManifest.cpu, ["x64"]); @@ -133,7 +133,7 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { const darwinManifest = yield* decodeManifest( yield* fs.readFileString( - path.join(fixture.outputDir, "@t3tools/t3-darwin-arm64/package.json"), + path.join(fixture.outputDir, "@t3code/t3-darwin-arm64/package.json"), ), ); assert.deepStrictEqual(darwinManifest.os, ["darwin"]); @@ -148,22 +148,22 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { assert.deepStrictEqual(launcherManifest.bin, { t3: "./bin/t3.js" }); assert.deepStrictEqual(launcherManifest.files, ["bin"]); assert.deepStrictEqual(launcherManifest.optionalDependencies, { - "@t3tools/t3-darwin-arm64": VERSION, - "@t3tools/t3-linux-x64": VERSION, + "@t3code/t3-darwin-arm64": VERSION, + "@t3code/t3-linux-x64": VERSION, }); assert.isUndefined(launcherManifest.engines); assert.isTrue(yield* fs.exists(path.join(launcherDir, "bin/t3.js"))); // The scratch dirs must not be left behind next to the packages. const outputEntries = yield* fs.readDirectory(fixture.outputDir); - assert.deepStrictEqual(outputEntries.sort(), ["@t3tools", "t3", "t3.tgz"]); + assert.deepStrictEqual(outputEntries.sort(), ["@t3code", "t3", "t3.tgz"]); // The tarball is what gets published: it must carry node_modules (which // `npm publish ` would strip) under npm's `package/` root, with the // executable bit intact. const listing = yield* run( "tar", - ["-tzvf", path.join(fixture.outputDir, "@t3tools/t3-linux-x64.tgz")], + ["-tzvf", path.join(fixture.outputDir, "@t3code/t3-linux-x64.tgz")], { cwd: fixture.outputDir }, ); assert.equal(listing.exitCode, 0, listing.stderr); diff --git a/scripts/build-npm-platform-packages.ts b/scripts/build-npm-platform-packages.ts index c5b7ff43fa27..812420326917 100644 --- a/scripts/build-npm-platform-packages.ts +++ b/scripts/build-npm-platform-packages.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node /** * Turns the per-platform CLI archives of one release into the npm packages - * behind `npx t3` / `npm i -g t3`: one `@t3tools/t3-` package per + * behind `npx t3` / `npm i -g t3`: one `@t3code/t3-` package per * archive holding the archive's contents verbatim, plus the `t3` launcher * that lists them as optionalDependencies and execs the one npm installed. * The bytes a user gets from npm are therefore the release archive's, and @@ -9,8 +9,8 @@ * * Output layout under `--output-dir`: * - * @t3tools/t3-/ archive contents flattened + package.json - * @t3tools/t3-.tgz the same tree as an npm tarball + * @t3code/t3-/ archive contents flattened + package.json + * @t3code/t3-.tgz the same tree as an npm tarball * t3/ launcher: package.json, bin/t3.js, README.md * t3.tgz the launcher as an npm tarball * @@ -42,7 +42,7 @@ import serverPackageJson from "../apps/server/package.json" with { type: "json" import { windowsSystemTar } from "./build-cli-archive.ts"; -export const NPM_PLATFORM_PACKAGE_SCOPE = "@t3tools"; +export const NPM_PLATFORM_PACKAGE_SCOPE = "@t3code"; export const NPM_LAUNCHER_PACKAGE_NAME = "t3"; const encodePackageJson = Schema.encodeEffect(fromJsonStringPretty(Schema.Unknown)); @@ -405,7 +405,7 @@ const command = Command.make( buildNpmPlatformPackages, ).pipe( Command.withDescription( - "Build the t3 launcher and @t3tools/t3- npm packages from CLI release archives.", + "Build the t3 launcher and @t3code/t3- npm packages from CLI release archives.", ), ); From 2cf1abac46ed08ba8cc481ab6dc3284c3dd7e2b7 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 13 Sep 2026 09:54:51 -0700 Subject: [PATCH 4/7] fix(release): strip nested node_modules/.bin symlinks from CLI archives npm refuses a tarball that contains a symlink, and the darwin-arm64 archive carried four in msgpackr-extract's nested .bin directory. Co-Authored-By: Claude Fable 5 --- scripts/build-cli-archive.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/scripts/build-cli-archive.ts b/scripts/build-cli-archive.ts index 99bb7cf4396f..b6cdd97b11fe 100644 --- a/scripts/build-cli-archive.ts +++ b/scripts/build-cli-archive.ts @@ -23,6 +23,7 @@ import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { Command, Flag } from "effect/unstable/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -241,8 +242,33 @@ const stageRuntimeExternals = Effect.fn("stageRuntimeExternals")(function* (inpu ]) { yield* fs.remove(path.join(input.stageDir, entry), { recursive: true, force: true }); } + // A hoisted install still leaves nested `node_modules/.bin` shim directories + // inside packages that declare bins (msgpackr-extract's). They are symlinks + // nothing runs, and the npm registry refuses a tarball that contains any + // symlink, so strip every `.bin` directory below node_modules. + yield* removeNestedBinDirectories(fs, path, path.join(input.stageDir, "node_modules")); }); +const removeNestedBinDirectories = ( + fs: FileSystem.FileSystem, + path: Path.Path, + root: string, +): Effect.Effect => + Effect.gen(function* () { + const entries = yield* fs.readDirectory(root).pipe(Effect.orElseSucceed(() => [])); + for (const entry of entries) { + const child = path.join(root, entry); + if (entry === ".bin") { + yield* fs.remove(child, { recursive: true, force: true }); + continue; + } + const info = yield* fs.stat(child).pipe(Effect.option); + if (Option.isSome(info) && info.value.type === "Directory") { + yield* removeNestedBinDirectories(fs, path, child); + } + } + }); + /** Copies the web client without its sourcemaps, which nothing serves. */ const stageWebClient = Effect.fn("stageWebClient")(function* (source: string, target: string) { const fs = yield* FileSystem.FileSystem; From 6e850baeac500ee45ac4e24c6939e8b0498b7d05 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:10:29 -0700 Subject: [PATCH 5/7] fix(release): store hard-linked files as plain files in Linux CLI archives pnpm and node-gyp leave hard links in the staged node_modules on Linux, GNU tar records them as link entries, and npm refuses a tarball that carries one. Co-Authored-By: Claude Fable 5 --- scripts/build-cli-archive.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/build-cli-archive.ts b/scripts/build-cli-archive.ts index b6cdd97b11fe..c70ffe4e0e08 100644 --- a/scripts/build-cli-archive.ts +++ b/scripts/build-cli-archive.ts @@ -542,8 +542,20 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: { "tar (zip)", ); } else { + // On Linux, pnpm hard-links identical files out of its store and node-gyp + // hard-links build outputs, and GNU tar records those as link entries. + // The npm registry rejects a tarball containing any, and the npm platform + // packages are re-packed from this archive's contents, so store every + // file as a file. macOS's bsdtar has no such flag; pnpm clones there. yield* runCommand( - ChildProcess.make("tar", ["-czf", archivePath, "-C", stageRoot, stem]), + ChildProcess.make("tar", [ + ...(input.platform === "linux" ? ["--hard-dereference"] : []), + "-czf", + archivePath, + "-C", + stageRoot, + stem, + ]), "tar (gzip)", ); } From 2ceff59741b4022036788e5ff207789638a558e9 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:58:55 -0700 Subject: [PATCH 6/7] docs(user): tell Intel Mac users how to run the server from source Co-Authored-By: Claude Fable 5 --- docs/user/install.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/user/install.md b/docs/user/install.md index a03e6b9c99a3..a4ed171bd986 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -22,6 +22,22 @@ npx t3@latest This starts the server and opens the local web app. Run `npx t3@latest --help` for command-line options. +The executable is built for Apple Silicon Macs, Linux, and Windows. There is +no Intel Mac build of it, because Node cannot produce a single executable for +that platform; the Intel desktop app is unaffected. To run a standalone server +on an Intel Mac, build it from source. You need Node.js 24 and `vp` (see +[Install vp](https://github.com/pingdotgg/t3code#install-vp)): + +```bash +git clone https://github.com/pingdotgg/t3code +cd t3code && vp i && vp run build:desktop +node apps/server/dist/bin.mjs +``` + +A server run this way is a plain Node program: `t3 update` and the background +service do not apply, so update it with `git pull` and a rebuild, and start it +however you run other Node processes. + ## Desktop app Download a release from [GitHub Releases](https://github.com/pingdotgg/t3code/releases), From 5ebde22ce8c9e6df646aef064354466cda2c0d05 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:39:01 -0700 Subject: [PATCH 7/7] fix(release): give each npm platform package its own README npm displays the first README in a tarball when the root has none, which for these packages was ffi-rs's from the bundled node_modules. Co-Authored-By: Claude Fable 5 --- scripts/build-npm-platform-packages.test.ts | 5 ++++ scripts/build-npm-platform-packages.ts | 26 +++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/scripts/build-npm-platform-packages.test.ts b/scripts/build-npm-platform-packages.test.ts index 4590b091699a..2e3a35a0e9c9 100644 --- a/scripts/build-npm-platform-packages.test.ts +++ b/scripts/build-npm-platform-packages.test.ts @@ -128,6 +128,11 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { assert.isUndefined(linuxManifest.bin); // Archive contents sit at the package root, not under the archive stem. assert.isTrue(yield* fs.exists(path.join(linuxDir, "client/index.html"))); + // A root README, or npm would display a bundled dependency's. + assert.include( + yield* fs.readFileString(path.join(linuxDir, "README.md")), + "# @t3code/t3-linux-x64", + ); assert.isTrue(yield* fs.exists(path.join(linuxDir, "node_modules/node-pty"))); assert.equal(Number((yield* fs.stat(path.join(linuxDir, "t3"))).mode) & 0o111, 0o111); diff --git a/scripts/build-npm-platform-packages.ts b/scripts/build-npm-platform-packages.ts index 812420326917..f9417426b82b 100644 --- a/scripts/build-npm-platform-packages.ts +++ b/scripts/build-npm-platform-packages.ts @@ -103,6 +103,28 @@ export function npmPlatformPackageManifest(platformKey: CliArchivePlatformKey, v }; } +/** + * README for one platform package. Without one at the package root, npm + * shows the first README it finds in the tarball, which is a bundled + * dependency's (ffi-rs). + */ +export function npmPlatformPackageReadme(platformKey: CliArchivePlatformKey): string { + return [ + `# ${npmPlatformPackageName(platformKey)}`, + "", + `The T3 Code CLI executable for ${platformKey}. Do not install this package directly:`, + `it is an optional dependency of \`${NPM_LAUNCHER_PACKAGE_NAME}\`, which picks the package for the`, + "current platform and runs the executable inside it.", + "", + "```sh", + `npx ${NPM_LAUNCHER_PACKAGE_NAME}@latest`, + "```", + "", + "Source and documentation: https://github.com/pingdotgg/t3code", + "", + ].join("\n"); +} + /** package.json for the `t3` launcher. No engines: bin/t3.js is trivial CJS. */ export function npmLauncherPackageManifest( version: string, @@ -283,6 +305,10 @@ const stagePlatformPackage = Effect.fn("stagePlatformPackage")(function* (input: path.join(contentDir, "package.json"), `${yield* encodePackageJson(npmPlatformPackageManifest(input.key, input.version))}\n`, ); + yield* fs.writeFileString( + path.join(contentDir, "README.md"), + npmPlatformPackageReadme(input.key), + ); // npm tarballs root everything under `package/`. yield* fs.rename(contentDir, path.join(scratch, "package")); const name = npmPlatformPackageName(input.key);