| 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 | 150 | # Manual-only test train: exercises the whole release flow for a |
| 151 | 151 | # commit end users must never receive. Never scheduled. |
| 152 | 152 | # Same versioning as nightly under its own prerelease identifier. |
| 153 | # A preview release is reachable only by downloading it by hand: | |
| 154 | # it is never published to npm, its desktop builds carry no update | |
| 155 | # feed, and no updater manifest is attached to the release, so | |
| 156 | # neither stable nor nightly installs can ever be offered one. | |
| 153 | # A preview release is reachable only by asking for it: npm gets it | |
| 154 | # under the `preview` dist-tag, which nothing resolves by default, | |
| 155 | # its desktop builds carry no update feed, and no updater manifest | |
| 156 | # is attached to the release, so neither stable nor nightly | |
| 157 | # installs can ever be offered one. | |
| 157 | 158 | nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" |
| 158 | 159 | |
| 159 | 160 | node scripts/resolve-nightly-release.ts \ |
| ... | ... | @@ -164,7 +165,7 @@ jobs: |
| 164 | 165 | --github-output |
| 165 | 166 | |
| 166 | 167 | echo "release_channel=preview" >> "$GITHUB_OUTPUT" |
| 167 | echo "cli_dist_tag=" >> "$GITHUB_OUTPUT" | |
| 168 | echo "cli_dist_tag=preview" >> "$GITHUB_OUTPUT" | |
| 168 | 169 | echo "is_prerelease=true" >> "$GITHUB_OUTPUT" |
| 169 | 170 | echo "make_latest=false" >> "$GITHUB_OUTPUT" |
| 170 | 171 | else |
| ... | ... | @@ -591,22 +592,28 @@ jobs: |
| 591 | 592 | resource_key: win32-arm64 |
| 592 | 593 | cli_archive: true |
| 593 | 594 | |
| 594 | # Preview releases never reach npm: the archive on the GitHub Release is the | |
| 595 | # only way to obtain one, so no dist-tag can ever resolve to a preview build. | |
| 595 | # npm gets the same bytes as the GitHub Release: the launcher plus one | |
| 596 | # package per CLI archive. Preview publishes too, under the `preview` | |
| 597 | # dist-tag, which nothing resolves unless asked for by name. | |
| 596 | 598 | publish_cli: |
| 597 | 599 | name: Publish CLI to npm |
| 598 | needs: [preflight, relay_public_config, quality, build_bundle] | |
| 599 | if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.build_bundle.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} | |
| 600 | needs: | |
| 601 | [ | |
| 602 | preflight, | |
| 603 | relay_public_config, | |
| 604 | quality, | |
| 605 | desktop_mac_arm64, | |
| 606 | desktop_linux_x64, | |
| 607 | desktop_linux_arm64, | |
| 608 | desktop_win_x64, | |
| 609 | desktop_win_arm64, | |
| 610 | ] | |
| 611 | if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }} | |
| 600 | 612 | runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 |
| 601 | timeout-minutes: 10 | |
| 613 | timeout-minutes: 15 | |
| 602 | 614 | permissions: |
| 603 | 615 | contents: read |
| 604 | 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 | 617 | steps: |
| 611 | 618 | - name: Checkout |
| 612 | 619 | uses: actions/checkout@v6 |
| ... | ... | @@ -627,38 +634,28 @@ jobs: |
| 627 | 634 | - --filter=t3... |
| 628 | 635 | - --filter=@t3tools/scripts... |
| 629 | 636 | |
| 630 | - name: Align package versions to release version | |
| 631 | run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" | |
| 632 | ||
| 633 | # The artifact root is `apps/` (upload-artifact keeps the least common | |
| 634 | # ancestor of its paths), so extracting into `apps` restores | |
| 635 | # apps/server/dist and apps/desktop/dist-electron at their build paths. | |
| 636 | - name: Download JS bundle | |
| 637 | - name: Download all CLI archives | |
| 637 | 638 | uses: actions/download-artifact@v8 |
| 638 | 639 | with: |
| 639 | name: js-bundle | |
| 640 | path: apps | |
| 640 | pattern: cli-* | |
| 641 | merge-multiple: true | |
| 642 | path: release-cli | |
| 641 | 643 | |
| 642 | - name: Download resource monitors | |
| 643 | uses: actions/download-artifact@v8 | |
| 644 | with: | |
| 645 | pattern: resource-monitor-* | |
| 646 | path: ${{ runner.temp }}/resource-monitors | |
| 644 | - name: Build npm packages from CLI archives | |
| 645 | run: node scripts/build-npm-platform-packages.ts --archives-dir release-cli --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages | |
| 647 | 646 | |
| 648 | - name: Bundle resource monitors into CLI package | |
| 649 | shell: bash | |
| 647 | # A dry run of every package first: an auth or scope error here (the | |
| 648 | # @t3code org missing, a package without a trusted publisher) fails | |
| 649 | # before anything is live, instead of after some platforms already are. | |
| 650 | - name: Check npm publish access (dry run) | |
| 650 | 651 | run: | |
| 651 | set -euo pipefail | |
| 652 | for artifact_dir in "$RUNNER_TEMP"/resource-monitors/resource-monitor-*; do | |
| 653 | resource_key="${artifact_dir##*/resource-monitor-}" | |
| 654 | target_dir="apps/server/dist/resource-monitor/${resource_key}" | |
| 655 | mkdir -p "$target_dir" | |
| 656 | cp "$artifact_dir"/t3-resource-monitor* "$target_dir/" | |
| 657 | chmod +x "$target_dir"/t3-resource-monitor 2>/dev/null || true | |
| 658 | done | |
| 652 | if ! node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --dry-run --verbose; then | |
| 653 | echo "::error::npm publish --dry-run failed. Make sure the @t3code npm org exists and that t3 and every @t3code/t3-<platform> package has a trusted publisher registered for .github/workflows/release.yml (see docs/operations/release.md)." >&2 | |
| 654 | exit 1 | |
| 655 | fi | |
| 659 | 656 | |
| 660 | - name: Publish CLI package | |
| 661 | run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose | |
| 657 | - name: Publish CLI packages | |
| 658 | run: node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --verbose | |
| 662 | 659 | |
| 663 | 660 | release: |
| 664 | 661 | name: Publish GitHub Release |
| ... | ... | @@ -673,7 +670,7 @@ jobs: |
| 673 | 670 | desktop_win_arm64, |
| 674 | 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 | 674 | runs-on: blacksmith-8vcpu-ubuntu-2404 |
| 678 | 675 | timeout-minutes: 30 |
| 679 | 676 | permissions: |
apps/server/scripts/cli.ts+49-178| ... | ... | @@ -6,69 +6,24 @@ import * as FileSystem from "effect/FileSystem"; |
| 6 | 6 | import * as Logger from "effect/Logger"; |
| 7 | 7 | import * as Option from "effect/Option"; |
| 8 | 8 | import * as Path from "effect/Path"; |
| 9 | import * as Schema from "effect/Schema"; | |
| 10 | 9 | import { Command, Flag } from "effect/unstable/cli"; |
| 11 | 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 12 | 11 | |
| 13 | import { | |
| 14 | DEVELOPMENT_ICON_OVERRIDES, | |
| 15 | resolveWebAssetBrandForPackageVersion, | |
| 16 | resolveWebIconOverrides, | |
| 17 | } from "../../../scripts/lib/brand-assets.ts"; | |
| 12 | import { DEVELOPMENT_ICON_OVERRIDES } from "../../../scripts/lib/brand-assets.ts"; | |
| 18 | 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 | 14 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 23 | import serverPackageJson from "../package.json" with { type: "json" }; | |
| 24 | 15 | import { |
| 25 | 16 | ServerCliBuildAssetMissingError, |
| 26 | 17 | ServerCliCommandExitError, |
| 27 | 18 | ServerCliDevelopmentIconSourceMissingError, |
| 28 | 19 | ServerCliDevelopmentIconTargetMissingError, |
| 29 | 20 | ServerCliExecutableImportError, |
| 30 | ServerCliPublishIconSourceMissingError, | |
| 31 | ServerCliPublishIconTargetMissingError, | |
| 32 | 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 | 23 | const RepoRoot = Effect.service(Path.Path).pipe( |
| 61 | 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 | 27 | const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.StandardCommand) { |
| 73 | 28 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 74 | 29 | const child = yield* spawner.spawn(command); |
| ... | ... | @@ -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 | 42 | const applyDevelopmentIconOverrides = Effect.fn("applyDevelopmentIconOverrides")(function* ( |
| 118 | 43 | repoRoot: string, |
| 119 | 44 | serverDir: string, |
| ... | ... | @@ -240,37 +165,21 @@ const buildExeCmd = Command.make( |
| 240 | 165 | // publish subcommand |
| 241 | 166 | // --------------------------------------------------------------------------- |
| 242 | 167 | |
| 243 | interface PublishCommandConfig { | |
| 244 | readonly access: string; | |
| 245 | readonly tag: string; | |
| 246 | readonly provenance: boolean; | |
| 247 | readonly dryRun: boolean; | |
| 248 | } | |
| 249 | ||
| 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 | ||
| 168 | /** | |
| 169 | * Publishes the tarballs scripts/build-npm-platform-packages.ts produced: | |
| 170 | * every `@t3code/t3-<platform>.tgz` first, `t3.tgz` (the launcher) last, so | |
| 171 | * the launcher is never installable before the executables it depends on. | |
| 172 | * Tarballs rather than directories because `npm publish <dir>` strips the | |
| 173 | * `node_modules/` the executable loads its native addons from. | |
| 174 | */ | |
| 268 | 175 | const publishCmd = Command.make( |
| 269 | 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 | 181 | tag: Flag.string("tag").pipe(Flag.withDefault("latest")), |
| 272 | 182 | access: Flag.string("access").pipe(Flag.withDefault("public")), |
| 273 | appVersion: Flag.string("app-version").pipe(Flag.optional), | |
| 274 | 183 | provenance: Flag.boolean("provenance").pipe(Flag.withDefault(false)), |
| 275 | 184 | dryRun: Flag.boolean("dry-run").pipe(Flag.withDefault(false)), |
| 276 | 185 | verbose: Flag.boolean("verbose").pipe(Flag.withDefault(false)), |
| ... | ... | @@ -279,86 +188,48 @@ const publishCmd = Command.make( |
| 279 | 188 | Effect.gen(function* () { |
| 280 | 189 | const path = yield* Path.Path; |
| 281 | 190 | const fs = yield* FileSystem.FileSystem; |
| 282 | const repoRoot = yield* RepoRoot; | |
| 283 | const serverDir = path.join(repoRoot, "apps/server"); | |
| 284 | const packageJsonPath = path.join(serverDir, "package.json"); | |
| 285 | ||
| 286 | // Assert build assets exist | |
| 287 | for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) { | |
| 288 | const abs = path.join(serverDir, relPath); | |
| 289 | if (!(yield* fs.exists(abs))) { | |
| 290 | return yield* new ServerCliBuildAssetMissingError({ assetPath: abs }); | |
| 291 | } | |
| 191 | // npm runs with cwd set to the packages dir below, so tarball paths are | |
| 192 | // resolved once here rather than joined twice. | |
| 193 | const packagesDir = path.resolve(config.packagesDir); | |
| 194 | const scopeDir = path.join(packagesDir, "@t3code"); | |
| 195 | const launcherTarball = path.join(packagesDir, "t3.tgz"); | |
| 196 | const platformTarballs = (yield* fs | |
| 197 | .readDirectory(scopeDir) | |
| 198 | .pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []))) | |
| 199 | .filter((entry) => entry.startsWith("t3-") && entry.endsWith(".tgz")) | |
| 200 | .sort() | |
| 201 | .map((entry) => path.join(scopeDir, entry)); | |
| 202 | if (platformTarballs.length === 0) { | |
| 203 | return yield* new ServerCliBuildAssetMissingError({ | |
| 204 | assetPath: path.join(scopeDir, "t3-<platform>.tgz"), | |
| 205 | }); | |
| 206 | } | |
| 207 | if (!(yield* fs.exists(launcherTarball))) { | |
| 208 | return yield* new ServerCliBuildAssetMissingError({ assetPath: launcherTarball }); | |
| 292 | 209 | } |
| 293 | 210 | |
| 294 | yield* Effect.acquireUseRelease( | |
| 295 | // Acquire: resolve publish metadata and read every original before mutation. | |
| 296 | Effect.gen(function* () { | |
| 297 | const version = Option.getOrElse(config.appVersion, () => serverPackageJson.version); | |
| 298 | const workspaceConfig = yield* readWorkspaceConfig(); | |
| 299 | const workspaceCatalog = workspaceConfig.catalog ?? {}; | |
| 300 | const workspaceOverrides = workspaceConfig.overrides ?? {}; | |
| 301 | const pkg: PackageJson = { | |
| 302 | name: serverPackageJson.name, | |
| 303 | repository: serverPackageJson.repository, | |
| 304 | bin: serverPackageJson.bin, | |
| 305 | type: serverPackageJson.type, | |
| 306 | version, | |
| 307 | engines: serverPackageJson.engines, | |
| 308 | files: serverPackageJson.files, | |
| 309 | dependencies: resolveCatalogDependencies( | |
| 310 | serverPackageJson.dependencies, | |
| 311 | workspaceCatalog, | |
| 312 | "apps/server", | |
| 313 | ), | |
| 314 | overrides: resolveCatalogDependencies( | |
| 315 | workspaceOverrides, | |
| 316 | workspaceCatalog, | |
| 317 | "apps/server", | |
| 318 | ), | |
| 319 | }; | |
| 320 | ||
| 321 | return { | |
| 322 | packageJsonString: yield* encodePackageJson(pkg), | |
| 323 | originalPackageJson: yield* fs.readFile(packageJsonPath), | |
| 324 | icons: yield* preparePublishIcons(repoRoot, serverDir, version), | |
| 325 | }; | |
| 326 | }), | |
| 327 | // Use: pnpm publish from the workspace root so pnpm-only workspace | |
| 328 | // config, including override selectors, is interpreted correctly. | |
| 329 | (resource) => | |
| 330 | Effect.gen(function* () { | |
| 331 | yield* fs.writeFileString(packageJsonPath, `${resource.packageJsonString}\n`); | |
| 332 | for (const icon of resource.icons) { | |
| 333 | yield* fs.writeFile(icon.targetPath, icon.publish); | |
| 334 | } | |
| 335 | yield* Effect.log("[cli] Applied package metadata and publish icon overrides"); | |
| 336 | ||
| 337 | const args = createVpPmPublishArgs(config); | |
| 338 | const spawnCommand = yield* resolveSpawnCommand("vp", ["pm", ...args]); | |
| 339 | ||
| 340 | yield* Effect.log(`[cli] Running: vp pm ${args.join(" ")}`); | |
| 341 | yield* runCommand( | |
| 342 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 343 | cwd: repoRoot, | |
| 344 | stdout: config.verbose ? "inherit" : "ignore", | |
| 345 | stderr: "inherit", | |
| 346 | shell: spawnCommand.shell, | |
| 347 | }), | |
| 348 | ); | |
| 211 | const args = ["publish", "--access", config.access, "--tag", config.tag]; | |
| 212 | if (config.provenance) args.push("--provenance"); | |
| 213 | if (config.dryRun) args.push("--dry-run"); | |
| 214 | ||
| 215 | for (const tarball of [...platformTarballs, launcherTarball]) { | |
| 216 | const spawnCommand = yield* resolveSpawnCommand("npm", [...args, tarball]); | |
| 217 | yield* Effect.log(`[cli] npm ${args.join(" ")} ${path.basename(tarball)}`); | |
| 218 | yield* runCommand( | |
| 219 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 220 | cwd: packagesDir, | |
| 221 | stdout: config.verbose ? "inherit" : "ignore", | |
| 222 | stderr: "inherit", | |
| 223 | shell: spawnCommand.shell, | |
| 349 | 224 | }), |
| 350 | // Release: restore every file even if applying overrides or publishing fails. | |
| 351 | (resource) => | |
| 352 | Effect.gen(function* () { | |
| 353 | yield* fs.writeFile(packageJsonPath, resource.originalPackageJson); | |
| 354 | for (const icon of resource.icons) { | |
| 355 | yield* fs.writeFile(icon.targetPath, icon.original); | |
| 356 | } | |
| 357 | if (config.verbose) yield* Effect.log("[cli] Restored original publish assets"); | |
| 358 | }), | |
| 359 | ); | |
| 225 | ); | |
| 226 | } | |
| 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 | 235 | // root command |
apps/server/scripts/cliErrors.ts-22| ... | ... | @@ -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 | 17 | export class ServerCliDevelopmentIconSourceMissingError extends Schema.TaggedError<ServerCliDevelopmentIconSourceMissingError>()( |
| 40 | 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 | 12 | - push tag matching `v*.*.*` for a stable release of an explicit commit |
| 13 | 13 | - scheduled nightly check every 30 minutes |
| 14 | 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 | 16 | - A manual stable release builds the commit of the latest published nightly, not `main` HEAD. |
| 17 | 17 | Nightly is the release candidate: verify the nightly, then promote it. Merges to `main` keep |
| 18 | 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 | 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 | 37 | - Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets. |
| 38 | 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 | 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 | 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 | 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 | 44 | - stable releases publish npm dist-tag `latest` |
| 45 | 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 | 48 | - Deploys the hosted web app to Vercel only after a release is published: |
| 48 | 49 | - stable releases are aliased to the `latest` hosted app channel |
| 49 | 50 | - nightly releases are aliased to the `nightly` hosted app channel |
| ... | ... | @@ -198,7 +199,7 @@ One-time Vercel dashboard setup: |
| 198 | 199 | - `make_latest` is always `false` |
| 199 | 200 | - Uses the next stable patch version as the nightly base. For example, `0.0.17` produces nightlies on `0.0.18-nightly.*`. |
| 200 | 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 | 203 | - Does not commit version bumps back to `main`. |
| 203 | 204 | |
| 204 | 205 | ## Server self-update release invariant |
| ... | ... | @@ -209,7 +210,7 @@ npm before users can receive that client. |
| 209 | 210 | |
| 210 | 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 | 214 | 2. `release` depends on `publish_cli` before exposing desktop artifacts in GitHub Releases. |
| 214 | 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 | 295 | |
| 295 | 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 | script temporarily prepares the `t3` package, then runs `vp pm publish --filter t3 ...` from the | |
| 299 | repository root so workspace publish configuration is applied correctly. | |
| 298 | The workflow runs `node scripts/build-npm-platform-packages.ts` on the downloaded CLI archives, then | |
| 299 | `node apps/server/scripts/cli.ts publish --packages-dir npm-packages`, which runs `npm publish` on | |
| 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 | 307 | Checklist: |
| 302 | 308 | |
| 303 | 1. Confirm npm org/user owns package `t3` (or rename package first if needed). | |
| 304 | 2. In npm package settings, configure Trusted Publisher: | |
| 309 | 1. Confirm the npm org owns package `t3` and the `@t3code` scope exists on npm (create the org if | |
| 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 | 315 | - Provider: GitHub Actions |
| 306 | 316 | - Repository: this repo |
| 307 | 317 | - Workflow file: `.github/workflows/release.yml` |
| 308 | 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 | 320 | 4. Create release tag `vX.Y.Z` and push; workflow will: |
| 311 | - align the release package versions to `X.Y.Z` | |
| 312 | - build web + server | |
| 313 | - invoke the CLI publish script with npm dist-tag `latest` | |
| 314 | 5. Nightly runs invoke the same publish script with npm dist-tag `nightly`. | |
| 321 | - build and smoke-test the five CLI archives | |
| 322 | - build the npm packages from those archives | |
| 323 | - publish them with npm dist-tag `latest` | |
| 324 | 5. Nightly runs publish with npm dist-tag `nightly`; preview runs with `preview`. | |
| 315 | 325 | |
| 316 | 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 | 5 | |
| 6 | 6 | ## Requirements |
| 7 | 7 | |
| 8 | Command-line use, SSH hosts, and WSL backends need Node.js 22.16+ (22.x), 23.11+ | |
| 9 | (23.x), or 24.10 and later. The native desktop app includes its server runtime. | |
| 8 | `npx t3` needs Node.js only to run npm itself; the CLI it installs is a | |
| 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 | 13 | You need an installed, authenticated provider before starting a thread. You can |
| 12 | 14 | launch T3 Code and configure providers afterwards. |
| ... | ... | @@ -20,6 +22,22 @@ npx t3@latest |
| 20 | 22 | This starts the server and opens the local web app. Run |
| 21 | 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 | 41 | ## Desktop app |
| 24 | 42 | |
| 25 | 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 | 19 | */ |
| 20 | 20 | // No darwin-x64: Node single-executables are unsupported on x64 macOS (the |
| 21 | 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 | 23 | "darwin-arm64", |
| 24 | 24 | "linux-arm64", |
| 25 | 25 | "linux-x64", |
scripts/build-cli-archive.ts+39-1| ... | ... | @@ -23,6 +23,7 @@ import * as Layer from "effect/Layer"; |
| 23 | 23 | import * as Logger from "effect/Logger"; |
| 24 | 24 | import * as Option from "effect/Option"; |
| 25 | 25 | import * as Path from "effect/Path"; |
| 26 | import * as PlatformError from "effect/PlatformError"; | |
| 26 | 27 | import * as Schema from "effect/Schema"; |
| 27 | 28 | import { Command, Flag } from "effect/unstable/cli"; |
| 28 | 29 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| ... | ... | @@ -241,8 +242,33 @@ const stageRuntimeExternals = Effect.fn("stageRuntimeExternals")(function* (inpu |
| 241 | 242 | ]) { |
| 242 | 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 | 272 | /** Copies the web client without its sourcemaps, which nothing serves. */ |
| 247 | 273 | const stageWebClient = Effect.fn("stageWebClient")(function* (source: string, target: string) { |
| 248 | 274 | const fs = yield* FileSystem.FileSystem; |
| ... | ... | @@ -516,8 +542,20 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: { |
| 516 | 542 | "tar (zip)", |
| 517 | 543 | ); |
| 518 | 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 | 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 | 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 | } |