| author | |
| committer | |
| log | 91cd91c0872bd52be3b45bea70a9539d1ce6adaa |
| tree | 0e4a2d8b944bad2f764d00158d2ad138764ea8f4 |
| parent | 2f7616ef1743de52d24581f7e5724f8f674d9152 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>9 files changed, 820 insertions(+), 262 deletions(-)
.github/workflows/release.yml+39-42| ... | @@ -150,10 +150,11 @@ jobs: | ... | @@ -150,10 +150,11 @@ jobs: |
| 150 | # Manual-only test train: exercises the whole release flow for a | 150 | # Manual-only test train: exercises the whole release flow for a |
| 151 | # commit end users must never receive. Never scheduled. | 151 | # commit end users must never receive. Never scheduled. |
| 152 | # Same versioning as nightly under its own prerelease identifier. | 152 | # Same versioning as nightly under its own prerelease identifier. |
| 153 | # A preview release is reachable only by downloading it by hand: | 153 | # A preview release is reachable only by asking for it: npm gets it |
| 154 | # it is never published to npm, its desktop builds carry no update | 154 | # under the `preview` dist-tag, which nothing resolves by default, |
| 155 | # feed, and no updater manifest is attached to the release, so | 155 | # its desktop builds carry no update feed, and no updater manifest |
| 156 | # neither stable nor nightly installs can ever be offered one. | 156 | # is attached to the release, so neither stable nor nightly |
| 157 | # installs can ever be offered one. | ||
| 157 | nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" | 158 | nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" |
| 158 | 159 | ||
| 159 | node scripts/resolve-nightly-release.ts \ | 160 | node scripts/resolve-nightly-release.ts \ |
| ... | @@ -164,7 +165,7 @@ jobs: | ... | @@ -164,7 +165,7 @@ jobs: |
| 164 | --github-output | 165 | --github-output |
| 165 | 166 | ||
| 166 | echo "release_channel=preview" >> "$GITHUB_OUTPUT" | 167 | echo "release_channel=preview" >> "$GITHUB_OUTPUT" |
| 167 | echo "cli_dist_tag=" >> "$GITHUB_OUTPUT" | 168 | echo "cli_dist_tag=preview" >> "$GITHUB_OUTPUT" |
| 168 | echo "is_prerelease=true" >> "$GITHUB_OUTPUT" | 169 | echo "is_prerelease=true" >> "$GITHUB_OUTPUT" |
| 169 | echo "make_latest=false" >> "$GITHUB_OUTPUT" | 170 | echo "make_latest=false" >> "$GITHUB_OUTPUT" |
| 170 | else | 171 | else |
| ... | @@ -591,22 +592,28 @@ jobs: | ... | @@ -591,22 +592,28 @@ jobs: |
| 591 | resource_key: win32-arm64 | 592 | resource_key: win32-arm64 |
| 592 | cli_archive: true | 593 | cli_archive: true |
| 593 | 594 | ||
| 594 | # Preview releases never reach npm: the archive on the GitHub Release is the | 595 | # npm gets the same bytes as the GitHub Release: the launcher plus one |
| 595 | # only way to obtain one, so no dist-tag can ever resolve to a preview build. | 596 | # package per CLI archive. Preview publishes too, under the `preview` |
| 597 | # dist-tag, which nothing resolves unless asked for by name. | ||
| 596 | publish_cli: | 598 | publish_cli: |
| 597 | name: Publish CLI to npm | 599 | name: Publish CLI to npm |
| 598 | needs: [preflight, relay_public_config, quality, build_bundle] | 600 | needs: |
| 599 | if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.build_bundle.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} | 601 | [ |
| 602 | preflight, | ||
| 603 | relay_public_config, | ||
| 604 | quality, | ||
| 605 | desktop_mac_arm64, | ||
| 606 | desktop_linux_x64, | ||
| 607 | desktop_linux_arm64, | ||
| 608 | desktop_win_x64, | ||
| 609 | desktop_win_arm64, | ||
| 610 | ] | ||
| 611 | if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }} | ||
| 600 | runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 | 612 | runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 |
| 601 | timeout-minutes: 10 | 613 | timeout-minutes: 15 |
| 602 | permissions: | 614 | permissions: |
| 603 | contents: read | 615 | contents: read |
| 604 | id-token: write | 616 | id-token: write |
| 605 | env: | ||
| 606 | T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} | ||
| 607 | T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} | ||
| 608 | T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} | ||
| 609 | T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} | ||
| 610 | steps: | 617 | steps: |
| 611 | - name: Checkout | 618 | - name: Checkout |
| 612 | uses: actions/checkout@v6 | 619 | uses: actions/checkout@v6 |
| ... | @@ -627,38 +634,28 @@ jobs: | ... | @@ -627,38 +634,28 @@ jobs: |
| 627 | - --filter=t3... | 634 | - --filter=t3... |
| 628 | - --filter=@t3tools/scripts... | 635 | - --filter=@t3tools/scripts... |
| 629 | 636 | ||
| 630 | - name: Align package versions to release version | 637 | - name: Download all CLI archives |
| 631 | run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" | ||
| 632 | |||
| 633 | # The artifact root is `apps/` (upload-artifact keeps the least common | ||
| 634 | # ancestor of its paths), so extracting into `apps` restores | ||
| 635 | # apps/server/dist and apps/desktop/dist-electron at their build paths. | ||
| 636 | - name: Download JS bundle | ||
| 637 | uses: actions/download-artifact@v8 | 638 | uses: actions/download-artifact@v8 |
| 638 | with: | 639 | with: |
| 639 | name: js-bundle | 640 | pattern: cli-* |
| 640 | path: apps | 641 | merge-multiple: true |
| 642 | path: release-cli | ||
| 641 | 643 | ||
| 642 | - name: Download resource monitors | 644 | - name: Build npm packages from CLI archives |
| 643 | uses: actions/download-artifact@v8 | 645 | run: node scripts/build-npm-platform-packages.ts --archives-dir release-cli --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages |
| 644 | with: | ||
| 645 | pattern: resource-monitor-* | ||
| 646 | path: ${{ runner.temp }}/resource-monitors | ||
| 647 | 646 | ||
| 648 | - name: Bundle resource monitors into CLI package | 647 | # A dry run of every package first: an auth or scope error here (the |
| 649 | shell: bash | 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) | ||
| 650 | run: | | 651 | run: | |
| 651 | set -euo pipefail | 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 |
| 652 | for artifact_dir in "$RUNNER_TEMP"/resource-monitors/resource-monitor-*; do | 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 |
| 653 | resource_key="${artifact_dir##*/resource-monitor-}" | 654 | exit 1 |
| 654 | target_dir="apps/server/dist/resource-monitor/${resource_key}" | 655 | fi |
| 655 | mkdir -p "$target_dir" | ||
| 656 | cp "$artifact_dir"/t3-resource-monitor* "$target_dir/" | ||
| 657 | chmod +x "$target_dir"/t3-resource-monitor 2>/dev/null || true | ||
| 658 | done | ||
| 659 | 656 | ||
| 660 | - name: Publish CLI package | 657 | - name: Publish CLI packages |
| 661 | run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose | 658 | run: node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --verbose |
| 662 | 659 | ||
| 663 | release: | 660 | release: |
| 664 | name: Publish GitHub Release | 661 | name: Publish GitHub Release |
| ... | @@ -673,7 +670,7 @@ jobs: | ... | @@ -673,7 +670,7 @@ jobs: |
| 673 | desktop_win_arm64, | 670 | desktop_win_arm64, |
| 674 | publish_cli, | 671 | publish_cli, |
| 675 | ] | 672 | ] |
| 676 | if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' && (needs.publish_cli.result == 'success' || (needs.preflight.outputs.release_channel == 'preview' && needs.publish_cli.result == 'skipped')) }} | 673 | if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' && needs.publish_cli.result == 'success' }} |
| 677 | runs-on: blacksmith-8vcpu-ubuntu-2404 | 674 | runs-on: blacksmith-8vcpu-ubuntu-2404 |
| 678 | timeout-minutes: 30 | 675 | timeout-minutes: 30 |
| 679 | permissions: | 676 | permissions: |
apps/server/scripts/cli.ts+49-178| ... | @@ -6,69 +6,24 @@ import * as FileSystem from "effect/FileSystem"; | ... | @@ -6,69 +6,24 @@ import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Logger from "effect/Logger"; | 6 | import * as Logger from "effect/Logger"; |
| 7 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| 8 | import * as Path from "effect/Path"; | 8 | import * as Path from "effect/Path"; |
| 9 | import * as Schema from "effect/Schema"; | ||
| 10 | import { Command, Flag } from "effect/unstable/cli"; | 9 | import { Command, Flag } from "effect/unstable/cli"; |
| 11 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 12 | 11 | ||
| 13 | import { | 12 | import { DEVELOPMENT_ICON_OVERRIDES } from "../../../scripts/lib/brand-assets.ts"; |
| 14 | DEVELOPMENT_ICON_OVERRIDES, | ||
| 15 | resolveWebAssetBrandForPackageVersion, | ||
| 16 | resolveWebIconOverrides, | ||
| 17 | } from "../../../scripts/lib/brand-assets.ts"; | ||
| 18 | import { findEsmImportsOfExternalPackages } from "../../../scripts/lib/cli-external-packages.ts"; | 13 | import { findEsmImportsOfExternalPackages } from "../../../scripts/lib/cli-external-packages.ts"; |
| 19 | import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts"; | ||
| 20 | import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; | ||
| 21 | import { fromYaml } from "@t3tools/shared/schemaYaml"; | ||
| 22 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | 14 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 23 | import serverPackageJson from "../package.json" with { type: "json" }; | ||
| 24 | import { | 15 | import { |
| 25 | ServerCliBuildAssetMissingError, | 16 | ServerCliBuildAssetMissingError, |
| 26 | ServerCliCommandExitError, | 17 | ServerCliCommandExitError, |
| 27 | ServerCliDevelopmentIconSourceMissingError, | 18 | ServerCliDevelopmentIconSourceMissingError, |
| 28 | ServerCliDevelopmentIconTargetMissingError, | 19 | ServerCliDevelopmentIconTargetMissingError, |
| 29 | ServerCliExecutableImportError, | 20 | ServerCliExecutableImportError, |
| 30 | ServerCliPublishIconSourceMissingError, | ||
| 31 | ServerCliPublishIconTargetMissingError, | ||
| 32 | } from "./cliErrors.ts"; | 21 | } from "./cliErrors.ts"; |
| 33 | 22 | ||
| 34 | interface 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 | |||
| 50 | const PackageJsonPrettyJson = fromJsonStringPretty(Schema.Unknown); | ||
| 51 | const encodePackageJson = Schema.encodeEffect(PackageJsonPrettyJson); | ||
| 52 | |||
| 53 | const WorkspaceConfig = Schema.Struct({ | ||
| 54 | catalog: Schema.optional(Schema.Record(Schema.String, Schema.String)), | ||
| 55 | overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)), | ||
| 56 | }); | ||
| 57 | type WorkspaceConfig = typeof WorkspaceConfig.Type; | ||
| 58 | const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig)); | ||
| 59 | |||
| 60 | const RepoRoot = Effect.service(Path.Path).pipe( | 23 | const RepoRoot = Effect.service(Path.Path).pipe( |
| 61 | Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))), | 24 | Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))), |
| 62 | ); | 25 | ); |
| 63 | 26 | ||
| 64 | const 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 | |||
| 72 | const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.StandardCommand) { | 27 | const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.StandardCommand) { |
| 73 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 28 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 74 | const child = yield* spawner.spawn(command); | 29 | const child = yield* spawner.spawn(command); |
| ... | @@ -84,36 +39,6 @@ const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.Stan | ... | @@ -84,36 +39,6 @@ const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.Stan |
| 84 | } | 39 | } |
| 85 | }); | 40 | }); |
| 86 | 41 | ||
| 87 | const 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 | |||
| 117 | const applyDevelopmentIconOverrides = Effect.fn("applyDevelopmentIconOverrides")(function* ( | 42 | const applyDevelopmentIconOverrides = Effect.fn("applyDevelopmentIconOverrides")(function* ( |
| 118 | repoRoot: string, | 43 | repoRoot: string, |
| 119 | serverDir: string, | 44 | serverDir: string, |
| ... | @@ -240,37 +165,21 @@ const buildExeCmd = Command.make( | ... | @@ -240,37 +165,21 @@ const buildExeCmd = Command.make( |
| 240 | // publish subcommand | 165 | // publish subcommand |
| 241 | // --------------------------------------------------------------------------- | 166 | // --------------------------------------------------------------------------- |
| 242 | 167 | ||
| 243 | interface PublishCommandConfig { | 168 | /** |
| 244 | readonly access: string; | 169 | * Publishes the tarballs scripts/build-npm-platform-packages.ts produced: |
| 245 | readonly tag: string; | 170 | * every `@t3code/t3-<platform>.tgz` first, `t3.tgz` (the launcher) last, so |
| 246 | readonly provenance: boolean; | 171 | * the launcher is never installable before the executables it depends on. |
| 247 | readonly dryRun: boolean; | 172 | * Tarballs rather than directories because `npm publish <dir>` strips the |
| 248 | } | 173 | * `node_modules/` the executable loads its native addons from. |
| 249 | 174 | */ | |
| 250 | const 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 | |||
| 268 | const publishCmd = Command.make( | 175 | const publishCmd = Command.make( |
| 269 | "publish", | 176 | "publish", |
| 270 | { | 177 | { |
| 178 | packagesDir: Flag.string("packages-dir").pipe( | ||
| 179 | Flag.withDescription("Output dir of scripts/build-npm-platform-packages.ts."), | ||
| 180 | ), | ||
| 271 | tag: Flag.string("tag").pipe(Flag.withDefault("latest")), | 181 | tag: Flag.string("tag").pipe(Flag.withDefault("latest")), |
| 272 | access: Flag.string("access").pipe(Flag.withDefault("public")), | 182 | access: Flag.string("access").pipe(Flag.withDefault("public")), |
| 273 | appVersion: Flag.string("app-version").pipe(Flag.optional), | ||
| 274 | provenance: Flag.boolean("provenance").pipe(Flag.withDefault(false)), | 183 | provenance: Flag.boolean("provenance").pipe(Flag.withDefault(false)), |
| 275 | dryRun: Flag.boolean("dry-run").pipe(Flag.withDefault(false)), | 184 | dryRun: Flag.boolean("dry-run").pipe(Flag.withDefault(false)), |
| 276 | verbose: Flag.boolean("verbose").pipe(Flag.withDefault(false)), | 185 | verbose: Flag.boolean("verbose").pipe(Flag.withDefault(false)), |
| ... | @@ -279,86 +188,48 @@ const publishCmd = Command.make( | ... | @@ -279,86 +188,48 @@ const publishCmd = Command.make( |
| 279 | Effect.gen(function* () { | 188 | Effect.gen(function* () { |
| 280 | const path = yield* Path.Path; | 189 | const path = yield* Path.Path; |
| 281 | const fs = yield* FileSystem.FileSystem; | 190 | const fs = yield* FileSystem.FileSystem; |
| 282 | const repoRoot = yield* RepoRoot; | 191 | // npm runs with cwd set to the packages dir below, so tarball paths are |
| 283 | const serverDir = path.join(repoRoot, "apps/server"); | 192 | // resolved once here rather than joined twice. |
| 284 | const packageJsonPath = path.join(serverDir, "package.json"); | 193 | const packagesDir = path.resolve(config.packagesDir); |
| 285 | 194 | const scopeDir = path.join(packagesDir, "@t3code"); | |
| 286 | // Assert build assets exist | 195 | const launcherTarball = path.join(packagesDir, "t3.tgz"); |
| 287 | for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) { | 196 | const platformTarballs = (yield* fs |
| 288 | const abs = path.join(serverDir, relPath); | 197 | .readDirectory(scopeDir) |
| 289 | if (!(yield* fs.exists(abs))) { | 198 | .pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []))) |
| 290 | return yield* new ServerCliBuildAssetMissingError({ assetPath: abs }); | 199 | .filter((entry) => entry.startsWith("t3-") && entry.endsWith(".tgz")) |
| 291 | } | 200 | .sort() |
| 201 | .map((entry) => path.join(scopeDir, entry)); | ||
| 202 | if (platformTarballs.length === 0) { | ||
| 203 | return yield* new ServerCliBuildAssetMissingError({ | ||
| 204 | assetPath: path.join(scopeDir, "t3-<platform>.tgz"), | ||
| 205 | }); | ||
| 206 | } | ||
| 207 | if (!(yield* fs.exists(launcherTarball))) { | ||
| 208 | return yield* new ServerCliBuildAssetMissingError({ assetPath: launcherTarball }); | ||
| 292 | } | 209 | } |
| 293 | 210 | ||
| 294 | yield* Effect.acquireUseRelease( | 211 | const args = ["publish", "--access", config.access, "--tag", config.tag]; |
| 295 | // Acquire: resolve publish metadata and read every original before mutation. | 212 | if (config.provenance) args.push("--provenance"); |
| 296 | Effect.gen(function* () { | 213 | if (config.dryRun) args.push("--dry-run"); |
| 297 | const version = Option.getOrElse(config.appVersion, () => serverPackageJson.version); | 214 | |
| 298 | const workspaceConfig = yield* readWorkspaceConfig(); | 215 | for (const tarball of [...platformTarballs, launcherTarball]) { |
| 299 | const workspaceCatalog = workspaceConfig.catalog ?? {}; | 216 | const spawnCommand = yield* resolveSpawnCommand("npm", [...args, tarball]); |
| 300 | const workspaceOverrides = workspaceConfig.overrides ?? {}; | 217 | yield* Effect.log(`[cli] npm ${args.join(" ")} ${path.basename(tarball)}`); |
| 301 | const pkg: PackageJson = { | 218 | yield* runCommand( |
| 302 | name: serverPackageJson.name, | 219 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { |
| 303 | repository: serverPackageJson.repository, | 220 | cwd: packagesDir, |
| 304 | bin: serverPackageJson.bin, | 221 | stdout: config.verbose ? "inherit" : "ignore", |
| 305 | type: serverPackageJson.type, | 222 | stderr: "inherit", |
| 306 | version, | 223 | shell: spawnCommand.shell, |
| 307 | engines: serverPackageJson.engines, | ||
| 308 | files: serverPackageJson.files, | ||
| 309 | dependencies: resolveCatalogDependencies( | ||
| 310 | serverPackageJson.dependencies, | ||
| 311 | workspaceCatalog, | ||
| 312 | "apps/server", | ||
| 313 | ), | ||
| 314 | overrides: resolveCatalogDependencies( | ||
| 315 | workspaceOverrides, | ||
| 316 | workspaceCatalog, | ||
| 317 | "apps/server", | ||
| 318 | ), | ||
| 319 | }; | ||
| 320 | |||
| 321 | return { | ||
| 322 | packageJsonString: yield* encodePackageJson(pkg), | ||
| 323 | originalPackageJson: yield* fs.readFile(packageJsonPath), | ||
| 324 | icons: yield* preparePublishIcons(repoRoot, serverDir, version), | ||
| 325 | }; | ||
| 326 | }), | ||
| 327 | // Use: pnpm publish from the workspace root so pnpm-only workspace | ||
| 328 | // config, including override selectors, is interpreted correctly. | ||
| 329 | (resource) => | ||
| 330 | Effect.gen(function* () { | ||
| 331 | yield* fs.writeFileString(packageJsonPath, `${resource.packageJsonString}\n`); | ||
| 332 | for (const icon of resource.icons) { | ||
| 333 | yield* fs.writeFile(icon.targetPath, icon.publish); | ||
| 334 | } | ||
| 335 | yield* Effect.log("[cli] Applied package metadata and publish icon overrides"); | ||
| 336 | |||
| 337 | const args = createVpPmPublishArgs(config); | ||
| 338 | const spawnCommand = yield* resolveSpawnCommand("vp", ["pm", ...args]); | ||
| 339 | |||
| 340 | yield* Effect.log(`[cli] Running: vp pm ${args.join(" ")}`); | ||
| 341 | yield* runCommand( | ||
| 342 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | ||
| 343 | cwd: repoRoot, | ||
| 344 | stdout: config.verbose ? "inherit" : "ignore", | ||
| 345 | stderr: "inherit", | ||
| 346 | shell: spawnCommand.shell, | ||
| 347 | }), | ||
| 348 | ); | ||
| 349 | }), | 224 | }), |
| 350 | // Release: restore every file even if applying overrides or publishing fails. | 225 | ); |
| 351 | (resource) => | 226 | } |
| 352 | Effect.gen(function* () { | ||
| 353 | yield* fs.writeFile(packageJsonPath, resource.originalPackageJson); | ||
| 354 | for (const icon of resource.icons) { | ||
| 355 | yield* fs.writeFile(icon.targetPath, icon.original); | ||
| 356 | } | ||
| 357 | if (config.verbose) yield* Effect.log("[cli] Restored original publish assets"); | ||
| 358 | }), | ||
| 359 | ); | ||
| 360 | }), | 227 | }), |
| 361 | ).pipe(Command.withDescription("Publish the server package to npm.")); | 228 | ).pipe( |
| 229 | Command.withDescription( | ||
| 230 | "Publish the @t3code/t3-<platform> tarballs and then the t3 launcher to npm.", | ||
| 231 | ), | ||
| 232 | ); | ||
| 362 | 233 | ||
| 363 | // --------------------------------------------------------------------------- | 234 | // --------------------------------------------------------------------------- |
| 364 | // root command | 235 | // root command |
apps/server/scripts/cliErrors.ts-22| ... | @@ -14,28 +14,6 @@ export class ServerCliCommandExitError extends Schema.TaggedError<ServerCliComma | ... | @@ -14,28 +14,6 @@ export class ServerCliCommandExitError extends Schema.TaggedError<ServerCliComma |
| 14 | } | 14 | } |
| 15 | } | 15 | } |
| 16 | 16 | ||
| 17 | export 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 | |||
| 28 | export 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 | |||
| 39 | export class ServerCliDevelopmentIconSourceMissingError extends Schema.TaggedError<ServerCliDevelopmentIconSourceMissingError>()( | 17 | export class ServerCliDevelopmentIconSourceMissingError extends Schema.TaggedError<ServerCliDevelopmentIconSourceMissingError>()( |
| 40 | "ServerCliDevelopmentIconSourceMissingError", | 18 | "ServerCliDevelopmentIconSourceMissingError", |
| 41 | { | 19 | { |
docs/operations/release.md+26-16| ... | @@ -12,7 +12,7 @@ This document covers the unified release workflow for stable and nightly desktop | ... | @@ -12,7 +12,7 @@ This document covers the unified release workflow for stable and nightly desktop |
| 12 | - push tag matching `v*.*.*` for a stable release of an explicit commit | 12 | - push tag matching `v*.*.*` for a stable release of an explicit commit |
| 13 | - scheduled nightly check every 30 minutes | 13 | - scheduled nightly check every 30 minutes |
| 14 | - manual `workflow_dispatch` with `channel=nightly` | 14 | - manual `workflow_dispatch` with `channel=nightly` |
| 15 | - manual `workflow_dispatch` with `channel=preview`, the maintainers' test train. It exercises the whole release flow (build, sign, notarize, smoke, publish) for a commit that end users must never receive, which is how an unmerged branch or a risky change gets a real release run before it lands. It builds the triggering commit with nightly's versioning under the `preview` prerelease identifier (`0.0.41-preview.<date>.<run>`) and publishes only a GitHub prerelease. Nothing ever selects preview on its own: it is not on the schedule, not published to npm, its desktop builds carry no update feed, and no updater manifest (`latest*.yml`, `nightly*.yml`, blockmaps) is attached, so a stable or nightly install cannot be offered one. The only ways onto it are downloading the release by hand, `T3CODE_CHANNEL=preview` for the install scripts, or `t3 update --channel preview` from a terminal; each prints a warning, and the CLI asks for confirmation when the running build is not itself a preview. The release itself is named as a maintainer test build and its body is a warning rather than generated notes: a changelog of unmerged branch history is not a changelog, and nightly and stable notes are unaffected because each series resolves its previous tag within its own channel. The hosted web app, AUR, and Discord announcements are skipped. Keep it; it costs nothing when idle. | 15 | - manual `workflow_dispatch` with `channel=preview`, the maintainers' test train. It exercises the whole release flow (build, sign, notarize, smoke, publish) for a commit that end users must never receive, which is how an unmerged branch or a risky change gets a real release run before it lands. It builds the triggering commit with nightly's versioning under the `preview` prerelease identifier (`0.0.41-preview.<date>.<run>`) and publishes a GitHub prerelease plus the npm packages under the `preview` dist-tag. Nothing ever selects preview on its own: it is not on the schedule, no default npm dist-tag points at it, its desktop builds carry no update feed, and no updater manifest (`latest*.yml`, `nightly*.yml`, blockmaps) is attached, so a stable or nightly install cannot be offered one. The only ways onto it are downloading the release by hand, `npx t3@preview`, `T3CODE_CHANNEL=preview` for the install scripts, or `t3 update --channel preview` from a terminal; each prints a warning, and the CLI asks for confirmation when the running build is not itself a preview. The release itself is named as a maintainer test build and its body is a warning rather than generated notes: a changelog of unmerged branch history is not a changelog, and nightly and stable notes are unaffected because each series resolves its previous tag within its own channel. The hosted web app, AUR, and Discord announcements are skipped. Keep it; it costs nothing when idle. |
| 16 | - A manual stable release builds the commit of the latest published nightly, not `main` HEAD. | 16 | - A manual stable release builds the commit of the latest published nightly, not `main` HEAD. |
| 17 | Nightly is the release candidate: verify the nightly, then promote it. Merges to `main` keep | 17 | Nightly is the release candidate: verify the nightly, then promote it. Merges to `main` keep |
| 18 | landing while you verify and never leak into the stable build. | 18 | landing while you verify and never leak into the stable build. |
| ... | @@ -36,14 +36,15 @@ This document covers the unified release workflow for stable and nightly desktop | ... | @@ -36,14 +36,15 @@ This document covers the unified release workflow for stable and nightly desktop |
| 36 | - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag. | 36 | - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag. |
| 37 | - Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets. | 37 | - Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets. |
| 38 | - Builds a self-contained CLI archive per platform (`t3-<version>-<platform>-<arch>.tar.gz`, `.zip` on Windows) in the same job as that target's desktop artifact and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel, for five targets: macOS arm64, Linux x64 and arm64, Windows x64 and arm64. Every archive is built, signed, and smoke-tested on hardware of its own architecture. There is no macOS x64 archive: Node single-executables are unsupported on x64 macOS (the SEA docs list macOS as arm64 only) and the binary segfaults on start; the x64 desktop app is Electron and unaffected. | 38 | - Builds a self-contained CLI archive per platform (`t3-<version>-<platform>-<arch>.tar.gz`, `.zip` on Windows) in the same job as that target's desktop artifact and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel, for five targets: macOS arm64, Linux x64 and arm64, Windows x64 and arm64. Every archive is built, signed, and smoke-tested on hardware of its own architecture. There is no macOS x64 archive: Node single-executables are unsupported on x64 macOS (the SEA docs list macOS as arm64 only) and the binary segfaults on start; the x64 desktop app is Electron and unaffected. |
| 39 | - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the only form in which T3 Code manages a runtime: the desktop's SSH environments, the boot service, `t3 update`, and the install scripts all download and verify this archive against `SHA256SUMS`. The npm package exists for people who run `npx t3` or `npm install -g t3` themselves; nothing in the product installs from npm. The `curl | sh` installers are `scripts/install.sh` and `scripts/install.ps1`; the marketing site copies them into its `public/` at build time (`apps/marketing/scripts/stage-install-scripts.mjs`) and serves them at `t3.codes/install.sh` and `/install.ps1`. | 39 | - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the only form in which T3 Code manages a runtime: the desktop's SSH environments, the boot service, `t3 update`, and the install scripts all download and verify this archive against `SHA256SUMS`. The npm packages exist for people who run `npx t3` or `npm install -g t3` themselves and carry the same archive contents; nothing in the product installs from npm. The `curl | sh` installers are `scripts/install.sh` and `scripts/install.ps1`; the marketing site copies them into its `public/` at build time (`apps/marketing/scripts/stage-install-scripts.mjs`) and serves them at `t3.codes/install.sh` and `/install.ps1`. |
| 40 | - The executable is built with a Node that supports `--build-sea` (`VP_NODE_VERSION=26.8.2`, kept in step with `SEA_NODE_VERSION` in `apps/server/vite.config.ts`), while the repo stays on `engines.node`. | 40 | - The executable is built with a Node that supports `--build-sea` (`VP_NODE_VERSION=26.8.2`, kept in step with `SEA_NODE_VERSION` in `apps/server/vite.config.ts`), while the repo stays on `engines.node`. |
| 41 | - macOS archives are signed with the Developer ID certificate and notarized when the Apple secrets are present (ad hoc otherwise, which still runs from `curl`/`tar` installs). Windows executables use the same Azure Trusted Signing setup as the installer. Every native addon in the macOS archive is signed too, since the hardened runtime refuses unsigned libraries. | 41 | - macOS archives are signed with the Developer ID certificate and notarized when the Apple secrets are present (ad hoc otherwise, which still runs from `curl`/`tar` installs). Windows executables use the same Azure Trusted Signing setup as the installer. Every native addon in the macOS archive is signed too, since the hardened runtime refuses unsigned libraries. |
| 42 | - Each archive is extracted and executed on its build runner (`scripts/smoke-cli-archive.ts`) before it is uploaded. | 42 | - Each archive is extracted and executed on its build runner (`scripts/smoke-cli-archive.ts`) before it is uploaded. |
| 43 | - Publishes the CLI package (`apps/server`, npm package `t3`) with OIDC trusted publishing from the same workflow file: | 43 | - Publishes the CLI to npm with OIDC trusted publishing from the same workflow file, as the same bytes the GitHub Release carries: `scripts/build-npm-platform-packages.ts` unpacks the five CLI archives into `@t3code/t3-<platform>-<arch>` packages (each with `os`/`cpu` set so npm installs only the matching one) and generates the `t3` launcher, whose `bin/t3.js` lists them as `optionalDependencies` and execs the installed executable. `npx t3` therefore needs Node only to run the launcher, never to run the server. `node apps/server/scripts/cli.ts publish` publishes the platform packages first and the launcher last, after a `--dry-run` pass over all of them so an auth or scope error fails before anything is live. |
| 44 | - stable releases publish npm dist-tag `latest` | 44 | - stable releases publish npm dist-tag `latest` |
| 45 | - nightly releases publish npm dist-tag `nightly` | 45 | - nightly releases publish npm dist-tag `nightly` |
| 46 | - preview releases are not published to 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). | ||
| 47 | - Deploys the hosted web app to Vercel only after a release is published: | 48 | - Deploys the hosted web app to Vercel only after a release is published: |
| 48 | - stable releases are aliased to the `latest` hosted app channel | 49 | - stable releases are aliased to the `latest` hosted app channel |
| 49 | - nightly releases are aliased to the `nightly` hosted app channel | 50 | - nightly releases are aliased to the `nightly` hosted app channel |
| ... | @@ -198,7 +199,7 @@ One-time Vercel dashboard setup: | ... | @@ -198,7 +199,7 @@ One-time Vercel dashboard setup: |
| 198 | - `make_latest` is always `false` | 199 | - `make_latest` is always `false` |
| 199 | - Uses the next stable patch version as the nightly base. For example, `0.0.17` produces nightlies on `0.0.18-nightly.*`. | 200 | - Uses the next stable patch version as the nightly base. For example, `0.0.17` produces nightlies on `0.0.18-nightly.*`. |
| 200 | - Publishes Electron auto-update metadata to the dedicated `nightly` updater channel, so desktop users can opt into that track independently from stable. | 201 | - Publishes Electron auto-update metadata to the dedicated `nightly` updater channel, so desktop users can opt into that track independently from stable. |
| 201 | - Publishes the CLI package (`apps/server`, npm package `t3`) to the `nightly` npm dist-tag using the same nightly version. | 202 | - Publishes the CLI npm packages (`t3` and `@t3code/t3-<platform>-<arch>`) to the `nightly` npm dist-tag using the same nightly version. |
| 202 | - Does not commit version bumps back to `main`. | 203 | - Does not commit version bumps back to `main`. |
| 203 | 204 | ||
| 204 | ## Server self-update release invariant | 205 | ## Server self-update release invariant |
| ... | @@ -209,7 +210,7 @@ npm before users can receive that client. | ... | @@ -209,7 +210,7 @@ npm before users can receive that client. |
| 209 | 210 | ||
| 210 | The workflow enforces this ordering: | 211 | The workflow enforces this ordering: |
| 211 | 212 | ||
| 212 | 1. `publish_cli` publishes the exact stable or nightly version to npm. | 213 | 1. `publish_cli` publishes the exact release version to npm, on every channel. |
| 213 | 2. `release` depends on `publish_cli` before exposing desktop artifacts in GitHub Releases. | 214 | 2. `release` depends on `publish_cli` before exposing desktop artifacts in GitHub Releases. |
| 214 | 3. `deploy_web` depends on `release` before moving the hosted channel to the new client. | 215 | 3. `deploy_web` depends on `release` before moving the hosted channel to the new client. |
| 215 | 216 | ||
| ... | @@ -294,24 +295,33 @@ blockmaps, with a 60 MB maximum for a representative sidecar-to-sidecar update. | ... | @@ -294,24 +295,33 @@ blockmaps, with a 60 MB maximum for a representative sidecar-to-sidecar update. |
| 294 | 295 | ||
| 295 | ## 0) npm OIDC trusted publishing setup (CLI) | 296 | ## 0) npm OIDC trusted publishing setup (CLI) |
| 296 | 297 | ||
| 297 | The workflow invokes `node apps/server/scripts/cli.ts publish` after aligning package versions. That | 298 | The workflow runs `node scripts/build-npm-platform-packages.ts` on the downloaded CLI archives, then |
| 298 | script temporarily prepares the `t3` package, then runs `vp pm publish --filter t3 ...` from the | 299 | `node apps/server/scripts/cli.ts publish --packages-dir npm-packages`, which runs `npm publish` on |
| 299 | repository root so workspace publish configuration is applied correctly. | 300 | each `@t3code/t3-<platform>-<arch>.tgz` and finally on `t3.tgz`, the launcher. The script publishes |
| 301 | tarballs it built itself rather than directories: `npm publish <dir>` strips `node_modules/` from the | ||
| 302 | tarball no matter what `files` says, and the executable loads its native addons from there. Seven | ||
| 303 | packages 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`. | ||
| 300 | 306 | ||
| 301 | Checklist: | 307 | Checklist: |
| 302 | 308 | ||
| 303 | 1. Confirm npm org/user owns package `t3` (or rename package first if needed). | 309 | 1. Confirm the npm org owns package `t3` and the `@t3code` scope exists on npm (create the org if |
| 304 | 2. In npm package settings, configure Trusted Publisher: | 310 | it does not). |
| 311 | 2. For `t3` and each `@t3code/t3-<platform>-<arch>` package, configure a Trusted Publisher in the | ||
| 312 | npm package settings (a package that has never been published needs a first publish or a | ||
| 313 | placeholder before the setting exists; the `--dry-run` step in `publish_cli` reports which | ||
| 314 | names are still rejected): | ||
| 305 | - Provider: GitHub Actions | 315 | - Provider: GitHub Actions |
| 306 | - Repository: this repo | 316 | - Repository: this repo |
| 307 | - Workflow file: `.github/workflows/release.yml` | 317 | - Workflow file: `.github/workflows/release.yml` |
| 308 | - Environment (if used): match your npm trusted publishing config | 318 | - Environment (if used): match your npm trusted publishing config |
| 309 | 3. Ensure npm account and org policies allow trusted publishing for the package. | 319 | 3. Ensure npm account and org policies allow trusted publishing for every package. |
| 310 | 4. Create release tag `vX.Y.Z` and push; workflow will: | 320 | 4. Create release tag `vX.Y.Z` and push; workflow will: |
| 311 | - align the release package versions to `X.Y.Z` | 321 | - build and smoke-test the five CLI archives |
| 312 | - build web + server | 322 | - build the npm packages from those archives |
| 313 | - invoke the CLI publish script with npm dist-tag `latest` | 323 | - publish them with npm dist-tag `latest` |
| 314 | 5. Nightly runs invoke the same publish script with npm dist-tag `nightly`. | 324 | 5. Nightly runs publish with npm dist-tag `nightly`; preview runs with `preview`. |
| 315 | 325 | ||
| 316 | ## 1) Release validation and unsigned builds | 326 | ## 1) Release validation and unsigned builds |
| 317 | 327 |
docs/user/install.md+20-2| ... | @@ -5,8 +5,10 @@ desktop, web, or mobile app. Set up the machine where the agents will work first | ... | @@ -5,8 +5,10 @@ desktop, web, or mobile app. Set up the machine where the agents will work first |
| 5 | 5 | ||
| 6 | ## Requirements | 6 | ## Requirements |
| 7 | 7 | ||
| 8 | Command-line use, SSH hosts, and WSL backends need Node.js 22.16+ (22.x), 23.11+ | 8 | `npx t3` needs Node.js only to run npm itself; the CLI it installs is a |
| 9 | (23.x), or 24.10 and later. The native desktop app includes its server runtime. | 9 | self-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 | ||
| 11 | server runtime. | ||
| 10 | 12 | ||
| 11 | You need an installed, authenticated provider before starting a thread. You can | 13 | You need an installed, authenticated provider before starting a thread. You can |
| 12 | launch T3 Code and configure providers afterwards. | 14 | launch T3 Code and configure providers afterwards. |
| ... | @@ -20,6 +22,22 @@ npx t3@latest | ... | @@ -20,6 +22,22 @@ npx t3@latest |
| 20 | This starts the server and opens the local web app. Run | 22 | This starts the server and opens the local web app. Run |
| 21 | `npx t3@latest --help` for command-line options. | 23 | `npx t3@latest --help` for command-line options. |
| 22 | 24 | ||
| 25 | The executable is built for Apple Silicon Macs, Linux, and Windows. There is | ||
| 26 | no Intel Mac build of it, because Node cannot produce a single executable for | ||
| 27 | that platform; the Intel desktop app is unaffected. To run a standalone server | ||
| 28 | on 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 | ||
| 32 | git clone https://github.com/pingdotgg/t3code | ||
| 33 | cd t3code && vp i && vp run build:desktop | ||
| 34 | node apps/server/dist/bin.mjs | ||
| 35 | ``` | ||
| 36 | |||
| 37 | A server run this way is a plain Node program: `t3 update` and the background | ||
| 38 | service do not apply, so update it with `git pull` and a rebuild, and start it | ||
| 39 | however you run other Node processes. | ||
| 40 | |||
| 23 | ## Desktop app | 41 | ## Desktop app |
| 24 | 42 | ||
| 25 | Download a release from [GitHub Releases](https://github.com/pingdotgg/t3code/releases), | 43 | Download a release from [GitHub Releases](https://github.com/pingdotgg/t3code/releases), |
packages/shared/src/cliRelease.ts+1-1| ... | @@ -19,7 +19,7 @@ export const CLI_RELEASE_BASE_URL_ENV = "T3CODE_RELEASE_BASE_URL"; | ... | @@ -19,7 +19,7 @@ export const CLI_RELEASE_BASE_URL_ENV = "T3CODE_RELEASE_BASE_URL"; |
| 19 | */ | 19 | */ |
| 20 | // No darwin-x64: Node single-executables are unsupported on x64 macOS (the | 20 | // No darwin-x64: Node single-executables are unsupported on x64 macOS (the |
| 21 | // SEA docs list macOS as arm64 only) and the binary segfaults on start. | 21 | // SEA docs list macOS as arm64 only) and the binary segfaults on start. |
| 22 | const CLI_ARCHIVE_PLATFORM_KEYS = [ | 22 | export const CLI_ARCHIVE_PLATFORM_KEYS = [ |
| 23 | "darwin-arm64", | 23 | "darwin-arm64", |
| 24 | "linux-arm64", | 24 | "linux-arm64", |
| 25 | "linux-x64", | 25 | "linux-x64", |
scripts/build-cli-archive.ts+39-1| ... | @@ -23,6 +23,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -23,6 +23,7 @@ import * as Layer from "effect/Layer"; |
| 23 | import * as Logger from "effect/Logger"; | 23 | import * as Logger from "effect/Logger"; |
| 24 | import * as Option from "effect/Option"; | 24 | import * as Option from "effect/Option"; |
| 25 | import * as Path from "effect/Path"; | 25 | import * as Path from "effect/Path"; |
| 26 | import * as PlatformError from "effect/PlatformError"; | ||
| 26 | import * as Schema from "effect/Schema"; | 27 | import * as Schema from "effect/Schema"; |
| 27 | import { Command, Flag } from "effect/unstable/cli"; | 28 | import { Command, Flag } from "effect/unstable/cli"; |
| 28 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 29 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| ... | @@ -241,8 +242,33 @@ const stageRuntimeExternals = Effect.fn("stageRuntimeExternals")(function* (inpu | ... | @@ -241,8 +242,33 @@ const stageRuntimeExternals = Effect.fn("stageRuntimeExternals")(function* (inpu |
| 241 | ]) { | 242 | ]) { |
| 242 | yield* fs.remove(path.join(input.stageDir, entry), { recursive: true, force: true }); | 243 | yield* fs.remove(path.join(input.stageDir, entry), { recursive: true, force: true }); |
| 243 | } | 244 | } |
| 245 | // A hoisted install still leaves nested `node_modules/.bin` shim directories | ||
| 246 | // inside packages that declare bins (msgpackr-extract's). They are symlinks | ||
| 247 | // nothing runs, and the npm registry refuses a tarball that contains any | ||
| 248 | // symlink, so strip every `.bin` directory below node_modules. | ||
| 249 | yield* removeNestedBinDirectories(fs, path, path.join(input.stageDir, "node_modules")); | ||
| 244 | }); | 250 | }); |
| 245 | 251 | ||
| 252 | const removeNestedBinDirectories = ( | ||
| 253 | fs: FileSystem.FileSystem, | ||
| 254 | path: Path.Path, | ||
| 255 | root: string, | ||
| 256 | ): Effect.Effect<void, PlatformError.PlatformError> => | ||
| 257 | Effect.gen(function* () { | ||
| 258 | const entries = yield* fs.readDirectory(root).pipe(Effect.orElseSucceed(() => [])); | ||
| 259 | for (const entry of entries) { | ||
| 260 | const child = path.join(root, entry); | ||
| 261 | if (entry === ".bin") { | ||
| 262 | yield* fs.remove(child, { recursive: true, force: true }); | ||
| 263 | continue; | ||
| 264 | } | ||
| 265 | const info = yield* fs.stat(child).pipe(Effect.option); | ||
| 266 | if (Option.isSome(info) && info.value.type === "Directory") { | ||
| 267 | yield* removeNestedBinDirectories(fs, path, child); | ||
| 268 | } | ||
| 269 | } | ||
| 270 | }); | ||
| 271 | |||
| 246 | /** Copies the web client without its sourcemaps, which nothing serves. */ | 272 | /** Copies the web client without its sourcemaps, which nothing serves. */ |
| 247 | const stageWebClient = Effect.fn("stageWebClient")(function* (source: string, target: string) { | 273 | const stageWebClient = Effect.fn("stageWebClient")(function* (source: string, target: string) { |
| 248 | const fs = yield* FileSystem.FileSystem; | 274 | const fs = yield* FileSystem.FileSystem; |
| ... | @@ -516,8 +542,20 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: { | ... | @@ -516,8 +542,20 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: { |
| 516 | "tar (zip)", | 542 | "tar (zip)", |
| 517 | ); | 543 | ); |
| 518 | } else { | 544 | } else { |
| 545 | // On Linux, pnpm hard-links identical files out of its store and node-gyp | ||
| 546 | // hard-links build outputs, and GNU tar records those as link entries. | ||
| 547 | // The npm registry rejects a tarball containing any, and the npm platform | ||
| 548 | // packages are re-packed from this archive's contents, so store every | ||
| 549 | // file as a file. macOS's bsdtar has no such flag; pnpm clones there. | ||
| 519 | yield* runCommand( | 550 | yield* runCommand( |
| 520 | ChildProcess.make("tar", ["-czf", archivePath, "-C", stageRoot, stem]), | 551 | ChildProcess.make("tar", [ |
| 552 | ...(input.platform === "linux" ? ["--hard-dereference"] : []), | ||
| 553 | "-czf", | ||
| 554 | archivePath, | ||
| 555 | "-C", | ||
| 556 | stageRoot, | ||
| 557 | stem, | ||
| 558 | ]), | ||
| 521 | "tar (gzip)", | 559 | "tar (gzip)", |
| 522 | ); | 560 | ); |
| 523 | } | 561 | } |
scripts/build-npm-platform-packages.test.ts created+203| ... | @@ -0,0 +1,203 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import * as Path from "effect/Path"; | ||
| 6 | import * as Schema from "effect/Schema"; | ||
| 7 | import * as Stream from "effect/Stream"; | ||
| 8 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 9 | |||
| 10 | import { | ||
| 11 | buildNpmPlatformPackages, | ||
| 12 | NpmPackagesArchivesMissingError, | ||
| 13 | } from "./build-npm-platform-packages.ts"; | ||
| 14 | |||
| 15 | const VERSION = "1.2.3"; | ||
| 16 | const decodeManifest = Schema.decodeEffect( | ||
| 17 | Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), | ||
| 18 | ); | ||
| 19 | const KEYS = ["linux-x64", "darwin-arm64"] as const; | ||
| 20 | |||
| 21 | const 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 | |||
| 30 | const 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. */ | ||
| 47 | const 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 | |||
| 75 | it.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 | */ | ||
| 22 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | ||
| 23 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 24 | import * as Effect from "effect/Effect"; | ||
| 25 | import * as FileSystem from "effect/FileSystem"; | ||
| 26 | import * as Layer from "effect/Layer"; | ||
| 27 | import * as Logger from "effect/Logger"; | ||
| 28 | import * as Path from "effect/Path"; | ||
| 29 | import * as Schema from "effect/Schema"; | ||
| 30 | import { Command, Flag } from "effect/unstable/cli"; | ||
| 31 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 32 | |||
| 33 | import { | ||
| 34 | CLI_ARCHIVE_PLATFORM_KEYS, | ||
| 35 | cliArchiveFileName, | ||
| 36 | type CliArchivePlatformKey, | ||
| 37 | } from "@t3tools/shared/cliRelease"; | ||
| 38 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 39 | import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; | ||
| 40 | import { isCommandAvailable } from "@t3tools/shared/shell"; | ||
| 41 | import serverPackageJson from "../apps/server/package.json" with { type: "json" }; | ||
| 42 | |||
| 43 | import { windowsSystemTar } from "./build-cli-archive.ts"; | ||
| 44 | |||
| 45 | export const NPM_PLATFORM_PACKAGE_SCOPE = "@t3code"; | ||
| 46 | export const NPM_LAUNCHER_PACKAGE_NAME = "t3"; | ||
| 47 | |||
| 48 | const encodePackageJson = Schema.encodeEffect(fromJsonStringPretty(Schema.Unknown)); | ||
| 49 | |||
| 50 | export 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 | |||
| 59 | export 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 | |||
| 68 | export 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 | |||
| 77 | export 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 | |||
| 86 | export 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. */ | ||
| 91 | export 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 | */ | ||
| 111 | export 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. */ | ||
| 129 | export 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 | */ | ||
| 152 | export const NPM_LAUNCHER_SCRIPT = `#!/usr/bin/env node | ||
| 153 | "use strict"; | ||
| 154 | const { spawnSync } = require("node:child_process"); | ||
| 155 | const { constants } = require("node:os"); | ||
| 156 | const { dirname, join } = require("node:path"); | ||
| 157 | |||
| 158 | const SUPPORTED = [${CLI_ARCHIVE_PLATFORM_KEYS.map((key) => `"${key}"`).join(", ")}]; | ||
| 159 | const key = process.platform + "-" + process.arch; | ||
| 160 | |||
| 161 | let packageDir; | ||
| 162 | try { | ||
| 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 | |||
| 177 | const executable = join(packageDir, process.platform === "win32" ? "t3.exe" : "t3"); | ||
| 178 | const result = spawnSync(executable, process.argv.slice(2), { stdio: "inherit" }); | ||
| 179 | if (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. | ||
| 184 | process.exit(result.status ?? 128 + (constants.signals[result.signal] || 1)); | ||
| 185 | `; | ||
| 186 | |||
| 187 | const 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 | */ | ||
| 210 | const 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. */ | ||
| 242 | const 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 | */ | ||
| 250 | const 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 | |||
| 265 | export 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 | */ | ||
| 276 | const 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. */ | ||
| 325 | const 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 | |||
| 358 | export 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 | |||
| 414 | const 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 | |||
| 438 | if (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 | } | ||