| author | |
| committer | |
| log | 07549200dcdc70dce57285276879004024beeaa7 |
| tree | f3a5b3faa1460263bcb2be6cd72e2d14eeca8635 |
| parent | af6c138a0de297f94ddef3133e2f47d017b46e94 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>8 files changed, 725 insertions(+), 844 deletions(-)
.github/workflows/release.yml+103-40| ... | ... | @@ -354,21 +354,28 @@ jobs: |
| 354 | 354 | # job — the Windows artifact then ships a ready WSL backend binary with no |
| 355 | 355 | # cross-compiling and no first-launch compiler/node-gyp/network on the user's |
| 356 | 356 | # machine. node-pty is N-API, so one binary works across all WSL Node versions. |
| 357 | build_wsl_node_pty: | |
| 358 | name: Build WSL node-pty (linux-x64) | |
| 357 | # The Linux CLI archive is built ahead of the desktop matrix because two | |
| 358 | # consumers need it: the Linux desktop entry attaches it to the release, and | |
| 359 | # the Windows desktop entry embeds it as the WSL runtime. Building it once | |
| 360 | # here means the WSL backend runs the exact bytes a Linux user downloads. | |
| 361 | build_linux_cli: | |
| 362 | name: Build CLI archive (linux-x64) | |
| 359 | 363 | # Same gating as relay_public_config: only the release commit is needed, so |
| 360 | 364 | # this runs alongside preflight. See the condition comment there. |
| 361 | needs: [resolve_commit] | |
| 362 | if: | | |
| 363 | needs.resolve_commit.result == 'success' && | |
| 364 | (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true') | |
| 365 | runs-on: blacksmith-8vcpu-ubuntu-2404 | |
| 366 | timeout-minutes: 15 | |
| 365 | needs: [resolve_commit, preflight, relay_public_config] | |
| 366 | if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} | |
| 367 | runs-on: blacksmith-32vcpu-ubuntu-2404 | |
| 368 | timeout-minutes: 20 | |
| 369 | env: | |
| 370 | T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} | |
| 371 | T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} | |
| 372 | T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} | |
| 373 | T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} | |
| 367 | 374 | steps: |
| 368 | 375 | - name: Checkout |
| 369 | 376 | uses: actions/checkout@v6 |
| 370 | 377 | with: |
| 371 | ref: ${{ needs.resolve_commit.outputs.ref }} | |
| 378 | ref: ${{ needs.preflight.outputs.ref }} | |
| 372 | 379 | sparse-checkout: | |
| 373 | 380 | /* |
| 374 | 381 | !/.repos/ |
| ... | ... | @@ -382,38 +389,92 @@ jobs: |
| 382 | 389 | run-install: | |
| 383 | 390 | args: |
| 384 | 391 | - --filter=t3... |
| 392 | - --filter=@t3tools/web... | |
| 393 | - --filter=@t3tools/scripts... | |
| 394 | ||
| 395 | - name: Cache resource monitor | |
| 396 | id: resource_monitor_cache | |
| 397 | uses: actions/cache@v6 | |
| 398 | with: | |
| 399 | path: native/resource-monitor/target/x86_64-unknown-linux-gnu/release/t3-resource-monitor | |
| 400 | key: resource-monitor-x86_64-unknown-linux-gnu-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} | |
| 401 | ||
| 402 | - name: Setup Rust | |
| 403 | if: steps.resource_monitor_cache.outputs.cache-hit != 'true' | |
| 404 | uses: dtolnay/rust-toolchain@stable | |
| 405 | with: | |
| 406 | targets: x86_64-unknown-linux-gnu | |
| 407 | ||
| 408 | - name: Build resource monitor | |
| 409 | if: steps.resource_monitor_cache.outputs.cache-hit != 'true' | |
| 410 | run: cargo build --locked --release --manifest-path native/resource-monitor/Cargo.toml --target x86_64-unknown-linux-gnu | |
| 411 | ||
| 412 | - name: Download relay client tracing config | |
| 413 | uses: actions/download-artifact@v8 | |
| 414 | with: | |
| 415 | name: relay-client-tracing-config | |
| 416 | path: ${{ runner.temp }}/relay-client-tracing | |
| 385 | 417 | |
| 386 | - name: Build node-pty linux-x64 prebuild | |
| 418 | - name: Load relay client tracing config | |
| 387 | 419 | shell: bash |
| 420 | run: | | |
| 421 | config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" | |
| 422 | tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" | |
| 423 | echo "::add-mask::$tracing_token" | |
| 424 | cat "$config_path" >> "$GITHUB_ENV" | |
| 425 | ||
| 426 | - name: Align package versions to release version | |
| 427 | run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" | |
| 428 | ||
| 429 | # The t3 build task depends on @t3tools/web#build, so the web client is | |
| 430 | # built as part of this step. | |
| 431 | - name: Build CLI package | |
| 432 | run: vp run --filter t3 build | |
| 433 | ||
| 434 | - name: Build CLI single-executable | |
| 435 | env: | |
| 436 | # The exact version, not a major: vp downloads it from nodejs.org/dist on | |
| 437 | # the runner, and only exact versions have a dist directory. Keep in | |
| 438 | # step with SEA_NODE_VERSION in apps/server/vite.config.ts. | |
| 439 | VP_NODE_VERSION: "26.8.2" | |
| 440 | run: node apps/server/scripts/cli.ts build-exe --verbose | |
| 441 | ||
| 442 | - name: Stage resource monitor for the CLI archive | |
| 388 | 443 | run: | |
| 389 | 444 | set -euo pipefail |
| 390 | # Resolve node-pty from apps/server (where it's a dependency) and build | |
| 391 | # its native binary from source for Linux. node-addon-api resolves from | |
| 392 | # node-pty's own dependency tree, so node-gyp has everything it needs. | |
| 393 | pty_pkg="$(node -e "console.log(require.resolve('node-pty/package.json', { paths: ['$GITHUB_WORKSPACE/apps/server'] }))")" | |
| 394 | pty_dir="$(dirname "$pty_pkg")" | |
| 395 | ( cd "$pty_dir" && npx --yes node-gyp rebuild ) | |
| 396 | mkdir -p wsl-prebuild | |
| 397 | cp "$pty_dir/build/Release/pty.node" wsl-prebuild/pty.node | |
| 398 | file wsl-prebuild/pty.node | |
| 399 | ||
| 400 | - name: Upload node-pty linux-x64 prebuild | |
| 445 | target_dir="$RUNNER_TEMP/cli-resource-monitor/linux-x64" | |
| 446 | mkdir -p "$target_dir" | |
| 447 | cp native/resource-monitor/target/x86_64-unknown-linux-gnu/release/t3-resource-monitor "$target_dir/" | |
| 448 | ||
| 449 | - name: Build CLI archive | |
| 450 | run: | | |
| 451 | node scripts/build-cli-archive.ts \ | |
| 452 | --platform linux \ | |
| 453 | --arch x64 \ | |
| 454 | --version "${{ needs.preflight.outputs.version }}" \ | |
| 455 | --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ | |
| 456 | --output-dir release-cli | |
| 457 | ||
| 458 | - name: Smoke-test CLI archive | |
| 459 | run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ needs.preflight.outputs.version }}" | |
| 460 | ||
| 461 | - name: Upload CLI archive | |
| 401 | 462 | uses: actions/upload-artifact@v7 |
| 402 | 463 | with: |
| 403 | name: wsl-node-pty-x64 | |
| 404 | path: wsl-prebuild/pty.node | |
| 464 | name: cli-linux-x64 | |
| 465 | path: release-cli/* | |
| 405 | 466 | if-no-files-found: error |
| 406 | 467 | |
| 407 | 468 | build: |
| 408 | 469 | name: Build ${{ matrix.label }} |
| 409 | # build_wsl_node_pty stays in `needs` so it runs first and its artifact is | |
| 410 | # available to download, but only the Windows matrix entry consumes it. We | |
| 411 | # therefore gate the job on preflight + relay (must succeed) WITHOUT requiring | |
| 412 | # build_wsl_node_pty, so a failed Linux prebuild doesn't skip the macOS/Linux | |
| 413 | # builds. `!cancelled()` (not `!failure()`) lets the job run even when | |
| 414 | # build_wsl_node_pty failed; the Windows-only download step below then fails | |
| 415 | # that single platform if the prebuild is missing. | |
| 416 | needs: [preflight, relay_public_config, build_wsl_node_pty] | |
| 470 | # build_linux_cli stays in `needs` so it runs first and its artifact is | |
| 471 | # available to download, but only the Windows matrix entry consumes it (as | |
| 472 | # the WSL runtime). The job is gated on preflight + relay WITHOUT requiring | |
| 473 | # build_linux_cli, so a failed Linux archive doesn't skip the macOS builds. | |
| 474 | # `!cancelled()` (not `!failure()`) lets the job run even when | |
| 475 | # build_linux_cli failed; the Windows-only download step below then fails | |
| 476 | # that single platform if the archive is missing. | |
| 477 | needs: [preflight, relay_public_config, build_linux_cli] | |
| 417 | 478 | if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} |
| 418 | 479 | runs-on: ${{ matrix.runner }} |
| 419 | 480 | timeout-minutes: 30 |
| ... | ... | @@ -448,6 +509,7 @@ jobs: |
| 448 | 509 | rust_target: x86_64-apple-darwin |
| 449 | 510 | resource_key: darwin-x64 |
| 450 | 511 | cli_archive: false |
| 512 | # The Linux CLI archive is produced by build_linux_cli, not here. | |
| 451 | 513 | - label: Linux x64 |
| 452 | 514 | runner: blacksmith-32vcpu-ubuntu-2404 |
| 453 | 515 | platform: linux |
| ... | ... | @@ -455,7 +517,7 @@ jobs: |
| 455 | 517 | arch: x64 |
| 456 | 518 | rust_target: x86_64-unknown-linux-gnu |
| 457 | 519 | resource_key: linux-x64 |
| 458 | cli_archive: true | |
| 520 | cli_archive: false | |
| 459 | 521 | - label: Windows x64 |
| 460 | 522 | runner: blacksmith-32vcpu-windows-2025 |
| 461 | 523 | platform: win |
| ... | ... | @@ -553,12 +615,14 @@ jobs: |
| 553 | 615 | - name: Align package versions to release version |
| 554 | 616 | run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" |
| 555 | 617 | |
| 556 | - name: Download WSL node-pty prebuild | |
| 618 | # The WSL backend runs the Linux CLI archive inside the distro, so the | |
| 619 | # Windows desktop embeds the same archive the release attaches. | |
| 620 | - name: Download Linux CLI archive for WSL | |
| 557 | 621 | if: matrix.platform == 'win' |
| 558 | uses: actions/download-artifact@v7 | |
| 622 | uses: actions/download-artifact@v8 | |
| 559 | 623 | with: |
| 560 | name: wsl-node-pty-x64 | |
| 561 | path: wsl-prebuild | |
| 624 | name: cli-linux-x64 | |
| 625 | path: wsl-runtime | |
| 562 | 626 | |
| 563 | 627 | - name: Install Spectre-mitigated MSVC libs |
| 564 | 628 | if: matrix.platform == 'win' |
| ... | ... | @@ -720,10 +784,9 @@ jobs: |
| 720 | 784 | echo "macOS signing disabled (missing one or more Apple signing secrets)." |
| 721 | 785 | fi |
| 722 | 786 | elif [[ "${{ matrix.platform }}" == "win" ]]; then |
| 723 | # Bundle the Linux node-pty binary built by the build_wsl_node_pty job | |
| 724 | # so the packaged WSL backend ships a ready binary (no first-launch | |
| 725 | # compile). Required for a working WSL backend on Windows. | |
| 726 | args+=(--wsl-prebuild "$GITHUB_WORKSPACE/wsl-prebuild/pty.node") | |
| 787 | # Embed the Linux CLI archive built by build_linux_cli as the WSL | |
| 788 | # runtime. Required for a working WSL backend on Windows. | |
| 789 | args+=(--wsl-runtime "$GITHUB_WORKSPACE"/wsl-runtime/t3-*-linux-x64.tar.gz) | |
| 727 | 790 | if has_all \ |
| 728 | 791 | "$AZURE_TENANT_ID" \ |
| 729 | 792 | "$AZURE_CLIENT_ID" \ |
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+44-57| ... | ... | @@ -374,9 +374,10 @@ describe("DesktopBackendConfiguration", () => { |
| 374 | 374 | runtimeId: string; |
| 375 | 375 | sha256: string; |
| 376 | 376 | }> = []; |
| 377 | const observedNodePtyRoots: string[] = []; | |
| 377 | const observedProbeRoots: string[] = []; | |
| 378 | 378 | let legacyCleanupCount = 0; |
| 379 | 379 | const linuxAppRoot = "/home/test/.t3/wsl-runtime/1.2.3-x64"; |
| 380 | const resolvedPath = "/home/test/.local/bin:/usr/bin:/bin"; | |
| 380 | 381 | |
| 381 | 382 | return withPackagedWslHarness( |
| 382 | 383 | { |
| ... | ... | @@ -394,9 +395,14 @@ describe("DesktopBackendConfiguration", () => { |
| 394 | 395 | }); |
| 395 | 396 | return { ok: true, linuxAppRoot }; |
| 396 | 397 | }, |
| 397 | ensureNodePty: (_distro, root) => { | |
| 398 | observedNodePtyRoots.push(root); | |
| 399 | return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" }; | |
| 398 | probeRuntime: (_distro, root) => { | |
| 399 | observedProbeRoots.push(root); | |
| 400 | return { ok: true, resolvedPath }; | |
| 401 | }, | |
| 402 | // The staged runtime carries its own Node, so the preflight must not | |
| 403 | // go looking for one in the distro. | |
| 404 | ensureNodePty: () => { | |
| 405 | throw new Error("the staged runtime must not probe for Node"); | |
| 400 | 406 | }, |
| 401 | 407 | }), |
| 402 | 408 | }, |
| ... | ... | @@ -413,12 +419,22 @@ describe("DesktopBackendConfiguration", () => { |
| 413 | 419 | sha256: archiveHash, |
| 414 | 420 | }, |
| 415 | 421 | ]); |
| 416 | assert.deepEqual(observedNodePtyRoots, [linuxAppRoot]); | |
| 422 | assert.deepEqual(observedProbeRoots, [linuxAppRoot]); | |
| 417 | 423 | assert.equal( |
| 418 | 424 | config.entryPath, |
| 419 | 425 | path.join(baseDir, "server.asar/apps/server/dist/bin.mjs"), |
| 420 | 426 | ); |
| 421 | assert.include(config.args, `${linuxAppRoot}/apps/server/dist/bin.mjs`); | |
| 427 | assert.deepEqual(config.args, [ | |
| 428 | "-d", | |
| 429 | "Ubuntu", | |
| 430 | "--exec", | |
| 431 | "env", | |
| 432 | `PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${resolvedPath}`, | |
| 433 | `${linuxAppRoot}/t3`, | |
| 434 | "--bootstrap-fd", | |
| 435 | "0", | |
| 436 | ]); | |
| 437 | assert.notInclude(config.args, "/usr/bin/node"); | |
| 422 | 438 | assert.equal(config.wslRuntimeId, `sha256-${archiveHash}`); |
| 423 | 439 | assert.equal(legacyCleanupCount, 1); |
| 424 | 440 | assert.isTrue(Option.isNone(config.preflightFailure)); |
| ... | ... | @@ -457,8 +473,11 @@ describe("DesktopBackendConfiguration", () => { |
| 457 | 473 | |
| 458 | 474 | assert.deepEqual(observedRuntimeIds, [`sha256-${firstHash}`, `sha256-${secondHash}`]); |
| 459 | 475 | assert.equal(first.wslRuntimeId, observedRuntimeIds[0]); |
| 476 | assert.include(first.args, `/runtime/sha256-${firstHash}/t3`); | |
| 460 | 477 | assert.equal(second.wslRuntimeId, observedRuntimeIds[1]); |
| 478 | assert.include(second.args, `/runtime/sha256-${secondHash}/t3`); | |
| 461 | 479 | assert.isUndefined(invalidIdentity.wslRuntimeId); |
| 480 | assert.include(invalidIdentity.args, "/usr/bin/node"); | |
| 462 | 481 | assert.include(invalidIdentity.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`); |
| 463 | 482 | }), |
| 464 | 483 | ); |
| ... | ... | @@ -484,6 +503,7 @@ describe("DesktopBackendConfiguration", () => { |
| 484 | 503 | |
| 485 | 504 | assert.deepEqual(observedNodePtyRoots, [mountedAppRoot]); |
| 486 | 505 | assert.equal(config.entryPath, mountedEntryPath); |
| 506 | assert.include(config.args, "/usr/bin/node"); | |
| 487 | 507 | assert.include(config.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`); |
| 488 | 508 | assert.isUndefined(config.wslRuntimeId); |
| 489 | 509 | assert.isTrue(Option.isNone(config.preflightFailure)); |
| ... | ... | @@ -491,9 +511,10 @@ describe("DesktopBackendConfiguration", () => { |
| 491 | 511 | ); |
| 492 | 512 | }); |
| 493 | 513 | |
| 494 | it.effect("resolveWsl retires a staged runtime that cannot load node-pty", () => { | |
| 514 | it.effect("resolveWsl retires a staged runtime whose executable does not start", () => { | |
| 495 | 515 | const archiveHash = "c".repeat(64); |
| 496 | 516 | const stagedAppRoot = `/home/test/.t3/wsl-runtime/sha256-${archiveHash}`; |
| 517 | const observedProbeRoots: string[] = []; | |
| 497 | 518 | const observedNodePtyRoots: string[] = []; |
| 498 | 519 | const invalidatedRuntimeIds: string[] = []; |
| 499 | 520 | return withPackagedWslHarness( |
| ... | ... | @@ -505,11 +526,13 @@ describe("DesktopBackendConfiguration", () => { |
| 505 | 526 | Effect.sync(() => { |
| 506 | 527 | invalidatedRuntimeIds.push(runtimeId); |
| 507 | 528 | }), |
| 529 | probeRuntime: (_distro, root) => { | |
| 530 | observedProbeRoots.push(root); | |
| 531 | return { ok: false, reason: `${root}/t3 --version failed (exit 127)` }; | |
| 532 | }, | |
| 508 | 533 | ensureNodePty: (_distro, root) => { |
| 509 | 534 | observedNodePtyRoots.push(root); |
| 510 | return root === stagedAppRoot | |
| 511 | ? { ok: false, reason: "pty.node could not be loaded", fatal: true } | |
| 512 | : { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" }; | |
| 535 | return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" }; | |
| 513 | 536 | }, |
| 514 | 537 | }), |
| 515 | 538 | }, |
| ... | ... | @@ -518,8 +541,11 @@ describe("DesktopBackendConfiguration", () => { |
| 518 | 541 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; |
| 519 | 542 | const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); |
| 520 | 543 | |
| 521 | assert.deepEqual(observedNodePtyRoots, [stagedAppRoot, mountedAppRoot]); | |
| 544 | assert.deepEqual(observedProbeRoots, [stagedAppRoot]); | |
| 545 | assert.deepEqual(observedNodePtyRoots, [mountedAppRoot]); | |
| 546 | assert.include(config.args, "/usr/bin/node"); | |
| 522 | 547 | assert.include(config.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`); |
| 548 | assert.notInclude(config.args, `${stagedAppRoot}/t3`); | |
| 523 | 549 | assert.equal(config.entryPath, mountedEntryPath); |
| 524 | 550 | assert.isUndefined(config.wslRuntimeId); |
| 525 | 551 | assert.isTrue(Option.isNone(config.preflightFailure)); |
| ... | ... | @@ -540,12 +566,13 @@ describe("DesktopBackendConfiguration", () => { |
| 540 | 566 | Effect.sync(() => { |
| 541 | 567 | invalidatedRuntimeIds.push(runtimeId); |
| 542 | 568 | }), |
| 543 | ensureNodePty: (_distro, root) => ({ | |
| 569 | probeRuntime: () => ({ | |
| 544 | 570 | ok: false, |
| 545 | reason: | |
| 546 | root === stagedAppRoot | |
| 547 | ? "unsupported CPU architecture or incompatible system libraries" | |
| 548 | : "mounted tree is broken in some other way", | |
| 571 | reason: "unsupported CPU architecture or incompatible system libraries", | |
| 572 | }), | |
| 573 | ensureNodePty: () => ({ | |
| 574 | ok: false, | |
| 575 | reason: "mounted tree is broken in some other way", | |
| 549 | 576 | fatal: true, |
| 550 | 577 | }), |
| 551 | 578 | }), |
| ... | ... | @@ -575,51 +602,11 @@ describe("DesktopBackendConfiguration", () => { |
| 575 | 602 | Effect.sync(() => { |
| 576 | 603 | invalidatedRuntimeIds.push(runtimeId); |
| 577 | 604 | }), |
| 578 | ensureNodePty: (_distro, root) => | |
| 579 | root === stagedAppRoot | |
| 580 | ? { ok: false, reason: "pty.node could not be loaded", fatal: true } | |
| 581 | : { | |
| 582 | ok: false, | |
| 583 | reason: "WSL backend preflight timed out while probing for Node.js.", | |
| 584 | fatal: false, | |
| 585 | }, | |
| 586 | }), | |
| 587 | }, | |
| 588 | () => | |
| 589 | Effect.gen(function* () { | |
| 590 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | |
| 591 | const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); | |
| 592 | const failure = Option.getOrThrow(config.preflightFailure); | |
| 593 | ||
| 594 | assert.isFalse(failure.fatal); | |
| 595 | assert.equal(failure.retryLimit, 12); | |
| 596 | assert.include(failure.reason, "timed out"); | |
| 597 | assert.deepEqual(invalidatedRuntimeIds, []); | |
| 598 | }), | |
| 599 | ); | |
| 600 | }); | |
| 601 | ||
| 602 | it.effect("resolveWsl retries the staged runtime after a transient probe failure", () => { | |
| 603 | const invalidatedRuntimeIds: string[] = []; | |
| 604 | return withPackagedWslHarness( | |
| 605 | { | |
| 606 | archiveHash: "e".repeat(64), | |
| 607 | forbidFallback: "A transient probe failure must not extract the fallback", | |
| 608 | forbidCleanup: "A transient probe failure must not clean the fallback tree", | |
| 609 | wsl: () => ({ | |
| 610 | prepareRuntime: () => ({ | |
| 611 | ok: true, | |
| 612 | linuxAppRoot: "/home/test/.t3/wsl-runtime/cache", | |
| 613 | }), | |
| 614 | invalidateRuntime: (_distro, runtimeId) => | |
| 615 | Effect.sync(() => { | |
| 616 | invalidatedRuntimeIds.push(runtimeId); | |
| 617 | }), | |
| 605 | probeRuntime: () => ({ ok: false, reason: "t3 --version failed (exit 1)" }), | |
| 618 | 606 | ensureNodePty: () => ({ |
| 619 | 607 | ok: false, |
| 620 | 608 | reason: "WSL backend preflight timed out while probing for Node.js.", |
| 621 | 609 | fatal: false, |
| 622 | retryLimit: 12, | |
| 623 | 610 | }), |
| 624 | 611 | }), |
| 625 | 612 | }, |
apps/desktop/src/backend/DesktopBackendConfiguration.ts+75-50| ... | ... | @@ -95,6 +95,11 @@ const WSL_FORWARDED_ENV_NAMES = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] as const |
| 95 | 95 | |
| 96 | 96 | const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; |
| 97 | 97 | |
| 98 | const nodeBinDirOf = (nodePath: string): string => { | |
| 99 | const lastSlash = nodePath.lastIndexOf("/"); | |
| 100 | return lastSlash > 0 ? nodePath.slice(0, lastSlash) : "/usr/bin"; | |
| 101 | }; | |
| 102 | ||
| 98 | 103 | const backendChildEnvPatch = (): Record<string, string | undefined> => |
| 99 | 104 | Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined])); |
| 100 | 105 | |
| ... | ... | @@ -210,18 +215,31 @@ interface SharedBootstrapInput { |
| 210 | 215 | readonly observabilitySettings: BackendObservabilitySettings; |
| 211 | 216 | } |
| 212 | 217 | |
| 218 | // What the launch runs inside the distro. The staged runtime is the release's | |
| 219 | // self-contained `t3` executable (Node inside); the mounted server tree is a | |
| 220 | // script that needs the distro's own Node. | |
| 221 | type WslPreflightRuntime = | |
| 222 | | { | |
| 223 | readonly kind: "executable"; | |
| 224 | readonly entryPath: string; | |
| 225 | } | |
| 226 | | { | |
| 227 | readonly kind: "node-script"; | |
| 228 | // Absolute path to the node binary the preflight validated after the | |
| 229 | // shared remote resolver repaired PATH. The launch must use this exact | |
| 230 | // path so it doesn't fall through to a different/old node than the one | |
| 231 | // node-pty was probed with. | |
| 232 | readonly nodePath: string; | |
| 233 | readonly linuxEntryPath: string; | |
| 234 | }; | |
| 235 | ||
| 213 | 236 | interface WslPreflightSuccess { |
| 214 | 237 | readonly _tag: "Ready"; |
| 215 | 238 | readonly runningDistro: string; |
| 216 | 239 | readonly windowsEntryPath: string; |
| 217 | readonly linuxEntryPath: string; | |
| 218 | // Absolute path to the node binary the preflight validated after the shared | |
| 219 | // remote resolver repaired PATH. The launch must use this exact path so it | |
| 220 | // doesn't fall through to a different/old node than the one node-pty was | |
| 221 | // built against. | |
| 222 | readonly nodePath: string; | |
| 223 | // PATH captured from the same login shell after the shared resolver loaded | |
| 224 | // version managers. The launch forwards this value directly without a shell. | |
| 240 | readonly runtime: WslPreflightRuntime; | |
| 241 | // PATH captured from the user's login shell. The launch forwards this value | |
| 242 | // directly without a shell so the server can spawn provider CLIs by name. | |
| 225 | 243 | readonly resolvedPath: string; |
| 226 | 244 | // Identifies the distro-local runtime cache selected from the packaged archive. |
| 227 | 245 | readonly runtimeId?: string; |
| ... | ... | @@ -355,39 +373,36 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f |
| 355 | 373 | // fatal verdict the cached reason is the more actionable one to report. |
| 356 | 374 | // A transient mounted failure is neither — it rules nothing out, so it stays |
| 357 | 375 | // retryable and the staged verdict waits for an attempt that can answer. |
| 358 | let stagedFailure: | |
| 359 | | { readonly runtimeId: string; readonly nodePty: FailedNodePtyResult } | |
| 360 | | undefined; | |
| 376 | let stagedFailure: { readonly runtimeId: string; readonly reason: string } | undefined; | |
| 377 | const failedStaged = (failure: { readonly reason: string }) => | |
| 378 | ({ | |
| 379 | _tag: "Failed", | |
| 380 | reason: `WSL runtime unavailable: ${failure.reason}`, | |
| 381 | fatal: true, | |
| 382 | }) as const; | |
| 361 | 383 | |
| 362 | 384 | if (input.runtimeArchive !== null) { |
| 363 | 385 | const runtime = yield* wslEnv.prepareRuntime(runningDistro, input.runtimeArchive); |
| 364 | 386 | if (runtime.ok) { |
| 365 | const stagedNodePty = yield* wslEnv.ensureNodePty( | |
| 366 | runningDistro, | |
| 367 | runtime.linuxAppRoot, | |
| 368 | nodePtyOptions, | |
| 369 | ); | |
| 370 | if (stagedNodePty.ok) { | |
| 387 | // The staged runtime is self-contained, so the only question is whether | |
| 388 | // it runs here; there is no Node to find or node-pty to load. | |
| 389 | const stagedProbe = yield* wslEnv.probeRuntime(runningDistro, runtime.linuxAppRoot); | |
| 390 | if (stagedProbe.ok) { | |
| 371 | 391 | yield* wslServerTree.cleanupLegacy; |
| 372 | 392 | return { |
| 373 | 393 | _tag: "Ready", |
| 374 | 394 | runningDistro, |
| 375 | 395 | windowsEntryPath: environment.backendEntryPath, |
| 376 | linuxEntryPath: `${runtime.linuxAppRoot}/apps/server/dist/bin.mjs`, | |
| 377 | nodePath: stagedNodePty.nodePath, | |
| 378 | resolvedPath: stagedNodePty.resolvedPath, | |
| 396 | runtime: { kind: "executable", entryPath: `${runtime.linuxAppRoot}/t3` }, | |
| 397 | resolvedPath: stagedProbe.resolvedPath, | |
| 379 | 398 | runtimeId: input.runtimeArchive.runtimeId, |
| 380 | 399 | } as const; |
| 381 | 400 | } |
| 382 | // A transport failure says nothing about the staged tree, so it is | |
| 383 | // retried against the same cache rather than spending a second probe on | |
| 384 | // the mounted tree and risking a needless reinstall. | |
| 385 | if (!stagedNodePty.fatal) return failedNodePty(stagedNodePty); | |
| 386 | 401 | yield* Effect.logWarning( |
| 387 | "The staged WSL runtime could not load node-pty; retrying from the mounted server tree.", | |
| 388 | { reason: stagedNodePty.reason }, | |
| 402 | "The staged WSL runtime did not start; retrying from the mounted server tree.", | |
| 403 | { reason: stagedProbe.reason }, | |
| 389 | 404 | ); |
| 390 | stagedFailure = { runtimeId: input.runtimeArchive.runtimeId, nodePty: stagedNodePty }; | |
| 405 | stagedFailure = { runtimeId: input.runtimeArchive.runtimeId, reason: stagedProbe.reason }; | |
| 391 | 406 | } else { |
| 392 | 407 | yield* Effect.logWarning( |
| 393 | 408 | "Could not stage the WSL runtime; launching from the mounted server tree instead.", |
| ... | ... | @@ -399,7 +414,7 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f |
| 399 | 414 | const mounted = yield* resolveMountedAppRoot; |
| 400 | 415 | if (!mounted.ok) { |
| 401 | 416 | return stagedFailure && mounted.fatal |
| 402 | ? failedNodePty(stagedFailure.nodePty) | |
| 417 | ? failedStaged(stagedFailure) | |
| 403 | 418 | : ({ _tag: "Failed", reason: mounted.reason, fatal: mounted.fatal } as const); |
| 404 | 419 | } |
| 405 | 420 | |
| ... | ... | @@ -413,9 +428,9 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f |
| 413 | 428 | // turn a retryable failure into a fatal one, ending the WSL attempt (and, |
| 414 | 429 | // in wsl-only mode, persisting Windows) before the slow /mnt path had a |
| 415 | 430 | // chance to answer and clear the bad cache. |
| 416 | return failedNodePty( | |
| 417 | stagedFailure && nodePtyResult.fatal ? stagedFailure.nodePty : nodePtyResult, | |
| 418 | ); | |
| 431 | return stagedFailure && nodePtyResult.fatal | |
| 432 | ? failedStaged(stagedFailure) | |
| 433 | : failedNodePty(nodePtyResult); | |
| 419 | 434 | } |
| 420 | 435 | |
| 421 | 436 | // The mounted tree runs what the cache could not, so the cache is the broken |
| ... | ... | @@ -429,8 +444,11 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f |
| 429 | 444 | _tag: "Ready", |
| 430 | 445 | runningDistro, |
| 431 | 446 | windowsEntryPath: mounted.windowsEntryPath, |
| 432 | linuxEntryPath: `${mounted.linuxAppRoot}/apps/server/dist/bin.mjs`, | |
| 433 | nodePath: nodePtyResult.nodePath, | |
| 447 | runtime: { | |
| 448 | kind: "node-script", | |
| 449 | nodePath: nodePtyResult.nodePath, | |
| 450 | linuxEntryPath: `${mounted.linuxAppRoot}/apps/server/dist/bin.mjs`, | |
| 451 | }, | |
| 434 | 452 | resolvedPath: nodePtyResult.resolvedPath, |
| 435 | 453 | } as const; |
| 436 | 454 | }); |
| ... | ... | @@ -610,13 +628,13 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl |
| 610 | 628 | runtimeId: `sha256-${archiveHash}`, |
| 611 | 629 | sha256: archiveHash, |
| 612 | 630 | }, |
| 613 | // Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and | |
| 614 | // attached to the Windows artifact — see build-desktop-artifact.ts), so the | |
| 615 | // WSL backend never needs a compiler, node-gyp, or network on first launch. | |
| 616 | // Compiling from source is a dev-only convenience: a checkout has no shipped | |
| 617 | // prebuilt, and developers have the toolchain. In packaged builds we instead | |
| 618 | // surface a clear diagnostic if the prebuilt can't load (unsupported | |
| 619 | // arch/distro), rather than silently dropping into a fragile runtime build. | |
| 631 | // Packaged builds run the self-contained Linux runtime and, on fallback, | |
| 632 | // whatever Linux node-pty the mounted tree carries, so the WSL backend never | |
| 633 | // needs a compiler, node-gyp, or network on first launch. Compiling from | |
| 634 | // source is a dev-only convenience: a checkout has no Linux binary, and | |
| 635 | // developers have the toolchain. In packaged builds we instead surface a | |
| 636 | // clear diagnostic if the binary can't load (unsupported arch/distro), | |
| 637 | // rather than silently dropping into a fragile runtime build. | |
| 620 | 638 | allowBuild: !environment.isPackaged, |
| 621 | 639 | }); |
| 622 | 640 | |
| ... | ... | @@ -709,15 +727,23 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl |
| 709 | 727 | |
| 710 | 728 | // The WSL server spawns commands its providers reference by name — `npm`/`npx` |
| 711 | 729 | // for provider updates, and the installed CLIs themselves (e.g. `codex`). Those |
| 712 | // live in the resolved Node's bin dir, which `wsl.exe -- node` does NOT put on | |
| 730 | // live on the user's login-shell PATH, which `wsl.exe --exec` does NOT put on | |
| 713 | 731 | // the process PATH, so `npm install -g ...` fails with NotFound. Pass the |
| 714 | // user PATH entries captured by the login-shell preflight. Every dynamic | |
| 715 | // value is a separate argv entry under `wsl.exe --exec`; no shell command is | |
| 716 | // involved, so Windows cannot mangle nested quotes and stdin remains reserved | |
| 717 | // for the bootstrap envelope. | |
| 718 | const lastSlash = preflight.nodePath.lastIndexOf("/"); | |
| 719 | const nodeBinDir = lastSlash > 0 ? preflight.nodePath.slice(0, lastSlash) : "/usr/bin"; | |
| 720 | const launchPath = `${nodeBinDir}:${WSL_SERVER_SYSTEM_PATH}:${preflight.resolvedPath}`; | |
| 732 | // user PATH entries captured by the preflight. Every dynamic value is a | |
| 733 | // separate argv entry under `wsl.exe --exec`; no shell command is involved, | |
| 734 | // so Windows cannot mangle nested quotes and stdin remains reserved for the | |
| 735 | // bootstrap envelope. A node-script runtime additionally leads with the | |
| 736 | // probed Node's bin dir so the server cannot pick up a different node than | |
| 737 | // the one node-pty was probed with. | |
| 738 | const runtime = preflight.runtime; | |
| 739 | const launchPath = | |
| 740 | runtime.kind === "executable" | |
| 741 | ? `${WSL_SERVER_SYSTEM_PATH}:${preflight.resolvedPath}` | |
| 742 | : `${nodeBinDirOf(runtime.nodePath)}:${WSL_SERVER_SYSTEM_PATH}:${preflight.resolvedPath}`; | |
| 743 | const command = | |
| 744 | runtime.kind === "executable" | |
| 745 | ? [runtime.entryPath] | |
| 746 | : [runtime.nodePath, runtime.linuxEntryPath]; | |
| 721 | 747 | |
| 722 | 748 | return { |
| 723 | 749 | ...baseConfig, |
| ... | ... | @@ -726,8 +752,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl |
| 726 | 752 | "--exec", |
| 727 | 753 | "env", |
| 728 | 754 | `PATH=${launchPath}`, |
| 729 | preflight.nodePath, | |
| 730 | preflight.linuxEntryPath, | |
| 755 | ...command, | |
| 731 | 756 | "--bootstrap-fd", |
| 732 | 757 | "0", |
| 733 | 758 | ...devUrlArgs, |
apps/desktop/src/wsl/DesktopWslEnvironment.test.ts+42-45| ... | ... | @@ -74,7 +74,9 @@ const readField = (stdout: string, field: string) => { |
| 74 | 74 | return line.slice(field.length + 1).trim(); |
| 75 | 75 | }; |
| 76 | 76 | |
| 77 | const SERVER_ENTRY_SOURCE = 'console.log("t3code wsl runtime test server");'; | |
| 77 | // Stands in for the release's self-contained `t3` executable: the install | |
| 78 | // script only asks it for `--version`. | |
| 79 | const SERVER_ENTRY_SOURCE = '#!/bin/sh\necho "t3code wsl runtime test server 0.0.0"\n'; | |
| 78 | 80 | |
| 79 | 81 | const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) => |
| 80 | 82 | ChildProcessSpawner.make(() => |
| ... | ... | @@ -164,21 +166,22 @@ describe("WSL runtime cache", () => { |
| 164 | 166 | |
| 165 | 167 | expect(script).toContain('runtime_parent="$HOME/.t3/wsl-runtime"'); |
| 166 | 168 | expect(script).toContain(' [ -f "$ready_marker" ] &&'); |
| 167 | expect(script).toContain(' [ -f "$runtime_root/apps/server/dist/bin.mjs" ] &&'); | |
| 168 | expect(script).toContain(' [ -f "$runtime_root/node_modules/node-pty/package.json" ] &&'); | |
| 169 | expect(script).toContain(' node_pty_payload_present "$runtime_root"'); | |
| 169 | expect(script).toContain(' runtime_entry_runs "$runtime_root" &&'); | |
| 170 | 170 | expect(script).toContain("if runtime_is_ready; then"); |
| 171 | expect(script).not.toContain("bin.mjs"); | |
| 172 | expect(script).not.toContain("node-pty"); | |
| 171 | 173 | expect(script).toContain("trap 'exit 1' HUP INT TERM"); |
| 172 | 174 | expect(script).toContain('exec 9> "$runtime_lock"'); |
| 173 | 175 | expect(script).toContain("flock -x 9"); |
| 174 | 176 | expect(script).not.toContain('rm -rf "$runtime_lock"'); |
| 175 | 177 | expect(script).toContain('mv -T "$runtime_root" "$runtime_stale"'); |
| 176 | 178 | expect(script).toContain('mktemp -d "$runtime_parent/.1.2.3-x64.tmp.XXXXXX"'); |
| 179 | // The release archive wraps everything in one `t3-<version>-linux-x64/` | |
| 180 | // directory; stripping it puts the executable at `$runtime_root/t3`. | |
| 177 | 181 | expect(script).toContain( |
| 178 | "tar -xzf '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' -C \"$runtime_tmp\"", | |
| 182 | "tar -xzf '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' -C \"$runtime_tmp\" --strip-components=1", | |
| 179 | 183 | ); |
| 180 | expect(script).toContain('test -f "$runtime_tmp/apps/server/dist/bin.mjs"'); | |
| 181 | expect(script).toContain('test -f "$runtime_tmp/node_modules/node-pty/package.json"'); | |
| 184 | expect(script).toContain('if ! runtime_entry_runs "$runtime_tmp"; then'); | |
| 182 | 185 | expect(script).toContain('mv -T "$runtime_tmp" "$runtime_root"'); |
| 183 | 186 | expect(script).not.toContain('rm -rf "$runtime_root"'); |
| 184 | 187 | |
| ... | ... | @@ -248,46 +251,39 @@ describe("WSL runtime cache", () => { |
| 248 | 251 | expect(deleted).toBeGreaterThan(kept); |
| 249 | 252 | }); |
| 250 | 253 | |
| 251 | it("treats a runtime whose native payload went missing as a cache miss", () => { | |
| 254 | it("treats a runtime whose executable no longer runs as a cache miss", () => { | |
| 252 | 255 | const script = buildWslRuntimeInstallScript( |
| 253 | 256 | "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", |
| 254 | 257 | "1.2.3-x64", |
| 255 | 258 | "b".repeat(64), |
| 256 | 259 | ); |
| 257 | 260 | |
| 258 | // A glob, not a mapped `uname -m`: this is a presence check, and the later | |
| 259 | // native probe is what judges arch and loadability. | |
| 260 | expect(script).toContain( | |
| 261 | ' for candidate in "$1"/node_modules/node-pty/prebuilds/linux-*/pty.node; do', | |
| 262 | ); | |
| 263 | // The marker the probe reads must sit beside the binary, or the runtime is | |
| 264 | // just as unusable as one missing pty.node outright. | |
| 265 | expect(script).toContain(' [ -f "${candidate%/*}/t3code-wsl-node-pty.json" ] || continue'); | |
| 261 | // The same proof the SSH runner and CLI installers use: executable, and | |
| 262 | // `--version` exits 0. That is what decides arch and loadability, so no | |
| 263 | // separate native probe is needed. | |
| 264 | expect(script).toContain(' [ -x "$1/t3" ] && "$1/t3" --version >/dev/null 2>&1'); | |
| 266 | 265 | |
| 267 | // Readiness gates the short-circuit, so a cache missing the payload | |
| 266 | // Readiness gates the short-circuit, so a cache whose executable broke | |
| 268 | 267 | // reinstalls from the archive instead of being reused forever. |
| 269 | const payloadCheckDefined = script.indexOf("node_pty_payload_present() {"); | |
| 268 | const entryCheckDefined = script.indexOf("runtime_entry_runs() {"); | |
| 270 | 269 | const readinessDefined = script.indexOf("runtime_is_ready() {"); |
| 271 | 270 | const readyShortCircuit = script.indexOf("if runtime_is_ready; then"); |
| 272 | expect(payloadCheckDefined).toBeGreaterThan(-1); | |
| 273 | expect(payloadCheckDefined).toBeLessThan(readinessDefined); | |
| 271 | expect(entryCheckDefined).toBeGreaterThan(-1); | |
| 272 | expect(entryCheckDefined).toBeLessThan(readinessDefined); | |
| 274 | 273 | expect(readinessDefined).toBeLessThan(readyShortCircuit); |
| 275 | 274 | }); |
| 276 | 275 | |
| 277 | // A truncated or half-written bin.mjs passes every presence check the cache | |
| 278 | // had: the file exists, node-pty still loads, and launch then picks a server | |
| 279 | // that exits before it becomes ready — forever, because nothing ever | |
| 280 | // reinstalls. The digest the install records is what turns that into a miss. | |
| 281 | it("re-hashes the server entry against the digest the install recorded", () => { | |
| 276 | // A swapped or half-written `t3` can still exist and even still answer | |
| 277 | // `--version`, and launch then runs something this install never verified. | |
| 278 | // The digest the install records is what turns that into a miss. | |
| 279 | it("re-hashes the executable against the digest the install recorded", () => { | |
| 282 | 280 | const script = buildWslRuntimeInstallScript( |
| 283 | 281 | "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", |
| 284 | 282 | "1.2.3-x64", |
| 285 | 283 | "b".repeat(64), |
| 286 | 284 | ); |
| 287 | 285 | |
| 288 | expect(script).toContain( | |
| 289 | ` sha256sum "$1/apps/server/dist/bin.mjs" 2>/dev/null | cut -d ' ' -f 1`, | |
| 290 | ); | |
| 286 | expect(script).toContain(` sha256sum "$1/t3" 2>/dev/null | cut -d ' ' -f 1`); | |
| 291 | 287 | expect(script).toContain( |
| 292 | 288 | ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]', |
| 293 | 289 | ); |
| ... | ... | @@ -310,18 +306,18 @@ describe("WSL runtime cache", () => { |
| 310 | 306 | expect(promoted).toBeGreaterThan(markerWritten); |
| 311 | 307 | }); |
| 312 | 308 | |
| 313 | it("refuses to mark an archive without a native payload as ready", () => { | |
| 309 | it("refuses to mark an archive whose executable does not run as ready", () => { | |
| 314 | 310 | const script = buildWslRuntimeInstallScript( |
| 315 | 311 | "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", |
| 316 | 312 | "1.2.3-x64", |
| 317 | 313 | "b".repeat(64), |
| 318 | 314 | ); |
| 319 | 315 | |
| 320 | expect(script).toContain('if ! node_pty_payload_present "$runtime_tmp"; then'); | |
| 316 | expect(script).toContain('if ! runtime_entry_runs "$runtime_tmp"; then'); | |
| 321 | 317 | |
| 322 | 318 | // The extracted tree is rejected before the ready marker is written, so a |
| 323 | 319 | // defective archive falls back to the mounted tree instead of caching. |
| 324 | const payloadValidated = script.indexOf('node_pty_payload_present "$runtime_tmp"'); | |
| 320 | const payloadValidated = script.indexOf('runtime_entry_runs "$runtime_tmp"'); | |
| 325 | 321 | const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"'); |
| 326 | 322 | const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"'); |
| 327 | 323 | expect(payloadValidated).toBeGreaterThan(-1); |
| ... | ... | @@ -351,7 +347,7 @@ describe("WSL runtime cache", () => { |
| 351 | 347 | it("never deletes a runtime another backend is running from", () => { |
| 352 | 348 | const script = buildWslRuntimePruneScript("1.2.3/x64"); |
| 353 | 349 | |
| 354 | // The running backend's argv holds `<runtime>/apps/server/dist/bin.mjs`, so | |
| 350 | // The running backend's argv holds `<runtime>/t3`, so | |
| 355 | 351 | // the process itself is the lease and exiting releases it. Nothing has to be |
| 356 | 352 | // registered up front, which is what makes this cover backends already |
| 357 | 353 | // running from an older version that knows nothing about pruning. |
| ... | ... | @@ -394,7 +390,7 @@ describe("WSL runtime cache", () => { |
| 394 | 390 | }); |
| 395 | 391 | |
| 396 | 392 | // Reading the generated script proves what it says, not what it does. A cache |
| 397 | // whose bin.mjs was truncated satisfied every assertion above and still got | |
| 393 | // whose entry was truncated satisfied every assertion above and still got | |
| 398 | 394 | // reused, so these run the real script against a real archive in a throwaway |
| 399 | 395 | // HOME and check the outcome. |
| 400 | 396 | describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed)", () => { |
| ... | ... | @@ -410,13 +406,14 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed |
| 410 | 406 | [ |
| 411 | 407 | "set -eu", |
| 412 | 408 | "work=$(mktemp -d)", |
| 413 | 'stage="$work/stage"', | |
| 414 | 'mkdir -p "$stage/apps/server/dist" "$stage/node_modules/node-pty/prebuilds/linux-x64" "$work/home"', | |
| 415 | `printf '%s' ${sh(SERVER_ENTRY_SOURCE)} > "$stage/apps/server/dist/bin.mjs"`, | |
| 416 | `printf '%s' '{"name":"node-pty","version":"0.0.0-test"}' > "$stage/node_modules/node-pty/package.json"`, | |
| 417 | `printf '%s' 'pty-native-payload' > "$stage/node_modules/node-pty/prebuilds/linux-x64/pty.node"`, | |
| 418 | `printf '%s' '{"arch":"x64"}' > "$stage/node_modules/node-pty/prebuilds/linux-x64/t3code-wsl-node-pty.json"`, | |
| 419 | `tar -czf "$work/wsl-runtime.tar.gz" -C "$stage" apps/server/dist node_modules`, | |
| 409 | // Mirrors the release archive: one top-level versioned directory that | |
| 410 | // holds the executable and its native addons. | |
| 411 | 'stage="$work/stage/t3-0.0.0-linux-x64"', | |
| 412 | 'mkdir -p "$stage/node_modules/node-pty/build/Release" "$work/home"', | |
| 413 | `printf '%s' ${sh(SERVER_ENTRY_SOURCE)} > "$stage/t3"`, | |
| 414 | 'chmod +x "$stage/t3"', | |
| 415 | `printf '%s' 'pty-native-payload' > "$stage/node_modules/node-pty/build/Release/pty.node"`, | |
| 416 | `tar -czf "$work/wsl-runtime.tar.gz" -C "$work/stage" t3-0.0.0-linux-x64`, | |
| 420 | 417 | `printf 'work:%s\\n' "$work"`, |
| 421 | 418 | `printf 'archiveSha:%s\\n' "$(sha256sum "$work/wsl-runtime.tar.gz" | cut -d ' ' -f 1)"`, |
| 422 | 419 | ].join("\n"), |
| ... | ... | @@ -443,7 +440,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed |
| 443 | 440 | runtimeId, |
| 444 | 441 | runtimeParent: `${work}/home/.t3/wsl-runtime`, |
| 445 | 442 | runtimeRoot: `${work}/home/.t3/wsl-runtime/${runtimeId}`, |
| 446 | serverEntry: `${work}/home/.t3/wsl-runtime/${runtimeId}/apps/server/dist/bin.mjs`, | |
| 443 | serverEntry: `${work}/home/.t3/wsl-runtime/${runtimeId}/t3`, | |
| 447 | 444 | installScript, |
| 448 | 445 | install: (archive?: string, sha?: string) => runShell(installScript(archive, sha)), |
| 449 | 446 | }; |
| ... | ... | @@ -462,7 +459,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed |
| 462 | 459 | expect(parseWslRuntimeRoot(warm.stdout)).toBe(fixture.runtimeRoot); |
| 463 | 460 | }); |
| 464 | 461 | |
| 465 | it("reinstalls a cache whose server entry was truncated", () => { | |
| 462 | it("reinstalls a cache whose executable was truncated", () => { | |
| 466 | 463 | const fixture = createFixture(); |
| 467 | 464 | expect(fixture.install().status).toBe(0); |
| 468 | 465 | expect(runShell(`set -eu\n: > ${sh(fixture.serverEntry)}`).status).toBe(0); |
| ... | ... | @@ -645,7 +642,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed |
| 645 | 642 | `runtime_root=${sh(fixture.runtimeRoot)}`, |
| 646 | 643 | `runtime_parent=${sh(fixture.runtimeParent)}`, |
| 647 | 644 | 'rm "$runtime_root/.t3code-wsl-runtime-ready"', |
| 648 | 'sh -c "sleep 30" "$runtime_root/apps/server/dist/bin.mjs" >/dev/null 2>&1 &', | |
| 645 | 'sh -c "sleep 30" "$runtime_root/t3" >/dev/null 2>&1 &', | |
| 649 | 646 | "active_pid=$!", |
| 650 | 647 | "sleep 0.1", |
| 651 | 648 | fixture.installScript(), |
| ... | ... | @@ -672,7 +669,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed |
| 672 | 669 | 'home="$work/home"', |
| 673 | 670 | 'runtime_parent="$home/.t3/wsl-runtime"', |
| 674 | 671 | 'mkdir -p "$runtime_parent"', |
| 675 | 'make_ready() { mkdir -p "$runtime_parent/$1/apps/server/dist"; printf ready > "$runtime_parent/$1/.t3code-wsl-runtime-ready"; }', | |
| 672 | 'make_ready() { mkdir -p "$runtime_parent/$1"; printf ready > "$runtime_parent/$1/.t3code-wsl-runtime-ready"; }', | |
| 676 | 673 | "make_ready sha256-current", |
| 677 | 674 | "make_ready sha256-previous", |
| 678 | 675 | "make_ready sha256-active", |
| ... | ... | @@ -683,7 +680,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed |
| 683 | 680 | 'touch -d "4 minutes ago" "$runtime_parent/sha256-active"', |
| 684 | 681 | 'touch -d "3 minutes ago" "$runtime_parent/sha256-old"', |
| 685 | 682 | 'touch -d "2 minutes ago" "$runtime_parent/sha256-locked"', |
| 686 | 'sh -c "sleep 30" "$runtime_parent/sha256-active/apps/server/dist/bin.mjs" >/dev/null 2>&1 &', | |
| 683 | 'sh -c "sleep 30" "$runtime_parent/sha256-active/t3" >/dev/null 2>&1 &', | |
| 687 | 684 | "active_pid=$!", |
| 688 | 685 | "(", |
| 689 | 686 | ' exec 9> "$runtime_parent/.sha256-locked.install.lock"', |
apps/desktop/src/wsl/DesktopWslEnvironment.ts+125-71| ... | ... | @@ -66,6 +66,19 @@ export type EnsureWslNodePtyResult = |
| 66 | 66 | readonly retryLimit?: number; |
| 67 | 67 | }; |
| 68 | 68 | |
| 69 | // Outcome of asking the staged self-contained runtime to prove itself. Any | |
| 70 | // failure sends the launch to the mounted server tree; the caller decides what | |
| 71 | // to do with the cache. | |
| 72 | export type ProbeWslRuntimeResult = | |
| 73 | | { | |
| 74 | readonly ok: true; | |
| 75 | readonly resolvedPath: string; | |
| 76 | } | |
| 77 | | { | |
| 78 | readonly ok: false; | |
| 79 | readonly reason: string; | |
| 80 | }; | |
| 81 | ||
| 69 | 82 | export class DesktopWslDistroListError extends Schema.TaggedError<DesktopWslDistroListError>()( |
| 70 | 83 | "DesktopWslDistroListError", |
| 71 | 84 | { reason: Schema.String }, |
| ... | ... | @@ -108,6 +121,13 @@ export class DesktopWslEnvironment extends Context.Service< |
| 108 | 121 | readonly pruneRuntimes: (distro: string | null, runtimeId: string) => Effect.Effect<void>; |
| 109 | 122 | // Marks a staged runtime as unusable so the next launch reinstalls it. |
| 110 | 123 | readonly invalidateRuntime: (distro: string | null, runtimeId: string) => Effect.Effect<void>; |
| 124 | // Proves a staged self-contained runtime can run (`<root>/t3 --version`) | |
| 125 | // and captures the user's login-shell PATH for the launch. Needs no Node | |
| 126 | // in the distro; the mounted server tree still goes through ensureNodePty. | |
| 127 | readonly probeRuntime: ( | |
| 128 | distro: string | null, | |
| 129 | linuxAppRoot: string, | |
| 130 | ) => Effect.Effect<ProbeWslRuntimeResult>; | |
| 111 | 131 | readonly ensureNodePty: ( |
| 112 | 132 | distro: string | null, |
| 113 | 133 | linuxAppRoot: string, |
| ... | ... | @@ -149,14 +169,15 @@ const TIMEOUT_RESULT: ShellResult = { |
| 149 | 169 | |
| 150 | 170 | const formatWslShellTransportFailureReason = ( |
| 151 | 171 | failure: ShellResult["transportFailure"], |
| 172 | subject = "Node.js", | |
| 152 | 173 | ): string | null => { |
| 153 | 174 | switch (failure) { |
| 154 | 175 | case "timeout": |
| 155 | return "WSL backend preflight timed out while probing for Node.js. WSL may be slow to start; retry, or check that the distro is healthy."; | |
| 176 | return `WSL backend preflight timed out while probing for ${subject}. WSL may be slow to start; retry, or check that the distro is healthy.`; | |
| 156 | 177 | case "spawn": |
| 157 | return "WSL backend preflight could not start wsl.exe to probe for Node.js. Check that WSL is installed and the distro is accessible."; | |
| 178 | return `WSL backend preflight could not start wsl.exe to probe for ${subject}. Check that WSL is installed and the distro is accessible.`; | |
| 158 | 179 | case "process": |
| 159 | return "WSL backend preflight lost communication with wsl.exe while probing for Node.js. Retry, or check that the distro is healthy."; | |
| 180 | return `WSL backend preflight lost communication with wsl.exe while probing for ${subject}. Retry, or check that the distro is healthy.`; | |
| 160 | 181 | case null: |
| 161 | 182 | return null; |
| 162 | 183 | } |
| ... | ... | @@ -255,7 +276,7 @@ const runWslShell = ( |
| 255 | 276 | |
| 256 | 277 | const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; |
| 257 | 278 | |
| 258 | // Holds the sha256 of the runtime's server entry, written when the install | |
| 279 | // Holds the sha256 of the runtime's `t3` executable, written when the install | |
| 259 | 280 | // promotes a verified tree. Presence alone only says an install once finished |
| 260 | 281 | // here; the digest is what lets a later launch prove the entry still is what |
| 261 | 282 | // that install wrote. |
| ... | ... | @@ -279,35 +300,25 @@ export const buildWslRuntimeInstallScript = ( |
| 279 | 300 | 'runtime_parent="$HOME/.t3/wsl-runtime"', |
| 280 | 301 | `runtime_root="$runtime_parent/${safeRuntimeId}"`, |
| 281 | 302 | `ready_marker="$runtime_root/${WSL_RUNTIME_READY_MARKER}"`, |
| 282 | // The native payload is the part of the tree the WSL backend actually | |
| 283 | // dlopens, and the only part a user can plausibly break by hand. Checking | |
| 284 | // node-pty's package.json alone let a runtime whose pty.node had gone | |
| 285 | // missing stay cache-ready forever: every launch reused it and then failed | |
| 286 | // the native probe, with no reinstall and no fallback. Match on the glob | |
| 287 | // rather than a mapped `uname -m` so this stays a presence check; the probe | |
| 288 | // is what decides whether the binary is the right arch and loadable. | |
| 289 | "node_pty_payload_present() {", | |
| 290 | ' for candidate in "$1"/node_modules/node-pty/prebuilds/linux-*/pty.node; do', | |
| 291 | ' [ -f "$candidate" ] || continue', | |
| 292 | ' [ -f "${candidate%/*}/t3code-wsl-node-pty.json" ] || continue', | |
| 293 | " return 0", | |
| 294 | " done", | |
| 295 | " return 1", | |
| 303 | // The runtime is a self-contained `t3` executable with Node inside, so the | |
| 304 | // readiness proof is the same one the SSH runner and the CLI installers | |
| 305 | // use: the file is executable and `t3 --version` exits 0. That covers the | |
| 306 | // truncated-binary and wrong-arch cases without a separate native probe. | |
| 307 | "runtime_entry_runs() {", | |
| 308 | ' [ -x "$1/t3" ] && "$1/t3" --version >/dev/null 2>&1', | |
| 296 | 309 | "}", |
| 297 | // Hashing the server entry is the only check that can tell a working cache | |
| 298 | // from one whose bin.mjs was truncated or half-written: the file is still | |
| 299 | // there, the native probe still passes, and launch then picks a server that | |
| 300 | // exits before it can become ready, on every restart. Hashing the ~7MB | |
| 301 | // entry measures in single-digit milliseconds inside the distro, once per | |
| 302 | // launch, against a cold reinstall of a few hundred megabytes. | |
| 310 | // Hashing the entry is what tells a working cache from one whose `t3` was | |
| 311 | // swapped or half-written after install: the file is still there and may | |
| 312 | // even still run, and launch then picks an executable that is not what | |
| 313 | // this install verified. Hashing the executable measures in tens of | |
| 314 | // milliseconds inside the distro, once per launch, against a cold | |
| 315 | // reinstall of a few hundred megabytes. | |
| 303 | 316 | "runtime_server_entry_digest() {", |
| 304 | ` sha256sum "$1/apps/server/dist/bin.mjs" 2>/dev/null | cut -d ' ' -f 1`, | |
| 317 | ` sha256sum "$1/t3" 2>/dev/null | cut -d ' ' -f 1`, | |
| 305 | 318 | "}", |
| 306 | 319 | "runtime_is_ready() {", |
| 307 | 320 | ' [ -f "$ready_marker" ] &&', |
| 308 | ' [ -f "$runtime_root/apps/server/dist/bin.mjs" ] &&', | |
| 309 | ' [ -f "$runtime_root/node_modules/node-pty/package.json" ] &&', | |
| 310 | ' node_pty_payload_present "$runtime_root" &&', | |
| 321 | ' runtime_entry_runs "$runtime_root" &&', | |
| 311 | 322 | // An empty or unreadable marker is a miss, not a pass: that is what a |
| 312 | 323 | // runtime installed before the marker carried a digest looks like, and one |
| 313 | 324 | // reinstall is the cheapest way to make it verifiable from then on. |
| ... | ... | @@ -370,15 +381,14 @@ export const buildWslRuntimeInstallScript = ( |
| 370 | 381 | `runtime_tmp=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.tmp.XXXXXX")`, |
| 371 | 382 | 'cleanup_runtime_install() { rm -rf "$runtime_tmp"; }', |
| 372 | 383 | "trap cleanup_runtime_install EXIT", |
| 373 | `tar -xzf ${shellQuote(linuxArchivePath)} -C "$runtime_tmp"`, | |
| 374 | 'test -f "$runtime_tmp/apps/server/dist/bin.mjs"', | |
| 375 | 'test -f "$runtime_tmp/node_modules/node-pty/package.json"', | |
| 376 | ||
| 377 | // Never write the ready marker over a tree that is missing the native | |
| 378 | // payload. Failing here drops out to the mounted-tree fallback, which is | |
| 384 | // The release archive has one top-level `t3-<version>-linux-<arch>/` | |
| 385 | // directory; strip it so the executable lands at `$runtime_root/t3`. | |
| 386 | `tar -xzf ${shellQuote(linuxArchivePath)} -C "$runtime_tmp" --strip-components=1`, | |
| 387 | // Never write the ready marker over a tree whose executable does not run. | |
| 388 | // Failing here drops out to the mounted-tree fallback, which is | |
| 379 | 389 | // recoverable; promoting it would mark the defect ready and cache it. |
| 380 | 'if ! node_pty_payload_present "$runtime_tmp"; then', | |
| 381 | " printf 'WSL runtime archive is missing its Linux node-pty binary\\n' >&2", | |
| 390 | 'if ! runtime_entry_runs "$runtime_tmp"; then', | |
| 391 | " printf 'WSL runtime archive does not contain a working t3 executable\\n' >&2", | |
| 382 | 392 | " exit 1", |
| 383 | 393 | "fi", |
| 384 | 394 | // The archive's bytes were verified against archiveSha256 above, so the |
| ... | ... | @@ -466,12 +476,12 @@ export const buildWslRuntimePruneScript = (runtimeId: string): string => { |
| 466 | 476 | }; |
| 467 | 477 | |
| 468 | 478 | // Drops the ready marker so the next launch reinstalls the runtime from the |
| 469 | // archive. Readiness is a presence check by design, so a cached tree whose | |
| 470 | // native payload is present but unloadable (truncated pty.node, a distro whose | |
| 471 | // glibc the binary needs and the tree was copied from another machine) stays | |
| 472 | // ready forever and fails the probe on every launch. Only the probe can see | |
| 473 | // that, so the probe is what revokes the marker. The tree itself is left in | |
| 474 | // place: the install script moves an unready root aside before extracting. | |
| 479 | // archive. Readiness is decided inside the install script, so a cached tree | |
| 480 | // that passes there but fails the launch-time probe (a distro whose glibc the | |
| 481 | // executable needs, a tree copied from another machine) would stay ready | |
| 482 | // forever and fail on every launch. Only the probe can see that, so the probe | |
| 483 | // is what revokes the marker. The tree itself is left in place: the install | |
| 484 | // script moves an unready root aside before extracting. | |
| 475 | 485 | export const buildWslRuntimeInvalidateScript = (runtimeId: string): string => { |
| 476 | 486 | const safeRuntimeId = sanitizeWslRuntimeId(runtimeId); |
| 477 | 487 | return [ |
| ... | ... | @@ -488,22 +498,29 @@ export const parseWslRuntimeRoot = (stdout: string): string | null => { |
| 488 | 498 | return runtimeRoot.startsWith("/") ? runtimeRoot : null; |
| 489 | 499 | }; |
| 490 | 500 | |
| 491 | const NODE_PTY_PREBUILD_MISSING_EXIT_CODE = 4; | |
| 501 | // The mounted server tree carries no Linux pty.node unless the build put one | |
| 502 | // there. Distinct from a binary that is present but will not load, which is a | |
| 503 | // distro problem rather than a build problem. | |
| 504 | const NODE_PTY_BINARY_MISSING_EXIT_CODE = 4; | |
| 492 | 505 | |
| 493 | 506 | const formatNodePtyProbeFailureReason = (exitCode: number): string | null => |
| 494 | exitCode === NODE_PTY_PREBUILD_MISSING_EXIT_CODE | |
| 495 | ? "WSL support is missing from this T3 Code build: the packaged Linux node-pty binary was not included. Rebuild the Windows artifact with `--wsl-prebuild <path-to-linux-pty.node>` or install a build that includes WSL support." | |
| 507 | exitCode === NODE_PTY_BINARY_MISSING_EXIT_CODE | |
| 508 | ? "WSL support is missing from this T3 Code build: the packaged Linux node-pty binary was not included. Install a build that includes WSL support." | |
| 496 | 509 | : null; |
| 497 | 510 | |
| 511 | // Captures the login-shell PATH as `resolvedPath:` so the launch can forward the | |
| 512 | // user's PATH; the server spawns provider CLIs (`codex`, `claude`) by name. | |
| 513 | const RESOLVED_PATH_LINE = `printf 'resolvedPath:%s\\n' "$PATH"`; | |
| 514 | ||
| 498 | 515 | const NODE_PTY_PROBE_SCRIPT = ( |
| 499 | 516 | linuxServerDir: string, |
| 500 | 517 | ) => `printf 'nodePath:%s\\n' "$(command -v node 2>/dev/null)" |
| 501 | 518 | printf 'nodeVersion:%s\\n' "$(node -p 'process.versions.node' 2>/dev/null)" |
| 502 | printf 'resolvedPath:%s\\n' "$PATH" | |
| 519 | ${RESOLVED_PATH_LINE} | |
| 503 | 520 | cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1 |
| 504 | 521 | // The WSL Node can't read inside app.asar, so confirm what the server needs is |
| 505 | 522 | // unpacked on the real filesystem before reporting the backend healthy. Exit 3 |
| 506 | // marks this distinct from a node-pty prebuild problem so the caller can report | |
| 523 | // marks this distinct from a node-pty binary problem so the caller can report | |
| 507 | 524 | // it accurately instead of letting the server crash on ERR_MODULE_NOT_FOUND at |
| 508 | 525 | // launch (which, in wsl-only mode, would just fail to launch with no fallback). |
| 509 | 526 | // |
| ... | ... | @@ -517,26 +534,27 @@ const fs = require("node:fs"); |
| 517 | 534 | const path = require("node:path"); |
| 518 | 535 | const pkgDir = path.dirname(require.resolve("node-pty/package.json")); |
| 519 | 536 | // node-pty 1.x is N-API based, so a single Linux pty.node is ABI-stable across |
| 520 | // Node versions — require() succeeding IS the real compatibility test. Compare | |
| 521 | // only arch and node-pty version (a stale binary from a different node-pty), | |
| 522 | // NOT process.versions.modules: that would reject a perfectly loadable prebuilt | |
| 523 | // whenever the user's WSL Node ABI differs from the build's, defeating the | |
| 524 | // whole point of shipping one prebuilt for all Node versions. | |
| 525 | const expected = { | |
| 526 | arch: process.arch, | |
| 527 | nodePtyVersion: require("node-pty/package.json").version, | |
| 528 | }; | |
| 529 | const prebuildDir = path.join(pkgDir, "prebuilds", "linux-" + process.arch); | |
| 530 | const marker = path.join(prebuildDir, "t3code-wsl-node-pty.json"); | |
| 531 | const binary = path.join(prebuildDir, "pty.node"); | |
| 532 | if (!fs.existsSync(marker) || !fs.existsSync(binary)) process.exit(${NODE_PTY_PREBUILD_MISSING_EXIT_CODE}); | |
| 537 | // Node versions — require() succeeding IS the real compatibility test. Look in | |
| 538 | // the same places node-pty's own loader does. | |
| 539 | const candidates = [ | |
| 540 | path.join(pkgDir, "build", "Release", "pty.node"), | |
| 541 | path.join(pkgDir, "prebuilds", "linux-" + process.arch, "pty.node"), | |
| 542 | ]; | |
| 543 | if (!candidates.some((candidate) => fs.existsSync(candidate))) process.exit(${NODE_PTY_BINARY_MISSING_EXIT_CODE}); | |
| 533 | 544 | require("node-pty"); |
| 534 | const actual = JSON.parse(fs.readFileSync(marker, "utf8")); | |
| 535 | for (const key of Object.keys(expected)) { | |
| 536 | if (actual[key] !== expected[key]) process.exit(2); | |
| 537 | } | |
| 538 | 545 | NODE`; |
| 539 | 546 | |
| 547 | // Readiness proof for a staged self-contained runtime: the executable runs and | |
| 548 | // reports its version, and the login shell's PATH is captured for the launch. | |
| 549 | // This runs under plain `sh` (no Node resolver preamble, since the runtime | |
| 550 | // needs no Node), so the login shell is entered explicitly for the PATH | |
| 551 | // capture; a distro without bash falls back to the PATH sh was started with. | |
| 552 | const RUNTIME_PROBE_SCRIPT = (linuxAppRoot: string) => | |
| 553 | [ | |
| 554 | `bash -lc ${shellQuote(RESOLVED_PATH_LINE)} 2>/dev/null || ${RESOLVED_PATH_LINE}`, | |
| 555 | `${shellQuote(`${linuxAppRoot}/t3`)} --version >/dev/null 2>&1`, | |
| 556 | ].join("\n"); | |
| 557 | ||
| 540 | 558 | const TOOLCHAIN_CHECK_SCRIPT = [ |
| 541 | 559 | "for tool in node make g++ python3; do", |
| 542 | 560 | ' command -v "$tool" >/dev/null 2>&1 || echo "missing:$tool"', |
| ... | ... | @@ -552,15 +570,8 @@ const NODE_PTY_BUILD_SCRIPT = (linuxServerDir: string) => |
| 552 | 570 | "set -e", |
| 553 | 571 | `cd ${shellQuote(linuxServerDir)}`, |
| 554 | 572 | `pkg_dir=$(node -p "require('node:path').dirname(require.resolve('node-pty/package.json'))")`, |
| 555 | `arch=$(node -p "process.arch")`, | |
| 556 | `modules=$(node -p "process.versions.modules")`, | |
| 557 | `node_pty_version=$(node -p "require('node-pty/package.json').version")`, | |
| 558 | 573 | `cd "$pkg_dir"`, |
| 559 | 574 | "npx --yes node-gyp rebuild", |
| 560 | `prebuild_dir="prebuilds/linux-$arch"`, | |
| 561 | `mkdir -p "$prebuild_dir"`, | |
| 562 | `cp build/Release/pty.node "$prebuild_dir/pty.node"`, | |
| 563 | `printf '{"arch":"%s","modules":"%s","nodePtyVersion":"%s"}\\n' "$arch" "$modules" "$node_pty_version" > "$prebuild_dir/t3code-wsl-node-pty.json"`, | |
| 564 | 575 | `node -e 'require("node-pty")'`, |
| 565 | 576 | ].join("\n"); |
| 566 | 577 | |
| ... | ... | @@ -660,6 +671,38 @@ export const formatMissingToolsReason = ( |
| 660 | 671 | return `WSL distro is missing required tools: ${issues.join(", ")}. Install ${remediations.join(" and ")}, then retry.`; |
| 661 | 672 | }; |
| 662 | 673 | |
| 674 | const probeWslRuntimeImpl = ( | |
| 675 | distro: string | null, | |
| 676 | linuxAppRoot: string, | |
| 677 | ): Effect.Effect<ProbeWslRuntimeResult, never, ChildProcessSpawner.ChildProcessSpawner> => | |
| 678 | Effect.gen(function* () { | |
| 679 | const probe = yield* runWslShell(distro, RUNTIME_PROBE_SCRIPT(linuxAppRoot), PROBE_TIMEOUT, { | |
| 680 | resolveNode: false, | |
| 681 | }); | |
| 682 | const transportFailureReason = formatWslShellTransportFailureReason( | |
| 683 | probe.transportFailure, | |
| 684 | "the staged runtime", | |
| 685 | ); | |
| 686 | if (transportFailureReason !== null) { | |
| 687 | return { ok: false, reason: transportFailureReason } as const; | |
| 688 | } | |
| 689 | if (probe.exitCode !== 0) { | |
| 690 | const trimmedTail = probe.stderr.trim().slice(-500); | |
| 691 | return { | |
| 692 | ok: false, | |
| 693 | reason: `${linuxAppRoot}/t3 --version failed (exit ${probe.exitCode})${trimmedTail ? `: ${trimmedTail}` : ""}`, | |
| 694 | } as const; | |
| 695 | } | |
| 696 | const resolvedPath = parseResolvedPath(probe.stdout); | |
| 697 | if (resolvedPath === null) { | |
| 698 | return { | |
| 699 | ok: false, | |
| 700 | reason: "WSL login-shell PATH could not be resolved during backend preflight.", | |
| 701 | } as const; | |
| 702 | } | |
| 703 | return { ok: true, resolvedPath } as const; | |
| 704 | }); | |
| 705 | ||
| 663 | 706 | const ensureNodePtyImpl = ( |
| 664 | 707 | distro: string | null, |
| 665 | 708 | linuxRepoRoot: string, |
| ... | ... | @@ -1133,6 +1176,9 @@ export interface DesktopWslEnvironmentTestStub { |
| 1133 | 1176 | ) => PrepareWslRuntimeResult; |
| 1134 | 1177 | readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect<void>; |
| 1135 | 1178 | readonly invalidateRuntime?: (distro: string | null, runtimeId: string) => Effect.Effect<void>; |
| 1179 | // Defaults to success with a plain PATH: a staged runtime that was prepared | |
| 1180 | // is assumed to run unless the test says otherwise. | |
| 1181 | readonly probeRuntime?: (distro: string | null, linuxAppRoot: string) => ProbeWslRuntimeResult; | |
| 1136 | 1182 | readonly ensureNodePty?: ( |
| 1137 | 1183 | distro: string | null, |
| 1138 | 1184 | linuxAppRoot: string, |
| ... | ... | @@ -1165,6 +1211,10 @@ export const layerTest = (stub: DesktopWslEnvironmentTestStub = {}) => { |
| 1165 | 1211 | pruneRuntimes: (distro, runtimeId) => stub.pruneRuntimes?.(distro, runtimeId) ?? Effect.void, |
| 1166 | 1212 | invalidateRuntime: (distro, runtimeId) => |
| 1167 | 1213 | stub.invalidateRuntime?.(distro, runtimeId) ?? Effect.void, |
| 1214 | probeRuntime: (distro, linuxAppRoot) => | |
| 1215 | Effect.succeed( | |
| 1216 | stub.probeRuntime?.(distro, linuxAppRoot) ?? { ok: true, resolvedPath: "/usr/bin:/bin" }, | |
| 1217 | ), | |
| 1168 | 1218 | ensureNodePty: (distro, linuxAppRoot, options) => |
| 1169 | 1219 | Effect.succeed( |
| 1170 | 1220 | stub.ensureNodePty?.(distro, linuxAppRoot, options) ?? { |
| ... | ... | @@ -1259,6 +1309,10 @@ export const layer = Layer.effect( |
| 1259 | 1309 | provideSpawner(invalidateWslRuntimeImpl(distro, runtimeId)).pipe( |
| 1260 | 1310 | Effect.withSpan("desktop.wsl.invalidateRuntime"), |
| 1261 | 1311 | ), |
| 1312 | probeRuntime: (distro, linuxAppRoot) => | |
| 1313 | provideSpawner(probeWslRuntimeImpl(distro, linuxAppRoot)).pipe( | |
| 1314 | Effect.withSpan("desktop.wsl.probeRuntime"), | |
| 1315 | ), | |
| 1262 | 1316 | ensureNodePty: (distro, linuxAppRoot, options) => |
| 1263 | 1317 | provideSpawner(ensureNodePtyImpl(distro, linuxAppRoot, options)).pipe( |
| 1264 | 1318 | Effect.withSpan("desktop.wsl.ensureNodePty"), |
docs/operations/development.md+3-2| ... | ... | @@ -191,8 +191,9 @@ rustup target add x86_64-pc-windows-msvc |
| 191 | 191 | rustup target add aarch64-pc-windows-msvc |
| 192 | 192 | ``` |
| 193 | 193 | |
| 194 | NSIS is downloaded by electron-builder. WSL support additionally needs a Linux node-pty prebuild; | |
| 195 | see the [release runbook](./release.md#windows-payload-topology-and-update-validation). | |
| 194 | NSIS is downloaded by electron-builder. WSL support additionally needs the Linux CLI archive | |
| 195 | passed as `--wsl-runtime`; see the | |
| 196 | [release runbook](./release.md#windows-payload-topology-and-update-validation). | |
| 196 | 197 | |
| 197 | 198 | ### Signing and passkeys |
| 198 | 199 |
scripts/build-desktop-artifact.test.ts+216-324| ... | ... | @@ -17,7 +17,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 17 | 17 | import { |
| 18 | 18 | BundleNotSelfContainedError, |
| 19 | 19 | BuildCommandFailedError, |
| 20 | buildWslRuntimeArchiveArgs, | |
| 21 | 20 | parseWslRuntimeArchiveMembers, |
| 22 | 21 | DesktopDmgBackgroundSourceMissingError, |
| 23 | 22 | createStageWorkspaceConfig, |
| ... | ... | @@ -88,32 +87,48 @@ import { |
| 88 | 87 | WSL_RUNTIME_ARCHIVE_HASH_NAME, |
| 89 | 88 | WSL_RUNTIME_ARCHIVE_NAME, |
| 90 | 89 | WSL_RUNTIME_EXTRA_RESOURCES, |
| 91 | wslRuntimeArchiveTarTarget, | |
| 90 | WslRuntimeArchiveMissingError, | |
| 91 | wslRuntimeArchiveStem, | |
| 92 | 92 | } from "./build-desktop-artifact.ts"; |
| 93 | 93 | import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; |
| 94 | 94 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 95 | 95 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; |
| 96 | 96 | |
| 97 | // A minimal stand-in for the staged sidecar roots packed into the WSL archive. | |
| 98 | const stageWslRuntimeTreeFixture = Effect.fn("stageWslRuntimeTreeFixture")(function* ( | |
| 99 | root: string, | |
| 100 | serverSource: string, | |
| 101 | ) { | |
| 97 | // A minimal stand-in for the Linux CLI release archive: one top-level | |
| 98 | // directory named after the archive stem holding the executable, the web | |
| 99 | // client, and the runtime externals with node-pty built from source. | |
| 100 | const makeLinuxCliArchiveFixture = Effect.fn("test.makeLinuxCliArchiveFixture")(function* (input: { | |
| 101 | readonly root: string; | |
| 102 | readonly stem: string; | |
| 103 | readonly extraMembers?: ReadonlyArray<string>; | |
| 104 | readonly omitMembers?: ReadonlyArray<string>; | |
| 105 | }) { | |
| 102 | 106 | const fs = yield* FileSystem.FileSystem; |
| 103 | 107 | const path = yield* Path.Path; |
| 104 | yield* fs.makeDirectory(path.join(root, "apps/server/dist"), { recursive: true }); | |
| 105 | yield* fs.writeFileString(path.join(root, "apps/server/dist/bin.mjs"), serverSource); | |
| 106 | yield* fs.makeDirectory(path.join(root, "node_modules/node-pty/prebuilds/linux-x64"), { | |
| 107 | recursive: true, | |
| 108 | }); | |
| 109 | yield* fs.writeFileString( | |
| 110 | path.join(root, "node_modules/node-pty/package.json"), | |
| 111 | '{"name":"node-pty"}\n', | |
| 112 | ); | |
| 113 | yield* fs.writeFileString( | |
| 114 | path.join(root, "node_modules/node-pty/prebuilds/linux-x64/pty.node"), | |
| 115 | "pty", | |
| 108 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 109 | const contentRoot = path.join(input.root, "content"); | |
| 110 | const members = [ | |
| 111 | `${input.stem}/t3`, | |
| 112 | `${input.stem}/client/index.html`, | |
| 113 | `${input.stem}/node_modules/node-pty/package.json`, | |
| 114 | `${input.stem}/node_modules/node-pty/build/Release/pty.node`, | |
| 115 | ...(input.extraMembers ?? []), | |
| 116 | ].filter((member) => !(input.omitMembers ?? []).includes(member)); | |
| 117 | for (const member of members) { | |
| 118 | const memberPath = path.join(contentRoot, member); | |
| 119 | yield* fs.makeDirectory(path.dirname(memberPath), { recursive: true }); | |
| 120 | yield* fs.writeFileString(memberPath, member); | |
| 121 | } | |
| 122 | const archivePath = path.join(input.root, `${input.stem}.tar.gz`); | |
| 123 | const tar = yield* spawner.spawn( | |
| 124 | ChildProcess.make("tar", ["-czf", archivePath, "-C", contentRoot, "."], { | |
| 125 | stdin: "ignore", | |
| 126 | stdout: "ignore", | |
| 127 | stderr: "pipe", | |
| 128 | }), | |
| 116 | 129 | ); |
| 130 | assert.equal(Number(yield* tar.exitCode), 0); | |
| 131 | return archivePath; | |
| 117 | 132 | }); |
| 118 | 133 | |
| 119 | 134 | function mockProcess(exitCode: number, stdout = "") { |
| ... | ... | @@ -154,10 +169,12 @@ function iconResizeSpawnerLayer( |
| 154 | 169 | ); |
| 155 | 170 | } |
| 156 | 171 | |
| 172 | const WINDOWS_PAYLOAD_FIXTURE_VERSION = "1.2.3"; | |
| 173 | ||
| 157 | 174 | const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(function* (input: { |
| 158 | 175 | readonly copyUnpackedNatives: boolean; |
| 159 | 176 | readonly serverEntrySource?: string; |
| 160 | readonly wslRuntime?: "valid" | "forbidden" | "bad-digest"; | |
| 177 | readonly wslRuntime?: "valid" | "loose-server-tree" | "missing-pty" | "bad-digest"; | |
| 161 | 178 | }) { |
| 162 | 179 | const fs = yield* FileSystem.FileSystem; |
| 163 | 180 | const path = yield* Path.Path; |
| ... | ... | @@ -195,58 +212,30 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu |
| 195 | 212 | yield* fs.writeFileString(path.join(packagedAppDir, "chrome_crashpad_handler.exe"), "crashpad"); |
| 196 | 213 | |
| 197 | 214 | if (input.wslRuntime !== undefined) { |
| 198 | const wslSourceDir = path.join(tempDir, "wsl-source"); | |
| 199 | const linuxPrebuildDir = path.join(wslSourceDir, "node_modules/node-pty/prebuilds/linux-x64"); | |
| 200 | yield* fs.makeDirectory(path.join(wslSourceDir, "apps/server/dist"), { recursive: true }); | |
| 201 | yield* fs.makeDirectory(linuxPrebuildDir, { recursive: true }); | |
| 202 | yield* fs.writeFileString( | |
| 203 | path.join(wslSourceDir, "apps/server/dist/bin.mjs"), | |
| 204 | "console.log('wsl server');\n", | |
| 205 | ); | |
| 206 | yield* fs.writeFileString( | |
| 207 | path.join(wslSourceDir, "node_modules/node-pty/package.json"), | |
| 208 | '{"name":"node-pty"}', | |
| 209 | ); | |
| 210 | yield* fs.writeFileString(path.join(linuxPrebuildDir, "pty.node"), "linux-pty"); | |
| 211 | yield* fs.writeFileString( | |
| 212 | path.join(linuxPrebuildDir, "t3code-wsl-node-pty.json"), | |
| 213 | '{"arch":"x64"}', | |
| 214 | ); | |
| 215 | if (input.wslRuntime === "forbidden") { | |
| 216 | const windowsPrebuildDir = path.join( | |
| 217 | wslSourceDir, | |
| 218 | "node_modules/node-pty/prebuilds/win32-x64", | |
| 219 | ); | |
| 220 | yield* fs.makeDirectory(windowsPrebuildDir, { recursive: true }); | |
| 221 | yield* fs.writeFileString(path.join(windowsPrebuildDir, "pty.node"), "windows-pty"); | |
| 222 | } | |
| 223 | ||
| 215 | const stem = wslRuntimeArchiveStem(WINDOWS_PAYLOAD_FIXTURE_VERSION, "x64"); | |
| 216 | const sourceArchivePath = | |
| 217 | input.wslRuntime === "loose-server-tree" | |
| 218 | ? // The old hand-rolled runtime: apps/server/dist + node_modules at the | |
| 219 | // archive root, no single stem directory, no `t3` executable. | |
| 220 | yield* makeLinuxCliArchiveFixture({ | |
| 221 | root: path.join(tempDir, "wsl-runtime"), | |
| 222 | stem: "apps", | |
| 223 | omitMembers: ["apps/t3", "apps/client/index.html"], | |
| 224 | extraMembers: ["apps/server/dist/bin.mjs", "node_modules/node-pty/package.json"], | |
| 225 | }) | |
| 226 | : yield* makeLinuxCliArchiveFixture({ | |
| 227 | root: path.join(tempDir, "wsl-runtime"), | |
| 228 | stem, | |
| 229 | ...(input.wslRuntime === "missing-pty" | |
| 230 | ? { omitMembers: [`${stem}/node_modules/node-pty/build/Release/pty.node`] } | |
| 231 | : {}), | |
| 232 | }); | |
| 224 | 233 | const archivePath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_NAME); |
| 225 | 234 | const hashPath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_HASH_NAME); |
| 226 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 227 | const tar = yield* spawner.spawn( | |
| 228 | ChildProcess.make( | |
| 229 | "tar", | |
| 230 | [ | |
| 231 | "-czf", | |
| 232 | wslRuntimeArchiveTarTarget(path.relative(wslSourceDir, archivePath)), | |
| 233 | "apps/server/dist", | |
| 234 | "node_modules", | |
| 235 | ], | |
| 236 | { cwd: wslSourceDir, stdin: "ignore", stdout: "ignore", stderr: "pipe" }, | |
| 237 | ), | |
| 238 | ); | |
| 239 | assert.equal(Number(yield* tar.exitCode), 0); | |
| 240 | const archiveDigest = NodeCrypto.createHash("sha256"); | |
| 241 | yield* fs | |
| 242 | .stream(archivePath) | |
| 243 | .pipe(Stream.runForEach((chunk) => Effect.sync(() => archiveDigest.update(chunk)))); | |
| 244 | yield* fs.writeFileString( | |
| 245 | hashPath, | |
| 246 | input.wslRuntime === "bad-digest" | |
| 247 | ? `${"0".repeat(64)}\n` | |
| 248 | : `${archiveDigest.digest("hex")}\n`, | |
| 249 | ); | |
| 235 | yield* stageWslRuntimeArchive({ sourceArchivePath, archivePath, hashPath }); | |
| 236 | if (input.wslRuntime === "bad-digest") { | |
| 237 | yield* fs.writeFileString(hashPath, `${"0".repeat(64)}\n`); | |
| 238 | } | |
| 250 | 239 | } |
| 251 | 240 | |
| 252 | 241 | return { |
| ... | ... | @@ -463,40 +452,20 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 463 | 452 | libc: ["glibc"], |
| 464 | 453 | }, |
| 465 | 454 | }); |
| 466 | // The Windows app stage only serves the desktop main process; the server | |
| 467 | // sidecar stage is the one that needs Linux natives (below). | |
| 455 | // Windows stages only win32 natives; WSL runs the separately built Linux | |
| 456 | // CLI archive rather than anything installed here. | |
| 468 | 457 | assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "x64" }), { |
| 469 | 458 | supportedArchitectures: { |
| 470 | 459 | os: ["win32"], |
| 471 | 460 | cpu: ["x64"], |
| 472 | 461 | }, |
| 473 | 462 | }); |
| 474 | // The server sidecar stage bundles the same-architecture WSL (Linux, | |
| 475 | // glibc) backend, so its install must fetch Linux native optional deps | |
| 476 | // (e.g. ffi-rs) too — and must be hoisted so the tree survives asar | |
| 477 | // packing and runtime extraction without symlinks. | |
| 478 | assert.deepStrictEqual( | |
| 479 | createStageWorkspaceConfig({ platform: "win", arch: "x64", linuxServerBackend: true }), | |
| 480 | { | |
| 481 | supportedArchitectures: { | |
| 482 | os: ["win32", "linux"], | |
| 483 | cpu: ["x64"], | |
| 484 | libc: ["glibc"], | |
| 485 | }, | |
| 486 | nodeLinker: "hoisted", | |
| 487 | }, | |
| 488 | ); | |
| 489 | assert.deepStrictEqual( | |
| 490 | createStageWorkspaceConfig({ platform: "win", arch: "arm64", linuxServerBackend: true }), | |
| 491 | { | |
| 492 | supportedArchitectures: { | |
| 493 | os: ["win32", "linux"], | |
| 494 | cpu: ["arm64"], | |
| 495 | libc: ["glibc"], | |
| 496 | }, | |
| 497 | nodeLinker: "hoisted", | |
| 463 | assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "arm64" }), { | |
| 464 | supportedArchitectures: { | |
| 465 | os: ["win32"], | |
| 466 | cpu: ["arm64"], | |
| 498 | 467 | }, |
| 499 | ); | |
| 468 | }); | |
| 500 | 469 | assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "mac", arch: "universal" }), { |
| 501 | 470 | supportedArchitectures: { |
| 502 | 471 | os: ["darwin"], |
| ... | ... | @@ -635,7 +604,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 635 | 604 | undefined, |
| 636 | 605 | true, |
| 637 | 606 | ); |
| 638 | const winWithoutWslPrebuild = yield* createBuildConfig( | |
| 607 | const winWithoutWslRuntime = yield* createBuildConfig( | |
| 639 | 608 | "win", |
| 640 | 609 | "nsis", |
| 641 | 610 | "1.2.3", |
| ... | ... | @@ -654,8 +623,8 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 654 | 623 | assert.notProperty(linux, "asarUnpack"); |
| 655 | 624 | assert.deepStrictEqual(win.asar, { smartUnpack: false }); |
| 656 | 625 | assert.deepStrictEqual(win.asarUnpack, [WINDOWS_NATIVE_ASAR_UNPACK_GLOB]); |
| 657 | assert.deepStrictEqual(winWithoutWslPrebuild.asar, win.asar); | |
| 658 | assert.deepStrictEqual(winWithoutWslPrebuild.asarUnpack, win.asarUnpack); | |
| 626 | assert.deepStrictEqual(winWithoutWslRuntime.asar, win.asar); | |
| 627 | assert.deepStrictEqual(winWithoutWslRuntime.asarUnpack, win.asarUnpack); | |
| 659 | 628 | assert.deepStrictEqual(mac.extraResources, DESKTOP_EXTRA_RESOURCES); |
| 660 | 629 | assert.deepStrictEqual(linux.extraResources, [ |
| 661 | 630 | ...DESKTOP_EXTRA_RESOURCES, |
| ... | ... | @@ -670,9 +639,9 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 670 | 639 | ...WINDOWS_SERVER_EXTRA_RESOURCES, |
| 671 | 640 | ...WSL_RUNTIME_EXTRA_RESOURCES, |
| 672 | 641 | ]); |
| 673 | // No Linux prebuild means the sidecar staging never writes the archive, | |
| 674 | // so listing it here would fail the build on a missing source file. | |
| 675 | assert.deepStrictEqual(winWithoutWslPrebuild.extraResources, [ | |
| 642 | // No Linux CLI archive means staging never writes the runtime, so | |
| 643 | // listing it here would fail the build on a missing source file. | |
| 644 | assert.deepStrictEqual(winWithoutWslRuntime.extraResources, [ | |
| 676 | 645 | { |
| 677 | 646 | from: "apps/desktop/prod-resources/resource-monitor", |
| 678 | 647 | to: "resource-monitor", |
| ... | ... | @@ -707,7 +676,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 707 | 676 | assert.deepStrictEqual(mac.files, [...DESKTOP_FILE_EXCLUSIONS, ...MAC_FILE_EXCLUSIONS]); |
| 708 | 677 | assert.deepStrictEqual(linux.files, [...DESKTOP_FILE_EXCLUSIONS, ...LINUX_FILE_EXCLUSIONS]); |
| 709 | 678 | assert.deepStrictEqual(win.files, DESKTOP_FILE_EXCLUSIONS); |
| 710 | assert.deepStrictEqual(winWithoutWslPrebuild.files, win.files); | |
| 679 | assert.deepStrictEqual(winWithoutWslRuntime.files, win.files); | |
| 711 | 680 | assert.notProperty(mac.mac as Record<string, unknown>, "sign"); |
| 712 | 681 | for (const config of [linux, win]) { |
| 713 | 682 | assert.deepStrictEqual(config.electronLanguages, DESKTOP_ELECTRON_LANGUAGES); |
| ... | ... | @@ -819,60 +788,50 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 819 | 788 | ]); |
| 820 | 789 | }); |
| 821 | 790 | |
| 822 | it.effect( | |
| 823 | "keeps target and WSL native files while excluding the other Windows architecture", | |
| 824 | () => | |
| 825 | Effect.scoped( | |
| 826 | Effect.gen(function* () { | |
| 827 | const fs = yield* FileSystem.FileSystem; | |
| 828 | const path = yield* Path.Path; | |
| 829 | const tempDir = yield* fs.makeTempDirectoryScoped({ | |
| 830 | prefix: "t3-windows-architecture-test-", | |
| 831 | }); | |
| 832 | const sourceDir = path.join(tempDir, "server"); | |
| 833 | const nativeFiles = [ | |
| 834 | "node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe", | |
| 835 | "node_modules/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe", | |
| 836 | "node_modules/node-pty/prebuilds/linux-x64/pty.node", | |
| 837 | "node_modules/node-pty/third_party/conpty/1.0.0/win10-x64/OpenConsole.exe", | |
| 838 | "node_modules/node-pty/third_party/conpty/1.0.0/win10-arm64/OpenConsole.exe", | |
| 839 | ]; | |
| 840 | ||
| 841 | for (const nativeFile of nativeFiles) { | |
| 842 | const nativePath = path.join(sourceDir, nativeFile); | |
| 843 | yield* fs.makeDirectory(path.dirname(nativePath), { recursive: true }); | |
| 844 | yield* fs.writeFileString(nativePath, "native"); | |
| 845 | } | |
| 791 | it.effect("keeps target native files while excluding the other Windows architecture", () => | |
| 792 | Effect.scoped( | |
| 793 | Effect.gen(function* () { | |
| 794 | const fs = yield* FileSystem.FileSystem; | |
| 795 | const path = yield* Path.Path; | |
| 796 | const tempDir = yield* fs.makeTempDirectoryScoped({ | |
| 797 | prefix: "t3-windows-architecture-test-", | |
| 798 | }); | |
| 799 | const sourceDir = path.join(tempDir, "server"); | |
| 800 | const nativeFiles = [ | |
| 801 | "node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe", | |
| 802 | "node_modules/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe", | |
| 803 | "node_modules/node-pty/third_party/conpty/1.0.0/win10-x64/OpenConsole.exe", | |
| 804 | "node_modules/node-pty/third_party/conpty/1.0.0/win10-arm64/OpenConsole.exe", | |
| 805 | ]; | |
| 806 | ||
| 807 | for (const nativeFile of nativeFiles) { | |
| 808 | const nativePath = path.join(sourceDir, nativeFile); | |
| 809 | yield* fs.makeDirectory(path.dirname(nativePath), { recursive: true }); | |
| 810 | yield* fs.writeFileString(nativePath, "native"); | |
| 811 | } | |
| 846 | 812 | |
| 847 | const asarPath = path.join(tempDir, "server.asar"); | |
| 848 | yield* packWindowsServerAsar({ sourceDir, asarPath, arch: "x64" }); | |
| 849 | const unpackedRoot = `${asarPath}.unpacked`; | |
| 813 | const asarPath = path.join(tempDir, "server.asar"); | |
| 814 | yield* packWindowsServerAsar({ sourceDir, asarPath, arch: "x64" }); | |
| 815 | const unpackedRoot = `${asarPath}.unpacked`; | |
| 850 | 816 | |
| 851 | assert.isTrue( | |
| 852 | yield* fs.exists( | |
| 853 | path.join( | |
| 854 | unpackedRoot, | |
| 855 | "node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe", | |
| 856 | ), | |
| 857 | ), | |
| 858 | ); | |
| 859 | assert.isTrue( | |
| 860 | yield* fs.exists( | |
| 861 | path.join(unpackedRoot, "node_modules/node-pty/prebuilds/linux-x64/pty.node"), | |
| 862 | ), | |
| 863 | ); | |
| 864 | assert.isFalse( | |
| 865 | yield* fs.exists( | |
| 866 | path.join(unpackedRoot, "node_modules/node-pty/prebuilds/win32-arm64"), | |
| 867 | ), | |
| 868 | ); | |
| 869 | assert.isFalse( | |
| 870 | yield* fs.exists( | |
| 871 | path.join(unpackedRoot, "node_modules/node-pty/third_party/conpty/1.0.0/win10-arm64"), | |
| 817 | assert.isTrue( | |
| 818 | yield* fs.exists( | |
| 819 | path.join( | |
| 820 | unpackedRoot, | |
| 821 | "node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe", | |
| 872 | 822 | ), |
| 873 | ); | |
| 874 | }), | |
| 875 | ), | |
| 823 | ), | |
| 824 | ); | |
| 825 | assert.isFalse( | |
| 826 | yield* fs.exists(path.join(unpackedRoot, "node_modules/node-pty/prebuilds/win32-arm64")), | |
| 827 | ); | |
| 828 | assert.isFalse( | |
| 829 | yield* fs.exists( | |
| 830 | path.join(unpackedRoot, "node_modules/node-pty/third_party/conpty/1.0.0/win10-arm64"), | |
| 831 | ), | |
| 832 | ); | |
| 833 | }), | |
| 834 | ), | |
| 876 | 835 | ); |
| 877 | 836 | |
| 878 | 837 | it.effect("stages a cached resource monitor without invoking Cargo", () => |
| ... | ... | @@ -1205,6 +1164,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1205 | 1164 | stageDistDir: fixture.stageDistDir, |
| 1206 | 1165 | appExecutableName: fixture.appExecutableName, |
| 1207 | 1166 | targetArch: "x64", |
| 1167 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | |
| 1208 | 1168 | }); |
| 1209 | 1169 | |
| 1210 | 1170 | const secondAsarPath = path.join(path.dirname(fixture.generatedAsarPath), "second.asar"); |
| ... | ... | @@ -1226,7 +1186,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1226 | 1186 | ).pipe(Effect.provideService(HostProcessPlatform, "linux")), |
| 1227 | 1187 | ); |
| 1228 | 1188 | |
| 1229 | it.effect("validates the emitted WSL archive and its SHA-256 sidecar", () => | |
| 1189 | it.effect("accepts an embedded Linux CLI release archive with a matching digest", () => | |
| 1230 | 1190 | Effect.scoped( |
| 1231 | 1191 | Effect.gen(function* () { |
| 1232 | 1192 | const fixture = yield* makeWindowsPayloadFixture({ |
| ... | ... | @@ -1237,6 +1197,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1237 | 1197 | stageDistDir: fixture.stageDistDir, |
| 1238 | 1198 | appExecutableName: fixture.appExecutableName, |
| 1239 | 1199 | targetArch: "x64", |
| 1200 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | |
| 1240 | 1201 | expectWslRuntime: true, |
| 1241 | 1202 | }); |
| 1242 | 1203 | |
| ... | ... | @@ -1245,6 +1206,27 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1245 | 1206 | ).pipe(Effect.provideService(HostProcessPlatform, "linux")), |
| 1246 | 1207 | ); |
| 1247 | 1208 | |
| 1209 | it.effect("rejects an embedded archive built for a different release version", () => | |
| 1210 | Effect.scoped( | |
| 1211 | Effect.gen(function* () { | |
| 1212 | const fixture = yield* makeWindowsPayloadFixture({ | |
| 1213 | copyUnpackedNatives: true, | |
| 1214 | wslRuntime: "valid", | |
| 1215 | }); | |
| 1216 | const error = yield* validateWindowsPackagedPayload({ | |
| 1217 | stageDistDir: fixture.stageDistDir, | |
| 1218 | appExecutableName: fixture.appExecutableName, | |
| 1219 | targetArch: "x64", | |
| 1220 | appVersion: "9.9.9", | |
| 1221 | expectWslRuntime: true, | |
| 1222 | }).pipe(Effect.flip); | |
| 1223 | ||
| 1224 | assert.instanceOf(error, WindowsPackagedPayloadValidationError); | |
| 1225 | assert.equal(error.reason, "wsl-runtime-invalid"); | |
| 1226 | }), | |
| 1227 | ), | |
| 1228 | ); | |
| 1229 | ||
| 1248 | 1230 | it.effect("rejects a Windows package missing its expected WSL runtime", () => |
| 1249 | 1231 | Effect.scoped( |
| 1250 | 1232 | Effect.gen(function* () { |
| ... | ... | @@ -1253,6 +1235,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1253 | 1235 | stageDistDir: fixture.stageDistDir, |
| 1254 | 1236 | appExecutableName: fixture.appExecutableName, |
| 1255 | 1237 | targetArch: "x64", |
| 1238 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | |
| 1256 | 1239 | expectWslRuntime: true, |
| 1257 | 1240 | }).pipe(Effect.flip); |
| 1258 | 1241 | |
| ... | ... | @@ -1262,22 +1245,47 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1262 | 1245 | ), |
| 1263 | 1246 | ); |
| 1264 | 1247 | |
| 1265 | it.effect("rejects forbidden native members in the emitted WSL archive", () => | |
| 1248 | it.effect("rejects a loose server tree that is not a Linux CLI release archive", () => | |
| 1249 | Effect.scoped( | |
| 1250 | Effect.gen(function* () { | |
| 1251 | const fixture = yield* makeWindowsPayloadFixture({ | |
| 1252 | copyUnpackedNatives: true, | |
| 1253 | wslRuntime: "loose-server-tree", | |
| 1254 | }); | |
| 1255 | const error = yield* validateWindowsPackagedPayload({ | |
| 1256 | stageDistDir: fixture.stageDistDir, | |
| 1257 | appExecutableName: fixture.appExecutableName, | |
| 1258 | targetArch: "x64", | |
| 1259 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | |
| 1260 | expectWslRuntime: true, | |
| 1261 | }).pipe(Effect.flip); | |
| 1262 | ||
| 1263 | assert.instanceOf(error, WindowsPackagedPayloadValidationError); | |
| 1264 | assert.equal(error.reason, "wsl-runtime-invalid"); | |
| 1265 | }), | |
| 1266 | ), | |
| 1267 | ); | |
| 1268 | ||
| 1269 | it.effect("rejects an embedded archive without the Linux node-pty binary", () => | |
| 1266 | 1270 | Effect.scoped( |
| 1267 | 1271 | Effect.gen(function* () { |
| 1268 | 1272 | const fixture = yield* makeWindowsPayloadFixture({ |
| 1269 | 1273 | copyUnpackedNatives: true, |
| 1270 | wslRuntime: "forbidden", | |
| 1274 | wslRuntime: "missing-pty", | |
| 1271 | 1275 | }); |
| 1272 | 1276 | const error = yield* validateWindowsPackagedPayload({ |
| 1273 | 1277 | stageDistDir: fixture.stageDistDir, |
| 1274 | 1278 | appExecutableName: fixture.appExecutableName, |
| 1275 | 1279 | targetArch: "x64", |
| 1280 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | |
| 1276 | 1281 | expectWslRuntime: true, |
| 1277 | 1282 | }).pipe(Effect.flip); |
| 1278 | 1283 | |
| 1279 | 1284 | assert.instanceOf(error, WindowsPackagedPayloadValidationError); |
| 1280 | 1285 | assert.equal(error.reason, "wsl-runtime-invalid"); |
| 1286 | assert.deepStrictEqual(error.missingFiles, [ | |
| 1287 | `${wslRuntimeArchiveStem(WINDOWS_PAYLOAD_FIXTURE_VERSION, "x64")}/node_modules/node-pty/build/Release/pty.node`, | |
| 1288 | ]); | |
| 1281 | 1289 | }), |
| 1282 | 1290 | ), |
| 1283 | 1291 | ); |
| ... | ... | @@ -1293,6 +1301,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1293 | 1301 | stageDistDir: fixture.stageDistDir, |
| 1294 | 1302 | appExecutableName: fixture.appExecutableName, |
| 1295 | 1303 | targetArch: "x64", |
| 1304 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | |
| 1296 | 1305 | expectWslRuntime: true, |
| 1297 | 1306 | }).pipe(Effect.flip); |
| 1298 | 1307 | |
| ... | ... | @@ -1327,6 +1336,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1327 | 1336 | stageDistDir: fixture.stageDistDir, |
| 1328 | 1337 | appExecutableName: fixture.appExecutableName, |
| 1329 | 1338 | targetArch: "x64", |
| 1339 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | |
| 1330 | 1340 | }); |
| 1331 | 1341 | |
| 1332 | 1342 | const primaryProbe = commands.find( |
| ... | ... | @@ -1466,6 +1476,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1466 | 1476 | stageDistDir: fixture.stageDistDir, |
| 1467 | 1477 | appExecutableName: fixture.appExecutableName, |
| 1468 | 1478 | targetArch: "arm64", |
| 1479 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | |
| 1469 | 1480 | }); |
| 1470 | 1481 | |
| 1471 | 1482 | assert.isFalse( |
| ... | ... | @@ -1502,6 +1513,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1502 | 1513 | stageDistDir: fixture.stageDistDir, |
| 1503 | 1514 | appExecutableName: fixture.appExecutableName, |
| 1504 | 1515 | targetArch: "arm64", |
| 1516 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | |
| 1505 | 1517 | }).pipe(Effect.flip); |
| 1506 | 1518 | |
| 1507 | 1519 | assert.instanceOf(error, WindowsPrimaryNativeProbeError); |
| ... | ... | @@ -1525,6 +1537,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1525 | 1537 | stageDistDir: fixture.stageDistDir, |
| 1526 | 1538 | appExecutableName: fixture.appExecutableName, |
| 1527 | 1539 | targetArch: "x64", |
| 1540 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | |
| 1528 | 1541 | }).pipe(Effect.flip); |
| 1529 | 1542 | |
| 1530 | 1543 | assert.instanceOf(error, WindowsPackagedPayloadValidationError); |
| ... | ... | @@ -1553,6 +1566,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1553 | 1566 | stageDistDir: fixture.stageDistDir, |
| 1554 | 1567 | appExecutableName: fixture.appExecutableName, |
| 1555 | 1568 | targetArch: "x64", |
| 1569 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | |
| 1556 | 1570 | }).pipe(Effect.flip); |
| 1557 | 1571 | assert.instanceOf(nativeError, WindowsPackagedPayloadValidationError); |
| 1558 | 1572 | assert.equal(nativeError.reason, "unpacked-native-missing"); |
| ... | ... | @@ -1573,6 +1587,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1573 | 1587 | stageDistDir: fixture.stageDistDir, |
| 1574 | 1588 | appExecutableName: fixture.appExecutableName, |
| 1575 | 1589 | targetArch: "x64", |
| 1590 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | |
| 1576 | 1591 | }).pipe(Effect.flip); |
| 1577 | 1592 | assert.instanceOf(resourceMonitorError, WindowsPackagedPayloadValidationError); |
| 1578 | 1593 | assert.equal(resourceMonitorError.reason, "resource-monitor-missing"); |
| ... | ... | @@ -1591,6 +1606,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1591 | 1606 | stageDistDir: fixture.stageDistDir, |
| 1592 | 1607 | appExecutableName: fixture.appExecutableName, |
| 1593 | 1608 | targetArch: "x64", |
| 1609 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | |
| 1594 | 1610 | fileLimit: 2, |
| 1595 | 1611 | }).pipe(Effect.flip); |
| 1596 | 1612 | |
| ... | ... | @@ -1612,6 +1628,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1612 | 1628 | stageDistDir: fixture.stageDistDir, |
| 1613 | 1629 | appExecutableName: fixture.appExecutableName, |
| 1614 | 1630 | targetArch: "x64", |
| 1631 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | |
| 1615 | 1632 | }).pipe(Effect.flip); |
| 1616 | 1633 | |
| 1617 | 1634 | assert.instanceOf(error, BundleNotSelfContainedError); |
| ... | ... | @@ -1923,7 +1940,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1923 | 1940 | assert.equal(resourceMonitorExecutableName("win"), "t3-resource-monitor.exe"); |
| 1924 | 1941 | }); |
| 1925 | 1942 | |
| 1926 | it("packages the WSL server and production dependencies as one compressed runtime", () => { | |
| 1943 | it("ships the Linux CLI release archive as the WSL runtime", () => { | |
| 1927 | 1944 | assert.equal(WSL_RUNTIME_ARCHIVE_NAME, "wsl-runtime.tar.gz"); |
| 1928 | 1945 | assert.equal(WSL_RUNTIME_ARCHIVE_HASH_NAME, "wsl-runtime.tar.gz.sha256"); |
| 1929 | 1946 | assert.deepStrictEqual(WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE, { |
| ... | ... | @@ -1934,190 +1951,65 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1934 | 1951 | from: "apps/desktop/prod-resources/wsl-runtime.tar.gz.sha256", |
| 1935 | 1952 | to: "wsl-runtime.tar.gz.sha256", |
| 1936 | 1953 | }); |
| 1937 | // The archive is only usable alongside a Linux pty.node, so both the | |
| 1938 | // staging and the packaging config hang off this one decision. | |
| 1939 | assert.isTrue(bundlesWslRuntime({ arch: "x64", prebuildPath: "/tmp/pty.node" })); | |
| 1940 | assert.isTrue(bundlesWslRuntime({ arch: "arm64", prebuildPath: "/tmp/pty.node" })); | |
| 1941 | assert.isFalse(bundlesWslRuntime({ arch: "x64", prebuildPath: undefined })); | |
| 1942 | assert.isFalse(bundlesWslRuntime({ arch: "universal", prebuildPath: "/tmp/pty.node" })); | |
| 1943 | ||
| 1944 | assert.deepStrictEqual(buildWslRuntimeArchiveArgs(), [ | |
| 1945 | "-czf", | |
| 1946 | "apps/desktop/prod-resources/wsl-runtime.tar.gz", | |
| 1947 | "--exclude=node_modules/@anthropic-ai/claude-agent-sdk-*", | |
| 1948 | "--exclude=node_modules/.bin*", | |
| 1949 | "--exclude=node_modules/.pnpm*", | |
| 1950 | "--exclude=node_modules/.modules.yaml*", | |
| 1951 | "--exclude=node_modules/.pnpm-workspace-state-v1.json*", | |
| 1952 | "--exclude=node_modules/node-pty/prebuilds/darwin-*", | |
| 1953 | "--exclude=node_modules/node-pty/prebuilds/win32-*", | |
| 1954 | "--exclude=node_modules/node-pty/build*", | |
| 1955 | "--exclude=node_modules/node-pty/third_party/conpty*", | |
| 1956 | "--exclude=node_modules/@ff-labs/fff-bin-win32-*", | |
| 1957 | "--exclude=node_modules/@yuuang/ffi-rs-win32-*", | |
| 1958 | "--exclude=node_modules/@msgpackr-extract/msgpackr-extract-win32-*", | |
| 1959 | "apps/server/dist", | |
| 1960 | "node_modules", | |
| 1961 | ]); | |
| 1954 | // Both the staging and the packaging config hang off this one decision: | |
| 1955 | // Windows only, and only when CI handed the build a Linux CLI archive. | |
| 1956 | const runtimeArchivePath = "/tmp/t3-1.2.3-linux-x64.tar.gz"; | |
| 1957 | assert.isTrue(bundlesWslRuntime({ platform: "win", runtimeArchivePath })); | |
| 1958 | assert.isFalse(bundlesWslRuntime({ platform: "win", runtimeArchivePath: undefined })); | |
| 1959 | assert.isFalse(bundlesWslRuntime({ platform: "linux", runtimeArchivePath })); | |
| 1960 | assert.isFalse(bundlesWslRuntime({ platform: "mac", runtimeArchivePath })); | |
| 1961 | assert.equal(wslRuntimeArchiveStem("1.2.3", "x64"), "t3-1.2.3-linux-x64"); | |
| 1962 | 1962 | }); |
| 1963 | 1963 | |
| 1964 | 1964 | it("parses Windows bsdtar member listings with CRLF line endings", () => { |
| 1965 | 1965 | assert.deepStrictEqual( |
| 1966 | parseWslRuntimeArchiveMembers( | |
| 1967 | "./apps/server/dist/bin.mjs\r\nnode_modules/node-pty/package.json\r\n", | |
| 1968 | ), | |
| 1969 | ["apps/server/dist/bin.mjs", "node_modules/node-pty/package.json"], | |
| 1966 | parseWslRuntimeArchiveMembers("./t3-1.2.3-linux-x64/t3\r\nt3-1.2.3-linux-x64/client/\r\n"), | |
| 1967 | ["t3-1.2.3-linux-x64/t3", "t3-1.2.3-linux-x64/client"], | |
| 1970 | 1968 | ); |
| 1971 | 1969 | }); |
| 1972 | 1970 | |
| 1973 | it("keeps Windows tar targets colon-free so GNU tar does not read them as remote hosts", () => { | |
| 1974 | assert.equal( | |
| 1975 | wslRuntimeArchiveTarTarget("..\\app\\apps\\desktop\\prod-resources\\wsl-runtime.tar.gz"), | |
| 1976 | "../app/apps/desktop/prod-resources/wsl-runtime.tar.gz", | |
| 1977 | ); | |
| 1978 | assert.equal( | |
| 1979 | wslRuntimeArchiveTarTarget("../app/apps/desktop/prod-resources/wsl-runtime.tar.gz"), | |
| 1980 | "../app/apps/desktop/prod-resources/wsl-runtime.tar.gz", | |
| 1981 | ); | |
| 1982 | }); | |
| 1983 | ||
| 1984 | // The staged source tree and the archive live in sibling stage directories, | |
| 1985 | // so this covers the real call: on Windows the archive path is an absolute | |
| 1986 | // C:\... path, and handing that to tar is what made Git's GNU tar try to | |
| 1987 | // reach a host named "C". | |
| 1988 | it.effect("spawns tar with an archive target relative to the staged source tree", () => { | |
| 1989 | const commands: Array<{ | |
| 1990 | readonly command: string; | |
| 1991 | readonly args: ReadonlyArray<string>; | |
| 1992 | readonly options: { readonly cwd?: string }; | |
| 1993 | }> = []; | |
| 1994 | ||
| 1995 | return Effect.scoped( | |
| 1971 | it.effect("stages the Linux CLI archive verbatim with its SHA-256 sidecar", () => | |
| 1972 | Effect.scoped( | |
| 1996 | 1973 | Effect.gen(function* () { |
| 1997 | 1974 | const fs = yield* FileSystem.FileSystem; |
| 1998 | 1975 | const path = yield* Path.Path; |
| 1999 | const stageRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-wsl-runtime-archive-" }); | |
| 2000 | const sourceDir = path.join(stageRoot, "server"); | |
| 2001 | const stageAppDir = path.join(stageRoot, "app"); | |
| 1976 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-wsl-runtime-stage-" }); | |
| 1977 | const sourceArchivePath = yield* makeLinuxCliArchiveFixture({ | |
| 1978 | root, | |
| 1979 | stem: "t3-1.2.3-linux-x64", | |
| 1980 | }); | |
| 1981 | const stageAppDir = path.join(root, "app"); | |
| 2002 | 1982 | const archivePath = path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE.from); |
| 2003 | 1983 | const hashPath = path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE.from); |
| 2004 | yield* stageWslRuntimeTreeFixture(sourceDir, "export const serve = 1;\n"); | |
| 2005 | ||
| 2006 | const spawnerLayer = Layer.succeed( | |
| 2007 | ChildProcessSpawner.ChildProcessSpawner, | |
| 2008 | ChildProcessSpawner.make((command) => { | |
| 2009 | const childProcess = command as unknown as (typeof commands)[number]; | |
| 2010 | commands.push(childProcess); | |
| 2011 | // Stand in for tar: write the archive by resolving the -f target | |
| 2012 | // against the cwd tar was spawned in, exactly as tar would. | |
| 2013 | const target = path.resolve(childProcess.options.cwd ?? "", childProcess.args[1] ?? ""); | |
| 2014 | return Effect.as(fs.writeFileString(target, "wsl-runtime-archive"), mockProcess(0)); | |
| 2015 | }), | |
| 2016 | ); | |
| 2017 | ||
| 2018 | yield* stageWslRuntimeArchive({ sourceDir, archivePath, hashPath }).pipe( | |
| 2019 | Effect.provide(spawnerLayer), | |
| 2020 | ); | |
| 2021 | 1984 | |
| 2022 | const tarCommand = commands.find((command) => command.command === "tar"); | |
| 2023 | if (tarCommand === undefined) return assert.fail("tar was not spawned"); | |
| 1985 | yield* stageWslRuntimeArchive({ sourceArchivePath, archivePath, hashPath }); | |
| 2024 | 1986 | |
| 2025 | const target = tarCommand.args[1] ?? ""; | |
| 2026 | assert.equal(tarCommand.options.cwd, sourceDir); | |
| 2027 | assert.notInclude(target, ":"); | |
| 2028 | assert.isFalse(path.isAbsolute(target)); | |
| 2029 | // Relative or not, tar has to land the archive where the build expects it. | |
| 2030 | assert.equal(path.resolve(sourceDir, target), archivePath); | |
| 2031 | assert.isTrue(yield* fs.exists(archivePath)); | |
| 2032 | ||
| 2033 | // The archive digest both gates installation and names the cache. | |
| 1987 | const [source, staged] = yield* Effect.all([ | |
| 1988 | fs.readFile(sourceArchivePath), | |
| 1989 | fs.readFile(archivePath), | |
| 1990 | ]); | |
| 1991 | assert.deepStrictEqual(staged, source); | |
| 1992 | // The digest both gates installation inside the distro and names the | |
| 1993 | // extracted runtime's cache directory. | |
| 2034 | 1994 | const hash = yield* fs.readFileString(hashPath); |
| 2035 | assert.match(hash.trim(), /^[0-9a-f]{64}$/); | |
| 1995 | assert.equal(hash, `${NodeCrypto.createHash("sha256").update(source).digest("hex")}\n`); | |
| 2036 | 1996 | }), |
| 2037 | ); | |
| 2038 | }); | |
| 1997 | ), | |
| 1998 | ); | |
| 2039 | 1999 | |
| 2040 | it.effect("ships only Linux runtime members in the WSL archive", () => | |
| 2000 | it.effect("fails when the Linux CLI archive handed to the build does not exist", () => | |
| 2041 | 2001 | Effect.scoped( |
| 2042 | 2002 | Effect.gen(function* () { |
| 2043 | 2003 | const fs = yield* FileSystem.FileSystem; |
| 2044 | 2004 | const path = yield* Path.Path; |
| 2045 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 2046 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-wsl-runtime-members-" }); | |
| 2047 | const sourceDir = path.join(root, "server"); | |
| 2048 | const archivePath = path.join(root, "wsl-runtime.tar.gz"); | |
| 2049 | const hashPath = `${archivePath}.sha256`; | |
| 2050 | yield* stageWslRuntimeTreeFixture(sourceDir, "export const serve = 1;\n"); | |
| 2051 | ||
| 2052 | const members = [ | |
| 2053 | "node_modules/node-pty/prebuilds/darwin-x64/pty.node", | |
| 2054 | "node_modules/node-pty/prebuilds/win32-x64/pty.node", | |
| 2055 | "node_modules/node-pty/build/Release/pty.node", | |
| 2056 | "node_modules/node-pty/third_party/conpty/win10-x64/conpty.dll", | |
| 2057 | "node_modules/@ff-labs/fff-bin-win32-x64/fff.dll", | |
| 2058 | "node_modules/@ff-labs/fff-bin-linux-x64-gnu/libfff.so", | |
| 2059 | "node_modules/@yuuang/ffi-rs-win32-x64-msvc/ffi.dll", | |
| 2060 | "node_modules/@yuuang/ffi-rs-linux-x64-gnu/libffi.so", | |
| 2061 | "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64/addon.node", | |
| 2062 | "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64/addon.node", | |
| 2063 | "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64/index.js", | |
| 2064 | "node_modules/.bin/tool", | |
| 2065 | "node_modules/.pnpm/lock.yaml", | |
| 2066 | "node_modules/.modules.yaml", | |
| 2067 | "node_modules/.pnpm-workspace-state-v1.json", | |
| 2068 | ] as const; | |
| 2069 | yield* Effect.forEach( | |
| 2070 | members, | |
| 2071 | (member) => | |
| 2072 | Effect.gen(function* () { | |
| 2073 | const memberPath = path.join(sourceDir, member); | |
| 2074 | yield* fs.makeDirectory(path.dirname(memberPath), { recursive: true }); | |
| 2075 | yield* fs.writeFileString(memberPath, member); | |
| 2076 | }), | |
| 2077 | { discard: true }, | |
| 2078 | ); | |
| 2079 | ||
| 2080 | yield* stageWslRuntimeArchive({ sourceDir, archivePath, hashPath }); | |
| 2081 | const process = yield* spawner.spawn( | |
| 2082 | ChildProcess.make("tar", ["-tzf", archivePath], { | |
| 2083 | stdin: "ignore", | |
| 2084 | stdout: "pipe", | |
| 2085 | stderr: "pipe", | |
| 2086 | }), | |
| 2087 | ); | |
| 2088 | const listing = yield* process.stdout.pipe( | |
| 2089 | Stream.decodeText(), | |
| 2090 | Stream.runFold( | |
| 2091 | () => "", | |
| 2092 | (output, chunk) => output + chunk, | |
| 2093 | ), | |
| 2094 | ); | |
| 2095 | assert.equal(Number(yield* process.exitCode), 0); | |
| 2005 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-wsl-runtime-missing-" }); | |
| 2006 | const error = yield* stageWslRuntimeArchive({ | |
| 2007 | sourceArchivePath: path.join(root, "t3-1.2.3-linux-x64.tar.gz"), | |
| 2008 | archivePath: path.join(root, WSL_RUNTIME_ARCHIVE_NAME), | |
| 2009 | hashPath: path.join(root, WSL_RUNTIME_ARCHIVE_HASH_NAME), | |
| 2010 | }).pipe(Effect.flip); | |
| 2096 | 2011 | |
| 2097 | assert.include(listing, "apps/server/dist/bin.mjs"); | |
| 2098 | assert.include(listing, "node_modules/node-pty/prebuilds/linux-x64/pty.node"); | |
| 2099 | assert.include(listing, "node_modules/@ff-labs/fff-bin-linux-x64-gnu/libfff.so"); | |
| 2100 | assert.include(listing, "node_modules/@yuuang/ffi-rs-linux-x64-gnu/libffi.so"); | |
| 2101 | assert.include( | |
| 2102 | listing, | |
| 2103 | "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64/addon.node", | |
| 2104 | ); | |
| 2105 | for (const excluded of [ | |
| 2106 | "prebuilds/darwin-", | |
| 2107 | "prebuilds/win32-", | |
| 2108 | "node-pty/build", | |
| 2109 | "third_party/conpty", | |
| 2110 | "fff-bin-win32-", | |
| 2111 | "ffi-rs-win32-", | |
| 2112 | "msgpackr-extract-win32-", | |
| 2113 | "claude-agent-sdk-", | |
| 2114 | "node_modules/.bin", | |
| 2115 | "node_modules/.pnpm", | |
| 2116 | "node_modules/.modules.yaml", | |
| 2117 | "node_modules/.pnpm-workspace-state-v1.json", | |
| 2118 | ]) { | |
| 2119 | assert.notInclude(listing, excluded); | |
| 2120 | } | |
| 2012 | assert.instanceOf(error, WslRuntimeArchiveMissingError); | |
| 2121 | 2013 | }), |
| 2122 | 2014 | ), |
| 2123 | 2015 | ); |
| ... | ... | @@ -2228,7 +2120,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 2228 | 2120 | verbose: Option.none(), |
| 2229 | 2121 | mockUpdates: Option.none(), |
| 2230 | 2122 | mockUpdateServerPort: Option.none(), |
| 2231 | wslPrebuild: Option.none(), | |
| 2123 | wslRuntime: Option.none(), | |
| 2232 | 2124 | }).pipe( |
| 2233 | 2125 | Effect.provide( |
| 2234 | 2126 | Layer.mergeAll( |
| ... | ... | @@ -2268,7 +2160,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 2268 | 2160 | verbose: Option.none(), |
| 2269 | 2161 | mockUpdates: Option.none(), |
| 2270 | 2162 | mockUpdateServerPort: Option.none(), |
| 2271 | wslPrebuild: Option.none(), | |
| 2163 | wslRuntime: Option.none(), | |
| 2272 | 2164 | }), |
| 2273 | 2165 | ); |
| 2274 | 2166 | |
| ... | ... | @@ -2292,7 +2184,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 2292 | 2184 | verbose: Option.some(false), |
| 2293 | 2185 | mockUpdates: Option.some(false), |
| 2294 | 2186 | mockUpdateServerPort: Option.none(), |
| 2295 | wslPrebuild: Option.none(), | |
| 2187 | wslRuntime: Option.none(), | |
| 2296 | 2188 | }).pipe( |
| 2297 | 2189 | Effect.provide( |
| 2298 | 2190 | ConfigProvider.layer( |
scripts/build-desktop-artifact.ts+117-255| ... | ... | @@ -89,9 +89,6 @@ const RepoRoot = Effect.service(Path.Path).pipe( |
| 89 | 89 | ); |
| 90 | 90 | const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); |
| 91 | 91 | const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig)); |
| 92 | const decodeNodePtyManifest = Schema.decodeUnknownEffect( | |
| 93 | Schema.fromJsonString(Schema.Struct({ version: Schema.String })), | |
| 94 | ); | |
| 95 | 92 | const encodeStageWorkspaceConfig = Schema.encodeEffect(fromYaml(StageWorkspaceConfig)); |
| 96 | 93 | |
| 97 | 94 | const readWorkspaceConfig = Effect.fn("readWorkspaceConfig")(function* () { |
| ... | ... | @@ -164,7 +161,7 @@ interface BuildCliInput { |
| 164 | 161 | readonly verbose: Option.Option<boolean>; |
| 165 | 162 | readonly mockUpdates: Option.Option<boolean>; |
| 166 | 163 | readonly mockUpdateServerPort: Option.Option<number>; |
| 167 | readonly wslPrebuild: Option.Option<string>; | |
| 164 | readonly wslRuntime: Option.Option<string>; | |
| 168 | 165 | } |
| 169 | 166 | |
| 170 | 167 | function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined { |
| ... | ... | @@ -399,7 +396,7 @@ const WINDOWS_DESKTOP_BUILD_PREREQUISITES = [ |
| 399 | 396 | id: "msvc", |
| 400 | 397 | description: "Visual Studio Build Tools with C++, Windows SDK, and Spectre libraries", |
| 401 | 398 | }, |
| 402 | { id: "tar", description: "tar for the bundled WSL runtime" }, | |
| 399 | { id: "tar", description: "tar to inspect the bundled WSL runtime archive" }, | |
| 403 | 400 | ] as const; |
| 404 | 401 | |
| 405 | 402 | export class WindowsDesktopBuildPrerequisitesMissingError extends Schema.TaggedError<WindowsDesktopBuildPrerequisitesMissingError>()( |
| ... | ... | @@ -670,14 +667,14 @@ export class DesktopBuildNoArtifactsProducedError extends Schema.TaggedError<Des |
| 670 | 667 | } |
| 671 | 668 | } |
| 672 | 669 | |
| 673 | export class WslNodePtyPrebuildMissingError extends Schema.TaggedError<WslNodePtyPrebuildMissingError>()( | |
| 674 | "WslNodePtyPrebuildMissingError", | |
| 670 | export class WslRuntimeArchiveMissingError extends Schema.TaggedError<WslRuntimeArchiveMissingError>()( | |
| 671 | "WslRuntimeArchiveMissingError", | |
| 675 | 672 | { |
| 676 | prebuildPath: Schema.String, | |
| 673 | archivePath: Schema.String, | |
| 677 | 674 | }, |
| 678 | 675 | ) { |
| 679 | 676 | override get message(): string { |
| 680 | return `WSL node-pty prebuild not found at ${this.prebuildPath}.`; | |
| 677 | return `WSL runtime archive not found at ${this.archivePath}.`; | |
| 681 | 678 | } |
| 682 | 679 | } |
| 683 | 680 | |
| ... | ... | @@ -754,18 +751,6 @@ export class WindowsPackagedPayloadValidationError extends Schema.TaggedError<Wi |
| 754 | 751 | } |
| 755 | 752 | } |
| 756 | 753 | |
| 757 | export class WslNodePtyManifestReadError extends Schema.TaggedError<WslNodePtyManifestReadError>()( | |
| 758 | "WslNodePtyManifestReadError", | |
| 759 | { | |
| 760 | manifestPath: Schema.String, | |
| 761 | cause: Schema.Defect(), | |
| 762 | }, | |
| 763 | ) { | |
| 764 | override get message(): string { | |
| 765 | return `Could not read node-pty version from ${this.manifestPath}.`; | |
| 766 | } | |
| 767 | } | |
| 768 | ||
| 769 | 754 | export class LinuxIconResizeError extends Schema.TaggedError<LinuxIconResizeError>()( |
| 770 | 755 | "LinuxIconResizeError", |
| 771 | 756 | { |
| ... | ... | @@ -933,7 +918,7 @@ interface ResolvedBuildOptions { |
| 933 | 918 | readonly verbose: boolean; |
| 934 | 919 | readonly mockUpdates: boolean; |
| 935 | 920 | readonly mockUpdateServerPort: number | undefined; |
| 936 | readonly wslPrebuild: string | undefined; | |
| 921 | readonly wslRuntime: string | undefined; | |
| 937 | 922 | } |
| 938 | 923 | |
| 939 | 924 | interface StagePackageJson { |
| ... | ... | @@ -1007,8 +992,8 @@ export function resolveMacFileExclusions(arch?: typeof BuildArch.Type) { |
| 1007 | 992 | // then extracts a handful of large archives instead of thousands of small |
| 1008 | 993 | // files, which dominates install (and update) time. The Windows primary runs |
| 1009 | 994 | // the server from inside server.asar via the asar-aware ELECTRON_RUN_AS_NODE |
| 1010 | // runtime. WSL normally uses the dedicated compressed Linux runtime below; | |
| 1011 | // DesktopWslServerTree can still materialize this sidecar as a fallback. | |
| 995 | // runtime. WSL does not use this sidecar: it runs the Linux CLI archive | |
| 996 | // embedded as resources/wsl-runtime.tar.gz (see WSL_RUNTIME_ARCHIVE_NAME). | |
| 1012 | 997 | export const WINDOWS_SERVER_ASAR_RESOURCE = "server.asar"; |
| 1013 | 998 | // dlopen/spawn need real files, so native modules, shared libraries, and |
| 1014 | 999 | // helper executables live in each archive's .unpacked sibling (the standard |
| ... | ... | @@ -1064,38 +1049,16 @@ export const WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE = { |
| 1064 | 1049 | from: `apps/desktop/prod-resources/${WSL_RUNTIME_ARCHIVE_HASH_NAME}`, |
| 1065 | 1050 | to: WSL_RUNTIME_ARCHIVE_HASH_NAME, |
| 1066 | 1051 | } as const; |
| 1067 | export const WSL_RUNTIME_ARCHIVE_CONTENT_ROOTS = ["apps/server/dist", "node_modules"] as const; | |
| 1068 | ||
| 1069 | // The WSL runtime uses only the Linux half of the shared Windows/WSL sidecar. | |
| 1070 | // Keep build/install metadata and target-native packages that cannot run in | |
| 1071 | // WSL out of the compressed archive. | |
| 1072 | export const WSL_RUNTIME_ARCHIVE_EXCLUDED_PREFIXES = [ | |
| 1073 | "node_modules/@anthropic-ai/claude-agent-sdk-", | |
| 1074 | "node_modules/.bin", | |
| 1075 | "node_modules/.pnpm", | |
| 1076 | "node_modules/.modules.yaml", | |
| 1077 | "node_modules/.pnpm-workspace-state-v1.json", | |
| 1078 | "node_modules/node-pty/prebuilds/darwin-", | |
| 1079 | "node_modules/node-pty/prebuilds/win32-", | |
| 1080 | "node_modules/node-pty/build", | |
| 1081 | "node_modules/node-pty/third_party/conpty", | |
| 1082 | "node_modules/@ff-labs/fff-bin-win32-", | |
| 1083 | "node_modules/@yuuang/ffi-rs-win32-", | |
| 1084 | "node_modules/@msgpackr-extract/msgpackr-extract-win32-", | |
| 1085 | ] as const; | |
| 1086 | // WSL runs the same CPU arch as the Windows host; universal is mac-only. | |
| 1087 | export const resolveWslPrebuildArch = (arch: typeof BuildArch.Type): "x64" | "arm64" | undefined => | |
| 1088 | arch === "x64" ? "x64" : arch === "arm64" ? "arm64" : undefined; | |
| 1089 | ||
| 1090 | // A packaged WSL runtime is only usable when a Linux pty.node is bundled with | |
| 1091 | // it, so this one predicate decides both whether the archive is built and | |
| 1092 | // whether the packaging config ships it. Without it the build would produce an | |
| 1093 | // archive that can never pass the install script's payload check, and every | |
| 1094 | // launch would extract a few hundred MB from /mnt/c only to throw it away. | |
| 1052 | ||
| 1053 | // The WSL runtime is the Linux CLI release archive (t3-<version>-linux-<arch> | |
| 1054 | // .tar.gz, built by scripts/build-cli-archive.ts) copied in verbatim, so WSL | |
| 1055 | // runs the exact bytes a Linux user downloads. This one predicate decides both | |
| 1056 | // whether the archive is staged and whether the packaging config ships it: | |
| 1057 | // listing an extraResource whose source was never written fails electron-builder. | |
| 1095 | 1058 | export const bundlesWslRuntime = (input: { |
| 1096 | readonly arch: typeof BuildArch.Type; | |
| 1097 | readonly prebuildPath: string | undefined; | |
| 1098 | }): boolean => input.prebuildPath !== undefined && resolveWslPrebuildArch(input.arch) !== undefined; | |
| 1059 | readonly platform: typeof BuildPlatform.Type; | |
| 1060 | readonly runtimeArchivePath: string | undefined; | |
| 1061 | }): boolean => input.platform === "win" && input.runtimeArchivePath !== undefined; | |
| 1099 | 1062 | |
| 1100 | 1063 | export const WSL_RUNTIME_EXTRA_RESOURCES = [ |
| 1101 | 1064 | WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE, |
| ... | ... | @@ -1515,15 +1478,8 @@ export function createStageWorkspaceConfig(input: { |
| 1515 | 1478 | readonly allowBuilds?: Record<string, boolean>; |
| 1516 | 1479 | readonly patchedDependencies?: Record<string, string>; |
| 1517 | 1480 | readonly overrides?: Record<string, string>; |
| 1518 | // The Windows server sidecar stage runs both the Windows primary and the | |
| 1519 | // WSL Linux backend from one dependency tree, so it needs win32 + linux | |
| 1520 | // natives (e.g. @yuuang/ffi-rs-linux-x64-gnu) — and a hoisted (physical, | |
| 1521 | // symlink-free) node_modules: the tree gets packed into server.asar and | |
| 1522 | // later extracted for WSL, and neither step can rely on pnpm's | |
| 1523 | // symlink/junction layout surviving the trip. | |
| 1524 | readonly linuxServerBackend?: boolean; | |
| 1525 | 1481 | }): StageWorkspaceConfig { |
| 1526 | const { platform, arch, allowBuilds, patchedDependencies, overrides, linuxServerBackend } = input; | |
| 1482 | const { platform, arch, allowBuilds, patchedDependencies, overrides } = input; | |
| 1527 | 1483 | const hostOs = platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux"; |
| 1528 | 1484 | const hostCpu = arch === "universal" ? ["arm64", "x64"] : [arch]; |
| 1529 | 1485 | // Linux AppImages execute a Linux/glibc Node process that loads |
| ... | ... | @@ -1536,16 +1492,10 @@ export function createStageWorkspaceConfig(input: { |
| 1536 | 1492 | cpu: hostCpu, |
| 1537 | 1493 | libc: ["glibc"], |
| 1538 | 1494 | } |
| 1539 | : linuxServerBackend | |
| 1540 | ? { | |
| 1541 | os: Array.from(new Set([hostOs, "linux"])), | |
| 1542 | cpu: hostCpu, | |
| 1543 | libc: ["glibc"], | |
| 1544 | } | |
| 1545 | : { | |
| 1546 | os: [hostOs], | |
| 1547 | cpu: hostCpu, | |
| 1548 | }; | |
| 1495 | : { | |
| 1496 | os: [hostOs], | |
| 1497 | cpu: hostCpu, | |
| 1498 | }; | |
| 1549 | 1499 | |
| 1550 | 1500 | return { |
| 1551 | 1501 | supportedArchitectures, |
| ... | ... | @@ -1554,7 +1504,6 @@ export function createStageWorkspaceConfig(input: { |
| 1554 | 1504 | ? { patchedDependencies } |
| 1555 | 1505 | : {}), |
| 1556 | 1506 | ...(overrides && Object.keys(overrides).length > 0 ? { overrides } : {}), |
| 1557 | ...(linuxServerBackend ? { nodeLinker: "hoisted" as const } : {}), | |
| 1558 | 1507 | }; |
| 1559 | 1508 | } |
| 1560 | 1509 | |
| ... | ... | @@ -1600,11 +1549,10 @@ const BuildEnvConfig = Config.all({ |
| 1600 | 1549 | verbose: Config.boolean("T3CODE_DESKTOP_VERBOSE").pipe(Config.withDefault(false)), |
| 1601 | 1550 | mockUpdates: Config.boolean("T3CODE_DESKTOP_MOCK_UPDATES").pipe(Config.withDefault(false)), |
| 1602 | 1551 | mockUpdateServerPort: Config.string("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(Config.option), |
| 1603 | // Path to a prebuilt Linux node-pty binary (pty.node) for the target arch, | |
| 1604 | // produced by the Linux CI job and handed to the Windows packaging job. Placed | |
| 1605 | // into the staged node-pty so the WSL backend ships a ready binary and never | |
| 1606 | // compiles on the user's machine. | |
| 1607 | wslPrebuild: Config.string("T3CODE_DESKTOP_WSL_PREBUILD").pipe(Config.option), | |
| 1552 | // Path to the Linux CLI release archive (t3-<version>-linux-x64.tar.gz) built | |
| 1553 | // by the build_linux_cli CI job. The Windows build embeds it verbatim as the | |
| 1554 | // WSL runtime. | |
| 1555 | wslRuntime: Config.string("T3CODE_DESKTOP_WSL_RUNTIME").pipe(Config.option), | |
| 1608 | 1556 | }); |
| 1609 | 1557 | |
| 1610 | 1558 | const MockUpdateServerPortSchema = Schema.NumberFromString.check( |
| ... | ... | @@ -1696,8 +1644,8 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( |
| 1696 | 1644 | ), |
| 1697 | 1645 | )); |
| 1698 | 1646 | |
| 1699 | const wslPrebuild = | |
| 1700 | Option.getOrUndefined(input.wslPrebuild) ?? Option.getOrUndefined(env.wslPrebuild); | |
| 1647 | const wslRuntime = | |
| 1648 | Option.getOrUndefined(input.wslRuntime) ?? Option.getOrUndefined(env.wslRuntime); | |
| 1701 | 1649 | |
| 1702 | 1650 | return { |
| 1703 | 1651 | platform, |
| ... | ... | @@ -1711,7 +1659,7 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( |
| 1711 | 1659 | verbose, |
| 1712 | 1660 | mockUpdates, |
| 1713 | 1661 | mockUpdateServerPort, |
| 1714 | wslPrebuild, | |
| 1662 | wslRuntime, | |
| 1715 | 1663 | } satisfies ResolvedBuildOptions; |
| 1716 | 1664 | }); |
| 1717 | 1665 | |
| ... | ... | @@ -2683,9 +2631,9 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( |
| 2683 | 2631 | readonly provisioningProfilePath: string; |
| 2684 | 2632 | } |
| 2685 | 2633 | | undefined, |
| 2686 | // Windows only, and false when no Linux node-pty prebuild was bundled: the | |
| 2687 | // sidecar staging skips the archive in that case, and listing a resource | |
| 2688 | // whose source file was never written fails the electron-builder step. | |
| 2634 | // Windows only, and false when no Linux CLI archive was handed to the build: | |
| 2635 | // staging skips the archive in that case, and listing a resource whose | |
| 2636 | // source file was never written fails the electron-builder step. | |
| 2689 | 2637 | wslRuntimeBundled = false, |
| 2690 | 2638 | arch?: typeof BuildArch.Type, |
| 2691 | 2639 | ) { |
| ... | ... | @@ -2850,114 +2798,25 @@ const assertPlatformBuildResources = Effect.fn("assertPlatformBuildResources")(f |
| 2850 | 2798 | } |
| 2851 | 2799 | }); |
| 2852 | 2800 | |
| 2853 | // Stage the prebuilt Linux node-pty binary into the packaged app so the WSL | |
| 2854 | // backend never compiles on the user's machine. node-pty publishes no Linux | |
| 2855 | // prebuilt and the WSL Linux Node can't load the Windows/Electron binary, so the | |
| 2856 | // Linux CI job builds pty.node and hands it here. We drop it into the staged | |
| 2857 | // node-pty's prebuilds/linux-<arch>/ with a t3code marker the WSL preflight | |
| 2858 | // checks (arch + node-pty version; the binary is N-API, hence ABI-stable across | |
| 2859 | // Node versions). A missing prebuild is a warning, not an error, so local and | |
| 2860 | // non-Windows builds still succeed — they just won't ship a working WSL backend. | |
| 2861 | const stageWslNodePtyPrebuild = Effect.fn("stageWslNodePtyPrebuild")(function* (input: { | |
| 2862 | readonly stageAppDir: string; | |
| 2863 | readonly arch: typeof BuildArch.Type; | |
| 2864 | readonly prebuildPath: string | undefined; | |
| 2865 | }) { | |
| 2866 | const fs = yield* FileSystem.FileSystem; | |
| 2867 | const path = yield* Path.Path; | |
| 2868 | ||
| 2869 | if (input.prebuildPath === undefined) { | |
| 2870 | yield* Effect.logWarning( | |
| 2871 | "[desktop-artifact] No WSL node-pty prebuild provided (--wsl-prebuild / T3CODE_DESKTOP_WSL_PREBUILD); the packaged WSL backend will not start until a Linux pty.node is bundled.", | |
| 2872 | ); | |
| 2873 | return; | |
| 2874 | } | |
| 2875 | ||
| 2876 | const linuxArch = resolveWslPrebuildArch(input.arch); | |
| 2877 | if (linuxArch === undefined) { | |
| 2878 | yield* Effect.logWarning( | |
| 2879 | `[desktop-artifact] No WSL node-pty prebuild mapping for arch "${input.arch}"; skipping WSL backend bundling.`, | |
| 2880 | ); | |
| 2881 | return; | |
| 2882 | } | |
| 2883 | ||
| 2884 | const prebuildExists = yield* fs | |
| 2885 | .exists(input.prebuildPath) | |
| 2886 | .pipe(Effect.orElseSucceed(() => false)); | |
| 2887 | if (!prebuildExists) { | |
| 2888 | return yield* new WslNodePtyPrebuildMissingError({ | |
| 2889 | prebuildPath: input.prebuildPath, | |
| 2890 | }); | |
| 2891 | } | |
| 2892 | ||
| 2893 | // Resolve through the (pnpm) symlink so we write into the stage's own node-pty | |
| 2894 | // copy, never a shared content-addressable store. | |
| 2895 | const nodePtyLink = path.join(input.stageAppDir, "node_modules", "node-pty"); | |
| 2896 | const nodePtyDir = yield* fs.realPath(nodePtyLink).pipe(Effect.orElseSucceed(() => nodePtyLink)); | |
| 2897 | ||
| 2898 | const manifestPath = path.join(nodePtyDir, "package.json"); | |
| 2899 | const pkgRaw = yield* fs.readFileString(manifestPath); | |
| 2900 | const manifest = yield* decodeNodePtyManifest(pkgRaw).pipe( | |
| 2901 | Effect.mapError( | |
| 2902 | (cause) => | |
| 2903 | new WslNodePtyManifestReadError({ | |
| 2904 | manifestPath, | |
| 2905 | cause, | |
| 2906 | }), | |
| 2907 | ), | |
| 2908 | ); | |
| 2909 | const nodePtyVersion = manifest.version; | |
| 2910 | ||
| 2911 | const prebuildDir = path.join(nodePtyDir, "prebuilds", `linux-${linuxArch}`); | |
| 2912 | yield* fs.makeDirectory(prebuildDir, { recursive: true }); | |
| 2913 | yield* fs.copyFile(input.prebuildPath, path.join(prebuildDir, "pty.node")); | |
| 2914 | const markerJson = yield* encodeJsonString({ arch: linuxArch, nodePtyVersion }); | |
| 2915 | yield* fs.writeFileString(path.join(prebuildDir, "t3code-wsl-node-pty.json"), `${markerJson}\n`); | |
| 2916 | ||
| 2917 | yield* Effect.log( | |
| 2918 | `[desktop-artifact] Staged WSL node-pty prebuild (linux-${linuxArch}, node-pty ${nodePtyVersion}).`, | |
| 2919 | ); | |
| 2920 | }); | |
| 2921 | ||
| 2922 | // tar reads an `-f` target containing a colon as `host:path` and tries to reach | |
| 2923 | // it over rsh, so handing it a Windows drive path (C:\...\wsl-runtime.tar.gz) | |
| 2924 | // makes Git for Windows' GNU tar fail with "Cannot connect to C: resolve | |
| 2925 | // failed". The staged source tree and the archive both live under the build's | |
| 2926 | // stage root, so the target is always expressible relative to tar's cwd. | |
| 2927 | export const wslRuntimeArchiveTarTarget = (relativeArchivePath: string): string => | |
| 2928 | relativeArchivePath.replaceAll("\\", "/"); | |
| 2929 | ||
| 2930 | // `archivePath` is relative to the cwd tar runs in; see wslRuntimeArchiveTarTarget. | |
| 2931 | export const buildWslRuntimeArchiveArgs = ( | |
| 2932 | archivePath: string = WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE.from, | |
| 2933 | ): ReadonlyArray<string> => [ | |
| 2934 | "-czf", | |
| 2935 | archivePath, | |
| 2936 | ...WSL_RUNTIME_ARCHIVE_EXCLUDED_PREFIXES.map((prefix) => `--exclude=${prefix}*`), | |
| 2937 | ...WSL_RUNTIME_ARCHIVE_CONTENT_ROOTS, | |
| 2938 | ]; | |
| 2939 | ||
| 2940 | export const parseWslRuntimeArchiveMembers = (listing: string): ReadonlyArray<string> => | |
| 2941 | listing | |
| 2942 | .split(/\r?\n/) | |
| 2943 | .map((member) => member.replace(/^\.\//, "").replace(/\/$/, "")) | |
| 2944 | .filter((member) => member.length > 0); | |
| 2945 | ||
| 2801 | // Copy the Linux CLI release archive into the stage verbatim and record its | |
| 2802 | // SHA-256 beside it. The desktop app verifies the digest inside the distro | |
| 2803 | // before extracting, and the digest also names the extracted runtime's cache | |
| 2804 | // directory, so it must be computed from the exact bytes that ship. | |
| 2946 | 2805 | export const stageWslRuntimeArchive = Effect.fn("stageWslRuntimeArchive")(function* (input: { |
| 2947 | readonly sourceDir: string; | |
| 2806 | readonly sourceArchivePath: string; | |
| 2948 | 2807 | readonly archivePath: string; |
| 2949 | 2808 | readonly hashPath: string; |
| 2950 | 2809 | }) { |
| 2951 | 2810 | const fs = yield* FileSystem.FileSystem; |
| 2952 | 2811 | const path = yield* Path.Path; |
| 2812 | const sourceExists = yield* fs | |
| 2813 | .exists(input.sourceArchivePath) | |
| 2814 | .pipe(Effect.orElseSucceed(() => false)); | |
| 2815 | if (!sourceExists) { | |
| 2816 | return yield* new WslRuntimeArchiveMissingError({ archivePath: input.sourceArchivePath }); | |
| 2817 | } | |
| 2953 | 2818 | yield* fs.makeDirectory(path.dirname(input.archivePath), { recursive: true }); |
| 2954 | const tarTarget = wslRuntimeArchiveTarTarget(path.relative(input.sourceDir, input.archivePath)); | |
| 2955 | yield* runCommand( | |
| 2956 | ChildProcess.make("tar", buildWslRuntimeArchiveArgs(tarTarget), { | |
| 2957 | cwd: input.sourceDir, | |
| 2958 | }), | |
| 2959 | { label: "tar WSL runtime", verbose: false }, | |
| 2960 | ); | |
| 2819 | yield* fs.copyFile(input.sourceArchivePath, input.archivePath); | |
| 2961 | 2820 | const hash = NodeCrypto.createHash("sha256"); |
| 2962 | 2821 | yield* fs |
| 2963 | 2822 | .stream(input.archivePath) |
| ... | ... | @@ -2965,16 +2824,27 @@ export const stageWslRuntimeArchive = Effect.fn("stageWslRuntimeArchive")(functi |
| 2965 | 2824 | const digest = hash.digest("hex"); |
| 2966 | 2825 | yield* fs.writeFileString(input.hashPath, `${digest}\n`); |
| 2967 | 2826 | yield* Effect.log( |
| 2968 | `[desktop-artifact] Staged compressed WSL runtime at ${input.archivePath} (${digest}).`, | |
| 2827 | `[desktop-artifact] Staged WSL runtime archive at ${input.archivePath} (${digest}).`, | |
| 2969 | 2828 | ); |
| 2970 | 2829 | }); |
| 2971 | 2830 | |
| 2831 | // Mirrors cliArchiveStem in scripts/build-cli-archive.ts (which imports from | |
| 2832 | // this module, so it cannot be imported here). WSL runs the same CPU arch as | |
| 2833 | // the Windows host. | |
| 2834 | export const wslRuntimeArchiveStem = (version: string, arch: typeof BuildArch.Type): string => | |
| 2835 | `t3-${version}-linux-${arch}`; | |
| 2836 | ||
| 2837 | export const parseWslRuntimeArchiveMembers = (listing: string): ReadonlyArray<string> => | |
| 2838 | listing | |
| 2839 | .split(/\r?\n/) | |
| 2840 | .map((member) => member.replace(/^\.\//, "").replace(/\/$/, "")) | |
| 2841 | .filter((member) => member.length > 0); | |
| 2842 | ||
| 2972 | 2843 | // Stage and pack the Windows server sidecar: the bundled server plus a hoisted |
| 2973 | // install of only its runtime-external/native dependency closure for win32 and | |
| 2974 | // WSL Linux. The Windows primary runs from the archive through the asar-aware | |
| 2975 | // ELECTRON_RUN_AS_NODE runtime; enabling WSL extracts it to a real directory. | |
| 2976 | // Shipping one packed archive instead of thousands of loose files is what | |
| 2977 | // makes the NSIS install/update fast. | |
| 2844 | // install of only its runtime-external/native dependency closure for win32. | |
| 2845 | // The Windows primary runs from the archive through the asar-aware | |
| 2846 | // ELECTRON_RUN_AS_NODE runtime. Shipping one packed archive instead of | |
| 2847 | // thousands of loose files is what makes the NSIS install/update fast. | |
| 2978 | 2848 | export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function* (input: { |
| 2979 | 2849 | readonly sourceDir: string; |
| 2980 | 2850 | readonly asarPath: string; |
| ... | ... | @@ -3025,10 +2895,7 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")( |
| 3025 | 2895 | readonly allowBuilds: Record<string, boolean>; |
| 3026 | 2896 | readonly patchedDependencies: Record<string, string>; |
| 3027 | 2897 | readonly overrides: Record<string, string>; |
| 3028 | readonly wslPrebuildPath: string | undefined; | |
| 3029 | 2898 | readonly asarPath: string; |
| 3030 | readonly wslRuntimeArchivePath: string; | |
| 3031 | readonly wslRuntimeArchiveHashPath: string; | |
| 3032 | 2899 | readonly verbose: boolean; |
| 3033 | 2900 | }) { |
| 3034 | 2901 | const fs = yield* FileSystem.FileSystem; |
| ... | ... | @@ -3040,11 +2907,7 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")( |
| 3040 | 2907 | |
| 3041 | 2908 | const sidecarDependencies = { |
| 3042 | 2909 | ...input.runtimeExternalDependencies, |
| 3043 | // The sidecar serves two processes: the Windows primary loads win32 | |
| 3044 | // natives, and the WSL backend loads the matching Linux natives (fff via | |
| 3045 | // ffi-rs) from the extracted copy of this same tree. | |
| 3046 | 2910 | ...resolveFffNativeDependencies("win", input.arch, input.fffNodeVersion), |
| 3047 | ...resolveFffNativeDependencies("linux", input.arch, input.fffNodeVersion), | |
| 3048 | 2911 | }; |
| 3049 | 2912 | const sidecarPatchedDependencies = createStagePatchedDependencies( |
| 3050 | 2913 | input.patchedDependencies, |
| ... | ... | @@ -3062,14 +2925,18 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")( |
| 3062 | 2925 | path.join(serverStageDir, "package.json"), |
| 3063 | 2926 | `${sidecarPackageJsonString}\n`, |
| 3064 | 2927 | ); |
| 3065 | const sidecarWorkspaceConfig = createStageWorkspaceConfig({ | |
| 3066 | platform: "win", | |
| 3067 | arch: input.arch, | |
| 3068 | allowBuilds: input.allowBuilds, | |
| 3069 | patchedDependencies: sidecarPatchedDependencies, | |
| 3070 | overrides: input.overrides, | |
| 3071 | linuxServerBackend: true, | |
| 3072 | }); | |
| 2928 | const sidecarWorkspaceConfig = { | |
| 2929 | ...createStageWorkspaceConfig({ | |
| 2930 | platform: "win", | |
| 2931 | arch: input.arch, | |
| 2932 | allowBuilds: input.allowBuilds, | |
| 2933 | patchedDependencies: sidecarPatchedDependencies, | |
| 2934 | overrides: input.overrides, | |
| 2935 | }), | |
| 2936 | // The tree gets packed into server.asar, which cannot carry pnpm's | |
| 2937 | // symlink/junction layout, so install a physical, hoisted node_modules. | |
| 2938 | nodeLinker: "hoisted" as const, | |
| 2939 | }; | |
| 3073 | 2940 | const sidecarWorkspaceConfigString = yield* encodeStageWorkspaceConfig(sidecarWorkspaceConfig); |
| 3074 | 2941 | yield* fs.writeFileString( |
| 3075 | 2942 | path.join(serverStageDir, "pnpm-workspace.yaml"), |
| ... | ... | @@ -3089,22 +2956,6 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")( |
| 3089 | 2956 | { label: "vp install --prod (server sidecar)", verbose: input.verbose }, |
| 3090 | 2957 | ); |
| 3091 | 2958 | |
| 3092 | yield* stageWslNodePtyPrebuild({ | |
| 3093 | stageAppDir: serverStageDir, | |
| 3094 | arch: input.arch, | |
| 3095 | prebuildPath: input.wslPrebuildPath, | |
| 3096 | }); | |
| 3097 | // Skip the archive entirely rather than shipping one the install script must | |
| 3098 | // extract and reject on every launch. The desktop app treats a missing | |
| 3099 | // archive as "no WSL-local runtime" and goes straight to the mounted tree. | |
| 3100 | if (bundlesWslRuntime({ arch: input.arch, prebuildPath: input.wslPrebuildPath })) { | |
| 3101 | yield* stageWslRuntimeArchive({ | |
| 3102 | sourceDir: serverStageDir, | |
| 3103 | archivePath: input.wslRuntimeArchivePath, | |
| 3104 | hashPath: input.wslRuntimeArchiveHashPath, | |
| 3105 | }); | |
| 3106 | } | |
| 3107 | ||
| 3108 | 2959 | yield* Effect.log("[desktop-artifact] Packing server.asar..."); |
| 3109 | 2960 | yield* fs.makeDirectory(path.dirname(input.asarPath), { recursive: true }); |
| 3110 | 2961 | yield* packWindowsServerAsar({ |
| ... | ... | @@ -3248,6 +3099,9 @@ export const validateWindowsPackagedPayload = Effect.fn( |
| 3248 | 3099 | readonly stageDistDir: string; |
| 3249 | 3100 | readonly appExecutableName: string; |
| 3250 | 3101 | readonly targetArch: typeof BuildArch.Type; |
| 3102 | // The version the embedded Linux CLI archive must carry; its top-level | |
| 3103 | // directory is named t3-<version>-linux-<arch>. | |
| 3104 | readonly appVersion: string; | |
| 3251 | 3105 | readonly expectWslRuntime?: boolean; |
| 3252 | 3106 | readonly fileLimit?: number; |
| 3253 | 3107 | readonly verbose?: boolean; |
| ... | ... | @@ -3411,24 +3265,22 @@ export const validateWindowsPackagedPayload = Effect.fn( |
| 3411 | 3265 | ); |
| 3412 | 3266 | } |
| 3413 | 3267 | const members = parseWslRuntimeArchiveMembers(listing.stdout); |
| 3414 | const forbiddenMember = members.find((member) => | |
| 3415 | WSL_RUNTIME_ARCHIVE_EXCLUDED_PREFIXES.some((prefix) => member.startsWith(prefix)), | |
| 3416 | ); | |
| 3417 | if (forbiddenMember !== undefined) { | |
| 3268 | // A release archive unpacks to one directory named after its stem; the | |
| 3269 | // desktop app's WSL install script relies on that layout to find `t3`. | |
| 3270 | const stem = wslRuntimeArchiveStem(input.appVersion, input.targetArch); | |
| 3271 | const topLevel = new Set(members.map((member) => member.split("/")[0])); | |
| 3272 | if (topLevel.size !== 1 || !topLevel.has(stem)) { | |
| 3418 | 3273 | return yield* invalidWslRuntime( |
| 3419 | new Error(`WSL runtime archive contains forbidden member ${forbiddenMember}`), | |
| 3274 | new Error( | |
| 3275 | `WSL runtime archive must contain a single top-level directory ${stem}, found ${[...topLevel].join(", ") || "nothing"}`, | |
| 3276 | ), | |
| 3420 | 3277 | ); |
| 3421 | 3278 | } |
| 3422 | const wslArch = resolveWslPrebuildArch(input.targetArch); | |
| 3423 | 3279 | const requiredMembers = [ |
| 3424 | "apps/server/dist/bin.mjs", | |
| 3425 | "node_modules/node-pty/package.json", | |
| 3426 | ...(wslArch === undefined | |
| 3427 | ? [] | |
| 3428 | : [ | |
| 3429 | `node_modules/node-pty/prebuilds/linux-${wslArch}/pty.node`, | |
| 3430 | `node_modules/node-pty/prebuilds/linux-${wslArch}/t3code-wsl-node-pty.json`, | |
| 3431 | ]), | |
| 3280 | `${stem}/t3`, | |
| 3281 | `${stem}/client`, | |
| 3282 | `${stem}/node_modules`, | |
| 3283 | `${stem}/node_modules/node-pty/build/Release/pty.node`, | |
| 3432 | 3284 | ]; |
| 3433 | 3285 | const missingMembers = requiredMembers.filter((member) => !members.includes(member)); |
| 3434 | 3286 | if (missingMembers.length > 0) { |
| ... | ... | @@ -3436,9 +3288,16 @@ export const validateWindowsPackagedPayload = Effect.fn( |
| 3436 | 3288 | reason: "wsl-runtime-invalid", |
| 3437 | 3289 | packagedAppDir, |
| 3438 | 3290 | missingFiles: missingMembers, |
| 3439 | cause: new Error("WSL runtime archive is incomplete"), | |
| 3291 | cause: new Error("WSL runtime archive is not a Linux CLI release archive"), | |
| 3440 | 3292 | }); |
| 3441 | 3293 | } |
| 3294 | // The CLI archive runs the single-executable, never a loose server bundle. | |
| 3295 | const bundleEntry = members.find((member) => member.endsWith("/bin.mjs")); | |
| 3296 | if (bundleEntry !== undefined) { | |
| 3297 | return yield* invalidWslRuntime( | |
| 3298 | new Error(`WSL runtime archive contains a server bundle entry ${bundleEntry}`), | |
| 3299 | ); | |
| 3300 | } | |
| 3442 | 3301 | } |
| 3443 | 3302 | |
| 3444 | 3303 | const fileCount = yield* countPayloadFiles(packagedAppDir); |
| ... | ... | @@ -3487,8 +3346,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 3487 | 3346 | yield* preflightWindowsDesktopBuild({ |
| 3488 | 3347 | arch: options.arch, |
| 3489 | 3348 | bundlesWslRuntime: bundlesWslRuntime({ |
| 3490 | arch: options.arch, | |
| 3491 | prebuildPath: options.wslPrebuild, | |
| 3349 | platform: options.platform, | |
| 3350 | runtimeArchivePath: options.wslRuntime, | |
| 3492 | 3351 | }), |
| 3493 | 3352 | }); |
| 3494 | 3353 | } |
| ... | ... | @@ -3807,7 +3666,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 3807 | 3666 | provisioningProfilePath: macPasskeySigning.provisioningProfilePath, |
| 3808 | 3667 | } |
| 3809 | 3668 | : undefined, |
| 3810 | bundlesWslRuntime({ arch: options.arch, prebuildPath: options.wslPrebuild }), | |
| 3669 | bundlesWslRuntime({ platform: options.platform, runtimeArchivePath: options.wslRuntime }), | |
| 3811 | 3670 | options.arch, |
| 3812 | 3671 | ), |
| 3813 | 3672 | dependencies: stageDependencies, |
| ... | ... | @@ -3847,9 +3706,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 3847 | 3706 | yield* stageClerkPasskeyNativeBinaries(stageAppDir, options.platform, options.arch); |
| 3848 | 3707 | yield* stageKeyringNativeBinaries(stageAppDir, options.platform, options.arch); |
| 3849 | 3708 | |
| 3850 | // WSL is Windows-only, so only the Windows artifact carries the server | |
| 3851 | // sidecar (which embeds the Linux node-pty prebuild); other platforms | |
| 3852 | // ignore the prebuild input. | |
| 3709 | // Only the Windows artifact carries the server sidecar and the WSL runtime; | |
| 3710 | // other platforms ignore the --wsl-runtime input. | |
| 3853 | 3711 | if (options.platform === "win" && windowsServerAsarPath) { |
| 3854 | 3712 | yield* stageWindowsServerSidecar({ |
| 3855 | 3713 | stageRoot, |
| ... | ... | @@ -3862,17 +3720,20 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 3862 | 3720 | allowBuilds: workspaceAllowBuilds, |
| 3863 | 3721 | patchedDependencies: workspacePatchedDependencies, |
| 3864 | 3722 | overrides: resolvedOverrides, |
| 3865 | wslPrebuildPath: options.wslPrebuild, | |
| 3866 | 3723 | asarPath: windowsServerAsarPath, |
| 3867 | wslRuntimeArchivePath: path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE.from), | |
| 3868 | wslRuntimeArchiveHashPath: path.join( | |
| 3869 | stageAppDir, | |
| 3870 | WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE.from, | |
| 3871 | ), | |
| 3872 | ||
| 3873 | 3724 | verbose: options.verbose, |
| 3874 | 3725 | }); |
| 3875 | 3726 | } |
| 3727 | if ( | |
| 3728 | options.wslRuntime !== undefined && | |
| 3729 | bundlesWslRuntime({ platform: options.platform, runtimeArchivePath: options.wslRuntime }) | |
| 3730 | ) { | |
| 3731 | yield* stageWslRuntimeArchive({ | |
| 3732 | sourceArchivePath: options.wslRuntime, | |
| 3733 | archivePath: path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE.from), | |
| 3734 | hashPath: path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE.from), | |
| 3735 | }); | |
| 3736 | } | |
| 3876 | 3737 | |
| 3877 | 3738 | // electron-builder treats several set-but-empty variables (e.g. CSC_LINK="") |
| 3878 | 3739 | // as enabled, so copy the host env and scrub empty values instead of relying |
| ... | ... | @@ -3968,9 +3829,10 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 3968 | 3829 | stageDistDir, |
| 3969 | 3830 | appExecutableName: `${resolveDesktopProductName(appVersion)}.exe`, |
| 3970 | 3831 | targetArch: options.arch, |
| 3832 | appVersion, | |
| 3971 | 3833 | expectWslRuntime: bundlesWslRuntime({ |
| 3972 | arch: options.arch, | |
| 3973 | prebuildPath: options.wslPrebuild, | |
| 3834 | platform: options.platform, | |
| 3835 | runtimeArchivePath: options.wslRuntime, | |
| 3974 | 3836 | }), |
| 3975 | 3837 | verbose: options.verbose, |
| 3976 | 3838 | }); |
| ... | ... | @@ -4055,9 +3917,9 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", { |
| 4055 | 3917 | Flag.withDescription("Mock update server port (env: T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT)."), |
| 4056 | 3918 | Flag.optional, |
| 4057 | 3919 | ), |
| 4058 | wslPrebuild: Flag.string("wsl-prebuild").pipe( | |
| 3920 | wslRuntime: Flag.string("wsl-runtime").pipe( | |
| 4059 | 3921 | Flag.withDescription( |
| 4060 | "Path to a prebuilt Linux node-pty (pty.node) for the target arch, staged for the WSL backend (env: T3CODE_DESKTOP_WSL_PREBUILD).", | |
| 3922 | "Path to the Linux CLI release archive (t3-<version>-linux-x64.tar.gz) to embed as the WSL runtime of a Windows build (env: T3CODE_DESKTOP_WSL_RUNTIME).", | |
| 4061 | 3923 | ), |
| 4062 | 3924 | Flag.optional, |
| 4063 | 3925 | ), |