From f117561f54ea8b74ff4504943333e4f663fedbef Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Wed, 7 Oct 2026 15:04:28 +0200 Subject: [PATCH 01/32] feat(runtime): run the server and first-party helpers with Bun --- .bun-version | 1 + apps/server/package.json | 16 +- .../scripts/record-grok-acp-replay-fixture.ts | 2 +- apps/server/src/bin.ts | 5 + apps/server/src/cli/serviceLauncher.ts | 2 +- apps/server/src/compileCache.test.ts | 53 ------ apps/server/src/compileCache.ts | 9 - apps/server/src/entrypoint.ts | 7 +- .../Adapters/AcpAdapterV2.test.ts | 2 +- .../orchestration-v2/Adapters/AcpAdapterV2.ts | 2 +- .../Adapters/AcpRegistryAdapterV2.test.ts | 2 +- .../Adapters/AcpRegistryAdapterV2.testkit.ts | 2 +- .../Adapters/AcpRegistryAdapterV2.ts | 2 +- .../Adapters/AntigravityAdapterV2.test.ts | 2 +- .../Adapters/AntigravityAdapterV2.ts | 2 +- .../Adapters/GrokAdapterV2.test.ts | 2 +- .../Adapters/GrokAdapterV2.testkit.ts | 2 +- .../Adapters/GrokAdapterV2.ts | 2 +- .../src/preview/ServerBrowserContexts.ts | 5 +- .../provider/AntigravityInstallation.test.ts | 4 +- .../src/provider/AntigravityInstallation.ts | 6 +- .../src/provider/Drivers/AntigravityDriver.ts | 14 +- .../provider/antigravityAuthSupport.test.ts | 10 +- .../src/provider/antigravityAuthSupport.ts | 6 +- .../src/provider/cursorKeychainToken.ts | 3 +- apps/server/src/serverRuntimeStartup.ts | 2 - apps/server/src/terminal/NodePtyAdapter.ts | 8 +- .../src/workspace/WorkspaceSearchIndex.ts | 8 +- package.json | 20 +- packages/shared/package.json | 6 +- ...nodeRuntime.test.ts => bunRuntime.test.ts} | 178 +++++++++--------- packages/shared/src/bunRuntime.ts | 134 +++++++++++++ packages/shared/src/hostProcess.ts | 21 ++- packages/shared/src/nodeRuntime.ts | 117 ------------ packages/shared/src/nodeSqliteClient.test.ts | 25 +++ packages/shared/src/nodeSqliteClient.ts | 116 +++++------- .../src/testing/sqliteRuntime.fixture.ts | 59 ++++++ scripts/dev-runner.test.ts | 10 +- scripts/dev-runner.ts | 2 +- 39 files changed, 454 insertions(+), 415 deletions(-) create mode 100644 .bun-version delete mode 100644 apps/server/src/compileCache.test.ts delete mode 100644 apps/server/src/compileCache.ts rename packages/shared/src/{nodeRuntime.test.ts => bunRuntime.test.ts} (54%) create mode 100644 packages/shared/src/bunRuntime.ts delete mode 100644 packages/shared/src/nodeRuntime.ts create mode 100644 packages/shared/src/testing/sqliteRuntime.fixture.ts diff --git a/.bun-version b/.bun-version new file mode 100644 index 000000000000..88c5fb891dcf --- /dev/null +++ b/.bun-version @@ -0,0 +1 @@ +1.4.0 diff --git a/apps/server/package.json b/apps/server/package.json index 2aa6a961d6eb..63ab7b7edb7a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -4,7 +4,7 @@ "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/pingdotgg/t3code", + "url": "https://github.com/iglo-tech/iglo.code", "directory": "apps/server" }, "bin": { @@ -15,17 +15,17 @@ ], "type": "module", "scripts": { - "dev": "node --watch src/bin.ts", + "dev": "bun --watch src/bin.ts", "build:bundle": "vp pack", - "build:exe": "node scripts/cli.ts build-exe", - "start": "node dist/bin.mjs", + "build:exe": "bun scripts/cli.ts build-exe", + "start": "bun dist/bin.mjs", "typecheck": "tsc --noEmit", "test": "vp test run", "record:codex-replay": "bun scripts/record-codex-app-server-replay-fixture.ts", "record:claude-replay": "bun scripts/record-claude-agent-sdk-replay-fixture.ts", - "record:cursor-replay": "node --env-file-if-exists=../../.env --experimental-strip-types scripts/record-cursor-agent-sdk-replay-fixture.ts", - "record:pi-replay": "node scripts/record-pi-rpc-replay-fixture.ts", - "record:grok-replay": "node scripts/record-grok-acp-replay-fixture.ts" + "record:cursor-replay": "bun --env-file=../../.env scripts/record-cursor-agent-sdk-replay-fixture.ts", + "record:pi-replay": "bun scripts/record-pi-rpc-replay-fixture.ts", + "record:grok-replay": "bun scripts/record-grok-acp-replay-fixture.ts" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.276", @@ -66,6 +66,6 @@ "vite-plus": "catalog:" }, "engines": { - "node": "^22.16 || ^23.11 || >=24.10" + "bun": ">=1.4.0" } } diff --git a/apps/server/scripts/record-grok-acp-replay-fixture.ts b/apps/server/scripts/record-grok-acp-replay-fixture.ts index cd384c0db7c0..a269f1bf20be 100644 --- a/apps/server/scripts/record-grok-acp-replay-fixture.ts +++ b/apps/server/scripts/record-grok-acp-replay-fixture.ts @@ -12,7 +12,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { GrokSettings, type ProviderReplayEntry } from "@t3tools/contracts"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; +import { resolveSelfInvocation } from "@t3tools/shared/bunRuntime"; import * as Clock from "effect/Clock"; import * as Console from "effect/Console"; import * as Crypto from "effect/Crypto"; diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 063093874503..7928dc3f3c8c 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -7,6 +7,7 @@ * CLI module graph (seconds of evaluation) loads; everything else defers to * the real CLI in ./binCli.ts. */ +import { BUN_VERSION, isSupportedBunVersion } from "@t3tools/shared/bunRuntime"; import { isEntrypoint } from "./entrypoint.ts"; if ( @@ -16,6 +17,10 @@ if ( runtimeMain: import.meta.main, }) ) { + if (!isSupportedBunVersion(process.versions.bun)) { + process.stderr.write(`T3 Code requires Bun ${BUN_VERSION} or newer. Run this CLI with Bun.\n`); + process.exit(1); + } const command = process.argv[2]; if (command === "acp-mcp-bridge" || command === "acp-mcp-call") { const { runAcpMcpCliFastPath } = await import("./mcp/AcpMcpStdioBridge.ts"); diff --git a/apps/server/src/cli/serviceLauncher.ts b/apps/server/src/cli/serviceLauncher.ts index ddd53fd63026..2d09b7646bb8 100644 --- a/apps/server/src/cli/serviceLauncher.ts +++ b/apps/server/src/cli/serviceLauncher.ts @@ -6,7 +6,7 @@ import { main as runServiceLauncher } from "../serviceLauncher.ts"; /** * Hosts the service launcher inside the CLI executable. The service manager * runs `t3 __service-launcher` and the launcher spawns the server from the - * same executable, so the machine needs no Node to run either. + * same executable, so the archive carries the runtime needed to run either. * * The launcher owns SIGTERM handling and the process lifetime: it must finish * stopping its child before the process exits, so it runs detached from the diff --git a/apps/server/src/compileCache.test.ts b/apps/server/src/compileCache.test.ts deleted file mode 100644 index 17827bb38ba9..000000000000 --- a/apps/server/src/compileCache.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off - the test kills a real Node process to prove the cache is on disk. -import * as NodeChildProcess from "node:child_process"; -import * as NodeFSP from "node:fs/promises"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; -import { assert, it } from "@effect/vitest"; - -it.each([false, true])( - "persists an enabled cache before forced exit (disabled: %s)", - async (disabled) => { - const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-compile-cache-")); - try { - const cacheDirectory = NodePath.join(directory, "cache"); - const child = NodeChildProcess.spawnSync( - process.execPath, - [ - "--input-type=module", - "--eval", - `import * as Effect from ${JSON.stringify(import.meta.resolve("effect/Effect"))}; -const { flushCompileCache } = await import(${JSON.stringify(new URL("./compileCache.ts", import.meta.url).href)}); -await Effect.runPromise(flushCompileCache); -process.kill(process.pid, "SIGKILL");`, - ], - { - encoding: "utf8", - env: { - ...process.env, - NODE_COMPILE_CACHE: cacheDirectory, - NODE_DISABLE_COMPILE_CACHE: disabled ? "1" : undefined, - }, - }, - ); - assert.equal(child.error, undefined); - assert.equal(child.stderr, ""); - assert.notEqual(child.status, 0); - const entries = await NodeFSP.readdir(cacheDirectory, { recursive: true }).catch( - (error: NodeJS.ErrnoException) => { - if (error.code === "ENOENT") return []; - throw error; - }, - ); - const files = await Promise.all( - entries.map((entry) => NodeFSP.stat(NodePath.join(cacheDirectory, entry))), - ); - assert.equal( - files.some((entry) => entry.isFile()), - !disabled, - ); - } finally { - await NodeFSP.rm(directory, { recursive: true, force: true }); - } - }, -); diff --git a/apps/server/src/compileCache.ts b/apps/server/src/compileCache.ts deleted file mode 100644 index d31e4305774c..000000000000 --- a/apps/server/src/compileCache.ts +++ /dev/null @@ -1,9 +0,0 @@ -import * as NodeModule from "node:module"; -import * as Effect from "effect/Effect"; - -// Desktop enables this cache before loading the backend. Windows force-kills -// the backend on quit, so persist it after startup instead of waiting for exit. -// This is a no-op when caching is disabled, including normal dev launches. -export const flushCompileCache = Effect.try(() => NodeModule.flushCompileCache()).pipe( - Effect.ignore, -); diff --git a/apps/server/src/entrypoint.ts b/apps/server/src/entrypoint.ts index 1ac083ec5873..1fdb033acf37 100644 --- a/apps/server/src/entrypoint.ts +++ b/apps/server/src/entrypoint.ts @@ -7,11 +7,8 @@ import * as NodeURL from "node:url"; /** * Whether the module identified by `moduleUrl` is the process entrypoint. * - * `import.meta.main` answers this directly, but it only exists on Node 22.18+ - * and 24.2+. This package's `engines.node` range also accepts 22.16, 22.17 and - * 23.11, where it is `undefined`: an `if (import.meta.main)` guard never runs, - * so the process loads every module and exits 0 without output. Fall back to - * comparing the entrypoint path on those versions. + * Bun answers this through `import.meta.main`. The filesystem comparison + * also supports a bundled entrypoint reached through a launcher symlink. */ export const isEntrypoint = (input: { readonly moduleUrl: string; diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 0724c3c0a57f..2e47068a19a4 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -28,7 +28,7 @@ import { type OrchestrationV2ProviderThread, } from "@t3tools/contracts"; import { HostProcessIsExecutable, HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; +import { resolveSelfInvocation } from "@t3tools/shared/bunRuntime"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index 179455cb64a8..1a69c57226f5 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -33,7 +33,7 @@ import { type ThreadId, } from "@t3tools/contracts"; import { modelSelectionsEqual } from "@t3tools/shared/model"; -import { type SelfInvocation, selfInvocationArgs } from "@t3tools/shared/nodeRuntime"; +import { type SelfInvocation, selfInvocationArgs } from "@t3tools/shared/bunRuntime"; import { FILE_HEADERS_ONLY, formatPatch, structuredPatch } from "diff"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 140476f4fa27..e51282ad95d7 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -1,7 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts"; -import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; +import { resolveSelfInvocation } from "@t3tools/shared/bunRuntime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Crypto from "effect/Crypto"; diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts index 4289d0314b38..c62c12cfbe08 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts @@ -1,6 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { AcpRegistrySettings } from "@t3tools/contracts"; -import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; +import { resolveSelfInvocation } from "@t3tools/shared/bunRuntime"; import * as Effect from "effect/Effect"; import * as Crypto from "effect/Crypto"; import * as FileSystem from "effect/FileSystem"; diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts index be73eaec6819..e761ee6f35dc 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts @@ -9,7 +9,7 @@ import { ProviderDriverKind, } from "@t3tools/contracts"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; -import { resolveSelfInvocation, type SelfInvocation } from "@t3tools/shared/nodeRuntime"; +import { resolveSelfInvocation, type SelfInvocation } from "@t3tools/shared/bunRuntime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index 6905e72fafb5..054c5552b992 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -10,7 +10,7 @@ import { RunId, ThreadId, } from "@t3tools/contracts"; -import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; +import { resolveSelfInvocation } from "@t3tools/shared/bunRuntime"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts index 1ee304556cc1..9d4ec2675f5d 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts @@ -5,7 +5,7 @@ import { type OrchestrationV2ProviderCapabilities, type ProviderSetupError, } from "@t3tools/contracts"; -import type { SelfInvocation } from "@t3tools/shared/nodeRuntime"; +import type { SelfInvocation } from "@t3tools/shared/bunRuntime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import type * as FileSystem from "effect/FileSystem"; diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts index 59e09b2b44c0..d5a2a8e48e21 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts @@ -8,7 +8,7 @@ import { ThreadId, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; +import { resolveSelfInvocation } from "@t3tools/shared/bunRuntime"; import * as EffectAcpErrors from "effect-acp/errors"; import { xAiRateLimitedErrorCode } from "../../provider/acp/XAiAcpExtension.ts"; import { assert, describe, it } from "@effect/vitest"; diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts index 96d0bedab89a..85c1236d1d84 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts @@ -8,7 +8,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/process"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; +import { resolveSelfInvocation } from "@t3tools/shared/bunRuntime"; import * as ServerConfig from "../../config.ts"; import { diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts index b446e7ff02df..cb0a0d9c942a 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts @@ -5,7 +5,7 @@ import { xAiRateLimitedErrorCode, } from "../../provider/acp/XAiAcpExtension.ts"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { resolveSelfInvocation, type SelfInvocation } from "@t3tools/shared/nodeRuntime"; +import { resolveSelfInvocation, type SelfInvocation } from "@t3tools/shared/bunRuntime"; import { defaultInstanceIdForDriver, GrokSettings, diff --git a/apps/server/src/preview/ServerBrowserContexts.ts b/apps/server/src/preview/ServerBrowserContexts.ts index 3e48bfb6b88c..e7dd9e8a1522 100644 --- a/apps/server/src/preview/ServerBrowserContexts.ts +++ b/apps/server/src/preview/ServerBrowserContexts.ts @@ -1,3 +1,4 @@ +import { resolveHostModuleUrl } from "@t3tools/shared/hostProcess"; // @effect-diagnostics nodeBuiltinImport:off - Owns Playwright resources outside the Effect runtime. import { INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts"; import { constVoid } from "effect/Function"; @@ -8,8 +9,8 @@ import type { Browser, BrowserContext } from "playwright-core"; import { sandboxDisabled } from "./PreviewBrowserHost.ts"; -// Playwright needs its files on disk. createRequire also resolves it from a Node SEA executable. -const requirePlaywright = NodeModule.createRequire(import.meta.url); +// Playwright loads its files from disk beside the compiled CLI. +const requirePlaywright = NodeModule.createRequire(resolveHostModuleUrl(import.meta.url)); const loadPlaywright = () => requirePlaywright("playwright-core") as typeof import("playwright-core"); diff --git a/apps/server/src/provider/AntigravityInstallation.test.ts b/apps/server/src/provider/AntigravityInstallation.test.ts index ac256ef3dcd0..d6b38be77b5f 100644 --- a/apps/server/src/provider/AntigravityInstallation.test.ts +++ b/apps/server/src/provider/AntigravityInstallation.test.ts @@ -249,13 +249,13 @@ const expectPreviousRelease = Effect.fn("test.expectPreviousAntigravityRelease") }); it.layer(NodeServices.layer)("Antigravity installation", (it) => { - it.effect("reports missing Node before downloading the standalone provider runtime", () => + it.effect("reports missing Bun before downloading the standalone provider runtime", () => Effect.gen(function* () { const { installation, requests, validations } = yield* makeHarness(); yield* installation.start; expect(yield* terminalState(installation)).toMatchObject({ phase: "failed", - message: expect.stringContaining("Install Node.js"), + message: expect.stringContaining("Install Bun"), }); expect(requests).toEqual([]); expect(validations).toEqual([]); diff --git a/apps/server/src/provider/AntigravityInstallation.ts b/apps/server/src/provider/AntigravityInstallation.ts index 4f78ee8ebb33..68c40ba01a29 100644 --- a/apps/server/src/provider/AntigravityInstallation.ts +++ b/apps/server/src/provider/AntigravityInstallation.ts @@ -6,7 +6,7 @@ import { HostProcessEnvironment, HostProcessPlatform, } from "@t3tools/shared/hostProcess"; -import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; +import { resolveBunExecutable, bunRuntimeUnavailableMessage } from "@t3tools/shared/bunRuntime"; import * as Clock from "effect/Clock"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; @@ -407,12 +407,12 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma const install = Effect.fn("AntigravityInstallation.install")( function* (asset: AntigravityReleaseAsset) { - yield* resolveNodeExecutable("Antigravity", environment).pipe( + yield* resolveBunExecutable("Antigravity", environment).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.provideService(HostProcessPlatform, platform), Effect.mapError((cause) => - installationError("verify", nodeRuntimeUnavailableMessage("Antigravity"), cause), + installationError("verify", bunRuntimeUnavailableMessage("Antigravity"), cause), ), ); const report = (phase: ProviderInstallState["phase"], message: string | null) => diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 7a10b4a725cd..77b026e4c688 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -1,11 +1,11 @@ import { withAgentDeviceEnvironment } from "../../mcp/McpProviderSession.ts"; import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; +import { resolveSelfInvocation } from "@t3tools/shared/bunRuntime"; import { - NodeRuntimeUnavailableError, - nodeRuntimeUnavailableMessage, -} from "@t3tools/shared/nodeRuntime"; + BunRuntimeUnavailableError, + bunRuntimeUnavailableMessage, +} from "@t3tools/shared/bunRuntime"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -66,7 +66,7 @@ import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./Antigra const DRIVER = ProviderDriverKind.make("antigravity"); const decodeSettings = Schema.decodeSync(AntigravitySettings); -const isNodeRuntimeUnavailableError = Schema.is(NodeRuntimeUnavailableError); +const isBunRuntimeUnavailableError = Schema.is(BunRuntimeUnavailableError); export type AntigravityDriverEnv = | AntigravityInstallation.AntigravityInstallation @@ -198,11 +198,11 @@ export const AntigravityDriver: ProviderDriver - isNodeRuntimeUnavailableError(cause.cause) + isBunRuntimeUnavailableError(cause.cause) ? new ProviderSetupError({ instanceId, operation: "start", - detail: nodeRuntimeUnavailableMessage("Antigravity sign-in"), + detail: bunRuntimeUnavailableMessage("Antigravity sign-in"), cause, }) : cause, diff --git a/apps/server/src/provider/antigravityAuthSupport.test.ts b/apps/server/src/provider/antigravityAuthSupport.test.ts index 8f794b59a1af..86de34035226 100644 --- a/apps/server/src/provider/antigravityAuthSupport.test.ts +++ b/apps/server/src/provider/antigravityAuthSupport.test.ts @@ -562,14 +562,14 @@ describe("Antigravity stderr compatibility", () => { }); it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { - it.effect("runs the browser helper with installed Node in standalone builds", () => + it.effect("runs the browser helper with the Bun interpreter in standalone builds", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const directory = yield* fs.makeTempDirectoryScoped(); const profile = yield* prepareAntigravityProfile({ profileDirectory: path.join(directory, "profile"), - baseEnv: { PATH: path.dirname(process.execPath) }, + baseEnv: { PATH: "", T3_BUN_EXECUTABLE: process.env.T3_BUN_EXECUTABLE ?? "bun" }, }); expect(profile.browserCommand).not.toContain("/packaged/t3"); expect(yield* fs.exists(profile.acpDirectory)).toBe(true); @@ -579,7 +579,7 @@ it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { ), ); - it.effect("reports missing Node before creating the standalone sign-in profile", () => + it.effect("reports missing Bun before creating the standalone sign-in profile", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -593,9 +593,9 @@ it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { if (Result.isFailure(result)) { expect(result.failure).toMatchObject({ _tag: "AcpTransportError", - detail: expect.stringContaining("Install Node.js"), + detail: expect.stringContaining("Install Bun"), cause: { - _tag: "NodeRuntimeUnavailableError", + _tag: "BunRuntimeUnavailableError", cause: { _tag: "CommandResolutionError" }, }, }); diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index 7a87854b19db..8f759b299f89 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -3,7 +3,7 @@ import * as NodeFSP from "node:fs/promises"; import type { AntigravityAuthMethod, ProviderInstanceId } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; +import { resolveBunExecutable, bunRuntimeUnavailableMessage } from "@t3tools/shared/bunRuntime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; @@ -320,11 +320,11 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")( input.userHome ?? resolveAntigravityUserHome(platform, input.baseEnv ?? process.env); const runtimeExecutablePath = input.runtimeExecutablePath ?? - (yield* resolveNodeExecutable("Antigravity sign-in", input.baseEnv).pipe( + (yield* resolveBunExecutable("Antigravity sign-in", input.baseEnv).pipe( Effect.mapError( (cause) => new AcpErrors.AcpTransportError({ - detail: nodeRuntimeUnavailableMessage("Antigravity sign-in"), + detail: bunRuntimeUnavailableMessage("Antigravity sign-in"), cause, }), ), diff --git a/apps/server/src/provider/cursorKeychainToken.ts b/apps/server/src/provider/cursorKeychainToken.ts index 2857683e4821..3ede72283409 100644 --- a/apps/server/src/provider/cursorKeychainToken.ts +++ b/apps/server/src/provider/cursorKeychainToken.ts @@ -1,8 +1,9 @@ +import { resolveHostModuleUrl } from "@t3tools/shared/hostProcess"; import * as NodeModule from "node:module"; const CACHE_MS = 5 * 60_000; -const requireForKeyring = NodeModule.createRequire(import.meta.url); +const requireForKeyring = NodeModule.createRequire(resolveHostModuleUrl(import.meta.url)); /** Rejected when nobody answers the macOS Keychain prompt in time. */ export class CursorKeychainTimeoutError extends Error { diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 88aa4298c596..733c04354180 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -27,7 +27,6 @@ import * as Schema from "effect/Schema"; import * as ServerConfig from "./config.ts"; import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; -import { flushCompileCache } from "./compileCache.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as EffectWorker from "./orchestration-v2/EffectWorker.ts"; @@ -651,7 +650,6 @@ const make = (options?: StartupOptions) => }), ); yield* Effect.logDebug("startup phase: complete"); - yield* flushCompileCache; }).pipe( Effect.annotateSpans({ "server.mode": serverConfig.mode, diff --git a/apps/server/src/terminal/NodePtyAdapter.ts b/apps/server/src/terminal/NodePtyAdapter.ts index 8ba78ee4d288..852c816e24d2 100644 --- a/apps/server/src/terminal/NodePtyAdapter.ts +++ b/apps/server/src/terminal/NodePtyAdapter.ts @@ -1,3 +1,4 @@ +import { resolveHostModuleUrl } from "@t3tools/shared/hostProcess"; import * as NodeModule from "node:module"; import * as NodeNet from "node:net"; @@ -27,10 +28,9 @@ export class NodePtyModuleLoadError extends Schema.TaggedError Promise; // node-pty stays external to the CLI bundle because it dlopens a native -// addon. Inside a Node single-executable, `import()` cannot load files from -// disk (only built-ins resolve), while `require` always reads the real -// filesystem, so both the module and its spawn-helper resolve through it. -const requireForNodePty = NodeModule.createRequire(import.meta.url); +// addon. The compiled CLI loads the module and its spawn-helper from the +// archive's real filesystem rather than Bun's embedded virtual entrypoint. +const requireForNodePty = NodeModule.createRequire(resolveHostModuleUrl(import.meta.url)); const loadNodePty: NodePtyModuleLoader = () => Promise.resolve().then(() => requireForNodePty("node-pty") as typeof import("node-pty")); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 7cd96a65ebee..f098a4c28bad 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -1,3 +1,4 @@ +import { resolveHostModuleUrl } from "@t3tools/shared/hostProcess"; import * as NodeModule from "node:module"; import type { @@ -28,10 +29,9 @@ import type { import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; // fff-node stays external to the CLI bundle because it dlopens a native -// library. A static `import` of an external package is a hard error inside a -// Node single-executable (only built-ins resolve there), so load it through -// `require`, which reads from the real filesystem in every runtime. -const requireForFff = NodeModule.createRequire(import.meta.url); +// library. Resolve the archive's native package from the real filesystem +// rather than Bun's embedded virtual entrypoint. +const requireForFff = NodeModule.createRequire(resolveHostModuleUrl(import.meta.url)); const { FileFinder } = requireForFff("@ff-labs/fff-node") as typeof import("@ff-labs/fff-node"); const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; diff --git a/package.json b/package.json index 9f7ce8cee086..29848b8bc58d 100644 --- a/package.json +++ b/package.json @@ -4,17 +4,17 @@ "type": "module", "scripts": { "prepare": "effect-tsgo patch && vp config --no-agent", - "dev": "node scripts/dev-runner.ts dev", - "dev:share": "node scripts/dev-runner.ts dev --share", - "dev:server": "node scripts/dev-runner.ts dev:server", - "dev:web": "node scripts/dev-runner.ts dev:web", + "dev": "bun scripts/dev-runner.ts dev", + "dev:share": "bun scripts/dev-runner.ts dev --share", + "dev:server": "bun scripts/dev-runner.ts dev:server", + "dev:web": "bun scripts/dev-runner.ts dev:web", "dev:marketing": "vp run --filter @t3tools/marketing dev", - "migrate-dev-db": "node apps/server/scripts/migrate-dev-db.ts", + "migrate-dev-db": "bun apps/server/scripts/migrate-dev-db.ts", "start": "vp run --filter t3 start", "start:marketing": "vp run --filter @t3tools/marketing preview", - "icons:export": "node scripts/export-brand-icons.ts", - "icons:check": "node scripts/export-brand-icons.ts --check", - "licenses:sync": "node scripts/sync-third-party-license-notices.ts", + "icons:export": "bun scripts/export-brand-icons.ts", + "icons:check": "bun scripts/export-brand-icons.ts --check", + "licenses:sync": "bun scripts/sync-third-party-license-notices.ts", "build": "vp run --filter './apps/*' build", "build:marketing": "vp run --filter @t3tools/marketing build", "build:resource-monitor": "cargo build --locked --release --manifest-path native/resource-monitor/Cargo.toml", @@ -29,9 +29,9 @@ "test:resource-monitor": "cargo test --locked --manifest-path native/resource-monitor/Cargo.toml", "fmt": "vp fmt", "fmt:check": "vp fmt --check", - "release:smoke": "node scripts/release-smoke.ts", + "release:smoke": "bun scripts/release-smoke.ts", "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist packages/*/dist .vite-plus apps/*/.vite-plus packages/*/.vite-plus", - "sync:repos": "node scripts/sync-reference-repos.ts" + "sync:repos": "bun scripts/sync-reference-repos.ts" }, "devDependencies": { "@coderabbitai/config": "1.1.0", diff --git a/packages/shared/package.json b/packages/shared/package.json index 3732ebd5cfa6..6d9e706cfd6c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -299,9 +299,9 @@ "types": "./src/chatList.ts", "import": "./src/chatList.ts" }, - "./nodeRuntime": { - "types": "./src/nodeRuntime.ts", - "import": "./src/nodeRuntime.ts" + "./bunRuntime": { + "types": "./src/bunRuntime.ts", + "import": "./src/bunRuntime.ts" }, "./hostProcess": { "types": "./src/hostProcess.ts", diff --git a/packages/shared/src/nodeRuntime.test.ts b/packages/shared/src/bunRuntime.test.ts similarity index 54% rename from packages/shared/src/nodeRuntime.test.ts rename to packages/shared/src/bunRuntime.test.ts index 31c73ac732f6..20037d9950f5 100644 --- a/packages/shared/src/nodeRuntime.test.ts +++ b/packages/shared/src/bunRuntime.test.ts @@ -1,9 +1,12 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Exercises actual Bun subprocesses outside the test runtime. +import * as NodeChildProcess from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Result from "effect/Result"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { HostProcessArguments, @@ -11,19 +14,23 @@ import { HostProcessIsExecutable, HostProcessPlatform, } from "./hostProcess.ts"; -import { resolveNodeExecutable, resolveSelfInvocation, selfInvocationArgs } from "./nodeRuntime.ts"; +import { resolveBunExecutable, resolveSelfInvocation, selfInvocationArgs } from "./bunRuntime.ts"; import { symlinksSupported } from "./testing/symlinks.ts"; +const bunExecutable = + process.env.T3_BUN_EXECUTABLE ?? + NodeChildProcess.execFileSync("which", ["bun"], { encoding: "utf8" }).trim(); + describe("Self invocation", () => { it.effect("runs the entrypoint script with the current runtime", () => Effect.gen(function* () { const path = yield* Path.Path; const invocation = yield* resolveSelfInvocation().pipe( - Effect.provideService(HostProcessExecutablePath, "/runtime/node"), + Effect.provideService(HostProcessExecutablePath, "/runtime/bun"), Effect.provideService(HostProcessIsExecutable, false), - Effect.provideService(HostProcessArguments, ["/runtime/node", "dist/bin.mjs", "serve"]), + Effect.provideService(HostProcessArguments, ["/runtime/bun", "dist/bin.mjs", "serve"]), ); - expect(invocation.command).toBe("/runtime/node"); + expect(invocation.command).toBe("/runtime/bun"); expect(invocation.entrypoint).toBe(path.resolve("dist/bin.mjs")); expect(selfInvocationArgs(invocation, ["acp-mcp-bridge"])).toEqual([ path.resolve("dist/bin.mjs"), @@ -34,11 +41,11 @@ describe("Self invocation", () => { it.effect("passes subcommands straight to the standalone executable", () => Effect.gen(function* () { - // Node repeats the binary at argv[1] for a single-executable; it is not a script. + // Bun points argv[1] at its embedded virtual filesystem entrypoint. const invocation = yield* resolveSelfInvocation().pipe( Effect.provideService(HostProcessExecutablePath, "/packaged/t3"), Effect.provideService(HostProcessIsExecutable, true), - Effect.provideService(HostProcessArguments, ["/packaged/t3", "/packaged/t3", "serve"]), + Effect.provideService(HostProcessArguments, ["bun", "/$bunfs/root/t3", "serve"]), ); expect(invocation).toEqual({ command: "/packaged/t3", entrypoint: undefined }); expect(selfInvocationArgs(invocation, ["acp-mcp-bridge"])).toEqual(["acp-mcp-bridge"]); @@ -46,12 +53,12 @@ describe("Self invocation", () => { ); }); -describe("Node runtime selection", () => { - it.effect("keeps the current Node or Electron runtime without requiring Node on PATH", () => +describe("Bun helper runtime selection", () => { + it.effect("keeps the current Bun runtime without requiring Bun on PATH", () => Effect.gen(function* () { - for (const executable of ["/runtime/node", "/Applications/T3 Code.app/Electron"]) { + for (const executable of ["/runtime/bun"]) { expect( - yield* resolveNodeExecutable("Local device support", { PATH: "" }).pipe( + yield* resolveBunExecutable("Local device support", { PATH: "" }).pipe( Effect.provideService(HostProcessExecutablePath, executable), Effect.provideService(HostProcessIsExecutable, false), ), @@ -60,14 +67,14 @@ describe("Node runtime selection", () => { }).pipe(Effect.provide(NodeServices.layer)), ); - it.effect("uses installed Node instead of the standalone T3 executable", () => + it.effect("uses installed Bun instead of the standalone T3 executable", () => Effect.gen(function* () { const path = yield* Path.Path; expect( - yield* resolveNodeExecutable("Local device support", { - PATH: path.dirname(process.execPath), + yield* resolveBunExecutable("Local device support", { + PATH: path.dirname(bunExecutable), }), - ).toBe(process.execPath); + ).toBe(bunExecutable); }).pipe( Effect.provideService(HostProcessExecutablePath, "/packaged/t3"), Effect.provideService(HostProcessIsExecutable, true), @@ -75,14 +82,14 @@ describe("Node runtime selection", () => { ), ); - it.effect("explains how to install Node when a standalone helper has no runtime", () => + it.effect("explains how to install Bun when a standalone helper has no Bun interpreter", () => Effect.gen(function* () { - const error = yield* resolveNodeExecutable("Local device support", { PATH: "" }).pipe( + const error = yield* resolveBunExecutable("Local device support", { PATH: "" }).pipe( Effect.flip, ); - expect(error._tag).toBe("NodeRuntimeUnavailableError"); - expect(error.message).toContain("Local device support requires Node.js"); - expect(error.message).toContain("Install Node.js"); + expect(error._tag).toBe("BunRuntimeUnavailableError"); + expect(error.message).toContain("Local device support requires Bun"); + expect(error.message).toContain("Install Bun"); }).pipe( Effect.provideService(HostProcessIsExecutable, true), Effect.provide(NodeServices.layer), @@ -95,90 +102,41 @@ describe("Node runtime selection", () => { const path = yield* Path.Path; const directory = yield* fs.makeTempDirectoryScoped(); const platform = yield* HostProcessPlatform; - const node = path.join(directory, platform === "win32" ? "node.exe" : "node"); + const node = path.join(directory, platform === "win32" ? "bun.exe" : "bun"); const env = { PATH: directory }; expect( Result.isFailure( - yield* resolveNodeExecutable("Local device support", env).pipe(Effect.result), + yield* resolveBunExecutable("Local device support", env).pipe(Effect.result), ), ).toBe(true); - yield* fs.copyFile(process.execPath, node); + yield* fs.copyFile(bunExecutable, node); yield* fs.chmod(node, 0o755); - expect(yield* resolveNodeExecutable("Local device support", env)).toBe(node); - }).pipe( - Effect.scoped, - Effect.provideService(HostProcessExecutablePath, "/packaged/t3"), - Effect.provideService(HostProcessIsExecutable, true), - Effect.provide(NodeServices.layer), - ), - ); - - it.effect("uses node.exe even when Windows batch wrappers appear first on PATH", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = yield* fs.makeTempDirectoryScoped(); - const wrappers = path.join(directory, "wrappers"); - const runtime = path.join(directory, "runtime"); - yield* fs.makeDirectory(wrappers); - yield* fs.makeDirectory(runtime); - yield* fs.writeFileString(path.join(wrappers, "node.cmd"), "@echo off"); - yield* fs.writeFileString(path.join(wrappers, "node.bat"), "@echo off"); - const node = path.join(runtime, "node.exe"); - yield* fs.copyFile(process.execPath, node); - expect( - yield* resolveNodeExecutable("Local device support", { - PATH: `${wrappers};${runtime}`, - PATHEXT: ".CMD;.BAT", - }), - ).toBe(node); + expect(yield* resolveBunExecutable("Local device support", env)).toBe(node); }).pipe( Effect.scoped, Effect.provideService(HostProcessExecutablePath, "/packaged/t3"), Effect.provideService(HostProcessIsExecutable, true), - Effect.provideService(HostProcessPlatform, "win32"), - Effect.provide(NodeServices.layer), - ), - ); - - it.effect("reports install guidance when Windows only has batch runtime wrappers", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = yield* fs.makeTempDirectoryScoped(); - yield* fs.writeFileString(path.join(directory, "node.cmd"), "@echo off"); - yield* fs.writeFileString(path.join(directory, "node.bat"), "@echo off"); - const error = yield* resolveNodeExecutable("Local device support", { - PATH: directory, - PATHEXT: ".CMD;.BAT;.EXE", - }).pipe(Effect.flip); - expect(error._tag).toBe("NodeRuntimeUnavailableError"); - expect(error.message).toContain("Install Node.js"); - }).pipe( - Effect.scoped, - Effect.provideService(HostProcessIsExecutable, true), - Effect.provideService(HostProcessPlatform, "win32"), Effect.provide(NodeServices.layer), ), ); - it.effect("rejects a hard-linked node alias pointing back at the standalone app", () => + it.effect("rejects a hard-linked Bun alias pointing back at the standalone app", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const directory = yield* fs.makeTempDirectoryScoped(); const platform = yield* HostProcessPlatform; const executable = path.join(directory, platform === "win32" ? "t3.exe" : "t3"); - const node = path.join(directory, platform === "win32" ? "node.exe" : "node"); + const node = path.join(directory, platform === "win32" ? "bun.exe" : "bun"); yield* fs.writeFileString(executable, "standalone executable fixture"); yield* fs.chmod(executable, 0o755); yield* fs.link(executable, node); - const error = yield* resolveNodeExecutable("Local device support", { PATH: directory }).pipe( + const error = yield* resolveBunExecutable("Local device support", { PATH: directory }).pipe( Effect.provideService(HostProcessExecutablePath, executable), Effect.flip, ); - expect(error._tag).toBe("NodeRuntimeUnavailableError"); - expect(error.message).toContain("Install Node.js"); + expect(error._tag).toBe("BunRuntimeUnavailableError"); + expect(error.message).toContain("Install Bun"); }).pipe( Effect.scoped, Effect.provideService(HostProcessIsExecutable, true), @@ -186,15 +144,15 @@ describe("Node runtime selection", () => { ), ); - it.effect.skipIf(!symlinksSupported)("preserves the node alias used by runtime launchers", () => + it.effect.skipIf(!symlinksSupported)("preserves the Bun alias used by runtime launchers", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const directory = yield* fs.makeTempDirectoryScoped(); const platform = yield* HostProcessPlatform; - const node = path.join(directory, platform === "win32" ? "node.exe" : "node"); - yield* fs.symlink(process.execPath, node); - expect(yield* resolveNodeExecutable("Local device support", { PATH: directory })).toBe(node); + const node = path.join(directory, platform === "win32" ? "bun.exe" : "bun"); + yield* fs.symlink(bunExecutable, node); + expect(yield* resolveBunExecutable("Local device support", { PATH: directory })).toBe(node); }).pipe( Effect.scoped, Effect.provideService(HostProcessExecutablePath, "/packaged/t3"), @@ -204,23 +162,71 @@ describe("Node runtime selection", () => { ); it.effect.skipIf(!symlinksSupported)( - "rejects a node alias pointing back at the standalone app", + "rejects a Bun alias pointing back at the standalone app", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const directory = yield* fs.makeTempDirectoryScoped(); const platform = yield* HostProcessPlatform; - const node = path.join(directory, platform === "win32" ? "node.exe" : "node"); - yield* fs.symlink(process.execPath, node); - const error = yield* resolveNodeExecutable("Local device support", { + const node = path.join(directory, platform === "win32" ? "bun.exe" : "bun"); + yield* fs.symlink(bunExecutable, node); + const error = yield* resolveBunExecutable("Local device support", { PATH: directory, }).pipe(Effect.flip); - expect(error.message).toContain("Install Node.js"); + expect(error.message).toContain("Install Bun"); }).pipe( Effect.scoped, Effect.provideService(HostProcessIsExecutable, true), + Effect.provideService(HostProcessExecutablePath, bunExecutable), Effect.provide(NodeServices.layer), ), ); }); + +it.effect("runs arbitrary helpers using the archive interpreter with no runtime on PATH", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const directory = yield* fs.makeTempDirectoryScoped(); + const runtimeDirectory = path.join(directory, "runtime"); + yield* fs.makeDirectory(runtimeDirectory); + yield* fs.symlink(bunExecutable, path.join(runtimeDirectory, "bun")); + const helper = path.join(directory, "helper.mjs"); + yield* fs.writeFileString( + helper, + "console.log(JSON.stringify({args:process.argv.slice(2),cwd:process.cwd(),value:process.env.HELPER_VALUE}))", + ); + const runtime = yield* resolveBunExecutable("Device automation", { PATH: "" }).pipe( + Effect.provideService(HostProcessExecutablePath, path.join(directory, "t3")), + Effect.provideService(HostProcessIsExecutable, true), + ); + const output = yield* spawner.string( + ChildProcess.make(runtime, [helper, "a path with spaces"], { + cwd: directory, + env: { PATH: "", HELPER_VALUE: "from owning environment" }, + extendEnv: false, + }), + ); + expect(JSON.parse(output)).toEqual({ + args: ["a path with spaces"], + cwd: yield* fs.realPath(directory), + value: "from owning environment", + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("rejects a Node interpreter configured for first-party helpers", () => + Effect.gen(function* () { + const error = yield* resolveBunExecutable("Device automation", { + PATH: "", + T3_BUN_EXECUTABLE: process.execPath, + }).pipe( + Effect.provideService(HostProcessExecutablePath, "/packaged/t3"), + Effect.provideService(HostProcessIsExecutable, true), + Effect.flip, + ); + expect(error._tag).toBe("BunRuntimeUnavailableError"); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/packages/shared/src/bunRuntime.ts b/packages/shared/src/bunRuntime.ts new file mode 100644 index 000000000000..9b743fc39c8d --- /dev/null +++ b/packages/shared/src/bunRuntime.ts @@ -0,0 +1,134 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; + +export const BUN_VERSION = "1.4.0"; + +export const isSupportedBunVersion = (version: string | undefined): boolean => { + if (version === undefined || !/^\d+\.\d+\.\d+(?:[-+].*)?$/.test(version)) return false; + const [major = 0, minor = 0] = version.split(".").map(Number); + return major > 1 || (major === 1 && minor >= 4); +}; + +import { + HostProcessArguments, + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessIsExecutable, +} from "./hostProcess.ts"; +import { CommandResolutionCache, resolveCommandPath } from "./shell.ts"; + +const BunRuntimeFeature = Schema.Literals([ + "Local device support", + "Device automation", + "Antigravity", + "Antigravity sign-in", +]); + +export const bunRuntimeUnavailableMessage = (feature: typeof BunRuntimeFeature.Type): string => + `${feature} requires Bun ${BUN_VERSION} or newer. Install Bun and make sure bun is on PATH, then retry.`; + +export class BunRuntimeUnavailableError extends Schema.TaggedError()( + "BunRuntimeUnavailableError", + { feature: BunRuntimeFeature, cause: Schema.optional(Schema.Defect()) }, +) { + override get message(): string { + return bunRuntimeUnavailableMessage(this.feature); + } +} + +export interface SelfInvocation { + /** The binary to spawn: Bun or the compiled T3 executable. */ + readonly command: string; + /** + * The absolute entrypoint script to place before the subcommand, or + * undefined for the compiled binary, which dispatches its embedded CLI. + */ + readonly entrypoint: string | undefined; +} + +/** + * How another process runs this T3 install's CLI, for hidden subcommands the + * server hands to children such as `acp-mcp-bridge`. `process.execPath` plus + * `argv[1]` only works for a source script; compiled Bun points to an embedded + * entrypoint in its virtual filesystem, so callers must not + * assemble the pair themselves. + */ +export const resolveSelfInvocation = Effect.fn("bunRuntime.resolveSelfInvocation")(function* () { + const command = yield* HostProcessExecutablePath; + if (yield* HostProcessIsExecutable) + return { command, entrypoint: undefined } satisfies SelfInvocation; + const path = yield* Path.Path; + const entry = (yield* HostProcessArguments)[1]; + // Children spawn from their own working directory, so the script path must be absolute. + return { + command, + entrypoint: entry === undefined ? undefined : path.resolve(entry), + } satisfies SelfInvocation; +}); + +/** `[entrypoint?, ...args]`: the argv that runs `args` against this T3 install. */ +export const selfInvocationArgs = ( + invocation: SelfInvocation, + args: ReadonlyArray, +): ReadonlyArray => + invocation.entrypoint === undefined ? args : [invocation.entrypoint, ...args]; + +/** A standalone T3 binary runs its embedded CLI, regardless of script arguments. */ +export const resolveBunExecutable = Effect.fn("bunRuntime.resolveBunExecutable")(function* ( + feature: typeof BunRuntimeFeature.Type, + environment?: NodeJS.ProcessEnv, +) { + const executablePath = yield* HostProcessExecutablePath; + if (!(yield* HostProcessIsExecutable)) return executablePath; + + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const env = environment ?? (yield* HostProcessEnvironment); + const bundledRuntime = path.join(path.dirname(executablePath), "runtime", "bun"); + const bunPath = env.T3_BUN_EXECUTABLE + ? path.resolve(env.T3_BUN_EXECUTABLE) + : (yield* fs.exists(bundledRuntime)) + ? bundledRuntime + : yield* resolveCommandPath("bun", { env }).pipe( + Effect.provideService(CommandResolutionCache, new Map()), + Effect.map((commandPath) => path.resolve(commandPath)), + Effect.mapError((cause) => new BunRuntimeUnavailableError({ feature, cause })), + ); + // A launcher or symlink named bun must not point back at the standalone app. + const resolvedPath = yield* fs + .realPath(bunPath) + .pipe(Effect.mapError((cause) => new BunRuntimeUnavailableError({ feature, cause }))); + if (resolvedPath === executablePath) return yield* new BunRuntimeUnavailableError({ feature }); + const [hostInfo, bunInfo] = yield* Effect.all([ + fs.stat(executablePath).pipe(Effect.option), + fs.stat(bunPath).pipe(Effect.option), + ]); + if ( + Option.isSome(hostInfo) && + Option.isSome(bunInfo) && + hostInfo.value.dev === bunInfo.value.dev && + Option.isSome(hostInfo.value.ino) && + Option.isSome(bunInfo.value.ino) && + Number.isSafeInteger(hostInfo.value.ino.value) && + hostInfo.value.ino.value > 0 && + hostInfo.value.ino.value === bunInfo.value.ino.value + ) { + return yield* new BunRuntimeUnavailableError({ feature }); + } + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const version = yield* spawner + .string( + ChildProcess.make(bunPath, ["-p", "process.versions.bun ?? ''"], { + env, + extendEnv: false, + }), + ) + .pipe(Effect.mapError((cause) => new BunRuntimeUnavailableError({ feature, cause }))); + if (!isSupportedBunVersion(version.trim())) + return yield* new BunRuntimeUnavailableError({ feature }); + return bunPath; +}); diff --git a/packages/shared/src/hostProcess.ts b/packages/shared/src/hostProcess.ts index 9bd41a825044..fa74d279ac6b 100644 --- a/packages/shared/src/hostProcess.ts +++ b/packages/shared/src/hostProcess.ts @@ -2,7 +2,9 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as NodeDns from "node:dns"; import * as NodeOS from "node:os"; -import * as NodeSea from "node:sea"; +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Native dependency resolution runs before an Effect runtime exists. +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; export const HostProcessPlatform = Context.Reference( "@t3tools/shared/hostProcess/HostProcessPlatform", @@ -54,7 +56,7 @@ export const HostProcessArguments = Context.Reference>( ); /** - * The command the shell was given, before Node resolved it to the binary: + * The command the shell was given, before the runtime resolved it to the binary: * `t3` for a PATH lookup, `./t3` or the launcher symlink for an explicit * path. `process.argv[0]` and `execPath` are always the resolved binary. */ @@ -66,15 +68,14 @@ export const HostProcessInvokedAs = Context.Reference( ); /** - * Whether this process is a Node single-executable rather than a script run - * by a Node on the machine. Code that needs a sibling file or a Node to run - * one branches on this: an executable hosts such things as hidden - * subcommands of itself. + * Bun compiled executables identify the embedded CLI through the virtual + * `$bunfs` entrypoint. Helpers need a separate interpreter; self-invocations + * dispatch hidden CLI subcommands directly. */ export const HostProcessIsExecutable = Context.Reference( "@t3tools/shared/hostProcess/HostProcessIsExecutable", { - defaultValue: () => NodeSea.isSea(), + defaultValue: () => process.argv[1]?.startsWith("/$bunfs/") ?? false, }, ); @@ -116,3 +117,9 @@ export const HostProcessUserId = Context.Reference( ); export const isHostWindows = Effect.map(HostProcessPlatform, (platform) => platform === "win32"); + +/** Resolve disk-backed dependencies beside a compiled CLI, outside Bun's virtual filesystem. */ +export const resolveHostModuleUrl = (moduleUrl: string): string => + process.argv[1]?.startsWith("/$bunfs/") + ? NodeURL.pathToFileURL(NodePath.join(NodePath.dirname(process.execPath), "package.json")).href + : moduleUrl; diff --git a/packages/shared/src/nodeRuntime.ts b/packages/shared/src/nodeRuntime.ts deleted file mode 100644 index baa4d9dc610c..000000000000 --- a/packages/shared/src/nodeRuntime.ts +++ /dev/null @@ -1,117 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; - -import { - HostProcessArguments, - HostProcessEnvironment, - HostProcessExecutablePath, - HostProcessIsExecutable, - HostProcessPlatform, -} from "./hostProcess.ts"; -import { CommandResolutionCache, resolveCommandPath } from "./shell.ts"; - -const NodeRuntimeFeature = Schema.Literals([ - "Local device support", - "Device automation", - "Antigravity", - "Antigravity sign-in", -]); - -export const nodeRuntimeUnavailableMessage = (feature: typeof NodeRuntimeFeature.Type): string => - `${feature} requires Node.js. Install Node.js and make sure node is on PATH, then retry.`; - -export class NodeRuntimeUnavailableError extends Schema.TaggedError()( - "NodeRuntimeUnavailableError", - { feature: NodeRuntimeFeature, cause: Schema.optional(Schema.Defect()) }, -) { - override get message(): string { - return nodeRuntimeUnavailableMessage(this.feature); - } -} - -export interface SelfInvocation { - /** The binary to spawn: Node, Electron (with `ELECTRON_RUN_AS_NODE`), or the packaged T3. */ - readonly command: string; - /** - * The absolute entrypoint script to place before the subcommand, or - * undefined for the packaged binary, which dispatches subcommands from - * argv[2] and treats a leading path as the subcommand itself. - */ - readonly entrypoint: string | undefined; -} - -/** - * How another process runs this T3 install's CLI, for hidden subcommands the - * server hands to children such as `acp-mcp-bridge`. `process.execPath` plus - * `argv[1]` only works for a script run by Node; the single-executable has no - * entrypoint script (Node repeats the binary at argv[1]), so callers must not - * assemble the pair themselves. - */ -export const resolveSelfInvocation = Effect.fn("nodeRuntime.resolveSelfInvocation")(function* () { - const command = yield* HostProcessExecutablePath; - if (yield* HostProcessIsExecutable) - return { command, entrypoint: undefined } satisfies SelfInvocation; - const path = yield* Path.Path; - const entry = (yield* HostProcessArguments)[1]; - // Children spawn from their own working directory, so the script path must be absolute. - return { - command, - entrypoint: entry === undefined ? undefined : path.resolve(entry), - } satisfies SelfInvocation; -}); - -/** `[entrypoint?, ...args]`: the argv that runs `args` against this T3 install. */ -export const selfInvocationArgs = ( - invocation: SelfInvocation, - args: ReadonlyArray, -): ReadonlyArray => - invocation.entrypoint === undefined ? args : [invocation.entrypoint, ...args]; - -/** A standalone T3 binary runs its embedded CLI, regardless of script arguments. */ -export const resolveNodeExecutable = Effect.fn("nodeRuntime.resolveNodeExecutable")(function* ( - feature: typeof NodeRuntimeFeature.Type, - environment?: NodeJS.ProcessEnv, -) { - const executablePath = yield* HostProcessExecutablePath; - if (!(yield* HostProcessIsExecutable)) return executablePath; - - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const platform = yield* HostProcessPlatform; - const env = environment ?? (yield* HostProcessEnvironment); - const nodePath = yield* resolveCommandPath(platform === "win32" ? "node.exe" : "node", { - // Batch wrappers require a shell; helper callers launch the runtime directly. - env: platform === "win32" ? { ...env, PATHEXT: ".EXE" } : env, - }).pipe( - // Refresh immediately after the user installs Node and retries setup. - Effect.provideService(CommandResolutionCache, new Map()), - Effect.map((commandPath) => path.resolve(commandPath)), - Effect.mapError((cause) => new NodeRuntimeUnavailableError({ feature, cause })), - ); - // A launcher or symlink named node must not point back at the standalone app. - const resolvedPath = yield* fs - .realPath(nodePath) - .pipe(Effect.mapError((cause) => new NodeRuntimeUnavailableError({ feature, cause }))); - if (resolvedPath === executablePath) return yield* new NodeRuntimeUnavailableError({ feature }); - const [hostInfo, nodeInfo] = yield* Effect.all([ - fs.stat(executablePath).pipe(Effect.option), - fs.stat(nodePath).pipe(Effect.option), - ]); - if ( - Option.isSome(hostInfo) && - Option.isSome(nodeInfo) && - hostInfo.value.dev === nodeInfo.value.dev && - Option.isSome(hostInfo.value.ino) && - Option.isSome(nodeInfo.value.ino) && - Number.isSafeInteger(hostInfo.value.ino.value) && - hostInfo.value.ino.value > 0 && - hostInfo.value.ino.value === nodeInfo.value.ino.value - ) { - return yield* new NodeRuntimeUnavailableError({ feature }); - } - // Launchers such as Vite+ dispatch by argv[0]; keep the node name intact. - return nodePath; -}); diff --git a/packages/shared/src/nodeSqliteClient.test.ts b/packages/shared/src/nodeSqliteClient.test.ts index 83fe851ac8ad..8b4e628c34f0 100644 --- a/packages/shared/src/nodeSqliteClient.test.ts +++ b/packages/shared/src/nodeSqliteClient.test.ts @@ -1,3 +1,9 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Exercises actual Bun subprocesses outside the test runtime. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; import * as NodeSqlite from "node:sqlite"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; @@ -182,3 +188,22 @@ it.effect( }).pipe(Effect.provide(SqliteClient.layer({ filename }))); }).pipe(Effect.provide(NodeServices.layer)), ); + +it("persists transactions and large integers under the actual Bun runtime", async () => { + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-bun-sqlite-")); + try { + const child = NodeChildProcess.spawnSync( + process.env.T3_BUN_EXECUTABLE ?? "bun", + [ + NodeURL.fileURLToPath(new URL("./testing/sqliteRuntime.fixture.ts", import.meta.url)), + NodePath.join(directory, "state.sqlite"), + ], + { encoding: "utf8" }, + ); + assert.equal(child.error, undefined); + assert.equal(child.status, 0, child.stderr); + assert.equal(child.stdout, "persisted, rolled back, and recovered from lock contention\n"); + } finally { + await NodeFSP.rm(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/shared/src/nodeSqliteClient.ts b/packages/shared/src/nodeSqliteClient.ts index 6b05d8ac6ca1..d6d37364cf4f 100644 --- a/packages/shared/src/nodeSqliteClient.ts +++ b/packages/shared/src/nodeSqliteClient.ts @@ -38,15 +38,12 @@ export interface SqliteClientConfig { readonly transformQueryNames?: ((str: string) => string) | undefined; } -export class UnsupportedNodeSqliteVersionError extends Schema.TaggedError()( - "UnsupportedNodeSqliteVersionError", - { - nodeVersion: Schema.String, - requirement: Schema.String, - }, +export class UnsupportedSqliteCapabilitiesError extends Schema.TaggedError()( + "UnsupportedSqliteCapabilitiesError", + { missingCapabilities: Schema.Array(Schema.String) }, ) { override get message(): string { - return `Node.js ${this.nodeVersion} is missing required node:sqlite APIs. Upgrade to ${this.requirement}.`; + return `The application runtime is missing required node:sqlite APIs: ${this.missingCapabilities.join(", ")}. Use the supported Bun runtime.`; } } @@ -59,30 +56,6 @@ export class UnsupportedNodeSqliteOperationError extends Schema.TaggedError { - const parts = process.versions.node.split(".").map(Number); - const major = parts[0] ?? 0; - const minor = parts[1] ?? 0; - const supported = (major === 22 && minor >= 16) || (major === 23 && minor >= 11) || major >= 24; - - if (!supported) { - return Effect.die( - new UnsupportedNodeSqliteVersionError({ - nodeVersion: process.versions.node, - requirement: "Node.js >=22.16, >=23.11, or >=24", - }), - ); - } - return Effect.void; -}; - /** * `node:sqlite` reports the SQLite result code as `errcode`, while * `classifySqliteError` reads `errno`. Copy it across so busy, locked and @@ -102,7 +75,11 @@ const classifyError = (cause: unknown, message: string, operation: string) => { const make = Effect.fn("makeWithDatabase")(function* ( options: SqliteClientConfig, ): Effect.fn.Return { - yield* checkNodeSqliteCompat(); + const missingCapabilities = ["columns", "setReturnArrays", "setReadBigInts"].filter( + (name) => typeof Reflect.get(NodeSqlite.StatementSync.prototype, name) !== "function", + ); + if (missingCapabilities.length > 0) + return yield* Effect.die(new UnsupportedSqliteCapabilitiesError({ missingCapabilities })); const compiler = Statement.makeCompilerSqlite(options.transformQueryNames); const transformRows = options.transformResultNames @@ -189,42 +166,45 @@ const make = Effect.fn("makeWithDatabase")(function* ( statement: NodeSqlite.StatementSync, params: ReadonlyArray, ) => - Effect.acquireUseRelease( - Effect.succeed(statement), - (statement) => - Effect.try({ - try: () => { - if (hasRows(statement)) { - statement.setReturnArrays(true); - // Safe to cast to array after we've setReturnArrays(true) - return statement.all(...(params as any)) as unknown as ReadonlyArray< - ReadonlyArray - >; - } - statement.run(...(params as any)); - return []; - }, - catch: (cause) => - new SqlError({ - reason: classifyError(cause, "Failed to execute statement", "execute"), - }), - }), - (statement) => - Effect.try({ - try: () => { - if (hasRows(statement)) { - statement.setReturnArrays(false); - } - }, - catch: (cause) => - new SqlError({ - reason: classifyError( - cause, - "Failed to reset statement result mode", - "resetResultMode", - ), - }), - }).pipe(Effect.orDie), + Effect.withFiber((fiber) => + Effect.acquireUseRelease( + Effect.succeed(statement), + (statement) => + Effect.try({ + try: () => { + statement.setReadBigInts(Boolean(Context.get(fiber.context, Client.SafeIntegers))); + if (hasRows(statement)) { + statement.setReturnArrays(true); + // Safe to cast to array after we've setReturnArrays(true) + return statement.all(...(params as any)) as unknown as ReadonlyArray< + ReadonlyArray + >; + } + statement.run(...(params as any)); + return []; + }, + catch: (cause) => + new SqlError({ + reason: classifyError(cause, "Failed to execute statement", "execute"), + }), + }), + (statement) => + Effect.try({ + try: () => { + if (hasRows(statement)) { + statement.setReturnArrays(false); + } + }, + catch: (cause) => + new SqlError({ + reason: classifyError( + cause, + "Failed to reset statement result mode", + "resetResultMode", + ), + }), + }).pipe(Effect.orDie), + ), ); const runValues = (sql: string, params: ReadonlyArray) => diff --git a/packages/shared/src/testing/sqliteRuntime.fixture.ts b/packages/shared/src/testing/sqliteRuntime.fixture.ts new file mode 100644 index 000000000000..a1876acd59a7 --- /dev/null +++ b/packages/shared/src/testing/sqliteRuntime.fixture.ts @@ -0,0 +1,59 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Exercises SQLite's actual runtime bindings. +import * as NodeAssert from "node:assert/strict"; +import * as NodeSqlite from "node:sqlite"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; +import * as SqliteClient from "../nodeSqliteClient.ts"; + +const filename = process.argv[2]!; +const value = 9007199254740993n; + +const write = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`PRAGMA journal_mode = WAL`; + yield* sql`PRAGMA busy_timeout = 0`; + yield* sql`CREATE TABLE entries(id INTEGER PRIMARY KEY, name TEXT UNIQUE, large INTEGER)`; + yield* sql`INSERT INTO entries VALUES (1, ${"kept"}, ${value})`; + const duplicate = yield* sql`INSERT INTO entries VALUES (2, ${"kept"}, 0)`.pipe(Effect.flip); + NodeAssert.equal(duplicate.reason._tag, "UniqueViolation"); + const rollback = yield* sql + .withTransaction( + sql`INSERT INTO entries VALUES (3, ${"rolled back"}, 0)`.pipe( + Effect.andThen(Effect.fail("abort")), + ), + ) + .pipe(Effect.flip); + NodeAssert.equal(rollback, "abort"); + NodeAssert.deepEqual(yield* sql`SELECT id, name FROM entries`.values, [[1, "kept"]]); + NodeAssert.deepEqual( + yield* sql`SELECT large FROM entries`.values.pipe( + Effect.provideService(SqlClient.SafeIntegers, true), + ), + [[value]], + ); + const rows = yield* sql<{ readonly large: bigint }>`SELECT large FROM entries`.pipe( + Effect.provideService(SqlClient.SafeIntegers, true), + ); + NodeAssert.equal(rows[0]?.large, value); + + const other = yield* Effect.acquireRelease( + Effect.sync(() => new NodeSqlite.DatabaseSync(filename)), + (db) => Effect.sync(() => db.close()), + ); + yield* Effect.sync(() => other.exec("BEGIN IMMEDIATE")); + const blocked = yield* sql + .withTransaction(sql`UPDATE entries SET name = 'blocked'`) + .pipe(Effect.flip); + NodeAssert.equal(blocked.reason._tag, "LockTimeoutError"); + yield* Effect.sync(() => other.exec("ROLLBACK")); + yield* sql.withTransaction(sql`UPDATE entries SET name = 'committed'`); +}).pipe(Effect.provide(SqliteClient.layer({ filename })), Effect.scoped); + +const read = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + NodeAssert.deepEqual(yield* sql`SELECT id, name FROM entries`.values, [[1, "committed"]]); +}).pipe(Effect.provide(SqliteClient.layer({ filename })), Effect.scoped); + +await Effect.runPromise(write); +await Effect.runPromise(read); +process.stdout.write("persisted, rolled back, and recovered from lock contention\n"); diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 2ef2929bf6d0..d9f1a48eda89 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -79,9 +79,13 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; const output = yield* spawner.string( - ChildProcess.make(process.execPath, ["scripts/dev-runner.ts", "dev", "--dry-run"], { - cwd: path.resolve(import.meta.dirname, ".."), - }), + ChildProcess.make( + process.env.T3_BUN_EXECUTABLE ?? "bun", + ["scripts/dev-runner.ts", "dev", "--dry-run"], + { + cwd: path.resolve(import.meta.dirname, ".."), + }, + ), ); assert.include(output, "[dev-runner] mode=dev"); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 96ec42b0f3a9..f694d3a23291 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import * as NodeOS from "node:os"; From 2d1fa902b3923965987b695f48b8ae953e593f87 Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Wed, 7 Oct 2026 15:06:40 +0200 Subject: [PATCH 02/32] feat(distribution): package Bun executables and fork releases --- .github/workflows/release-cli.yml | 135 +---------- .github/workflows/release.yml | 159 +++++++------ apps/server/resources/cli-entitlements.plist | 4 +- apps/server/scripts/cli.ts | 57 +++-- apps/server/src/provider/cursorSdk.ts | 5 +- apps/server/vite.config.ts | 65 +----- packages/shared/src/cliRelease.test.ts | 14 +- packages/shared/src/cliRelease.ts | 22 +- scripts/build-cli-archive.ts | 215 +++++------------- scripts/build-npm-platform-packages.test.ts | 39 ++-- scripts/build-npm-platform-packages.ts | 109 +++------ scripts/install.ps1 | 226 +------------------ scripts/install.sh | 14 +- scripts/install.test.ts | 65 ++++++ scripts/lib/cli-executable-imports.ts | 18 +- scripts/lib/cli-external-packages.test.ts | 4 +- scripts/lib/cli-external-packages.ts | 4 +- scripts/lib/cursor-sdk-packaging.test.ts | 36 +-- 18 files changed, 385 insertions(+), 806 deletions(-) diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index d20e763c3c46..862cd7132974 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -13,18 +13,6 @@ on: required: false APPLE_API_ISSUER: required: false - AZURE_TENANT_ID: - required: false - AZURE_CLIENT_ID: - required: false - AZURE_CLIENT_SECRET: - required: false - AZURE_TRUSTED_SIGNING_ENDPOINT: - required: false - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: - required: false - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: - required: false inputs: label: required: true @@ -73,7 +61,6 @@ on: type: string permissions: - # Windows builds read this run's artifact list to wait for the Linux CLI archive. actions: read contents: read @@ -81,8 +68,6 @@ jobs: build: name: Build ${{ inputs.label }} runs-on: ${{ inputs.runner }} - # Windows waits for the Linux CLI archive inside its own budget (see - # "Wait for Linux CLI archive"), so it gets that wait on top of the usual 30. timeout-minutes: 30 env: T3CODE_CLERK_PUBLISHABLE_KEY: ${{ inputs.clerk_publishable_key }} @@ -93,9 +78,6 @@ jobs: # This repository is public, so Git needs no credentials. checkout's # credential cleanup runs submodule foreach even with submodules disabled, # which fails on the orphaned gitlinks in our vendored .repos tree. - # checkout.workers=0 writes the files on every core. Alternating both ways - # in one job on the Windows runners, it wrote the files about 10s faster - # (about 40% on arm64). - name: Checkout shell: bash env: @@ -109,17 +91,16 @@ jobs: git sparse-checkout set --no-cone '/*' '!/.repos/' git -c checkout.workers=0 checkout --detach FETCH_HEAD - # Windows installs straight from the registry. There, linking the packages - # takes the time, not downloading them, so a ~700 MB package cache did not - # pay for its own restore. Across recent nightlies, restore plus install - # took 183-233s on arm64 and 116-172s on x64, while a plain install took - # 142-210s and 84-160s. The arm64 entries also filled 4 GB of the - # repository's 10 GB Actions cache. + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.0" + - name: Setup Vite+ uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 with: node-version-file: package.json - cache: ${{ inputs.platform != 'win' }} + cache: true run-install: false # pnpm checks the lockfile and policy before reusing this result. A missing @@ -140,7 +121,7 @@ jobs: id: resource_monitor_cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: - path: native/resource-monitor/target/${{ inputs.rust_target }}/release/t3-resource-monitor${{ inputs.platform == 'win' && '.exe' || '' }} + path: native/resource-monitor/target/${{ inputs.rust_target }}/release/t3-resource-monitor key: resource-monitor-${{ inputs.rust_target }}-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} - name: Setup Rust @@ -167,7 +148,7 @@ jobs: cat "$config_path" >> "$GITHUB_ENV" - name: Align package versions to release version - run: node scripts/update-release-package-versions.ts "${{ inputs.version }}" + run: bun scripts/update-release-package-versions.ts "${{ inputs.version }}" - name: Download JS bundle uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 @@ -175,94 +156,12 @@ jobs: name: js-bundle path: apps/server/dist - - name: Install Spectre-mitigated MSVC libs - if: inputs.platform == 'win' && steps.resource_monitor_cache.outputs.cache-hit != 'true' - shell: pwsh - run: | - $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" - $installPath = & $vswhere -products * -latest -property installationPath - $setupExe = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\setup.exe" - $proc = Start-Process -FilePath $setupExe ` - -ArgumentList "modify", "--installPath", "`"$installPath`"", "--add", ` - "Microsoft.VisualStudio.Component.VC.Runtimes.${{ inputs.arch == 'arm64' && 'ARM64' || 'x86.x64' }}.Spectre", "--quiet", "--norestart" ` - -Wait -PassThru -NoNewWindow - if ($null -eq $proc -or $proc.ExitCode -ne 0) { - $code = if ($null -ne $proc) { $proc.ExitCode } else { 1 } - Write-Error "Visual Studio Installer failed with exit code $code" - exit $code - } - - uses: ./.github/actions/setup-apt-mirrors if: inputs.platform == 'linux' - - name: Prepare Azure Trusted Signing - if: inputs.platform == 'win' - shell: pwsh - env: - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} - run: | - $ErrorActionPreference = "Stop" - - $requiredSecrets = @( - $env:AZURE_TENANT_ID, - $env:AZURE_CLIENT_ID, - $env:AZURE_CLIENT_SECRET, - $env:AZURE_TRUSTED_SIGNING_ENDPOINT, - $env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME, - $env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME - ) - if ($requiredSecrets | Where-Object { [string]::IsNullOrWhiteSpace($_) }) { - Write-Host "Azure Trusted Signing disabled; skipping TrustedSigning module preparation." - exit 0 - } - - try { - Install-PackageProvider ` - -Name NuGet ` - -MinimumVersion 2.8.5.201 ` - -Force ` - -Scope CurrentUser ` - -ErrorAction Stop - } catch { - Write-Warning "Could not bootstrap NuGet package provider. Continuing because the runner may already have a usable provider. $($_.Exception.Message)" - } - - Install-Module ` - -Name TrustedSigning ` - -MinimumVersion 0.5.0 ` - -Force ` - -AllowClobber ` - -Repository PSGallery ` - -Scope CurrentUser ` - -ErrorAction Stop - - Import-Module TrustedSigning -MinimumVersion 0.5.0 -Force - Get-Command Invoke-TrustedSigning -ErrorAction Stop - - $moduleRoots = @( - [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "PowerShell", "Modules"), - [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "WindowsPowerShell", "Modules"), - [System.IO.Path]::Combine($env:ProgramFiles, "PowerShell", "Modules"), - [System.IO.Path]::Combine($env:ProgramFiles, "WindowsPowerShell", "Modules") - ) - $modulePathEntries = @($moduleRoots + ($env:PSModulePath -split ";")) | - Where-Object { $_ -and (Test-Path $_) } | - Select-Object -Unique - "PSModulePath=$($modulePathEntries -join ';')" >> $env:GITHUB_ENV - - name: Build CLI single-executable shell: bash - env: - # The exact version, not a major: vp downloads it from nodejs.org/dist on - # the runner, and only exact versions have a dist directory. Keep in - # step with SEA_NODE_VERSION in apps/server/vite.config.ts. - VP_NODE_VERSION: "26.8.2" - run: node apps/server/scripts/cli.ts build-exe --verbose + run: bun apps/server/scripts/cli.ts build-exe --verbose - name: Import macOS signing certificate for the CLI archive if: inputs.platform == 'mac' @@ -305,9 +204,6 @@ jobs: run: | set -euo pipefail binary_name="t3-resource-monitor" - if [[ "${{ inputs.platform }}" == "win" ]]; then - binary_name="${binary_name}.exe" - fi target_dir="$RUNNER_TEMP/cli-resource-monitor/${{ inputs.resource_key }}" mkdir -p "$target_dir" cp "native/resource-monitor/target/${{ inputs.rust_target }}/release/${binary_name}" "$target_dir/$binary_name" @@ -318,12 +214,6 @@ jobs: APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} run: | set -euo pipefail if [[ "${{ inputs.platform }}" == "mac" && -n "${APPLE_API_KEY:-}" ]]; then @@ -331,7 +221,7 @@ jobs: printf '%s' "$APPLE_API_KEY" > "$key_path" export APPLE_API_KEY="$key_path" fi - node scripts/build-cli-archive.ts \ + bun scripts/build-cli-archive.ts \ --platform "${{ inputs.platform }}" \ --arch "${{ inputs.arch }}" \ --version "${{ inputs.version }}" \ @@ -340,7 +230,7 @@ jobs: - name: Smoke-test CLI archive shell: bash - run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ inputs.version }}" + run: bun scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ inputs.version }}" - name: Upload CLI archive uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -354,9 +244,6 @@ jobs: run: | set -euo pipefail binary_name="t3-resource-monitor" - if [[ "${{ inputs.platform }}" == "win" ]]; then - binary_name="${binary_name}.exe" - fi source_path="native/resource-monitor/target/${{ inputs.rust_target }}/release/${binary_name}" target_dir="resource-monitor-publish/${{ inputs.resource_key }}" mkdir -p "$target_dir" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 76402495dd17..b203a0ee42b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -126,6 +126,11 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.0" + - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: @@ -149,7 +154,7 @@ jobs: if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-preview}" == "nightly" ) ]]; then nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" - node scripts/resolve-nightly-release.ts \ + bun scripts/resolve-nightly-release.ts \ --date "$nightly_date" \ --run-number "$NIGHTLY_RUN_NUMBER" \ --sha "$NIGHTLY_SHA" \ @@ -162,7 +167,7 @@ jobs: elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-preview}" == "preview" ]]; then nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" - node scripts/resolve-nightly-release.ts \ + bun scripts/resolve-nightly-release.ts \ --channel preview \ --date "$nightly_date" \ --run-number "$NIGHTLY_RUN_NUMBER" \ @@ -207,7 +212,7 @@ jobs: - id: previous_tag name: Resolve previous release tag run: | - node scripts/resolve-previous-release-tag.ts \ + bun scripts/resolve-previous-release-tag.ts \ --channel "${{ steps.release_meta.outputs.release_channel }}" \ --current-tag "${{ steps.release_meta.outputs.tag }}" \ --github-output @@ -236,6 +241,11 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.0" + - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: @@ -267,6 +277,11 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.0" + - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: @@ -304,6 +319,11 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.0" + - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: @@ -312,7 +332,7 @@ jobs: run-install: true - name: Install Chromium for server browser integration tests - run: node apps/server/node_modules/playwright-core/cli.js install --with-deps --only-shell chromium + run: bun apps/server/node_modules/playwright-core/cli.js install --with-deps --only-shell chromium - name: Test run: vp run --filter t3 test --shard ${{ matrix.shard }}/${{ strategy.job-total }} @@ -352,6 +372,11 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.0" + - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: @@ -450,6 +475,11 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.0" + - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: @@ -486,7 +516,7 @@ jobs: cat "$config_path" >> "$GITHUB_ENV" - name: Align package versions to release version - run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" + run: bun scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - uses: ./.github/actions/setup-apt-mirrors @@ -565,49 +595,6 @@ jobs: rust_target: aarch64-unknown-linux-gnu resource_key: linux-arm64 - # The Windows jobs start with the other platforms. Their checkout, install, - # and toolchain setup take longer than the same-arch Linux job needs to - # upload its CLI archive, so the wait inside is usually instant. - cli_win_x64: - name: CLI Windows x64 - needs: [preflight, relay_public_config, build_bundle] - if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} - uses: ./.github/workflows/release-cli.yml - secrets: inherit - with: - version: ${{ needs.preflight.outputs.version }} - ref: ${{ needs.preflight.outputs.ref }} - clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} - clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} - clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} - relay_url: ${{ needs.relay_public_config.outputs.relay_url }} - label: Windows x64 - runner: blacksmith-32vcpu-windows-2025 - platform: win - arch: x64 - rust_target: x86_64-pc-windows-msvc - resource_key: win32-x64 - - cli_win_arm64: - name: CLI Windows arm64 - needs: [preflight, relay_public_config, build_bundle] - if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} - uses: ./.github/workflows/release-cli.yml - secrets: inherit - with: - version: ${{ needs.preflight.outputs.version }} - ref: ${{ needs.preflight.outputs.ref }} - clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} - clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} - clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} - relay_url: ${{ needs.relay_public_config.outputs.relay_url }} - label: Windows arm64 - runner: windows-11-arm - platform: win - arch: arm64 - rust_target: aarch64-pc-windows-msvc - resource_key: win32-arm64 - # npm gets the same bytes as the GitHub Release: the launcher plus one # package per CLI archive. Preview publishes too, under the `preview` # dist-tag, which nothing resolves unless asked for by name. @@ -623,10 +610,8 @@ jobs: cli_mac_arm64, cli_linux_x64, cli_linux_arm64, - cli_win_x64, - cli_win_arm64, ] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.test.result == 'success' && needs.test_server.result == 'success' && needs.cli_mac_arm64.result == 'success' && needs.cli_linux_x64.result == 'success' && needs.cli_linux_arm64.result == 'success' && needs.cli_win_x64.result == 'success' && needs.cli_win_arm64.result == 'success' }} + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.test.result == 'success' && needs.test_server.result == 'success' && needs.cli_mac_arm64.result == 'success' && needs.cli_linux_x64.result == 'success' && needs.cli_linux_arm64.result == 'success' }} runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 15 permissions: @@ -642,6 +627,11 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.0" + - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: @@ -660,34 +650,25 @@ jobs: path: release-cli - name: Build npm packages from CLI archives - run: node scripts/build-npm-platform-packages.ts --archives-dir release-cli --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages + run: bun scripts/build-npm-platform-packages.ts --archives-dir release-cli --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages # A dry run of every package first: an auth or scope error here (the # @t3code org missing, a package without a trusted publisher) fails # before anything is live, instead of after some platforms already are. - name: Check npm publish access (dry run) run: | - if ! node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --dry-run --verbose; then + if ! bun apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --dry-run --verbose; then echo "::error::npm publish --dry-run failed. Make sure the @t3code npm org exists and that t3 and every @t3code/t3- package has a trusted publisher registered for .github/workflows/release.yml (see docs/operations/release.md)." >&2 exit 1 fi - name: Publish CLI packages - run: node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --verbose + run: bun apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --verbose release: name: Publish GitHub Release - needs: - [ - preflight, - cli_mac_arm64, - cli_linux_x64, - cli_linux_arm64, - cli_win_x64, - cli_win_arm64, - publish_cli, - ] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.cli_mac_arm64.result == 'success' && needs.cli_linux_x64.result == 'success' && needs.cli_linux_arm64.result == 'success' && needs.cli_win_x64.result == 'success' && needs.cli_win_arm64.result == 'success' && needs.publish_cli.result == 'success' }} + needs: [preflight, cli_mac_arm64, cli_linux_x64, cli_linux_arm64, publish_cli] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.cli_mac_arm64.result == 'success' && needs.cli_linux_x64.result == 'success' && needs.cli_linux_arm64.result == 'success' && needs.publish_cli.result == 'success' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 30 permissions: @@ -702,6 +683,11 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.0" + - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: @@ -726,7 +712,7 @@ jobs: set -euo pipefail cd release-assets shopt -s nullglob - archives=(t3-*.tar.gz t3-*.zip) + archives=(t3-*.tar.gz) if [[ ${#archives[@]} -eq 0 ]]; then echo "No CLI archives were produced." >&2 exit 1 @@ -740,7 +726,6 @@ jobs: run: | { echo 'files< [!WARNING] > **This is a preview build. Do not install it unless you know exactly why you are here.** > - > Preview builds are cut by maintainers from unreleased branches to exercise the release pipeline. They can be broken, receive no fixes, are never offered as updates, and are not supported. If you want T3 Code, install the [latest release](https://github.com/pingdotgg/t3code/releases/latest) or a nightly instead. + > Preview builds are cut by maintainers from unreleased branches to exercise the release pipeline. They can be broken, receive no fixes, are never offered as updates, and are not supported. If you want T3 Code, install the [latest release](https://github.com/iglo-tech/iglo.code/releases/latest) or a nightly instead. Built from `${{ needs.preflight.outputs.ref }}`. EOF @@ -823,6 +808,11 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.0" + - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: @@ -848,7 +838,7 @@ jobs: cat "$config_path" >> "$GITHUB_ENV" - name: Align package versions to release version - run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" + run: bun scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - name: Refresh release lockfile run: vp install --lockfile-only --ignore-scripts @@ -919,6 +909,11 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.0" + - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: @@ -991,6 +986,11 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.0" + - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: @@ -1054,6 +1054,11 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.0" + - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: @@ -1114,6 +1119,11 @@ jobs: echo "name=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT" echo "email=${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT" + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.0" + - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: @@ -1128,7 +1138,7 @@ jobs: name: Update version strings env: RELEASE_VERSION: ${{ needs.preflight.outputs.version }} - run: node scripts/update-release-package-versions.ts "$RELEASE_VERSION" --github-output + run: bun scripts/update-release-package-versions.ts "$RELEASE_VERSION" --github-output - name: Format package.json files if: steps.update_versions.outputs.changed == 'true' @@ -1179,6 +1189,11 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.0" + - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: @@ -1213,7 +1228,7 @@ jobs: DISCORD_MENTION_ROLE_ID: ${{ secrets.DISCORD_RELEASE_NIGHTLY_ROLE_ID }} DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} run: | - node scripts/notify-discord-release.ts prerelease \ + bun scripts/notify-discord-release.ts prerelease \ --role-id "$DISCORD_MENTION_ROLE_ID" \ --release-name "${{ needs.preflight.outputs.release_name }}" \ --release-version "${{ needs.preflight.outputs.version }}" \ @@ -1228,7 +1243,7 @@ jobs: DISCORD_MENTION_ROLE_ID: ${{ secrets.DISCORD_RELEASE_LATEST_ROLE_ID }} DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} run: | - node scripts/notify-discord-release.ts latest \ + bun scripts/notify-discord-release.ts latest \ --role-id "$DISCORD_MENTION_ROLE_ID" \ --release-name "${{ needs.preflight.outputs.release_name }}" \ --release-version "${{ needs.preflight.outputs.version }}" \ diff --git a/apps/server/resources/cli-entitlements.plist b/apps/server/resources/cli-entitlements.plist index 3078fc969a73..258af4aec10d 100644 --- a/apps/server/resources/cli-entitlements.plist +++ b/apps/server/resources/cli-entitlements.plist @@ -2,8 +2,8 @@ - + com.apple.security.cs.allow-jit com.apple.security.cs.allow-unsigned-executable-memory diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 60bc08b0d4bb..e7d34ee515d7 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; @@ -11,6 +11,9 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { DEVELOPMENT_ICON_OVERRIDES } from "../../../scripts/lib/brand-assets.ts"; import { findEsmImportsOfExternalPackages } from "../../../scripts/lib/cli-executable-imports.ts"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { BUN_VERSION } from "@t3tools/shared/bunRuntime"; +import { CLI_ARCHIVE_PLATFORM_KEYS } from "@t3tools/shared/cliRelease"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { ServerCliBuildAssetMissingError, @@ -81,12 +84,13 @@ const buildCmd = Command.make( const serverDir = path.join(repoRoot, "apps/server"); yield* Effect.log("[cli] Running tsdown..."); + const bundleCommand = yield* resolveSpawnCommand("vp", ["pack"]); yield* runCommand( - ChildProcess.make(process.execPath, ["--run", "build:bundle"], { + ChildProcess.make(bundleCommand.command, bundleCommand.args, { cwd: serverDir, stdout: config.verbose ? "inherit" : "ignore", stderr: "inherit", - shell: false, + shell: bundleCommand.shell, }), ); @@ -113,7 +117,7 @@ const buildExeCmd = Command.make( verbose: Flag.Boolean("verbose").pipe(Flag.withDefault(false)), target: Flag.String("target").pipe( Flag.withDescription( - "Cross-build for - in nodejs.org naming (for example darwin-x64); defaults to the host.", + "Compile for darwin-arm64, linux-x64, or linux-arm64; defaults to the host.", ), Flag.optional, ), @@ -125,7 +129,20 @@ const buildExeCmd = Command.make( const repoRoot = yield* RepoRoot; const serverDir = path.join(repoRoot, "apps/server"); - yield* Effect.log("[cli] Building single-executable..."); + const hostPlatform = yield* HostProcessPlatform; + const hostArch = yield* HostProcessArchitecture; + const target = Option.getOrElse(config.target, () => `${hostPlatform}-${hostArch}`); + if (!CLI_ARCHIVE_PLATFORM_KEYS.some((supported) => supported === target)) { + return yield* Effect.fail( + new Error( + `Unsupported CLI target "${target}". Supported targets: ${CLI_ARCHIVE_PLATFORM_KEYS.join(", ")}.`, + ), + ); + } + if (process.versions.bun !== BUN_VERSION) { + return yield* Effect.fail(new Error(`Build the CLI with Bun ${BUN_VERSION}.`)); + } + yield* Effect.log("[cli] Building Bun executable..."); const spawnCommand = yield* resolveSpawnCommand("vp", ["pack"]); yield* runCommand( ChildProcess.make(spawnCommand.command, spawnCommand.args, { @@ -133,10 +150,6 @@ const buildExeCmd = Command.make( env: { ...process.env, T3CODE_PACK_EXE: "1", - ...Option.match(config.target, { - onNone: () => ({}), - onSome: (target) => ({ T3CODE_PACK_EXE_TARGET: target }), - }), }, stdout: config.verbose ? "inherit" : "ignore", stderr: "inherit", @@ -144,21 +157,37 @@ const buildExeCmd = Command.make( }), ); - // The executable can only `import` built-ins. A file-backed import - // passes the bundler and `node dist/bin.mjs`, then throws inside the - // binary, so read the emitted module graph rather than trusting config. + // Disk-backed packages must resolve beside the executable rather than + // becoming another embedded graph with missing native or SDK assets. const bundlePath = path.join(serverDir, "dist-exe/bin.mjs"); const specifiers = findEsmImportsOfExternalPackages(yield* fs.readFileString(bundlePath)); if (specifiers.length > 0) { return yield* new ServerCliExecutableImportError({ bundlePath, specifiers }); } + const executablePath = path.join(serverDir, "dist-exe", `t3-${target}`); + yield* runCommand( + ChildProcess.make( + process.execPath, + ["build", "--compile", `--target=bun-${target}`, "--outfile", executablePath, bundlePath], + { + cwd: serverDir, + stdout: config.verbose ? "inherit" : "ignore", + stderr: "inherit", + }, + ), + ); + if (target === "darwin-arm64" && hostPlatform === "darwin") { + yield* runCommand( + ChildProcess.make("codesign", ["--force", "--sign", "-", executablePath]), + ); + } yield* Effect.log( - "[cli] Built dist-exe/t3 (expects client/, resource-monitor/, and the runtime-external node_modules beside it; scripts/build-cli-archive.ts assembles that tree)", + `[cli] Built ${executablePath} (archive adds client/, runtime/bun, resource-monitor/, and runtime-external node_modules).`, ); }), ).pipe( Command.withDescription( - "Build the server as a Node single-executable (needs a Node 25.7+ host for --build-sea). The binary still resolves native packages from a node_modules tree beside it.", + "Build the server as a Bun executable. Native packages resolve from node_modules beside it.", ), ); diff --git a/apps/server/src/provider/cursorSdk.ts b/apps/server/src/provider/cursorSdk.ts index 07381bcf3a6f..6dfc4c5e6f27 100644 --- a/apps/server/src/provider/cursorSdk.ts +++ b/apps/server/src/provider/cursorSdk.ts @@ -1,5 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off globalConsole:off -- Installed before the SDK loads, outside an Effect runtime. stderr must match Node's default unhandled-rejection print. import * as NodeModule from "node:module"; +import { resolveHostModuleUrl } from "@t3tools/shared/hostProcess"; /** * The Cursor Agent SDK runs in this process and spawns a shell for tool @@ -65,8 +66,8 @@ function installCursorShellSpawnGuard(): void { installCursorShellSpawnGuard(); // Cursor's Webpack chunks and local helpers must stay beside the SDK entry. -// createRequire also loads that disk-backed package from a Node SEA executable. -const requireCursorSdk = NodeModule.createRequire(import.meta.url); +// Resolve the SDK beside the installed Bun executable, including its chunks. +const requireCursorSdk = NodeModule.createRequire(resolveHostModuleUrl(import.meta.url)); export const { Agent, AuthenticationError, diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index bcb0208d9c86..294ab75dd887 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -6,14 +6,8 @@ import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; import packageJson from "./package.json" with { type: "json" }; import { WeightedShardSequencer } from "./src/testUtils/weightedShardSequencer.ts"; -// The bundle used to inline only workspace packages, leaving every third-party -// runtime dep external. External deps must exist on the real filesystem (the WSL -// backend runs plain `wsl.exe -- node`, which cannot read inside an asar), so the -// desktop build unpacked `**\/node_modules\/**` wholesale: 13,875 loose files to -// support 20 native binaries. NSIS install time tracks file count, not bytes. -// -// Inverted here — bundle everything except the packages that genuinely cannot be -// inlined. See scripts/lib/cli-external-packages.ts for what earns an exemption. +// Inline the JavaScript graph while preserving native loaders, SDK chunks, +// and browser package assets on disk. The archive carries these externals. import { isExternalCliDependency, shouldBundleCliDependency, @@ -26,42 +20,9 @@ const cliBuildChannel = /^[^-+]+-(?:nightly|preview)\./.test(packageJson.version ? "nightly" : "latest"; -// `build:exe` wraps the same bundle in a Node single-executable. tsdown's exe -// step refuses multi-chunk output and counts the sourcemap as a chunk, and the -// executable needs a host Node that supports `--build-sea` (25.7+), so this is -// a separate mode rather than a second entry in the default build. +// Emit one module graph before Bun compiles it. Keeping Effect in this bundle +// preserves its shared HTTP/auth context across source and executable builds. const packExecutable = process.env.T3CODE_PACK_EXE === "1"; -// `-` in nodejs.org naming (darwin-x64, linux-arm64, win-x64). -// When set, tsdown injects the bundle into a downloaded Node of that target -// instead of the host Node, which is how the arm64 macOS runner produces the -// x64 archive. Cross-building is safe because the code cache is off. -// -// The Node inside the executable is pinned here rather than taken from the -// build host, so every archive of a release embeds the same runtime no matter -// which Node happens to run the build. -const SEA_NODE_VERSION = "26.8.2"; -const SEA_TARGETS = { - "darwin-arm64": { platform: "darwin", arch: "arm64" }, - "darwin-x64": { platform: "darwin", arch: "x64" }, - "linux-arm64": { platform: "linux", arch: "arm64" }, - "linux-x64": { platform: "linux", arch: "x64" }, - "win-arm64": { platform: "win", arch: "arm64" }, - "win-x64": { platform: "win", arch: "x64" }, -} as const; -const packExecutableTarget = process.env.T3CODE_PACK_EXE_TARGET?.trim(); -if (packExecutableTarget && !Object.hasOwn(SEA_TARGETS, packExecutableTarget)) { - throw new Error( - `T3CODE_PACK_EXE_TARGET must be one of ${Object.keys(SEA_TARGETS).join(", ")}, got "${packExecutableTarget}".`, - ); -} -const packExecutableTargets = packExecutableTarget - ? [ - { - ...SEA_TARGETS[packExecutableTarget as keyof typeof SEA_TARGETS], - nodeVersion: SEA_NODE_VERSION, - }, - ] - : undefined; export default mergeConfig( baseConfig, @@ -69,7 +30,7 @@ export default mergeConfig( run: { tasks: { build: { - command: "node scripts/cli.ts build", + command: "bun scripts/cli.ts build", dependsOn: ["@t3tools/web#build"], cache: false, }, @@ -82,20 +43,6 @@ export default mergeConfig( outDir: packExecutable ? "dist-exe" : "dist", sourcemap: !packExecutable, clean: true, - ...(packExecutable - ? { - exe: { - fileName: "t3", - outDir: "dist-exe", - ...(packExecutableTargets ? { targets: packExecutableTargets } : {}), - // Node's SEA docs: `import()` does not work when useCodeCache is - // true, and the server reaches several modules that way. The - // cache is also platform-bound, so leaving it off keeps the - // build correct on any host. - seaConfig: { useCodeCache: false }, - }, - } - : {}), deps: { // Both halves are required. `alwaysBundle` forces the JS dependencies in // (declared deps are external by default, which is what this change is @@ -108,7 +55,7 @@ export default mergeConfig( onlyBundle: false, }, banner: { - js: "#!/usr/bin/env node\n", + js: "#!/usr/bin/env bun\n", }, define: { __T3CODE_BUILD_CHANNEL__: JSON.stringify(cliBuildChannel), diff --git a/packages/shared/src/cliRelease.test.ts b/packages/shared/src/cliRelease.test.ts index c92421db5ec2..7c1b35136498 100644 --- a/packages/shared/src/cliRelease.test.ts +++ b/packages/shared/src/cliRelease.test.ts @@ -12,28 +12,28 @@ import { } from "./cliRelease.ts"; describe("cliRelease", () => { - it("names archives by version and platform, zip only on Windows", () => { + it("names supported archives by version and platform", () => { expect(cliArchiveFileName("1.2.3-preview.20260911.4", "linux-x64")).toBe( "t3-1.2.3-preview.20260911.4-linux-x64.tar.gz", ); - expect(cliArchiveFileName("1.2.3", "win32-x64")).toBe("t3-1.2.3-win32-x64.zip"); + expect(cliArchiveFileName("1.2.3", "darwin-arm64")).toBe("t3-1.2.3-darwin-arm64.tar.gz"); }); it("only maps platforms and architectures that have a release archive", () => { expect(cliArchivePlatformKey("darwin", "arm64")).toBe("darwin-arm64"); expect(cliArchivePlatformKey("linux", "x64")).toBe("linux-x64"); - expect(cliArchivePlatformKey("win32", "x64")).toBe("win32-x64"); - // Node single-executables are unsupported on x64 macOS. + expect(cliArchivePlatformKey("win32", "x64")).toBeUndefined(); + // This fork publishes only Apple Silicon macOS archives. expect(cliArchivePlatformKey("darwin", "x64")).toBeUndefined(); expect(cliArchivePlatformKey("linux", "arm64")).toBe("linux-arm64"); - expect(cliArchivePlatformKey("win32", "arm64")).toBe("win32-arm64"); + expect(cliArchivePlatformKey("win32", "arm64")).toBeUndefined(); expect(cliArchivePlatformKey("freebsd", "x64")).toBeUndefined(); expect(cliArchivePlatformKey("linux", "ia32")).toBeUndefined(); }); it("resolves download URLs under the tagged release, honoring a mirror", () => { expect(cliReleaseDownloadBaseUrl("1.2.3")).toBe( - "https://github.com/pingdotgg/t3code/releases/download/v1.2.3", + "https://github.com/iglo-tech/iglo.code/releases/download/v1.2.3", ); expect(cliReleaseDownloadBaseUrl("1.2.3", "https://mirror.example/t3/")).toBe( "https://mirror.example/t3/v1.2.3", @@ -87,7 +87,7 @@ describe("cliRelease", () => { it("pages through the release index at the largest page GitHub allows", () => { expect(cliReleaseIndexPageUrl(1)).toBe( - "https://api.github.com/repos/pingdotgg/t3code/releases?per_page=100&page=1", + "https://api.github.com/repos/iglo-tech/iglo.code/releases?per_page=100&page=1", ); expect(cliReleaseIndexPageUrl(3)).toContain("page=3"); }); diff --git a/packages/shared/src/cliRelease.ts b/packages/shared/src/cliRelease.ts index 28f0d530bb29..f39dbd61573a 100644 --- a/packages/shared/src/cliRelease.ts +++ b/packages/shared/src/cliRelease.ts @@ -5,27 +5,13 @@ * platform key, so a rename here is a release-breaking change. */ -const CLI_RELEASE_REPOSITORY = "pingdotgg/t3code"; +const CLI_RELEASE_REPOSITORY = "iglo-tech/iglo.code"; export const CLI_RELEASE_CHECKSUMS_FILE = "SHA256SUMS"; /** Overrides the download origin for mirrors and air-gapped installs. */ export const CLI_RELEASE_BASE_URL_ENV = "T3CODE_RELEASE_BASE_URL"; -/** - * The archives a release attaches. Kept in step with the build_linux_cli - * matrix, build_windows_arm64_cli, and the `cli_archive` rows in - * .github/workflows/release.yml: a key here without a build there produces - * download URLs that 404, and a build there without a key here is - * unreachable from every installer. - */ -// No darwin-x64: Node single-executables are unsupported on x64 macOS (the -// SEA docs list macOS as arm64 only) and the binary segfaults on start. -export const CLI_ARCHIVE_PLATFORM_KEYS = [ - "darwin-arm64", - "linux-arm64", - "linux-x64", - "win32-arm64", - "win32-x64", -] as const; +/** Release targets supported by the web-only fork. */ +export const CLI_ARCHIVE_PLATFORM_KEYS = ["darwin-arm64", "linux-arm64", "linux-x64"] as const; export type CliArchivePlatformKey = (typeof CLI_ARCHIVE_PLATFORM_KEYS)[number]; export function cliArchivePlatformKey( @@ -51,7 +37,7 @@ export function cliArchiveTarCommand( } export function cliArchiveFileName(version: string, platformKey: CliArchivePlatformKey): string { - return `t3-${version}-${platformKey}.${platformKey.startsWith("win32") ? "zip" : "tar.gz"}`; + return `t3-${version}-${platformKey}.tar.gz`; } const CLI_RELEASE_DEFAULT_BASE_URL = `https://github.com/${CLI_RELEASE_REPOSITORY}/releases/download`; diff --git a/scripts/build-cli-archive.ts b/scripts/build-cli-archive.ts index 0c76c3b9190c..7c31aa02f122 100644 --- a/scripts/build-cli-archive.ts +++ b/scripts/build-cli-archive.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun /** * Packages the server single-executable into a self-contained per-platform * archive: the `t3` binary, the web client, the resource monitor, and a @@ -9,10 +9,11 @@ * Layout inside the archive (a single top-level directory named after the * archive stem): * - * t3 | t3.exe the single-executable + * t3 the Bun-compiled CLI + * runtime/bun the pinned interpreter for JavaScript helpers * client/ web app served by the server * resource-monitor/ per-platform Rust helper, same paths as the npm package - * node_modules/ runtime externals (node-pty, msgpackr-extract, fff) + * node_modules/ runtime externals (native modules, provider SDK, browser assets) */ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -29,6 +30,7 @@ import { Command, Flag } from "effect/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { BUN_VERSION } from "@t3tools/shared/bunRuntime"; import { fromYaml } from "@t3tools/shared/schemaYaml"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import rootPackageJson from "../package.json" with { type: "json" }; @@ -43,7 +45,7 @@ import { import { selectCliRuntimeExternalDependencies } from "./lib/cli-external-packages.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; -const BuildPlatform = Schema.Literals(["mac", "linux", "win"]); +const BuildPlatform = Schema.Literals(["mac", "linux"]); const BuildArch = Schema.Literals(["arm64", "x64"]); type BuildPlatform = typeof BuildPlatform.Type; type BuildArch = typeof BuildArch.Type; @@ -93,8 +95,8 @@ export class CliArchiveInputMissingError extends Schema.TaggedError path.join(input.contentDir, entry)); - for (const target of [...libraries, input.executablePath]) { + for (const target of [ + ...libraries, + path.join(input.contentDir, "runtime/bun"), + input.executablePath, + ]) { yield* runCommand( ChildProcess.make("codesign", [ "--force", "--sign", identity, ...(identity === "-" ? [] : ["--options", "runtime", "--timestamp"]), - ...(target === input.executablePath ? ["--entitlements", entitlements] : []), + ...(target === input.executablePath || target.endsWith("/runtime/bun") + ? ["--entitlements", entitlements] + : []), target, ]), `codesign ${path.relative(input.contentDir, target)}`, @@ -345,10 +347,10 @@ const signMacArchiveContents = Effect.fn("signMacArchiveContents")(function* (in return; } // notarytool only accepts archives, and a bare executable cannot be stapled, - // so notarize a zip of the binary and rely on the online ticket lookup. - const notarizeZip = path.join(path.dirname(input.executablePath), ".notarize-t3.zip"); + // so notarize all signed archive contents and rely on the online ticket lookup. + const notarizeZip = path.join(path.dirname(input.contentDir), ".notarize-t3.zip"); yield* runCommand( - ChildProcess.make("ditto", ["-c", "-k", "--keepParent", input.executablePath, notarizeZip]), + ChildProcess.make("ditto", ["-c", "-k", "--keepParent", input.contentDir, notarizeZip]), "ditto (notarization zip)", ); yield* runCommand( @@ -369,93 +371,6 @@ const signMacArchiveContents = Effect.fn("signMacArchiveContents")(function* (in yield* Effect.log("[cli-archive] Notarized t3."); }); -const WindowsSigningConfig = Config.all({ - endpoint: Config.String("AZURE_TRUSTED_SIGNING_ENDPOINT").pipe(Config.option), - accountName: Config.String("AZURE_TRUSTED_SIGNING_ACCOUNT_NAME").pipe(Config.option), - certificateProfileName: Config.String("AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME").pipe( - Config.option, - ), -}); - -/** - * Node's --build-sea injects the blob into a copy of node.exe by rebuilding - * its resource section but, unlike its Mach-O path, leaves node's original - * Authenticode data-directory entry in the PE header. The file grows, so the - * entry now points into the middle of the new section at bytes that are not a - * certificate table. signtool refuses to sign such an image (0x800700C1, "not - * a valid Win32 application") and cannot `remove /s` it either, since the SIP - * fails to parse the garbage. Clearing the entry is exactly what a signature - * strip does, without needing a parser that trusts the broken table. - */ -const stripStaleAuthenticodeEntry = Effect.fn("stripStaleAuthenticodeEntry")(function* ( - executablePath: string, -) { - const fs = yield* FileSystem.FileSystem; - const bytes = yield* fs.readFile(executablePath); - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - const peOffset = view.getUint32(0x3c, true); - if (view.getUint32(peOffset, true) !== 0x00004550) { - return yield* new CliArchiveInputMissingError({ - inputPath: executablePath, - hint: "Expected a PE executable to strip the stale signature entry from.", - }); - } - const optionalHeader = peOffset + 24; - const magic = view.getUint16(optionalHeader, true); - // Data directories start at +112 (PE32+) or +96 (PE32); the certificate - // table is directory index 4, eight bytes (file offset, size). - const securityEntry = optionalHeader + (magic === 0x20b ? 112 : 96) + 4 * 8; - const offset = view.getUint32(securityEntry, true); - const size = view.getUint32(securityEntry + 4, true); - if (offset === 0 && size === 0) return; - if (offset + size === bytes.byteLength) { - // A certificate table that still ends at EOF is intact; leave it for - // signtool to replace rather than second-guessing it here. - return; - } - view.setUint32(securityEntry, 0, true); - view.setUint32(securityEntry + 4, 0, true); - yield* fs.writeFile(executablePath, bytes); - yield* Effect.log( - `[cli-archive] Cleared the stale Authenticode entry (offset ${String(offset)}, size ${String(size)}) left by --build-sea.`, - ); -}); - -/** Signs t3.exe through the same Azure Trusted Signing setup the installer uses. */ -const signWindowsExecutable = Effect.fn("signWindowsExecutable")(function* ( - executablePath: string, -) { - const signing = yield* WindowsSigningConfig; - const endpoint = Option.getOrUndefined(signing.endpoint); - const accountName = Option.getOrUndefined(signing.accountName); - const profile = Option.getOrUndefined(signing.certificateProfileName); - if (!endpoint || !accountName || !profile) { - yield* Effect.log("[cli-archive] Windows signing disabled (missing Azure Trusted Signing)."); - return; - } - yield* stripStaleAuthenticodeEntry(executablePath); - // Quote each signing argument and pass the file path in Windows form. - // `$ErrorActionPreference` - // makes a signing failure inside the cmdlet surface as a non-zero exit. - const quote = (value: string) => `'${value.replaceAll("'", "''")}'`; - const script = [ - "$ErrorActionPreference = 'Stop';", - "Invoke-TrustedSigning", - `-Endpoint ${quote(endpoint)}`, - `-CodeSigningAccountName ${quote(accountName)}`, - `-CertificateProfileName ${quote(profile)}`, - "-FileDigest 'SHA256'", - "-TimestampRfc3161 'http://timestamp.acs.microsoft.com'", - "-TimestampDigest 'SHA256'", - `-Files ${quote(executablePath)}`, - ].join(" "); - yield* runCommand( - ChildProcess.make("pwsh", ["-NoProfile", "-NonInteractive", "-Command", script]), - "Invoke-TrustedSigning t3.exe", - ); - yield* Effect.log("[cli-archive] Signed t3.exe (Azure Trusted Signing)."); -}); - const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: { readonly platform: BuildPlatform; readonly arch: BuildArch; @@ -467,24 +382,22 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: { const path = yield* Path.Path; const repoRoot = yield* RepoRoot; const serverDir = path.join(repoRoot, "apps/server"); - const executableName = input.platform === "win" ? "t3.exe" : "t3"; - // tsdown suffixes cross-built executables with their target (t3-darwin-x64); - // a host build is plain t3. Prefer the exact target when both exist. - const targetKey = `${input.platform === "mac" ? "darwin" : input.platform}-${input.arch}`; - const targetExecutable = path.join( - serverDir, - "dist-exe", - `t3-${targetKey}${input.platform === "win" ? ".exe" : ""}`, - ); - // The unsuffixed host build is only a valid stand-in when it was built for - // this platform and architecture; otherwise a missing target must fail. - const hostPlatform = yield* HostProcessPlatform; - const hostKey = `${hostPlatform === "win32" ? "win" : hostPlatform}-${yield* HostProcessArchitecture}`; - const builtExecutable = (yield* fs.exists(targetExecutable)) - ? targetExecutable - : targetKey === hostKey - ? path.join(serverDir, "dist-exe", executableName) - : targetExecutable; + const executableName = "t3"; + const targetKey = cliArchivePlatformKey(input.platform, input.arch); + const hostKey = `${yield* HostProcessPlatform}-${yield* HostProcessArchitecture}`; + if (targetKey !== hostKey || targetKey === "darwin-x64") { + return yield* new CliArchiveInputMissingError({ + inputPath: targetKey, + hint: "Build native archives on the corresponding supported host: darwin-arm64, linux-x64, or linux-arm64.", + }); + } + if (process.versions.bun !== BUN_VERSION) { + return yield* new CliArchiveInputMissingError({ + inputPath: process.execPath, + hint: `Run archive packaging with Bun ${BUN_VERSION}; its interpreter is shipped for JavaScript helpers.`, + }); + } + const builtExecutable = path.join(serverDir, "dist-exe", `t3-${targetKey}`); const webClient = path.join(serverDir, "dist/client"); const resourceMonitorDir = Option.getOrElse(input.resourceMonitorDir, () => path.join(serverDir, "dist/resource-monitor"), @@ -492,7 +405,7 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: { yield* requireInput( builtExecutable, - `Run \`node apps/server/scripts/cli.ts build-exe --target ${targetKey}\` first.`, + `Run \`bun apps/server/scripts/cli.ts build-exe --target ${targetKey}\` first.`, ); yield* requireInput(path.join(webClient, "index.html"), "Run `vp run --filter t3 build` first."); yield* requireInput( @@ -507,6 +420,10 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: { yield* Effect.log(`[cli-archive] Staging ${stem}...`); yield* fs.copyFile(builtExecutable, path.join(contentDir, executableName)); + const runtimeDir = path.join(contentDir, "runtime"); + yield* fs.makeDirectory(runtimeDir); + yield* fs.copyFile(process.execPath, path.join(runtimeDir, "bun")); + yield* fs.chmod(path.join(runtimeDir, "bun"), 0o755); yield* stageWebClient(webClient, path.join(contentDir, "client")); yield* fs.copy(resourceMonitorDir, path.join(contentDir, "resource-monitor")); yield* stageRuntimeExternals({ @@ -520,12 +437,8 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: { const executablePath = path.join(contentDir, executableName); if (input.platform === "mac") { yield* signMacArchiveContents({ repoRoot, contentDir, executablePath }); - } else if (input.platform === "win") { - yield* signWindowsExecutable(executablePath); - } - if (input.platform !== "win") { - yield* fs.chmod(executablePath, 0o755); } + yield* fs.chmod(executablePath, 0o755); yield* fs.makeDirectory(input.outputDir, { recursive: true }); const archivePath = path.join( @@ -533,32 +446,22 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: { cliArchiveFileName(input.version, input.platform, input.arch), ); yield* fs.remove(archivePath, { force: true }); - if (input.platform === "win") { - // Windows ships bsdtar, which writes zip natively. Name it by path: under - // the Git Bash shell CI uses, a bare `tar` is GNU tar, which neither - // writes zip nor accepts a drive-letter path. - yield* runCommand( - ChildProcess.make(windowsSystemTar(), ["-a", "-c", "-f", archivePath, "-C", stageRoot, stem]), - "tar (zip)", - ); - } else { - // On Linux, pnpm hard-links identical files out of its store and node-gyp - // hard-links build outputs, and GNU tar records those as link entries. - // The npm registry rejects a tarball containing any, and the npm platform - // packages are re-packed from this archive's contents, so store every - // file as a file. macOS's bsdtar has no such flag; pnpm clones there. - yield* runCommand( - ChildProcess.make("tar", [ - ...(input.platform === "linux" ? ["--hard-dereference"] : []), - "-czf", - archivePath, - "-C", - stageRoot, - stem, - ]), - "tar (gzip)", - ); - } + // On Linux, pnpm hard-links identical files out of its store and node-gyp + // hard-links build outputs, and GNU tar records those as link entries. + // The npm registry rejects a tarball containing any, and the npm platform + // packages are re-packed from this archive's contents, so store every + // file as a file. macOS's bsdtar has no such flag; pnpm clones there. + yield* runCommand( + ChildProcess.make("tar", [ + ...(input.platform === "linux" ? ["--hard-dereference"] : []), + "-czf", + archivePath, + "-C", + stageRoot, + stem, + ]), + "tar (gzip)", + ); const stat = yield* fs.stat(archivePath); yield* Effect.log(`[cli-archive] Wrote ${archivePath} (${String(stat.size)} bytes).`); return archivePath; diff --git a/scripts/build-npm-platform-packages.test.ts b/scripts/build-npm-platform-packages.test.ts index cd813ae742b7..ba45b67f2dc2 100644 --- a/scripts/build-npm-platform-packages.test.ts +++ b/scripts/build-npm-platform-packages.test.ts @@ -96,11 +96,7 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { allowMissing: false, }).pipe(Effect.flip); assert.instanceOf(error, NpmPackagesArchivesMissingError); - assert.deepStrictEqual((error as NpmPackagesArchivesMissingError).missing, [ - "linux-arm64", - "win32-arm64", - "win32-x64", - ]); + assert.deepStrictEqual((error as NpmPackagesArchivesMissingError).missing, ["linux-arm64"]); }), ); @@ -133,7 +129,7 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { assert.deepStrictEqual(linuxManifest.cpu, ["x64"]); assert.deepStrictEqual(linuxManifest.files, [ "t3", - "t3.exe", + "runtime", "client", "resource-monitor", "node_modules", @@ -201,13 +197,22 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { listing.stdout, ); - // NODE_PATH stands in for node_modules: require.resolve finds the - // platform package there exactly as it would after `npm install`. + // The command must run after npm installation without Node or Bun on PATH. + const toolBin = path.join(fixture.root, "system-tools"); + yield* fs.makeDirectory(toolBin); + for (const tool of ["dirname", "uname", "readlink"]) { + yield* fs.symlink(`/usr/bin/${tool}`, path.join(toolBin, tool)); + } const hostPlatform = yield* HostProcessPlatform; const hostArch = yield* HostProcessArchitecture; - const env = { ...process.env, NODE_PATH: fixture.outputDir } as Record; + const env = { ...process.env, PATH: toolBin, NODE_PATH: fixture.outputDir } as Record< + string, + string + >; if (KEYS.some((key) => key === `${hostPlatform}-${hostArch}`)) { - const passthrough = yield* run(process.execPath, ["bin/t3.js", "serve", "--port", "1234"], { + const globalBin = path.join(fixture.root, "global-t3"); + yield* fs.symlink(path.join(launcherDir, "bin/t3.js"), globalBin); + const passthrough = yield* run(globalBin, ["serve", "--port", "1234"], { cwd: launcherDir, env, }); @@ -248,14 +253,18 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { assert.equal(legacy.exitCode, 7); } - const unsupported = yield* run(process.execPath, ["bin/t3.js", "--version"], { - cwd: launcherDir, - env: { ...env, NODE_PATH: path.join(fixture.root, "nowhere") }, + const missingLauncher = path.join(fixture.root, "missing/bin/t3.js"); + yield* fs.makeDirectory(path.dirname(missingLauncher), { recursive: true }); + yield* fs.copyFile(path.join(launcherDir, "bin/t3.js"), missingLauncher); + yield* fs.chmod(missingLauncher, 0o755); + const unsupported = yield* run(missingLauncher, ["--version"], { + cwd: fixture.root, + env, }); assert.equal(unsupported.exitCode, 1); assert.include(unsupported.stderr, "linux-x64"); - assert.include(unsupported.stderr, "win32-arm64"); - assert.include(unsupported.stderr, "https://github.com/pingdotgg/t3code/releases"); + assert.notInclude(unsupported.stderr, "win32-arm64"); + assert.include(unsupported.stderr, "https://github.com/iglo-tech/iglo.code/releases"); }), ); }); diff --git a/scripts/build-npm-platform-packages.ts b/scripts/build-npm-platform-packages.ts index 26ac80b1a3d9..5af26b483f7d 100644 --- a/scripts/build-npm-platform-packages.ts +++ b/scripts/build-npm-platform-packages.ts @@ -36,13 +36,9 @@ import { cliArchiveFileName, type CliArchivePlatformKey, } from "@t3tools/shared/cliRelease"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; -import { isCommandAvailable } from "@t3tools/shared/shell"; import serverPackageJson from "../apps/server/package.json" with { type: "json" }; -import { windowsSystemTar } from "./build-cli-archive.ts"; - export const NPM_PLATFORM_PACKAGE_SCOPE = "@t3code"; export const NPM_LAUNCHER_PACKAGE_NAME = "t3"; @@ -90,7 +86,7 @@ export function npmPlatformPackageName(platformKey: CliArchivePlatformKey): stri /** * package.json for one platform package; `os`/`cpu` let npm skip the other - * five. The archive's runtime `node_modules` (native addons and their + * two. The archive's runtime `node_modules` (native addons and their * loaders) ships inside the tarball, and npm only keeps a nested tree it can * account for: anything not declared is extraneous and pruned on the next * `npm install` in that project, which then breaks the executable. Declaring @@ -112,7 +108,7 @@ export function npmPlatformPackageManifest( repository: serverPackageJson.repository, os: [os], cpu: [cpu], - files: ["t3", "t3.exe", "client", "resource-monitor", "node_modules"], + files: ["t3", "runtime", "client", "resource-monitor", "node_modules"], preferUnplugged: true, dependencies: Object.fromEntries(bundleDependencies.map((name) => [name, bundled[name]])), bundleDependencies, @@ -163,12 +159,12 @@ export function npmPlatformPackageReadme(platformKey: CliArchivePlatformKey): st `npx ${NPM_LAUNCHER_PACKAGE_NAME}@latest`, "```", "", - "Source and documentation: https://github.com/pingdotgg/t3code", + "Source and documentation: https://github.com/iglo-tech/iglo.code", "", ].join("\n"); } -/** package.json for the `t3` launcher. No engines: bin/t3.js is trivial CJS. */ +/** package.json for the shell launcher, retaining the established bin path. */ export function npmLauncherPackageManifest( version: string, platformKeys: ReadonlyArray, @@ -187,44 +183,34 @@ export function npmLauncherPackageManifest( }; } -/** - * The launcher every `npx t3` runs. Plain CommonJS with no dependencies so it - * loads on any Node that npm itself runs on; the real work happens in the - * single-executable it execs. - */ -export const NPM_LAUNCHER_SCRIPT = `#!/usr/bin/env node -"use strict"; -const { spawnSync } = require("node:child_process"); -const { constants } = require("node:os"); -const { dirname, join } = require("node:path"); - -const SUPPORTED = [${CLI_ARCHIVE_PLATFORM_KEYS.map((key) => `"${key}"`).join(", ")}]; -const key = process.platform + "-" + process.arch; - -let packageDir; -try { - packageDir = dirname(require.resolve("${NPM_PLATFORM_PACKAGE_SCOPE}/t3-" + key + "/package.json")); -} catch { - process.stderr.write( - [ - "t3: no T3 Code CLI build is available for this platform (" + key + ").", - "Supported platforms: " + SUPPORTED.join(", ") + ".", - "If yours is listed, reinstall t3 so npm fetches its optional dependency.", - "The desktop app and release archives are at https://github.com/pingdotgg/t3code/releases", - "", - ].join("\\n"), - ); - process.exit(1); -} - -const executable = join(packageDir, process.platform === "win32" ? "t3.exe" : "t3"); -const result = spawnSync(executable, process.argv.slice(2), { stdio: "inherit" }); -if (result.error) { - process.stderr.write("t3: failed to start " + executable + ": " + result.error.message + "\\n"); - process.exit(1); -} -// A child killed by a signal has no status; report it the way a shell would. -process.exit(result.status ?? 128 + (constants.signals[result.signal] || 1)); +/** The npm command execs the archive binary without an application interpreter. */ +export const NPM_LAUNCHER_SCRIPT = `#!/bin/sh +set -eu +entry="$0" +while [ -L "$entry" ]; do + directory="$(CDPATH= cd -- "$(dirname -- "$entry")" && pwd)" + target="$(readlink "$entry")" + case "$target" in + /*) entry="$target" ;; + *) entry="$directory/$target" ;; + esac +done +package_dir="$(CDPATH= cd -- "$(dirname -- "$entry")/.." && pwd)" +case "$(uname -s)-$(uname -m)" in + Darwin-arm64) key=darwin-arm64 ;; + Linux-x86_64 | Linux-amd64) key=linux-x64 ;; + Linux-aarch64 | Linux-arm64) key=linux-arm64 ;; + *) key="$(uname -s)-$(uname -m)" ;; +esac +for executable in "$package_dir/node_modules/${NPM_PLATFORM_PACKAGE_SCOPE}/t3-$key/t3" "$package_dir/../${NPM_PLATFORM_PACKAGE_SCOPE}/t3-$key/t3"; do + if [ -x "$executable" ]; then exec "$executable" "$@"; fi +done +printf '%s\n' \ + "t3: no iglo.code CLI build is available for this platform ($key)." \ + "Supported platforms: ${CLI_ARCHIVE_PLATFORM_KEYS.join(", ")}." \ + "If yours is listed, reinstall the fork package so npm fetches its optional dependency." \ + "Release archives are at https://github.com/iglo-tech/iglo.code/releases" >&2 +exit 1 `; const runCommand = Effect.fn("runCommand")(function* ( @@ -245,31 +231,11 @@ const runCommand = Effect.fn("runCommand")(function* ( } }); -/** - * Extracts an archive and returns its single top-level directory. `.tar.gz` - * goes through tar everywhere; `.zip` through the bsdtar Windows ships or, - * elsewhere, `unzip`, since GNU tar cannot read zip. - */ +/** Extracts a release tarball and returns its single top-level directory. */ const extractArchive = Effect.fn("extractArchive")(function* (archive: string, into: string) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const platform = yield* HostProcessPlatform; - if (!archive.endsWith(".zip")) { - yield* runCommand(ChildProcess.make("tar", ["-xf", archive, "-C", into]), "tar -xf"); - } else if (platform === "win32") { - yield* runCommand( - ChildProcess.make(windowsSystemTar(), ["-xf", archive, "-C", into]), - "tar.exe -xf (zip)", - ); - } else { - if (!(yield* isCommandAvailable("unzip"))) { - return yield* new NpmPackagesToolMissingError({ - tool: "unzip", - purpose: `extract ${path.basename(archive)} (GNU tar cannot read zip)`, - }); - } - yield* runCommand(ChildProcess.make("unzip", ["-q", archive, "-d", into]), "unzip"); - } + yield* runCommand(ChildProcess.make("tar", ["-xf", archive, "-C", into]), "tar -xf"); const entries = yield* fs.readDirectory(into); const [root] = entries; if (root === undefined || entries.length !== 1) { @@ -281,11 +247,6 @@ const extractArchive = Effect.fn("extractArchive")(function* (archive: string, i return path.join(into, root); }); -/** Tar to build npm tarballs with; see build-cli-archive.ts for why Windows names bsdtar by path. */ -const hostTar = Effect.map(HostProcessPlatform, (platform) => - platform === "win32" ? windowsSystemTar() : "tar", -); - /** * Writes `stageDir/package` as a gzipped npm tarball and then moves the tree * to `packageDir` so the contents stay inspectable beside the tarball. @@ -298,7 +259,7 @@ const packAndPlace = Effect.fn("packAndPlace")(function* (input: { const fs = yield* FileSystem.FileSystem; yield* fs.remove(input.tarball, { force: true }); yield* runCommand( - ChildProcess.make(yield* hostTar, ["-czf", input.tarball, "-C", input.stageDir, "package"]), + ChildProcess.make("tar", ["-czf", input.tarball, "-C", input.stageDir, "package"]), `tar (${input.tarball})`, ); yield* fs.remove(input.packageDir, { recursive: true, force: true }); diff --git a/scripts/install.ps1 b/scripts/install.ps1 index d0d7140d2f4f..d31cbfdd9972 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,224 +1,4 @@ -# Installs the T3 Code CLI from a GitHub Release archive on Windows. Needs -# only PowerShell 5.1+; no Node, npm, or compiler. -# -# irm https://t3.codes/install.ps1 | iex -# -# Environment: -# T3CODE_CHANNEL release train to follow: stable, nightly, or preview -# (default: stable; preview is a maintainers' test train) -# T3CODE_VERSION exact version to install (overrides T3CODE_CHANNEL) -# T3CODE_HOME T3 home directory (default: ~\.t3) -# T3CODE_INSTALL_BIN_DIR where t3.exe is linked (default: ~\.local\bin) -# T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub) -# -# The archive is unpacked into $T3CODE_HOME\runtime\versions\, the -# same layout `t3 service install` uses, so the service reuses this download. +# iglo.code supports macOS arm64, Linux x64, and Linux arm64. $ErrorActionPreference = "Stop" -[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - -$repo = "pingdotgg/t3code" -$baseUrl = if ($env:T3CODE_RELEASE_BASE_URL) { $env:T3CODE_RELEASE_BASE_URL.TrimEnd("/") } else { "https://github.com/$repo/releases/download" } -$t3Home = if ($env:T3CODE_HOME) { $env:T3CODE_HOME } else { Join-Path $HOME ".t3" } -$binDir = if ($env:T3CODE_INSTALL_BIN_DIR) { $env:T3CODE_INSTALL_BIN_DIR } else { Join-Path $HOME ".local\bin" } - -function Fail([string] $message) { - Write-Error "t3 install: $message" - exit 1 -} - -$terminal = -not [Console]::IsErrorRedirected -and $env:TERM -ne "dumb" -$interactive = $terminal -and $Host.UI.SupportsVirtualTerminal -if ($interactive) { - # Legacy code pages may support ANSI escapes but not the logo or bar glyphs. - $glyphs = -join [char[]](0x2588, 0x2580, 0x2584, 0x25A0, 0x00B7) - $encoding = [Console]::Error.Encoding - $interactive = $encoding.GetString($encoding.GetBytes($glyphs)) -eq $glyphs -} -$esc = [char]27 -$reset = $bold = $muted = $accent = $green = "" -if ($interactive -and -not $env:NO_COLOR) { - $reset = "$esc[0m"; $bold = "$esc[1m"; $muted = "$esc[2m" - $accent = "$esc[94m"; $green = "$esc[32m" -} -function Step([string] $message) { - if ($interactive) { [Console]::Error.Write("`r$esc[2K $muted$message$reset") } - else { [Console]::Error.WriteLine(" $message") } -} -function Draw-Download([long] $bytes, [long] $total) { - if (-not $interactive) { return } - $columns = [Console]::WindowWidth - if ($columns -le 0) { $columns = 80 } - if ($total -gt 0 -and $columns -ge 9) { - $percent = [Math]::Min(100, [Math]::Floor($bytes * 100.0 / $total)) - $width = [Math]::Min(32, $columns - 8) - $filled = [int][Math]::Floor($percent * $width / 100) - $bar = ([string][char]0x25A0) * $filled - $rest = ([string][char]0x00B7) * ($width - $filled) - $sizes = (" {0:F1} / {1:F1} MB" -f ($bytes / 1MB), ($total / 1MB)) - if ($width + 7 + $sizes.Length -ge $columns) { $sizes = "" } - [Console]::Error.Write(("`r$esc[2K $accent$bar$reset$muted$rest$reset {0,3}%" -f $percent) + "$muted$sizes$reset") - } else { - $line = if ($columns -ge 32) { " Downloading {0:F1} MB" -f ($bytes / 1MB) } else { " {0:F1} MB" -f ($bytes / 1MB) } - [Console]::Error.Write("`r$esc[2K" + $line.Substring(0, [Math]::Min($line.Length, $columns - 1))) - } -} -function Fetch([string] $uri, [string] $destination, [switch] $progress) { - if (-not $progress -or -not $interactive) { - $ProgressPreference = if ($progress -and $terminal) { "Continue" } else { "SilentlyContinue" } - Invoke-WebRequest -Uri $uri -OutFile $destination -UseBasicParsing - return - } - # Read the response once, displaying actual bytes received at most ten times a second. - Add-Type -AssemblyName System.Net.Http - $client = New-Object System.Net.Http.HttpClient - $response = $source = $file = $null - try { - $response = $client.GetAsync($uri, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult() - $response.EnsureSuccessStatusCode() | Out-Null - $source = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() - $file = [IO.File]::Create($destination) - $buffer = New-Object byte[] 65536 - $bytes = 0L - $clock = [Diagnostics.Stopwatch]::StartNew() - Draw-Download 0 $response.Content.Headers.ContentLength - while (($count = $source.Read($buffer, 0, $buffer.Length)) -gt 0) { - $file.Write($buffer, 0, $count) - $bytes += $count - if ($clock.ElapsedMilliseconds -ge 100) { - Draw-Download $bytes $response.Content.Headers.ContentLength - $clock.Restart() - } - } - Draw-Download $bytes $bytes - [Console]::Error.WriteLine() - } finally { - if ($file) { $file.Dispose() } - if ($source) { $source.Dispose() } - if ($response) { $response.Dispose() } - $client.Dispose() - } -} -if ($interactive) { - # Block characters are constructed so this file also loads correctly in PowerShell 5.1. - $mark = @( - "########## ######## ", - " ### _##^ ", - " ### ####_ ", - " ### _ ###", - " ### #######^ " - ) - [Console]::Error.WriteLine() - for ($i = 0; $i -lt $mark.Length; $i++) { - $row = $mark[$i].Replace('#', [char]0x2588).Replace('^', [char]0x2580).Replace('_', [char]0x2584) - $label = if ($i -eq 1) { " ${bold}T3 Code$reset" } elseif ($i -eq 2) { " ${muted}CLI installer$reset" } else { "" } - [Console]::Error.WriteLine(" $bold$row$reset$label") - } - [Console]::Error.WriteLine() -} -Step "Finding your release..." - -# PROCESSOR_ARCHITEW6432 reports the real machine when a 32-bit PowerShell -# runs under WOW64; RuntimeInformation needs .NET 4.7.1+, which 5.1 hosts -# may lack. -$rawArch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE } -$arch = switch ($rawArch) { - "AMD64" { "x64" } - "ARM64" { "arm64" } - default { Fail "unsupported architecture $rawArch" } -} - -$channel = if ($env:T3CODE_CHANNEL) { $env:T3CODE_CHANNEL } else { "stable" } -$version = $env:T3CODE_VERSION -if (-not $version) { - # Tags are v; the channel is the prerelease identifier, or none for - # stable. Only tags of the requested train are considered, so a stable - # install can never pick up a nightly or preview build by accident. - $tagPattern = switch ($channel) { - "stable" { '^v\d+\.\d+\.\d+$' } - "nightly" { '^v\d+\.\d+\.\d+-nightly\.\d+\.\d+$' } - "preview" { '^v\d+\.\d+\.\d+-preview\.\d+\.\d+$' } - default { Fail "T3CODE_CHANNEL must be stable, nightly, or preview" } - } - $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases?per_page=100" -Headers @{ "User-Agent" = "t3-install" } - $tag = ($releases | Where-Object { -not $_.draft -and $_.tag_name -match $tagPattern } | Select-Object -First 1).tag_name - if (-not $tag) { Fail "could not find a $channel release; set T3CODE_VERSION" } - $version = $tag.Substring(1) -} -if ($version -match '-preview\.') { - Write-Warning "t3 $version is a preview build. Preview builds are cut by maintainers from unreleased branches to exercise the release pipeline. They can be broken, receive no fixes, and are never offered as updates. Set T3CODE_CHANNEL=stable (the default) for a supported build." - if ($channel -ne "preview" -and -not $env:T3CODE_VERSION) { - Fail "refusing a preview build that was not explicitly requested" - } -} - -$stem = "t3-$version-win32-$arch" -$archive = "$stem.zip" -$versionsDir = Join-Path $t3Home "runtime\versions" -$targetDir = Join-Path $versionsDir $version -$marker = Join-Path $targetDir ".install-complete" - -if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) { - Step "Version $version is already downloaded." -} else { - New-Item -ItemType Directory -Force -Path $versionsDir | Out-Null - $staging = Join-Path $versionsDir (".staging-" + [System.IO.Path]::GetRandomFileName()) - New-Item -ItemType Directory -Path $staging | Out-Null - try { - if ($interactive) { [Console]::Error.Write("`r$esc[2K") } - [Console]::Error.WriteLine(" ${muted}Installing$reset T3 Code $bold$version$reset`n") - Step "Downloading..." - try { - Fetch "$baseUrl/v$version/SHA256SUMS" (Join-Path $staging "SHA256SUMS") - } catch { - $status = $_.Exception.Response.StatusCode.value__ - if ($status -eq 404) { - Fail "t3 $version has no release archive for win32-$arch; releases before the self-contained CLI can only be installed with 'npm install -g t3@$version'" - } - throw - } - Fetch "$baseUrl/v$version/$archive" (Join-Path $staging $archive) -progress - - Step "Verifying the download..." - - $expected = (Get-Content (Join-Path $staging "SHA256SUMS") | Where-Object { $_ -match "\s\*?$([regex]::Escape($archive))$" } | Select-Object -First 1) - if (-not $expected) { Fail "$archive is not listed in SHA256SUMS" } - $expected = ($expected -split "\s+")[0].ToLowerInvariant() - $actual = (Get-FileHash -Algorithm SHA256 (Join-Path $staging $archive)).Hash.ToLowerInvariant() - if ($actual -ne $expected) { Fail "checksum mismatch for $archive" } - - Step "Extracting T3 Code..." - # The archive module reads the global preference, not the caller's local scope. - $savedProgress = $global:ProgressPreference - try { - $global:ProgressPreference = "SilentlyContinue" - Expand-Archive -Path (Join-Path $staging $archive) -DestinationPath $staging -Force - } finally { $global:ProgressPreference = $savedProgress } - # The archive wraps everything in one directory named after its stem. - Get-ChildItem (Join-Path $staging $stem) | Move-Item -Destination $staging - Remove-Item (Join-Path $staging $stem), (Join-Path $staging $archive), (Join-Path $staging "SHA256SUMS") -Recurse -Force - - & (Join-Path $staging "t3.exe") --version | Out-Null - if ($LASTEXITCODE -ne 0) { Fail "the downloaded executable does not run" } - Set-Content -Path (Join-Path $staging ".install-complete") -Value $version -NoNewline - - if (Test-Path $targetDir) { Remove-Item $targetDir -Recurse -Force } - Move-Item $staging $targetDir - } catch { - if (Test-Path $staging) { Remove-Item $staging -Recurse -Force } - throw - } -} - -Step "Setting up the t3 command..." -New-Item -ItemType Directory -Force -Path $binDir | Out-Null -$shim = Join-Path $binDir "t3.cmd" -# UTF-8 without a BOM: cmd.exe reads the shim as-is, and ASCII would corrupt -# non-ASCII characters in the user's home path. -[System.IO.File]::WriteAllText($shim, "@echo off`r`n`"$(Join-Path $targetDir 't3.exe')`" %*", (New-Object System.Text.UTF8Encoding $false)) -if ($interactive) { [Console]::Error.Write("`r$esc[2K") } -[Console]::Error.WriteLine(" ${green}Installed T3 Code $version$reset`n") -if (($env:PATH -split ";") -notcontains $binDir) { - Write-Host " Add $binDir to your PATH, then run ${bold}t3$reset.`n" -} else { - Write-Host " Run ${bold}t3$reset to get started.`n" -} +[Console]::Error.WriteLine("iglo.code: Windows is unsupported. Supported targets: darwin-arm64, linux-x64, linux-arm64.") +exit 1 diff --git a/scripts/install.sh b/scripts/install.sh index 421d8d021aef..0165444364a0 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,7 +2,7 @@ # Installs the T3 Code CLI from a GitHub Release archive. Needs only sh, tar, # sha256sum or shasum, and curl or wget; no Node, npm, or compiler. # -# curl -fsSL https://t3.codes/install.sh | sh +# curl -fsSL https://raw.githubusercontent.com/iglo-tech/iglo.code/main/scripts/install.sh | sh # # Environment: # T3CODE_CHANNEL release train to follow: stable, nightly, or preview @@ -17,7 +17,7 @@ # instead of fetching the release again. set -eu -repo="pingdotgg/t3code" +repo="iglo-tech/iglo.code" base_url="${T3CODE_RELEASE_BASE_URL:-https://github.com/${repo}/releases/download}" t3_home="${T3CODE_HOME:-$HOME/.t3}" bin_dir="${T3CODE_INSTALL_BIN_DIR:-$HOME/.local/bin}" @@ -126,12 +126,16 @@ download() { case "$(uname -s)" in Darwin) platform="darwin" ;; Linux) platform="linux" ;; - *) fail "unsupported operating system $(uname -s); use the desktop app or npm" ;; + *) fail "unsupported operating system $(uname -s); supported targets: darwin-arm64, linux-x64, linux-arm64" ;; esac case "$(uname -m)" in arm64 | aarch64) arch="arm64" ;; x86_64 | amd64) arch="x64" ;; - *) fail "unsupported architecture $(uname -m)" ;; + *) fail "unsupported architecture $(uname -m); supported targets: darwin-arm64, linux-x64, linux-arm64" ;; +esac +case "${platform}-${arch}" in + darwin-arm64 | linux-x64 | linux-arm64) ;; + *) fail "unsupported target ${platform}-${arch}; supported targets: darwin-arm64, linux-x64, linux-arm64" ;; esac command -v tar >/dev/null 2>&1 || fail "tar is required" if command -v sha256sum >/dev/null 2>&1; then @@ -193,7 +197,7 @@ else fetch_status=0 fetch "${base_url}/v${version}/SHA256SUMS" "${staging}/SHA256SUMS" || fetch_status=$? if [ "$fetch_status" -eq 44 ]; then - fail "t3 ${version} has no release archive for ${platform}-${arch}; releases before the self-contained CLI can only be installed with \`npm install -g t3@${version}\`" + fail "iglo.code ${version} has no release archive for ${platform}-${arch} at ${base_url}; choose an available fork release" elif [ "$fetch_status" -ne 0 ]; then fail "could not download the release checksums" fi diff --git a/scripts/install.test.ts b/scripts/install.test.ts index 9ded5cb51166..3532397039a7 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -113,3 +113,68 @@ describe.skipIf(HostProcessPlatform.defaultValue() !== "linux")("installer termi }, ); }); + +describe("installer release selection", () => { + it.each([ + ["Darwin", "x86_64"], + ["FreeBSD", "x86_64"], + ["Linux", "i686"], + ])("rejects unsupported hosts before downloading (%s/%s)", async (platform, arch) => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-install-platform-")); + try { + await NodeFSP.writeFile( + NodePath.join(root, "uname"), + `#!/bin/sh\ncase "$1" in -s) echo '${platform}';; -m) echo '${arch}';; esac\n`, + { mode: 0o755 }, + ); + const result = NodeChildProcess.spawnSync( + "/bin/sh", + [NodePath.join(import.meta.dirname, "install.sh")], + { + env: { ...process.env, PATH: root, T3CODE_HOME: NodePath.join(root, "home") }, + encoding: "utf8", + }, + ); + expect(result.status).toBe(1); + expect(result.stderr).toContain("supported targets: darwin-arm64, linux-x64, linux-arm64"); + expect(await NodeFSP.readdir(root)).toEqual(["uname"]); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }); + + it("reports missing fork artifacts without proposing an upstream npm install", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-install-fork-")); + try { + await NodeFSP.writeFile( + NodePath.join(root, "curl"), + '#!/bin/sh\nprintf "%s\\n" "$@" > "$T3_INSTALL_REQUEST_LOG"\nprintf 404\n', + { mode: 0o755 }, + ); + const requestLog = NodePath.join(root, "request"); + const result = NodeChildProcess.spawnSync( + "/bin/sh", + [NodePath.join(import.meta.dirname, "install.sh")], + { + env: { + ...process.env, + PATH: `${root}:/usr/bin:/bin`, + T3CODE_HOME: NodePath.join(root, "home"), + T3CODE_VERSION: "1.2.3", + T3CODE_RELEASE_BASE_URL: "", + T3_INSTALL_REQUEST_LOG: requestLog, + }, + encoding: "utf8", + }, + ); + expect(result.status).toBe(1); + expect(await NodeFSP.readFile(requestLog, "utf8")).toContain( + "https://github.com/iglo-tech/iglo.code/releases/download/v1.2.3/SHA256SUMS", + ); + expect(result.stderr).toContain("choose an available fork release"); + expect(result.stderr).not.toContain("npm install"); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/lib/cli-executable-imports.ts b/scripts/lib/cli-executable-imports.ts index 3391445beb2a..7167ed1e16e7 100644 --- a/scripts/lib/cli-executable-imports.ts +++ b/scripts/lib/cli-executable-imports.ts @@ -3,15 +3,9 @@ import * as NodeModule from "node:module"; import ts from "typescript-legacy"; /** - * Scan an emitted bundle chunk for ESM imports of packages that are not Node - * built-ins. - * - * Inside a Node single-executable, `import` statements and `import()` can only - * resolve built-in modules; any file-backed specifier throws at module - * evaluation (static) or at first use (dynamic). External packages therefore - * have to be reached through `createRequire`, which reads the real filesystem - * in every runtime. The bundler cannot enforce this, so the check reads what it - * produced. + * Finds file-backed ESM imports left outside the emitted executable graph. + * Native packages and disk-backed SDKs load through a require rooted beside + * the installed executable. Runtime built-ins belong to Bun itself. */ export function findEsmImportsOfExternalPackages(source: string): ReadonlyArray { const specifiers = new Set(); @@ -33,11 +27,7 @@ export function findEsmImportsOfExternalPackages(source: string): ReadonlyArray< : undefined; if (specifierNode && ts.isStringLiteralLike(specifierNode)) { const specifier = specifierNode.text; - // Multi-runtime SDKs can retain optional Bun imports. Those are runtime - // built-ins, not packages to stage beside the executable. Static imports - // still fail here because Node would evaluate them unconditionally. - const runtimeBuiltin = - NodeModule.isBuiltin(specifier) || (dynamic && specifier.startsWith("bun:")); + const runtimeBuiltin = NodeModule.isBuiltin(specifier) || specifier.startsWith("bun:"); if (!runtimeBuiltin && !specifier.startsWith("./") && !specifier.startsWith("../")) { specifiers.add(specifier); } diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts index 33c2e897b047..2bf99f910486 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -321,14 +321,14 @@ describe("findEsmImportsOfExternalPackages", () => { assert.deepStrictEqual(findEsmImportsOfExternalPackages(source), ["real-package"]); }); - it("allows optional dynamic Bun built-ins but rejects static imports", () => { + it("allows Bun built-ins in static and dynamic imports", () => { assert.deepStrictEqual( findEsmImportsOfExternalPackages('const load = () => import("bun:sqlite");'), [], ); assert.deepStrictEqual( findEsmImportsOfExternalPackages('import { Database } from "bun:sqlite";'), - ["bun:sqlite"], + [], ); }); diff --git a/scripts/lib/cli-external-packages.ts b/scripts/lib/cli-external-packages.ts index ce74d537e704..b2b78c4092ff 100644 --- a/scripts/lib/cli-external-packages.ts +++ b/scripts/lib/cli-external-packages.ts @@ -7,7 +7,7 @@ * - scripts/build-cli-archive.ts selects runtime dependency roots for the CLI archive. * * A runtime package that is external but absent from the archive fails as soon - * as Node resolves it from the emitted bundle. Keeping both consumers on one + * as Bun resolves it from the emitted bundle. Keeping both consumers on one * list prevents packaging from drifting away from the bundle boundary. * * Entries are matched as prefixes (`id.startsWith(prefix)`), so they also cover @@ -15,7 +15,7 @@ * `node-gyp-build-optional-packages`, `@yuuang/` covers every `ffi-rs-*` binding. */ /** - * External because Node actually loads them from disk at runtime. + * External because the runtime loads them from disk at runtime. * * Native addons (.node), the JS wrappers that dlopen them by real path, and — * critically — the ordinary JS packages those wrappers require. An external diff --git a/scripts/lib/cursor-sdk-packaging.test.ts b/scripts/lib/cursor-sdk-packaging.test.ts index 1eabc6664e1a..a7e4b96449a5 100644 --- a/scripts/lib/cursor-sdk-packaging.test.ts +++ b/scripts/lib/cursor-sdk-packaging.test.ts @@ -6,6 +6,7 @@ import * as NodeModule from "node:module"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeURL from "node:url"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { assert, it } from "@effect/vitest"; import * as Schema from "effect/Schema"; import { build } from "vite-plus/pack"; @@ -31,8 +32,8 @@ const decodeManifest = Schema.decodeUnknownSync( const repoRoot = NodeURL.fileURLToPath(new URL("../..", import.meta.url)); // Copy the installed production JS dependency graph, with no pnpm symlinks back -// into the checkout. Optional platform executables are covered by desktop staging -// tests; this probe never creates a local agent or contacts Cursor. +// into the checkout. Platform executables are carried by archive staging; this probe never +// creates a local agent or contacts Cursor. async function stagePackage(name: string, from: string, destination: string): Promise { const require = NodeModule.createRequire(from); let source = NodePath.dirname(require.resolve(name)); @@ -116,21 +117,22 @@ it("loads packaged Cursor catalog chunks without credentials or checkout depende } const probe = NodePath.join(output, "probe.mjs"); assert.deepEqual(findEsmImportsOfExternalPackages(await NodeFSP.readFile(probe, "utf8")), []); - const stdout = NodeChildProcess.execFileSync( - process.execPath, - ["--no-global-search-paths", probe], - { - cwd: output, - env: { - HOME: scratch, - USERPROFILE: scratch, - PATH: "", - SystemRoot: process.env.SystemRoot ?? "", - }, - encoding: "utf8", - timeout: 30_000, - }, - ); + const executable = NodePath.join(output, "cursor-probe"); + const bun = process.env.T3_BUN_EXECUTABLE ?? "bun"; + NodeChildProcess.execFileSync(bun, ["build", "--compile", probe, "--outfile", executable], { + cwd: output, + stdio: "pipe", + timeout: 30_000, + }); + if (HostProcessPlatform.defaultValue() === "darwin") { + NodeChildProcess.execFileSync("codesign", ["--force", "--sign", "-", executable]); + } + const stdout = NodeChildProcess.execFileSync(executable, [], { + cwd: scratch, + env: { HOME: scratch, USERPROFILE: scratch, PATH: "" }, + encoding: "utf8", + timeout: 30_000, + }); assert.include(stdout, "Cursor catalog chunks loaded; empty keys rejected locally"); } finally { await NodeFSP.rm(scratch, { recursive: true, force: true }); From 94b930632a2728e97d8487fcf95c43e9f7ac9efb Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Wed, 7 Oct 2026 15:11:35 +0200 Subject: [PATCH 03/32] fix(distribution): resolve disk packages in Bun executables --- apps/server/scripts/cli.ts | 12 +++++++++++- scripts/lib/cursor-sdk-packaging.test.ts | 14 +++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index e7d34ee515d7..85d6b7192ae8 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -165,10 +165,20 @@ const buildExeCmd = Command.make( return yield* new ServerCliExecutableImportError({ bundlePath, specifiers }); } const executablePath = path.join(serverDir, "dist-exe", `t3-${target}`); + // Bun disables package.json loading in executables by default, including + // the exports and dependency resolution needed by disk-backed packages. yield* runCommand( ChildProcess.make( process.execPath, - ["build", "--compile", `--target=bun-${target}`, "--outfile", executablePath, bundlePath], + [ + "build", + "--compile", + "--compile-autoload-package-json", + `--target=bun-${target}`, + "--outfile", + executablePath, + bundlePath, + ], { cwd: serverDir, stdout: config.verbose ? "inherit" : "ignore", diff --git a/scripts/lib/cursor-sdk-packaging.test.ts b/scripts/lib/cursor-sdk-packaging.test.ts index a7e4b96449a5..0ff22d389053 100644 --- a/scripts/lib/cursor-sdk-packaging.test.ts +++ b/scripts/lib/cursor-sdk-packaging.test.ts @@ -119,11 +119,15 @@ it("loads packaged Cursor catalog chunks without credentials or checkout depende assert.deepEqual(findEsmImportsOfExternalPackages(await NodeFSP.readFile(probe, "utf8")), []); const executable = NodePath.join(output, "cursor-probe"); const bun = process.env.T3_BUN_EXECUTABLE ?? "bun"; - NodeChildProcess.execFileSync(bun, ["build", "--compile", probe, "--outfile", executable], { - cwd: output, - stdio: "pipe", - timeout: 30_000, - }); + NodeChildProcess.execFileSync( + bun, + ["build", "--compile", "--compile-autoload-package-json", probe, "--outfile", executable], + { + cwd: output, + stdio: "pipe", + timeout: 30_000, + }, + ); if (HostProcessPlatform.defaultValue() === "darwin") { NodeChildProcess.execFileSync("codesign", ["--force", "--sign", "-", executable]); } From 24fc5262ee1f5098e33a2c4d6f66b8ab0c236dcb Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Wed, 7 Oct 2026 15:12:55 +0200 Subject: [PATCH 04/32] fix(runtime): preserve Bun helper service dependencies --- apps/server/src/provider/AntigravityInstallation.ts | 1 + packages/shared/src/bunRuntime.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/AntigravityInstallation.ts b/apps/server/src/provider/AntigravityInstallation.ts index 68c40ba01a29..c4bc651b2321 100644 --- a/apps/server/src/provider/AntigravityInstallation.ts +++ b/apps/server/src/provider/AntigravityInstallation.ts @@ -410,6 +410,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma yield* resolveBunExecutable("Antigravity", environment).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(HostProcessPlatform, platform), Effect.mapError((cause) => installationError("verify", bunRuntimeUnavailableMessage("Antigravity"), cause), diff --git a/packages/shared/src/bunRuntime.ts b/packages/shared/src/bunRuntime.ts index 9b743fc39c8d..41a9a7e9f5a4 100644 --- a/packages/shared/src/bunRuntime.ts +++ b/packages/shared/src/bunRuntime.ts @@ -91,7 +91,9 @@ export const resolveBunExecutable = Effect.fn("bunRuntime.resolveBunExecutable") const bundledRuntime = path.join(path.dirname(executablePath), "runtime", "bun"); const bunPath = env.T3_BUN_EXECUTABLE ? path.resolve(env.T3_BUN_EXECUTABLE) - : (yield* fs.exists(bundledRuntime)) + : (yield* fs + .exists(bundledRuntime) + .pipe(Effect.mapError((cause) => new BunRuntimeUnavailableError({ feature, cause })))) ? bundledRuntime : yield* resolveCommandPath("bun", { env }).pipe( Effect.provideService(CommandResolutionCache, new Map()), From 529b6a54c162b928cc06583c0ea9b1647c888b0a Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Wed, 7 Oct 2026 15:17:18 +0200 Subject: [PATCH 05/32] feat(device): run pinned tools and SSH bootstrap with Bun --- apps/server/scripts/smoke-device-tools.ts | 215 ++++++++++++++++++ apps/server/src/device/AgentDeviceShim.ts | 12 +- .../src/device/AgentDeviceTarget.test.ts | 3 +- apps/server/src/device/DeviceActions.test.ts | 2 +- apps/server/src/device/DeviceActions.ts | 2 +- apps/server/src/device/DeviceHost.ts | 8 +- .../server/src/device/DeviceMultiHost.test.ts | 2 +- apps/server/src/device/DeviceService.test.ts | 16 +- apps/server/src/device/DeviceService.ts | 20 +- .../server/src/device/DeviceToolchain.test.ts | 41 +++- apps/server/src/device/DeviceToolchain.ts | 82 ++++--- .../server/src/device/LocalDeviceHost.test.ts | 6 +- apps/server/src/device/LocalDeviceHost.ts | 46 ++-- apps/server/src/device/SshDeviceHost.test.ts | 2 +- apps/server/src/device/SshDeviceHost.ts | 15 +- .../src/device/deviceToolMaintenance.test.ts | 13 +- .../src/device/deviceToolMaintenance.ts | 8 +- .../server/src/device/sshDeviceScript.test.ts | 71 +++++- apps/server/src/device/sshDeviceScript.ts | 29 ++- .../src/mcp/toolkits/device/handlers.ts | 6 +- apps/server/src/mcp/toolkits/device/tools.ts | 9 +- 21 files changed, 478 insertions(+), 130 deletions(-) create mode 100644 apps/server/scripts/smoke-device-tools.ts diff --git a/apps/server/scripts/smoke-device-tools.ts b/apps/server/scripts/smoke-device-tools.ts new file mode 100644 index 000000000000..2c2c4ef7c7b7 --- /dev/null +++ b/apps/server/scripts/smoke-device-tools.ts @@ -0,0 +1,215 @@ +#!/usr/bin/env bun +// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - this external smoke harness owns disposable helper processes and state. +/** Installs and exercises the pinned Device packages with no Node or npm on PATH. */ +import * as NodeAssert from "node:assert"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeUtil from "node:util"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { DeviceToolVersions } from "@t3tools/contracts"; +import * as NetService from "@t3tools/shared/Net"; +import { BUN_VERSION } from "@t3tools/shared/bunRuntime"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as ProcessRunner from "../src/processRunner.ts"; +import { + AGENT_DEVICE_VERSION, + ensureAgentDevice, + ensureDeviceHub, +} from "../src/device/DeviceToolchain.ts"; +import { remoteDeviceScript } from "../src/device/sshDeviceScript.ts"; + +const exec = NodeUtil.promisify(NodeChildProcess.execFile); +const scratch = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "iglo-device-bun-smoke-")); +const bun = process.execPath; +const bin = NodePath.join(scratch, "bin"); +const state = NodePath.join(scratch, "agent-state"); +await NodeFSP.mkdir(bin); +await NodeFSP.mkdir(state); +await NodeFSP.symlink(bun, NodePath.join(bin, "bun")); +// These are independent OS tools. Deliberately omit node, npm and npx, +// including on Linux where they may otherwise live in /usr/bin. +for (const command of [ + "sh", + "bash", + "env", + "ps", + "kill", + "tar", + "gzip", + "uname", + "chmod", + "mkdir", + "rm", + "cp", + "which", + "file", +]) { + try { + const executable = NodeChildProcess.execFileSync("/bin/sh", ["-c", `command -v ${command}`], { + encoding: "utf8", + }).trim(); + if (executable.startsWith("/")) await NodeFSP.symlink(executable, NodePath.join(bin, command)); + } catch { + // Some optional OS tools are absent on minimal CI hosts. + } +} +// No physical device or SDK is required for readiness and unavailable-device flows. +await NodeFSP.writeFile( + NodePath.join(bin, "adb"), + "#!/bin/sh\nif [ \"$1\" = version ]; then echo 'Android Debug Bridge version 1.0.41'; else printf 'List of devices attached\\n\\n'; fi\n", + { mode: 0o755 }, +); +const env: NodeJS.ProcessEnv = { + ...process.env, + PATH: bin, + HOME: scratch, + ANDROID_HOME: "", + ANDROID_SDK_ROOT: "", + AGENT_DEVICE_STATE_DIR: state, + AGENT_DEVICE_DAEMON_SERVER_MODE: "http", + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: "0", + AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1", + FORCE_COLOR: "0", + NO_COLOR: "1", +}; +delete env.AGENT_DEVICE_DAEMON_BASE_URL; +delete env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; +delete env.AGENT_DEVICE_CONFIG; + +const run = (args: ReadonlyArray, cwd = scratch) => + exec(bun, [...args], { cwd, env, timeout: 650_000, maxBuffer: 8 * 1024 * 1024 }); +let agentEntry: string | undefined; +let hub: ReturnType | undefined; +let remoteStarted = false; +const Probe = Schema.Struct({ + bunPath: Schema.String, + platforms: Schema.Array(Schema.Struct({ platform: Schema.String, available: Schema.Boolean })), +}); +const Started = Schema.Struct({ + hubPort: Schema.Number, + daemonPort: Schema.Number, + tools: DeviceToolVersions, +}); +const decode = ( + schema: S, + text: string, +) => Schema.decodeUnknownSync(Schema.fromJsonString(schema))(text); +const remote = async (mode: "probe" | "agent-start" | "stop") => { + const result = await run(["-e", remoteDeviceScript("smoke", mode)]); + return result.stdout.trim(); +}; + +try { + NodeAssert.strict.equal((await run(["--version"])).stdout.trim(), BUN_VERSION); + await NodeAssert.strict.rejects(exec("node", ["--version"], { env })); + await NodeAssert.strict.rejects(exec("npm", ["--version"], { env })); + const tools = await Effect.runPromise( + Effect.all([ensureDeviceHub(scratch), ensureAgentDevice(scratch)]).pipe( + Effect.provide(ProcessRunner.layer), + Effect.provideService(HostProcessEnvironment, env), + Effect.provide(NodeServices.layer), + ), + ); + const [hubTool, agentTool] = tools; + agentEntry = agentTool.entryPath; + // Loading the WebRTC addon proves its required prebuild install step completed. + await run( + [ + "-e", + "const {PeerConnection,cleanup}=require('node-datachannel');const peer=new PeerConnection('bun-smoke',{iceServers:[]});peer.close();cleanup();", + ], + hubTool.installDir, + ); + const port = await Effect.runPromise( + NetService.NetService.pipe( + Effect.flatMap((net) => net.reserveLoopbackPort()), + Effect.scoped, + Effect.provide(NetService.layer), + ), + ); + hub = NodeChildProcess.spawn( + bun, + [hubTool.entryPath, "--port", String(port), "--host", "127.0.0.1", "--platform", "android"], + { cwd: scratch, env, stdio: ["ignore", "pipe", "pipe"] }, + ); + const runningHub = hub; + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("Device hub did not become ready")), 30_000); + let output = ""; + runningHub.stdout?.on("data", (chunk: Buffer) => { + output += chunk.toString(); + if (output.includes("Expo Device Hub ready")) { + clearTimeout(timer); + resolve(); + } + }); + runningHub.stderr?.on("data", (chunk: Buffer) => process.stderr.write(chunk)); + runningHub.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + runningHub.once("exit", (code) => { + clearTimeout(timer); + reject(new Error(`Device hub exited before readiness (${code})`)); + }); + }); + const origin = `http://127.0.0.1:${port}`; + NodeAssert.strict.equal((await fetch(`${origin}/readyz`)).status, 200); + NodeAssert.strict.match(await (await fetch(origin)).text(), //i); + const devices = decode( + Schema.Struct({ emulators: Schema.Array(Schema.Unknown) }), + await (await fetch(`${origin}/api/devices`)).text(), + ); + NodeAssert.strict.deepEqual(devices.emulators, []); + const listed = decode( + Schema.Struct({ success: Schema.Boolean }), + (await run([agentEntry, "devices", "--json"])).stdout, + ); + NodeAssert.strict.equal(listed.success, true); + const daemon = decode( + Schema.Struct({ version: Schema.String, pid: Schema.Number, httpPort: Schema.Number }), + await NodeFSP.readFile(NodePath.join(state, "daemon.json"), "utf8"), + ); + NodeAssert.strict.equal(daemon.version, AGENT_DEVICE_VERSION); + NodeAssert.strict.equal((await fetch(`http://127.0.0.1:${daemon.httpPort}/health`)).status, 200); + const command = ( + await exec(NodePath.join(bin, "ps"), ["-p", String(daemon.pid), "-o", "command="], { env }) + ).stdout; + NodeAssert.strict.ok(command.includes(bun), "Nested agent daemon must use the Bun interpreter"); + await run([agentEntry, "daemon", "stop", "--state-dir", state]); + await NodeAssert.strict.rejects(fetch(`http://127.0.0.1:${daemon.httpPort}/health`)); + const probed = decode(Probe, await remote("probe")); + NodeAssert.strict.equal(probed.bunPath, bun); + NodeAssert.strict.equal( + probed.platforms.find((platform) => platform.platform === "android")?.available, + true, + ); + remoteStarted = true; + const started = decode(Started, await remote("agent-start")); + NodeAssert.strict.equal((await fetch(`http://127.0.0.1:${started.hubPort}/readyz`)).status, 200); + NodeAssert.strict.equal( + (await fetch(`http://127.0.0.1:${started.daemonPort}/health`)).status, + 200, + ); + NodeAssert.strict.equal(started.tools.agent.runningVersion, AGENT_DEVICE_VERSION); + await remote("stop"); + remoteStarted = false; + await NodeAssert.strict.rejects(fetch(`http://127.0.0.1:${started.daemonPort}/health`)); + console.log( + "Pinned Device installs, native WebRTC, hub HTTP, nested agent daemon and remote Bun bootstrap passed.", + ); +} finally { + if (remoteStarted) await remote("stop").catch(() => {}); + if (agentEntry) await run([agentEntry, "daemon", "stop", "--state-dir", state]).catch(() => {}); + if (hub && hub.exitCode === null && hub.signalCode === null) { + const runningHub = hub; + const exited = new Promise((resolve) => runningHub.once("exit", () => resolve())); + hub.kill("SIGTERM"); + await exited; + } + await NodeFSP.rm(scratch, { recursive: true, force: true }); +} diff --git a/apps/server/src/device/AgentDeviceShim.ts b/apps/server/src/device/AgentDeviceShim.ts index a5a669d1d8ba..07cce2c1282a 100644 --- a/apps/server/src/device/AgentDeviceShim.ts +++ b/apps/server/src/device/AgentDeviceShim.ts @@ -1,12 +1,12 @@ // @effect-diagnostics preferSchemaOverJson:off - JSON string literals embed paths safely into generated JavaScript. /** * A directory holding an `agent-device` launcher that runs the pinned install - * with a Node runtime. Prepended to provider subprocess PATHs so the agent + * with a Bun runtime. Prepended to provider subprocess PATHs so the agent * types `agent-device …` and gets the version the injected instructions were * written for, regardless of what is or is not globally installed. */ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { resolveNodeExecutable } from "@t3tools/shared/nodeRuntime"; +import { resolveBunExecutable } from "@t3tools/shared/bunRuntime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -21,7 +21,7 @@ export const ensureAgentDeviceShim = Effect.fn("AgentDeviceShim.ensure")(functio const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const platform = yield* HostProcessPlatform; - const node = yield* resolveNodeExecutable("Device automation"); + const bun = yield* resolveBunExecutable("Device automation"); const shimDir = path.join(input.stateDir, SHIM_DIR); yield* fs.makeDirectory(shimDir, { recursive: true }); const launcherPath = path.join(shimDir, "agent-device-launcher.mjs"); @@ -39,16 +39,16 @@ const env = { ...process.env }; delete env.AGENT_DEVICE_DAEMON_BASE_URL; delete env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; delete env.AGENT_DEVICE_CONFIG; -const child = spawn(${JSON.stringify(node)}, [${JSON.stringify(entryPath)}, ...args], { stdio: "inherit", env }); +const child = spawn(${JSON.stringify(bun)}, [${JSON.stringify(entryPath)}, ...args], { stdio: "inherit", env }); child.on("error", error => { console.error(error.message); process.exitCode = 1; }); child.on("exit", code => { process.exitCode = code ?? 1; }); `, ); if (platform === "win32") { - const script = `@echo off\r\n"${node}" "${launcherPath}" %*\r\n`; + const script = `@echo off\r\n"${bun}" "${launcherPath}" %*\r\n`; yield* fs.writeFileString(path.join(shimDir, "agent-device.cmd"), script); } else { - const command = [node, launcherPath] + const command = [bun, launcherPath] .map((value) => "'" + value.replaceAll("'", "'\"'\"'") + "'") .join(" "); const script = `#!/bin/sh\nexec ${command} "$@"\n`; diff --git a/apps/server/src/device/AgentDeviceTarget.test.ts b/apps/server/src/device/AgentDeviceTarget.test.ts index 4dfd5e20d1c8..45b0a13bf651 100644 --- a/apps/server/src/device/AgentDeviceTarget.test.ts +++ b/apps/server/src/device/AgentDeviceTarget.test.ts @@ -19,6 +19,7 @@ import { } from "./AgentDeviceTarget.ts"; const exec = NodeUtil.promisify(NodeChildProcess.execFile); +const bunPath = process.env.T3_BUN_EXECUTABLE ?? "bun"; describe("host-bound agent commands", () => { it.effect("runs two hosts concurrently and only updates the reconnected host", () => @@ -86,7 +87,7 @@ if (process.env.AGENT_DEVICE_DAEMON_BASE_URL) process.exit(2);`, ]) { yield* Effect.promise(() => expect( - exec(process.execPath, [path.join(shim, "agent-device-launcher.mjs"), ...args]), + exec(bunPath, [path.join(shim, "agent-device-launcher.mjs"), ...args]), ).rejects.toThrow("Call device_open first"), ); } diff --git a/apps/server/src/device/DeviceActions.test.ts b/apps/server/src/device/DeviceActions.test.ts index c22dcfe86610..383e1bd2f2e3 100644 --- a/apps/server/src/device/DeviceActions.test.ts +++ b/apps/server/src/device/DeviceActions.test.ts @@ -16,7 +16,7 @@ const makeReady = ( ) => { const calls: Call[] = []; const ready: DeviceHostReady = { - nodePath: process.execPath, + bunPath: process.execPath, hub: { origin: "http://127.0.0.1:1" }, helpers, run: (command, args, options) => { diff --git a/apps/server/src/device/DeviceActions.ts b/apps/server/src/device/DeviceActions.ts index 509dac526b93..340c7a03ad3d 100644 --- a/apps/server/src/device/DeviceActions.ts +++ b/apps/server/src/device/DeviceActions.ts @@ -327,7 +327,7 @@ const serveSimPermissions = ( reason: "helper_missing", }); yield* ready - .run(ready.nodePath, [ + .run(ready.bunPath, [ cli, "permissions", input.decision, diff --git a/apps/server/src/device/DeviceHost.ts b/apps/server/src/device/DeviceHost.ts index 14537985b9b8..24bcfa2b5d77 100644 --- a/apps/server/src/device/DeviceHost.ts +++ b/apps/server/src/device/DeviceHost.ts @@ -18,7 +18,7 @@ import type { import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import type { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime"; +import type { BunRuntimeUnavailableError } from "@t3tools/shared/bunRuntime"; export class DeviceHostError extends Schema.TaggedError()("DeviceHostError", { hostId: Schema.String, @@ -52,7 +52,7 @@ export interface AgentDeviceEndpoint { } export interface DeviceHostReady { - readonly nodePath: string; + readonly bunPath: string; readonly hub: DeviceHubEndpoint; /** * Runs a host command (`xcrun`, `adb`, or a helper bundled with the hub) @@ -90,13 +90,13 @@ export class DeviceHost extends Context.Service< */ readonly ensureReady: ( onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect, - ) => Effect.Effect; + ) => Effect.Effect; /** Installs and starts agent-device after the user grants agent access. */ readonly ensureAgentReady: ( onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect, ) => Effect.Effect< DeviceHostAgentReady, - DeviceHostError | DeviceHostTimeoutError | NodeRuntimeUnavailableError + DeviceHostError | DeviceHostTimeoutError | BunRuntimeUnavailableError >; /** Current endpoints when already running, without starting anything. */ readonly current: Effect.Effect; diff --git a/apps/server/src/device/DeviceMultiHost.test.ts b/apps/server/src/device/DeviceMultiHost.test.ts index 63502353238c..3e344135b7e6 100644 --- a/apps/server/src/device/DeviceMultiHost.test.ts +++ b/apps/server/src/device/DeviceMultiHost.test.ts @@ -13,7 +13,7 @@ it.effect("keeps hosts independent when serials collide and another host fails", Effect.gen(function* () { const host = (id: string, failed = false): DeviceHost.DeviceHost["Service"] => { const ready = { - nodePath: process.execPath, + bunPath: process.execPath, hub: { origin: `http://${id}` }, agentDevice: { baseUrl: `http://${id}`, token: "test", entryPath: "/agent-device" }, run: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 55a7ac28a67c..2da81806951b 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -19,7 +19,7 @@ import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/http"; import * as ServerSettings from "../serverSettings.ts"; import * as DeviceHost from "./DeviceHost.ts"; -import { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime"; +import { BunRuntimeUnavailableError } from "@t3tools/shared/bunRuntime"; import * as DeviceService from "./DeviceService.ts"; @@ -66,7 +66,7 @@ const fixture = Effect.fn("fixture")(function* ( onBoot: Effect.Effect = Effect.void, bootError?: string, failListAfterShutdown = false, - runtimeFailure?: NodeRuntimeUnavailableError | DeviceHost.DeviceHostError, + runtimeFailure?: BunRuntimeUnavailableError | DeviceHost.DeviceHostError, inspectError = false, installTool?: Parameters[3], ) { @@ -78,7 +78,7 @@ const fixture = Effect.fn("fixture")(function* ( let booted = false; let shutDown = false; const ready: DeviceHost.DeviceHostReady = { - nodePath: process.execPath, + bunPath: process.execPath, hub: { origin: "http://device.test" }, helpers: { serveSimAxSettings: null, serveSimCli: null }, run: () => Effect.succeed({ code: 0, stdout: "Pixel_API_35\n", stderr: "" }), @@ -222,7 +222,7 @@ describe("device setup consent", () => { () => Effect.gen(function* () { const underlying = new Error("private lookup diagnostics"); - const runtimeFailure = new NodeRuntimeUnavailableError({ + const runtimeFailure = new BunRuntimeUnavailableError({ feature: "Local device support", cause: underlying, }); @@ -241,14 +241,14 @@ describe("device setup consent", () => { const error = yield* readiness.pipe(Effect.flip); expect(error).toMatchObject({ _tag: "DeviceHostUnavailableError", - reason: expect.stringContaining("Install Node.js"), + reason: expect.stringContaining("Install Bun"), cause: runtimeFailure, }); expect(error.message).not.toContain(underlying.message); } expect((yield* service.state).hostStatuses[LOCAL_DEVICE_HOST_ID]).toMatchObject({ status: "failed", - detail: expect.stringContaining("Install Node.js"), + detail: expect.stringContaining("Install Bun"), }); expect(requests).toEqual([]); }).pipe(Effect.scoped), @@ -424,7 +424,7 @@ it.effect.each(["shutdown", "close"] as const)( let capture: number | null = null; let generation = 0; const ready: DeviceHost.DeviceHostReady = { - nodePath: process.execPath, + bunPath: process.execPath, hub: { origin: "http://device.test" }, helpers: { serveSimAxSettings: null, serveSimCli: null }, run: () => Effect.succeed({ code: 0, stdout: "", stderr: "" }), @@ -513,7 +513,7 @@ it.effect.each([ // The device list is stale until shutdown re-reads it from the hub. let listed: "booted" | "off" | "missing" = "booted"; const ready: DeviceHost.DeviceHostReady = { - nodePath: process.execPath, + bunPath: process.execPath, hub: { origin: "http://device.test" }, helpers: { serveSimAxSettings: null, serveSimCli: null }, run: () => Effect.succeed({ code: 0, stdout: "", stderr: "" }), diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 4226755d48eb..416447499b76 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -36,7 +36,7 @@ import { type ThreadId, } from "@t3tools/contracts"; import * as FileSystem from "effect/FileSystem"; -import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; +import { resolveBunExecutable, bunRuntimeUnavailableMessage } from "@t3tools/shared/bunRuntime"; import * as Path from "effect/Path"; import { ensureAgentDevice, ensureDeviceHub } from "./DeviceToolchain.ts"; import * as ServerConfig from "../config.ts"; @@ -46,6 +46,7 @@ import { writeAgentDeviceConfig, } from "./AgentDeviceTarget.ts"; import * as Context from "effect/Context"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -278,8 +279,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* new DeviceHostUnavailableError({ hostId: host.id, reason: - error._tag === "NodeRuntimeUnavailableError" - ? nodeRuntimeUnavailableMessage("Local device support") + error._tag === "BunRuntimeUnavailableError" + ? bunRuntimeUnavailableMessage("Local device support") : error.step === "probe" ? "Could not connect to this host over SSH." : `Device support failed during ${error.step}.`, @@ -333,8 +334,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* new DeviceHostUnavailableError({ hostId: host.id, reason: - error._tag === "NodeRuntimeUnavailableError" - ? nodeRuntimeUnavailableMessage("Local device support") + error._tag === "BunRuntimeUnavailableError" + ? bunRuntimeUnavailableMessage("Local device support") : error._tag === "DeviceHostTimeoutError" ? `Agent tools did not start within ${error.timeoutMs} ms.` : error.step === "probe" @@ -995,6 +996,7 @@ export const make = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const runner = yield* ProcessRunner.ProcessRunner; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const settings = yield* ServerSettings.ServerSettingsService; const scope = yield* Scope.Scope; const hosts = new Map([ @@ -1050,6 +1052,7 @@ export const make = Effect.gen(function* () { Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.mapError( (cause) => new DeviceOperationError({ @@ -1146,17 +1149,18 @@ export const make = Effect.gen(function* () { ); return { ...service, - agentCli: resolveNodeExecutable("Device automation").pipe( + agentCli: resolveBunExecutable("Device automation").pipe( Effect.andThen(ensureAgentDevice(config.baseDir)), Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.map((tool) => tool.entryPath), Effect.mapError((error) => - error._tag === "NodeRuntimeUnavailableError" + error._tag === "BunRuntimeUnavailableError" ? new DeviceHostUnavailableError({ hostId: LOCAL_DEVICE_HOST_ID, - reason: nodeRuntimeUnavailableMessage("Device automation"), + reason: bunRuntimeUnavailableMessage("Device automation"), cause: error, }) : new DeviceOperationError({ diff --git a/apps/server/src/device/DeviceToolchain.test.ts b/apps/server/src/device/DeviceToolchain.test.ts index 78dc719b141e..c1790652482b 100644 --- a/apps/server/src/device/DeviceToolchain.test.ts +++ b/apps/server/src/device/DeviceToolchain.test.ts @@ -36,7 +36,7 @@ it.effect("failed installation cleans staging and exposes only a safe failure me Effect.flip, ); expect(error.message).toBe( - "Installing expo-device-hub failed while running npm install (exit code 1).", + "Installing expo-device-hub failed while running Bun install (exit code 1).", ); expect(error.cause).toBe(result); expect(yield* isDeviceHubInstalled(baseDir)).toBe(false); @@ -44,6 +44,45 @@ it.effect("failed installation cleans staging and exposes only a safe failure me }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect("does not publish a hub whose native streaming install failed", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-native-install-" }); + const output = { + code: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + const error = yield* ensureDeviceHub(baseDir).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: (input) => + Effect.gen(function* () { + const staging = input.args[input.args.indexOf("--cwd") + 1]; + if (input.args.includes("install") && staging) { + const directory = path.join(staging, "node_modules/expo-device-hub/dist/server"); + yield* fs.makeDirectory(directory, { recursive: true }); + yield* fs.writeFileString(path.join(directory, "cli.mjs"), ""); + return output; + } + return { ...output, code: ChildProcessSpawner.ExitCode(1) }; + }).pipe(Effect.orDie), + }), + Effect.flip, + ); + expect(error.message).toBe( + "Installing expo-device-hub failed while installing native Device streaming support (exit code 1).", + ); + expect(yield* isDeviceHubInstalled(baseDir)).toBe(false); + expect(yield* fs.readDirectory(path.join(baseDir, "tools", "expo-device-hub"))).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect("inventory reports only completed versions without installing the required version", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts index f30960ed2212..76e9b8445c0b 100644 --- a/apps/server/src/device/DeviceToolchain.ts +++ b/apps/server/src/device/DeviceToolchain.ts @@ -3,15 +3,15 @@ import type { DeviceToolVersions } from "@t3tools/contracts"; * Pinned installs of the two external tools device support is built on. * * `expo-device-hub` streams simulator and emulator screens and `agent-device` - * drives them. Each is npm-installed separately after its matching consent + * drives them. Each is Bun-installed separately after its matching consent * step into `/tools//` and executed from there with the - * resolved Node runtime, never `npx`: an ephemeral - * npx cache would make every first `device_open` after a reboot depend on the + * resolved Bun runtime: an ephemeral + * package runner cache would make every first `device_open` after a reboot depend on the * registry, and the pinned versions are part of the contract the injected * agent instructions describe. * * Install follows the pinned-runtime recipe: stage into a temp sibling, write a - * sentinel only after npm exits 0, then rename into place. npm extracts files + * sentinel only after Bun exits 0, then rename into place. Bun extracts files * before it finishes, so an entry file alone does not prove a usable tree. */ import * as Duration from "effect/Duration"; @@ -19,10 +19,11 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; +import { resolveBunExecutable } from "@t3tools/shared/bunRuntime"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import * as ProcessRunner from "../processRunner.ts"; const DEVICE_HUB_PACKAGE = "expo-device-hub"; @@ -35,7 +36,7 @@ const installLock = Semaphore.makeUnsafe(1); export interface DeviceToolPaths { readonly installDir: string; - /** Absolute path of the tool's entry script, run with a resolved Node runtime. */ + /** Absolute path of the tool's entry script, run with a resolved Bun runtime. */ readonly entryPath: string; readonly sentinelPath: string; } @@ -132,38 +133,59 @@ const installTool = Effect.fn("DeviceToolchain.installTool")(function* ( .pipe(Effect.mapError(fail("preparing the install directory"))); return yield* Effect.gen(function* () { - const installArgs = [ - "install", - "--prefix", - stagingDir, - "--no-fund", - "--no-audit", - `${spec.name}@${spec.version}`, - ]; - const result = yield* runner - .run({ command: "npm", args: installArgs, timeout: INSTALL_TIMEOUT }) - .pipe( - Effect.catchTags({ - ProcessSpawnError: (error) => - error.cause instanceof PlatformError.PlatformError && - error.cause.reason._tag === "NotFound" - ? runner.run({ - command: "pnpm", - args: ["--package=npm@11", "dlx", "npm", ...installArgs], - timeout: INSTALL_TIMEOUT, - }) - : Effect.fail(error), + const environment = yield* HostProcessEnvironment; + const bunPath = yield* resolveBunExecutable( + spec.name === DEVICE_HUB_PACKAGE ? "Local device support" : "Device automation", + environment, + ).pipe(Effect.mapError(fail("resolving the Bun runtime"))); + yield* fs + .writeFileString( + path.join(stagingDir, "package.json"), + JSON.stringify({ + private: true, + dependencies: { [spec.name]: spec.version }, + trustedDependencies: [], }), - Effect.mapError(fail("running npm install")), - ); + ) + .pipe(Effect.mapError(fail("preparing the package manifest"))); + const result = yield* runner + .run({ + command: bunPath, + args: ["--bun", "install", "--cwd", stagingDir, "--production", "--ignore-scripts"], + timeout: INSTALL_TIMEOUT, + env: environment, + }) + .pipe(Effect.mapError(fail("running Bun install"))); if (result.code !== 0) { return yield* new DeviceToolchainInstallError({ tool: spec.name, - step: "running npm install", + step: "running Bun install", exitCode: Number(result.code), cause: result, }); } + if (spec.name === DEVICE_HUB_PACKAGE) { + // node-datachannel's lifecycle downloads its prebuilt N-API addon, then + // falls back to npm and a native build. Run only that supported prebuild + // step with Bun so a failed download cannot add a Node/npm requirement. + const native = yield* runner + .run({ + command: bunPath, + args: [path.join(stagingDir, "node_modules", "prebuild-install", "bin.js"), "-r", "napi"], + cwd: path.join(stagingDir, "node_modules", "node-datachannel"), + env: environment, + timeout: INSTALL_TIMEOUT, + }) + .pipe(Effect.mapError(fail("installing native Device streaming support"))); + if (native.code !== 0) { + return yield* new DeviceToolchainInstallError({ + tool: spec.name, + step: "installing native Device streaming support", + exitCode: Number(native.code), + cause: native, + }); + } + } const stagedEntry = path.join(stagingDir, "node_modules", spec.name, ...spec.entry); if (!(yield* fs.exists(stagedEntry).pipe(Effect.orElseSucceed(() => false)))) { return yield* new DeviceToolchainInstallError({ diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index 7dde6263defd..46f9310a442b 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -212,10 +212,10 @@ it.effect( ); expect(yield* host.current).toBeNull(); const error = yield* host - .ensureReady(() => Effect.die("Must not install without Node")) + .ensureReady(() => Effect.die("Must not install without Bun")) .pipe(Effect.flip, Effect.provideService(HostProcessIsExecutable, true)); - expect(error.message).toContain("Local device support requires Node.js"); - expect(error.message).toContain("Install Node.js"); + expect(error.message).toContain("Local device support requires Bun"); + expect(error.message).toContain("Install Bun"); yield* host.stop; expect(yield* fs.exists(`${baseDir}/tools`)).toBe(false); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index 65ddf6895742..28fcda3a4593 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -24,10 +24,7 @@ import { HostProcessPlatform, HostProcessUserId, } from "@t3tools/shared/hostProcess"; -import { - resolveNodeExecutable, - type NodeRuntimeUnavailableError, -} from "@t3tools/shared/nodeRuntime"; +import { resolveBunExecutable, type BunRuntimeUnavailableError } from "@t3tools/shared/bunRuntime"; import * as NetService from "@t3tools/shared/Net"; import { isCommandAvailable } from "@t3tools/shared/shell"; import * as Clock from "effect/Clock"; @@ -91,7 +88,7 @@ const AgentDeviceDaemonFile = Schema.Struct({ const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile)); interface HubProcess { - readonly nodePath: string; + readonly bunPath: string; readonly child: ChildProcessSpawner.ChildProcessHandle; readonly scope: Scope.Closeable; readonly origin: string; @@ -365,7 +362,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const spawnHub = Effect.fn("LocalDeviceHost.spawnHub")(function* ( hubTool: DeviceToolPaths, - nodePath: string, + bunPath: string, ): Effect.fn.Return { yield* reapStaleHub; yield* fs @@ -389,7 +386,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const child = yield* spawner .spawn( ChildProcess.make( - nodePath, + bunPath, [ hubTool.entryPath, "--port", @@ -423,7 +420,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { ), ); const startedAtMillis = yield* Clock.currentTimeMillis; - const hub: HubProcess = { child, scope, origin, startedAtMillis, nodePath }; + const hub: HubProcess = { child, scope, origin, startedAtMillis, bunPath }; yield* Effect.forkIn(observeHubOutput(hub), scope); yield* waitForHttpReady({ baseUrl: origin, @@ -478,7 +475,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { Effect.gen(function* () { const current = yield* Ref.get(runningRef); if (current?.hub.child.pid !== hub.child.pid) return; - const replacement = yield* spawnHub(hubTool, hub.nodePath); + const replacement = yield* spawnHub(hubTool, hub.bunPath); yield* Ref.set(runningRef, { ...current, hub: replacement }); yield* Effect.forkDetach(superviseHub(replacement, hubTool)); }), @@ -501,7 +498,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { */ const startAgentDeviceDaemon = Effect.fn("LocalDeviceHost.startAgentDeviceDaemon")(function* ( agentTool: DeviceToolPaths, - nodePath: string, + bunPath: string, ): Effect.fn.Return { const stateDir = agentDeviceStateDir(path, config.stateDir); yield* fs.makeDirectory(stateDir, { recursive: true }).pipe(Effect.ignore); @@ -542,7 +539,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { // daemon and blocks until it answers. `devices` is the cheapest one. yield* runner .run({ - command: nodePath, + command: bunPath, args: [agentTool.entryPath, "devices", "--json"], env: daemonEnvironment, timeout: Duration.millis(DAEMON_READY_TIMEOUT_MS), @@ -564,12 +561,12 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { }); const stopAgentDeviceDaemon = ( - agentTool: { readonly entryPath: string; readonly nodePath: string } | null, + agentTool: { readonly entryPath: string; readonly bunPath: string } | null, ) => agentTool ? runner .run({ - command: agentTool.nodePath, + command: agentTool.bunPath, args: [ agentTool.entryPath, "daemon", @@ -584,21 +581,22 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { .pipe(Effect.ignore) : Effect.void; - let agentToolRef: { readonly entryPath: string; readonly nodePath: string } | null = null; + let agentToolRef: { readonly entryPath: string; readonly bunPath: string } | null = null; const ensureHubReady = Effect.fn("LocalDeviceHost.ensureHubReady")(function* ( onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect, - ): Effect.fn.Return { + ): Effect.fn.Return { const running = yield* Ref.get(runningRef); if (running) { const alive = yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false)); if (alive) return running; yield* Ref.set(runningRef, null); } - const nodePath = yield* resolveNodeExecutable("Local device support", hostEnvironment).pipe( + const bunPath = yield* resolveBunExecutable("Local device support", hostEnvironment).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.provideService(HostProcessPlatform, hostPlatform), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const installed = yield* isDeviceHubInstalled(config.baseDir).pipe( Effect.provideService(FileSystem.FileSystem, fs), @@ -612,6 +610,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.mapError( (cause) => new DeviceHost.DeviceHostError({ @@ -622,11 +621,11 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { ), ); yield* onPhase("starting"); - const hub = yield* spawnHub(hubTool, nodePath); + const hub = yield* spawnHub(hubTool, bunPath); // Until the hub is in runningRef, nothing else stops it, so publishing it // is part of the guarded step. const next = yield* Effect.gen(function* () { - yield* pruneLocalDeviceTools(config.baseDir, nodePath, "hub").pipe( + yield* pruneLocalDeviceTools(config.baseDir, bunPath, "hub").pipe( Effect.provideService(Path.Path, path), Effect.provideService(ProcessRunner.ProcessRunner, runner), Effect.ignore, @@ -662,7 +661,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { unknown, | DeviceHost.DeviceHostError | DeviceHost.DeviceHostTimeoutError - | NodeRuntimeUnavailableError + | BunRuntimeUnavailableError >, DeviceHost.DeviceHostAgentReady > { @@ -683,6 +682,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.mapError( (cause) => new DeviceHost.DeviceHostError({ @@ -692,10 +692,10 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { }), ), ); - agentToolRef = { entryPath: agentTool.entryPath, nodePath: running.hub.nodePath }; + agentToolRef = { entryPath: agentTool.entryPath, bunPath: running.hub.bunPath }; yield* onPhase("starting"); - const agentDevice = yield* startAgentDeviceDaemon(agentTool, running.hub.nodePath); - yield* pruneLocalDeviceTools(config.baseDir, running.hub.nodePath, "agent").pipe( + const agentDevice = yield* startAgentDeviceDaemon(agentTool, running.hub.bunPath); + yield* pruneLocalDeviceTools(config.baseDir, running.hub.bunPath, "agent").pipe( Effect.provideService(Path.Path, path), Effect.provideService(ProcessRunner.ProcessRunner, runner), Effect.ignore, @@ -749,7 +749,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const toReady = (running: RunningHost): DeviceHost.DeviceHostReady => ({ hub: { origin: running.hub.origin } satisfies DeviceHost.DeviceHubEndpoint, - nodePath: running.hub.nodePath, + bunPath: running.hub.bunPath, run, helpers: running.helpers, }); diff --git a/apps/server/src/device/SshDeviceHost.test.ts b/apps/server/src/device/SshDeviceHost.test.ts index 871f66bae0d6..cb63d2ba7ffc 100644 --- a/apps/server/src/device/SshDeviceHost.test.ts +++ b/apps/server/src/device/SshDeviceHost.test.ts @@ -64,7 +64,7 @@ it.effect("preserves installed status after probes and cleans failed agent activ modes.push(mode); owners.push(/const owner = "([^"]+)"/.exec(script)?.[1] ?? ""); output = JSON.stringify({ - nodePath: "/node", + bunPath: "/bun", platforms: [{ platform: "ios", available: true }], hubPort: 1234, helpers: { serveSimAxSettings: null, serveSimCli: null }, diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts index e1ac55edc3f0..054bb2c2f06a 100644 --- a/apps/server/src/device/SshDeviceHost.ts +++ b/apps/server/src/device/SshDeviceHost.ts @@ -23,10 +23,15 @@ import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as ServerConfig from "../config.ts"; import * as DeviceHost from "./DeviceHost.ts"; -import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./sshDeviceScript.ts"; +import { + quoteRemoteArg, + remoteDeviceEnvironment, + remoteDeviceScript, + remoteDeviceBunCommand, +} from "./sshDeviceScript.ts"; const Probe = Schema.Struct({ - nodePath: Schema.String, + bunPath: Schema.String, tools: Schema.optional(DeviceToolVersions), platforms: Schema.Array(DevicePlatformAvailability), }); @@ -63,9 +68,7 @@ const bootstrap = ( ) => runSshCommand(targetFor(config), { preHostArgs: identityArgs(config), - remoteCommandArgs: commandArgs( - 'command -v node >/dev/null 2>&1 || { echo "Node is missing from the non-interactive SSH PATH" >&2; exit 1; }; exec node', - ), + remoteCommandArgs: commandArgs(remoteDeviceBunCommand), stdin: remoteDeviceScript(owner, mode), timeoutMs: mode === "start" || mode === "agent-start" ? 1_300_000 : 45_000, }).pipe( @@ -275,7 +278,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( Effect.forkIn(scope), ); const next = { - nodePath: remote.nodePath, + bunPath: remote.bunPath, hub: { origin: `http://127.0.0.1:${hubPort}` }, ...(remote.daemonPort !== undefined && remote.token !== undefined && diff --git a/apps/server/src/device/deviceToolMaintenance.test.ts b/apps/server/src/device/deviceToolMaintenance.test.ts index 67da025672fa..d652845d5e4d 100644 --- a/apps/server/src/device/deviceToolMaintenance.test.ts +++ b/apps/server/src/device/deviceToolMaintenance.test.ts @@ -12,6 +12,7 @@ import * as NodeUtil from "node:util"; import { pruneLocalDeviceTools, deviceToolMaintenanceScript } from "./deviceToolMaintenance.ts"; const exec = NodeUtil.promisify(NodeChildProcess.execFile); +const bunPath = process.env.T3_BUN_EXECUTABLE ?? "bun"; describe.each([false, true])("device tool cleanup, flat=%s", (flat) => { it("keeps current, previous, active and incomplete installs, pruning unused completed versions", async () => { @@ -38,11 +39,7 @@ describe.each([false, true])("device tool cleanup, flat=%s", (flat) => { const root = ${JSON.stringify(root)}; await pruneTools(root, [['${name}', '0.6.0']], ${flat}); })().catch(error => { console.error(error); process.exitCode = 1; });`; - await exec(process.execPath, [ - "-e", - script, - NodePath.join(directory("0.2.0"), "active-helper.cjs"), - ]); + await exec(bunPath, ["-e", script, NodePath.join(directory("0.2.0"), "active-helper.cjs")]); await expect(NodeFSP.stat(directory("0.1.0"))).rejects.toThrow(); await expect(NodeFSP.stat(directory("0.3.0"))).rejects.toThrow(); for (const version of ["0.2.0", "0.4.0", "0.5.0", "0.6.0"]) @@ -62,7 +59,7 @@ describe.each([false, true])("device tool cleanup, flat=%s", (flat) => { await NodeFSP.mkdir(dir, { recursive: true }); await NodeFSP.writeFile(NodePath.join(dir, ".install-complete"), version); } - await exec(process.execPath, [ + await exec(bunPath, [ "-e", deviceToolMaintenanceScript + ` @@ -85,7 +82,7 @@ describe.each([false, true])("device tool cleanup, flat=%s", (flat) => { : NodePath.join(root, "expo-device-hub/0.1.0"); await NodeFSP.mkdir(dir, { recursive: true }); await NodeFSP.writeFile(NodePath.join(dir, ".install-complete"), "0.1.0"); - await exec(process.execPath, [ + await exec(bunPath, [ "-e", deviceToolMaintenanceScript + `pruneTools(${JSON.stringify(root)}, [['expo-device-hub','0.6.0']], ${flat}).catch(() => process.exitCode = 1);`, @@ -150,7 +147,7 @@ it("serializes competing maintenance processes after reclaiming a stale lock", a maintenanceFs.unlinkSync(marker); }); })().catch(error => { console.error(error); process.exitCode = 1; });`; - await Promise.all(Array.from({ length: 6 }, () => exec(process.execPath, ["-e", script]))); + await Promise.all(Array.from({ length: 6 }, () => exec(bunPath, ["-e", script]))); await expect(NodeFSP.stat(lock)).rejects.toThrow(); expect(await NodeFSP.readdir(root)).toEqual([]); } finally { diff --git a/apps/server/src/device/deviceToolMaintenance.ts b/apps/server/src/device/deviceToolMaintenance.ts index 0d7d61a18d59..083b91214f29 100644 --- a/apps/server/src/device/deviceToolMaintenance.ts +++ b/apps/server/src/device/deviceToolMaintenance.ts @@ -111,14 +111,14 @@ class DeviceToolMaintenanceError extends Schema.TaggedError Effect.gen(function* () { @@ -47,6 +53,37 @@ it.effect("preserves shell metacharacters and newlines in remote arguments", () }), ); +it("runs the remote stdin bootstrap using Bun in its default install location", async () => { + const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-ssh-bun-path-")); + try { + const bin = NodePath.join(home, ".bun/bin"); + await NodeFSP.mkdir(bin, { recursive: true }); + await NodeFSP.symlink( + (await exec("which", [bunPath])).stdout.trim(), + NodePath.join(bin, "bun"), + ); + const pending = exec("/bin/sh", ["-c", remoteDeviceEnvironment + remoteDeviceBunCommand], { + env: { HOME: home, PATH: "/usr/bin:/bin" }, + }); + pending.child.stdin!.end(remoteDeviceScript("probe-only", "probe")); + const result = JSON.parse((await pending).stdout); + expect(result.bunPath).toBe((await exec("which", [bunPath])).stdout.trim()); + expect(result.platforms).toHaveLength(2); + await expect(NodeFSP.stat(NodePath.join(home, ".t3/device/hosts"))).rejects.toThrow(); + } finally { + await NodeFSP.rm(home, { recursive: true, force: true }); + } +}); + +it("rejects an unsupported remote Bun runtime with actionable SSH guidance", async () => { + await expect( + exec(bunPath, [ + "-e", + `Object.defineProperty(process.versions, 'bun', { value: '1.3.14' });\n${remoteDeviceScript("unsupported", "probe")}`, + ]), + ).rejects.toThrow("Bun 1.4.0 or newer is required on the device host"); +}); + describe("remote helper lifecycle", () => { it.effect("reuses its own healthy helpers and stops only its own runtime", () => Effect.gen(function* () { @@ -55,6 +92,10 @@ describe("remote helper lifecycle", () => { const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-remote-script-")); const bin = NodePath.join(home, "bin"); await NodeFSP.mkdir(bin); + await NodeFSP.symlink( + (await exec("which", [bunPath])).stdout.trim(), + NodePath.join(bin, "bun"), + ); await NodeFSP.writeFile(NodePath.join(bin, "adb"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); const root = NodePath.join(home, ".t3/device"); const hubDir = NodePath.join(root, `tools/expo-device-hub@${DEVICE_HUB_VERSION}`); @@ -98,17 +139,28 @@ else { const child=spawn(process.execPath,[path.join(path.dirname(process.argv[1 const file = NodePath.join(home, `${owner}-${mode}-${invocation++}.cjs`); await NodeFSP.writeFile( file, - `const originalKill = process.kill; process.kill = (pid, signal) => { if (signal === 'SIGTERM') require('node:fs').appendFileSync(${JSON.stringify(NodePath.join(home, "stops"))}, pid+'\\n'); return originalKill(pid, signal); };\n` + + `const childProcess = require('node:child_process'); const originalSpawnSync = childProcess.spawnSync; +childProcess.spawnSync = (command, args, options) => { + if (command === process.execPath && args[0] === '--bun' && args[1] === 'install') { + const staging = args[args.indexOf('--cwd') + 1]; + require('node:fs').cpSync(${JSON.stringify(NodePath.join(home, "hub-template"))}, staging, { recursive: true }); + return { status: 0, stdout: '', stderr: '' }; + } + if (command === process.execPath && args[0]?.endsWith('/prebuild-install/bin.js')) return { status: 0, stdout: '', stderr: '' }; + return originalSpawnSync(command, args, options); +}; +const originalKill = process.kill; process.kill = (pid, signal) => { if (signal === 'SIGTERM') require('node:fs').appendFileSync(${JSON.stringify(NodePath.join(home, "stops"))}, pid+'\\n'); return originalKill(pid, signal); };\n` + remoteDeviceScript(owner, mode) .replace(DEVICE_HUB_VERSION, upgraded ? nextHubVersion : DEVICE_HUB_VERSION) .replace(AGENT_DEVICE_VERSION, upgraded ? nextAgentVersion : AGENT_DEVICE_VERSION), ); - const result = await exec(process.execPath, [file], { - env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` }, + const result = await exec(bunPath, [file], { + env: { ...process.env, HOME: home, PATH: `${bin}:/usr/bin:/bin` }, }); return result.stdout ? JSON.parse(result.stdout) : null; }; const inventory = await invoke("one", "probe"); + expect(inventory.bunPath).toBe((await exec("which", [bunPath])).stdout.trim()); expect(inventory.tools.hub.installedVersions).toEqual([DEVICE_HUB_VERSION]); expect(inventory.tools.hub.runningVersion).toBeNull(); expect(inventory.tools.agent.installedVersions).toEqual([AGENT_DEVICE_VERSION]); @@ -118,11 +170,6 @@ else { const child=spawn(process.execPath,[path.join(path.dirname(process.argv[1 await NodeFSP.rm(NodePath.join(hubDir, ".install-complete")); const installLock = hubDir + ".lock"; await NodeFSP.symlink("2147483647:exited-installer", installLock); - await NodeFSP.writeFile( - NodePath.join(bin, "npm"), - `#!${process.execPath}\nconst fs=require('node:fs');const args=process.argv.slice(2);if(args[0]==='--version'){console.log('10.0.0');process.exit(0);}fs.cpSync(${JSON.stringify(template)},args[args.indexOf('--prefix')+1],{recursive:true});`, - { mode: 0o755 }, - ); await NodeFSP.mkdir(NodePath.join(root, "hosts/one"), { recursive: true }); await NodeFSP.writeFile(NodePath.join(root, "hosts/one/fail-start-once"), ""); // Unavailable advisory bookkeeping must not prevent either helper from starting. @@ -205,8 +252,8 @@ else { const child=spawn(process.execPath,[path.join(path.dirname(process.argv[1 upgradedStop, originalScript.replace(AGENT_DEVICE_VERSION, "999.0.0"), ); - await exec(process.execPath, [upgradedStop], { - env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` }, + await exec(bunPath, [upgradedStop], { + env: { ...process.env, HOME: home, PATH: `${bin}:/usr/bin:/bin` }, }); const daemon = JSON.parse( await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"), diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts index de412b5873d5..dba4b77fc1dc 100644 --- a/apps/server/src/device/sshDeviceScript.ts +++ b/apps/server/src/device/sshDeviceScript.ts @@ -1,10 +1,11 @@ +import { BUN_VERSION } from "@t3tools/shared/bunRuntime"; import { deviceToolMaintenanceScript } from "./deviceToolMaintenance.ts"; import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts"; export const quoteRemoteArg = (value: string) => `'${value.replaceAll("'", "'\"'\"'")}'`; -/** Resolve common non-interactive SDK and Node locations without sourcing user shell scripts. */ -export const remoteDeviceEnvironment = `export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" +/** Resolve common non-interactive SDK and Bun locations without sourcing user shell scripts. */ +export const remoteDeviceEnvironment = `export PATH="$HOME/.bun/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" if [ -z "$ANDROID_HOME" ]; then if [ -d "$HOME/Library/Android/sdk" ]; then export ANDROID_HOME="$HOME/Library/Android/sdk"; elif [ -d "$HOME/Android/Sdk" ]; then export ANDROID_HOME="$HOME/Android/Sdk"; fi @@ -18,12 +19,17 @@ fi if [ -n "$JAVA_HOME" ]; then export PATH="$JAVA_HOME/bin:$PATH"; fi `; -/** Node runs this on the host. All paths it returns belong to that host. */ +/** Bun requires an explicit stdin entrypoint; a bare invocation only prints its CLI help. */ +export const remoteDeviceBunCommand = + 'command -v bun >/dev/null 2>&1 || { echo "Bun is missing from the non-interactive SSH PATH. Install Bun and expose ~/.bun/bin or bun on PATH." >&2; exit 1; }; exec bun -'; + +/** Bun runs this on the host. All paths it returns belong to that host. */ export const remoteDeviceScript = ( owner: string, mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop", ) => ` +const bunVersion = ${JSON.stringify(BUN_VERSION)}; const owner = ${JSON.stringify(owner)}; const mode = ${JSON.stringify(mode)}; const hubVersion = ${JSON.stringify(DEVICE_HUB_VERSION)}; @@ -120,8 +126,14 @@ async function install(name, version, entry) { try { if (complete()) return file; staging = fs.mkdtempSync(path.join(path.dirname(dir), '.install-')); - const result = run('npm', ['install', '--prefix', staging, '--no-fund', '--no-audit', name + '@' + version], { timeout: 600000, maxBuffer: 8 * 1024 * 1024 }); + fs.writeFileSync(path.join(staging, 'package.json'), JSON.stringify({ private: true, dependencies: { [name]: version }, trustedDependencies: [] })); + const result = run(process.execPath, ['--bun', 'install', '--cwd', staging, '--production', '--ignore-scripts'], { timeout: 600000, maxBuffer: 8 * 1024 * 1024 }); if (result.status !== 0) throw Error('Installing ' + name + ': ' + (result.error?.message || result.stderr?.slice(-2000))); + if (name === 'expo-device-hub') { + // Preserve the required N-API prebuild step without the package's npm/native-build fallback. + const native = run(process.execPath, [path.join(staging, 'node_modules', 'prebuild-install', 'bin.js'), '-r', 'napi'], { cwd: path.join(staging, 'node_modules', 'node-datachannel'), timeout: 600000, maxBuffer: 8 * 1024 * 1024 }); + if (native.status !== 0) throw Error('Installing native Device streaming support: ' + (native.error?.message || native.stderr?.slice(-2000))); + } if (!fs.existsSync(path.join(staging, 'node_modules', name, entry))) throw Error('Missing installed entry for ' + name); fs.writeFileSync(path.join(staging, '.install-complete'), version); fs.rmSync(dir, { recursive: true, force: true }); @@ -133,6 +145,9 @@ async function install(name, version, entry) { } } (async () => { + const [requiredMajor, requiredMinor] = bunVersion.split('.').map(Number); + const [major, minor] = (process.versions.bun || '').split('.').map(Number); + if (!process.versions.bun || !/^\d+\.\d+\.\d+(?:[-+].*)?$/.test(process.versions.bun) || major < requiredMajor || (major === requiredMajor && minor < requiredMinor)) throw Error('Bun ' + bunVersion + ' or newer is required on the device host. Install the supported version and expose ~/.bun/bin or bun on the non-interactive SSH PATH.'); const ios = process.platform === 'darwin' && run('xcrun', ['simctl', 'help']).status === 0; const android = run('adb', ['version']).status === 0; const platforms = [ @@ -140,9 +155,7 @@ async function install(name, version, entry) { { platform: 'android', available: android, ...(!android ? { reason: 'Android SDK missing. Set ANDROID_HOME or put adb on the SSH PATH.' } : {}) }, ]; if (mode === 'probe') { - if (Number(process.versions.node.split('.')[0]) < 22) throw Error('Node 22 or newer is required on the device host.'); - if (run('npm', ['--version']).status !== 0) throw Error('npm is missing from the non-interactive SSH PATH.'); - console.log(JSON.stringify({ nodePath: process.execPath, platforms, tools: versions() })); return; + console.log(JSON.stringify({ bunPath: process.execPath, platforms, tools: versions() })); return; } fs.mkdirSync(state, { recursive: true, mode: 0o700 }); // Serialize starts and stops for this environment/host owner, including agent startup. @@ -217,7 +230,7 @@ async function install(name, version, entry) { const vendor = path.resolve(path.dirname(hubEntry), '../../vendor/serve-sim/dist'); const optional = file => fs.existsSync(file) ? file : null; await pruneTools(path.join(root, 'tools'), [['expo-device-hub', hubVersion], ...(mode === 'agent-start' ? [['agent-device', agentVersion]] : [])], true).catch(() => {}); - console.log(JSON.stringify({ nodePath: process.execPath, platforms, tools: versions(), hubPort: hub.port, ...agentResult, + console.log(JSON.stringify({ bunPath: process.execPath, platforms, tools: versions(), hubPort: hub.port, ...agentResult, helpers: { serveSimAxSettings: optional(path.join(vendor, 'simax/serve-sim-ax-settings')), serveSimCli: optional(path.join(vendor, 'serve-sim.js')) } })); } finally { releaseHost(); } })().catch(error => { console.error(error.message); process.exitCode = 1; }); diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index bf697229718c..64894e1705a4 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -12,7 +12,7 @@ import * as Path from "effect/Path"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as ServerConfig from "../../../config.ts"; import { ensureAgentDeviceShim } from "../../../device/AgentDeviceShim.ts"; -import { nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; +import { bunRuntimeUnavailableMessage } from "@t3tools/shared/bunRuntime"; import * as DeviceService from "../../../device/DeviceService.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -188,8 +188,8 @@ const handlers = { (error) => new DeviceToolUnavailableError({ reason: - error._tag === "NodeRuntimeUnavailableError" - ? nodeRuntimeUnavailableMessage("Device automation") + error._tag === "BunRuntimeUnavailableError" + ? bunRuntimeUnavailableMessage("Device automation") : "Could not prepare the agent-device launcher.", cause: error, }), diff --git a/apps/server/src/mcp/toolkits/device/tools.ts b/apps/server/src/mcp/toolkits/device/tools.ts index da6828e5c712..bd4d784a08c4 100644 --- a/apps/server/src/mcp/toolkits/device/tools.ts +++ b/apps/server/src/mcp/toolkits/device/tools.ts @@ -11,6 +11,7 @@ import { import * as Schema from "effect/Schema"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as ServerConfig from "../../../config.ts"; import { Tool, Toolkit } from "effect/ai"; @@ -60,7 +61,13 @@ const DeviceOpenTool = Tool.make("device_open", { parameters: DeviceToolOpenInput, success: DeviceToolOpenResult, failure: DeviceToolFailure, - dependencies: [...dependencies, FileSystem.FileSystem, Path.Path, ServerConfig.ServerConfig], + dependencies: [ + ...dependencies, + FileSystem.FileSystem, + Path.Path, + ServerConfig.ServerConfig, + ChildProcessSpawner.ChildProcessSpawner, + ], }) .annotate(Tool.Title, "Open device") .annotate(Tool.Readonly, false) From 268031e57cad7bceb3b0c3e67d7940fbd649d6c5 Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Wed, 7 Oct 2026 15:17:56 +0200 Subject: [PATCH 06/32] fix(distribution): publish packages under the fork namespace --- .github/workflows/release.yml | 4 +-- apps/server/scripts/cli.ts | 16 +++++---- packages/shared/src/legacyCliLauncher.test.ts | 4 +-- packages/shared/src/legacyCliLauncher.ts | 6 ++-- scripts/build-npm-platform-packages.test.ts | 33 ++++++++++++------- scripts/build-npm-platform-packages.ts | 22 ++++++------- 6 files changed, 49 insertions(+), 36 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b203a0ee42b0..2fef757e9a8d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -653,12 +653,12 @@ jobs: run: bun scripts/build-npm-platform-packages.ts --archives-dir release-cli --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages # A dry run of every package first: an auth or scope error here (the - # @t3code org missing, a package without a trusted publisher) fails + # @iglo-tech org missing, a package without a trusted publisher) fails # before anything is live, instead of after some platforms already are. - name: Check npm publish access (dry run) run: | if ! bun apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --dry-run --verbose; then - echo "::error::npm publish --dry-run failed. Make sure the @t3code npm org exists and that t3 and every @t3code/t3- package has a trusted publisher registered for .github/workflows/release.yml (see docs/operations/release.md)." >&2 + echo "::error::npm publish --dry-run failed. Make sure the @iglo-tech npm org exists and that @iglo-tech/iglo-code and every @iglo-tech/iglo-code- package has a trusted publisher registered for .github/workflows/release.yml (see docs/operations/release.md)." >&2 exit 1 fi diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 85d6b7192ae8..d7129f044c1a 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -9,6 +9,10 @@ import * as Path from "effect/Path"; import { Command, Flag } from "effect/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import { + NPM_LAUNCHER_PACKAGE_NAME, + NPM_PLATFORM_PACKAGE_SCOPE, +} from "../../../scripts/build-npm-platform-packages.ts"; import { DEVELOPMENT_ICON_OVERRIDES } from "../../../scripts/lib/brand-assets.ts"; import { findEsmImportsOfExternalPackages } from "../../../scripts/lib/cli-executable-imports.ts"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -207,7 +211,7 @@ const buildExeCmd = Command.make( /** * Publishes the tarballs scripts/build-npm-platform-packages.ts produced: - * every `@t3code/t3-.tgz` first, `t3.tgz` (the launcher) last, so + * every `@iglo-tech/iglo-code-.tgz` first, `@iglo-tech/iglo-code.tgz` (the launcher) last, so * the launcher is never installable before the executables it depends on. * Tarballs rather than directories because `npm publish ` strips the * `node_modules/` the executable loads its native addons from. @@ -231,17 +235,17 @@ const publishCmd = Command.make( // npm runs with cwd set to the packages dir below, so tarball paths are // resolved once here rather than joined twice. const packagesDir = path.resolve(config.packagesDir); - const scopeDir = path.join(packagesDir, "@t3code"); - const launcherTarball = path.join(packagesDir, "t3.tgz"); + const scopeDir = path.join(packagesDir, NPM_PLATFORM_PACKAGE_SCOPE); + const launcherTarball = path.join(packagesDir, `${NPM_LAUNCHER_PACKAGE_NAME}.tgz`); const platformTarballs = (yield* fs .readDirectory(scopeDir) .pipe(Effect.orElseSucceed((): ReadonlyArray => []))) - .filter((entry) => entry.startsWith("t3-") && entry.endsWith(".tgz")) + .filter((entry) => entry.startsWith("iglo-code-") && entry.endsWith(".tgz")) .sort() .map((entry) => path.join(scopeDir, entry)); if (platformTarballs.length === 0) { return yield* new ServerCliBuildAssetMissingError({ - assetPath: path.join(scopeDir, "t3-.tgz"), + assetPath: path.join(scopeDir, "iglo-code-.tgz"), }); } if (!(yield* fs.exists(launcherTarball))) { @@ -270,7 +274,7 @@ const publishCmd = Command.make( }), ).pipe( Command.withDescription( - "Publish the @t3code/t3- tarballs and then the t3 launcher to npm.", + "Publish the @iglo-tech/iglo-code- tarballs and then the fork launcher to npm.", ), ); diff --git a/packages/shared/src/legacyCliLauncher.test.ts b/packages/shared/src/legacyCliLauncher.test.ts index b6630f468a83..d419abd4d542 100644 --- a/packages/shared/src/legacyCliLauncher.test.ts +++ b/packages/shared/src/legacyCliLauncher.test.ts @@ -18,10 +18,10 @@ it.skipIf(hostPlatform === "win32")( "keeps service IPC, arguments, and termination connected", async () => { const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-legacy-launcher-")); - const entry = NodePath.join(root, "node_modules/t3/dist/bin.mjs"); + const entry = NodePath.join(root, "node_modules/@iglo-tech/iglo-code/dist/bin.mjs"); const executable = NodePath.join( root, - `node_modules/@t3code/t3-${hostPlatform}-${hostArch}/t3`, + `node_modules/@iglo-tech/iglo-code-${hostPlatform}-${hostArch}/t3`, ); await NodeFSP.mkdir(NodePath.dirname(entry), { recursive: true }); await NodeFSP.mkdir(NodePath.dirname(executable), { recursive: true }); diff --git a/packages/shared/src/legacyCliLauncher.ts b/packages/shared/src/legacyCliLauncher.ts index b852e2310dba..bab2da987c9b 100644 --- a/packages/shared/src/legacyCliLauncher.ts +++ b/packages/shared/src/legacyCliLauncher.ts @@ -1,9 +1,9 @@ /** - * `dist/bin.mjs` of the `t3` npm package: the entry point boot-service + * `dist/bin.mjs` of the `@iglo-tech/iglo-code` npm package: the entry point boot-service * launchers installed before 0.0.41 run with Node to start a new version they * just npm-installed. It forwards everything (arguments, stdio, the IPC * channel the launcher talks over, signals, exit status) to the platform - * executable in the sibling `@t3code/t3--` package. + * executable in the sibling `@iglo-tech/iglo-code--` package. * * The first server started this way rewrites the service unit to run the * executable directly, so nothing depends on this file after one update. @@ -17,7 +17,7 @@ import { dirname, join } from "node:path"; import { createRequire } from "node:module"; const require = createRequire(import.meta.url); const executableName = process.platform === "win32" ? "t3.exe" : "t3"; -const executable = join(dirname(require.resolve("@t3code/t3-" + process.platform + "-" + process.arch + "/package.json")), executableName); +const executable = join(dirname(require.resolve("@iglo-tech/iglo-code-" + process.platform + "-" + process.arch + "/package.json")), executableName); const ipc = process.send !== undefined; const child = spawn(executable, process.argv.slice(2), { stdio: ipc ? ["inherit", "inherit", "inherit", "ipc"] : "inherit", diff --git a/scripts/build-npm-platform-packages.test.ts b/scripts/build-npm-platform-packages.test.ts index ba45b67f2dc2..9bbebfa472a1 100644 --- a/scripts/build-npm-platform-packages.test.ts +++ b/scripts/build-npm-platform-packages.test.ts @@ -113,17 +113,21 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { // Platform packages in CLI_ARCHIVE_PLATFORM_KEYS order, launcher last. assert.deepStrictEqual( outputs.map((output) => output.name), - ["@t3code/t3-darwin-arm64", "@t3code/t3-linux-x64", "t3"], + [ + "@iglo-tech/iglo-code-darwin-arm64", + "@iglo-tech/iglo-code-linux-x64", + "@iglo-tech/iglo-code", + ], ); for (const output of outputs) { assert.isTrue(yield* fs.exists(output.tarball), output.tarball); } - const linuxDir = path.join(fixture.outputDir, "@t3code/t3-linux-x64"); + const linuxDir = path.join(fixture.outputDir, "@iglo-tech/iglo-code-linux-x64"); const linuxManifest = yield* decodeManifest( yield* fs.readFileString(path.join(linuxDir, "package.json")), ); - assert.equal(linuxManifest.name, "@t3code/t3-linux-x64"); + assert.equal(linuxManifest.name, "@iglo-tech/iglo-code-linux-x64"); assert.equal(linuxManifest.version, VERSION); assert.deepStrictEqual(linuxManifest.os, ["linux"]); assert.deepStrictEqual(linuxManifest.cpu, ["x64"]); @@ -148,44 +152,44 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { // A root README, or npm would display a bundled dependency's. assert.include( yield* fs.readFileString(path.join(linuxDir, "README.md")), - "# @t3code/t3-linux-x64", + "# @iglo-tech/iglo-code-linux-x64", ); assert.isTrue(yield* fs.exists(path.join(linuxDir, "node_modules/node-pty"))); assert.equal(Number((yield* fs.stat(path.join(linuxDir, "t3"))).mode) & 0o111, 0o111); const darwinManifest = yield* decodeManifest( yield* fs.readFileString( - path.join(fixture.outputDir, "@t3code/t3-darwin-arm64/package.json"), + path.join(fixture.outputDir, "@iglo-tech/iglo-code-darwin-arm64/package.json"), ), ); assert.deepStrictEqual(darwinManifest.os, ["darwin"]); assert.deepStrictEqual(darwinManifest.cpu, ["arm64"]); - const launcherDir = path.join(fixture.outputDir, "t3"); + const launcherDir = path.join(fixture.outputDir, "@iglo-tech/iglo-code"); const launcherManifest = yield* decodeManifest( yield* fs.readFileString(path.join(launcherDir, "package.json")), ); - assert.equal(launcherManifest.name, "t3"); + assert.equal(launcherManifest.name, "@iglo-tech/iglo-code"); assert.equal(launcherManifest.version, VERSION); assert.deepStrictEqual(launcherManifest.bin, { t3: "./bin/t3.js" }); assert.deepStrictEqual(launcherManifest.files, ["bin", "dist"]); assert.deepStrictEqual(launcherManifest.optionalDependencies, { - "@t3code/t3-darwin-arm64": VERSION, - "@t3code/t3-linux-x64": VERSION, + "@iglo-tech/iglo-code-darwin-arm64": VERSION, + "@iglo-tech/iglo-code-linux-x64": VERSION, }); assert.isUndefined(launcherManifest.engines); assert.isTrue(yield* fs.exists(path.join(launcherDir, "bin/t3.js"))); // The scratch dirs must not be left behind next to the packages. const outputEntries = yield* fs.readDirectory(fixture.outputDir); - assert.deepStrictEqual(outputEntries.sort(), ["@t3code", "t3", "t3.tgz"]); + assert.deepStrictEqual(outputEntries.sort(), ["@iglo-tech"]); // The tarball is what gets published: it must carry node_modules (which // `npm publish ` would strip) under npm's `package/` root, with the // executable bit intact. const listing = yield* run( "tar", - ["-tzvf", path.join(fixture.outputDir, "@t3code/t3-linux-x64.tgz")], + ["-tzvf", path.join(fixture.outputDir, "@iglo-tech/iglo-code-linux-x64.tgz")], { cwd: fixture.outputDir }, ); assert.equal(listing.exitCode, 0, listing.stderr); @@ -228,7 +232,12 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { yield* fs.makeDirectory(installedLauncher); const unpack = yield* run( "tar", - ["-xf", path.join(fixture.outputDir, "t3.tgz"), "-C", installedLauncher], + [ + "-xf", + path.join(fixture.outputDir, "@iglo-tech/iglo-code.tgz"), + "-C", + installedLauncher, + ], { cwd: fixture.root, }, diff --git a/scripts/build-npm-platform-packages.ts b/scripts/build-npm-platform-packages.ts index 5af26b483f7d..8092997a9371 100644 --- a/scripts/build-npm-platform-packages.ts +++ b/scripts/build-npm-platform-packages.ts @@ -1,18 +1,18 @@ #!/usr/bin/env node /** * Turns the per-platform CLI archives of one release into the npm packages - * behind `npx t3` / `npm i -g t3`: one `@t3code/t3-` package per - * archive holding the archive's contents verbatim, plus the `t3` launcher + * behind `npx @iglo-tech/iglo-code`: one `@iglo-tech/iglo-code-` package per + * archive holding the archive's contents verbatim, plus the fork launcher * that lists them as optionalDependencies and execs the one npm installed. * The bytes a user gets from npm are therefore the release archive's, and * running them needs neither a Node runtime, npm, nor a native build. * * Output layout under `--output-dir`: * - * @t3code/t3-/ archive contents flattened + package.json - * @t3code/t3-.tgz the same tree as an npm tarball - * t3/ launcher: package.json, bin/t3.js, README.md - * t3.tgz the launcher as an npm tarball + * @iglo-tech/iglo-code-/ archive contents flattened + package.json + * @iglo-tech/iglo-code-.tgz the same tree as an npm tarball + * @iglo-tech/iglo-code/ launcher: package.json, bin/t3.js, README.md + * @iglo-tech/iglo-code.tgz the launcher as an npm tarball * * The tarballs are what gets published. `npm publish ` always drops * `node_modules/` (npm-packlist ignores it whatever `files` says, and @@ -39,8 +39,8 @@ import { import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; import serverPackageJson from "../apps/server/package.json" with { type: "json" }; -export const NPM_PLATFORM_PACKAGE_SCOPE = "@t3code"; -export const NPM_LAUNCHER_PACKAGE_NAME = "t3"; +export const NPM_PLATFORM_PACKAGE_SCOPE = "@iglo-tech"; +export const NPM_LAUNCHER_PACKAGE_NAME = "@iglo-tech/iglo-code"; const encodePackageJson = Schema.encodeEffect(fromJsonStringPretty(Schema.Unknown)); @@ -81,7 +81,7 @@ export class NpmPackagesArchiveLayoutError extends Schema.TaggedError npm packages from CLI release archives.", + "Build the t3 launcher and @iglo-tech/iglo-code- npm packages from CLI release archives.", ), ); From a548ee1267b8bf47a59d7311f020b8ec933aeabe Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Wed, 7 Oct 2026 15:21:08 +0200 Subject: [PATCH 07/32] fix(releases): explain unsupported targets and missing fork assets --- apps/server/src/cloud/pinnedRuntime.test.ts | 56 +++++++++++++++++++++ apps/server/src/cloud/pinnedRuntime.ts | 16 ++++-- scripts/build-npm-platform-packages.ts | 11 +--- 3 files changed, 70 insertions(+), 13 deletions(-) diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index 4c75a78ff03f..4bc993c8dc07 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -63,6 +63,62 @@ const extractingRunner = (fs: FileSystem.FileSystem, path: Path.Path, commands: }); it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { + it.effect("rejects unsupported targets without fetching an archive", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-platform-" }); + const requests: string[] = []; + const error = yield* ensurePinnedRuntimeInstalled({ + baseDir, + version, + fs, + path, + platform: "win32", + arch: "x64", + httpClient: releaseHttpClient(yield* validChecksums, requests), + runner: extractingRunner(fs, path), + validate: () => Effect.die("unsupported archives must never validate"), + }).pipe(Effect.flip); + assert.include(error.message, "unsupported target win32-x64"); + assert.include(error.message, "darwin-arm64, linux-arm64, linux-x64"); + assert.deepEqual(requests, []); + }), + ); + + it.effect("reports an unavailable fork release without falling back to upstream", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-unavailable-" }); + const requests: string[] = []; + const error = yield* ensurePinnedRuntimeInstalled({ + baseDir, + version, + fs, + path, + platform: "linux", + arch: "x64", + httpClient: HttpClient.make((request) => { + requests.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status: 404 })), + ); + }), + runner: extractingRunner(fs, path), + validate: () => Effect.die("missing archives must never validate"), + }).pipe(Effect.flip); + assert.include(error.message, "fork release artifact unavailable"); + assert.include( + error.message, + "https://github.com/iglo-tech/iglo.code/releases/download/v1.2.3/SHA256SUMS", + ); + assert.deepEqual(requests, [ + "https://github.com/iglo-tech/iglo.code/releases/download/v1.2.3/SHA256SUMS", + ]); + }), + ); + it.effect("installs the verified release archive as the runtime executable", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 3c04e4960d10..957b28d696e2 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -10,6 +10,7 @@ import * as Semaphore from "effect/Semaphore"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import { + CLI_ARCHIVE_PLATFORM_KEYS, CLI_RELEASE_CHECKSUMS_FILE, cliArchiveFileName, cliArchivePlatformKey, @@ -167,7 +168,16 @@ const fetchReleaseAsset = Effect.fn("cloud.pinned_runtime.fetch_release_asset")( return bytes; }), ), - Effect.mapError((cause) => new PinnedRuntimeInstallError({ step, cause })), + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ + step: + cause.reason._tag === "StatusCodeError" && cause.reason.response.status === 404 + ? `${step} (fork release artifact unavailable at ${url}, HTTP 404)` + : step, + cause, + }), + ), Effect.timeoutOrElse({ duration: PINNED_RUNTIME_INSTALL_TIMEOUT, orElse: () => Effect.fail(new PinnedRuntimeInstallError({ step: `${step} (timed out)` })), @@ -179,7 +189,7 @@ const fetchReleaseAsset = Effect.fn("cloud.pinned_runtime.fetch_release_asset")( * Downloads the release archive for this platform, verifies it against the * release's checksum file, and unpacks it so the executable sits directly in * the staging directory. Only `tar` is required on the host; every supported - * OS ships one that reads gzip and zip. + * OS ships one that reads gzip. */ const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(function* ( input: PinnedRuntimeInstallInput, @@ -189,7 +199,7 @@ const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(fun const platformKey = cliArchivePlatformKey(input.platform, input.arch); if (platformKey === undefined) { return yield* new PinnedRuntimeInstallError({ - step: `selecting a t3 release archive for ${input.platform}-${input.arch}`, + step: `selecting an iglo.code archive for unsupported target ${input.platform}-${input.arch} (supported: ${CLI_ARCHIVE_PLATFORM_KEYS.join(", ")})`, }); } const httpClient = input.httpClient; diff --git a/scripts/build-npm-platform-packages.ts b/scripts/build-npm-platform-packages.ts index 8092997a9371..b26b6ab8e26e 100644 --- a/scripts/build-npm-platform-packages.ts +++ b/scripts/build-npm-platform-packages.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun /** * Turns the per-platform CLI archives of one release into the npm packages * behind `npx @iglo-tech/iglo-code`: one `@iglo-tech/iglo-code-` package per @@ -53,15 +53,6 @@ export class NpmPackagesCommandFailedError extends Schema.TaggedError()( - "NpmPackagesToolMissingError", - { tool: Schema.String, purpose: Schema.String }, -) { - override get message(): string { - return `\`${this.tool}\` is not on PATH; it is needed to ${this.purpose}.`; - } -} - export class NpmPackagesArchivesMissingError extends Schema.TaggedError()( "NpmPackagesArchivesMissingError", { archivesDir: Schema.String, missing: Schema.Array(Schema.String) }, From 33a4b5a9aaab489c97393ff6a0d8d5380f45d5b8 Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Wed, 7 Oct 2026 15:44:47 +0200 Subject: [PATCH 08/32] test(web): exercise Bun environments through a real client --- apps/server/scripts/web-client-regression.ts | 629 ++++++++++++++++++ .../scripts/web-fixtures/device-hub.mjs | 35 + .../scripts/web-fixtures/fake-codex.mjs | 151 +++++ .../scripts/web-fixtures/fake-codex.test.ts | 85 +++ apps/server/scripts/web-fixtures/prepare.ts | 153 +++++ apps/server/scripts/web-fixtures/rpc.ts | 45 ++ 6 files changed, 1098 insertions(+) create mode 100644 apps/server/scripts/web-client-regression.ts create mode 100644 apps/server/scripts/web-fixtures/device-hub.mjs create mode 100644 apps/server/scripts/web-fixtures/fake-codex.mjs create mode 100644 apps/server/scripts/web-fixtures/fake-codex.test.ts create mode 100644 apps/server/scripts/web-fixtures/prepare.ts create mode 100644 apps/server/scripts/web-fixtures/rpc.ts diff --git a/apps/server/scripts/web-client-regression.ts b/apps/server/scripts/web-client-regression.ts new file mode 100644 index 000000000000..febdb9961448 --- /dev/null +++ b/apps/server/scripts/web-client-regression.ts @@ -0,0 +1,629 @@ +// @effect-diagnostics nodeBuiltinImport:off +// Acceptance entry point: isolated processes and a real web client, outside Effect services. +import * as NodeAssert from "node:assert/strict"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeHttp from "node:http"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { chromium, type Locator, type Page } from "playwright-core"; +import { + AuthPairingCredentialResult, + AuthGrantScope, + ExecutionEnvironmentDescriptor, + OrchestrationV2Command, + ORCHESTRATION_V2_WS_METHODS, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + WS_METHODS, + type TerminalAttachStreamEvent, +} from "@t3tools/contracts"; +import { + createEnvironmentFixture, + redactEnvironmentLog, + type EnvironmentFixture, + type EnvironmentSmokeInput, +} from "../../../scripts/lib/environment-smoke.ts"; +import { + installBrowser, + prepareWebDependencies, + releaseProvider, + type WebDependencies, +} from "./web-fixtures/prepare.ts"; +import { requestRpc, withRegressionRpc, type RegressionRpcClient } from "./web-fixtures/rpc.ts"; + +const repoRoot = NodeURL.fileURLToPath(new URL("../../../", import.meta.url)); +const visible = (locator: Locator) => locator.waitFor({ state: "visible", timeout: 30_000 }); +const decodeCommand = Schema.decodeUnknownSync(OrchestrationV2Command); +const decodeDescriptor = Schema.decodeUnknownSync(ExecutionEnvironmentDescriptor); +const decodePairingCredential = Schema.decodeUnknownSync(AuthPairingCredentialResult); +const providerId = ProviderInstanceId.make("codex"); +const authProviderId = ProviderInstanceId.make("fixture_signin"); + +async function milestone(name: string, run: () => Promise) { + process.stdout.write(`${JSON.stringify({ case: name, status: "started" })}\n`); + await run(); + process.stdout.write(`${JSON.stringify({ case: name, status: "passed" })}\n`); +} + +async function pairingUrl(fixture: EnvironmentFixture) { + const credential = decodePairingCredential( + await ( + await fixture.request("/api/auth/pairing-token", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ label: "web regression", scopes: AuthGrantScope.literals }), + }) + ).json(), + ); + const url = new URL("/pair", fixture.origin); + url.searchParams.set("token", credential.credential); + return url.href; +} + +async function configure(fixture: EnvironmentFixture, dependencies: WebDependencies) { + await fixture.pair(); + const descriptor = decodeDescriptor( + await (await fixture.request("/.well-known/t3/environment")).json(), + ); + const projectId = NodeCrypto.randomUUID(); + await fixture.request("/api/projects/mutate", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + type: "project.create", + commandId: NodeCrypto.randomUUID(), + projectId, + title: `Project ${dependencies.owner}`, + workspaceRoot: fixture.workspace, + }), + }); + const threadId = ThreadId.make(NodeCrypto.randomUUID()); + await withRegressionRpc(fixture, async (client) => { + const provider = (signin: boolean) => ({ + driver: ProviderDriverKind.make("codex"), + displayName: signin ? "Fixture sign-in" : "Fixture Codex", + enabled: true, + config: { + setupMode: "existing", + enabled: true, + binaryPath: dependencies.provider, + customModels: ["gpt-5.6-sol"], + }, + environment: [ + { name: "T3_FAKE_CONTROL", value: dependencies.control, sensitive: false }, + { name: "T3_FAKE_OWNER", value: dependencies.owner, sensitive: false }, + { name: "T3_FAKE_AUTH", value: signin ? "signin" : "ready", sensitive: false }, + ], + }); + await requestRpc( + client[WS_METHODS.serverUpdateSettings]({ + patch: { + enableProviderUpdateChecks: false, + defaultModelSelection: { instanceId: providerId, model: "gpt-5.6-sol" }, + providerInstances: { [providerId]: provider(false), [authProviderId]: provider(true) }, + responseStreamingMode: "paragraph", + enableDeviceSupport: false, + enableAgentDeviceAccess: false, + deviceOnboardingCompleted: false, + }, + }), + ); + await requestRpc( + client[WS_METHODS.serverRefreshProviders]({ + instanceId: providerId, + cwd: fixture.workspace, + fresh: true, + }), + ); + await requestRpc( + client[ORCHESTRATION_V2_WS_METHODS.dispatchCommand]( + decodeCommand({ + type: "thread.create", + commandId: NodeCrypto.randomUUID(), + threadId, + projectId, + title: `Thread ${dependencies.owner}`, + modelSelection: { instanceId: providerId, model: "gpt-5.6-sol" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }), + ), + ); + }); + return { descriptor, threadId, projectId, route: `/${descriptor.environmentId}/${threadId}` }; +} + +async function sendMessage(page: Page, text: string) { + const editor = page.getByTestId("composer-editor"); + await visible(editor); + await editor.fill(text); + await page.getByRole("button", { name: "Send message", exact: true }).click(); +} + +async function addSurface(page: Page, name: "Terminal" | "Browser" | "Device") { + const add = page.getByRole("button", { name: "Add panel surface", exact: true }); + const launcher = page.getByLabel("Open a surface", { exact: true }); + if (!(await add.isVisible()) && !(await launcher.isVisible())) { + await page.getByRole("button", { name: "Toggle right panel", exact: true }).click(); + } + if (await add.isVisible()) { + await add.click(); + await page.getByRole("menuitem", { name, exact: true }).click(); + } else { + await launcher.getByRole("button", { name, exact: true }).click(); + } +} + +async function terminalMilestone( + client: RegressionRpcClient, + threadId: ThreadId, + predicate: (event: TerminalAttachStreamEvent) => boolean, +) { + const result = await requestRpc( + client[WS_METHODS.terminalObserve]({ threadId, terminalId: "term-1" }).pipe( + Stream.filter(predicate), + Stream.take(1), + Stream.runHead, + ), + ); + NodeAssert.ok(Option.isSome(result), "The terminal stream must expose the requested milestone."); + return result.value; +} + +function terminalText(event: TerminalAttachStreamEvent) { + return event.type === "snapshot" + ? event.snapshot.history + : event.type === "output" + ? event.data + : ""; +} + +/** Required behavior is asserted in every run; missing dependencies fail explicitly. */ +export async function runWebClientRegression( + input: EnvironmentSmokeInput, + browserExecutable: string, +) { + NodeAssert.ok( + browserExecutable, + "Set T3_WEB_REGRESSION_BROWSER to the pinned chrome-headless-shell executable before running the web regression suite.", + ); + const artifacts = + process.env.T3_WEB_REGRESSION_ARTIFACTS ?? + (await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-web-regression-evidence-"))); + await NodeFSP.mkdir(artifacts, { recursive: true }); + const fixtures: Array = []; + let primaryDependencies: WebDependencies | undefined; + let secondaryDependencies: WebDependencies | undefined; + const website = NodeHttp.createServer((_request, response) => { + response.setHeader("content-type", "text/html"); + response.end( + 'Browser fixture ready

Browser fixture ready

', + ); + }); + await new Promise((resolve, reject) => { + website.once("error", reject); + website.listen(0, "127.0.0.1", resolve); + }); + const address = website.address(); + NodeAssert.ok(address && typeof address !== "string"); + const websiteUrl = `http://127.0.0.1:${address.port}/`; + const browser = await chromium + .launch({ + executablePath: browserExecutable, + headless: true, + chromiumSandbox: false, + }) + .catch(async (error: unknown) => { + await new Promise((resolve) => website.close(() => resolve())); + throw error; + }); + const context = await browser.newContext({ + viewport: { width: 1440, height: 1100 }, + permissions: ["clipboard-read", "clipboard-write"], + }); + // Network snapshots contain cookies and socket tickets. Keep only rendered + // screenshots and action metadata, and pause even those around pairing input. + let tracing = false; + let traceIndex = 0; + const startTrace = async () => { + await context.tracing.start({ screenshots: true, snapshots: false, sources: false }); + tracing = true; + }; + const stopTrace = async () => { + if (!tracing) return; + await context.tracing.stop({ path: NodePath.join(artifacts, `trace-${++traceIndex}.zip`) }); + tracing = false; + }; + const page = await context.newPage(); + const browserErrors: Array = []; + const consoleMessages: Array = []; + let failure: Error | undefined; + let pairingActive = false; + const redact = (text: string) => + redactEnvironmentLog(text).replace(/(wsTicket=)[^\s"'<>]+/g, "$1"); + page.on("pageerror", (error) => browserErrors.push(error.message)); + page.on("console", (message) => + consoleMessages.push(redact(`${message.type()}: ${message.text()}`)), + ); + try { + const primary = await createEnvironmentFixture(input, { + prepare: async (paths) => { + primaryDependencies = await prepareWebDependencies( + paths, + "environment-a", + input.kind === "source" + ? `${NodePath.join(input.repoRoot, "node_modules/.bin")}${NodePath.delimiter}${process.env.PATH ?? ""}` + : "", + ); + return primaryDependencies.env; + }, + }); + fixtures.push(primary); + NodeAssert.ok(primaryDependencies); + const dependencies = primaryDependencies; + const seeded = await configure(primary, dependencies); + const primaryPairing = await pairingUrl(primary); + const secondary = await createEnvironmentFixture(input, { + prepare: async (paths) => { + secondaryDependencies = await prepareWebDependencies( + paths, + "environment-b", + input.kind === "source" + ? `${NodePath.join(input.repoRoot, "node_modules/.bin")}${NodePath.delimiter}${process.env.PATH ?? ""}` + : "", + ); + return { ...secondaryDependencies.env, T3CODE_DEV_ALLOWED_ORIGINS: primary.origin }; + }, + }); + fixtures.push(secondary); + NodeAssert.ok(secondaryDependencies); + const remoteDependencies = secondaryDependencies; + const remote = await configure(secondary, remoteDependencies); + NodeAssert.notEqual(primary.origin, secondary.origin); + NodeAssert.notEqual(seeded.descriptor.environmentId, remote.descriptor.environmentId); + + await milestone("fresh browser pairing and reload persistence", async () => { + pairingActive = true; + const paired = page.waitForResponse( + (response) => + new URL(response.url()).pathname === "/api/auth/browser-session" && + response.request().method() === "POST", + ); + await page.goto(primaryPairing); + NodeAssert.equal((await paired).status(), 200); + await page.goto(new URL(seeded.route, primary.origin).href); + await visible(page.getByTestId("composer-editor")); + pairingActive = false; + NodeAssert.equal( + new URL(page.url()).searchParams.has("token"), + false, + "Pairing credentials must leave the visible URL.", + ); + await page.reload(); + await visible(page.getByTestId("composer-editor")); + await visible(page.getByText("Project environment-a", { exact: true }).first()); + await visible(page.getByText("Thread environment-a", { exact: true }).first()); + }); + await startTrace(); + + await milestone("streamed text, tool activity, completion and cancellation", async () => { + await sendMessage(page, "Run the controlled streaming turn"); + await visible(page.getByText("Streaming from environment-a", { exact: true })); + await visible(page.getByText("printf fixture-tool", { exact: false }).first()); + await visible(page.getByRole("button", { name: "Stop generation", exact: true })); + await releaseProvider(dependencies); + await visible(page.getByText("Finished from environment-a.", { exact: true })); + await page + .getByRole("button", { name: "Stop generation", exact: true }) + .waitFor({ state: "hidden" }); + await sendMessage(page, "Cancel this controlled turn"); + await visible(page.getByText("Streaming from environment-a", { exact: true }).nth(1)); + await visible(page.getByRole("button", { name: "Stop generation", exact: true })); + await page.getByRole("button", { name: "Stop generation", exact: true }).click(); + await page + .getByRole("button", { name: "Stop generation", exact: true }) + .waitFor({ state: "hidden" }); + const row = page.getByTestId("sidebar-row-card").filter({ hasText: "Thread environment-a" }); + await row.hover(); + await row.getByRole("button", { name: "Settle thread", exact: true }).click(); + await visible(page.getByRole("button", { name: "Un-settle thread", exact: true }).first()); + await page.getByRole("button", { name: "Un-settle thread", exact: true }).first().click(); + }); + + await milestone("terminal input, output, resize, error and exit", async () => { + await addSurface(page, "Terminal"); + const inputField = page.getByLabel("Terminal input").first(); + await visible(inputField); + await withRegressionRpc(primary, async (client) => { + await inputField.pressSequentially("printf 'terminal-io-fixture\\n'"); + await inputField.press("Enter"); + await terminalMilestone(client, seeded.threadId, (event) => + /\r?\nterminal-io-fixture\r?\n/.test(terminalText(event)), + ); + await page.setViewportSize({ width: 1280, height: 900 }); + await requestRpc( + client[WS_METHODS.terminalResize]({ + threadId: seeded.threadId, + terminalId: "term-1", + cols: 91, + rows: 27, + }), + ); + await inputField.pressSequentially("/bin/stty size"); + await inputField.press("Enter"); + await terminalMilestone(client, seeded.threadId, (event) => + /27\s+91/.test(terminalText(event)), + ); + await inputField.pressSequentially("printf 'terminal-error-fixture\\n' >&2; exit 7"); + await inputField.press("Enter"); + const exited = await terminalMilestone( + client, + seeded.threadId, + (event) => + event.type === "exited" || + (event.type === "snapshot" && event.snapshot.status === "exited"), + ); + NodeAssert.equal( + exited.type === "snapshot" + ? exited.snapshot.exitCode + : exited.type === "exited" + ? exited.exitCode + : null, + 7, + ); + const snapshot = await terminalMilestone( + client, + seeded.threadId, + (event) => event.type === "snapshot", + ); + NodeAssert.match(terminalText(snapshot), /\r?\nterminal-error-fixture\r?\n/); + const writeAfterExit = await Effect.runPromiseExit( + client[WS_METHODS.terminalWrite]({ + threadId: seeded.threadId, + terminalId: "term-1", + data: "should fail\r", + }), + ); + NodeAssert.equal( + writeAfterExit._tag, + "Failure", + "A write to an exited terminal must report an error.", + ); + }); + await page.screenshot({ path: NodePath.join(artifacts, "terminal-exited.png") }); + }); + + await milestone("settings persist and controlled provider sign-in URL/error", async () => { + await page.goto(new URL("/settings/general", primary.origin).href); + const updateChecks = page.getByRole("switch", { + name: "Check provider versions", + exact: true, + }); + await visible(updateChecks); + await updateChecks.click(); + await page.reload(); + NodeAssert.equal(await updateChecks.getAttribute("aria-checked"), "true"); + await updateChecks.click(); + await page.goto(new URL("/settings/providers", primary.origin).href); + await visible(page.getByText("Fixture sign-in", { exact: true })); + await page.getByRole("button", { name: "Sign in", exact: true }).last().click(); + await visible(page.getByRole("button", { name: "Copy sign-in link", exact: true })); + await page.getByRole("button", { name: "Copy sign-in link", exact: true }).click(); + NodeAssert.match( + await page.evaluate("navigator.clipboard.readText()"), + /^https:\/\/auth\.fixture\.invalid\/authorize\?/, + ); + await withRegressionRpc(primary, async (client) => { + const state = await requestRpc( + client[WS_METHODS.providerAuthStart]({ instanceId: authProviderId }), + ); + NodeAssert.ok(state.flowId); + await requestRpc( + client[WS_METHODS.providerAuthCancel]({ + instanceId: authProviderId, + flowId: state.flowId, + }), + ); + }); + await NodeFSP.writeFile(NodePath.join(dependencies.control, "auth-error"), "fail"); + await page.getByRole("button", { name: "Sign in", exact: true }).last().click(); + await visible(page.getByText("Controlled sign-in failure", { exact: false }).first()); + await NodeFSP.rm(NodePath.join(dependencies.control, "auth-error")); + }); + + await milestone("Browser unavailable state", async () => { + await page.goto(new URL(seeded.route, primary.origin).href); + await addSurface(page, "Browser"); + const url = page.getByPlaceholder("Search or enter URL").first(); + await url.fill(websiteUrl); + await url.press("Enter"); + await visible(page.getByRole("button", { name: "Try again", exact: true }).first()); + await page.screenshot({ path: NodePath.join(artifacts, "browser-unavailable.png") }); + }); + + await installBrowser(dependencies, browserExecutable); + await milestone("disconnect, server restart, reconnect and history deduplication", async () => { + await primary.stop(); + await visible(page.getByText(/Disconnected|Reconnecting|Connecting|offline/i).first()); + await primary.restart(); + await visible(page.getByTestId("composer-editor")); + NodeAssert.equal( + await page.getByText("Finished from environment-a.", { exact: true }).count(), + 1, + ); + NodeAssert.equal( + await page.getByText("Run the controlled streaming turn", { exact: true }).count(), + 1, + ); + await page.reload(); + await visible(page.getByTestId("composer-editor")); + NodeAssert.equal( + await page.getByText("Finished from environment-a.", { exact: true }).count(), + 1, + ); + }); + + await milestone("Browser ready frame and navigation error", async () => { + const url = page.getByPlaceholder("Search or enter URL").first(); + await visible(url); + await url.fill(websiteUrl); + await url.press("Enter"); + await visible(page.getByLabel("Browser page", { exact: true })); + // Inspect the rendered public canvas: the fixture site has a known red background. + await page.waitForFunction(`() => { + const canvas = document.querySelector('canvas[aria-label="Browser page"]'); + if (!canvas || !canvas.width || !canvas.height) return false; + const rgba = canvas + .getContext("2d") + ?.getImageData(Math.floor(canvas.width / 2), Math.floor(canvas.height / 2), 1, 1).data; + return rgba?.[0] === 220 && rgba[1] === 20 && rgba[2] === 60; + }`); + await page.screenshot({ path: NodePath.join(artifacts, "browser-ready.png") }); + await url.fill("http://127.0.0.1:1/"); + await url.press("Enter"); + await visible(page.getByText("This site can't be reached", { exact: true })); + }); + + await milestone("Device unavailable, ready inventory and controlled error", async () => { + await addSurface(page, "Device"); + await visible(page.getByRole("switch", { name: "Enable device hub", exact: true })); + NodeAssert.equal( + await page + .getByRole("switch", { name: "Enable device hub", exact: true }) + .getAttribute("aria-checked"), + "false", + ); + await page.getByRole("switch", { name: "Enable device hub", exact: true }).click(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByRole("button", { name: "Done", exact: true }).click(); + await visible(page.getByRole("button", { name: "Start Fixture Android", exact: true })); + await page.getByRole("button", { name: "Start Fixture Android", exact: true }).click(); + await visible( + page.getByRole("alert").filter({ hasText: "Device fixture-emulator failed to boot" }), + ); + await page.getByRole("button", { name: "Dismiss device error", exact: true }).click(); + await withRegressionRpc(primary, async (client) => { + const state = await requestRpc(client[WS_METHODS.deviceList]({})); + NodeAssert.equal( + state.agentAccessEnabled, + false, + "Manual device testing must leave agent access disabled.", + ); + }); + await page.screenshot({ path: NodePath.join(artifacts, "device-ready.png") }); + }); + + await milestone("distinct origin and multiple environment destination ownership", async () => { + await stopTrace(); + pairingActive = true; + const remotePairing = await pairingUrl(secondary); + await page.goto(new URL("/settings/connections", primary.origin).href); + await page.getByRole("button", { name: "Add environment", exact: true }).first().click(); + const dialog = page.getByRole("dialog"); + await dialog.getByLabel("Host", { exact: true }).fill(remotePairing); + await dialog.getByRole("button", { name: "Add environment", exact: true }).click(); + await dialog.waitFor({ state: "hidden" }); + pairingActive = false; + await startTrace(); + await page.goto(new URL(remote.route, primary.origin).href); + await visible(page.getByText("Project environment-b", { exact: true }).first()); + await sendMessage(page, "Execute only in environment-b"); + await visible(page.getByText("Streaming from environment-b", { exact: true })); + await releaseProvider(remoteDependencies); + await visible(page.getByText("Finished from environment-b.", { exact: true })); + const localHistory = await ( + await primary.request(`/api/orchestration/threads/${seeded.threadId}`) + ).text(); + NodeAssert.doesNotMatch( + localHistory, + /Execute only in environment-b|Streaming from environment-b/, + ); + const remoteHistory = await ( + await secondary.request(`/api/orchestration/threads/${remote.threadId}`) + ).text(); + NodeAssert.match(remoteHistory, /Execute only in environment-b/); + const remoteContext = await browser.newContext(); + try { + const remotePage = await remoteContext.newPage(); + const paired = remotePage.waitForResponse( + (response) => + new URL(response.url()).pathname === "/api/auth/browser-session" && + response.request().method() === "POST", + ); + await remotePage.goto(await pairingUrl(secondary)); + NodeAssert.equal((await paired).status(), 200); + await remotePage.goto(new URL(remote.route, secondary.origin).href); + await visible(remotePage.getByText("Finished from environment-b.", { exact: true })); + NodeAssert.equal(new URL(remotePage.url()).origin, secondary.origin); + } finally { + await remoteContext.close(); + } + }); + NodeAssert.deepEqual( + browserErrors, + [], + "The real client must not throw unhandled page errors.", + ); + } catch (error) { + if (!pairingActive) + await page + .screenshot({ path: NodePath.join(artifacts, "failure.png") }) + .catch(() => undefined); + await NodeFSP.writeFile(NodePath.join(artifacts, "browser.log"), consoleMessages.join("\n")); + await NodeFSP.writeFile( + NodePath.join(artifacts, "server.log"), + fixtures.map((fixture) => fixture.log).join("\n"), + ); + process.stderr.write(`Web regression failed; evidence: ${artifacts}\n`); + failure = new Error(redact(error instanceof Error ? error.message : String(error))); + } finally { + const cleanup = await Promise.allSettled([ + (async () => { + try { + await stopTrace(); + } finally { + await browser.close(); + } + })(), + new Promise((resolve, reject) => + website.close((error) => (error ? reject(error) : resolve())), + ), + ...fixtures.map((fixture) => fixture.dispose()), + ]); + for (const result of cleanup) { + if (result.status === "rejected" && failure === undefined) { + failure = new Error(`Acceptance fixture cleanup failed: ${redact(String(result.reason))}`); + } + } + } + if (failure !== undefined) throw failure; + process.stdout.write(`${JSON.stringify({ status: "passed", artifacts })}\n`); +} + +if ( + process.argv[1] && + NodePath.resolve(process.argv[1]) === NodeURL.fileURLToPath(import.meta.url) +) { + const [mode, archive, expectVersion] = process.argv.slice(2); + NodeAssert.ok( + mode === "source" || (mode === "archive" && archive && expectVersion), + "Usage: bun apps/server/scripts/web-client-regression.ts source | archive ", + ); + const input: EnvironmentSmokeInput = + mode === "source" + ? { kind: "source", repoRoot, bun: process.execPath } + : { kind: "archive", archive: NodePath.resolve(archive!), expectVersion: expectVersion! }; + await runWebClientRegression(input, process.env.T3_WEB_REGRESSION_BROWSER ?? ""); +} diff --git a/apps/server/scripts/web-fixtures/device-hub.mjs b/apps/server/scripts/web-fixtures/device-hub.mjs new file mode 100644 index 000000000000..07bf51985afe --- /dev/null +++ b/apps/server/scripts/web-fixtures/device-hub.mjs @@ -0,0 +1,35 @@ +// A pinned-package fixture implementing the hub's public HTTP boundary. +import * as NodeHttp from "node:http"; +import * as NodeFS from "node:fs"; +const port = Number(process.argv[process.argv.indexOf("--port") + 1]); +NodeHttp.createServer((request, response) => { + const mode = NodeFS.readFileSync(process.env.T3_FAKE_DEVICE_MODE, "utf8").trim(); + response.setHeader("content-type", "application/json"); + if (request.url === "/readyz") return response.end("{}"); + if (request.url === "/api/devices") { + if (mode === "error") { + response.statusCode = 503; + return response.end(JSON.stringify({ error: "Controlled device inventory failure" })); + } + return response.end( + JSON.stringify({ + simulators: [], + emulators: + mode === "empty" + ? [] + : [ + { + id: "fixture-emulator", + name: "Fixture Android", + version: "35", + platform: "android", + booted: false, + physical: false, + }, + ], + }), + ); + } + response.statusCode = request.url === "/api/devices/boot" ? 200 : 500; + response.end(JSON.stringify({ ok: false, error: "Controlled device boot failure" })); +}).listen(port, "127.0.0.1"); diff --git a/apps/server/scripts/web-fixtures/fake-codex.mjs b/apps/server/scripts/web-fixtures/fake-codex.mjs new file mode 100644 index 000000000000..2adbb49e8c78 --- /dev/null +++ b/apps/server/scripts/web-fixtures/fake-codex.mjs @@ -0,0 +1,151 @@ +// External Codex app-server fixture. No credentials, network calls or server internals. +import * as NodeFS from "node:fs"; +import * as NodeReadline from "node:readline"; +import * as NodeCrypto from "node:crypto"; + +if (process.argv.includes("--version")) { + console.log("codex-cli 0.145.0"); + process.exit(0); +} +if (process.argv.includes("--help")) process.exit(0); +const control = process.env.T3_FAKE_CONTROL; +const owner = process.env.T3_FAKE_OWNER ?? "fixture"; +const captured = JSON.parse( + NodeFS.readFileSync( + process.env.T3_FAKE_CAPTURE ?? + new URL("../../src/provider/testFixtures/codexMultiAgentWire.json", import.meta.url), + "utf8", + ), +); +const send = (message) => process.stdout.write(`${JSON.stringify(message)}\n`); +let threadId = NodeCrypto.randomUUID(); +let cwd = process.cwd(); +let active; +let sequence = 0; +const notify = (method, params) => send({ jsonrpc: "2.0", method, params }); +const turnEvent = (method, extra) => + notify(method, { + threadId, + turnId: active.turn.id, + ...(method === "item/started" ? { startedAtMs: Date.now() } : {}), + ...(method === "item/completed" ? { completedAtMs: Date.now() } : {}), + ...extra, + }); +const complete = (status) => { + if (!active) return; + if (status === "completed") { + turnEvent("item/agentMessage/delta", { + itemId: active.message.id, + delta: `Finished from ${owner}.`, + }); + turnEvent("item/completed", { + item: { ...active.message, text: `Streaming from ${owner}\n\nFinished from ${owner}.` }, + }); + } + notify("turn/completed", { + threadId, + turn: { ...active.turn, status, completedAt: Math.floor(Date.now() / 1000) }, + }); + active = undefined; +}; +process.on("SIGUSR1", () => complete("completed")); +const rl = NodeReadline.createInterface({ input: process.stdin }); +rl.on("line", (line) => { + const { id, method, params = {} } = JSON.parse(line); + const reply = (result) => send({ id, result }); + if (method === "initialize") + return reply({ + userAgent: "t3-web-regression/1.0", + codexHome: control, + platformFamily: "unix", + platformOs: process.env.T3_FAKE_PLATFORM ?? "linux", + }); + if (method === "account/read") + return reply({ + account: process.env.T3_FAKE_AUTH === "signin" ? null : { type: "apiKey" }, + requiresOpenaiAuth: process.env.T3_FAKE_AUTH === "signin", + }); + if (method === "account/login/start") { + if (NodeFS.existsSync(`${control}/auth-error`)) + return send({ id, error: { code: -32000, message: "Controlled sign-in failure" } }); + return reply({ + type: "chatgpt", + authUrl: "https://auth.fixture.invalid/authorize?fixture=web-regression", + loginId: NodeCrypto.randomUUID(), + }); + } + if (method === "account/rateLimits/read") + return send({ id, error: { code: -32000, message: "Fixture has no billing" } }); + if (method === "skills/list" || method === "model/list") return reply({ data: [] }); + if (method === "thread/start" || method === "thread/resume") { + threadId = params.threadId ?? threadId; + cwd = params.cwd ?? cwd; + const template = captured.responses.threadStart; + return reply({ + ...template, + cwd, + runtimeWorkspaceRoots: [cwd], + thread: { ...template.thread, id: threadId, sessionId: threadId, cwd }, + }); + } + if (method === "turn/start") { + sequence += 1; + const turn = { ...captured.responses.turnStart.turn, id: NodeCrypto.randomUUID() }; + const message = { + type: "agentMessage", + id: `message-${sequence}`, + text: "", + phase: "final_answer", + memoryCitation: null, + }; + active = { turn, message }; + if (control) + NodeFS.writeFileSync( + `${control}/active-provider.json`, + JSON.stringify({ pid: process.pid, owner }), + ); + reply({ turn }); + notify("turn/started", { threadId, turn }); + turnEvent("item/started", { item: message }); + turnEvent("item/agentMessage/delta", { + itemId: message.id, + delta: `Streaming from ${owner}\n\n`, + }); + const tool = { + type: "commandExecution", + id: `tool-${sequence}`, + command: "printf fixture-tool", + cwd, + processId: "fixture-process", + status: "completed", + commandActions: [], + aggregatedOutput: `fixture-tool in ${owner}`, + exitCode: 0, + durationMs: 1, + }; + turnEvent("item/started", { + item: { + ...tool, + status: "inProgress", + aggregatedOutput: null, + exitCode: null, + durationMs: null, + }, + }); + turnEvent("item/commandExecution/outputDelta", { + itemId: tool.id, + delta: `fixture-tool in ${owner}`, + }); + turnEvent("item/completed", { item: tool }); + return; + } + if (method === "turn/interrupt") { + reply({}); + complete("interrupted"); + return; + } + if (id !== undefined) reply({}); +}); +rl.on("close", () => { + process.exit(0); +}); diff --git a/apps/server/scripts/web-fixtures/fake-codex.test.ts b/apps/server/scripts/web-fixtures/fake-codex.test.ts new file mode 100644 index 000000000000..f5450c9d50b6 --- /dev/null +++ b/apps/server/scripts/web-fixtures/fake-codex.test.ts @@ -0,0 +1,85 @@ +// @effect-diagnostics nodeBuiltinImport:off globalTimers:off -- External process deadline, never a domain polling delay. +// These fixtures are external subprocesses, rather than mocks of server services. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeReadline from "node:readline"; +import * as NodeURL from "node:url"; +import { expect, test } from "@effect/vitest"; +import * as Schema from "effect/Schema"; +import { ServerNotification } from "effect-codex-app-server/schema"; + +const decodeNotification = Schema.decodeUnknownSync(ServerNotification); + +test("the controlled provider streams until released and acknowledges cancellation", async () => { + const control = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-fake-codex-")); + const child = NodeChildProcess.spawn( + process.env.BUN_EXECUTABLE ?? "bun", + [NodeURL.fileURLToPath(new URL("fake-codex.mjs", import.meta.url))], + { + env: { ...process.env, T3_FAKE_CONTROL: control, T3_FAKE_OWNER: "environment-a" }, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + const lines = NodeReadline.createInterface({ input: child.stdout }); + const messages: Array> = []; + const waiting = new Set<() => void>(); + lines.on("line", (line) => { + messages.push(JSON.parse(line)); + for (const notify of waiting) notify(); + }); + const until = (predicate: (message: Record) => boolean) => + new Promise>((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error(`Missing protocol milestone: ${JSON.stringify(messages)}`)), + 5000, + ); + const inspect = () => { + const found = messages.find(predicate); + if (!found) return; + clearTimeout(timeout); + waiting.delete(inspect); + resolve(found); + }; + waiting.add(inspect); + inspect(); + }); + const send = (id: number, method: string, params = {}) => + child.stdin.write(`${JSON.stringify({ id, method, params })}\n`); + try { + send(1, "initialize"); + expect((await until((message) => message.id === 1)).result).toMatchObject({ + userAgent: "t3-web-regression/1.0", + }); + send(2, "thread/start"); + await until((message) => message.id === 2); + send(3, "turn/start"); + expect( + (await until((message) => message.method === "item/agentMessage/delta")).params, + ).toMatchObject({ delta: "Streaming from environment-a\n\n" }); + expect(messages.some((message) => message.method === "turn/completed")).toBe(false); + child.kill("SIGUSR1"); + expect((await until((message) => message.method === "turn/completed")).params).toMatchObject({ + turn: { status: "completed" }, + }); + for (const message of messages.filter((message) => message.method !== undefined)) { + expect(() => decodeNotification(message)).not.toThrow(); + } + messages.length = 0; + send(4, "turn/start"); + await until((message) => message.method === "item/agentMessage/delta"); + send(5, "turn/interrupt"); + expect((await until((message) => message.method === "turn/completed")).params).toMatchObject({ + turn: { status: "interrupted" }, + }); + } finally { + if (child.exitCode === null && child.signalCode === null) { + const exited = new Promise((resolve) => child.once("exit", () => resolve())); + child.kill(); + await exited; + } + lines.close(); + await NodeFSP.rm(control, { recursive: true, force: true }); + } +}); diff --git a/apps/server/scripts/web-fixtures/prepare.ts b/apps/server/scripts/web-fixtures/prepare.ts new file mode 100644 index 000000000000..175ffe4a13cc --- /dev/null +++ b/apps/server/scripts/web-fixtures/prepare.ts @@ -0,0 +1,153 @@ +// @effect-diagnostics nodeBuiltinImport:off +// Disposable dependency installations are an external acceptance fixture. +import * as NodeAssert from "node:assert/strict"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { previewBrowserRelease } from "../../src/preview/PreviewBrowser.ts"; +import { DEVICE_HUB_VERSION } from "../../src/device/DeviceToolchain.ts"; + +const here = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; +export async function prepareWebDependencies( + paths: { + scratch: string; + home: string; + workspace: string; + interpreter: string; + }, + owner: string, + inheritedPath: string, +) { + const control = NodePath.join(paths.scratch, "provider-control"); + const bin = NodePath.join(paths.scratch, "bin"); + await NodeFSP.mkdir(control); + await NodeFSP.mkdir(bin); + const provider = NodePath.join(bin, "codex"); + await NodeFSP.writeFile( + provider, + `#!/bin/sh\nexec ${shellQuote(paths.interpreter)} ${shellQuote(NodePath.join(here, "fake-codex.mjs"))} "$@"\n`, + { mode: 0o755 }, + ); + // Never inspect the operator's actual Apple devices during an acceptance run. + await NodeFSP.writeFile(NodePath.join(bin, "xcrun"), "#!/bin/sh\nexit 1\n", { mode: 0o755 }); + // Git is an independently documented prerequisite, not a JavaScript runtime. + const git = process.env.T3_WEB_REGRESSION_GIT ?? "/usr/bin/git"; + await NodeFSP.symlink(git, NodePath.join(bin, "git")); + const sdk = NodePath.join(paths.scratch, "android-sdk"); + await NodeFSP.mkdir(NodePath.join(sdk, "platform-tools"), { recursive: true }); + await NodeFSP.mkdir(NodePath.join(sdk, "emulator")); + await NodeFSP.writeFile( + NodePath.join(sdk, "platform-tools", "adb"), + "#!/bin/sh\nprintf 'List of devices attached\\n'\n", + { mode: 0o755 }, + ); + await NodeFSP.writeFile( + NodePath.join(sdk, "emulator", "emulator"), + "#!/bin/sh\nprintf 'fixture-emulator\\n'\n", + { mode: 0o755 }, + ); + const hub = NodePath.join(paths.home, "tools", "expo-device-hub", DEVICE_HUB_VERSION); + const entry = NodePath.join(hub, "node_modules", "expo-device-hub", "dist", "server", "cli.mjs"); + await NodeFSP.mkdir(NodePath.dirname(entry), { recursive: true }); + await NodeFSP.copyFile(NodePath.join(here, "device-hub.mjs"), entry); + await NodeFSP.writeFile(NodePath.join(hub, ".install-complete"), `${DEVICE_HUB_VERSION}\n`); + const deviceMode = NodePath.join(paths.scratch, "device-mode"); + await NodeFSP.writeFile(deviceMode, "ready"); + const platform = HostProcessPlatform.defaultValue(); + const release = previewBrowserRelease(platform, HostProcessArchitecture.defaultValue()); + NodeAssert.ok(release, "This acceptance runner needs a supported Chromium platform."); + const browserRoot = NodePath.join( + paths.home, + "tools", + "chrome-headless-shell", + release.platform, + release.version, + ); + await NodeFSP.mkdir(browserRoot, { recursive: true }); + const browserExecutable = NodePath.join( + browserRoot, + platform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell", + ); + // A complete but failing local installation exercises the real unavailable UI, + // without relying on internet access or a download failure. + await NodeFSP.writeFile( + browserExecutable, + "#!/bin/sh\nprintf 'No usable sandbox! Controlled browser launch failure\\n' >&2\nexit 70\n", + { mode: 0o755 }, + ); + await NodeFSP.writeFile(NodePath.join(paths.workspace, "environment-owner.txt"), owner); + NodeChildProcess.execFileSync(git, ["init", "--initial-branch=main", paths.workspace], { + stdio: "pipe", + }); + NodeChildProcess.execFileSync(git, [ + "-C", + paths.workspace, + "config", + "user.name", + "Web regression fixture", + ]); + NodeChildProcess.execFileSync(git, [ + "-C", + paths.workspace, + "config", + "user.email", + "fixture@example.invalid", + ]); + NodeChildProcess.execFileSync(git, ["-C", paths.workspace, "add", "environment-owner.txt"]); + NodeChildProcess.execFileSync( + git, + ["-C", paths.workspace, "commit", "--message=Initialize regression workspace"], + { stdio: "pipe" }, + ); + return { + env: { + PATH: `${bin}${inheritedPath ? `${NodePath.delimiter}${inheritedPath}` : ""}`, + SHELL: "/bin/sh", + T3_BUN_EXECUTABLE: paths.interpreter, + ANDROID_HOME: sdk, + ANDROID_SDK_ROOT: sdk, + T3_FAKE_DEVICE_MODE: deviceMode, + T3CODE_SERVER_BROWSER_SANDBOX: "0", + // This is solely the fixture's provider configuration, not application state. + T3_WEB_FIXTURE_BIN: bin, + }, + provider, + control, + owner, + bin, + browserRoot, + browserExecutable, + deviceMode, + }; +} + +export type WebDependencies = Awaited>; + +export async function releaseProvider(dependencies: WebDependencies) { + const record: unknown = JSON.parse( + await NodeFSP.readFile(NodePath.join(dependencies.control, "active-provider.json"), "utf8"), + ); + NodeAssert.ok( + typeof record === "object" && + record !== null && + "owner" in record && + record.owner === dependencies.owner && + "pid" in record && + typeof record.pid === "number", + ); + // The PID was recorded by the fixture child at its stream-start milestone. + process.kill(record.pid, "SIGUSR1"); +} + +export async function installBrowser(dependencies: WebDependencies, executable: string) { + NodeAssert.ok( + NodePath.isAbsolute(executable), + "T3_WEB_REGRESSION_BROWSER must be an absolute chrome-headless-shell NodePath.", + ); + await NodeFSP.rm(dependencies.browserRoot, { recursive: true, force: true }); + await NodeFSP.cp(NodePath.dirname(executable), dependencies.browserRoot, { recursive: true }); + NodeAssert.ok((await NodeFSP.stat(dependencies.browserExecutable)).isFile()); +} diff --git a/apps/server/scripts/web-fixtures/rpc.ts b/apps/server/scripts/web-fixtures/rpc.ts new file mode 100644 index 000000000000..1ead8c5bc171 --- /dev/null +++ b/apps/server/scripts/web-fixtures/rpc.ts @@ -0,0 +1,45 @@ +import { AuthWebSocketTicketResult, WsRpcGroup } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Schedule from "effect/Schedule"; +import * as RpcClient from "effect/rpc/RpcClient"; +import * as RpcSerialization from "effect/rpc/RpcSerialization"; +import * as Socket from "effect/socket/Socket"; +import type { EnvironmentFixture } from "../../../../scripts/lib/environment-smoke.ts"; + +const makeClient = RpcClient.make(WsRpcGroup); +const decodeTicket = Schema.decodeUnknownSync(AuthWebSocketTicketResult); +export type RegressionRpcClient = Effect.Success; + +/** The same authenticated transport the web client uses, with a scoped socket. */ +export async function withRegressionRpc( + fixture: EnvironmentFixture, + use: (client: RegressionRpcClient) => Promise, +) { + const ticket = decodeTicket( + await (await fixture.request("/api/auth/websocket-ticket", { method: "POST" })).json(), + ); + const url = new URL("/ws", fixture.origin); + url.protocol = "ws:"; + url.searchParams.set("wsTicket", ticket.ticket); + url.searchParams.set("orchestrationProtocol", "2"); + const socket = Socket.layerWebSocket(url.href).pipe( + Layer.provide(Layer.succeed(Socket.WebSocketConstructor, (url) => new WebSocket(url))), + ); + const protocol = Layer.effect( + RpcClient.Protocol, + RpcClient.makeProtocolSocket({ retryTransientErrors: false, retryPolicy: Schedule.recurs(0) }), + ).pipe(Layer.provide(Layer.mergeAll(socket, RpcSerialization.layerJson))); + return Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const client = yield* makeClient; + return yield* Effect.promise(() => use(client)); + }), + ).pipe(Effect.provide(protocol)), + ); +} + +export const requestRpc = (request: Effect.Effect) => + Effect.runPromise(request.pipe(Effect.timeout("30 seconds"))); From f8c441ed88fa95ae2ec3ade0a451e60eb3546982 Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Wed, 7 Oct 2026 16:35:01 +0200 Subject: [PATCH 09/32] fix(runtime): normalize SQLite boolean bindings under Bun --- apps/server/src/auth/authRuntime.test.ts | 23 +++++++ .../src/auth/testing/authRuntime.fixture.ts | 62 +++++++++++++++++++ packages/shared/src/nodeSqliteClient.ts | 16 +++-- .../src/testing/sqliteRuntime.fixture.ts | 16 +++++ 4 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/auth/authRuntime.test.ts create mode 100644 apps/server/src/auth/testing/authRuntime.fixture.ts diff --git a/apps/server/src/auth/authRuntime.test.ts b/apps/server/src/auth/authRuntime.test.ts new file mode 100644 index 000000000000..64e4e32e9065 --- /dev/null +++ b/apps/server/src/auth/authRuntime.test.ts @@ -0,0 +1,23 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Exercises auth under the actual Bun runtime. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import { expect, it } from "@effect/vitest"; + +it("pairs and persists browser and scoped OAuth sessions under actual Bun", async () => { + const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-bun-auth-")); + try { + const child = NodeChildProcess.spawnSync( + process.env.T3_BUN_EXECUTABLE ?? "bun", + [NodeURL.fileURLToPath(new URL("./testing/authRuntime.fixture.ts", import.meta.url)), home], + { encoding: "utf8", timeout: 15_000 }, + ); + expect(child.error).toBeUndefined(); + expect(child.status, child.stderr).toBe(0); + expect(child.stdout).toContain("paired and persisted browser and scoped OAuth sessions\n"); + } finally { + await NodeFSP.rm(home, { recursive: true, force: true }); + } +}); diff --git a/apps/server/src/auth/testing/authRuntime.fixture.ts b/apps/server/src/auth/testing/authRuntime.fixture.ts new file mode 100644 index 000000000000..76e5c03580aa --- /dev/null +++ b/apps/server/src/auth/testing/authRuntime.fixture.ts @@ -0,0 +1,62 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Verifies pairing through actual Bun SQLite bindings. +import * as NodeAssert from "node:assert/strict"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ServerConfig from "../../config.ts"; +import * as EnvironmentAuth from "../EnvironmentAuth.ts"; +import * as SessionStore from "../SessionStore.ts"; + +const services = EnvironmentAuth.layerRuntime.pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), process.argv[2]!)), + Layer.provide(NodeServices.layer), +); +const metadata = { + deviceType: "desktop" as const, + os: "fixture", + browser: "fixture", + ipAddress: "127.0.0.1", +}; + +const issued = await Effect.runPromise( + Effect.gen(function* () { + const auth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const pairing = yield* auth.issuePairingCredential({ scopes: ["orchestration:read"] }); + const exchange = yield* auth.createBrowserSession(pairing.credential, metadata); + const session = yield* sessions.verify(exchange.sessionToken); + NodeAssert.deepEqual(session.scopes, ["orchestration:read"]); + const oauthPairing = yield* auth.issuePairingCredential({ scopes: ["orchestration:read"] }); + const denied = yield* auth + .exchangeBootstrapCredentialForAccessToken( + oauthPairing.credential, + ["access:write"], + metadata, + ) + .pipe(Effect.flip); + NodeAssert.equal(denied._tag, "ServerAuthScopeNotGrantedError"); + const oauth = yield* auth.exchangeBootstrapCredentialForAccessToken( + oauthPairing.credential, + ["orchestration:read"], + metadata, + ); + NodeAssert.equal(oauth.scope, "orchestration:read"); + return { browserToken: exchange.sessionToken, oauthToken: oauth.access_token, pairing }; + }).pipe(Effect.provide(services), Effect.scoped), +); + +await Effect.runPromise( + Effect.gen(function* () { + const auth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + for (const token of [issued.browserToken, issued.oauthToken]) { + const session = yield* sessions.verify(token); + NodeAssert.deepEqual(session.scopes, ["orchestration:read"]); + } + const consumed = yield* auth + .createBrowserSession(issued.pairing.credential, metadata) + .pipe(Effect.flip); + NodeAssert.equal(consumed._tag, "ServerAuthInvalidCredentialError"); + }).pipe(Effect.provide(services), Effect.scoped), +); +process.stdout.write("paired and persisted browser and scoped OAuth sessions\n"); diff --git a/packages/shared/src/nodeSqliteClient.ts b/packages/shared/src/nodeSqliteClient.ts index d6d37364cf4f..67148c05b7b1 100644 --- a/packages/shared/src/nodeSqliteClient.ts +++ b/packages/shared/src/nodeSqliteClient.ts @@ -111,6 +111,10 @@ const make = Effect.fn("makeWithDatabase")(function* ( ); const statementReaderCache = new WeakMap(); + // Node accepts booleans as SQLite integers; Bun's node:sqlite binding rejects + // them. Normalize here so every execution/result mode has the same semantics. + const bindParameters = (params: ReadonlyArray) => + params.map((value) => (typeof value === "boolean" ? Number(value) : value)); const hasRows = (statement: NodeSqlite.StatementSync): boolean => { const cached = statementReaderCache.get(statement); if (cached !== undefined) { @@ -146,9 +150,9 @@ const make = Effect.fn("makeWithDatabase")(function* ( try { statement.setReadBigInts(Boolean(Context.get(fiber.context, Client.SafeIntegers))); if (hasRows(statement)) { - return Effect.succeed(statement.all(...(params as any))); + return Effect.succeed(statement.all(...(bindParameters(params) as any))); } - const result = statement.run(...(params as any)); + const result = statement.run(...(bindParameters(params) as any)); return Effect.succeed(raw ? (result as unknown as ReadonlyArray) : []); } catch (cause) { return Effect.fail( @@ -176,11 +180,11 @@ const make = Effect.fn("makeWithDatabase")(function* ( if (hasRows(statement)) { statement.setReturnArrays(true); // Safe to cast to array after we've setReturnArrays(true) - return statement.all(...(params as any)) as unknown as ReadonlyArray< - ReadonlyArray - >; + return statement.all( + ...(bindParameters(params) as any), + ) as unknown as ReadonlyArray>; } - statement.run(...(params as any)); + statement.run(...(bindParameters(params) as any)); return []; }, catch: (cause) => diff --git a/packages/shared/src/testing/sqliteRuntime.fixture.ts b/packages/shared/src/testing/sqliteRuntime.fixture.ts index a1876acd59a7..41c6a6380312 100644 --- a/packages/shared/src/testing/sqliteRuntime.fixture.ts +++ b/packages/shared/src/testing/sqliteRuntime.fixture.ts @@ -13,6 +13,22 @@ const write = Effect.gen(function* () { yield* sql`PRAGMA journal_mode = WAL`; yield* sql`PRAGMA busy_timeout = 0`; yield* sql`CREATE TABLE entries(id INTEGER PRIMARY KEY, name TEXT UNIQUE, large INTEGER)`; + const flags = yield* sql<{ enabled: number; disabled: number }>` + SELECT ${true} AS enabled, ${false} AS disabled + `; + NodeAssert.equal(flags[0]?.enabled, 1); + NodeAssert.equal(flags[0]?.disabled, 0); + NodeAssert.equal( + (yield* sql<{ disabled: number }>`SELECT ${false} AS disabled`.unprepared)[0]?.disabled, + 0, + ); + NodeAssert.equal((yield* sql<{ enabled: number }>`SELECT ${true} AS enabled`.raw)[0]?.enabled, 1); + NodeAssert.deepEqual(yield* sql`SELECT ${true}, ${false}`.values, [[1, 0]]); + NodeAssert.deepEqual(yield* sql`SELECT ${false}`.valuesUnprepared, [[0]]); + yield* sql`CREATE TABLE flags(enabled INTEGER)`; + yield* sql`INSERT INTO flags VALUES (${true})`; + yield* sql`INSERT INTO flags VALUES (${false})`.values; + NodeAssert.deepEqual(yield* sql`SELECT enabled FROM flags ORDER BY enabled`.values, [[0], [1]]); yield* sql`INSERT INTO entries VALUES (1, ${"kept"}, ${value})`; const duplicate = yield* sql`INSERT INTO entries VALUES (2, ${"kept"}, 0)`.pipe(Effect.flip); NodeAssert.equal(duplicate.reason._tag, "UniqueViolation"); From cca65647f4db3a7e9b4579c657354d989d870559 Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Wed, 7 Oct 2026 16:35:55 +0200 Subject: [PATCH 10/32] test(server): verify Bun service lifecycle and rollback --- .../src/serviceLauncher.runtime.test.ts | 409 ++++++++++++++++++ .../src/testUtils/serviceRuntime.child.ts | 84 ++++ .../src/testUtils/serviceRuntime.launcher.ts | 3 + 3 files changed, 496 insertions(+) create mode 100644 apps/server/src/serviceLauncher.runtime.test.ts create mode 100644 apps/server/src/testUtils/serviceRuntime.child.ts create mode 100644 apps/server/src/testUtils/serviceRuntime.launcher.ts diff --git a/apps/server/src/serviceLauncher.runtime.test.ts b/apps/server/src/serviceLauncher.runtime.test.ts new file mode 100644 index 000000000000..104d776e5036 --- /dev/null +++ b/apps/server/src/serviceLauncher.runtime.test.ts @@ -0,0 +1,409 @@ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as NodeReadline from "node:readline"; +import * as NodeSqlite from "node:sqlite"; +import * as NodeURL from "node:url"; +import * as NodeCrypto from "node:crypto"; +import * as NodeHttp from "node:http"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { FetchHttpClient, HttpClient } from "effect/http"; +import { cliArchiveFileName, cliArchivePlatformKey } from "@t3tools/shared/cliRelease"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { afterAll, afterEach, beforeAll, expect } from "vite-plus/test"; +import { it as effectIt } from "@effect/vitest"; +import * as Layer from "effect/Layer"; + +import { readServiceState, writeServiceState } from "./serviceLauncher.ts"; +import { SERVICE_LAUNCHER_PROTOCOL, SERVICE_STOP_MARKER_FILE } from "./cloud/serviceProtocol.ts"; +import { ensurePinnedRuntimeInstalled } from "./cloud/pinnedRuntime.ts"; +import * as ProcessRunner from "./processRunner.ts"; + +// Set T3_SERVICE_TEST_EXECUTABLE to an extracted archive's t3 to exercise its +// public __service-launcher command. Otherwise compile the production launcher. +const fixtureDir = NodeURL.fileURLToPath(new URL("./testUtils/", import.meta.url)); +const bun = NodeChildProcess.execFileSync( + process.env.T3_BUN_EXECUTABLE ?? "bun", + ["--print", "process.execPath"], + { + encoding: "utf8", + }, +).trim(); +let buildDir: string; +let releaseServer: NodeHttp.Server; +let releaseBaseUrl: string; +const releaseFiles = new Map(); +const releaseRequests: string[] = []; +const platform = HostProcessPlatform.defaultValue(); +const arch = HostProcessArchitecture.defaultValue(); +const platformKey = (() => { + const key = cliArchivePlatformKey(platform, arch); + if (!key) + throw new Error(`Service runtime acceptance requires a supported target: ${platform}-${arch}`); + return key; +})(); +const launchers = new Set(); +const homes = new Set(); + +beforeAll(async () => { + buildDir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-bun-service-build-")); + for (const name of ["launcher", "child"]) { + const output = NodePath.join(buildDir, name); + NodeChildProcess.execFileSync(bun, [ + "build", + "--compile", + "--compile-autoload-package-json", + NodePath.join(fixtureDir, `serviceRuntime.${name}.ts`), + "--outfile", + output, + ]); + if (platform === "darwin") + NodeChildProcess.execFileSync("codesign", ["--force", "--sign", "-", output]); + } + for (const mode of ["source", "compiled"] as const) { + for (const version of ["1.0.0", "1.1.0"]) { + const archiveName = cliArchiveFileName(version, platformKey); + const archiveRoot = NodePath.join(buildDir, archiveName.replace(/\.tar\.gz$/, "")); + await NodeFSP.mkdir(archiveRoot, { recursive: true }); + const executable = NodePath.join(archiveRoot, "t3"); + if (mode === "compiled") await NodeFSP.copyFile(NodePath.join(buildDir, "child"), executable); + else + await NodeFSP.writeFile( + executable, + `#!${bun}\nimport ${JSON.stringify(NodePath.join(fixtureDir, "serviceRuntime.child.ts"))};\n`, + ); + await NodeFSP.chmod(executable, 0o755); + const archivePath = NodePath.join(buildDir, `${mode}-${archiveName}`); + NodeChildProcess.execFileSync("tar", [ + "-czf", + archivePath, + "-C", + buildDir, + NodePath.basename(archiveRoot), + ]); + const archive = await NodeFSP.readFile(archivePath); + const digest = NodeCrypto.createHash("sha256").update(archive).digest("hex"); + releaseFiles.set(`/${mode}/v${version}/${archiveName}`, archive); + releaseFiles.set( + `/${mode}/v${version}/SHA256SUMS`, + Buffer.from(`${digest} ${archiveName}\n`), + ); + } + } + releaseServer = NodeHttp.createServer((request, response) => { + releaseRequests.push(request.url ?? ""); + const body = releaseFiles.get(request.url ?? ""); + response.writeHead(body ? 200 : 404); + response.end(body); + }); + await new Promise((resolve, reject) => { + releaseServer.once("listening", resolve); + releaseServer.once("error", reject); + releaseServer.listen(0, "127.0.0.1"); + }); + const address = releaseServer.address(); + if (!address || typeof address === "string") throw new Error("Missing release fixture port"); + releaseBaseUrl = `http://127.0.0.1:${address.port}`; +}); + +afterEach(async () => { + for (const launcher of launchers) { + if (launcher.exitCode === null && launcher.signalCode === null) { + const closed = waitForClose(launcher); + launcher.kill("SIGTERM"); + await closed; + } + } + launchers.clear(); + for (const home of homes) await NodeFSP.rm(home, { recursive: true, force: true }); + homes.clear(); +}); +afterAll(async () => { + if (releaseServer?.listening) + await new Promise((resolve, reject) => + releaseServer.close((error) => (error ? reject(error) : resolve())), + ); + releaseFiles.clear(); + if (buildDir) await NodeFSP.rm(buildDir, { recursive: true, force: true }); +}); + +type RuntimeEvent = { + event: string; + pid: number; + version: string; + bun: string; + dbValue: string; + activeVersion: string; + status: string; + stopMarker: boolean; + wal: boolean; + backup: boolean; +}; + +function isRuntimeEvent(value: unknown): value is RuntimeEvent { + if (typeof value !== "object" || value === null) return false; + return ( + "event" in value && + typeof value.event === "string" && + "pid" in value && + typeof value.pid === "number" && + "version" in value && + typeof value.version === "string" && + "bun" in value && + typeof value.bun === "string" && + "dbValue" in value && + typeof value.dbValue === "string" && + "activeVersion" in value && + typeof value.activeVersion === "string" && + "status" in value && + typeof value.status === "string" && + "stopMarker" in value && + typeof value.stopMarker === "boolean" && + "wal" in value && + typeof value.wal === "boolean" && + "backup" in value && + typeof value.backup === "boolean" + ); +} + +const setup = (mode: "source" | "compiled") => + Effect.gen(function* () { + const home = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-bun-service-home-")), + ); + homes.add(home); + const dbPath = NodePath.join(home, "userdata", "statev2.sqlite"); + yield* Effect.promise(() => NodeFSP.mkdir(NodePath.dirname(dbPath), { recursive: true })); + const db = new NodeSqlite.DatabaseSync(dbPath); + db.exec( + "CREATE TABLE history (value TEXT NOT NULL); INSERT INTO history VALUES ('original history')", + ); + db.close(); + const filesystem = yield* FileSystem.FileSystem; + const paths = yield* Path.Path; + const runner = yield* ProcessRunner.ProcessRunner; + const httpClient = yield* HttpClient.HttpClient; + const hostPlatform = yield* HostProcessPlatform; + const hostArchitecture = yield* HostProcessArchitecture; + for (const version of ["1.0.0", "1.1.0"]) { + const installed = yield* ensurePinnedRuntimeInstalled({ + baseDir: home, + version, + fs: filesystem, + path: paths, + runner, + httpClient, + platform: hostPlatform, + arch: hostArchitecture, + releaseBaseUrl: `${releaseBaseUrl}/${mode}`, + validate: (staged) => + filesystem.stat(staged.entryPath).pipe( + Effect.tap((stat) => Effect.sync(() => expect(stat.type).toBe("File"))), + Effect.asVoid, + Effect.orDie, + ), + }); + expect(yield* filesystem.readFileString(installed.sentinelPath)).toBe(`${version}\n`); + expect(releaseRequests).toContain(`/${mode}/v${version}/SHA256SUMS`); + expect(releaseRequests).toContain( + `/${mode}/v${version}/${cliArchiveFileName(version, platformKey)}`, + ); + } + const statePath = NodePath.join(home, "runtime", "service-state.json"); + yield* Effect.promise(() => + writeServiceState(statePath, { protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "1.0.0" }), + ); + return { home, dbPath, statePath }; + }).pipe( + Effect.provideService(HostProcessPlatform, platform), + Effect.provideService(HostProcessArchitecture, arch), + ); + +function waitForClose(child: NodeChildProcess.ChildProcess) { + return new Promise<[number | null, NodeJS.Signals | null]>((resolve) => { + child.once("close", (code, signal) => resolve([code, signal])); + }); +} + +function start(home: string, mode: "source" | "compiled", scenario = "lifecycle") { + const executable = + mode === "source" + ? bun + : (process.env.T3_SERVICE_TEST_EXECUTABLE ?? NodePath.join(buildDir, "launcher")); + const args = + mode === "source" + ? [NodePath.join(fixtureDir, "serviceRuntime.launcher.ts")] + : process.env.T3_SERVICE_TEST_EXECUTABLE + ? ["__service-launcher"] + : []; + const child = NodeChildProcess.spawn(executable, args, { + env: { ...process.env, PATH: "", T3CODE_HOME: home, T3_SERVICE_TEST_SCENARIO: scenario }, + stdio: ["ignore", "pipe", "pipe"], + }); + launchers.add(child); + let diagnostics = ""; + child.stderr?.on("data", (chunk: Buffer) => { + diagnostics += chunk.toString(); + }); + const events: RuntimeEvent[] = []; + const waiters: Array<{ + event: string; + resolve: (value: RuntimeEvent) => void; + reject: (error: Error) => void; + }> = []; + const closed = waitForClose(child); + child.once("error", (error) => { + diagnostics += error.message; + }); + const lines = NodeReadline.createInterface({ input: child.stdout! }); + lines.on("line", (line) => { + if (!line.startsWith("service-runtime:")) return; + const event: unknown = JSON.parse(line.slice("service-runtime:".length)); + if (!isRuntimeEvent(event)) throw new Error(`Invalid runtime fixture event: ${line}`); + const index = waiters.findIndex((waiter) => waiter.event === event.event); + if (index < 0) events.push(event); + else waiters.splice(index, 1)[0]!.resolve(event); + }); + child.once("close", (code, signal) => { + for (const waiter of waiters.splice(0)) { + waiter.reject( + new Error(`Launcher exited before ${waiter.event}: ${code}/${signal}\n${diagnostics}`), + ); + } + }); + return { + child, + closed, + next(event: string): Promise { + const index = events.findIndex((value) => value.event === event); + if (index >= 0) return Promise.resolve(events.splice(index, 1)[0]!); + if (child.exitCode !== null || child.signalCode !== null) + return Promise.reject(new Error(diagnostics)); + return new Promise((resolve, reject) => { + waiters.push({ event, resolve, reject }); + }); + }, + }; +} + +async function stop(launcher: ReturnType) { + launcher.child.kill("SIGTERM"); + const stopped = await launcher.next("stopped"); + expect(await launcher.closed).toEqual([0, null]); + expect(stopped.stopMarker).toBe(true); + expect(() => process.kill(stopped.pid, 0)).toThrow(); +} + +effectIt.layer( + ProcessRunner.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(FetchHttpClient.layer), + ), +)("real Bun service lifecycle", (it) => { + it.effect.each(["source", "compiled"] as const)( + "%s Bun launcher starts, stops and restarts a real Bun child", + (mode) => + Effect.gen(function* () { + const { home, statePath } = yield* setup(mode); + yield* Effect.promise(async () => { + const first = start(home, mode); + const ready = await first.next("ready"); + expect(ready.bun).toBe("1.4.0"); + expect(ready.dbValue).toBe("original history"); + expect(ready.stopMarker).toBe(false); + await stop(first); + expect( + await NodeFSP.readFile( + NodePath.join(home, "runtime", SERVICE_STOP_MARKER_FILE), + "utf8", + ), + ).toBeDefined(); + const restarted = start(home, mode); + const restored = await restarted.next("ready"); + expect(restored.pid).not.toBe(ready.pid); + expect(restored.dbValue).toBe("original history"); + expect(restored.stopMarker).toBe(false); + await stop(restarted); + expect((await readServiceState(statePath)).activeVersion).toBe("1.0.0"); + }); + }), + ); + + it.effect.each(["source", "compiled"] as const)( + "%s Bun IPC commits an update only after the candidate is prepared", + (mode) => + Effect.gen(function* () { + const { home, dbPath, statePath } = yield* setup(mode); + yield* Effect.promise(async () => { + const launcher = start(home, mode, "commit"); + const previous = await launcher.next("accepted"); + expect(previous.activeVersion).toBe("1.0.0"); + expect(previous.status).toBe("pending"); + const trial = await launcher.next("trial"); + expect(trial.bun).toBe("1.4.0"); + expect(trial.version).toBe("1.1.0"); + expect(trial.activeVersion).toBe("1.0.0"); + expect(trial.status).toBe("pending"); + expect(trial.backup).toBe(true); + const committed = await launcher.next("committed"); + expect(committed.activeVersion).toBe("1.1.0"); + expect(committed.status).toBe("committed"); + expect(committed.dbValue).toBe("migrated history"); + expect(committed.backup).toBe(false); + expect(() => process.kill(previous.pid, 0)).toThrow(); + await stop(launcher); + const state = await readServiceState(statePath); + expect(state.activeVersion).toBe("1.1.0"); + expect(state.update?.status).toBe("committed"); + const db = new NodeSqlite.DatabaseSync(dbPath, { readOnly: true }); + try { + expect(db.prepare("SELECT value FROM history").get()?.value).toBe("migrated history"); + } finally { + db.close(); + } + }); + }), + ); + + it.effect.each(["source", "compiled"] as const)( + "%s Bun launcher restores SQLite and WAL state after a failed update", + (mode) => + Effect.gen(function* () { + const { home, dbPath, statePath } = yield* setup(mode); + yield* Effect.promise(async () => { + const originalDatabase = await NodeFSP.readFile(dbPath); + const launcher = start(home, mode, "rollback"); + const previous = await launcher.next("accepted"); + const failed = await launcher.next("failing"); + expect(failed.version).toBe("1.1.0"); + expect(failed.dbValue).toBe("failed migration"); + expect(failed.wal).toBe(true); + expect(failed.backup).toBe(true); + const restored = await launcher.next("rolled-back"); + expect(restored.bun).toBe("1.4.0"); + expect(restored.version).toBe("1.0.0"); + expect(restored.activeVersion).toBe("1.0.0"); + expect(restored.status).toBe("rolled-back"); + expect(restored.dbValue).toBe("original history"); + expect(restored.wal).toBe(false); + expect(restored.backup).toBe(false); + expect(() => process.kill(previous.pid, 0)).toThrow(); + expect(() => process.kill(failed.pid, 0)).toThrow(); + await stop(launcher); + const state = await readServiceState(statePath); + expect(state.update).toMatchObject({ + status: "rolled-back", + reason: "candidate-exited:23", + }); + expect(await NodeFSP.readFile(dbPath)).toEqual(originalDatabase); + expect(await NodeFSP.readdir(NodePath.join(home, "userdata"))).toEqual([ + "statev2.sqlite", + ]); + }); + }), + ); +}); diff --git a/apps/server/src/testUtils/serviceRuntime.child.ts b/apps/server/src/testUtils/serviceRuntime.child.ts new file mode 100644 index 000000000000..c01581995bb7 --- /dev/null +++ b/apps/server/src/testUtils/serviceRuntime.child.ts @@ -0,0 +1,84 @@ +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; + +import { readServiceState } from "../serviceLauncher.ts"; +import { + decodeServiceLauncherContext, + decodeServiceLauncherParentMessage, + SERVICE_LAUNCHER_CONTEXT_ENV, + SERVICE_STOP_MARKER_FILE, +} from "../cloud/serviceProtocol.ts"; + +const home = process.env.T3CODE_HOME; +const context = decodeServiceLauncherContext(process.env[SERVICE_LAUNCHER_CONTEXT_ENV] ?? ""); +if (!home || !context || !process.send || !process.versions["bun"]) { + throw new Error("Service runtime fixture requires Bun and the launcher's real IPC channel"); +} +const dbPath = NodePath.join(home, "userdata", "statev2.sqlite"); +const db = new NodeSqlite.DatabaseSync(dbPath); +const statePath = NodePath.join(home, "runtime", "service-state.json"); +const scenario = process.env.T3_SERVICE_TEST_SCENARIO ?? "lifecycle"; +if (!["lifecycle", "commit", "rollback"].includes(scenario)) + throw new Error(`Unimplemented service fixture scenario: ${scenario}`); + +async function emit(event: string) { + const state = await readServiceState(statePath); + const row = db.prepare("SELECT value FROM history").get(); + console.log( + `service-runtime:${JSON.stringify({ + event, + pid: process.pid, + version: context?.childVersion, + bun: process.versions["bun"], + dbValue: row?.value, + activeVersion: state.activeVersion, + status: state.update?.status ?? "none", + stopMarker: NodeFS.existsSync(NodePath.join(home!, "runtime", SERVICE_STOP_MARKER_FILE)), + wal: NodeFS.existsSync(`${dbPath}-wal`), + backup: state.update + ? NodeFS.existsSync( + NodePath.join(home!, "runtime", "db-backup", state.update.id, "database"), + ) + : false, + })}`, + ); +} + +process.once("SIGTERM", async () => { + await emit("stopped"); + db.close(); + process.exit(0); +}); +// The fixture represents a long-running server, stopped only by the launcher. +setInterval(() => undefined, 60_000); +process.on("message", async (value: unknown) => { + const message = decodeServiceLauncherParentMessage(value); + if (!message) throw new Error("Invalid launcher IPC message"); + if (message.type === "update-rejected") throw new Error(message.reason); + if (message.type === "update-accepted") { + await emit("accepted"); + db.close(); + process.exit(0); + } + if (context.update?.status !== "pending" || message.updateId !== context.update.id) { + throw new Error("Commit does not match the pending update"); + } + await emit("committed"); +}); +await emit("ready"); +if (context.update?.status === "pending") { + db.exec("PRAGMA journal_mode=WAL; UPDATE history SET value = 'migrated history'"); + if (scenario === "rollback") { + // Change the main file and leave a new WAL behind, exercising both restore paths. + db.exec("PRAGMA wal_checkpoint(TRUNCATE); UPDATE history SET value = 'failed migration'"); + await emit("failing"); + process.exit(23); + } + await emit("trial"); + process.send({ type: "prepared", updateId: context.update.id }); +} else if (context.update?.status === "rolled-back") { + await emit("rolled-back"); +} else if (scenario !== "lifecycle" && !context.update) { + process.send({ type: "request-update", targetVersion: "1.1.0", dbPath }); +} diff --git a/apps/server/src/testUtils/serviceRuntime.launcher.ts b/apps/server/src/testUtils/serviceRuntime.launcher.ts new file mode 100644 index 000000000000..54c748c6c848 --- /dev/null +++ b/apps/server/src/testUtils/serviceRuntime.launcher.ts @@ -0,0 +1,3 @@ +import { main } from "../serviceLauncher.ts"; + +await main(); From c4085bca192fcf5d0be1b4412a2dfb28a1acc5ae Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Wed, 7 Oct 2026 16:39:01 +0200 Subject: [PATCH 11/32] fix(distribution): type executable build configuration errors --- apps/server/scripts/cli.ts | 13 +++++++------ apps/server/scripts/cliErrors.ts | 7 +++++++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index d7129f044c1a..92841e826a7f 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -25,6 +25,7 @@ import { ServerCliDevelopmentIconSourceMissingError, ServerCliDevelopmentIconTargetMissingError, ServerCliExecutableImportError, + ServerCliExecutableBuildConfigurationError, } from "./cliErrors.ts"; import { publishPlatformsThenLauncher } from "./publishOrder.ts"; @@ -137,14 +138,14 @@ const buildExeCmd = Command.make( const hostArch = yield* HostProcessArchitecture; const target = Option.getOrElse(config.target, () => `${hostPlatform}-${hostArch}`); if (!CLI_ARCHIVE_PLATFORM_KEYS.some((supported) => supported === target)) { - return yield* Effect.fail( - new Error( - `Unsupported CLI target "${target}". Supported targets: ${CLI_ARCHIVE_PLATFORM_KEYS.join(", ")}.`, - ), - ); + return yield* new ServerCliExecutableBuildConfigurationError({ + message: `Unsupported CLI target "${target}". Supported targets: ${CLI_ARCHIVE_PLATFORM_KEYS.join(", ")}.`, + }); } if (process.versions.bun !== BUN_VERSION) { - return yield* Effect.fail(new Error(`Build the CLI with Bun ${BUN_VERSION}.`)); + return yield* new ServerCliExecutableBuildConfigurationError({ + message: `Build the CLI with Bun ${BUN_VERSION}.`, + }); } yield* Effect.log("[cli] Building Bun executable..."); const spawnCommand = yield* resolveSpawnCommand("vp", ["pack"]); diff --git a/apps/server/scripts/cliErrors.ts b/apps/server/scripts/cliErrors.ts index 5c02281aabb1..12a5811ce8eb 100644 --- a/apps/server/scripts/cliErrors.ts +++ b/apps/server/scripts/cliErrors.ts @@ -58,3 +58,10 @@ export class ServerCliExecutableImportError extends Schema.TaggedError()( + "ServerCliExecutableBuildConfigurationError", + { + message: Schema.String, + }, +) {} From 3e81fecfe55825b4afc0c1b8aacfab71d4c1d57d Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Wed, 7 Oct 2026 16:43:01 +0200 Subject: [PATCH 12/32] fix(test): align web regression with client protocols --- apps/server/scripts/web-client-regression.ts | 85 ++++++++++++++----- .../scripts/web-fixtures/fake-codex.mjs | 7 ++ .../scripts/web-fixtures/fake-codex.test.ts | 30 +++++++ 3 files changed, 101 insertions(+), 21 deletions(-) diff --git a/apps/server/scripts/web-client-regression.ts b/apps/server/scripts/web-client-regression.ts index febdb9961448..11bb1e6dac9d 100644 --- a/apps/server/scripts/web-client-regression.ts +++ b/apps/server/scripts/web-client-regression.ts @@ -8,6 +8,7 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeURL from "node:url"; import * as Effect from "effect/Effect"; +import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -24,6 +25,7 @@ import { WS_METHODS, type TerminalAttachStreamEvent, } from "@t3tools/contracts"; +import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "@t3tools/shared/remote"; import { createEnvironmentFixture, redactEnvironmentLog, @@ -41,8 +43,12 @@ import { requestRpc, withRegressionRpc, type RegressionRpcClient } from "./web-f const repoRoot = NodeURL.fileURLToPath(new URL("../../../", import.meta.url)); const visible = (locator: Locator) => locator.waitFor({ state: "visible", timeout: 30_000 }); const decodeCommand = Schema.decodeUnknownSync(OrchestrationV2Command); -const decodeDescriptor = Schema.decodeUnknownSync(ExecutionEnvironmentDescriptor); -const decodePairingCredential = Schema.decodeUnknownSync(AuthPairingCredentialResult); +const decodeDescriptor = Schema.decodeUnknownSync( + Schema.toCodecJson(ExecutionEnvironmentDescriptor), +); +const decodePairingCredential = Schema.decodeUnknownSync( + Schema.toCodecJson(AuthPairingCredentialResult), +); const providerId = ProviderInstanceId.make("codex"); const authProviderId = ProviderInstanceId.make("fixture_signin"); @@ -62,9 +68,7 @@ async function pairingUrl(fixture: EnvironmentFixture) { }) ).json(), ); - const url = new URL("/pair", fixture.origin); - url.searchParams.set("token", credential.credential); - return url.href; + return setPairingTokenOnUrl(new URL("/pair", fixture.origin), credential.credential).href; } async function configure(fixture: EnvironmentFixture, dependencies: WebDependencies) { @@ -148,7 +152,7 @@ async function sendMessage(page: Page, text: string) { const editor = page.getByTestId("composer-editor"); await visible(editor); await editor.fill(text); - await page.getByRole("button", { name: "Send message", exact: true }).click(); + await page.getByRole("button", { name: "Submit message", exact: true }).click(); } async function addSurface(page: Page, name: "Terminal" | "Browser" | "Device") { @@ -159,7 +163,7 @@ async function addSurface(page: Page, name: "Terminal" | "Browser" | "Device") { } if (await add.isVisible()) { await add.click(); - await page.getByRole("menuitem", { name, exact: true }).click(); + await page.getByRole("menuitem", { name: new RegExp(`^${name}(?:\\s|$)`) }).click(); } else { await launcher.getByRole("button", { name, exact: true }).click(); } @@ -232,6 +236,8 @@ export async function runWebClientRegression( viewport: { width: 1440, height: 1100 }, permissions: ["clipboard-read", "clipboard-write"], }); + context.setDefaultTimeout(30_000); + context.setDefaultNavigationTimeout(30_000); // Network snapshots contain cookies and socket tickets. Keep only rendered // screenshots and action metadata, and pause even those around pairing input. let tracing = false; @@ -302,12 +308,14 @@ export async function runWebClientRegression( ); await page.goto(primaryPairing); NodeAssert.equal((await paired).status(), 200); + await page.waitForURL((url) => url.pathname !== "/pair"); + await visible(page.getByText("Project environment-a", { exact: true }).first()); await page.goto(new URL(seeded.route, primary.origin).href); await visible(page.getByTestId("composer-editor")); pairingActive = false; NodeAssert.equal( - new URL(page.url()).searchParams.has("token"), - false, + getPairingTokenFromUrl(new URL(page.url())), + null, "Pairing credentials must leave the visible URL.", ); await page.reload(); @@ -320,10 +328,13 @@ export async function runWebClientRegression( await milestone("streamed text, tool activity, completion and cancellation", async () => { await sendMessage(page, "Run the controlled streaming turn"); await visible(page.getByText("Streaming from environment-a", { exact: true })); - await visible(page.getByText("printf fixture-tool", { exact: false }).first()); + await visible( + page.getByRole("button", { name: /^(?:Running printf|Ran printf|Ran 1 command)$/ }).first(), + ); await visible(page.getByRole("button", { name: "Stop generation", exact: true })); await releaseProvider(dependencies); await visible(page.getByText("Finished from environment-a.", { exact: true })); + await visible(page.getByRole("button", { name: /^(?:Ran printf|Ran 1 command)$/ }).first()); await page .getByRole("button", { name: "Stop generation", exact: true }) .waitFor({ state: "hidden" }); @@ -337,8 +348,15 @@ export async function runWebClientRegression( const row = page.getByTestId("sidebar-row-card").filter({ hasText: "Thread environment-a" }); await row.hover(); await row.getByRole("button", { name: "Settle thread", exact: true }).click(); - await visible(page.getByRole("button", { name: "Un-settle thread", exact: true }).first()); - await page.getByRole("button", { name: "Un-settle thread", exact: true }).first().click(); + const settledShelf = page.getByTestId("sidebar-settled-shelf-toggle"); + await visible(settledShelf); + if ((await settledShelf.getAttribute("aria-expanded")) === "false") + await settledShelf.click(); + const settledRow = page + .getByTestId("sidebar-row-slim") + .filter({ hasText: "Thread environment-a" }); + await settledRow.hover(); + await settledRow.getByRole("button", { name: "Un-settle thread", exact: true }).click(); }); await milestone("terminal input, output, resize, error and exit", async () => { @@ -388,18 +406,20 @@ export async function runWebClientRegression( (event) => event.type === "snapshot", ); NodeAssert.match(terminalText(snapshot), /\r?\nterminal-error-fixture\r?\n/); - const writeAfterExit = await Effect.runPromiseExit( + const missingTerminal = await Effect.runPromiseExit( client[WS_METHODS.terminalWrite]({ threadId: seeded.threadId, - terminalId: "term-1", + terminalId: "missing-terminal", data: "should fail\r", }), ); NodeAssert.equal( - writeAfterExit._tag, + missingTerminal._tag, "Failure", - "A write to an exited terminal must report an error.", + "A write to a missing terminal must report an error.", ); + if (missingTerminal._tag === "Failure") + NodeAssert.match(Cause.pretty(missingTerminal.cause), /missing-terminal/); }); await page.screenshot({ path: NodePath.join(artifacts, "terminal-exited.png") }); }); @@ -411,12 +431,29 @@ export async function runWebClientRegression( exact: true, }); await visible(updateChecks); - await updateChecks.click(); + await withRegressionRpc(primary, async (client) => { + await updateChecks.click(); + const saved = await requestRpc( + client[WS_METHODS.subscribeServerConfig]({}).pipe( + Stream.filter((event) => + event.type === "settingsUpdated" + ? event.payload.settings.enableProviderUpdateChecks + : event.type === "snapshot" && event.config.settings.enableProviderUpdateChecks, + ), + Stream.take(1), + Stream.runHead, + ), + ); + NodeAssert.ok( + Option.isSome(saved), + "The settings stream must confirm the save before reload.", + ); + }); await page.reload(); NodeAssert.equal(await updateChecks.getAttribute("aria-checked"), "true"); await updateChecks.click(); await page.goto(new URL("/settings/providers", primary.origin).href); - await visible(page.getByText("Fixture sign-in", { exact: true })); + await page.getByRole("button", { name: "Select Fixture sign-in", exact: true }).click(); await page.getByRole("button", { name: "Sign in", exact: true }).last().click(); await visible(page.getByRole("button", { name: "Copy sign-in link", exact: true })); await page.getByRole("button", { name: "Copy sign-in link", exact: true }).click(); @@ -437,7 +474,7 @@ export async function runWebClientRegression( ); }); await NodeFSP.writeFile(NodePath.join(dependencies.control, "auth-error"), "fail"); - await page.getByRole("button", { name: "Sign in", exact: true }).last().click(); + await page.getByRole("button", { name: "Retry sign-in", exact: true }).last().click(); await visible(page.getByText("Controlled sign-in failure", { exact: false }).first()); await NodeFSP.rm(NodePath.join(dependencies.control, "auth-error")); }); @@ -487,12 +524,14 @@ export async function runWebClientRegression( const rgba = canvas .getContext("2d") ?.getImageData(Math.floor(canvas.width / 2), Math.floor(canvas.height / 2), 1, 1).data; - return rgba?.[0] === 220 && rgba[1] === 20 && rgba[2] === 60; + // The stream uses JPEG, so permit its small color quantization error. + return rgba && Math.abs(rgba[0] - 220) <= 8 + && Math.abs(rgba[1] - 20) <= 8 && Math.abs(rgba[2] - 60) <= 8; }`); await page.screenshot({ path: NodePath.join(artifacts, "browser-ready.png") }); await url.fill("http://127.0.0.1:1/"); await url.press("Enter"); - await visible(page.getByText("This site can't be reached", { exact: true })); + await visible(page.getByRole("heading", { name: /This site can[’']t be reached/ })); }); await milestone("Device unavailable, ready inventory and controlled error", async () => { @@ -555,6 +594,8 @@ export async function runWebClientRegression( ).text(); NodeAssert.match(remoteHistory, /Execute only in environment-b/); const remoteContext = await browser.newContext(); + remoteContext.setDefaultTimeout(30_000); + remoteContext.setDefaultNavigationTimeout(30_000); try { const remotePage = await remoteContext.newPage(); const paired = remotePage.waitForResponse( @@ -564,6 +605,8 @@ export async function runWebClientRegression( ); await remotePage.goto(await pairingUrl(secondary)); NodeAssert.equal((await paired).status(), 200); + await remotePage.waitForURL((url) => url.pathname !== "/pair"); + await visible(remotePage.getByText("Project environment-b", { exact: true }).first()); await remotePage.goto(new URL(remote.route, secondary.origin).href); await visible(remotePage.getByText("Finished from environment-b.", { exact: true })); NodeAssert.equal(new URL(remotePage.url()).origin, secondary.origin); diff --git a/apps/server/scripts/web-fixtures/fake-codex.mjs b/apps/server/scripts/web-fixtures/fake-codex.mjs index 2adbb49e8c78..fcd5c2523ba1 100644 --- a/apps/server/scripts/web-fixtures/fake-codex.mjs +++ b/apps/server/scripts/web-fixtures/fake-codex.mjs @@ -10,6 +10,13 @@ if (process.argv.includes("--version")) { if (process.argv.includes("--help")) process.exit(0); const control = process.env.T3_FAKE_CONTROL; const owner = process.env.T3_FAKE_OWNER ?? "fixture"; +if (process.argv.includes("exec")) { + const output = process.argv[process.argv.indexOf("--output-last-message") + 1]; + // Text generation is a separate CLI invocation, with the prompt on stdin. + NodeFS.readFileSync(0, "utf8"); + NodeFS.writeFileSync(output, JSON.stringify({ title: `Thread ${owner}` })); + process.exit(0); +} const captured = JSON.parse( NodeFS.readFileSync( process.env.T3_FAKE_CAPTURE ?? diff --git a/apps/server/scripts/web-fixtures/fake-codex.test.ts b/apps/server/scripts/web-fixtures/fake-codex.test.ts index f5450c9d50b6..68e10272e9f4 100644 --- a/apps/server/scripts/web-fixtures/fake-codex.test.ts +++ b/apps/server/scripts/web-fixtures/fake-codex.test.ts @@ -12,6 +12,36 @@ import { ServerNotification } from "effect-codex-app-server/schema"; const decodeNotification = Schema.decodeUnknownSync(ServerNotification); +test("the controlled provider returns a deterministic title through codex exec", async () => { + const control = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-fake-title-")); + const output = NodePath.join(control, "title.json"); + try { + const child = NodeChildProcess.spawnSync( + process.env.BUN_EXECUTABLE ?? "bun", + [ + NodeURL.fileURLToPath(new URL("fake-codex.mjs", import.meta.url)), + "exec", + "--output-last-message", + output, + "-", + ], + { + env: { ...process.env, T3_FAKE_OWNER: "environment-a" }, + input: "You generate concise thread titles.\nReturn a JSON object with key: title.\n", + encoding: "utf8", + timeout: 5000, + }, + ); + expect(child.error).toBeUndefined(); + expect(child.status, child.stderr).toBe(0); + expect(JSON.parse(await NodeFSP.readFile(output, "utf8"))).toEqual({ + title: "Thread environment-a", + }); + } finally { + await NodeFSP.rm(control, { recursive: true, force: true }); + } +}); + test("the controlled provider streams until released and acknowledges cancellation", async () => { const control = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-fake-codex-")); const child = NodeChildProcess.spawn( From c7c86eabbbd6622ed012314f344eb9e61d0f32e1 Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Wed, 7 Oct 2026 16:44:29 +0200 Subject: [PATCH 13/32] fix(server): resolve packaged web client beside Bun executable --- apps/server/src/config.test.ts | 69 ++++++++++++++++++++++++++++++++++ apps/server/src/config.ts | 8 +++- 2 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/config.test.ts diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts new file mode 100644 index 000000000000..253ce0ee60e9 --- /dev/null +++ b/apps/server/src/config.test.ts @@ -0,0 +1,69 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { HostProcessExecutablePath, HostProcessIsExecutable } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { resolveStaticDir } from "./config.ts"; + +it.layer(NodeServices.layer)("static client resolution", (it) => { + it.effect("serves the on-disk client beside a Bun archive executable", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const archiveDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-static-archive-" }); + const clientDir = path.join(archiveDir, "client"); + yield* fs.makeDirectory(clientDir); + yield* fs.writeFileString(path.join(clientDir, "index.html"), "packaged client"); + const resolved = yield* resolveStaticDir().pipe( + Effect.provideService(HostProcessIsExecutable, true), + Effect.provideService(HostProcessExecutablePath, path.join(archiveDir, "t3")), + ); + assert.equal(resolved, clientDir); + }), + ); + + it.effect.each([true, false])( + "preserves source client resolution (sibling client available: %s)", + (siblingAvailable) => + Effect.gen(function* () { + const path = yield* Path.Path; + const sibling = path.join(import.meta.dirname, "client"); + const monorepo = path.resolve(import.meta.dirname, "../../web/dist"); + const resolved = yield* resolveStaticDir().pipe( + Effect.provideService(HostProcessIsExecutable, false), + Effect.provideService(HostProcessExecutablePath, "/unrelated/bun"), + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: (file) => + Effect.succeed( + file === path.join(monorepo, "index.html") || + (siblingAvailable && file === path.join(sibling, "index.html")), + ), + }), + ), + ); + assert.equal(resolved, siblingAvailable ? sibling : monorepo); + }), + ); + + it.effect("does not serve a source checkout when an archive is missing its client", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const monorepo = path.resolve(import.meta.dirname, "../../web/dist"); + const resolved = yield* resolveStaticDir().pipe( + Effect.provideService(HostProcessIsExecutable, true), + Effect.provideService(HostProcessExecutablePath, "/isolated/archive/t3"), + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: (file) => Effect.succeed(file === path.join(monorepo, "index.html")), + }), + ), + ); + assert.isUndefined(resolved); + }), + ); +}); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 91a1775bdf22..cf31ec55abcb 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -19,6 +19,7 @@ import * as Schema from "effect/Schema"; import { sweepStalePendingAttachments } from "./attachmentStore.ts"; import { DEFAULT_SIGNAL_EXPORT, type SignalExport } from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; +import { HostProcessExecutablePath, HostProcessIsExecutable } from "@t3tools/shared/hostProcess"; export const DEFAULT_PORT = 3773; @@ -252,15 +253,18 @@ export const layerTest = (cwd: string, baseDirOrPrefix: string | { readonly pref Layer.effect(ServerConfig, makeTest(cwd, baseDirOrPrefix)); export const resolveStaticDir = Effect.fn(function* () { - const { join, resolve } = yield* Path.Path; + const { dirname, join, resolve } = yield* Path.Path; const { exists } = yield* FileSystem.FileSystem; - const bundledClient = resolve(join(import.meta.dirname, "client")); + const isExecutable = yield* HostProcessIsExecutable; + const moduleDir = isExecutable ? dirname(yield* HostProcessExecutablePath) : import.meta.dirname; + const bundledClient = resolve(join(moduleDir, "client")); const bundledStat = yield* exists(join(bundledClient, "index.html")).pipe( Effect.orElseSucceed(() => false), ); if (bundledStat) { return bundledClient; } + if (isExecutable) return undefined; const monorepoClient = resolve(join(import.meta.dirname, "../../web/dist")); const monorepoStat = yield* exists(join(monorepoClient, "index.html")).pipe( From 5855d780cf7a93491507a6703df4bc1916f9f2b7 Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Wed, 7 Oct 2026 16:47:48 +0200 Subject: [PATCH 14/32] fix(distribution): remove obsolete Node PTY archive dependency --- .github/workflows/release.yml | 3 +-- scripts/build-cli-archive.ts | 11 +-------- scripts/build-npm-platform-packages.test.ts | 17 ++++++++------ scripts/lib/cli-external-packages.test.ts | 26 ++++++++++++--------- scripts/lib/cli-external-packages.ts | 5 ++-- scripts/lib/cli-stage.test.ts | 10 ++++---- 6 files changed, 34 insertions(+), 38 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2fef757e9a8d..3c5ffa03b99b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -573,8 +573,7 @@ jobs: rust_target: x86_64-unknown-linux-gnu resource_key: linux-x64 - # node-pty has no Linux prebuild and compiles from source, so the arm64 app - # and archive are built on arm64 hardware rather than cross-built. + # Build and exercise native libraries on their target hardware. cli_linux_arm64: name: CLI Linux arm64 needs: [preflight, relay_public_config, build_bundle] diff --git a/scripts/build-cli-archive.ts b/scripts/build-cli-archive.ts index 7c31aa02f122..d1b284a71750 100644 --- a/scripts/build-cli-archive.ts +++ b/scripts/build-cli-archive.ts @@ -216,15 +216,7 @@ const stageRuntimeExternals = Effect.fn("stageRuntimeExternals")(function* (inpu ); // pnpm's bookkeeping and the manifest only matter to pnpm; the runtime - // resolves packages by directory. node-pty ships every platform's prebuilds - // in one package (58 MB); only the archive's own platform loads. - const platformKey = cliArchivePlatformKey(input.platform, input.arch); - const prebuildsDir = path.join(input.stageDir, "node_modules/node-pty/prebuilds"); - const foreignPrebuilds = (yield* fs - .readDirectory(prebuildsDir) - .pipe(Effect.orElseSucceed((): ReadonlyArray => []))).filter( - (entry) => entry !== platformKey, - ); + // resolves packages by directory. for (const entry of [ "package.json", "pnpm-workspace.yaml", @@ -234,7 +226,6 @@ const stageRuntimeExternals = Effect.fn("stageRuntimeExternals")(function* (inpu "node_modules/.modules.yaml", "node_modules/.pnpm-workspace-state-v1.json", "node_modules/.bin", - ...foreignPrebuilds.map((entry) => `node_modules/node-pty/prebuilds/${entry}`), ]) { yield* fs.remove(path.join(input.stageDir, entry), { recursive: true, force: true }); } diff --git a/scripts/build-npm-platform-packages.test.ts b/scripts/build-npm-platform-packages.test.ts index 9bbebfa472a1..f624a3a3fc5d 100644 --- a/scripts/build-npm-platform-packages.test.ts +++ b/scripts/build-npm-platform-packages.test.ts @@ -58,14 +58,14 @@ const makeFakeArchives = Effect.fn("test.makeFakeArchives")(function* () { for (const dir of [ "client", "resource-monitor", - "node_modules/node-pty", + "node_modules/playwright-core", "node_modules/@ff-labs/fff-node", ]) { yield* fs.makeDirectory(path.join(contentDir, dir), { recursive: true }); } yield* fs.writeFileString( - path.join(contentDir, "node_modules/node-pty/package.json"), - '{ "name": "node-pty", "version": "1.1.0" }\n', + path.join(contentDir, "node_modules/playwright-core/package.json"), + '{ "name": "playwright-core", "version": "1.60.0" }\n', ); yield* fs.writeFileString( path.join(contentDir, "node_modules/@ff-labs/fff-node/package.json"), @@ -144,9 +144,12 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { // on the next install in the same project and the executable breaks. assert.deepStrictEqual(linuxManifest.dependencies, { "@ff-labs/fff-node": "0.9.4", - "node-pty": "1.1.0", + "playwright-core": "1.60.0", }); - assert.deepStrictEqual(linuxManifest.bundleDependencies, ["@ff-labs/fff-node", "node-pty"]); + assert.deepStrictEqual(linuxManifest.bundleDependencies, [ + "@ff-labs/fff-node", + "playwright-core", + ]); // Archive contents sit at the package root, not under the archive stem. assert.isTrue(yield* fs.exists(path.join(linuxDir, "client/index.html"))); // A root README, or npm would display a bundled dependency's. @@ -154,7 +157,7 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { yield* fs.readFileString(path.join(linuxDir, "README.md")), "# @iglo-tech/iglo-code-linux-x64", ); - assert.isTrue(yield* fs.exists(path.join(linuxDir, "node_modules/node-pty"))); + assert.isTrue(yield* fs.exists(path.join(linuxDir, "node_modules/playwright-core"))); assert.equal(Number((yield* fs.stat(path.join(linuxDir, "t3"))).mode) & 0o111, 0o111); const darwinManifest = yield* decodeManifest( @@ -194,7 +197,7 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { ); assert.equal(listing.exitCode, 0, listing.stderr); const lines = listing.stdout.split("\n"); - assert.isTrue(lines.some((line) => line.endsWith(" package/node_modules/node-pty/"))); + assert.isTrue(lines.some((line) => line.endsWith(" package/node_modules/playwright-core/"))); assert.isTrue(lines.some((line) => line.endsWith(" package/package.json"))); assert.isTrue( lines.some((line) => /^-rwxr-xr-x .* package\/t3$/.test(line)), diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts index 2bf99f910486..4e08387709c2 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -45,7 +45,6 @@ describe("shouldBundleCliDependency", () => { it("leaves native addons and their dlopen wrappers external", () => { for (const id of [ - "node-pty", "ffi-rs", "@yuuang/ffi-rs-win32-x64-msvc", "@ff-labs/fff-node", @@ -66,7 +65,7 @@ describe("shouldBundleCliDependency", () => { }); describe("selectCliRuntimeExternalDependencies", () => { - it("keeps only runtime-external dependency roots for the Windows sidecar", () => { + it("keeps only runtime-external dependency roots for the archive", () => { assert.deepStrictEqual( selectCliRuntimeExternalDependencies({ "@ff-labs/fff-node": "2.0.0", @@ -75,7 +74,6 @@ describe("selectCliRuntimeExternalDependencies", () => { }), { "@ff-labs/fff-node": "2.0.0", - "node-pty": "4.0.0", }, ); }); @@ -83,7 +81,7 @@ describe("selectCliRuntimeExternalDependencies", () => { it("selects every external root declared by the server", () => { assert.deepStrictEqual( Object.keys(selectCliRuntimeExternalDependencies(serverPackageJson.dependencies)).sort(), - ["@cursor/sdk", "@ff-labs/fff-node", "@napi-rs/keyring", "node-pty", "playwright-core"], + ["@cursor/sdk", "@ff-labs/fff-node", "@napi-rs/keyring", "playwright-core"], ); }); }); @@ -159,10 +157,14 @@ it.layer(NodeServices.layer)("external package dependency closure", (it) => { const found = [...installed.keys()].filter(isRuntimeExternal); // Without this the closure check below can pass vacuously: if nothing is - // read, nothing is checked. node-pty is the one native root every - // platform ships, and node-addon-api is its transitive runtime - // dependency, so require them by name. - for (const required of ["node-pty", "node-addon-api"]) { + // read, nothing is checked. Require the disk-backed roots shipped + // on every supported target. + for (const required of [ + "@cursor/sdk", + "@ff-labs/fff-node", + "@napi-rs/keyring", + "playwright-core", + ]) { assert.ok( found.includes(required), `expected ${required} in the pnpm store; the closure check is only meaningful if it can read these (found ${found.length})`, @@ -273,7 +275,9 @@ var x = 1; }); it("reports no regions when the marker format is absent", () => { - const result = findInlinedExternalPackages("var x = 1; // node_modules/node-pty/lib.js"); + const result = findInlinedExternalPackages( + "var x = 1; // node_modules/@napi-rs/keyring/lib.js", + ); assert.strictEqual(result.regionCount, 0); assert.deepStrictEqual(result.inlined, []); }); @@ -289,7 +293,7 @@ describe("findEsmImportsOfExternalPackages", () => { 'import { FileFinder } from "@ff-labs/fff-node";', 'import * as fs from "fs";', 'import { createRequire } from "node:module";', - 'const pty = () => import("node-pty");', + 'const keyring = () => import("@napi-rs/keyring");', 'const data = () => import("@ff-labs/fff-bin-linux-x64-gnu", { with: { type: "json" } });', 'const lazy = () => import(/* @vite-ignore */ "ffi-rs");', 'const local = () => import("./chunk-abc.mjs");', @@ -298,8 +302,8 @@ describe("findEsmImportsOfExternalPackages", () => { assert.deepStrictEqual(findEsmImportsOfExternalPackages(source), [ "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-node", + "@napi-rs/keyring", "ffi-rs", - "node-pty", ]); }); diff --git a/scripts/lib/cli-external-packages.ts b/scripts/lib/cli-external-packages.ts index b2b78c4092ff..92844cf3e50c 100644 --- a/scripts/lib/cli-external-packages.ts +++ b/scripts/lib/cli-external-packages.ts @@ -29,7 +29,6 @@ export const CLI_RUNTIME_EXTERNAL_PREFIXES = [ "@cursor/sdk", // Playwright reads package.json and browsers.json beside its runtime modules. "playwright-core", - "node-pty", "ffi-rs", "@yuuang/", "@ff-labs/", @@ -77,8 +76,8 @@ export function isRuntimeExternalCliDependency(id: string): boolean { * `alwaysBundle`. `alwaysBundle` only forces packages IN — returning false from * it means "no opinion", and the default then applies: a declared dependency * stays external, but a transitive one gets bundled. That is how a native - * loader such as node-gyp-build ended up inlined while node-pty (a declared - * dependency) stayed external. + * loader such as node-gyp-build ended up inlined while a declared native + * dependency stayed external. */ export function isExternalCliDependency(id: string): boolean { return isRuntimeExternalCliDependency(id); diff --git a/scripts/lib/cli-stage.test.ts b/scripts/lib/cli-stage.test.ts index 95e240ee67af..fa4ee0092f8d 100644 --- a/scripts/lib/cli-stage.test.ts +++ b/scripts/lib/cli-stage.test.ts @@ -32,26 +32,26 @@ describe("CLI archive stage", () => { const patches = createStagePatchedDependencies( { "@ff-labs/fff-node@0.9.4": "patches/fff.patch", - "node-pty@1.2.0": "patches/pty.patch", + "playwright-core@1.60.0": "patches/playwright.patch", "effect@4.0.1": "patches/effect.patch", }, - { "@ff-labs/fff-node": "0.9.4", "node-pty": "1.2.0" }, + { "@ff-labs/fff-node": "0.9.4", "playwright-core": "1.60.0" }, ); expect( createStageWorkspaceConfig({ platform: "mac", arch: "arm64", patchedDependencies: patches, - allowBuilds: { "node-pty": true }, + allowBuilds: { "@ff-labs/fff-node": false }, overrides: { "node-abi": "4.33.0" }, }), ).toEqual({ supportedArchitectures: { os: ["darwin"], cpu: ["arm64"] }, patchedDependencies: { "@ff-labs/fff-node@0.9.4": "patches/fff.patch", - "node-pty@1.2.0": "patches/pty.patch", + "playwright-core@1.60.0": "patches/playwright.patch", }, - allowBuilds: { "node-pty": true }, + allowBuilds: { "@ff-labs/fff-node": false }, overrides: { "node-abi": "4.33.0" }, }); }); From c47ada0b52f49ae17bdb5a08db4837385064f021 Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Wed, 7 Oct 2026 16:57:13 +0200 Subject: [PATCH 15/32] fix(terminal): use Bun PTY for reliable input and output --- apps/server/src/server.ts | 4 +- .../server/src/terminal/BunPtyAdapter.test.ts | 64 +++ apps/server/src/terminal/BunPtyAdapter.ts | 115 ++++++ .../src/terminal/NodePtyAdapter.test.ts | 365 ------------------ apps/server/src/terminal/NodePtyAdapter.ts | 304 --------------- 5 files changed, 181 insertions(+), 671 deletions(-) create mode 100644 apps/server/src/terminal/BunPtyAdapter.test.ts create mode 100644 apps/server/src/terminal/BunPtyAdapter.ts delete mode 100644 apps/server/src/terminal/NodePtyAdapter.test.ts delete mode 100644 apps/server/src/terminal/NodePtyAdapter.ts diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ebbc73758a43..0d60097e1055 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -31,7 +31,7 @@ import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts"; import { fixPath } from "./os-jank.ts"; import * as Ws from "./ws.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; -import * as NodePtyAdapter from "./terminal/NodePtyAdapter.ts"; +import * as BunPtyAdapter from "./terminal/BunPtyAdapter.ts"; import * as PullRequestHttp from "./pullRequest/http.ts"; import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; @@ -191,7 +191,7 @@ const layerApplicationObservability = EventLoopMonitor.layer.pipe( Layer.provideMerge(layerResourceAttribution), ); -const layerPtyAdapter = NodePtyAdapter.layer; +const layerPtyAdapter = BunPtyAdapter.layer; const layerServerSettings = ServerSettings.layer.pipe( Layer.provide(ServerSecretStore.layer), diff --git a/apps/server/src/terminal/BunPtyAdapter.test.ts b/apps/server/src/terminal/BunPtyAdapter.test.ts new file mode 100644 index 000000000000..0bb5b6e463f5 --- /dev/null +++ b/apps/server/src/terminal/BunPtyAdapter.test.ts @@ -0,0 +1,64 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as BunPtyAdapter from "./BunPtyAdapter.ts"; +import * as PtyAdapter from "./PtyAdapter.ts"; + +const input = { shell: "/bin/sh", cwd: "/workspace", cols: 80, rows: 24, env: {} }; + +it.effect("retains startup output, decodes split UTF-8 and replays a settled exit", () => + Effect.gen(function* () { + const exited = Promise.withResolvers(); + const terminal = { write: () => 0, resize: () => {}, close: () => {} }; + let emit: ((data: Uint8Array) => void) | undefined; + const runtime = { + spawn: ( + _command: string[], + options: { + terminal: { data: (_terminal: typeof terminal, data: Uint8Array) => void }; + }, + ) => { + emit = (data) => options.terminal.data(terminal, data); + emit(new Uint8Array([115, 116, 97, 114, 116, 10, 0xe2])); + return { pid: 42, terminal, exited: exited.promise, signalCode: null, kill: () => {} }; + }, + }; + const adapter = yield* BunPtyAdapter.make().pipe( + Effect.provideService(BunPtyAdapter.BunPtyRuntime, runtime), + ); + const child = yield* adapter.spawn(input); + const output: string[] = []; + const stop = child.onData((data) => output.push(data)); + emit?.(new Uint8Array([0x82, 0xac, 10])); + assert.deepEqual(output, ["start\n", "€\n"]); + stop(); + emit?.(new Uint8Array([65])); + assert.deepEqual(output, ["start\n", "€\n"]); + const done = Promise.withResolvers(); + child.onExit(done.resolve); + exited.resolve(7); + assert.deepEqual(yield* Effect.promise(() => done.promise), { exitCode: 7, signal: null }); + const late: PtyAdapter.PtyExitEvent[] = []; + child.onExit((event) => late.push(event)); + assert.deepEqual(late, [{ exitCode: 7, signal: null }]); + }), +); + +it.effect("reports a native spawn failure through the PTY contract", () => + Effect.gen(function* () { + const cause = new Error("No such executable"); + const adapter = yield* BunPtyAdapter.make().pipe( + Effect.provideService(BunPtyAdapter.BunPtyRuntime, { + spawn: () => { + throw cause; + }, + }), + ); + const result = yield* adapter.spawn(input).pipe(Effect.result); + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.instanceOf(result.failure, PtyAdapter.PtySpawnError); + assert.equal(result.failure.shell, "/bin/sh"); + assert.equal(result.failure.cause, cause); + } + }), +); diff --git a/apps/server/src/terminal/BunPtyAdapter.ts b/apps/server/src/terminal/BunPtyAdapter.ts new file mode 100644 index 000000000000..0606c3f727a4 --- /dev/null +++ b/apps/server/src/terminal/BunPtyAdapter.ts @@ -0,0 +1,115 @@ +// @effect-diagnostics nodeBuiltinImport:off -- POSIX signal numbers belong to the native PTY boundary. +import * as NodeOS from "node:os"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as PtyAdapter from "./PtyAdapter.ts"; + +interface BunTerminal { + write(data: string): number; + resize(cols: number, rows: number): void; + close(): void; +} + +interface BunPtyRuntime { + spawn( + command: string[], + options: { + cwd: string; + env: NodeJS.ProcessEnv; + terminal: { + cols: number; + rows: number; + data(terminal: BunTerminal, data: Uint8Array): void; + }; + }, + ): { + pid: number; + terminal: BunTerminal; + exited: Promise; + signalCode: NodeJS.Signals | null; + kill(signal: string): void; + }; +} + +// Keep Bun's native API local instead of adding ambient Bun types to all server code. +declare const Bun: BunPtyRuntime; + +export const BunPtyRuntime = Context.Reference("server/terminal/BunPtyRuntime", { + defaultValue: () => Bun, +}); + +/** node-pty's tty.ReadStream(fd) does not deliver PTY data under Bun 1.4.0. */ +export const make = Effect.fn("BunPtyAdapter.make")(function* () { + const bun = yield* BunPtyRuntime; + return PtyAdapter.PtyAdapter.of({ + spawn: Effect.fn("BunPtyAdapter.spawn")((input) => + Effect.try({ + try: (): PtyAdapter.PtyProcess => { + const decoder = new TextDecoder(); + const dataListeners = new Set<(data: string) => void>(); + const exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); + let exitEvent: PtyAdapter.PtyExitEvent | undefined; + let pending: string[] = []; + let subscribed = false; + const emit = (data: string) => { + if (!data) return; + if (!subscribed) pending.push(data); + else for (const listener of dataListeners) listener(data); + }; + const child = bun.spawn([input.shell, ...(input.args ?? [])], { + cwd: input.cwd, + env: { ...input.env, TERM: input.env.TERM ?? "xterm-256color" }, + terminal: { + cols: input.cols, + rows: input.rows, + data: (_terminal, data) => emit(decoder.decode(data, { stream: true })), + }, + }); + void child.exited.then((exitCode) => { + emit(decoder.decode()); + child.terminal.close(); + const signal = + child.signalCode === null ? null : NodeOS.constants.signals[child.signalCode]; + exitEvent = { exitCode: signal === null ? exitCode : 0, signal }; + for (const listener of exitListeners) listener(exitEvent); + exitListeners.clear(); + dataListeners.clear(); + }); + return { + pid: child.pid, + write: (data) => { + child.terminal.write(data); + }, + resize: (cols, rows) => child.terminal.resize(cols, rows), + kill: (signal = "SIGHUP") => child.kill(signal), + onData: (callback) => { + dataListeners.add(callback); + subscribed = true; + for (const data of pending) callback(data); + pending = []; + return () => { + dataListeners.delete(callback); + }; + }, + onExit: (callback) => { + if (exitEvent) { + callback(exitEvent); + return () => {}; + } + exitListeners.add(callback); + return () => { + exitListeners.delete(callback); + }; + }, + }; + }, + catch: (cause) => + new PtyAdapter.PtySpawnError({ adapter: "bun", shell: input.shell, cause }), + }), + ), + }); +}); + +export const layer = Layer.effect(PtyAdapter.PtyAdapter, make()); diff --git a/apps/server/src/terminal/NodePtyAdapter.test.ts b/apps/server/src/terminal/NodePtyAdapter.test.ts deleted file mode 100644 index fbfa62ab993a..000000000000 --- a/apps/server/src/terminal/NodePtyAdapter.test.ts +++ /dev/null @@ -1,365 +0,0 @@ -import * as NodeEvents from "node:events"; -import * as NodeNet from "node:net"; - -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; -import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; -import * as Scheduler from "effect/Scheduler"; -import { expect, vi } from "vite-plus/test"; - -import * as NodePtyAdapter from "./NodePtyAdapter.ts"; -import * as PtyAdapter from "./PtyAdapter.ts"; - -function makeNativeProcess(pid = 42) { - const events = new NodeEvents.EventEmitter(); - return { - pid, - _socket: new NodeNet.Socket(), - _agent: { kill: vi.fn() }, - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - onData: vi.fn((callback: (data: string) => void) => { - events.on("data", callback); - return { - dispose: () => { - events.off("data", callback); - }, - }; - }), - onExit: vi.fn((callback: (event: { exitCode: number; signal?: number }) => void) => { - events.on("exit", callback); - return { - dispose: () => { - events.off("exit", callback); - }, - }; - }), - events, - }; -} - -const spawn = vi.fn(() => makeNativeProcess()); - -function preparePendingProcess() { - const nativeProcess = makeNativeProcess(0); - const subscribed = Promise.withResolvers(); - nativeProcess._socket.on("newListener", (event) => { - if (event === "ready_datapipe") queueMicrotask(() => subscribed.resolve()); - }); - spawn.mockReturnValueOnce(nativeProcess); - return { nativeProcess, subscribed: Effect.promise(() => subscribed.promise) }; -} - -const spawnInput = { shell: "powershell.exe", cwd: ".", cols: 80, rows: 24, env: {} }; - -const fakeNodePty = { spawn } as unknown as typeof import("node-pty"); - -const layerTestFor = (platform: NodeJS.Platform = "win32") => - NodePtyAdapter.layer.pipe( - Layer.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.succeed(HostProcessPlatform, platform), - Layer.succeed(HostProcessArchitecture, "x64"), - Layer.succeed(NodePtyAdapter.NodePtyModuleLoaderRef, () => Promise.resolve(fakeNodePty)), - ), - ), - ); - -const layerTest = layerTestFor(); - -it.effect("waits for the Windows PID without requiring output", () => - Effect.gen(function* () { - const { nativeProcess, subscribed } = preparePendingProcess(); - const adapter = yield* PtyAdapter.PtyAdapter; - let completed = false; - const fiber = yield* adapter.spawn(spawnInput).pipe( - Effect.tap(() => - Effect.sync(() => { - completed = true; - }), - ), - Effect.forkChild, - ); - yield* subscribed; - assert.isFalse(completed); - nativeProcess.pid = 12345; - nativeProcess._socket.emit("ready_datapipe"); - const process = yield* Fiber.join(fiber); - assert.equal(process.pid, 12345); - assert.equal(nativeProcess._socket.listenerCount("ready_datapipe"), 0); - assert.equal(nativeProcess.events.listenerCount("exit"), 1); - - const output: string[] = []; - const exits: PtyAdapter.PtyExitEvent[] = []; - const stopData = process.onData((data) => output.push(data)); - const stopExit = process.onExit((event) => exits.push(event)); - nativeProcess.events.emit("data", "first output"); - nativeProcess.events.emit("exit", { exitCode: 0 }); - assert.deepEqual(output, ["first output"]); - assert.deepEqual(exits, [{ exitCode: 0, signal: null }]); - stopData(); - stopExit(); - }).pipe(Effect.provide(layerTest)), -); - -it.effect.each(["exit", "close", "error", "invalid-pid"] as const)( - "fails Windows startup on %s and cleans up", - (failure) => - Effect.gen(function* () { - const { nativeProcess, subscribed } = preparePendingProcess(); - const adapter = yield* PtyAdapter.PtyAdapter; - const fiber = yield* adapter.spawn(spawnInput).pipe(Effect.result, Effect.forkChild); - yield* subscribed; - if (failure === "exit") nativeProcess.events.emit("exit", { exitCode: 1 }); - else if (failure === "error") nativeProcess._socket.emit("error", new Error("pipe failed")); - else nativeProcess._socket.emit(failure === "close" ? "close" : "ready_datapipe"); - const result = yield* Fiber.join(fiber); - assert.equal(result._tag, "Failure"); - if (result._tag === "Failure") assert.instanceOf(result.failure, PtyAdapter.PtySpawnError); - assert.equal(nativeProcess._socket.listenerCount("ready_datapipe"), 0); - assert.equal(nativeProcess._socket.listenerCount("error"), 0); - assert.equal(nativeProcess._socket.listenerCount("close"), 0); - assert.equal(nativeProcess.events.listenerCount("exit"), 0); - assert.equal(nativeProcess._agent.kill.mock.calls.length, 1); - }).pipe(Effect.provide(layerTest)), -); - -it.effect("cancels the Windows connection without waiting for output", () => - Effect.gen(function* () { - const { nativeProcess, subscribed } = preparePendingProcess(); - const adapter = yield* PtyAdapter.PtyAdapter; - const fiber = yield* adapter.spawn(spawnInput).pipe(Effect.forkChild); - yield* subscribed; - yield* Fiber.interrupt(fiber); - assert.equal(nativeProcess._agent.kill.mock.calls.length, 1); - assert.equal(nativeProcess.kill.mock.calls.length, 0); - assert.equal(nativeProcess._socket.listenerCount("ready_datapipe"), 0); - assert.equal(nativeProcess.events.listenerCount("exit"), 0); - }).pipe(Effect.provide(layerTest)), -); - -it.effect("reports an incompatible Windows readiness API instead of hanging", () => - Effect.gen(function* () { - const nativeProcess = makeNativeProcess(0); - Reflect.deleteProperty(nativeProcess, "_socket"); - spawn.mockReturnValueOnce(nativeProcess); - const adapter = yield* PtyAdapter.PtyAdapter; - const error = yield* adapter.spawn(spawnInput).pipe(Effect.flip); - assert.instanceOf(error, PtyAdapter.PtySpawnError); - assert.instanceOf(error.cause, Error); - assert.equal(error.cause.message, "Windows PTY readiness socket is unavailable."); - assert.equal(nativeProcess._agent.kill.mock.calls.length, 1); - }).pipe(Effect.provide(layerTest)), -); - -it.effect.each(["win32", "linux", "darwin"] as const)( - "terminates through node-pty using %s semantics", - (platform) => - Effect.gen(function* () { - const adapter = yield* PtyAdapter.PtyAdapter; - const process = yield* adapter.spawn({ - shell: "test-shell", - cwd: ".", - cols: 80, - rows: 24, - env: {}, - }); - const nativeProcess = spawn.mock.results.at(-1)!.value; - nativeProcess.kill.mockImplementation((signal?: string) => { - if (platform === "win32" && signal) { - throw new Error("Signals not supported on windows."); - } - }); - - process.kill("SIGTERM"); - process.kill("SIGKILL"); - process.kill(); - - assert.deepEqual( - nativeProcess.kill.mock.calls, - platform === "win32" - ? [[undefined], [undefined], [undefined]] - : [["SIGTERM"], ["SIGKILL"], [undefined]], - ); - }).pipe(Effect.provide(layerTestFor(platform))), -); - -it.effect("spawns through the public adapter with the provided host references", () => - Effect.gen(function* () { - spawn.mockClear(); - const adapter = yield* PtyAdapter.PtyAdapter; - const process = yield* adapter.spawn({ - shell: "powershell.exe", - args: ["-NoLogo"], - cwd: "C:\\workspace", - cols: 120, - rows: 40, - env: {}, - }); - - assert.equal(process.pid, 42); - assert.equal(spawn.mock.calls.length, 1); - assert.deepEqual(spawn.mock.calls[0], [ - "powershell.exe", - ["-NoLogo"], - { - cwd: "C:\\workspace", - cols: 120, - rows: 40, - env: { TERM: "xterm-256color" }, - name: "xterm-256color", - }, - ]); - }).pipe(Effect.provide(layerTest)), -); - -it.effect("preserves a caller-provided TERM in the spawn env on win32", () => - Effect.gen(function* () { - spawn.mockClear(); - const adapter = yield* PtyAdapter.PtyAdapter; - yield* adapter.spawn({ - shell: "powershell.exe", - cwd: "C:\\workspace", - cols: 80, - rows: 24, - env: { TERM: "xterm-direct" }, - }); - - assert.equal(spawn.mock.calls.length, 1); - assert.deepEqual(spawn.mock.calls[0], [ - "powershell.exe", - [], - { - cwd: "C:\\workspace", - cols: 80, - rows: 24, - env: { TERM: "xterm-direct" }, - name: "xterm-256color", - }, - ]); - }).pipe(Effect.provide(layerTest)), -); - -it.effect("reports native module load failures as structured startup defects", () => - Effect.gen(function* () { - const cause = new Error("native binding could not be loaded"); - const exit = yield* NodePtyAdapter.make().pipe( - Effect.provideService(NodePtyAdapter.NodePtyModuleLoaderRef, () => Promise.reject(cause)), - Effect.exit, - ); - - assert.isTrue(Exit.isFailure(exit)); - if (Exit.isFailure(exit)) { - assert.isTrue(Cause.hasDies(exit.cause)); - const error = Cause.squash(exit.cause); - assert.instanceOf(error, NodePtyAdapter.NodePtyModuleLoadError); - assert.deepInclude(error, { - _tag: "NodePtyModuleLoadError", - platform: "win32", - architecture: "x64", - }); - assert.equal(error.message, "Failed to load node-pty for win32-x64."); - } - }).pipe( - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.succeed(HostProcessPlatform, "win32"), - Layer.succeed(HostProcessArchitecture, "x64"), - ), - ), - ), -); - -it.effect.each([2048, 8])( - "preserves an exit during readiness handoff with scheduler budget %s", - (budget) => - Effect.gen(function* () { - const { nativeProcess, subscribed } = preparePendingProcess(); - const adapter = yield* PtyAdapter.PtyAdapter; - const exits: PtyAdapter.PtyExitEvent[] = []; - const fiber = yield* Effect.gen(function* () { - const process = yield* adapter.spawn(spawnInput); - process.onExit((event) => exits.push(event)); - }).pipe( - Effect.provideService(Scheduler.MaxOpsBeforeYield, budget), - Effect.provideService(Scheduler.PreventSchedulerYield, false), - Effect.forkChild, - ); - yield* subscribed; - nativeProcess.pid = 12345; - nativeProcess._socket.emit("ready_datapipe"); - nativeProcess.events.emit("exit", { exitCode: 0 }); - yield* Fiber.join(fiber); - assert.equal(exits.length, 1); - }).pipe(Effect.provide(layerTest)), -); - -it.effect("replays an exit to late subscribers and respects unsubscription", () => - Effect.gen(function* () { - const adapter = yield* PtyAdapter.PtyAdapter; - const process = yield* adapter.spawn(spawnInput); - const nativeProcess = spawn.mock.results.at(-1)!.value; - const removed = vi.fn(); - process.onExit(removed)(); - nativeProcess.events.emit("exit", { exitCode: 7, signal: 2 }); - const late = vi.fn(); - process.onExit(late); - nativeProcess.events.emit("exit", { exitCode: 9 }); - assert.equal(removed.mock.calls.length, 0); - assert.deepEqual(late.mock.calls, [[{ exitCode: 7, signal: 2 }]]); - assert.equal(nativeProcess.events.listenerCount("exit"), 0); - }).pipe(Effect.provide(layerTest)), -); - -it.effect.each(["spawn", "interrupt"] as const)( - "logs cleanup failures without replacing %s", - (failure) => - Effect.gen(function* () { - const { nativeProcess, subscribed } = preparePendingProcess(); - const killError = new Error("native kill failed"); - nativeProcess._agent.kill.mockImplementation(() => { - throw killError; - }); - const messages: unknown[] = []; - const logger = Logger.make(({ message }) => { - messages.push(message); - }); - const adapter = yield* PtyAdapter.PtyAdapter; - const fiber = yield* adapter - .spawn(spawnInput) - .pipe( - Effect.provide(Logger.layer([logger], { mergeWithExisting: false })), - Effect.forkChild, - ); - yield* subscribed; - const spawnError = new Error("pipe failed"); - if (failure === "interrupt") yield* Fiber.interrupt(fiber); - else nativeProcess._socket.emit("error", spawnError); - const exit = yield* Fiber.await(fiber); - assert.isTrue(Exit.isFailure(exit)); - if (Exit.isFailure(exit)) { - if (failure === "interrupt") assert.isTrue(Cause.hasInterrupts(exit.cause)); - else { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, PtyAdapter.PtySpawnError); - assert.equal(error.cause, spawnError); - } - } - assert.equal(messages.length, 1); - expect(messages[0]).toMatchObject([ - "failed to cancel Windows terminal startup", - { terminalPid: 0, cause: { cause: killError } }, - ]); - assert.equal(nativeProcess.events.listenerCount("exit"), 0); - assert.equal(nativeProcess._socket.listenerCount("ready_datapipe"), 0); - }).pipe(Effect.provide(layerTest)), -); diff --git a/apps/server/src/terminal/NodePtyAdapter.ts b/apps/server/src/terminal/NodePtyAdapter.ts deleted file mode 100644 index 852c816e24d2..000000000000 --- a/apps/server/src/terminal/NodePtyAdapter.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { resolveHostModuleUrl } from "@t3tools/shared/hostProcess"; -import * as NodeModule from "node:module"; -import * as NodeNet from "node:net"; - -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; -import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; - -import * as PtyAdapter from "./PtyAdapter.ts"; - -export class NodePtyModuleLoadError extends Schema.TaggedError()( - "NodePtyModuleLoadError", - { - platform: Schema.String, - architecture: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to load node-pty for ${this.platform}-${this.architecture}.`; - } -} - -type NodePtyModuleLoader = () => Promise; - -// node-pty stays external to the CLI bundle because it dlopens a native -// addon. The compiled CLI loads the module and its spawn-helper from the -// archive's real filesystem rather than Bun's embedded virtual entrypoint. -const requireForNodePty = NodeModule.createRequire(resolveHostModuleUrl(import.meta.url)); - -const loadNodePty: NodePtyModuleLoader = () => - Promise.resolve().then(() => requireForNodePty("node-pty") as typeof import("node-pty")); - -/** Injectable so tests can substitute a fake module; `require` bypasses module mocks. */ -export const NodePtyModuleLoaderRef = Context.Reference( - "server/terminal/NodePtyModuleLoader", - { defaultValue: () => loadNodePty }, -); - -let didEnsureSpawnHelperExecutable = false; - -const resolveNodePtySpawnHelperPath = Effect.gen(function* () { - const path = yield* Path.Path; - const fs = yield* FileSystem.FileSystem; - const platform = yield* HostProcessPlatform; - const architecture = yield* HostProcessArchitecture; - - const packageJsonPath = requireForNodePty.resolve("node-pty/package.json"); - const packageDir = path.dirname(packageJsonPath); - const candidates = [ - path.join(packageDir, "build", "Release", "spawn-helper"), - path.join(packageDir, "build", "Debug", "spawn-helper"), - path.join(packageDir, "prebuilds", `${platform}-${architecture}`, "spawn-helper"), - ]; - - for (const candidate of candidates) { - if (yield* fs.exists(candidate)) { - return candidate; - } - } - return null; -}).pipe(Effect.orElseSucceed(() => null)); - -const ensureNodePtySpawnHelperExecutable = Effect.fn(function* () { - const fs = yield* FileSystem.FileSystem; - const platform = yield* HostProcessPlatform; - if (platform === "win32") return; - if (didEnsureSpawnHelperExecutable) return; - - const helperPath = yield* resolveNodePtySpawnHelperPath; - if (!helperPath) return; - didEnsureSpawnHelperExecutable = true; - - if (!(yield* fs.exists(helperPath))) { - return; - } - - // Best-effort: avoid FileSystem.stat in packaged mode where some fs metadata can be missing. - yield* fs.chmod(helperPath, 0o755).pipe(Effect.orElseSucceed(() => undefined)); -}); - -/** - * Waits for Windows process creation so the manager receives a valid PID. - * node-pty defers creation to avoid blocking on named pipes: - * https://github.com/microsoft/node-pty/pull/885 - * T3 adopted that behavior when upgrading from 1.1.0 to 1.2.0-beta.15: - * https://github.com/pingdotgg/t3code/pull/13748 - * Its public API has no readiness event. The private ready_datapipe handler sets - * pid before our listener runs. - */ -const waitForWindowsPid = ( - process: import("node-pty").IPty, - trackedProcess: NodePtyProcess, - shell: string, -) => - Effect.callback((resume) => { - const hasPid = () => Number.isInteger(process.pid) && process.pid > 0; - const failure = (cause: unknown) => - Effect.fail(new PtyAdapter.PtySpawnError({ adapter: "node-pty", shell, cause })); - - if (hasPid()) { - resume(Effect.void); - return; - } - - if (!("_socket" in process) || !(process._socket instanceof NodeNet.Socket)) { - resume(failure(new Error("Windows PTY readiness socket is unavailable."))); - return; - } - - const socket = process._socket; - const onReady = () => { - cleanup(); - resume( - hasPid() - ? Effect.void - : failure(new Error("Windows PTY became ready without a valid PID.")), - ); - }; - const onError = (cause: Error) => { - cleanup(); - resume(failure(cause)); - }; - const onClose = () => onError(new Error("Windows PTY closed before its PID was available.")); - let stopExit = () => {}; - const cleanup = () => { - socket.off("ready_datapipe", onReady); - socket.off("error", onError); - socket.off("close", onClose); - stopExit(); - }; - socket.once("ready_datapipe", onReady); - socket.once("error", onError); - socket.once("close", onClose); - stopExit = trackedProcess.onExit(({ exitCode }) => - onError( - new Error(`Windows PTY exited before its PID was available (exit code ${exitCode}).`), - ), - ); - return Effect.sync(cleanup); - }); - -/** - * Cancels Windows startup without waiting for the first output, unlike public kill(). - * The private agent can cancel the pending connection before a child exists. - * Cleanup failures are logged without replacing the startup failure. - */ -const killStartingWindowsPty = (process: import("node-pty").IPty) => - Effect.try(() => { - if ( - "_agent" in process && - typeof process._agent === "object" && - process._agent !== null && - "kill" in process._agent && - typeof process._agent.kill === "function" - ) { - process._agent.kill(); - } else { - process.kill(); - } - }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to cancel Windows terminal startup", { - terminalPid: process.pid, - cause: error, - }), - ), - ); - -class NodePtyProcess implements PtyAdapter.PtyProcess { - private readonly process: import("node-pty").IPty; - private readonly platform: NodeJS.Platform; - private exitEvent: PtyAdapter.PtyExitEvent | undefined; - private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); - private readonly exitSubscription: import("node-pty").IDisposable; - - constructor(process: import("node-pty").IPty, platform: NodeJS.Platform) { - this.process = process; - this.platform = platform; - // Retain exits while Windows readiness and the manager hand off the process. - this.exitSubscription = process.onExit((event) => { - if (this.exitEvent) return; - this.exitEvent = { exitCode: event.exitCode, signal: event.signal ?? null }; - this.exitSubscription.dispose(); - for (const listener of this.exitListeners) listener(this.exitEvent); - this.exitListeners.clear(); - }); - } - - get pid(): number { - return this.process.pid; - } - - write(data: string): void { - this.process.write(data); - } - - resize(cols: number, rows: number): void { - this.process.resize(cols, rows); - } - - kill(signal?: string): void { - // node-pty terminates the Windows process tree without a POSIX signal. - this.process.kill(this.platform === "win32" ? undefined : signal); - } - - onData(callback: (data: string) => void): () => void { - const disposable = this.process.onData(callback); - return () => { - disposable.dispose(); - }; - } - - onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void { - if (this.exitEvent) { - callback(this.exitEvent); - return () => {}; - } - this.exitListeners.add(callback); - return () => { - this.exitListeners.delete(callback); - }; - } - - disposeExitSubscription(): void { - this.exitSubscription.dispose(); - this.exitListeners.clear(); - } -} - -export const make = Effect.fn("NodePtyAdapter.make")(function* () { - const loadNodePtyModule = yield* NodePtyModuleLoaderRef; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const platform = yield* HostProcessPlatform; - const architecture = yield* HostProcessArchitecture; - - const nodePty = yield* Effect.tryPromise({ - try: loadNodePtyModule, - catch: (cause) => - new NodePtyModuleLoadError({ - platform, - architecture, - cause, - }), - }).pipe(Effect.orDie); - - const ensureNodePtySpawnHelperExecutableCached = yield* Effect.cached( - ensureNodePtySpawnHelperExecutable().pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - Effect.provideService(HostProcessPlatform, platform), - Effect.provideService(HostProcessArchitecture, architecture), - Effect.orElseSucceed(() => undefined), - ), - ); - - return PtyAdapter.PtyAdapter.of({ - spawn: Effect.fn("NodePtyAdapter.spawn")(function* (input) { - yield* ensureNodePtySpawnHelperExecutableCached; - // node-pty only writes `name` into the child's TERM on the Unix path; - // the ConPTY path leaves the environment untouched, so Windows children - // inherit a missing or 16-color TERM unless it is set here. - const env = - platform === "win32" && input.env["TERM"] === undefined - ? { ...input.env, TERM: "xterm-256color" } - : input.env; - const ptyProcess = yield* Effect.try({ - try: () => { - const nativeProcess = nodePty.spawn(input.shell, input.args ?? [], { - cwd: input.cwd, - cols: input.cols, - rows: input.rows, - env, - name: "xterm-256color", - }); - return { nativeProcess, process: new NodePtyProcess(nativeProcess, platform) }; - }, - catch: (cause) => - new PtyAdapter.PtySpawnError({ - adapter: "node-pty", - shell: input.shell, - cause, - }), - }); - if (platform === "win32") { - yield* waitForWindowsPid(ptyProcess.nativeProcess, ptyProcess.process, input.shell).pipe( - Effect.onError(() => - Effect.sync(() => ptyProcess.process.disposeExitSubscription()).pipe( - Effect.andThen(killStartingWindowsPty(ptyProcess.nativeProcess)), - ), - ), - ); - } - return ptyProcess.process; - }), - }); -}); - -export const layer = Layer.effect(PtyAdapter.PtyAdapter, make()); From 4004719b82564755d448e1f91f5d5ab6443f9d72 Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Wed, 7 Oct 2026 16:59:13 +0200 Subject: [PATCH 16/32] test(runtime): verify native Bun source and archive boundaries --- .../server/scripts/native-fixtures/runtime.ts | 371 ++++++++++++++++++ apps/server/scripts/smoke-device-tools.ts | 4 +- apps/server/scripts/smoke-native-runtime.ts | 105 +++++ apps/server/src/device/DeviceToolchain.ts | 11 +- 4 files changed, 488 insertions(+), 3 deletions(-) create mode 100644 apps/server/scripts/native-fixtures/runtime.ts create mode 100644 apps/server/scripts/smoke-native-runtime.ts diff --git a/apps/server/scripts/native-fixtures/runtime.ts b/apps/server/scripts/native-fixtures/runtime.ts new file mode 100644 index 000000000000..7f5a8f939b4a --- /dev/null +++ b/apps/server/scripts/native-fixtures/runtime.ts @@ -0,0 +1,371 @@ +// @effect-diagnostics nodeBuiltinImport:off globalTimers:off preferSchemaOverJson:off -- External protocol/native fixture with bounded process deadlines. +import * as NodeAssert from "node:assert"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeEvents from "node:events"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeHttp from "node:http"; +import * as NodeModule from "node:module"; +import * as NodePath from "node:path"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import { resolveSelfInvocation, selfInvocationArgs } from "@t3tools/shared/bunRuntime"; +import { + HostProcessArguments, + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessIsExecutable, + HostProcessPlatform, + resolveHostModuleUrl, +} from "@t3tools/shared/hostProcess"; +import * as BunPtyAdapter from "../../src/terminal/BunPtyAdapter.ts"; +import type { PtyExitEvent, PtyProcess } from "../../src/terminal/PtyAdapter.ts"; +import * as WorkspaceSearchIndex from "../../src/workspace/WorkspaceSearchIndex.ts"; +import { + ANTIGRAVITY_AUTH_BROWSER_MARKER, + makeAntigravityStderrHandler, + prepareAntigravityProfile, +} from "../../src/provider/antigravityAuthSupport.ts"; + +const workspace = process.env.T3_NATIVE_SMOKE_WORKSPACE!; +const platform = await Effect.runPromise(HostProcessPlatform); +NodeAssert.strict.equal( + await Effect.runPromise(HostProcessIsExecutable), + process.env.T3_NATIVE_SMOKE_COMPILED === "true", +); +for (const [name, pinned] of [ + ["@ff-labs/fff-node", "0.9.4"], + ["@napi-rs/keyring", "1.3.0"], +]) { + const manifest = JSON.parse( + await NodeFSP.readFile( + NodePath.join(process.cwd(), "node_modules", name!, "package.json"), + "utf8", + ), + ); + NodeAssert.strict.equal( + manifest.version, + pinned, + `Native smoke must exercise the pinned ${name}`, + ); +} + +function observePty(pty: PtyProcess, marker: string) { + let output = ""; + const ready = Promise.withResolvers(); + const exited = Promise.withResolvers(); + const timer = setTimeout(() => { + ready.reject(new Error(`PTY did not emit ${marker}`)); + exited.reject(new Error("PTY did not exit")); + }, 10_000); + const stopData = pty.onData((data) => { + output += data; + if (output.includes(marker)) ready.resolve(); + }); + const stopExit = pty.onExit((event) => { + clearTimeout(timer); + if (!output.includes(marker)) ready.reject(new Error("PTY exited before readiness")); + exited.resolve(event); + }); + // Both promises are observed immediately, including failure cleanup. + void ready.promise.catch(() => {}); + void exited.promise.catch(() => {}); + return { + ready: ready.promise, + exited: exited.promise, + output: () => output, + dispose: () => { + clearTimeout(timer); + stopData(); + stopExit(); + }, + }; +} + +const adapter = await Effect.runPromise(BunPtyAdapter.make()); +for (const mode of ["io", "kill", "interrupt"]) { + const pty = await Effect.runPromise( + adapter.spawn({ + shell: "/bin/sh", + args: [ + "-c", + mode !== "io" + ? "printf 'PTY-READY\\n'; read waiting" + : "printf 'PTY-READY\\n'; read value; printf '\\n🐧\\n' \"$value\"; stty size; i=0; while [ \"$i\" -lt 2000 ]; do printf '終'; i=$((i + 1)); done; printf 'STREAM-END\\n'; exit 7", + ], + cwd: workspace, + cols: 80, + rows: 24, + env: process.env, + }), + ); + const observed = observePty(pty, "PTY-READY"); + let exited = false; + try { + await observed.ready; + if (mode === "kill") pty.kill("SIGTERM"); + else if (mode === "interrupt") pty.write("\x03"); + else { + pty.resize(100, 41); + pty.write("bun-pty-input\r"); + } + const event = await observed.exited; + exited = true; + if (mode === "kill") NodeAssert.strict.equal(event.signal, 15); + else if (mode === "interrupt") NodeAssert.strict.equal(event.signal, 2); + else { + NodeAssert.strict.equal(event.exitCode, 7); + NodeAssert.strict.match(observed.output(), //); + NodeAssert.strict.match(observed.output(), /41\s+100/); + NodeAssert.strict.match(observed.output(), /🐧/u); + NodeAssert.strict.equal(observed.output().match(/終/gu)?.length, 2000); + NodeAssert.strict.match(observed.output(), /STREAM-END/); + } + NodeAssert.strict.throws(() => process.kill(pty.pid, 0), /No such process|ESRCH/); + } finally { + if (!exited) { + pty.kill("SIGKILL"); + await observed.exited.catch(() => {}); + } + observed.dispose(); + } +} +process.stdout.write("Bun PTY input, resize, exit and captured-child cleanup passed.\n"); + +await NodeFSP.mkdir(NodePath.join(workspace, "src")); +await NodeFSP.writeFile( + NodePath.join(workspace, "src/needle.ts"), + "const native = 'bun-native-needle';\n", +); +await Effect.runPromise( + Effect.gen(function* () { + const index = yield* WorkspaceSearchIndex.make(workspace, "content"); + const paths = yield* index.search("needle", 10, "file"); + NodeAssert.strict.deepEqual(paths.entries, [{ path: "src/needle.ts", kind: "file" }]); + const content = yield* index.searchContents({ + query: "bun-native-needle", + limit: 10, + caseSensitive: true, + wholeWord: false, + useRegex: false, + }); + NodeAssert.strict.deepEqual( + content.matches.map(({ path, lineNumber }) => ({ path, lineNumber })), + [{ path: "src/needle.ts", lineNumber: 1 }], + ); + }).pipe(Effect.scoped), +); +process.stdout.write("Pinned fff native path/content search and scoped index disposal passed.\n"); + +const requireNative = NodeModule.createRequire(resolveHostModuleUrl(import.meta.url)); +const keyring = requireNative("@napi-rs/keyring") as typeof import("@napi-rs/keyring"); +// Read only a new, absent identity. Never inspect, write or delete a user's credentials. +try { + const entry = new keyring.AsyncEntry( + `iglo-native-smoke-${process.pid}-${NodePath.basename(NodePath.dirname(workspace))}`, + "absent-smoke-account", + ); + const password = await entry.getPassword(AbortSignal.timeout(5_000)); + NodeAssert.strict.ok(password === null || password === undefined); + process.stdout.write("Pinned keyring loaded; absent-entry read passed (no live credentials).\n"); +} catch (cause) { + if ( + platform !== "linux" || + !(cause instanceof Error) || + !/platform|storage|dbus|secret service|no entry/i.test(cause.message) + ) + throw cause; + process.stdout.write( + `Pinned keyring loaded; host credential storage unavailable: ${cause.message}\n`, + ); +} + +const compiled = process.env.T3_NATIVE_SMOKE_COMPILED === "true"; +const application = process.env.T3_NATIVE_SMOKE_APPLICATION!; +const hostExecutable = compiled ? application : process.execPath; +const provideHost = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(HostProcessExecutablePath, hostExecutable), + Effect.provideService(HostProcessIsExecutable, compiled), + Effect.provideService(HostProcessArguments, [hostExecutable, application]), + Effect.provideService(HostProcessEnvironment, process.env), + Effect.provide(NodeServices.layer), + ); +const invocation = await Effect.runPromise(provideHost(resolveSelfInvocation())); +function runChild(command: string, args: ReadonlyArray, env = process.env, stdin = "") { + return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { + const child = NodeChildProcess.execFile( + command, + [...args], + { cwd: workspace, env, timeout: 10_000 }, + (error, stdout, stderr) => { + if (error) { + reject(error); + return; + } + try { + NodeAssert.strict.throws(() => process.kill(child.pid!, 0), /No such process|ESRCH/); + resolve({ stdout, stderr }); + } catch (cause) { + reject(cause); + } + }, + ); + child.stdin?.end(stdin); + }); +} + +const requests: string[] = []; +let endpointFailure: unknown; +const endpoint = NodeHttp.createServer(async (request, response) => { + try { + NodeAssert.strict.equal(request.headers.authorization, "Bearer native-smoke-fixture"); + let body = ""; + for await (const data of request) body += data; + const rpc = JSON.parse(body) as { + method: string; + id?: number; + params?: { arguments?: unknown }; + }; + requests.push(rpc.method); + if (rpc.method !== "initialize") { + NodeAssert.strict.equal(request.headers["mcp-session-id"], "native-smoke-session"); + NodeAssert.strict.equal(request.headers["mcp-protocol-version"], "2025-06-18"); + } + if (rpc.method === "initialize") { + response.writeHead(200, { + "content-type": "application/json", + "mcp-session-id": "native-smoke-session", + }); + response.end( + JSON.stringify({ + jsonrpc: "2.0", + id: rpc.id, + result: { + protocolVersion: "2025-06-18", + capabilities: {}, + serverInfo: { name: "native-fixture", version: "1" }, + }, + }), + ); + } else if (rpc.method === "notifications/initialized") { + response.writeHead(202); + response.end(); + } else { + NodeAssert.strict.equal(rpc.method, "tools/call"); + response.writeHead(200, { "content-type": "text/event-stream" }); + response.end( + `data: ${JSON.stringify({ jsonrpc: "2.0", id: rpc.id, result: { structuredContent: { echo: rpc.params?.arguments } } })}\n\n`, + ); + } + } catch (cause) { + endpointFailure = cause; + response.writeHead(500); + response.end("Fixture request failed"); + } +}); +endpoint.listen(0, "127.0.0.1"); +await NodeEvents.EventEmitter.once(endpoint, "listening"); +try { + const address = endpoint.address(); + NodeAssert.strict.ok(address !== null && typeof address !== "string"); + const env = { + ...process.env, + T3_ACP_MCP_ENDPOINT: `http://127.0.0.1:${address.port}/mcp`, + T3_ACP_MCP_AUTHORIZATION: "Bearer native-smoke-fixture", + }; + const bridge = await runChild( + invocation.command, + selfInvocationArgs(invocation, ["acp-mcp-bridge"]), + env, + [ + { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "native-smoke", version: "1" }, + }, + }, + { jsonrpc: "2.0", method: "notifications/initialized" }, + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "echo", arguments: { text: "bun-bridge" } }, + }, + ] + .map((rpc) => JSON.stringify(rpc)) + .join("\n") + "\n", + ); + NodeAssert.strict.equal(bridge.stderr, ""); + const replies = bridge.stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + NodeAssert.strict.deepEqual( + replies.map((reply) => reply.id), + [1, 2], + ); + NodeAssert.strict.deepEqual(replies[1].result.structuredContent, { + echo: { text: "bun-bridge" }, + }); + const call = await runChild( + invocation.command, + selfInvocationArgs(invocation, ["acp-mcp-call", "echo", '{"text":"bun-call"}']), + env, + ); + NodeAssert.strict.deepEqual(JSON.parse(call.stdout), { + structuredContent: { echo: { text: "bun-call" } }, + }); + if (endpointFailure) throw endpointFailure; + NodeAssert.strict.deepEqual(requests, [ + "initialize", + "notifications/initialized", + "tools/call", + "initialize", + "notifications/initialized", + "tools/call", + ]); +} finally { + await new Promise((resolve, reject) => + endpoint.close((error) => (error ? reject(error) : resolve())), + ); +} +process.stdout.write( + "ACP bridge and tool self-invocation passed with JSON/SSE and authenticated session reuse.\n", +); + +const profile = await Effect.runPromise( + provideHost( + prepareAntigravityProfile({ + profileDirectory: NodePath.join(process.env.HOME!, "antigravity"), + userHome: process.env.HOME!, + baseEnv: process.env, + }), + ), +); +const authorizationUrl = + "https://accounts.google.com/o/oauth2/v2/auth?response_type=code&state=native-fixture&redirect_uri=http%3A%2F%2F127.0.0.1%3A14271%2F"; +const relayed = await runChild("/bin/sh", [ + "-c", + profile.browserCommand.replace("%s", authorizationUrl), +]); +NodeAssert.strict.equal(relayed.stdout, ""); +NodeAssert.strict.equal( + relayed.stderr, + `${ANTIGRAVITY_AUTH_BROWSER_MARKER}${JSON.stringify(authorizationUrl)}\n`, +); +const urls: string[] = []; +const handleStderr = makeAntigravityStderrHandler({ + onAuthorizationUrl: (url) => + Effect.sync(() => { + urls.push(url); + }), +}); +await Effect.runPromise(handleStderr(relayed.stderr)); +NodeAssert.strict.deepEqual(urls, [authorizationUrl]); +process.stdout.write( + "Antigravity isolated profile, Bun relay preflight and authorization URL delivery passed.\n", +); diff --git a/apps/server/scripts/smoke-device-tools.ts b/apps/server/scripts/smoke-device-tools.ts index 2c2c4ef7c7b7..fd30350cf5de 100644 --- a/apps/server/scripts/smoke-device-tools.ts +++ b/apps/server/scripts/smoke-device-tools.ts @@ -1,5 +1,5 @@ #!/usr/bin/env bun -// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - this external smoke harness owns disposable helper processes and state. +// @effect-diagnostics nodeBuiltinImport:off globalTimers:off globalFetch:off globalFetchInEffect:off globalConsole:off - this external smoke harness owns disposable helper processes, HTTP assertions and deadline timers. /** Installs and exercises the pinned Device packages with no Node or npm on PATH. */ import * as NodeAssert from "node:assert"; import * as NodeChildProcess from "node:child_process"; @@ -97,7 +97,7 @@ const Started = Schema.Struct({ const decode = ( schema: S, text: string, -) => Schema.decodeUnknownSync(Schema.fromJsonString(schema))(text); +) => Schema.decodeSync(Schema.fromJsonString(schema))(text); const remote = async (mode: "probe" | "agent-start" | "stop") => { const result = await run(["-e", remoteDeviceScript("smoke", mode)]); return result.stdout.trim(); diff --git a/apps/server/scripts/smoke-native-runtime.ts b/apps/server/scripts/smoke-native-runtime.ts new file mode 100644 index 000000000000..174a760d9420 --- /dev/null +++ b/apps/server/scripts/smoke-native-runtime.ts @@ -0,0 +1,105 @@ +#!/usr/bin/env bun +// @effect-diagnostics nodeBuiltinImport:off -- This external runner owns temporary native fixtures and child processes. +import * as NodeAssert from "node:assert"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as NodeUtil from "node:util"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; + +const exec = NodeUtil.promisify(NodeChildProcess.execFile); +const platform = await Effect.runPromise(HostProcessPlatform); +const scriptDirectory = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const [requestedMode = "source", archive, ...extra] = process.argv.slice(2); +const mode = requestedMode.replace(/^--/u, ""); +NodeAssert.strict.ok( + extra.length === 0 && + ((mode === "source" && archive === undefined) || (mode === "archive" && archive)), + "Usage: bun apps/server/scripts/smoke-native-runtime.ts source | archive ", +); +NodeAssert.strict.equal( + (await exec(process.execPath, ["-p", "process.versions.bun ?? ''"])).stdout.trim(), + "1.4.0", +); +const scratch = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "iglo-native-bun-smoke-")); +try { + let root = scratch; + let bun = process.execPath; + let application = NodePath.resolve(scriptDirectory, "../src/bin.ts"); + if (mode === "archive") { + await exec("/usr/bin/tar", ["-xzf", NodePath.resolve(archive!), "-C", scratch]); + const entries = await NodeFSP.readdir(scratch); + NodeAssert.strict.equal(entries.length, 1, "The archive must contain one install root"); + root = NodePath.join(scratch, entries[0]!); + bun = NodePath.join(root, "runtime/bun"); + application = NodePath.join(root, "t3"); + } else { + await NodeFSP.symlink( + NodePath.resolve(scriptDirectory, "../node_modules"), + NodePath.join(root, "node_modules"), + ); + } + const fixture = NodePath.join( + root, + mode === "archive" ? "native-runtime-smoke" : "native-runtime-smoke.mjs", + ); + await exec( + process.execPath, + [ + "build", + NodePath.join(scriptDirectory, "native-fixtures/runtime.ts"), + ...(mode === "archive" ? ["--compile", "--compile-autoload-package-json"] : []), + "--target=bun", + "--packages=bundle", + "--external=@ff-labs/*", + "--external=@napi-rs/keyring*", + "--outfile", + fixture, + ], + { maxBuffer: 1024 * 1024 }, + ); + if (mode === "archive" && platform === "darwin") { + await exec("/usr/bin/codesign", ["--force", "--sign", "-", fixture]); + } + const bin = NodePath.join(scratch, "bin"); + const home = NodePath.join(scratch, "home"); + const workspace = NodePath.join(scratch, "workspace"); + await Promise.all([NodeFSP.mkdir(bin), NodeFSP.mkdir(home), NodeFSP.mkdir(workspace)]); + // The PTY shell needs one OS utility. No Node, npm, npx or system Bun is visible. + const stty = (await exec("/bin/sh", ["-c", "command -v stty"])).stdout.trim(); + await NodeFSP.symlink(stty, NodePath.join(bin, "stty")); + const env: NodeJS.ProcessEnv = { + PATH: bin, + HOME: home, + TMPDIR: scratch, + LANG: "en_US.UTF-8", + TERM: "xterm-256color", + T3_NATIVE_SMOKE_APPLICATION: application, + T3_NATIVE_SMOKE_COMPILED: String(mode === "archive"), + T3_NATIVE_SMOKE_WORKSPACE: workspace, + }; + for (const command of ["node", "npm", "npx", "bun"]) { + await NodeAssert.strict.rejects(exec(command, ["--version"], { cwd: root, env })); + } + NodeAssert.strict.equal( + (await exec(bun, ["-p", "process.versions.bun ?? ''"], { cwd: root, env })).stdout.trim(), + "1.4.0", + ); + const result = await exec( + mode === "archive" ? fixture : bun, + mode === "archive" ? [] : [fixture], + { + cwd: root, + env, + timeout: 60_000, + maxBuffer: 1024 * 1024, + }, + ); + process.stdout.write(result.stdout); + process.stderr.write(result.stderr); +} finally { + await NodeFSP.rm(scratch, { recursive: true, force: true }); +} diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts index 76e9b8445c0b..ad0376a134b5 100644 --- a/apps/server/src/device/DeviceToolchain.ts +++ b/apps/server/src/device/DeviceToolchain.ts @@ -33,6 +33,15 @@ export const AGENT_DEVICE_VERSION = "0.21.12"; const INSTALL_TIMEOUT = Duration.minutes(10); const installLock = Semaphore.makeUnsafe(1); +const encodeInstallManifest = Schema.encodeSync( + Schema.fromJsonString( + Schema.Struct({ + private: Schema.Boolean, + dependencies: Schema.Record(Schema.String, Schema.String), + trustedDependencies: Schema.Array(Schema.String), + }), + ), +); export interface DeviceToolPaths { readonly installDir: string; @@ -141,7 +150,7 @@ const installTool = Effect.fn("DeviceToolchain.installTool")(function* ( yield* fs .writeFileString( path.join(stagingDir, "package.json"), - JSON.stringify({ + encodeInstallManifest({ private: true, dependencies: { [spec.name]: spec.version }, trustedDependencies: [], From 05e2d0565fe28f76dd0d0e8d0e269bb18a8872dd Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Wed, 7 Oct 2026 17:19:30 +0200 Subject: [PATCH 17/32] test(runtime): verify Bun source and archive application boundaries --- .devcontainer/devcontainer.json | 6 +- .devcontainer/on-create.sh | 7 + .devcontainer/update-content.sh | 2 +- .github/triage/PLAYBOOK.md | 2 +- .github/workflows/bun-runtime.yml | 110 ++++ .github/workflows/ci.yml | 39 +- .github/workflows/windows-tests.yml | 75 --- apps/server/package.json | 1 - apps/server/scripts/web-client-regression.ts | 64 ++- apps/server/scripts/web-fixtures/rpc.ts | 2 +- apps/server/src/auth/PairingGrantStore.ts | 2 +- apps/server/src/cli/invocation.test.ts | 57 +- apps/server/src/cli/invocation.ts | 22 +- apps/server/src/cli/pair.test.ts | 4 +- apps/server/src/cli/pair.ts | 2 +- apps/server/src/cli/triagePrompt.ts | 2 +- apps/server/src/cli/uninstall.ts | 2 +- apps/server/src/cloud/pinnedRuntime.ts | 4 +- .../components/ServerUpdateAction.test.tsx | 14 +- .../web/src/components/ServerUpdateAction.tsx | 17 +- .../components/onboarding/WelcomeWizard.tsx | 6 +- apps/web/src/versionSkew.test.ts | 20 +- apps/web/src/versionSkew.ts | 11 +- docs/internals/devcontainer.md | 2 +- docs/internals/devices.md | 7 +- docs/internals/effect-services.md | 2 +- docs/internals/open-source-licenses.md | 2 +- docs/operations/background-verification.md | 6 +- docs/operations/development.md | 17 +- docs/operations/observability.md | 24 +- docs/operations/release.md | 78 +-- docs/user/devices.md | 6 +- docs/user/install.md | 35 +- docs/user/remote-access.md | 6 +- docs/user/updating.md | 15 +- patches/node-pty@1.2.0-beta.15.patch | 35 -- pnpm-lock.yaml | 19 +- pnpm-workspace.yaml | 2 - scripts/apply-web-brand-assets.ts | 2 +- scripts/export-brand-icons.ts | 2 +- scripts/lib/environment-smoke.ts | 527 ++++++++++++++++++ scripts/notify-discord-release.ts | 2 +- scripts/package.json | 1 + scripts/release-smoke.ts | 2 +- scripts/resolve-nightly-release.ts | 2 +- scripts/resolve-previous-release-tag.ts | 2 +- scripts/setup-worktree.ts | 6 +- scripts/smoke-cli-archive.ts | 233 ++------ scripts/sync-reference-repos.ts | 2 +- scripts/update-release-package-versions.ts | 2 +- t3.json | 2 +- 51 files changed, 955 insertions(+), 557 deletions(-) create mode 100644 .github/workflows/bun-runtime.yml delete mode 100644 .github/workflows/windows-tests.yml delete mode 100644 patches/node-pty@1.2.0-beta.15.patch create mode 100644 scripts/lib/environment-smoke.ts diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5880459d75a1..c899d38e09c6 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,12 +1,12 @@ -// Dev container for T3 Code. Mirrors CI (ubuntu-24.04, Node 24, Rust stable) +// Dev container for T3 Code. Mirrors CI (ubuntu-24.04, Bun runtime, Node 24 tooling, Rust stable) // and the canonical setup in docs/operations/development.md: global `vp`, `vp i`. // Contributor doc: docs/internals/devcontainer.md { "name": "T3 Code", "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", "features": { - // nodeGypDependencies (default true) brings python3/make/g++, which Linux - // needs for node-pty's node-gyp fallback (its prebuilds are mac/win only). + // Node serves the Vite+/pnpm toolchain; native dependencies may need + // the feature's default Python/make/C++ build tools. "ghcr.io/devcontainers/features/node:2": { "version": "24" }, diff --git a/.devcontainer/on-create.sh b/.devcontainer/on-create.sh index 964c4fb2438d..18447a65841f 100755 --- a/.devcontainer/on-create.sh +++ b/.devcontainer/on-create.sh @@ -24,6 +24,13 @@ rm -f "$installer" test -x "$VP_BIN_DIR/vp" sudo ln -sf "$VP_BIN_DIR/vp" /usr/local/bin/vp +# Install the same application runtime used by source and release checks. +bun_version=$(cat .bun-version) +curl -fsSL https://bun.com/install -o "$installer" +bash "$installer" "bun-v$bun_version" +rm -f "$installer" +sudo ln -sf "$HOME/.bun/bin/bun" /usr/local/bin/bun + # First-run terminal notice, rendered by the devcontainers base image. sudo mkdir -p /usr/local/etc/vscode-dev-containers sudo tee /usr/local/etc/vscode-dev-containers/first-run-notice.txt >/dev/null <<'EOF' diff --git a/.devcontainer/update-content.sh b/.devcontainer/update-content.sh index 6472d464fb1a..337203fd561e 100644 --- a/.devcontainer/update-content.sh +++ b/.devcontainer/update-content.sh @@ -12,4 +12,4 @@ for dir in "$HOME/.cache" "$HOME/.cache/pnpm" node_modules; do done CI=true vp i -node apps/web/scripts/warm-dep-cache.ts +bun apps/web/scripts/warm-dep-cache.ts diff --git a/.github/triage/PLAYBOOK.md b/.github/triage/PLAYBOOK.md index 32def6bc0c16..8259ee61102e 100644 --- a/.github/triage/PLAYBOOK.md +++ b/.github/triage/PLAYBOOK.md @@ -51,7 +51,7 @@ Diagnosis grounded in source beats guessing. First establish the shape of the install, because the same symptom points at different code depending on it: -- How is T3 Code running on this machine: `npx t3 serve` in a terminal, the +- How is T3 Code running on this machine: `t3 serve` in a terminal, the background service, or the desktop app? - Which surface is the user connecting from: the website (app.t3.codes), the desktop app against a local server, the desktop app against a remote server, diff --git a/.github/workflows/bun-runtime.yml b/.github/workflows/bun-runtime.yml new file mode 100644 index 000000000000..e7957848b846 --- /dev/null +++ b/.github/workflows/bun-runtime.yml @@ -0,0 +1,110 @@ +name: Bun runtime compatibility + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: bun-runtime-${{ github.ref }} + cancel-in-progress: true + +jobs: + environment: + name: Bun source and archive (${{ matrix.target }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - runner: blacksmith-12vcpu-macos-26 + platform: mac + target: darwin-arm64 + arch: arm64 + - runner: blacksmith-8vcpu-ubuntu-2404 + platform: linux + target: linux-x64 + arch: x64 + - runner: blacksmith-8vcpu-ubuntu-2404-arm + platform: linux + target: linux-arm64 + arch: arm64 + steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: .bun-version + - uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + - uses: dtolnay/rust-toolchain@stable + - name: Prepare pinned browser + shell: bash + run: | + if [[ "$RUNNER_OS" == "Linux" ]]; then + bun apps/server/node_modules/playwright-core/cli.js install-deps chromium + fi + bun -e 'import { previewBrowserRelease } from "./apps/server/src/preview/PreviewBrowser.ts"; const release = previewBrowserRelease(process.platform, process.arch); if (!release) throw new Error("Unsupported browser target"); process.stdout.write(JSON.stringify(release));' > "$RUNNER_TEMP/chrome-release.json" + chrome_url=$(bun -e 'console.log(JSON.parse(await Bun.file(process.argv[1]).text()).url)' "$RUNNER_TEMP/chrome-release.json") + chrome_sha=$(bun -e 'console.log(JSON.parse(await Bun.file(process.argv[1]).text()).sha256)' "$RUNNER_TEMP/chrome-release.json") + chrome_platform=$(bun -e 'console.log(JSON.parse(await Bun.file(process.argv[1]).text()).platform)' "$RUNNER_TEMP/chrome-release.json") + curl -fL "$chrome_url" -o "$RUNNER_TEMP/chrome.zip" + bun -e 'import { createHash } from "node:crypto"; const actual = createHash("sha256").update(await Bun.file(process.argv[1]).arrayBuffer()).digest("hex"); if (actual !== process.argv[2]) throw new Error("Chromium checksum mismatch");' "$RUNNER_TEMP/chrome.zip" "$chrome_sha" + unzip -q "$RUNNER_TEMP/chrome.zip" -d "$RUNNER_TEMP/chrome" + echo "T3_WEB_REGRESSION_BROWSER=$RUNNER_TEMP/chrome/chrome-headless-shell-$chrome_platform/chrome-headless-shell" >> "$GITHUB_ENV" + echo "T3_WEB_REGRESSION_ARTIFACTS=$RUNNER_TEMP/web-regression-source" >> "$GITHUB_ENV" + - name: Bun source transport and persistence + run: bun scripts/smoke-cli-archive.ts --source + - name: Bun source native and helper boundaries + run: bun apps/server/scripts/smoke-native-runtime.ts source + - name: Real development web client + run: bun apps/server/scripts/web-client-regression.ts source + - name: Pinned Device tools with Bun only + run: bun apps/server/scripts/smoke-device-tools.ts + - name: Build client, server, and native monitor + shell: bash + run: | + vp run --filter t3 build + cargo build --locked --release --manifest-path native/resource-monitor/Cargo.toml + mkdir -p "$RUNNER_TEMP/resource-monitor/${{ matrix.target }}" + cp native/resource-monitor/target/release/t3-resource-monitor "$RUNNER_TEMP/resource-monitor/${{ matrix.target }}/t3-resource-monitor" + bun apps/server/scripts/cli.ts build-exe --target "${{ matrix.target }}" + version=$(bun -e 'console.log((await Bun.file("apps/server/package.json").json()).version)') + bun scripts/build-cli-archive.ts --platform "${{ matrix.platform }}" --arch "${{ matrix.arch }}" --version "$version" --resource-monitor-dir "$RUNNER_TEMP/resource-monitor" --output-dir "$RUNNER_TEMP/release-cli" + echo "T3_RUNTIME_ARCHIVE=$RUNNER_TEMP/release-cli/t3-$version-${{ matrix.target }}.tar.gz" >> "$GITHUB_ENV" + echo "T3_RUNTIME_VERSION=$version" >> "$GITHUB_ENV" + - name: Extracted archive transport and persistence without system runtimes + run: bun scripts/smoke-cli-archive.ts --archive "$T3_RUNTIME_ARCHIVE" --expect-version "$T3_RUNTIME_VERSION" + - name: Archive native and helper boundaries + run: bun apps/server/scripts/smoke-native-runtime.ts archive "$T3_RUNTIME_ARCHIVE" + - name: Compiled service update and rollback + shell: bash + run: | + mkdir -p "$RUNNER_TEMP/service-runtime" + tar -xf "$T3_RUNTIME_ARCHIVE" -C "$RUNNER_TEMP/service-runtime" + T3_SERVICE_TEST_EXECUTABLE="$RUNNER_TEMP/service-runtime/t3-$T3_RUNTIME_VERSION-${{ matrix.target }}/t3" vp test run apps/server/src/serviceLauncher.runtime.test.ts + - name: Real shipped web client + env: + T3_WEB_REGRESSION_ARTIFACTS: ${{ runner.temp }}/web-regression-archive + run: bun apps/server/scripts/web-client-regression.ts archive "$T3_RUNTIME_ARCHIVE" "$T3_RUNTIME_VERSION" + - name: Upload verification evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: bun-runtime-${{ matrix.target }} + path: | + ${{ runner.temp }}/web-regression-source + ${{ runner.temp }}/web-regression-archive + if-no-files-found: ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d7653a272af..3657804b67d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,11 @@ jobs: exit 1 fi + - name: Setup Bun application runtime + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: .bun-version + - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: @@ -61,6 +66,11 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false + - name: Setup Bun application runtime + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: .bun-version + - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: @@ -87,6 +97,11 @@ jobs: - uses: ./.github/actions/setup-apt-mirrors # Runs in the background while Vite+ installs; the step before the build waits for it. + - name: Setup Bun application runtime + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: .bun-version + - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: @@ -122,6 +137,11 @@ jobs: - uses: ./.github/actions/setup-apt-mirrors + - name: Setup Bun application runtime + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: .bun-version + - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: @@ -150,6 +170,11 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false + - name: Setup Bun application runtime + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: .bun-version + - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: @@ -183,6 +208,11 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false + - name: Setup Bun application runtime + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: .bun-version + - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: @@ -191,7 +221,7 @@ jobs: run-install: true - name: Install Chromium for server browser integration tests - run: node apps/server/node_modules/playwright-core/cli.js install --with-deps --only-shell chromium + run: bun apps/server/node_modules/playwright-core/cli.js install --with-deps --only-shell chromium - name: Test env: @@ -291,6 +321,11 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false + - name: Setup Bun application runtime + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: .bun-version + - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: @@ -301,7 +336,7 @@ jobs: - --filter=@t3tools/scripts... - name: Exercise release-only workflow steps - run: node scripts/release-smoke.ts + run: bun scripts/release-smoke.ts # Branch protection requires this job by its name, `Check`. It fails when any # other job fails, is cancelled, or is skipped without a reason, so splitting diff --git a/.github/workflows/windows-tests.yml b/.github/workflows/windows-tests.yml deleted file mode 100644 index 46289a23fb91..000000000000 --- a/.github/workflows/windows-tests.yml +++ /dev/null @@ -1,75 +0,0 @@ -# On-demand Windows test lane. Manual only: nothing in the suite passes on -# Windows yet, so this exists to give contributors (and agents) a cloud Windows -# box to iterate against. Once the suite is green here, fold it into ci.yml. -# -# gh workflow run windows-tests.yml --ref -f package=packages/shared -# gh workflow run windows-tests.yml --ref -f package=apps/server \ -# -f files="src/process/externalLauncher.test.ts src/cli/theme.test.ts" -# gh run watch && gh run view --log-failed -name: Windows Tests - -on: - workflow_dispatch: - inputs: - package: - description: "Workspace directory to test, e.g. apps/server or packages/shared. Empty runs every package except apps/server." - type: string - default: "" - files: - description: "Space-separated test files relative to the package directory. Empty runs the package's whole suite. Requires package." - type: string - default: "" - -permissions: - contents: read - -jobs: - test: - name: Test (${{ inputs.package || 'all non-server' }}) - runs-on: blacksmith-8vcpu-windows-2025 - timeout-minutes: 45 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - # setup-vp's own cache restores a Linux-shaped store on Windows, which is - # slower than no cache (see #7975). Cache pnpm's Windows store directly. - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: false - run-install: false - - - name: Resolve package cache path - id: package_cache_path - shell: pwsh - run: '"path=$(vp pm cache dir)" >> $env:GITHUB_OUTPUT' - - - name: Cache packages - uses: actions/cache@v6 - with: - path: ${{ steps.package_cache_path.outputs.path }} - key: windows-tests-packages-v1-${{ hashFiles('pnpm-lock.yaml') }} - - - name: Install - run: vp install - - - name: Test - shell: pwsh - run: | - $package = '${{ inputs.package }}' - $files = '${{ inputs.files }}' - if ($package -eq '') { - vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test - } elseif ($files -eq '') { - vp run --filter "./$package" test - } else { - Set-Location $package - vp test run $files.Split(' ') - } diff --git a/apps/server/package.json b/apps/server/package.json index 63ab7b7edb7a..9c62b0364174 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -42,7 +42,6 @@ "diff": "8.0.3", "effect": "catalog:", "jose": "catalog:", - "node-pty": "^1.2.0-beta.15", "playwright-core": "1.60.0", "proper-lockfile": "4.1.2", "stream-chain": "^4.2.5", diff --git a/apps/server/scripts/web-client-regression.ts b/apps/server/scripts/web-client-regression.ts index 11bb1e6dac9d..3d56739ae9e0 100644 --- a/apps/server/scripts/web-client-regression.ts +++ b/apps/server/scripts/web-client-regression.ts @@ -180,7 +180,9 @@ async function terminalMilestone( Stream.take(1), Stream.runHead, ), - ); + ).catch((error: unknown) => { + throw new Error(`Terminal milestone ${predicate.toString()}: ${String(error)}`); + }); NodeAssert.ok(Option.isSome(result), "The terminal stream must expose the requested milestone."); return result.value; } @@ -309,6 +311,7 @@ export async function runWebClientRegression( await page.goto(primaryPairing); NodeAssert.equal((await paired).status(), 200); await page.waitForURL((url) => url.pathname !== "/pair"); + pairingActive = getPairingTokenFromUrl(new URL(page.url())) !== null; await visible(page.getByText("Project environment-a", { exact: true }).first()); await page.goto(new URL(seeded.route, primary.origin).href); await visible(page.getByTestId("composer-editor")); @@ -329,12 +332,20 @@ export async function runWebClientRegression( await sendMessage(page, "Run the controlled streaming turn"); await visible(page.getByText("Streaming from environment-a", { exact: true })); await visible( - page.getByRole("button", { name: /^(?:Running printf|Ran printf|Ran 1 command)$/ }).first(), + page + .getByRole("button", { + name: /^(?:printf fixture-tool|Running printf|Ran printf|Ran 1 command)$/, + }) + .first(), ); await visible(page.getByRole("button", { name: "Stop generation", exact: true })); await releaseProvider(dependencies); await visible(page.getByText("Finished from environment-a.", { exact: true })); - await visible(page.getByRole("button", { name: /^(?:Ran printf|Ran 1 command)$/ }).first()); + await visible( + page + .getByRole("button", { name: /^(?:printf fixture-tool|Ran printf|Ran 1 command)$/ }) + .first(), + ); await page .getByRole("button", { name: "Stop generation", exact: true }) .waitFor({ state: "hidden" }); @@ -357,19 +368,46 @@ export async function runWebClientRegression( .filter({ hasText: "Thread environment-a" }); await settledRow.hover(); await settledRow.getByRole("button", { name: "Un-settle thread", exact: true }).click(); + await page.goto(new URL(seeded.route, primary.origin).href); + await visible(page.getByTestId("composer-editor")); }); await milestone("terminal input, output, resize, error and exit", async () => { + await page.setViewportSize({ width: 1280, height: 900 }); await addSurface(page, "Terminal"); const inputField = page.getByLabel("Terminal input").first(); await visible(inputField); + // The textarea appears before fonts/WASM finish loading. Wait for the + // public canvas to paint the shell prompt before sending real keystrokes. + await page.waitForFunction(`() => { + const input = document.querySelector('textarea[aria-label="Terminal input"]'); + const canvas = input?.parentElement?.querySelector('canvas'); + if (!canvas || !canvas.width || !canvas.height) return false; + const pixels = canvas.getContext("2d")?.getImageData( + 0, 0, Math.min(canvas.width, 300), Math.min(canvas.height, 100) + ).data; + if (!pixels) return false; + let ink = 0; + for (let i = 4; i < pixels.length; i += 4) { + if (Math.abs(pixels[i] - pixels[0]) > 40 + || Math.abs(pixels[i + 1] - pixels[1]) > 40 + || Math.abs(pixels[i + 2] - pixels[2]) > 40) ink += 1; + } + return ink >= 20; + }`); + await page.screenshot({ path: NodePath.join(artifacts, "terminal-ready.png") }); await withRegressionRpc(primary, async (client) => { + NodeAssert.equal(await inputField.getAttribute("readonly"), null); await inputField.pressSequentially("printf 'terminal-io-fixture\\n'"); await inputField.press("Enter"); + NodeAssert.equal( + await inputField.inputValue(), + "", + "The rendered terminal must consume keyboard input.", + ); await terminalMilestone(client, seeded.threadId, (event) => /\r?\nterminal-io-fixture\r?\n/.test(terminalText(event)), ); - await page.setViewportSize({ width: 1280, height: 900 }); await requestRpc( client[WS_METHODS.terminalResize]({ threadId: seeded.threadId, @@ -431,6 +469,10 @@ export async function runWebClientRegression( exact: true, }); await visible(updateChecks); + await page.waitForFunction( + (element) => element?.getAttribute("aria-checked") === "false", + await updateChecks.elementHandle(), + ); await withRegressionRpc(primary, async (client) => { await updateChecks.click(); const saved = await requestRpc( @@ -620,6 +662,20 @@ export async function runWebClientRegression( "The real client must not throw unhandled page errors.", ); } catch (error) { + await NodeFSP.writeFile( + NodePath.join(artifacts, "page-errors.log"), + browserErrors.map(redact).join("\n"), + ); + if (!pairingActive) + await NodeFSP.writeFile( + NodePath.join(artifacts, "page.txt"), + redact( + await page + .locator("body") + .innerText() + .catch(() => "Page unavailable"), + ), + ); if (!pairingActive) await page .screenshot({ path: NodePath.join(artifacts, "failure.png") }) diff --git a/apps/server/scripts/web-fixtures/rpc.ts b/apps/server/scripts/web-fixtures/rpc.ts index 1ead8c5bc171..574fd9a49610 100644 --- a/apps/server/scripts/web-fixtures/rpc.ts +++ b/apps/server/scripts/web-fixtures/rpc.ts @@ -9,7 +9,7 @@ import * as Socket from "effect/socket/Socket"; import type { EnvironmentFixture } from "../../../../scripts/lib/environment-smoke.ts"; const makeClient = RpcClient.make(WsRpcGroup); -const decodeTicket = Schema.decodeUnknownSync(AuthWebSocketTicketResult); +const decodeTicket = Schema.decodeUnknownSync(Schema.toCodecJson(AuthWebSocketTicketResult)); export type RegressionRpcClient = Effect.Success; /** The same authenticated transport the web client uses, with a scoped socket. */ diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index d3f5bf7ef8e5..a0817cd8c0f4 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -262,7 +262,7 @@ const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5); // the user out of the backend. const DESKTOP_BOOTSTRAP_TTL_HOURS = Duration.hours(24); // A dev server's startup token is read off a log by whoever (or whatever) is -// driving the session, often minutes later — after a `node --watch` restart, a +// driving the session, often minutes later — after a `bun --watch` restart, a // detour into another task, or a hand-off to the person actually doing the // testing. Five minutes turns that into a restart-the-server loop for no // security benefit: the token only unlocks a local dev backend, and its holder diff --git a/apps/server/src/cli/invocation.test.ts b/apps/server/src/cli/invocation.test.ts index 56e4b9c06434..414b85e64f71 100644 --- a/apps/server/src/cli/invocation.test.ts +++ b/apps/server/src/cli/invocation.test.ts @@ -18,25 +18,28 @@ import { it("formats package runner commands from their cache entry paths", () => { for (const [entryPath, expected] of [ - ["/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs", "npx t3 serve"], + ["/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs", "npx @iglo-tech/iglo-code serve"], [ "C:\\Users\\theo\\AppData\\Local\\npm-cache\\_npx\\abc\\node_modules\\t3\\dist\\bin.mjs", - "npx t3 serve", + "npx @iglo-tech/iglo-code serve", + ], + [ + "/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs", + "pnpm dlx @iglo-tech/iglo-code serve", ], - ["/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs", "pnpm dlx t3 serve"], [ "/home/theo/.local/share/pnpm/.pnpm/dlx/abc/node_modules/t3/dist/bin.mjs", - "pnpm dlx t3 serve", + "pnpm dlx @iglo-tech/iglo-code serve", ], [ "C:\\Users\\theo\\AppData\\Local\\pnpm-cache\\dlx\\abc\\node_modules\\t3\\dist\\bin.mjs", - "pnpm dlx t3 serve", + "pnpm dlx @iglo-tech/iglo-code serve", ], - ["/home/theo/.bun/install/cache/t3@0.0.31/dist/bin.mjs", "bunx t3 serve"], - ["/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs", "bunx t3 serve"], + ["/home/theo/.bun/install/cache/t3@0.0.31/dist/bin.mjs", "bunx @iglo-tech/iglo-code serve"], + ["/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs", "bunx @iglo-tech/iglo-code serve"], [ "C:\\Users\\theo\\AppData\\Local\\Temp\\bunx-0-t3@latest\\node_modules\\t3\\dist\\bin.mjs", - "bunx t3 serve", + "bunx @iglo-tech/iglo-code serve", ], ] as const) { assert.equal(formatCliCommand({ subcommand: "serve", entryPath, version: "0.0.31" }), expected); @@ -59,10 +62,10 @@ it("treats stable installs as direct invocations", () => { it("re-suggests the prerelease channel only for prerelease builds", () => { for (const [version, expected] of [ - ["0.0.31-nightly.20260729", "npx t3@nightly serve"], - ["0.0.31-preview.20260729.1", "npx t3@preview serve"], - ["0.0.31-foo-preview.20260729.1", "npx t3 serve"], - ["0.0.31", "npx t3 serve"], + ["0.0.31-nightly.20260729", "npx @iglo-tech/iglo-code@nightly serve"], + ["0.0.31-preview.20260729.1", "npx @iglo-tech/iglo-code@preview serve"], + ["0.0.31-foo-preview.20260729.1", "npx @iglo-tech/iglo-code serve"], + ["0.0.31", "npx @iglo-tech/iglo-code serve"], ] as const) { assert.equal( formatCliCommand({ @@ -82,7 +85,7 @@ it("formats serve suggestions to match the launching command", () => { entryPath: "/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs", version: "0.0.31-nightly.20260729", }), - "npx t3@nightly serve", + "npx @iglo-tech/iglo-code@nightly serve", ); assert.equal( formatCliCommand({ @@ -90,7 +93,7 @@ it("formats serve suggestions to match the launching command", () => { entryPath: "/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs", version: "0.0.31", }), - "bunx t3 serve", + "bunx @iglo-tech/iglo-code serve", ); assert.equal( formatCliCommand({ @@ -102,7 +105,7 @@ it("formats serve suggestions to match the launching command", () => { ); }); -it.effect("keeps a user-installed Node reachable when the command runs under sudo", () => +it.effect("keeps a user-installed runtime reachable when the command runs under sudo", () => Effect.gen(function* () { const command = (node: string, entry: string) => resolveRootCliCommand("browser setup").pipe( @@ -111,10 +114,12 @@ it.effect("keeps a user-installed Node reachable when the command runs under sud ); const npx = "/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"; // sudo's secure_path already has a system Node. - expect(yield* command("/usr/bin/node", npx)).toBe("sudo npx t3 browser setup"); + expect(yield* command("/usr/bin/node", npx)).toBe( + "sudo npx @iglo-tech/iglo-code browser setup", + ); // nvm, fnm, and tarball installs are dropped by sudo's PATH reset. expect(yield* command("/home/theo/.nvm/versions/node/v24/bin/node", npx)).toBe( - 'sudo env "PATH=$PATH" npx t3 browser setup', + 'sudo env "PATH=$PATH" npx @iglo-tech/iglo-code browser setup', ); expect( yield* command( @@ -130,7 +135,7 @@ it.layer(NodeServices.layer)("manual server installation ownership", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped(); + const root = yield* fs.makeTempDirectoryScoped().pipe(Effect.flatMap(fs.realPath)); for (const [relative, kind] of [ ["npm/_npx/hash/node_modules/t3/dist/bin.mjs", "npx"], ["npm/_npx/hash/node_modules/@t3code/t3-linux-x64/t3", "npx"], @@ -154,7 +159,7 @@ it.layer(NodeServices.layer)("manual server installation ownership", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped(); + const root = yield* fs.makeTempDirectoryScoped().pipe(Effect.flatMap(fs.realPath)); const prefix = path.join(root, "bunx-tools"); const packageRoot = path.join(prefix, "lib/node_modules/t3"); const entry = path.join(packageRoot, "dist/bin.mjs"); @@ -181,15 +186,15 @@ it.layer(NodeServices.layer)("manual server installation ownership", (it) => { }), ); - it.effect("proves the native executable belongs to the npm launcher", () => + it.effect("proves the fork executable belongs to the scoped npm launcher", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped(); + const root = yield* fs.makeTempDirectoryScoped().pipe(Effect.flatMap(fs.realPath)); const prefix = path.join(root, "bunx-tools"); - const packageRoot = path.join(prefix, "lib/node_modules/t3"); + const packageRoot = path.join(prefix, "lib/node_modules/@iglo-tech/iglo-code"); const launcher = path.join(packageRoot, "bin/t3.js"); - const entry = path.join(packageRoot, "node_modules/@t3code/t3-linux-x64/t3"); + const entry = path.join(packageRoot, "node_modules/@iglo-tech/iglo-code-linux-x64/t3"); yield* fs.makeDirectory(path.dirname(launcher), { recursive: true }); yield* fs.makeDirectory(path.dirname(entry), { recursive: true }); yield* fs.makeDirectory(path.join(prefix, "bin")); @@ -197,7 +202,7 @@ it.layer(NodeServices.layer)("manual server installation ownership", (it) => { yield* fs.writeFileString(entry, ""); yield* fs.writeFileString( path.join(packageRoot, "package.json"), - '{"name":"t3","version":"0.0.45","bin":{"t3":"./bin/t3.js"},"optionalDependencies":{"@t3code/t3-linux-x64":"0.0.45"}}', + '{"name":"@iglo-tech/iglo-code","version":"0.0.45","bin":{"t3":"./bin/t3.js"},"optionalDependencies":{"@iglo-tech/iglo-code-linux-x64":"0.0.45"}}', ); yield* fs.symlink(launcher, path.join(prefix, "bin/t3")); const resolve = resolveServerInstallation.pipe( @@ -211,7 +216,7 @@ it.layer(NodeServices.layer)("manual server installation ownership", (it) => { ]) { yield* fs.writeFileString( path.join(path.dirname(entry), "package.json"), - `{"name":"@t3code/t3-linux-x64","version":"${version}"}`, + `{"name":"@iglo-tech/iglo-code-linux-x64","version":"${version}"}`, ); expect(yield* resolve).toEqual(expected); } @@ -222,7 +227,7 @@ it.layer(NodeServices.layer)("manual server installation ownership", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped(); + const root = yield* fs.makeTempDirectoryScoped().pipe(Effect.flatMap(fs.realPath)); for (const relative of [ "project/node_modules/t3/dist/bin.mjs", "project/apps/server/dist/bin.mjs", diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index cbfc4dff8b19..71a6a23da49b 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -63,7 +63,7 @@ export const resolveServerInstallation = Effect.gen(function* () { const platform = yield* HostProcessPlatform; const entry = yield* fs.realPath(executable ? executablePath : (args[1] ?? "")); const match = - /^(.*)\/lib\/node_modules\/t3\/(?:dist\/bin\.mjs|bin\/t3\.js|node_modules\/@t3code\/t3-[^/]+\/t3)$/.exec( + /^(.*)\/lib\/node_modules\/(t3|@iglo-tech\/iglo-code)\/(?:dist\/bin\.mjs|bin\/t3\.js|node_modules\/(?:@t3code\/t3-|@iglo-tech\/iglo-code-)[^/]+\/t3)$/.exec( entry, ); if (!match) { @@ -83,11 +83,12 @@ export const resolveServerInstallation = Effect.gen(function* () { ) return null; - const packageRoot = path.join(prefix, "lib/node_modules/t3"); + const packageName = match[2]!; + const packageRoot = path.join(prefix, "lib/node_modules", packageName); const manifest = yield* fs .readFileString(path.join(packageRoot, "package.json")) .pipe(Effect.flatMap(decodeInstallManifest)); - if (manifest.name !== "t3" || !manifest.bin) return null; + if (manifest.name !== packageName || !manifest.bin) return null; const bin = yield* fs.realPath(path.join(packageRoot, manifest.bin.t3)); const globalBin = yield* fs.realPath(path.join(prefix, "bin/t3")); if (globalBin !== bin) return null; @@ -115,13 +116,13 @@ export const resolveServerInstallation = Effect.gen(function* () { */ function suggestedPackageSpec(version: string): string { const channel = /^[^-+]+-(nightly|preview)\./.exec(version)?.[1]; - return channel === undefined ? "t3" : `t3@${channel}`; + return channel === undefined ? "@iglo-tech/iglo-code" : `@iglo-tech/iglo-code@${channel}`; } /** * Render a `t3 ` suggestion that matches how this process was - * launched, so copy/pasting it actually works: `npx t3 connect` suggests - * `npx t3 serve`, a global install suggests `t3 serve`, and a nightly build + * launched, so copy/pasting it actually works: a package runner suggests + * the fork's package, a global install suggests `t3 serve`, and a nightly build * keeps the `@nightly` tag. */ export function formatCliCommand(input: { @@ -148,18 +149,17 @@ export const resolveCliCommand = (subcommand: string) => /** * `t3 ` as root, for setup a person runs once on the host. `sudo` - * resets PATH on most distributions, which drops a user-installed Node (nvm, - * fnm, a tarball) and with it `npx` or a global `t3`, so the command carries - * PATH through unless Node is on root's PATH too. + * resets PATH on most distributions, which drops a user-installed Bun and the CLI, so the command carries + * PATH through unless the runtime is on root's PATH too. */ export const resolveRootCliCommand = (subcommand: string) => Effect.gen(function* () { const command = yield* resolveCliCommand(subcommand); const executablePath = yield* HostProcessExecutablePath; - const systemNode = ROOT_PATH_DIRECTORIES.some((directory) => + const systemRuntime = ROOT_PATH_DIRECTORIES.some((directory) => executablePath.startsWith(`${directory}/`), ); - return systemNode ? `sudo ${command}` : `sudo env "PATH=$PATH" ${command}`; + return systemRuntime ? `sudo ${command}` : `sudo env "PATH=$PATH" ${command}`; }); /** Debian and Ubuntu's sudo `secure_path`, minus snap. */ diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts index fa7c6b7588fd..cd2e98e6e6ef 100644 --- a/apps/server/src/cli/pair.test.ts +++ b/apps/server/src/cli/pair.test.ts @@ -270,8 +270,8 @@ describe("t3 pair", () => { typeof error === "object" && error !== null && "cause" in error ? error.cause : error, ); assert.include(rendered, "No running T3 Code server found."); - assert.include(rendered, "npx t3 serve"); - assert.include(rendered, "npx t3 connect"); + assert.include(rendered, "t3 serve"); + assert.include(rendered, "t3 connect"); }).pipe(Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index debac218a983..f1f002b95504 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -78,7 +78,7 @@ export class NoRunningServerError extends Schema.TaggedError ` checked ${statePath}`), - "Start one with `npx t3 serve`, or connect this machine with T3 Connect: `npx t3 connect`.", + "Start one with `t3 serve`, or connect this machine with T3 Connect: `t3 connect`.", ].join("\n"); } } diff --git a/apps/server/src/cli/triagePrompt.ts b/apps/server/src/cli/triagePrompt.ts index 1df712854263..a95fdb2f26be 100644 --- a/apps/server/src/cli/triagePrompt.ts +++ b/apps/server/src/cli/triagePrompt.ts @@ -63,7 +63,7 @@ Diagnosis grounded in source beats guessing. First establish the shape of the install, because the same symptom points at different code depending on it: -- How is T3 Code running on this machine: \`npx t3 serve\` in a terminal, the +- How is T3 Code running on this machine: \`t3 serve\` in a terminal, the background service, or the desktop app? - Which surface is the user connecting from: the website (app.t3.codes), the desktop app against a local server, the desktop app against a remote server, diff --git a/apps/server/src/cli/uninstall.ts b/apps/server/src/cli/uninstall.ts index 4b977bc24951..4b1c81afa728 100644 --- a/apps/server/src/cli/uninstall.ts +++ b/apps/server/src/cli/uninstall.ts @@ -137,7 +137,7 @@ const runUninstall = Effect.fn("cli.uninstall.run")(function* (input: { yield* Console.log(`Nothing to remove: t3 is not installed for ${input.baseDir}.`); if (!(yield* HostProcessIsExecutable)) { yield* Console.log( - " This t3 runs from a Node script, so it was installed by npm or built from source. Remove it the same way (`npm uninstall -g t3`, or delete the checkout).", + " This t3 runs from a Bun script built from source. Remove its checkout to uninstall it.", ); } return; diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 957b28d696e2..ed3825db58bb 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -27,8 +27,8 @@ import * as ProcessRunner from "../processRunner.ts"; * web client, and the native packages beside it. The boot service points its * unit or launch agent at the executable, and server self-update installs the * target version here before switching over. The runtime never depends on a - * Node or npm on the machine; the only npm involvement in T3 Code is the `t3` - * package for people who prefer `npx t3` or `npm install -g t3`, and even a + * Node or npm on the machine; the only npm involvement in T3 Code is the fork launcher + * package for people who prefer `bunx @iglo-tech/iglo-code` or a global package install, and even a * CLI installed that way pins an archive when it sets up the service. */ const PINNED_RUNTIME_DIR = "runtime"; diff --git a/apps/web/src/components/ServerUpdateAction.test.tsx b/apps/web/src/components/ServerUpdateAction.test.tsx index 385e12af7dfc..ef9128e2b039 100644 --- a/apps/web/src/components/ServerUpdateAction.test.tsx +++ b/apps/web/src/components/ServerUpdateAction.test.tsx @@ -165,21 +165,21 @@ describe("ServerUpdateAction", () => { it.each([ [ { kind: "npm-global", prefix: "/opt/node" }, - "npm install --global --prefix '/opt/node' t3@0.0.45", + "curl -fsSL https://raw.githubusercontent.com/iglo-tech/iglo.code/main/scripts/install.sh | T3CODE_VERSION='0.0.45' sh", "Update command copied", "then restart t3", ], [ { kind: "npx" }, - "npx t3@0.0.45", - "Relaunch command copied", - "This does not update an installed t3 command.", + "curl -fsSL https://raw.githubusercontent.com/iglo-tech/iglo.code/main/scripts/install.sh | T3CODE_VERSION='0.0.45' sh", + "Update command copied", + "then restart t3", ], [ undefined, - "npx t3@0.0.45", - "Relaunch command copied", - "This does not update an installed t3 command.", + "curl -fsSL https://raw.githubusercontent.com/iglo-tech/iglo.code/main/scripts/install.sh | T3CODE_VERSION='0.0.45' sh", + "Update command copied", + "then restart t3", ], ] satisfies ReadonlyArray)( "copies an honest manual command for %j without invoking remote update", diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index e40d4f5d0792..bf4abd812200 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -230,16 +230,12 @@ export function ServerUpdateAction({ ); const update = useServerUpdate(); const { copyToClipboard } = useCopyToClipboard<{ command: string }>({ - target: installation?.kind === "npm-global" ? "update command" : "relaunch command", + target: "update command", onCopy: ({ command }) => { toastManager.add({ type: "success", - title: - installation?.kind === "npm-global" ? "Update command copied" : "Relaunch command copied", - description: - installation?.kind === "npm-global" - ? `Run \`${command}\` on ${serverLabel}, then restart t3 with your usual options.` - : `Stop t3 on ${serverLabel}, then relaunch with \`${command}\` using the same subcommand and options. This does not update an installed t3 command.`, + title: "Update command copied", + description: `Stop t3 on ${serverLabel}, run \`${command}\`, then restart t3 with your usual options.`, }); }, onError: (error) => { @@ -292,12 +288,7 @@ export function ServerUpdateAction({ const manualCommand = selfUpdate === null ? manualServerUpdateCommand(targetVersion, installation) : null; - const actionLabel = - manualCommand !== null - ? installation?.kind === "npm-global" - ? "Copy update command" - : "Copy relaunch command" - : label; + const actionLabel = manualCommand !== null ? "Copy update command" : label; const onClick = manualCommand !== null ? () => copyToClipboard(manualCommand, { command: manualCommand }) diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 1d33c4ec7380..22fa09f7bd99 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -516,7 +516,7 @@ function ConnectAccountOption({

Run this on each computer you want to connect.

- +

Keep {APP_BASE_NAME} running. Select the computers you want to set up above.

@@ -630,9 +630,9 @@ function PairingForm({

Run this on the computer with your code.

- +

- Start T3 Code first, or run npx t3 serve. Add{" "} + Start T3 Code first, or run t3 serve. Add{" "} --tailscale to use your tailnet.

diff --git a/apps/web/src/versionSkew.test.ts b/apps/web/src/versionSkew.test.ts index 89e19e83319b..d5ed93edd229 100644 --- a/apps/web/src/versionSkew.test.ts +++ b/apps/web/src/versionSkew.test.ts @@ -27,20 +27,16 @@ const MISMATCH_HINT = "Version mismatch. Try syncing the client and server to the same T3 Code version."; describe("versionSkew", () => { - it("updates only the proven npm prefix and safely quotes its path", () => { + it("updates legacy and unknown installations only through the fork installer", () => { + const command = + "curl -fsSL https://raw.githubusercontent.com/iglo-tech/iglo.code/main/scripts/install.sh | T3CODE_VERSION='0.0.45' sh"; + expect(manualServerUpdateCommand("0.0.45")).toBe(command); expect(manualServerUpdateCommand("0.0.45", { kind: "npm-global", prefix: "/opt/node" })).toBe( - "npm install --global --prefix '/opt/node' t3@0.0.45", + command, ); - expect( - manualServerUpdateCommand("0.0.45", { kind: "npm-global", prefix: "/opt/maria's node" }), - ).toBe("npm install --global --prefix '/opt/maria'\\''s node' t3@0.0.45"); - }); - - it("keeps runner and unknown commands as relaunches", () => { - expect(manualServerUpdateCommand("0.0.45")).toBe("npx t3@0.0.45"); - expect(manualServerUpdateCommand("0.0.45", { kind: "npx" })).toBe("npx t3@0.0.45"); - expect(manualServerUpdateCommand("0.0.45", { kind: "pnpm-dlx" })).toBe("pnpm dlx t3@0.0.45"); - expect(manualServerUpdateCommand("0.0.45", { kind: "bunx" })).toBe("bunx t3@0.0.45"); + expect(manualServerUpdateCommand("0.0.45", { kind: "npx" })).toBe(command); + expect(manualServerUpdateCommand("0.0.45", { kind: "pnpm-dlx" })).toBe(command); + expect(manualServerUpdateCommand("0.0.45", { kind: "bunx" })).toBe(command); }); beforeEach(() => { branding.APP_VERSION = "0.0.34"; diff --git a/apps/web/src/versionSkew.ts b/apps/web/src/versionSkew.ts index ca8891a84a39..b2b2ade39ec3 100644 --- a/apps/web/src/versionSkew.ts +++ b/apps/web/src/versionSkew.ts @@ -122,15 +122,10 @@ export function supportsServerUpdateThreadContinuation( /** The command to hand users whose server cannot update itself. */ export function manualServerUpdateCommand( targetVersion: string, - installation?: ServerInstallation, + _installation?: ServerInstallation, ): string { - if (installation?.kind === "npm-global") { - const prefix = `'${installation.prefix.replaceAll("'", "'\\''")}'`; - return `npm install --global --prefix ${prefix} t3@${targetVersion}`; - } - const runner = - installation?.kind === "pnpm-dlx" ? "pnpm dlx" : installation?.kind === "bunx" ? "bunx" : "npx"; - return `${runner} t3@${targetVersion}`; + const version = `'${targetVersion.replaceAll("'", "'\\''")}'`; + return `curl -fsSL https://raw.githubusercontent.com/iglo-tech/iglo.code/main/scripts/install.sh | T3CODE_VERSION=${version} sh`; } export function serverUpdateGuidance(capability: ServerSelfUpdateCapability): string { diff --git a/docs/internals/devcontainer.md b/docs/internals/devcontainer.md index cf46620ca9bf..f36960a1382c 100644 --- a/docs/internals/devcontainer.md +++ b/docs/internals/devcontainer.md @@ -2,7 +2,7 @@ > For maintainers. Using T3 Code? See [docs/user](../user/). -`.devcontainer/` gives you a ready-to-code Linux environment matching CI: Ubuntu 24.04, Node 24, pnpm, Rust stable, the global `vp` CLI, and the GitHub CLI. Open the repo in VS Code and "Reopen in Container", or create a GitHub Codespace. Dependency install (`vp i`) and the Vite dep-cache warmup all run automatically before you attach. +`.devcontainer/` gives you a ready-to-code Linux environment matching CI: Ubuntu 24.04, pinned Bun for the application, Node 24 for Vite+/pnpm, Rust stable, the global `vp` CLI, and the GitHub CLI. Open the repo in VS Code and "Reopen in Container", or create a GitHub Codespace. Dependency install (`vp i`) and the Vite dep-cache warmup all run automatically before you attach. ## What works in the container diff --git a/docs/internals/devices.md b/docs/internals/devices.md index 7a082c17e150..9e662bc0485f 100644 --- a/docs/internals/devices.md +++ b/docs/internals/devices.md @@ -9,11 +9,12 @@ Device panel work over Tailscale and T3 Connect, including when an SSH host runs [expo-device-hub](../../apps/server/src/device/LocalDeviceHost.ts) streams and [agent-device](../../apps/server/src/device/AgentDeviceShim.ts) drives. Each is -npm-installed at a pinned version into the T3 home after its matching Device +Bun-installed at a pinned version into the T3 home after its matching Device panel consent step. Manual setup installs and starts only expo-device-hub; agent-device remains absent and stopped until agent access is granted. Both run -with the server's Node; `npx` would make the first `device_open` after a reboot -depend on the registry. The hub is a supervised child rather than an imported +with the environment's Bun interpreter, including the interpreter shipped in a +compiled archive. Installations are staged and versioned so first use after a +reboot does not depend on the registry. The hub is a supervised child rather than an imported middleware because serve-sim loads private CoreSimulator frameworks through a native addon, and a crash there must not take the server down. diff --git a/docs/internals/effect-services.md b/docs/internals/effect-services.md index 4da6357c0a91..85d380472e91 100644 --- a/docs/internals/effect-services.md +++ b/docs/internals/effect-services.md @@ -83,7 +83,7 @@ export const layer = Layer.effect(Foo, make); imports it. Knip fails CI on an unused export. Don't write `make = Effect.succeed(...)` to force `Layer.effect`; use the constructor that fits, like `Layer.succeed` or `Layer.sync`. - **Names.** A module named for its implementation uses plain `make` and `layer` - ([`NodePtyAdapter.ts`](../../apps/server/src/terminal/NodePtyAdapter.ts)). A port module that also + ([`BunPtyAdapter.ts`](../../apps/server/src/terminal/BunPtyAdapter.ts)). A port module that also holds implementations names them, like `makeCloudflaredRelayClient` and `layerCloudflared`. - **Moves.** Moving a service deletes the old files and updates every consumer, including orchestration, MCP, tests, and integration harnesses. No re-export shims. diff --git a/docs/internals/open-source-licenses.md b/docs/internals/open-source-licenses.md index 0146b89e1679..51cf9ef5d43d 100644 --- a/docs/internals/open-source-licenses.md +++ b/docs/internals/open-source-licenses.md @@ -1,7 +1,7 @@ # Open source license notices The web build emits `third-party-licenses.json` beside `index.html`. The Settings page loads that -static file, so the same artifact works in hosted web, the client bundled with `npx t3`, and +static file, so the same artifact works in hosted web, the client bundled with `t3`, and desktop. It does not depend on the connected environment or an RPC. ## What the build collects diff --git a/docs/operations/background-verification.md b/docs/operations/background-verification.md index 6d68e974bf0a..a4e28ba0ad17 100644 --- a/docs/operations/background-verification.md +++ b/docs/operations/background-verification.md @@ -1,10 +1,10 @@ # Live background-work verification -Run from the repository root with Node 24 and installed dependencies. This uses +Run from the repository root with the pinned Bun runtime and installed dependencies. This uses real provider CLI credentials and consumes model usage: ```sh -node apps/server/scripts/verify-background-live.ts --repeat 2 +bun apps/server/scripts/verify-background-live.ts --repeat 2 ``` Each scenario starts the production server in a fresh temporary T3 home and Git @@ -36,7 +36,7 @@ are failures, never skipped passes. Inspect the retained trace to distinguish th Check the verifier's failure detection with a real failing HTTP dependency: ```sh -node apps/server/scripts/verify-background-live.ts --scenario active --fail-gate +bun apps/server/scripts/verify-background-live.ts --scenario active --fail-gate ``` That command must exit nonzero. It must not be counted as a passing product test. diff --git a/docs/operations/development.md b/docs/operations/development.md index e5dfefd4b267..77073198a301 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -2,7 +2,10 @@ ## First checkout -The checkout requires Node 24 and Vite+ (`vp`); Bun is optional. Set up a coding agent +The application runs on Bun 1.4.0 or newer on macOS arm64, Linux x64, and Linux +arm64. Development and packaging use the version pinned in `.bun-version`. +The retained Vite+/pnpm contributor toolchain uses Node 24 independently of the +application runtime. Install Bun and Vite+ (`vp`) before starting the server. Set up a coding agent using the [provider guide](../user/install.md#providers). Install `vp` on macOS or Linux: @@ -11,12 +14,6 @@ Install `vp` on macOS or Linux: curl -fsSL https://vite.plus | bash ``` -On Windows, use PowerShell: - -```powershell -irm https://vite.plus/ps1 | iex -``` - Clone the fork and start it: ```sh @@ -134,8 +131,10 @@ vp run --filter typecheck CI owns the full suite; see [ci.yml](../../.github/workflows/ci.yml) for its current jobs. -The [manual Windows lane](../../.github/workflows/windows-tests.yml) is available for focused -Windows investigation while that suite is not a required gate. +The [Bun runtime lane](../../.github/workflows/bun-runtime.yml) exercises source +and extracted archives on each supported target, including their real web clients. +Run `bun scripts/smoke-cli-archive.ts --source` for the source transport/persistence +check, or pass `--archive --expect-version ` for an archive. ### Unused code diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 500a04cfc80f..59564c067e1a 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -139,15 +139,11 @@ You do not need any extra env vars. Just run the app normally and inspect `serve Examples: ```bash -npx t3 +t3 ``` ```bash -node --run dev -``` - -```bash -node --run dev +vp run dev ``` ### Option 2: Run With A Local LGTM Stack @@ -191,25 +187,15 @@ export T3CODE_TRACE_TIMING_ENABLED=true CLI: ```bash -npx t3 +t3 ``` Monorepo web/server dev: ```bash -node --run dev +vp run dev ``` -Monorepo desktop dev: - -```bash -node --run dev -``` - -Packaged desktop app: - -Launch the actual app executable from the same shell so the desktop app and embedded backend inherit `T3CODE_OTLP_*`. - macOS app bundle example: ```bash @@ -644,7 +630,7 @@ Current high-value span and metric boundaries include: ## Heap Snapshots To see what a long-running server holds in memory, send it `SIGUSR2`. The server writes a V8 heap -snapshot to its logs dir and logs the path. This works for `npx t3` and service installs +snapshot to its logs dir and logs the path. This works for `t3` and service installs on macOS and Linux. Windows has no `SIGUSR2`. Send the signal to the server pid in `server-runtime.json`, which sits in the server's state dir diff --git a/docs/operations/release.md b/docs/operations/release.md index 054b4be6592d..2e1249fd3026 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -14,7 +14,7 @@ This document covers the unified release workflow for stable and nightly web and - push tag matching `v*.*.*` for a stable release of an explicit commit - scheduled nightly check every 30 minutes - manual `workflow_dispatch` with `channel=nightly` - - manual `workflow_dispatch` with `channel=preview`, the maintainers' test train. It exercises the whole release flow (build, sign, notarize, smoke, publish) for a commit that end users must never receive, which is how an unmerged branch or a risky change gets a real release run before it lands. It builds the triggering commit with nightly's versioning under the `preview` prerelease identifier (`0.0.41-preview..`) and publishes a GitHub prerelease plus the npm packages under the `preview` dist-tag. Preview is not on the schedule, no default npm dist-tag points at it, it is selected only by an explicit preview channel. The only ways onto it are downloading the release by hand, `npx t3@preview`, `T3CODE_CHANNEL=preview` for the install scripts, or `t3 update --channel preview` from a terminal; each prints a warning, and the CLI asks for confirmation when the running build is not itself a preview. The release itself is named as a maintainer test build and its body is a warning rather than generated notes: a changelog of unmerged branch history is not a changelog, and nightly and stable notes are unaffected because each series resolves its previous tag within its own channel. The hosted web app and Discord announcements are skipped. Keep it; it costs nothing when idle. + - manual `workflow_dispatch` with `channel=preview`, the maintainers' test train. It exercises the whole release flow (build, sign, notarize, smoke, publish) for a commit that end users must never receive, which is how an unmerged branch or a risky change gets a real release run before it lands. It builds the triggering commit with nightly's versioning under the `preview` prerelease identifier (`0.0.41-preview..`) and publishes a GitHub prerelease plus the npm packages under the `preview` dist-tag. Preview is not on the schedule, no default npm dist-tag points at it, it is selected only by an explicit preview channel. The only ways onto it are downloading the release by hand, `bunx @iglo-tech/iglo-code@preview`, `T3CODE_CHANNEL=preview` for the install scripts, or `t3 update --channel preview` from a terminal; each prints a warning, and the CLI asks for confirmation when the running build is not itself a preview. The release itself is named as a maintainer test build and its body is a warning rather than generated notes: a changelog of unmerged branch history is not a changelog, and nightly and stable notes are unaffected because each series resolves its previous tag within its own channel. The hosted web app and Discord announcements are skipped. Keep it; it costs nothing when idle. - A manual stable release builds the commit of the latest published nightly, not `main` HEAD. Nightly is the release candidate: verify the nightly, then promote it. Merges to `main` keep landing while you verify and never leak into the stable build. @@ -26,22 +26,23 @@ This document covers the unified release workflow for stable and nightly web and - Runs lint, typecheck, and tests alongside artifact builds. Publishing waits for every check. - Reads the shared production T3 Connect relay URL and Clerk client configuration before packaging clients. - Builds the platform-independent JS (server bundle and web client) once in the `build_bundle` job and hands it to every platform job as the `js-bundle` artifact; the platform jobs only package it, so no runner rebuilds it. -- Builds five CLI archives in parallel through `release-cli.yml`, each on hardware of its own architecture: macOS arm64, Linux x64 and arm64, and Windows x64 and arm64. +- Builds three CLI archives in parallel through `release-cli.yml`, each on hardware of its own architecture: macOS arm64 and Linux x64 and arm64. - Publishes one GitHub Release with all produced files. - Stable tags with a suffix after `X.Y.Z` (for example `1.2.3-alpha.1`) are published as GitHub prereleases. - Only plain stable `X.Y.Z` releases are marked as the repository's latest release. - Nightly runs are always GitHub prereleases and never marked latest. - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag. -- Builds a self-contained CLI archive per platform (`t3---.tar.gz`, `.zip` on Windows) in a dedicated platform job and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel, for five targets: macOS arm64, Linux x64 and arm64, Windows x64 and arm64. Every archive is built, signed, and smoke-tested on hardware of its own architecture. There is no macOS x64 archive: Node single-executables are unsupported on x64 macOS (the SEA docs list macOS as arm64 only) and the binary segfaults on start. - - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the only form in which T3 Code manages a runtime: the boot service, `t3 update`, and the install scripts all download and verify this archive against `SHA256SUMS`. The npm packages exist for people who run `npx t3` or `npm install -g t3` themselves and carry the same archive contents; nothing in the product installs from npm. The `curl | sh` installers are `scripts/install.sh` and `scripts/install.ps1`; the marketing site copies them into its `public/` at build time (`apps/marketing/scripts/stage-install-scripts.mjs`) and serves them at `t3.codes/install.sh` and `/install.ps1`. - - The executable is built with a Node that supports `--build-sea` (`VP_NODE_VERSION=26.8.2`, kept in step with `SEA_NODE_VERSION` in `apps/server/vite.config.ts`), while the repo stays on `engines.node`. - - macOS archives are signed with the Developer ID certificate and notarized when the Apple secrets are present (ad hoc otherwise, which still runs from `curl`/`tar` installs). Windows executables use Azure Trusted Signing. Every native addon in the macOS archive is signed too, since the hardened runtime refuses unsigned libraries. - - Each archive is extracted and executed on its build runner (`scripts/smoke-cli-archive.ts`) before it is uploaded. -- Publishes the CLI to npm with OIDC trusted publishing from the same workflow file, as the same bytes the GitHub Release carries: `scripts/build-npm-platform-packages.ts` unpacks the five CLI archives into `@t3code/t3--` packages (each with `os`/`cpu` set so npm installs only the matching one) and generates the `t3` launcher, whose `bin/t3.js` lists them as `optionalDependencies` and execs the installed executable. `npx t3` therefore needs Node only to run the launcher, never to run the server. `node apps/server/scripts/cli.ts publish` publishes the platform packages first and the launcher last, after a `--dry-run` pass over all of them so an auth or scope error fails before anything is live. +- Builds self-contained CLI archives for macOS arm64, Linux x64, and Linux arm64, and attaches them to the fork's GitHub Release with `SHA256SUMS`. Intel macOS and Windows are excluded from release selection. + - [build-cli-archive.ts](../../scripts/build-cli-archive.ts) packages the Bun-compiled server, web client, disk-backed native/SDK dependencies, and a pinned interpreter at `runtime/bun`. The interpreter runs arbitrary helper scripts; the compiled CLI handles application subcommands. Installed execution needs no system Node, npm, or Bun. + - [vite.config.ts](../../apps/server/vite.config.ts) bundles one coherent Effect module graph before Bun compiles it. Development checks, compilation, and the interpreter archive use `.bun-version` (1.4.0). Vite+ and pnpm remain contributor tooling. + - macOS executables and native addons are signed with the Developer ID certificate and notarized when Apple secrets are present, or signed ad hoc otherwise. + - Installation, pinned service releases, and updates use `iglo-tech/iglo.code`. Explicit release-origin overrides remain supported; missing fork artifacts fail without selecting an upstream release. + - [smoke-cli-archive.ts](../../scripts/smoke-cli-archive.ts) extracts outside the repository, removes system runtimes from PATH, then checks authenticated HTTP, pairing cookies, CORS, WebSocket upgrade, and state across restart. The [Bun compatibility workflow](../../.github/workflows/bun-runtime.yml) also runs the source launch and real web-client suite on each supported target. +- Publishes the CLI to npm with OIDC trusted publishing from the same workflow file, as the same bytes the GitHub Release carries: `scripts/build-npm-platform-packages.ts` unpacks the three CLI archives into `@iglo-tech/iglo-code--` packages (each with `os`/`cpu` set so npm installs only the matching one) and generates the `@iglo-tech/iglo-code` launcher package, which lists them as `optionalDependencies` and execs the installed executable. The generated `bin/t3.js` is a POSIX shell launcher, so launching an installed package needs no JavaScript interpreter. `bun apps/server/scripts/cli.ts publish` publishes the platform packages first and the launcher last, after a `--dry-run` pass over all of them so an auth or scope error fails before anything is live. - stable releases publish npm dist-tag `latest` - nightly releases publish npm dist-tag `nightly` - preview releases publish npm dist-tag `preview`, which nothing resolves unless asked for by name - - one-time setup: the `@t3code` npm scope (org) must exist, and `t3` and each `@t3code/t3--` package needs a trusted publisher registered for this workflow file (see below). + - one-time setup: the `@iglo-tech` npm scope (org) must exist, and `@iglo-tech/iglo-code` and each `@iglo-tech/iglo-code--` package needs a trusted publisher registered for this workflow file (see below). - Builds the hosted web app on Vercel while the CLI jobs run, and makes it live only after a release is published: - stable releases are aliased to the `latest` hosted app channel - nightly releases are aliased to the `nightly` hosted app channel @@ -326,13 +327,13 @@ One-time Vercel dashboard setup: - release name includes the short commit SHA - `make_latest` is always `false` - Uses the next stable patch version as the nightly base. For example, `0.0.17` produces nightlies on `0.0.18-nightly.*`. -- Publishes the CLI npm packages (`t3` and `@t3code/t3--`) to the `nightly` npm dist-tag using the same nightly version. +- Publishes the CLI npm packages (`@iglo-tech/iglo-code` and `@iglo-tech/iglo-code--`) to the `nightly` npm dist-tag using the same nightly version. - Does not commit version bumps back to `main`. ## Server self-update release invariant Connected servers update to the client's exact version, not to an npm dist-tag. Every released -hosted client version must therefore have a matching `t3@` package available on +hosted client version must therefore have a matching `@iglo-tech/iglo-code@` package available on npm before users can receive that client. The workflow enforces this ordering: @@ -348,30 +349,29 @@ The workflow enforces this ordering: Preserve these dependencies when changing the release graph. Publishing a client first would leave the **Update server** action targeting a package version that does not exist yet. -For a release smoke test, confirm `npm view t3@ version` returns the expected version, then +For a release smoke test, confirm `npm view @iglo-tech/iglo-code@ version` returns the expected version, then connect the new client to a server on the previous version and verify that the update action reconnects to the matching server. When the release adds database migrations, verify that the remote update applies them and reconnects. A failed trial must restore the database snapshot and restart the previous server. If the installed launcher does not support the target protocol, -verify that the update stops before restart and run `npx t3@ service update` once on the +verify that the update stops before restart and run `bunx @iglo-tech/iglo-code@ service update` once on the server machine. Also test the manual update guidance when those environments are available. ## 0) npm OIDC trusted publishing setup (CLI) -The workflow runs `node scripts/build-npm-platform-packages.ts` on the downloaded CLI archives, then -`node apps/server/scripts/cli.ts publish --packages-dir npm-packages`, which runs `npm publish` on -each `@t3code/t3--.tgz` and finally on `t3.tgz`, the launcher. The script publishes +The workflow runs `bun scripts/build-npm-platform-packages.ts` on the downloaded CLI archives, then +`bun apps/server/scripts/cli.ts publish --packages-dir npm-packages`, which runs `npm publish` on +each `@iglo-tech/iglo-code--.tgz` and finally on `iglo-code.tgz`, the launcher. The script publishes tarballs it built itself rather than directories: `npm publish ` strips `node_modules/` from the -tarball no matter what `files` says, and the executable loads its native addons from there. Six -packages are published per release: `t3`, `@t3code/t3-darwin-arm64`, -`@t3code/t3-linux-arm64`, `@t3code/t3-linux-x64`, `@t3code/t3-win32-arm64`, -`@t3code/t3-win32-x64`. +tarball no matter what `files` says, and the executable loads its native addons from there. Four +packages are published per release: `@iglo-tech/iglo-code`, `@iglo-tech/iglo-code-darwin-arm64`, +`@iglo-tech/iglo-code-linux-arm64`, `@iglo-tech/iglo-code-linux-x64`. Checklist: -1. Confirm the npm org owns package `t3` and the `@t3code` scope exists on npm (create the org if +1. Confirm the npm org owns package `@iglo-tech/iglo-code` and the `@iglo-tech` scope exists on npm (create the org if it does not). -2. For `t3` and each `@t3code/t3--` package, configure a Trusted Publisher in the +2. For `@iglo-tech/iglo-code` and each `@iglo-tech/iglo-code--` package, configure a Trusted Publisher in the npm package settings (a package that has never been published needs a first publish or a placeholder before the setting exists; the `--dry-run` step in `publish_cli` reports which names are still rejected): @@ -381,7 +381,7 @@ Checklist: - Environment (if used): match your npm trusted publishing config 3. Ensure npm account and org policies allow trusted publishing for every package. 4. Create release tag `vX.Y.Z` and push; workflow will: - - build and smoke-test the five CLI archives + - build and smoke-test the three CLI archives - build the npm packages from those archives - publish them with npm dist-tag `latest` 5. Nightly runs publish with npm dist-tag `nightly`; preview runs with `preview`. @@ -389,7 +389,7 @@ Checklist: ## 1) Release validation and unsigned builds There is no dry-run tag path. Pushing any accepted non-nightly tag, including -`v0.0.0-test.1`, classifies the run as the stable channel. It publishes `t3` with npm dist-tag +`v0.0.0-test.1`, classifies the run as the stable channel. It publishes `@iglo-tech/iglo-code` with npm dist-tag `latest`, creates a real GitHub Release, aliases the hosted app to `latest.app.t3.codes` and `app.t3.codes`, and can commit a version bump to `main` in the finalize job. Do not push a test tag to validate the workflow. @@ -415,31 +415,7 @@ For notarization, create an App Store Connect API key and configure `APPLE_API_ISSUER`. The workflow writes the key to a temporary file, signs the executable and native addons, and notarizes the CLI archive. -## 3) Azure Trusted Signing setup (Windows) - -Required secrets used by the workflow: - -- `AZURE_TENANT_ID` -- `AZURE_CLIENT_ID` -- `AZURE_CLIENT_SECRET` -- `AZURE_TRUSTED_SIGNING_ENDPOINT` -- `AZURE_TRUSTED_SIGNING_ACCOUNT_NAME` -- `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME` - -Checklist: - -1. Create Azure Trusted Signing account and certificate profile. -2. Record ATS values: - - Endpoint - - Account name - - Certificate profile name -3. Create/choose an Entra app registration (service principal). -4. Grant service principal permissions required by Trusted Signing. -5. Create a client secret for the service principal. -6. Add Azure secrets listed above in GitHub Actions secrets. -7. Re-run a tag release and confirm the Windows CLI executable is signed. - -## 4) Ongoing release checklist +## 3) Ongoing release checklist 1. Pick the latest nightly and verify it: run the smoke test above against its artifacts and check the nightly channel for regressions. @@ -455,12 +431,10 @@ Checklist: - release job uploads expected files 5. Smoke test downloaded artifacts. -## 5) Troubleshooting +## 4) Troubleshooting - macOS build unsigned when expected signed: - Check the certificate and App Store Connect secrets are populated and non-empty. -- Windows build unsigned when expected signed: - - Check all Azure ATS and auth secrets are populated and non-empty. - Build fails with signing error: - Retry with secrets removed to confirm unsigned path still works. - Re-check certificate/profile names and tenant/client credentials. diff --git a/docs/user/devices.md b/docs/user/devices.md index a87770853a2d..c6faf2d6d2c1 100644 --- a/docs/user/devices.md +++ b/docs/user/devices.md @@ -94,11 +94,13 @@ an optional identity file and port. These resolve on the environment server, so use the SSH configuration and keys available there. Password prompts are not supported. -**Test connection** checks SSH, Node, npm, and platform tools without installing +**Test connection** checks SSH, Bun, and platform tools without installing anything, with a result for each selected environment. Targets that resolve to the environment’s own machine are skipped, since its devices are already local. The first device listing installs pinned device tools on the host. -Node 22 or newer and npm must be available to non-interactive SSH commands. +Bun 1.4.0 or newer must be available to non-interactive SSH commands. +Local installed environments use their packaged Bun interpreter; device tools +install automatically with Bun. T3 checks common Homebrew and Android SDK locations; custom installations need the appropriate PATH and ANDROID_HOME on the host. diff --git a/docs/user/install.md b/docs/user/install.md index 24ad7944a149..93308250ae0d 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -5,19 +5,17 @@ web app. Set up the machine where the agents will work first. ## Requirements +The supported targets are macOS arm64, Linux x64, and Linux arm64. Release archives +include the Bun runtime for the server and its helpers; Node.js and npm are not +required by iglo.code. Provider CLIs keep their own prerequisites. + You need an installed, authenticated provider before starting a thread. You can launch T3 Code and configure providers afterwards. ## Command line ```bash -curl -fsSL https://t3.codes/install.sh | sh -``` - -On Windows, in PowerShell: - -```powershell -irm https://t3.codes/install.ps1 | iex +curl -fsSL https://raw.githubusercontent.com/iglo-tech/iglo.code/main/scripts/install.sh | sh ``` This puts `t3` in `~/.local/bin`. If your shell reports `command not found` @@ -41,23 +39,24 @@ If `t3` or `t3 start` reports an already running server, connect to that server instead. Stop it before starting a replacement, or use a different `--base-dir` for an independent server. -To try T3 Code once without installing it, run `npx t3@latest` instead (needs -Node.js for `npx`). - -### Intel Macs +### Run from source -There is no `t3` executable for Intel Macs. To -run a server there, build it from source with Node.js 24 and `vp` -([Install vp](https://github.com/pingdotgg/t3code#install-vp)): +Install Bun 1.4.0 or newer and Vite+ for the contributor toolchain, then run: ```bash git clone https://github.com/iglo-tech/iglo.code.git -cd iglo.code && vp i && vp run --filter t3 build -node apps/server/dist/bin.mjs +cd iglo.code +vp i +vp run --filter t3 build +bun apps/server/src/bin.ts serve --base-dir /tmp/iglo-source --no-browser ``` -`t3 update` and the background service do not apply to a server run this way; -update it with `git pull` and a rebuild. +For the development web client, use `vp run dev` and open its printed pairing URL. +Vite+ and pnpm may use Node internally; that is separate from the Bun application +runtime. See [Development](../operations/development.md) for setup. + +Source runs are updated with `git pull`; installed archives use `t3 update`. +Intel macOS and Windows are unsupported. ## Providers diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index fd096b205424..4bbae8e717f6 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -172,7 +172,7 @@ sudo t3 browser setup ``` The server shows the exact line for how you started it, such as -`sudo npx t3 browser setup`, and keeps your `PATH` when Node is installed only +`sudo t3 browser setup`, and keeps your `PATH` when Bun or t3 is installed only for your user. It allows Chrome's sandbox with an AppArmor profile and installs any missing libraries with apt. It is safe to run again. Without `sudo`, it only reports what it would change. @@ -199,11 +199,11 @@ expires. To choose a token's permissions, pass `--scope` once for each scope you want: ```sh -npx t3 pair --scope orchestration:read --scope relay:read +t3 pair --scope orchestration:read --scope relay:read ``` The selected scopes replace the default permissions. The same option works with -`npx t3 auth pairing create` and `npx t3 auth session issue`; each command's +`t3 auth pairing create` and `t3 auth session issue`; each command's `--help` lists the available scopes. Without `--scope`, pairing tokens retain standard client permissions and issued bearer sessions retain administrative permissions. diff --git a/docs/user/updating.md b/docs/user/updating.md index 16efb6c55f03..ccae1809a00c 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -40,11 +40,10 @@ Update the side the notice names, then reconnect. The offered action depends on how the server runs: -| Action | What to do | -| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Update server** | Keep the client open while it installs and reconnects. Supported background services update remotely. | -| **Copy update command** | Run the command on the named host to update the detected global npm install, then restart the server with your usual options. | -| **Copy relaunch command** | Stop the command-line server on its host and relaunch with the copied command, keeping your usual subcommand and options. This does not update an installed `t3` command. | +| Action | What to do | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| **Update server** | Keep the client open while it installs and reconnects. Supported background services update remotely. | +| **Copy update command** | Stop the command-line server on the named host, run the copied installer command, then restart with your usual options. | On the host, run: @@ -58,9 +57,9 @@ asks before restarting the background service; if you decline, run stop it and start it again afterwards with your usual options such as `--host` or `--tailscale-serve`. -If you run the server with `npx` rather than an installed `t3`, there is -nothing to update on the host: stop the server and relaunch it as -`npx t3@` with the same subcommand and options. +Installed iglo.code environments select releases from `iglo-tech/iglo.code`. If that +fork has no matching archive, the update fails without downloading upstream T3 Code. +For a source checkout, update the repository and restart its Bun server instead. ## If an update fails diff --git a/patches/node-pty@1.2.0-beta.15.patch b/patches/node-pty@1.2.0-beta.15.patch deleted file mode 100644 index 2232766cdb65..000000000000 --- a/patches/node-pty@1.2.0-beta.15.patch +++ /dev/null @@ -1,35 +0,0 @@ -diff --git a/lib/windowsConoutConnection.js b/lib/windowsConoutConnection.js ---- a/lib/windowsConoutConnection.js -+++ b/lib/windowsConoutConnection.js -@@ -63,7 +63,11 @@ - conoutPipeName: _conoutPipeName - }; - var scriptPath = __dirname.replace('node_modules.asar', 'node_modules.asar.unpacked'); -- this._worker = new worker_threads_1.Worker((0, path_1.join)(scriptPath, 'worker/conoutSocketWorker.js'), { workerData: workerData }); -+ // Node's watch mode reports dependencies through worker messages. -+ // Keep the host's watch environment out of this protocol worker. -+ var workerEnv = Object.assign({}, process.env); -+ delete workerEnv.WATCH_REPORT_DEPENDENCIES; -+ this._worker = new worker_threads_1.Worker((0, path_1.join)(scriptPath, 'worker/conoutSocketWorker.js'), { workerData: workerData, env: workerEnv, execArgv: [] }); - this._worker.on('message', function (message) { - switch (message) { - case 1 /* ConoutWorkerMessage.READY */: -diff --git a/lib/windowsPtyAgent.js b/lib/windowsPtyAgent.js ---- a/lib/windowsPtyAgent.js -+++ b/lib/windowsPtyAgent.js -@@ -218,8 +218,14 @@ - return Promise.resolve([]); - } - return new Promise(function (resolve) { -- var agent = (0, child_process_1.fork)(path.join(__dirname, 'conpty_console_list_agent'), [_this._innerPid.toString()]); -+ // Node's watch mode sends its own messages over the helper's IPC channel. -+ var agentEnv = Object.assign({}, process.env); -+ delete agentEnv.WATCH_REPORT_DEPENDENCIES; -+ var agent = (0, child_process_1.fork)(path.join(__dirname, 'conpty_console_list_agent'), [_this._innerPid.toString()], { env: agentEnv, execArgv: [] }); - agent.on('message', function (message) { -+ if (!Array.isArray(message.consoleProcessList)) { -+ return; -+ } - clearTimeout(timeout); - resolve(message.consoleProcessList); - }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 921457c21f49..0a4ed252656f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,7 +93,6 @@ patchedDependencies: '@pierre/diffs@1.5.2': ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289 alchemy@2.0.0-beta.80: bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd effect@4.0.1: a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f - node-pty@1.2.0-beta.15: f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414 importers: @@ -187,9 +186,6 @@ importers: jose: specifier: 'catalog:' version: 6.2.2 - node-pty: - specifier: ^1.2.0-beta.15 - version: 1.2.0-beta.15(patch_hash=f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414) playwright-core: specifier: 1.60.0 version: 1.60.0 @@ -732,6 +728,9 @@ importers: '@effect/platform-node': specifier: 4.0.1 version: 4.0.1(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(redis@6.2.1)(utf-8-validate@6.0.6) + '@t3tools/contracts': + specifier: workspace:* + version: link:../packages/contracts '@t3tools/shared': specifier: workspace:* version: link:../packages/shared @@ -7968,9 +7967,6 @@ packages: nlcst-to-string@4.0.0: resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} - node-addon-api@7.1.1: - resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} - node-fetch-h2@2.3.0: resolution: {integrity: sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==} engines: {node: 4.x || >=6.0.0} @@ -8001,9 +7997,6 @@ packages: node-mock-http@1.0.5: resolution: {integrity: sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==} - node-pty@1.2.0-beta.15: - resolution: {integrity: sha512-vORSzHXi4Ofl7HemVWpuudLqCPdaQb4LfpRCUpE5HPxhp4JYscl8zZwxh11p26v2wvW24WMwnMfLjhRLixrfxA==} - node-readfiles@0.2.0: resolution: {integrity: sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA==} @@ -17429,8 +17422,6 @@ snapshots: dependencies: '@types/nlcst': 2.0.3 - node-addon-api@7.1.1: {} - node-fetch-h2@2.3.0: dependencies: http2-client: 1.3.5 @@ -17454,10 +17445,6 @@ snapshots: node-mock-http@1.0.5: {} - node-pty@1.2.0-beta.15(patch_hash=f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414): - dependencies: - node-addon-api: 7.1.1 - node-readfiles@0.2.0: dependencies: es6-promise: 3.3.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 85f4e97fa661..2bd1b36a33f2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,7 +17,6 @@ allowBuilds: esbuild: true msgpackr-extract: true msw: false - node-pty: true sharp: true utf-8-validate: false workerd: false @@ -183,7 +182,6 @@ patchedDependencies: # measured wrapped-row heights above an edit (fileEditorVirtualization.test.ts). "@pierre/diffs@1.5.2": patches/@pierre%2Fdiffs@1.5.2.patch effect@4.0.1: patches/effect@4.0.1.patch - node-pty@1.2.0-beta.15: patches/node-pty@1.2.0-beta.15.patch peerDependencyRules: allowAny: - vite diff --git a/scripts/apply-web-brand-assets.ts b/scripts/apply-web-brand-assets.ts index 90b4e39c104a..28e290f6ebaa 100644 --- a/scripts/apply-web-brand-assets.ts +++ b/scripts/apply-web-brand-assets.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; diff --git a/scripts/export-brand-icons.ts b/scripts/export-brand-icons.ts index 6ae7cf3802ea..30d1bdea020e 100644 --- a/scripts/export-brand-icons.ts +++ b/scripts/export-brand-icons.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; diff --git a/scripts/lib/environment-smoke.ts b/scripts/lib/environment-smoke.ts new file mode 100644 index 000000000000..028b002145ff --- /dev/null +++ b/scripts/lib/environment-smoke.ts @@ -0,0 +1,527 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeAssert from "node:assert/strict"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeEvents from "node:events"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeCrypto from "node:crypto"; +import * as NodeNet from "node:net"; +import * as Schema from "effect/Schema"; +import { + AuthSessionState, + AuthWebSocketTicketResult, + AuthMcpAuthorizationServerMetadata, + AuthMcpRegisteredClient, + AuthMcpApprovalDetails, + AuthMcpApprovalRedirect, + AuthMcpTokenResult, + ExecutionEnvironmentDescriptor, + Project, + ProjectSnapshot, +} from "@t3tools/contracts"; + +const decodeExecutionEnvironmentDescriptor = Schema.decodeUnknownSync( + Schema.toCodecJson(ExecutionEnvironmentDescriptor), +); +const decodeAuthSessionState = Schema.decodeUnknownSync(Schema.toCodecJson(AuthSessionState)); +const decodeAuthWebSocketTicketResult = Schema.decodeUnknownSync( + Schema.toCodecJson(AuthWebSocketTicketResult), +); +const decodeProject = Schema.decodeUnknownSync(Schema.toCodecJson(Project)); +const decodeProjectSnapshot = Schema.decodeUnknownSync(Schema.toCodecJson(ProjectSnapshot)); +const decodeMcpMetadata = Schema.decodeUnknownSync( + Schema.toCodecJson(AuthMcpAuthorizationServerMetadata), +); +const decodeMcpClient = Schema.decodeUnknownSync(Schema.toCodecJson(AuthMcpRegisteredClient)); +const decodeMcpApproval = Schema.decodeUnknownSync(Schema.toCodecJson(AuthMcpApprovalDetails)); +const decodeMcpRedirect = Schema.decodeUnknownSync(Schema.toCodecJson(AuthMcpApprovalRedirect)); +const decodeMcpToken = Schema.decodeUnknownSync(Schema.toCodecJson(AuthMcpTokenResult)); +const decodeMcpInitialized = Schema.decodeUnknownSync( + Schema.Struct({ result: Schema.Struct({ protocolVersion: Schema.String }) }), +); +const decodeMcpTools = Schema.decodeUnknownSync( + Schema.Struct({ + result: Schema.Struct({ tools: Schema.Array(Schema.Struct({ name: Schema.String })) }), + }), +); +const decodeMcpToolResult = Schema.decodeUnknownSync( + Schema.Struct({ + result: Schema.Struct({ + content: Schema.Array(Schema.Unknown), + isError: Schema.optionalKey(Schema.Boolean), + }), + }), +); + +export const redactEnvironmentLog = (value: string) => + value + .replace(/(token=)[^\s"'<>]+/g, "$1") + .replace(/(Token: )[^\s]+/g, "$1") + .replace(/^[ \t]*[\u2580-\u259f ][\u2580-\u259f \t]*$/gm, ""); + +const collectProcess = (child: NodeChildProcess.ChildProcess) => { + let output = ""; + child.stdout?.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + child.stderr?.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + return () => redactEnvironmentLog(output); +}; + +async function run( + executable: string, + args: ReadonlyArray, + cwd: string, + env = process.env, +) { + const child = NodeChildProcess.spawn(executable, args, { + cwd, + env, + stdio: ["ignore", "pipe", "pipe"], + }); + const output = collectProcess(child); + const [code] = await NodeEvents.EventEmitter.once(child, "exit"); + NodeAssert.equal(code, 0, output()); + return output(); +} + +export type EnvironmentSmokeInput = + | { readonly kind: "source"; readonly repoRoot: string; readonly bun: string } + | { readonly kind: "archive"; readonly archive: string; readonly expectVersion: string }; + +/** One disposable environment fixture for API checks and the real web-client suite. */ +export async function createEnvironmentFixture( + input: EnvironmentSmokeInput, + options: { + readonly prepare?: (paths: { + readonly scratch: string; + readonly home: string; + readonly workspace: string; + readonly interpreter: string; + }) => Promise; + } = {}, +) { + const scratch = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-environment-smoke-")); + const home = NodePath.join(scratch, "home"); + const workspace = NodePath.join(scratch, "workspace"); + await NodeFSP.mkdir(home); + await NodeFSP.mkdir(workspace); + let executable: string; + let args: ReadonlyArray; + let cwd: string; + let env: NodeJS.ProcessEnv; + if (input.kind === "archive") { + await run("/usr/bin/tar", ["-xf", input.archive, "-C", scratch], scratch); + const root = (await NodeFSP.readdir(scratch)).find( + (name) => name !== "home" && name !== "workspace", + ); + NodeAssert.ok(root, "The archive must contain an install directory."); + cwd = NodePath.join(scratch, root); + executable = NodePath.join(cwd, "t3"); + // No system Node, npm or Bun; helper execution must use the archive's runtime/bun. + env = { PATH: "", HOME: home, SHELL: "/bin/sh", TMPDIR: scratch, T3CODE_HOME: home }; + const version = await run(executable, ["--version"], cwd, env); + NodeAssert.ok(version.includes(input.expectVersion), `Unexpected CLI version: ${version}`); + const reservation = NodeNet.createServer(); + await new Promise((resolve, reject) => { + reservation.once("error", reject); + reservation.listen(0, "127.0.0.1", resolve); + }); + const address = reservation.address(); + NodeAssert.ok(address && typeof address !== "string"); + const port = address.port; + await new Promise((resolve, reject) => + reservation.close((error) => (error ? reject(error) : resolve())), + ); + args = [ + "serve", + "--host", + "127.0.0.1", + "--port", + String(port), + "--no-browser", + "--base-dir", + home, + ]; + } else { + executable = input.bun; + cwd = input.repoRoot; + args = ["scripts/dev-runner.ts", "dev", "--home-dir", home]; + env = { + HOME: home, + SHELL: "/bin/sh", + TMPDIR: scratch, + PATH: `${NodePath.join(cwd, "node_modules/.bin")}${NodePath.delimiter}${process.env.PATH ?? ""}`, + T3CODE_HOME: home, + T3CODE_DEV_INSTANCE: scratch, + T3CODE_DEV_AUTH_TOKEN: "", + T3CODE_DEV_ALLOWED_ORIGINS: "http://remote-client.example.test", + }; + delete env.VITE_HTTP_URL; + delete env.VITE_WS_URL; + } + const interpreter = input.kind === "archive" ? NodePath.join(cwd, "runtime/bun") : input.bun; + const prepared = await options.prepare?.({ scratch, home, workspace, interpreter }); + env = { ...env, ...prepared }; + let server: NodeChildProcess.ChildProcess | undefined; + let output = () => ""; + let pairingUrl = ""; + let serverOrigin = ""; + let cookie = ""; + const stop = async () => { + if (server === undefined || server.exitCode !== null || server.signalCode !== null) return; + const exited = NodeEvents.EventEmitter.once(server, "exit"); + // The captured process group contains only this fixture's Vite/server children. + process.kill(-server.pid!, "SIGTERM"); + try { + await Promise.race([ + exited, + new Promise((_, reject) => { + const timeout = setTimeout( + () => reject(new Error("Server did not stop within 15s.")), + 15_000, + ); + timeout.unref(); + }), + ]); + } catch (error) { + process.kill(-server.pid!, "SIGKILL"); + await exited; + throw error; + } + }; + const start = async () => { + server = NodeChildProcess.spawn(executable, args, { + cwd, + env, + detached: true, + stdio: ["ignore", "pipe", "pipe"], + }); + if (process.env.T3_SMOKE_DEBUG) + console.error(`[environment-smoke] spawned pid=${server.pid} executable=${executable}`); + output = collectProcess(server); + // The server's published pairing URL is an observable startup milestone. + // Readiness never depends on a fixed sleep or repeated domain assertions. + pairingUrl = await new Promise((resolve, reject) => { + const child = server!; + let startup = ""; + const timeout = setTimeout( + () => reject(new Error(`Startup deadline exceeded.\n${output()}`)), + 90_000, + ); + const inspect = (chunk: Buffer) => { + if (process.env.T3_SMOKE_DEBUG) + process.stderr.write(redactEnvironmentLog(chunk.toString())); + startup += chunk.toString(); + const match = /https?:\/\/[^\s"'<>]+\/pair[?#]token=[^\s"'<>]+/.exec(startup); + if (!match) return; + serverOrigin = + /Listening on (https?:\/\/[^\s]+)/.exec(startup)?.[1] ?? new URL(match[0]).origin; + clearTimeout(timeout); + child.stdout?.off("data", inspect); + child.stderr?.off("data", inspect); + resolve(match[0]); + }; + child.stdout?.on("data", inspect); + child.stderr?.on("data", inspect); + child.once("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + child.once("exit", (code, signal) => { + clearTimeout(timeout); + reject(new Error(`Server exited (${code ?? signal}).\n${output()}`)); + }); + }); + return pairingUrl; + }; + const request = async (route: string, options: RequestInit = {}): Promise => { + if (process.env.T3_SMOKE_DEBUG) + console.error(`[environment-smoke] ${options.method ?? "GET"} ${route}`); + const response = await fetch(new URL(route, pairingUrl), { + ...options, + signal: options.signal ?? AbortSignal.timeout(10_000), + headers: { ...(cookie ? { cookie } : {}), ...options.headers }, + }).catch((error: unknown) => { + throw new Error(`${options.method ?? "GET"} ${route}: ${String(error)}\n${output()}`); + }); + // Drain bounded API responses even when a caller only needs their headers. + // This releases the client's connection before restart or the next request. + const body = await response.arrayBuffer(); + NodeAssert.ok( + response.ok, + `${options.method ?? "GET"} ${route}: ${response.status} ${new TextDecoder().decode(body)}\n${output()}`, + ); + return new Response(body, { status: response.status, headers: response.headers }); + }; + try { + await start(); + } catch (error) { + await stop(); + await NodeFSP.rm(scratch, { recursive: true, force: true }); + throw error; + } + return { + scratch, + home, + workspace, + input, + get origin() { + return new URL(pairingUrl).origin; + }, + get pairingUrl() { + return pairingUrl; + }, + get serverOrigin() { + return serverOrigin; + }, + get log() { + return output(); + }, + request, + async pair() { + const url = new URL(pairingUrl); + const credential = + url.searchParams.get("token") ?? new URLSearchParams(url.hash.slice(1)).get("token"); + NodeAssert.ok(credential, "The startup URL must carry a pairing credential."); + const response = await request("/api/auth/browser-session", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ credential }), + }); + const header = response.headers.get("set-cookie"); + NodeAssert.ok(header, "Pairing must issue a session cookie."); + NodeAssert.match(header, /HttpOnly/i); + cookie = header.split(";", 1)[0]!; + return cookie; + }, + async restart() { + await stop(); + return start(); + }, + stop, + async dispose() { + await stop(); + await NodeFSP.rm(scratch, { recursive: true, force: true }); + }, + }; +} + +export type EnvironmentFixture = Awaited>; + +/** Public OAuth and MCP round trip, including redirects, response headers and session cleanup. */ +async function checkMcp(fixture: EnvironmentFixture) { + const jsonPost = (body: unknown): RequestInit => ({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + const metadata = decodeMcpMetadata( + await (await fixture.request("/.well-known/oauth-authorization-server")).json(), + ); + NodeAssert.equal(metadata.issuer, fixture.origin); + const registered = await fixture.request( + "/oauth/mcp/register", + jsonPost({ + client_name: "Runtime acceptance", + redirect_uris: ["http://localhost:51234/callback"], + token_endpoint_auth_method: "none", + }), + ); + NodeAssert.equal(registered.headers.get("cache-control"), "no-store"); + const client = decodeMcpClient(await registered.json()); + const verifier = NodeCrypto.randomBytes(32).toString("base64url"); + const authorization = { + response_type: "code", + client_id: client.client_id, + redirect_uri: "http://localhost:51234/callback", + code_challenge: NodeCrypto.createHash("sha256").update(verifier).digest("base64url"), + code_challenge_method: "S256", + state: "runtime-acceptance", + resource: `${fixture.origin}/mcp`, + }; + const redirect = await fetch( + new URL(`/oauth/mcp/authorize?${new URLSearchParams(authorization)}`, fixture.origin), + { + redirect: "manual", + signal: AbortSignal.timeout(10_000), + }, + ); + NodeAssert.equal(redirect.status, 302); + NodeAssert.ok(redirect.headers.get("location")?.startsWith("/connect-agent?")); + NodeAssert.equal(redirect.headers.get("cache-control"), "no-store"); + const approval = decodeMcpApproval( + await (await fixture.request("/oauth/mcp/approval", jsonPost(authorization))).json(), + ); + NodeAssert.ok(approval.csrfToken); + const approved = decodeMcpRedirect( + await ( + await fixture.request( + "/oauth/mcp/decision", + jsonPost({ + authorization, + decision: { + _tag: "browser-session", + access: "read-only", + csrfToken: approval.csrfToken, + }, + }), + ) + ).json(), + ); + const callback = new URL(approved.redirectTo); + NodeAssert.equal(callback.searchParams.get("state"), authorization.state); + NodeAssert.equal(callback.searchParams.get("iss"), fixture.origin); + const code = callback.searchParams.get("code"); + NodeAssert.ok(code); + const tokenResponse = await fixture.request("/oauth/mcp/token", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: client.client_id, + code, + redirect_uri: authorization.redirect_uri, + code_verifier: verifier, + resource: authorization.resource, + }), + }); + NodeAssert.equal(tokenResponse.headers.get("cache-control"), "no-store"); + const token = decodeMcpToken(await tokenResponse.json()); + let sessionId = ""; + const rpc = async (id: number, method: string, params?: unknown) => { + const response = await fixture.request("/mcp", { + method: "POST", + headers: { + authorization: `Bearer ${token.access_token}`, + "content-type": "application/json", + accept: "application/json, text/event-stream", + "mcp-protocol-version": "2025-06-18", + ...(sessionId ? { "mcp-session-id": sessionId } : {}), + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id, + method, + ...(params === undefined ? {} : { params }), + }), + }); + sessionId ||= response.headers.get("mcp-session-id") ?? ""; + const text = await response.text(); + const payload = + text.startsWith("data:") || text.startsWith("event:") + ? text + .split("\n") + .find((line) => line.startsWith("data: ")) + ?.slice(6) + : text; + NodeAssert.ok(payload, "MCP must return a JSON-RPC response."); + return JSON.parse(payload) as unknown; + }; + const initialized = decodeMcpInitialized( + await rpc(1, "initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "runtime-acceptance", version: "1" }, + }), + ); + NodeAssert.equal(initialized.result.protocolVersion, "2025-06-18"); + const tools = decodeMcpTools(await rpc(2, "tools/list")); + NodeAssert.ok(tools.result.tools.some((tool) => tool.name === "orchestrator_capabilities")); + const called = decodeMcpToolResult( + await rpc(3, "tools/call", { name: "orchestrator_capabilities", arguments: {} }), + ); + NodeAssert.notEqual(called.result.isError, true); + NodeAssert.ok(called.result.content.length > 0); + await fixture.request("/mcp", { + method: "DELETE", + headers: { authorization: `Bearer ${token.access_token}`, "mcp-session-id": sessionId }, + }); +} + +/** Exercises the public transport/persistence boundary in both launch forms. */ +export async function checkEnvironment(fixture: EnvironmentFixture) { + if (process.env.T3_SMOKE_DEBUG) console.error("[environment-smoke] checking transport"); + const descriptor = decodeExecutionEnvironmentDescriptor( + await (await fixture.request("/.well-known/t3/environment")).json(), + ); + await fixture.pair(); + const session = decodeAuthSessionState(await (await fixture.request("/api/auth/session")).json()); + NodeAssert.ok(session.authenticated, "Pairing must authenticate subsequent requests."); + const client = await fixture.request("/"); + NodeAssert.ok((await client.text()).includes("((resolve, reject) => { + const timeout = setTimeout(() => { + socket.close(); + reject(new Error("WebSocket upgrade deadline exceeded.")); + }, 10_000); + socket.addEventListener( + "open", + () => { + clearTimeout(timeout); + resolve(); + }, + { once: true }, + ); + socket.addEventListener( + "error", + () => { + clearTimeout(timeout); + reject(new Error("WebSocket upgrade failed.")); + }, + { once: true }, + ); + }); + socket.close(); + await checkMcp(fixture); + const projectId = NodeCrypto.randomUUID(); + const project = decodeProject( + await ( + await fixture.request("/api/projects/mutate", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + type: "project.create", + projectId, + commandId: NodeCrypto.randomUUID(), + title: "Runtime smoke project", + workspaceRoot: fixture.workspace, + }), + }) + ).json(), + ); + NodeAssert.equal(project.id, projectId); + if (process.env.T3_SMOKE_DEBUG) console.error("[environment-smoke] restarting"); + await fixture.restart(); + const restored = decodeProjectSnapshot(await (await fixture.request("/api/projects")).json()); + NodeAssert.ok( + restored.projects.some((entry) => entry.id === projectId), + "Projects and session authentication must survive restart.", + ); + const after = decodeExecutionEnvironmentDescriptor( + await (await fixture.request("/.well-known/t3/environment")).json(), + ); + NodeAssert.equal(after.environmentId, descriptor.environmentId); +} diff --git a/scripts/notify-discord-release.ts b/scripts/notify-discord-release.ts index 689fe965a2c5..a3177995d880 100644 --- a/scripts/notify-discord-release.ts +++ b/scripts/notify-discord-release.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; diff --git a/scripts/package.json b/scripts/package.json index cd5d77717a8c..37f3d823f757 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "@effect/platform-node": "catalog:", + "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/tailscale": "workspace:*", "effect": "catalog:", diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 740a5029ff3e..6b1126e6d265 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -63,7 +63,7 @@ const tempRoot = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-release-s try { NodeChildProcess.execFileSync( process.execPath, - ["--test", NodePath.resolve(repoRoot, ".github/scripts/relay-state-output.test.cjs")], + ["test", NodePath.resolve(repoRoot, ".github/scripts/relay-state-output.test.cjs")], { stdio: "inherit" }, ); diff --git a/scripts/resolve-nightly-release.ts b/scripts/resolve-nightly-release.ts index be7a3347e946..c9ebde7be6bd 100644 --- a/scripts/resolve-nightly-release.ts +++ b/scripts/resolve-nightly-release.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; diff --git a/scripts/resolve-previous-release-tag.ts b/scripts/resolve-previous-release-tag.ts index d6c17d060e42..d1e422a46362 100644 --- a/scripts/resolve-previous-release-tag.ts +++ b/scripts/resolve-previous-release-tag.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; diff --git a/scripts/setup-worktree.ts b/scripts/setup-worktree.ts index 027112ca1858..fc046ef008f5 100644 --- a/scripts/setup-worktree.ts +++ b/scripts/setup-worktree.ts @@ -1,8 +1,8 @@ -// @effect-diagnostics nodeBuiltinImport:off - runs before `vp i`, so only Node built-ins exist. +// @effect-diagnostics nodeBuiltinImport:off - runs before `vp i`, so only runtime built-ins exist. /** * Worktree setup, run by the t3.json "Setup Worktree" action as - * `node scripts/setup-worktree.ts`. Plain Node keeps one command working in - * every shell T3 Code spawns (zsh, bash, fish, PowerShell): it installs + * `bun scripts/setup-worktree.ts`. Bun keeps one command working in + * every supported shell T3 Code spawns (zsh, bash, fish): it installs * dependencies, links the main checkout's gitignored env files into this * worktree, then warms the web dependency cache. */ diff --git a/scripts/smoke-cli-archive.ts b/scripts/smoke-cli-archive.ts index 179c268b909a..d3c853e17357 100644 --- a/scripts/smoke-cli-archive.ts +++ b/scripts/smoke-cli-archive.ts @@ -1,210 +1,51 @@ -#!/usr/bin/env node -/** - * Unpacks a CLI archive into a scratch directory and runs the executable the - * way an installer would: no repo, no node_modules, no Node on PATH. Catches - * the failures that only show inside the single-executable, such as an - * external package reached through `import` or a native addon the hardened - * runtime refuses to load. - */ -import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +#!/usr/bin/env bun +// @effect-diagnostics nodeBuiltinImport:off import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Duration from "effect/Duration"; +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; -import * as Path from "effect/Path"; -import * as Schedule from "effect/Schedule"; -import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; +import * as Option from "effect/Option"; import { Command, Flag } from "effect/cli"; -import { ChildProcess, ChildProcessSpawner } from "effect/process"; - -import * as NetService from "@t3tools/shared/Net"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { windowsSystemTar } from "./build-cli-archive.ts"; -import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"; - -export class CliArchiveSmokeError extends Schema.TaggedError()( - "CliArchiveSmokeError", - { step: Schema.String, detail: Schema.String }, -) { - override get message(): string { - return `CLI archive smoke test failed while ${this.step}: ${this.detail}`; - } -} - -const collect = (stream: Stream.Stream) => - stream.pipe( - Stream.decodeText(), - Stream.runFold( - () => "", - (acc, chunk) => acc + chunk, - ), - ); - -const runExecutable = Effect.fn("runExecutable")(function* ( - executable: string, - args: ReadonlyArray, - cwd: string, -) { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const child = yield* spawner.spawn( - ChildProcess.make(executable, args, { - cwd, - // Empty PATH: the archive must not reach a system node, and the - // launcher context must not leak in from a developer shell. - env: { PATH: "", HOME: cwd, USERPROFILE: cwd, TMPDIR: cwd, TEMP: cwd }, - extendEnv: false, - }), - ); - const [stdout, stderr, exitCode] = yield* Effect.all( - [collect(child.stdout), collect(child.stderr), child.exitCode.pipe(Effect.map(Number))], - { concurrency: "unbounded" }, - ); - return { stdout, stderr, exitCode }; -}); - -const smokeCliArchive = Effect.fn("smokeCliArchive")(function* (input: { - readonly archive: string; - readonly expectVersion: string; -}) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const platform = yield* HostProcessPlatform; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const scratch = yield* fs.makeTempDirectory({ prefix: "t3-cli-smoke-" }); - // Windows can keep t3.exe locked (EBUSY) for a moment after the server - // exits. A leftover scratch directory on a CI runner is harmless, so - // cleanup retries briefly and never fails a smoke test that passed. - yield* Effect.addFinalizer(() => - fs.remove(scratch, { recursive: true }).pipe( - Effect.retry({ times: 10, schedule: Schedule.spaced("500 millis") }), - Effect.catch((error) => - Effect.logWarning(`[cli-smoke] could not remove ${scratch}: ${error.message}`), - ), - ), - ); - - // On Windows the archive is a zip and the Git Bash `tar` on PATH is GNU - // tar; use the bsdtar Windows ships, which reads both formats. - const tar = platform === "win32" ? windowsSystemTar() : "tar"; - const extract = yield* spawner - .spawn(ChildProcess.make(tar, ["-xf", input.archive, "-C", scratch])) - .pipe(Effect.flatMap((child) => child.exitCode)); - if (Number(extract) !== 0) { - return yield* new CliArchiveSmokeError({ - step: "extracting the archive", - detail: `tar exited with ${String(extract)}`, - }); - } - const [root] = yield* fs.readDirectory(scratch); - if (root === undefined) { - return yield* new CliArchiveSmokeError({ - step: "extracting the archive", - detail: "the archive was empty", - }); - } - const contentDir = path.join(scratch, root); - const executable = path.join(contentDir, platform === "win32" ? "t3.exe" : "t3"); - for (const required of [executable, path.join(contentDir, "client/index.html")]) { - if (!(yield* fs.exists(required))) { - return yield* new CliArchiveSmokeError({ - step: "checking the archive layout", - detail: `missing ${path.relative(contentDir, required)}`, - }); - } - } - - const version = yield* runExecutable(executable, ["--version"], contentDir); - if (version.exitCode !== 0 || !version.stdout.includes(input.expectVersion)) { - return yield* new CliArchiveSmokeError({ - step: "running --version", - detail: `exit ${String(version.exitCode)}\n${version.stdout}${version.stderr}`, - }); - } - - // Starting the server is what actually opens sqlite, loads the terminal - // and search stacks (node-pty, fff, msgpackr-extract), and serves the - // client, so probe a real `serve` in a scratch home rather than a - // command that only reads package metadata. - const net = yield* NetService.NetService; - const port = yield* net.findAvailablePort(47700); - const home = path.join(scratch, "home"); - const server = yield* spawner.spawn( - ChildProcess.make( - executable, - ["serve", "--host", "127.0.0.1", "--port", String(port), "--no-browser"], - { - cwd: contentDir, - env: { - PATH: "", - HOME: home, - USERPROFILE: home, - TMPDIR: scratch, - TEMP: scratch, - T3CODE_HOME: home, - }, - extendEnv: false, - }, - ), - ); - const output = yield* Effect.forkScoped( - Effect.all([collect(server.stdout), collect(server.stderr)]), - ); - const httpClient = yield* HttpClient.HttpClient; - // A request that connects while the server is still initializing can hang, - // so each probe gets its own deadline, like the SSH readiness probe. - const probe = httpClient.execute(HttpClientRequest.get(`http://127.0.0.1:${String(port)}/`)).pipe( - Effect.map((response) => response.status === 200), - Effect.timeout(Duration.seconds(2)), - Effect.orElseSucceed(() => false), - ); - const pollUntilReady = Effect.gen(function* () { - while (!(yield* probe)) { - yield* Effect.sleep(Duration.millis(250)); - } - return true; - }); - const ready = yield* pollUntilReady.pipe( - Effect.timeout(Duration.seconds(30)), - Effect.orElseSucceed(() => false), - ); - yield* server.kill({ killSignal: "SIGTERM" }).pipe(Effect.ignore); - yield* server.exitCode.pipe(Effect.timeout(Duration.seconds(10)), Effect.ignore); - const [stdout, stderr] = yield* Fiber.join(output).pipe( - Effect.timeout(Duration.seconds(5)), - Effect.orElseSucceed(() => ["", ""] as const), - ); - if (!ready) { - return yield* new CliArchiveSmokeError({ - step: "serving from the extracted archive", - detail: `no 200 from / within 30s\n${stdout}${stderr}`, - }); - } - yield* Effect.log(`[cli-smoke] ${root}: --version passed and serve answered on ${String(port)}.`); -}); +import * as NodeURL from "node:url"; +import { checkEnvironment, createEnvironmentFixture } from "./lib/environment-smoke.ts"; const command = Command.make( "smoke-cli-archive", { - archive: Flag.String("archive"), - expectVersion: Flag.String("expect-version"), + archive: Flag.String("archive").pipe(Flag.optional), + expectVersion: Flag.String("expect-version").pipe(Flag.withDefault("0.0.0")), + source: Flag.Boolean("source").pipe(Flag.withDefault(false)), }, - (input) => smokeCliArchive(input).pipe(Effect.scoped), -).pipe(Command.withDescription("Extract a CLI archive and run its executable.")); + (input) => + Effect.tryPromise(async () => { + const fixture = await createEnvironmentFixture( + input.source + ? { + kind: "source", + repoRoot: NodeURL.fileURLToPath(new URL("..", import.meta.url)), + bun: process.execPath, + } + : { + kind: "archive", + archive: Option.getOrThrow(input.archive), + expectVersion: input.expectVersion, + }, + ); + try { + await checkEnvironment(fixture); + console.log( + `[environment-smoke] ${input.source ? "source" : "archive"}: authenticated HTTP, cookies, CORS, WebSocket upgrade and persistence across restart passed.`, + ); + } finally { + await fixture.dispose(); + } + }), +).pipe( + Command.withDescription("Exercise an isolated Bun source environment or extracted CLI archive."), +); if (import.meta.main) { Command.run(command, { version: "0.0.0" }).pipe( - Effect.provide( - Layer.mergeAll( - Logger.layer([Logger.consolePretty()]), - NodeServices.layer, - NetService.layer, - FetchHttpClient.layer, - ), - ), + Effect.provide(NodeServices.layer), NodeRuntime.runMain, ); } diff --git a/scripts/sync-reference-repos.ts b/scripts/sync-reference-repos.ts index 6a2dcc68c6ed..1298a5f41d5e 100644 --- a/scripts/sync-reference-repos.ts +++ b/scripts/sync-reference-repos.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; diff --git a/scripts/update-release-package-versions.ts b/scripts/update-release-package-versions.ts index 2bd36cdbdbef..b9b40de4e77e 100644 --- a/scripts/update-release-package-versions.ts +++ b/scripts/update-release-package-versions.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; diff --git a/t3.json b/t3.json index d2c55e050776..acf209824934 100644 --- a/t3.json +++ b/t3.json @@ -4,7 +4,7 @@ "scripts": [ { "name": "Setup Worktree", - "command": "node scripts/setup-worktree.ts", + "command": "bun scripts/setup-worktree.ts", "icon": "configure", "runOnWorktreeCreate": true } From 004366df14b3922206a30535f4c8b0b8564c8695 Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Wed, 7 Oct 2026 17:24:28 +0200 Subject: [PATCH 18/32] test(web): provide a local ACP browser sign-in fixture --- apps/server/scripts/web-fixtures/fake-acp.mjs | 51 ++++++++++++++ .../scripts/web-fixtures/fake-acp.test.ts | 66 +++++++++++++++++++ apps/server/scripts/web-fixtures/prepare.ts | 10 +++ 3 files changed, 127 insertions(+) create mode 100644 apps/server/scripts/web-fixtures/fake-acp.mjs create mode 100644 apps/server/scripts/web-fixtures/fake-acp.test.ts diff --git a/apps/server/scripts/web-fixtures/fake-acp.mjs b/apps/server/scripts/web-fixtures/fake-acp.mjs new file mode 100644 index 000000000000..ecbd1840a82d --- /dev/null +++ b/apps/server/scripts/web-fixtures/fake-acp.mjs @@ -0,0 +1,51 @@ +// External ACP authentication fixture. No credentials, browser launch or network calls. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeReadline from "node:readline"; + +if (process.argv.includes("--version")) { + process.stdout.write("fixture-acp 1.0.0\n"); + process.exit(0); +} +const controlFlag = process.argv.indexOf("--control"); +const control = controlFlag === -1 ? process.env.T3_FAKE_CONTROL : process.argv[controlFlag + 1]; +const send = (message) => + process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`); +const lines = NodeReadline.createInterface({ input: process.stdin }); +lines.on("line", (line) => { + const { id, method, params = {} } = JSON.parse(line); + // The browser consent reply is deliberately not a completed sign-in. The test + // cancels the pending login and the production runtime disposes this process. + if (method === undefined || id === undefined) return; + const reply = (result) => send({ id, result }); + const fail = (code, message) => send({ id, error: { code, message } }); + if (method === "initialize") { + return reply({ + protocolVersion: 2, + info: { name: "web-auth-fixture", version: "1.0.0" }, + capabilities: {}, + authMethods: [{ type: "agent", methodId: "browser", name: "Browser sign-in" }], + }); + } + if (method === "session/new") return fail(-32000, "Authentication required"); + if (method === "authenticate" || method === "auth/login") { + if (params.methodId !== "browser") return fail(-32602, "Unknown authentication method"); + if (control && NodeFS.existsSync(NodePath.join(control, "auth-error"))) { + return fail(-32603, "Controlled sign-in failure"); + } + return send({ + id: "fixture-browser-consent", + method: "elicitation/create", + params: { + requestId: String(id), + mode: "url", + elicitationId: "fixture-browser", + message: "Sign in to the controlled provider", + url: "https://auth.fixture.invalid/authorize?fixture=web-regression", + }, + }); + } + if (method === "logout" || method === "auth/logout") return reply({}); + return fail(-32601, `Unsupported fixture method: ${method}`); +}); +lines.on("close", () => process.exit(0)); diff --git a/apps/server/scripts/web-fixtures/fake-acp.test.ts b/apps/server/scripts/web-fixtures/fake-acp.test.ts new file mode 100644 index 000000000000..faa6d0a0da36 --- /dev/null +++ b/apps/server/scripts/web-fixtures/fake-acp.test.ts @@ -0,0 +1,66 @@ +// @effect-diagnostics nodeBuiltinImport:off -- The acceptance dependency is an actual Bun subprocess. +import * as NodeURL from "node:url"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Path from "effect/Path"; +import * as AcpSessionRuntime from "../../src/provider/acp/AcpSessionRuntime.ts"; + +const fixture = NodeURL.fileURLToPath(new URL("fake-acp.mjs", import.meta.url)); +const makeRuntime = (control: string) => + AcpSessionRuntime.make({ + spawn: { + command: process.env.BUN_EXECUTABLE ?? "bun", + args: [fixture, "--control", control], + cwd: control, + }, + cwd: control, + clientInfo: { name: "web-auth-regression", version: "1" }, + clientCapabilities: { elicitation: { url: {} } }, + authenticateOnAuthRequired: false, + }); + +it.effect("the local ACP dependency requires sign-in and relays a cancellable browser URL", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const control = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fake-acp-" }); + const runtime = yield* makeRuntime(control); + const initialized = yield* runtime.initialize(); + expect(initialized.authMethods).toMatchObject([{ id: "browser", type: "agent" }]); + const readiness = yield* runtime.start().pipe(Effect.result); + expect(readiness._tag).toBe("Failure"); + if (readiness._tag === "Failure") + expect(readiness.failure).toMatchObject({ + code: -32000, + errorMessage: "Authentication required", + }); + const url = yield* Deferred.make(); + yield* runtime.handleElicitation((request) => { + expect(request.mode).toBe("url"); + if (!("url" in request)) return Effect.die("Expected browser URL elicitation"); + return Deferred.succeed(url, request.url).pipe(Effect.andThen(Effect.never)); + }); + const signIn = yield* runtime.authenticate("browser").pipe(Effect.forkScoped); + expect(yield* Deferred.await(url)).toBe( + "https://auth.fixture.invalid/authorize?fixture=web-regression", + ); + yield* Fiber.interrupt(signIn); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("the local ACP dependency reports a controlled sign-in failure", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const control = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fake-acp-error-" }); + yield* fs.writeFileString(path.join(control, "auth-error"), "fail"); + const runtime = yield* makeRuntime(control); + const signIn = yield* runtime.authenticate("browser").pipe(Effect.result); + expect(signIn._tag).toBe("Failure"); + if (signIn._tag === "Failure") + expect(signIn.failure).toMatchObject({ errorMessage: "Controlled sign-in failure" }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/scripts/web-fixtures/prepare.ts b/apps/server/scripts/web-fixtures/prepare.ts index 175ffe4a13cc..f4b48ceeb7c1 100644 --- a/apps/server/scripts/web-fixtures/prepare.ts +++ b/apps/server/scripts/web-fixtures/prepare.ts @@ -25,9 +25,19 @@ export async function prepareWebDependencies( const bin = NodePath.join(paths.scratch, "bin"); await NodeFSP.mkdir(control); await NodeFSP.mkdir(bin); + const authFixture = NodePath.join(paths.scratch, "provider-fixtures", "fake-acp.mjs"); + await NodeFSP.mkdir(NodePath.dirname(authFixture)); + await NodeFSP.copyFile(NodePath.join(here, "fake-acp.mjs"), authFixture); + const authProvider = NodePath.join(bin, "acp-signin"); + await NodeFSP.writeFile( + authProvider, + `#!/bin/sh\nexec ${shellQuote(paths.interpreter)} ${shellQuote(authFixture)} --control ${shellQuote(control)} "$@"\n`, + { mode: 0o755 }, + ); const provider = NodePath.join(bin, "codex"); await NodeFSP.writeFile( provider, + authProvider, `#!/bin/sh\nexec ${shellQuote(paths.interpreter)} ${shellQuote(NodePath.join(here, "fake-codex.mjs"))} "$@"\n`, { mode: 0o755 }, ); From 6b66c035939164a30f81cb1b38dbe9323ce3b77d Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Wed, 7 Oct 2026 17:25:53 +0200 Subject: [PATCH 19/32] fix(test): expose the ACP wrapper and narrow auth responses --- apps/server/scripts/web-fixtures/fake-acp.test.ts | 7 ++++++- apps/server/scripts/web-fixtures/prepare.ts | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/server/scripts/web-fixtures/fake-acp.test.ts b/apps/server/scripts/web-fixtures/fake-acp.test.ts index faa6d0a0da36..0eb3456f433b 100644 --- a/apps/server/scripts/web-fixtures/fake-acp.test.ts +++ b/apps/server/scripts/web-fixtures/fake-acp.test.ts @@ -40,9 +40,12 @@ it.effect("the local ACP dependency requires sign-in and relays a cancellable br const url = yield* Deferred.make(); yield* runtime.handleElicitation((request) => { expect(request.mode).toBe("url"); - if (!("url" in request)) return Effect.die("Expected browser URL elicitation"); + if (!("url" in request) || typeof request.url !== "string") + return Effect.die("Expected browser URL elicitation"); return Deferred.succeed(url, request.url).pipe(Effect.andThen(Effect.never)); }); + if (runtime.authenticate === undefined) + return yield* Effect.die("Expected explicit ACP authentication"); const signIn = yield* runtime.authenticate("browser").pipe(Effect.forkScoped); expect(yield* Deferred.await(url)).toBe( "https://auth.fixture.invalid/authorize?fixture=web-regression", @@ -58,6 +61,8 @@ it.effect("the local ACP dependency reports a controlled sign-in failure", () => const control = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fake-acp-error-" }); yield* fs.writeFileString(path.join(control, "auth-error"), "fail"); const runtime = yield* makeRuntime(control); + if (runtime.authenticate === undefined) + return yield* Effect.die("Expected explicit ACP authentication"); const signIn = yield* runtime.authenticate("browser").pipe(Effect.result); expect(signIn._tag).toBe("Failure"); if (signIn._tag === "Failure") diff --git a/apps/server/scripts/web-fixtures/prepare.ts b/apps/server/scripts/web-fixtures/prepare.ts index f4b48ceeb7c1..1c063679d922 100644 --- a/apps/server/scripts/web-fixtures/prepare.ts +++ b/apps/server/scripts/web-fixtures/prepare.ts @@ -37,7 +37,6 @@ export async function prepareWebDependencies( const provider = NodePath.join(bin, "codex"); await NodeFSP.writeFile( provider, - authProvider, `#!/bin/sh\nexec ${shellQuote(paths.interpreter)} ${shellQuote(NodePath.join(here, "fake-codex.mjs"))} "$@"\n`, { mode: 0o755 }, ); @@ -125,6 +124,7 @@ export async function prepareWebDependencies( T3_WEB_FIXTURE_BIN: bin, }, provider, + authProvider, control, owner, bin, From 32a24f9e21a5b174762bba48b4ca310920d31695 Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Wed, 7 Oct 2026 17:55:44 +0200 Subject: [PATCH 20/32] test(web): cover Codex probes without fixture control --- .../scripts/web-fixtures/fake-codex.test.ts | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/apps/server/scripts/web-fixtures/fake-codex.test.ts b/apps/server/scripts/web-fixtures/fake-codex.test.ts index 68e10272e9f4..8368e6a2bf10 100644 --- a/apps/server/scripts/web-fixtures/fake-codex.test.ts +++ b/apps/server/scripts/web-fixtures/fake-codex.test.ts @@ -8,9 +8,41 @@ import * as NodeReadline from "node:readline"; import * as NodeURL from "node:url"; import { expect, test } from "@effect/vitest"; import * as Schema from "effect/Schema"; -import { ServerNotification } from "effect-codex-app-server/schema"; +import { ServerNotification, v1 } from "effect-codex-app-server/schema"; const decodeNotification = Schema.decodeUnknownSync(ServerNotification); +const decodeInitialize = Schema.decodeUnknownSync(v1.InitializeResponse); + +test("a background Codex probe initializes without fixture control or CODEX_HOME", async () => { + const cwd = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-fake-codex-probe-")); + const env = { ...process.env }; + delete env.T3_FAKE_CONTROL; + delete env.CODEX_HOME; + try { + const child = NodeChildProcess.spawnSync( + process.env.BUN_EXECUTABLE ?? "bun", + [ + process.env.T3_TEST_CODEX_FIXTURE ?? + NodeURL.fileURLToPath(new URL("fake-codex.mjs", import.meta.url)), + ], + { + cwd, + env, + input: `${JSON.stringify({ id: 1, method: "initialize", params: {} })}\n`, + encoding: "utf8", + timeout: 5000, + }, + ); + expect(child.error).toBeUndefined(); + expect(child.status, child.stderr).toBe(0); + const response = JSON.parse(child.stdout); + expect(response.id).toBe(1); + const initialized = decodeInitialize(response.result); + expect(initialized.codexHome).toBe(await NodeFSP.realpath(cwd)); + } finally { + await NodeFSP.rm(cwd, { recursive: true, force: true }); + } +}); test("the controlled provider returns a deterministic title through codex exec", async () => { const control = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-fake-title-")); From 0f6df71b268bb288c4e4f4712de82a934881a9d1 Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Wed, 7 Oct 2026 18:00:58 +0200 Subject: [PATCH 21/32] test(web): keep Codex regression on the local fixture --- apps/server/scripts/web-fixtures/fake-codex.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/server/scripts/web-fixtures/fake-codex.test.ts b/apps/server/scripts/web-fixtures/fake-codex.test.ts index 8368e6a2bf10..d62196792d55 100644 --- a/apps/server/scripts/web-fixtures/fake-codex.test.ts +++ b/apps/server/scripts/web-fixtures/fake-codex.test.ts @@ -21,10 +21,7 @@ test("a background Codex probe initializes without fixture control or CODEX_HOME try { const child = NodeChildProcess.spawnSync( process.env.BUN_EXECUTABLE ?? "bun", - [ - process.env.T3_TEST_CODEX_FIXTURE ?? - NodeURL.fileURLToPath(new URL("fake-codex.mjs", import.meta.url)), - ], + [NodeURL.fileURLToPath(new URL("fake-codex.mjs", import.meta.url))], { cwd, env, From 8e1e21101719f2fbe84240ced82adf25cc0090b3 Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Wed, 7 Oct 2026 18:07:58 +0200 Subject: [PATCH 22/32] test(web): await observable Bun client milestones --- apps/server/scripts/web-client-regression.ts | 141 +++++++++++------- .../scripts/web-fixtures/fake-codex.mjs | 2 +- scripts/lib/environment-smoke.ts | 24 ++- 3 files changed, 111 insertions(+), 56 deletions(-) diff --git a/apps/server/scripts/web-client-regression.ts b/apps/server/scripts/web-client-regression.ts index 3d56739ae9e0..dd84947f1106 100644 --- a/apps/server/scripts/web-client-regression.ts +++ b/apps/server/scripts/web-client-regression.ts @@ -7,6 +7,7 @@ import * as NodeHttp from "node:http"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeURL from "node:url"; +import * as NodeUtil from "node:util"; import * as Effect from "effect/Effect"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; @@ -91,15 +92,22 @@ async function configure(fixture: EnvironmentFixture, dependencies: WebDependenc const threadId = ThreadId.make(NodeCrypto.randomUUID()); await withRegressionRpc(fixture, async (client) => { const provider = (signin: boolean) => ({ - driver: ProviderDriverKind.make("codex"), + driver: ProviderDriverKind.make(signin ? "acpRegistry" : "codex"), displayName: signin ? "Fixture sign-in" : "Fixture Codex", enabled: true, - config: { - setupMode: "existing", - enabled: true, - binaryPath: dependencies.provider, - customModels: ["gpt-5.6-sol"], - }, + config: signin + ? { + source: "local", + commandPath: dependencies.authProvider, + commandArgs: [], + authMethodId: "browser", + } + : { + setupMode: "existing", + enabled: true, + binaryPath: dependencies.provider, + customModels: ["gpt-5.6-sol"], + }, environment: [ { name: "T3_FAKE_CONTROL", value: dependencies.control, sensitive: false }, { name: "T3_FAKE_OWNER", value: dependencies.owner, sensitive: false }, @@ -172,27 +180,33 @@ async function addSurface(page: Page, name: "Terminal" | "Browser" | "Device") { async function terminalMilestone( client: RegressionRpcClient, threadId: ThreadId, - predicate: (event: TerminalAttachStreamEvent) => boolean, + predicate: (event: TerminalAttachStreamEvent, output: string) => boolean, ) { + let output = ""; const result = await requestRpc( client[WS_METHODS.terminalObserve]({ threadId, terminalId: "term-1" }).pipe( - Stream.filter(predicate), + Stream.filter((event) => { + // PTY chunks may split a line or escape sequence at any byte. + if (event.type === "snapshot") output = event.snapshot.history; + else if (event.type === "output") output += event.data; + return predicate(event, NodeUtil.stripVTControlCharacters(output).replace(/\r+\n/g, "\n")); + }), Stream.take(1), Stream.runHead, ), ).catch((error: unknown) => { - throw new Error(`Terminal milestone ${predicate.toString()}: ${String(error)}`); + throw new Error( + `Terminal milestone ${predicate.toString()}: ${String(error)}; output=${JSON.stringify(output)}`, + ); }); NodeAssert.ok(Option.isSome(result), "The terminal stream must expose the requested milestone."); return result.value; } function terminalText(event: TerminalAttachStreamEvent) { - return event.type === "snapshot" - ? event.snapshot.history - : event.type === "output" - ? event.data - : ""; + const output = + event.type === "snapshot" ? event.snapshot.history : event.type === "output" ? event.data : ""; + return NodeUtil.stripVTControlCharacters(output).replace(/\r+\n/g, "\n"); } /** Required behavior is asserted in every run; missing dependencies fail explicitly. */ @@ -264,6 +278,14 @@ export async function runWebClientRegression( page.on("console", (message) => consoleMessages.push(redact(`${message.type()}: ${message.text()}`)), ); + page.on("websocket", (socket) => { + socket.on("framesent", ({ payload }) => { + const frame = String(payload); + if (frame.includes("terminal.write")) consoleMessages.push("rpc sent: terminal.write"); + else if (frame.includes("server.updateSettings")) + consoleMessages.push("rpc sent: server.updateSettings"); + }); + }); try { const primary = await createEnvironmentFixture(input, { prepare: async (paths) => { @@ -382,7 +404,9 @@ export async function runWebClientRegression( await page.waitForFunction(`() => { const input = document.querySelector('textarea[aria-label="Terminal input"]'); const canvas = input?.parentElement?.querySelector('canvas'); - if (!canvas || !canvas.width || !canvas.height) return false; + if (!canvas || !canvas.width || !canvas.height + || canvas.width !== Math.round(canvas.clientWidth * devicePixelRatio) + || canvas.height !== Math.round(canvas.clientHeight * devicePixelRatio)) return false; const pixels = canvas.getContext("2d")?.getImageData( 0, 0, Math.min(canvas.width, 300), Math.min(canvas.height, 100) ).data; @@ -397,16 +421,26 @@ export async function runWebClientRegression( }`); await page.screenshot({ path: NodePath.join(artifacts, "terminal-ready.png") }); await withRegressionRpc(primary, async (client) => { + await inputField.locator("..").locator("canvas").click(); NodeAssert.equal(await inputField.getAttribute("readonly"), null); - await inputField.pressSequentially("printf 'terminal-io-fixture\\n'"); + await inputField.fill("printf 'terminal-io-fixture\\n'"); + await terminalMilestone(client, seeded.threadId, (_event, output) => + output.includes("printf 'terminal-io-fixture\\n'"), + ); await inputField.press("Enter"); - NodeAssert.equal( - await inputField.inputValue(), - "", - "The rendered terminal must consume keyboard input.", + await terminalMilestone(client, seeded.threadId, (_event, output) => + /\nterminal-io-fixture\n/.test(output), + ); + await inputField.fill("trap '/bin/stty size' WINCH; printf 'resize-watcher-ready\\n'"); + await terminalMilestone(client, seeded.threadId, (_event, output) => + output.includes("resize-watcher-ready"), + ); + await inputField.press("Enter"); + await terminalMilestone(client, seeded.threadId, (_event, output) => + /\nresize-watcher-ready\n/.test(output), ); - await terminalMilestone(client, seeded.threadId, (event) => - /\r?\nterminal-io-fixture\r?\n/.test(terminalText(event)), + const resized = terminalMilestone(client, seeded.threadId, (_event, output) => + /27\s+91/.test(output), ); await requestRpc( client[WS_METHODS.terminalResize]({ @@ -416,12 +450,11 @@ export async function runWebClientRegression( rows: 27, }), ); - await inputField.pressSequentially("/bin/stty size"); - await inputField.press("Enter"); - await terminalMilestone(client, seeded.threadId, (event) => - /27\s+91/.test(terminalText(event)), + await resized; + await inputField.fill("printf 'terminal-error-fixture\\n' >&2; exit 7"); + await terminalMilestone(client, seeded.threadId, (_event, output) => + output.includes("terminal-error-fixture"), ); - await inputField.pressSequentially("printf 'terminal-error-fixture\\n' >&2; exit 7"); await inputField.press("Enter"); const exited = await terminalMilestone( client, @@ -463,7 +496,10 @@ export async function runWebClientRegression( }); await milestone("settings persist and controlled provider sign-in URL/error", async () => { - await page.goto(new URL("/settings/general", primary.origin).href); + await page.goto( + new URL(`/settings/general?machine=${seeded.descriptor.environmentId}`, primary.origin) + .href, + ); const updateChecks = page.getByRole("switch", { name: "Check provider versions", exact: true, @@ -474,27 +510,35 @@ export async function runWebClientRegression( await updateChecks.elementHandle(), ); await withRegressionRpc(primary, async (client) => { - await updateChecks.click(); - const saved = await requestRpc( + const subscribed = Promise.withResolvers(); + const saved = requestRpc( client[WS_METHODS.subscribeServerConfig]({}).pipe( - Stream.filter((event) => - event.type === "settingsUpdated" + Stream.filter((event) => { + if (event.type === "snapshot") subscribed.resolve(); + return event.type === "settingsUpdated" ? event.payload.settings.enableProviderUpdateChecks - : event.type === "snapshot" && event.config.settings.enableProviderUpdateChecks, - ), + : event.type === "snapshot" && event.config.settings.enableProviderUpdateChecks; + }), Stream.take(1), Stream.runHead, ), - ); + ).catch((error: unknown) => { + throw new Error(`Settings persistence milestone: ${String(error)}`); + }); + await Promise.race([subscribed.promise, saved]); + await updateChecks.click(); NodeAssert.ok( - Option.isSome(saved), + Option.isSome(await saved), "The settings stream must confirm the save before reload.", ); }); await page.reload(); NodeAssert.equal(await updateChecks.getAttribute("aria-checked"), "true"); await updateChecks.click(); - await page.goto(new URL("/settings/providers", primary.origin).href); + await page.goto( + new URL(`/settings/providers?machine=${seeded.descriptor.environmentId}`, primary.origin) + .href, + ); await page.getByRole("button", { name: "Select Fixture sign-in", exact: true }).click(); await page.getByRole("button", { name: "Sign in", exact: true }).last().click(); await visible(page.getByRole("button", { name: "Copy sign-in link", exact: true })); @@ -503,30 +547,19 @@ export async function runWebClientRegression( await page.evaluate("navigator.clipboard.readText()"), /^https:\/\/auth\.fixture\.invalid\/authorize\?/, ); - await withRegressionRpc(primary, async (client) => { - const state = await requestRpc( - client[WS_METHODS.providerAuthStart]({ instanceId: authProviderId }), - ); - NodeAssert.ok(state.flowId); - await requestRpc( - client[WS_METHODS.providerAuthCancel]({ - instanceId: authProviderId, - flowId: state.flowId, - }), - ); - }); + await page.getByRole("button", { name: "Cancel sign-in", exact: true }).last().click(); + await visible(page.getByRole("button", { name: "Retry sign-in", exact: true }).last()); await NodeFSP.writeFile(NodePath.join(dependencies.control, "auth-error"), "fail"); await page.getByRole("button", { name: "Retry sign-in", exact: true }).last().click(); - await visible(page.getByText("Controlled sign-in failure", { exact: false }).first()); + await visible( + page.getByText("The ACP agent could not complete sign-in.", { exact: true }).first(), + ); await NodeFSP.rm(NodePath.join(dependencies.control, "auth-error")); }); await milestone("Browser unavailable state", async () => { await page.goto(new URL(seeded.route, primary.origin).href); await addSurface(page, "Browser"); - const url = page.getByPlaceholder("Search or enter URL").first(); - await url.fill(websiteUrl); - await url.press("Enter"); await visible(page.getByRole("button", { name: "Try again", exact: true }).first()); await page.screenshot({ path: NodePath.join(artifacts, "browser-unavailable.png") }); }); diff --git a/apps/server/scripts/web-fixtures/fake-codex.mjs b/apps/server/scripts/web-fixtures/fake-codex.mjs index fcd5c2523ba1..45a73f6fc16c 100644 --- a/apps/server/scripts/web-fixtures/fake-codex.mjs +++ b/apps/server/scripts/web-fixtures/fake-codex.mjs @@ -63,7 +63,7 @@ rl.on("line", (line) => { if (method === "initialize") return reply({ userAgent: "t3-web-regression/1.0", - codexHome: control, + codexHome: control ?? process.env.CODEX_HOME ?? process.cwd(), platformFamily: "unix", platformOs: process.env.T3_FAKE_PLATFORM ?? "linux", }); diff --git a/scripts/lib/environment-smoke.ts b/scripts/lib/environment-smoke.ts index 028b002145ff..30e6eec8a7c6 100644 --- a/scripts/lib/environment-smoke.ts +++ b/scripts/lib/environment-smoke.ts @@ -244,7 +244,9 @@ export async function createEnvironmentFixture( const response = await fetch(new URL(route, pairingUrl), { ...options, signal: options.signal ?? AbortSignal.timeout(10_000), - headers: { ...(cookie ? { cookie } : {}), ...options.headers }, + // Fixture setup does not share the browser's pool. Close each drained + // request so Bun cannot reuse a stale Vite proxy connection across scopes. + headers: { connection: "close", ...(cookie ? { cookie } : {}), ...options.headers }, }).catch((error: unknown) => { throw new Error(`${options.method ?? "GET"} ${route}: ${String(error)}\n${output()}`); }); @@ -453,6 +455,26 @@ export async function checkEnvironment(fixture: EnvironmentFixture) { NodeAssert.ok(session.authenticated, "Pairing must authenticate subsequent requests."); const client = await fixture.request("/"); NodeAssert.ok((await client.text()).includes(" Date: Wed, 7 Oct 2026 18:20:51 +0200 Subject: [PATCH 23/32] fix(browser): upgrade stream before asynchronous startup Bun rejects a pending Node HTTP WebSocket handshake after Browser attachment yields to child-process I/O. Acquire the existing scoped socket before attaching the viewer, after authentication and parameter validation. An actual Bun HTTP fixture reproduces the delayed-upgrade failure and verifies the host setup close frame. --- .../ServerBrowserStream.runtime.test.ts | 15 +++ .../server/src/preview/ServerBrowserStream.ts | 20 ++-- .../testing/browserStreamRuntime.fixture.ts | 105 ++++++++++++++++++ 3 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 apps/server/src/preview/ServerBrowserStream.runtime.test.ts create mode 100644 apps/server/src/preview/testing/browserStreamRuntime.fixture.ts diff --git a/apps/server/src/preview/ServerBrowserStream.runtime.test.ts b/apps/server/src/preview/ServerBrowserStream.runtime.test.ts new file mode 100644 index 000000000000..00e94b3c22c2 --- /dev/null +++ b/apps/server/src/preview/ServerBrowserStream.runtime.test.ts @@ -0,0 +1,15 @@ +// @effect-diagnostics nodeBuiltinImport:off -- The HTTP upgrade runs in an actual Bun child, independently of the test framework runtime. +import * as NodeChildProcess from "node:child_process"; +import * as NodeURL from "node:url"; +import { expect, test } from "@effect/vitest"; + +test("Bun Browser stream upgrades and delivers the controlled host-setup close frame", () => { + const child = NodeChildProcess.spawnSync( + process.env.BUN_EXECUTABLE ?? "bun", + [NodeURL.fileURLToPath(new URL("testing/browserStreamRuntime.fixture.ts", import.meta.url))], + { encoding: "utf8", timeout: 10_000 }, + ); + expect(child.error).toBeUndefined(); + expect(child.status, child.stderr).toBe(0); + expect(child.stdout).toContain("valid host-setup WebSocket close under Bun"); +}); diff --git a/apps/server/src/preview/ServerBrowserStream.ts b/apps/server/src/preview/ServerBrowserStream.ts index cfb124c4f9eb..f07633ad7b82 100644 --- a/apps/server/src/preview/ServerBrowserStream.ts +++ b/apps/server/src/preview/ServerBrowserStream.ts @@ -81,6 +81,17 @@ const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) => } return yield* Effect.scoped( Effect.gen(function* () { + const incoming = NodeHttpServerRequest.toIncomingMessage(request); + // JPEGs are already compressed. Disabling deflate also keeps all + // pending writes in the socket buffer we bound below, not a zlib queue. + delete incoming.headers["sec-websocket-extensions"]; + const transport = incoming.socket; + // Bun rejects a pending Node HTTP upgrade after browser startup yields + // to child-process I/O. Complete the handshake before attaching. + const socket = yield* request.upgrade; + // The reader performs the upgrade; writes wait for it. + const reader = yield* socket.reader; + const writer = yield* socket.writer; const attached = yield* browser .attachViewer({ threadId, @@ -102,15 +113,6 @@ const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) => }, }), ); - const incoming = NodeHttpServerRequest.toIncomingMessage(request); - // JPEGs are already compressed. Disabling deflate also keeps all - // pending writes in the socket buffer we bound below, not a zlib queue. - delete incoming.headers["sec-websocket-extensions"]; - const transport = incoming.socket; - const socket = yield* request.upgrade; - // The reader performs the upgrade; writes wait for it. - const reader = yield* socket.reader; - const writer = yield* socket.writer; // A refused upgrade reads as an auth failure to ticket clients, so a // missing tab is a close code they stop on. const gone = writer.write(new Socket.CloseEvent(TAB_GONE_CODE, "tab closed")); diff --git a/apps/server/src/preview/testing/browserStreamRuntime.fixture.ts b/apps/server/src/preview/testing/browserStreamRuntime.fixture.ts new file mode 100644 index 000000000000..7a8759a595a0 --- /dev/null +++ b/apps/server/src/preview/testing/browserStreamRuntime.fixture.ts @@ -0,0 +1,105 @@ +// @effect-diagnostics nodeBuiltinImport:off -- A transient Bun HTTP fixture verifies the public Browser stream wire protocol. +import * as NodeAssert from "node:assert"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeHttp from "node:http"; +import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + AuthOrchestrationReadScope, + AuthSessionId, + PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpMiddleware, HttpRouter, HttpServer } from "effect/http"; +import * as EnvironmentAuth from "../../auth/EnvironmentAuth.ts"; +import * as PreviewBrowserHost from "../PreviewBrowserHost.ts"; +import * as ServerBrowser from "../ServerBrowser.ts"; +import { routeLayer } from "../ServerBrowserStream.ts"; + +await Effect.runPromise( + Effect.gen(function* () { + const browser = ServerBrowser.ServerBrowser.of({ + clearProfile: () => Effect.void, + openDownload: () => Effect.succeedNone, + answerFileChooser: () => Effect.succeed(false), + // A captured child exit reproduces Chromium startup's process I/O boundary. + attachViewer: () => + Effect.promise( + () => + new Promise((resolve, reject) => { + NodeChildProcess.execFile( + process.env.BUN_EXECUTABLE ?? process.execPath, + ["-e", "process.stdout.write('ready')"], + (error) => (error ? reject(error) : resolve()), + ); + }), + ).pipe( + Effect.andThen( + Effect.fail( + new ServerBrowser.ServerBrowserLaunchError({ + cause: new PreviewBrowserHost.PreviewBrowserSandboxError({ + setupCommand: "sudo t3 browser setup", + }), + }), + ), + ), + ), + }); + const auth = Layer.mock(EnvironmentAuth.EnvironmentAuth, { + authenticateWebSocketUpgrade: () => + Effect.succeed({ + sessionId: AuthSessionId.make("browser-wire-fixture"), + subject: "browser-wire-fixture", + method: "bearer-access-token", + scopes: [AuthOrchestrationReadScope], + }), + }); + const platform = NodeHttpPlatform.layer.pipe(Layer.provideMerge(NodeServices.layer)); + const services = yield* Layer.build( + HttpRouter.serve( + routeLayer.pipe( + Layer.provide(Layer.succeed(ServerBrowser.ServerBrowser, browser)), + Layer.provide(HttpRouter.middleware(HttpMiddleware.compression(), { global: true })), + Layer.provide(platform), + ), + { disableListenLog: true }, + ).pipe( + Layer.provideMerge( + NodeHttpServer.layer(() => NodeHttp.createServer(), { + host: "127.0.0.1", + port: 0, + websocket: { perMessageDeflate: true }, + }), + ), + Layer.provide(auth), + ), + ); + const server = Context.get(services, HttpServer.HttpServer); + const origin = HttpServer.formatAddress(server.address).replace(/^http/u, "ws"); + const closed = Promise.withResolvers<{ code: number; reason: string }>(); + yield* Effect.acquireRelease( + Effect.sync(() => { + const socket = new WebSocket(`${origin}/api/preview-stream/ws?threadId=thread&tabId=tab`); + socket.addEventListener("error", (event) => + closed.reject( + new Error( + `Browser stream WebSocket failed: ${"message" in event ? String(event.message) : "unknown"}`, + ), + ), + ); + socket.addEventListener("close", (event) => + closed.resolve({ code: event.code, reason: event.reason }), + ); + return socket; + }), + (socket) => Effect.sync(() => socket.close()), + ); + const result = yield* Effect.promise(() => closed.promise); + NodeAssert.strict.equal(result.code, PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE); + NodeAssert.strict.equal(result.reason, '{"need":"sandbox","command":"sudo t3 browser setup"}'); + process.stdout.write("Browser stream sends a valid host-setup WebSocket close under Bun.\n"); + }).pipe(Effect.scoped), +); From d27a8bfc67faec6eb49bf2717a65f52089ee9db8 Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Wed, 7 Oct 2026 18:44:49 +0200 Subject: [PATCH 24/32] fix(runtime): preserve update destinations and safe release errors --- apps/server/src/cloud/pinnedRuntime.test.ts | 45 ++++++++++++++++--- apps/server/src/cloud/pinnedRuntime.ts | 2 +- .../components/ServerUpdateAction.test.tsx | 2 +- apps/web/src/versionSkew.test.ts | 23 +++++++++- apps/web/src/versionSkew.ts | 12 ++++- 5 files changed, 74 insertions(+), 10 deletions(-) diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index 4bc993c8dc07..d989d4fd8465 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -5,7 +5,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Path from "effect/Path"; -import { HttpClient, HttpClientResponse } from "effect/http"; +import { HttpClient, HttpClientError, HttpClientResponse } from "effect/http"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as ProcessRunner from "../processRunner.ts"; @@ -109,16 +109,51 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { validate: () => Effect.die("missing archives must never validate"), }).pipe(Effect.flip); assert.include(error.message, "fork release artifact unavailable"); - assert.include( - error.message, - "https://github.com/iglo-tech/iglo.code/releases/download/v1.2.3/SHA256SUMS", - ); + assert.include(error.message, "https://github.com, HTTP 404"); assert.deepEqual(requests, [ "https://github.com/iglo-tech/iglo.code/releases/download/v1.2.3/SHA256SUMS", ]); }), ); + it.effect("keeps sensitive release URL values only in the underlying download failure", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-private-url-" }); + const error = yield* ensurePinnedRuntimeInstalled({ + baseDir, + version, + fs, + path, + platform: "linux", + arch: "x64", + releaseBaseUrl: + "https://release-user:release-password@releases.example:8443/private/path?signature=private-query#private-fragment", + httpClient: HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 404 }))), + ), + runner: extractingRunner(fs, path), + validate: () => Effect.die("missing archives must never validate"), + }).pipe(Effect.flip); + + assert.instanceOf(error, PinnedRuntimeInstallError); + assert.equal( + error.step, + "downloading the t3 release checksums (fork release artifact unavailable at https://releases.example:8443, HTTP 404)", + ); + assert.equal( + error.message, + "Pinned runtime install failed while downloading the t3 release checksums (fork release artifact unavailable at https://releases.example:8443, HTTP 404).", + ); + assert.instanceOf(error.cause, HttpClientError.HttpClientError); + assert.equal( + error.cause.request.url, + "https://release-user:release-password@releases.example:8443/private/path?signature=private-query#private-fragment/v1.2.3/SHA256SUMS", + ); + }), + ); + it.effect("installs the verified release archive as the runtime executable", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index ed3825db58bb..2c6edecc291d 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -173,7 +173,7 @@ const fetchReleaseAsset = Effect.fn("cloud.pinned_runtime.fetch_release_asset")( new PinnedRuntimeInstallError({ step: cause.reason._tag === "StatusCodeError" && cause.reason.response.status === 404 - ? `${step} (fork release artifact unavailable at ${url}, HTTP 404)` + ? `${step} (fork release artifact unavailable at ${new URL(url).origin}, HTTP 404)` : step, cause, }), diff --git a/apps/web/src/components/ServerUpdateAction.test.tsx b/apps/web/src/components/ServerUpdateAction.test.tsx index ef9128e2b039..760dfc90153a 100644 --- a/apps/web/src/components/ServerUpdateAction.test.tsx +++ b/apps/web/src/components/ServerUpdateAction.test.tsx @@ -165,7 +165,7 @@ describe("ServerUpdateAction", () => { it.each([ [ { kind: "npm-global", prefix: "/opt/node" }, - "curl -fsSL https://raw.githubusercontent.com/iglo-tech/iglo.code/main/scripts/install.sh | T3CODE_VERSION='0.0.45' sh", + "curl -fsSL https://raw.githubusercontent.com/iglo-tech/iglo.code/main/scripts/install.sh | T3CODE_VERSION='0.0.45' T3CODE_INSTALL_BIN_DIR='/opt/node/bin' sh", "Update command copied", "then restart t3", ], diff --git a/apps/web/src/versionSkew.test.ts b/apps/web/src/versionSkew.test.ts index d5ed93edd229..5f0b51f5c893 100644 --- a/apps/web/src/versionSkew.test.ts +++ b/apps/web/src/versionSkew.test.ts @@ -1,5 +1,9 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { EnvironmentId } from "@t3tools/contracts"; import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; +import * as Effect from "effect/Effect"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; // Pinned so the direction cases below read as fixed versions instead of @@ -32,12 +36,29 @@ describe("versionSkew", () => { "curl -fsSL https://raw.githubusercontent.com/iglo-tech/iglo.code/main/scripts/install.sh | T3CODE_VERSION='0.0.45' sh"; expect(manualServerUpdateCommand("0.0.45")).toBe(command); expect(manualServerUpdateCommand("0.0.45", { kind: "npm-global", prefix: "/opt/node" })).toBe( - command, + "curl -fsSL https://raw.githubusercontent.com/iglo-tech/iglo.code/main/scripts/install.sh | T3CODE_VERSION='0.0.45' T3CODE_INSTALL_BIN_DIR='/opt/node/bin' sh", ); expect(manualServerUpdateCommand("0.0.45", { kind: "npx" })).toBe(command); expect(manualServerUpdateCommand("0.0.45", { kind: "pnpm-dlx" })).toBe(command); expect(manualServerUpdateCommand("0.0.45", { kind: "bunx" })).toBe(command); }); + + it("passes version and the active global bin directory literally to the POSIX installer", async () => { + const targetVersion = "0.0.45'; printf injected; #"; + const prefix = "/opt/My tools'$(printf injected)/"; + const command = manualServerUpdateCommand(targetVersion, { kind: "npm-global", prefix }); + // Serve a harmless installer body from the shell function; never fetch or install a release. + const output = await Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* spawner.string( + ChildProcess.make("/bin/sh", [ + "-c", + `curl() { printf '%s\\n' 'printf "%s\\n" "$T3CODE_VERSION" "$T3CODE_INSTALL_BIN_DIR"'; }\n${command}`, + ]), + ); + }).pipe(Effect.provide(NodeServices.layer), Effect.runPromise); + expect(output).toBe("0.0.45'; printf injected; #\n/opt/My tools'$(printf injected)/bin\n"); + }); beforeEach(() => { branding.APP_VERSION = "0.0.34"; }); diff --git a/apps/web/src/versionSkew.ts b/apps/web/src/versionSkew.ts index b2b2ade39ec3..a745f8ef5235 100644 --- a/apps/web/src/versionSkew.ts +++ b/apps/web/src/versionSkew.ts @@ -122,10 +122,18 @@ export function supportsServerUpdateThreadContinuation( /** The command to hand users whose server cannot update itself. */ export function manualServerUpdateCommand( targetVersion: string, - _installation?: ServerInstallation, + installation?: ServerInstallation, ): string { const version = `'${targetVersion.replaceAll("'", "'\\''")}'`; - return `curl -fsSL https://raw.githubusercontent.com/iglo-tech/iglo.code/main/scripts/install.sh | T3CODE_VERSION=${version} sh`; + const binDirectory = + installation?.kind === "npm-global" + ? `${installation.prefix.replace(/\/+$/, "")}/bin` + : undefined; + const destination = + binDirectory === undefined + ? "" + : ` T3CODE_INSTALL_BIN_DIR='${binDirectory.replaceAll("'", "'\\''")}'`; + return `curl -fsSL https://raw.githubusercontent.com/iglo-tech/iglo.code/main/scripts/install.sh | T3CODE_VERSION=${version}${destination} sh`; } export function serverUpdateGuidance(capability: ServerSelfUpdateCapability): string { From 7c1930078f76ac165c08452f2491ed4b28b6e853 Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Wed, 7 Oct 2026 19:02:19 +0200 Subject: [PATCH 25/32] test(web): stabilize complete Bun client acceptance flows --- apps/server/scripts/web-client-regression.ts | 60 +++++++++++++------- apps/server/scripts/web-fixtures/prepare.ts | 6 ++ 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/apps/server/scripts/web-client-regression.ts b/apps/server/scripts/web-client-regression.ts index dd84947f1106..581e6514d79e 100644 --- a/apps/server/scripts/web-client-regression.ts +++ b/apps/server/scripts/web-client-regression.ts @@ -19,6 +19,8 @@ import { AuthGrantScope, ExecutionEnvironmentDescriptor, OrchestrationV2Command, + ORCHESTRATION_PROTOCOL_HEADER, + ORCHESTRATION_PROTOCOL_VERSION_TEXT, ORCHESTRATION_V2_WS_METHODS, ProviderDriverKind, ProviderInstanceId, @@ -134,6 +136,13 @@ async function configure(fixture: EnvironmentFixture, dependencies: WebDependenc fresh: true, }), ); + await requestRpc( + client[WS_METHODS.serverRefreshProviders]({ + instanceId: authProviderId, + cwd: fixture.workspace, + fresh: true, + }), + ); await requestRpc( client[ORCHESTRATION_V2_WS_METHODS.dispatchCommand]( decodeCommand({ @@ -278,14 +287,6 @@ export async function runWebClientRegression( page.on("console", (message) => consoleMessages.push(redact(`${message.type()}: ${message.text()}`)), ); - page.on("websocket", (socket) => { - socket.on("framesent", ({ payload }) => { - const frame = String(payload); - if (frame.includes("terminal.write")) consoleMessages.push("rpc sent: terminal.write"); - else if (frame.includes("server.updateSettings")) - consoleMessages.push("rpc sent: server.updateSettings"); - }); - }); try { const primary = await createEnvironmentFixture(input, { prepare: async (paths) => { @@ -421,6 +422,11 @@ export async function runWebClientRegression( }`); await page.screenshot({ path: NodePath.join(artifacts, "terminal-ready.png") }); await withRegressionRpc(primary, async (client) => { + await terminalMilestone( + client, + seeded.threadId, + (event) => event.type === "snapshot" && event.snapshot.status === "running", + ); await inputField.locator("..").locator("canvas").click(); NodeAssert.equal(await inputField.getAttribute("readonly"), null); await inputField.fill("printf 'terminal-io-fixture\\n'"); @@ -548,9 +554,19 @@ export async function runWebClientRegression( /^https:\/\/auth\.fixture\.invalid\/authorize\?/, ); await page.getByRole("button", { name: "Cancel sign-in", exact: true }).last().click(); - await visible(page.getByRole("button", { name: "Retry sign-in", exact: true }).last()); + await withRegressionRpc(primary, async (client) => { + await requestRpc( + client[WS_METHODS.serverRefreshProviders]({ + instanceId: authProviderId, + cwd: primary.workspace, + fresh: true, + }), + ); + }); + const signIn = page.getByRole("button", { name: /^(?:Retry sign-in|Sign in)$/ }).last(); + await visible(signIn); await NodeFSP.writeFile(NodePath.join(dependencies.control, "auth-error"), "fail"); - await page.getByRole("button", { name: "Retry sign-in", exact: true }).last().click(); + await signIn.click(); await visible( page.getByText("The ACP agent could not complete sign-in.", { exact: true }).first(), ); @@ -566,9 +582,14 @@ export async function runWebClientRegression( await installBrowser(dependencies, browserExecutable); await milestone("disconnect, server restart, reconnect and history deduplication", async () => { + await visible(page.getByTestId("composer-editor")); await primary.stop(); - await visible(page.getByText(/Disconnected|Reconnecting|Connecting|offline/i).first()); + await visible(page.getByText(/Disconnected|Reconnect|Connecting|offline/i).first()); + // Vite reloads after its dev server returns; wait for that navigation + // before interacting with panel state that the reload would discard. + const reloaded = input.kind === "source" ? page.waitForEvent("load") : undefined; await primary.restart(); + await reloaded; await visible(page.getByTestId("composer-editor")); NodeAssert.equal( await page.getByText("Finished from environment-a.", { exact: true }).count(), @@ -578,15 +599,10 @@ export async function runWebClientRegression( await page.getByText("Run the controlled streaming turn", { exact: true }).count(), 1, ); - await page.reload(); - await visible(page.getByTestId("composer-editor")); - NodeAssert.equal( - await page.getByText("Finished from environment-a.", { exact: true }).count(), - 1, - ); }); await milestone("Browser ready frame and navigation error", async () => { + await addSurface(page, "Browser"); const url = page.getByPlaceholder("Search or enter URL").first(); await visible(url); await url.fill(websiteUrl); @@ -645,7 +661,7 @@ export async function runWebClientRegression( const remotePairing = await pairingUrl(secondary); await page.goto(new URL("/settings/connections", primary.origin).href); await page.getByRole("button", { name: "Add environment", exact: true }).first().click(); - const dialog = page.getByRole("dialog"); + const dialog = page.getByRole("dialog", { name: "Add Environment", exact: true }); await dialog.getByLabel("Host", { exact: true }).fill(remotePairing); await dialog.getByRole("button", { name: "Add environment", exact: true }).click(); await dialog.waitFor({ state: "hidden" }); @@ -658,14 +674,18 @@ export async function runWebClientRegression( await releaseProvider(remoteDependencies); await visible(page.getByText("Finished from environment-b.", { exact: true })); const localHistory = await ( - await primary.request(`/api/orchestration/threads/${seeded.threadId}`) + await primary.request(`/api/orchestration/threads/${seeded.threadId}`, { + headers: { [ORCHESTRATION_PROTOCOL_HEADER]: ORCHESTRATION_PROTOCOL_VERSION_TEXT }, + }) ).text(); NodeAssert.doesNotMatch( localHistory, /Execute only in environment-b|Streaming from environment-b/, ); const remoteHistory = await ( - await secondary.request(`/api/orchestration/threads/${remote.threadId}`) + await secondary.request(`/api/orchestration/threads/${remote.threadId}`, { + headers: { [ORCHESTRATION_PROTOCOL_HEADER]: ORCHESTRATION_PROTOCOL_VERSION_TEXT }, + }) ).text(); NodeAssert.match(remoteHistory, /Execute only in environment-b/); const remoteContext = await browser.newContext(); diff --git a/apps/server/scripts/web-fixtures/prepare.ts b/apps/server/scripts/web-fixtures/prepare.ts index 1c063679d922..bdf39c2b723e 100644 --- a/apps/server/scripts/web-fixtures/prepare.ts +++ b/apps/server/scripts/web-fixtures/prepare.ts @@ -48,6 +48,12 @@ export async function prepareWebDependencies( const sdk = NodePath.join(paths.scratch, "android-sdk"); await NodeFSP.mkdir(NodePath.join(sdk, "platform-tools"), { recursive: true }); await NodeFSP.mkdir(NodePath.join(sdk, "emulator")); + await NodeFSP.mkdir(NodePath.join(sdk, "cmdline-tools", "latest", "bin"), { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(sdk, "cmdline-tools", "latest", "bin", "avdmanager"), + "#!/bin/sh\nexit 0\n", + { mode: 0o755 }, + ); await NodeFSP.writeFile( NodePath.join(sdk, "platform-tools", "adb"), "#!/bin/sh\nprintf 'List of devices attached\\n'\n", From c182401b7324f228c459513ac9403d4210f9bdae Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Wed, 7 Oct 2026 19:11:20 +0200 Subject: [PATCH 26/32] fix(browser): preserve Bun websocket shim in CLI bundles Inlining npm ws bypassed Bun HTTP upgrade ownership, allowing an HTTP 200 response onto the WebSocket channel after the close frame. Keep ws as a Bun built-in without staging another package. Verify the complete raw close handshake through source and production pack/compile paths, with Node and npm absent from the compiled process PATH. --- .../ServerBrowserStream.runtime.test.ts | 84 +++++++++++++++++++ .../testing/browserStreamRuntime.fixture.ts | 68 ++++++++++++--- scripts/lib/cli-external-packages.ts | 15 ++-- 3 files changed, 149 insertions(+), 18 deletions(-) diff --git a/apps/server/src/preview/ServerBrowserStream.runtime.test.ts b/apps/server/src/preview/ServerBrowserStream.runtime.test.ts index 00e94b3c22c2..d02aa0885c89 100644 --- a/apps/server/src/preview/ServerBrowserStream.runtime.test.ts +++ b/apps/server/src/preview/ServerBrowserStream.runtime.test.ts @@ -1,7 +1,11 @@ // @effect-diagnostics nodeBuiltinImport:off -- The HTTP upgrade runs in an actual Bun child, independently of the test framework runtime. import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import * as NodeURL from "node:url"; import { expect, test } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; test("Bun Browser stream upgrades and delivers the controlled host-setup close frame", () => { const child = NodeChildProcess.spawnSync( @@ -13,3 +17,83 @@ test("Bun Browser stream upgrades and delivers the controlled host-setup close f expect(child.status, child.stderr).toBe(0); expect(child.stdout).toContain("valid host-setup WebSocket close under Bun"); }); + +test("CLI-bundled Bun Browser stream completes the close handshake without HTTP bytes", () => { + const repoRoot = NodeURL.fileURLToPath(new URL("../../../../", import.meta.url)); + const fixture = NodeURL.fileURLToPath( + new URL("testing/browserStreamRuntime.fixture.ts", import.meta.url), + ); + const scratch = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-browser-wire-")); + const successful = (child: ReturnType) => { + expect(child.error).toBeUndefined(); + expect(child.status, `${child.stdout ?? ""}${child.stderr ?? ""}`).toBe(0); + return String(child.stdout ?? ""); + }; + try { + const bun = successful( + NodeChildProcess.spawnSync(process.env.BUN_EXECUTABLE ?? "bun", ["-p", "process.execPath"], { + encoding: "utf8", + timeout: 10_000, + }), + ).trim(); + NodeFS.writeFileSync( + NodePath.join(scratch, "package.json"), + JSON.stringify({ name: "browser-wire-fixture", private: true, type: "module" }), + ); + NodeFS.symlinkSync( + NodePath.join(repoRoot, "apps/server/node_modules"), + NodePath.join(scratch, "node_modules"), + "junction", + ); + NodeFS.writeFileSync( + NodePath.join(scratch, "vite.config.ts"), + ` +import { defineConfig } from ${JSON.stringify(NodePath.join(repoRoot, "node_modules/vite-plus/dist/index.js"))}; +import { isExternalCliDependency, shouldBundleCliDependency } from ${JSON.stringify(NodePath.join(repoRoot, "scripts/lib/cli-external-packages.ts"))}; +export default defineConfig({pack: { + entry: [${JSON.stringify(fixture)}], outDir: ${JSON.stringify(NodePath.join(scratch, "dist"))}, + deps: {alwaysBundle: shouldBundleCliDependency, neverBundle: isExternalCliDependency, onlyBundle: false}, +}}); +`, + ); + successful( + NodeChildProcess.spawnSync(NodePath.join(repoRoot, "node_modules/.bin/vp"), ["pack"], { + cwd: scratch, + encoding: "utf8", + timeout: 30_000, + }), + ); + const executable = NodePath.join(scratch, "browser-wire"); + successful( + NodeChildProcess.spawnSync( + bun, + [ + "build", + NodePath.join(scratch, "dist/browserStreamRuntime.fixture.mjs"), + "--compile", + "--compile-autoload-package-json", + "--outfile", + executable, + ], + { encoding: "utf8", timeout: 30_000 }, + ), + ); + if (HostProcessPlatform.defaultValue() === "darwin") + successful( + NodeChildProcess.spawnSync("/usr/bin/codesign", ["--force", "--sign", "-", executable], { + encoding: "utf8", + timeout: 10_000, + }), + ); + const output = successful( + NodeChildProcess.spawnSync(executable, [], { + encoding: "utf8", + timeout: 10_000, + env: { ...process.env, PATH: "", BUN_EXECUTABLE: bun }, + }), + ); + expect(output).toContain("valid host-setup WebSocket close under Bun"); + } finally { + NodeFS.rmSync(scratch, { recursive: true, force: true }); + } +}, 60_000); diff --git a/apps/server/src/preview/testing/browserStreamRuntime.fixture.ts b/apps/server/src/preview/testing/browserStreamRuntime.fixture.ts index 7a8759a595a0..87a3e55e14ef 100644 --- a/apps/server/src/preview/testing/browserStreamRuntime.fixture.ts +++ b/apps/server/src/preview/testing/browserStreamRuntime.fixture.ts @@ -2,6 +2,7 @@ import * as NodeAssert from "node:assert"; import * as NodeChildProcess from "node:child_process"; import * as NodeHttp from "node:http"; +import * as NodeNet from "node:net"; import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -78,24 +79,69 @@ await Effect.runPromise( ), ); const server = Context.get(services, HttpServer.HttpServer); - const origin = HttpServer.formatAddress(server.address).replace(/^http/u, "ws"); + const origin = new URL(HttpServer.formatAddress(server.address)); const closed = Promise.withResolvers<{ code: number; reason: string }>(); + let buffer = Buffer.alloc(0); + let upgraded = false; + let close: { code: number; reason: string } | undefined; yield* Effect.acquireRelease( Effect.sync(() => { - const socket = new WebSocket(`${origin}/api/preview-stream/ws?threadId=thread&tabId=tab`); - socket.addEventListener("error", (event) => - closed.reject( - new Error( - `Browser stream WebSocket failed: ${"message" in event ? String(event.message) : "unknown"}`, - ), + const socket = NodeNet.connect(Number(origin.port), origin.hostname); + socket.on("error", (error) => closed.reject(error)); + socket.on("connect", () => + socket.write( + [ + "GET /api/preview-stream/ws?threadId=thread&tabId=tab HTTP/1.1", + `Host: ${origin.host}`, + "Connection: Upgrade", + "Upgrade: websocket", + "Sec-WebSocket-Version: 13", + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", + "Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits", + `Origin: ${origin.origin}`, + "", + "", + ].join("\r\n"), ), ); - socket.addEventListener("close", (event) => - closed.resolve({ code: event.code, reason: event.reason }), - ); + socket.on("data", (chunk) => { + try { + buffer = Buffer.concat([buffer, chunk]); + if (!upgraded) { + const end = buffer.indexOf("\r\n\r\n"); + if (end === -1) return; + NodeAssert.match(buffer.subarray(0, end).toString(), /^HTTP\/1\.1 101 /u); + buffer = buffer.subarray(end + 4); + upgraded = true; + } + if (buffer.byteLength < 2) return; + NodeAssert.equal(buffer[0], 0x88, "The server must send a valid WebSocket close frame"); + const length = buffer[1]!; + NodeAssert.ok(length >= 2 && length <= 125, "Close payload must be short and unmasked"); + if (buffer.byteLength < length + 2) return; + NodeAssert.equal(close, undefined, "The server must send only one close frame"); + const payload = buffer.subarray(2, length + 2); + close = { code: payload.readUInt16BE(0), reason: payload.subarray(2).toString() }; + buffer = buffer.subarray(length + 2); + NodeAssert.equal( + buffer.byteLength, + 0, + "The server must not send HTTP bytes after upgrading", + ); + // Echo a masked close acknowledgement, then wait for the server's TCP close. + socket.write(Buffer.concat([Buffer.from([0x88, 0x80 | length, 0, 0, 0, 0]), payload])); + } catch (error) { + closed.reject(error); + socket.destroy(); + } + }); + socket.on("close", () => { + if (close && buffer.byteLength === 0) closed.resolve(close); + else closed.reject(new Error("The server did not complete a valid WebSocket close")); + }); return socket; }), - (socket) => Effect.sync(() => socket.close()), + (socket) => Effect.sync(() => socket.destroy()), ); const result = yield* Effect.promise(() => closed.promise); NodeAssert.strict.equal(result.code, PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE); diff --git a/scripts/lib/cli-external-packages.ts b/scripts/lib/cli-external-packages.ts index 92844cf3e50c..caa58f04c877 100644 --- a/scripts/lib/cli-external-packages.ts +++ b/scripts/lib/cli-external-packages.ts @@ -1,14 +1,13 @@ /** - * The single source of truth for packages the server CLI bundle must NOT inline. + * The shared dependency boundary for CLI bundling and archive staging. * - * Two consumers derive from this list, and they must never disagree: + * The bundler and archive staging share these predicates: * * - apps/server/vite.config.ts decides what stays external to the bundle. - * - scripts/build-cli-archive.ts selects runtime dependency roots for the CLI archive. + * - scripts/build-cli-archive.ts selects file-backed runtime dependency roots for the archive. * - * A runtime package that is external but absent from the archive fails as soon - * as Bun resolves it from the emitted bundle. Keeping both consumers on one - * list prevents packaging from drifting away from the bundle boundary. + * File-backed externals must be staged beside the executable. Bun's built-in + * compatibility modules stay external without requiring a staged package. * * Entries are matched as prefixes (`id.startsWith(prefix)`), so they also cover * a package's platform-specific siblings — `node-gyp-build` covers @@ -80,7 +79,9 @@ export function isRuntimeExternalCliDependency(id: string): boolean { * dependency stayed external. */ export function isExternalCliDependency(id: string): boolean { - return isRuntimeExternalCliDependency(id); + // Bun's ws shim marks Node HTTP requests as upgraded. Inlining npm's ws + // bypasses it and lets Bun append an HTTP response to the WebSocket frames. + return id === "ws" || isRuntimeExternalCliDependency(id); } /** True when the CLI bundle should inline `id` rather than leave it external. */ From e9159aa14ab53755485906a13d4928bc06dbc371 Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Wed, 7 Oct 2026 19:18:57 +0200 Subject: [PATCH 27/32] fix(test): annotate acceptance runtime boundaries --- apps/server/src/serviceLauncher.runtime.test.ts | 1 + apps/server/src/testUtils/serviceRuntime.child.ts | 3 +++ scripts/lib/environment-smoke.ts | 3 ++- scripts/smoke-cli-archive.ts | 1 + 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/server/src/serviceLauncher.runtime.test.ts b/apps/server/src/serviceLauncher.runtime.test.ts index 104d776e5036..bcd1e3fb2177 100644 --- a/apps/server/src/serviceLauncher.runtime.test.ts +++ b/apps/server/src/serviceLauncher.runtime.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off - Acceptance setup builds real executables and release archives served by a local HTTP fixture. import * as NodeChildProcess from "node:child_process"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; diff --git a/apps/server/src/testUtils/serviceRuntime.child.ts b/apps/server/src/testUtils/serviceRuntime.child.ts index c01581995bb7..fa84d68392e2 100644 --- a/apps/server/src/testUtils/serviceRuntime.child.ts +++ b/apps/server/src/testUtils/serviceRuntime.child.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off - Standalone Bun child probes the launcher's real filesystem and IPC boundary outside Effect. import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import * as NodeSqlite from "node:sqlite"; @@ -25,6 +26,7 @@ if (!["lifecycle", "commit", "rollback"].includes(scenario)) async function emit(event: string) { const state = await readServiceState(statePath); const row = db.prepare("SELECT value FROM history").get(); + // @effect-diagnostics-next-line globalConsole:off - The parent parses these raw stdout records as service lifecycle milestones. console.log( `service-runtime:${JSON.stringify({ event, @@ -51,6 +53,7 @@ process.once("SIGTERM", async () => { process.exit(0); }); // The fixture represents a long-running server, stopped only by the launcher. +// @effect-diagnostics-next-line globalTimers:off - Native keepalive holds this standalone child open until launcher shutdown. setInterval(() => undefined, 60_000); process.on("message", async (value: unknown) => { const message = decodeServiceLauncherParentMessage(value); diff --git a/scripts/lib/environment-smoke.ts b/scripts/lib/environment-smoke.ts index 30e6eec8a7c6..ed04515a60c0 100644 --- a/scripts/lib/environment-smoke.ts +++ b/scripts/lib/environment-smoke.ts @@ -1,4 +1,5 @@ -// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics nodeBuiltinImport:off - External acceptance fixture owns disposable OS processes and filesystem state. +// @effect-diagnostics globalFetch:off globalTimers:off globalConsole:off - Plain async HTTP/WebSocket checks use native deadlines and raw stderr diagnostics outside Effect. import * as NodeAssert from "node:assert/strict"; import * as NodeChildProcess from "node:child_process"; import * as NodeEvents from "node:events"; diff --git a/scripts/smoke-cli-archive.ts b/scripts/smoke-cli-archive.ts index d3c853e17357..c92585fa71d4 100644 --- a/scripts/smoke-cli-archive.ts +++ b/scripts/smoke-cli-archive.ts @@ -32,6 +32,7 @@ const command = Command.make( ); try { await checkEnvironment(fixture); + // @effect-diagnostics-next-line globalConsoleInEffect:off - Preserve the raw acceptance status line on stdout without Effect logger metadata. console.log( `[environment-smoke] ${input.source ? "source" : "archive"}: authenticated HTTP, cookies, CORS, WebSocket upgrade and persistence across restart passed.`, ); From 457a03bbee12678eb1e35ec915200ff4b4255163 Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Wed, 7 Oct 2026 19:24:02 +0200 Subject: [PATCH 28/32] test(web): isolate Browser fixtures across Bun restart --- apps/server/scripts/web-client-regression.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/server/scripts/web-client-regression.ts b/apps/server/scripts/web-client-regression.ts index 581e6514d79e..f254f1b6dea8 100644 --- a/apps/server/scripts/web-client-regression.ts +++ b/apps/server/scripts/web-client-regression.ts @@ -578,18 +578,25 @@ export async function runWebClientRegression( await addSurface(page, "Browser"); await visible(page.getByRole("button", { name: "Try again", exact: true }).first()); await page.screenshot({ path: NodePath.join(artifacts, "browser-unavailable.png") }); + // End this fixture surface before restarting with Chromium available. + const closeBrowser = page.getByRole("button", { name: "Close Browser", exact: true }); + await closeBrowser.click(); + await closeBrowser.waitFor({ state: "hidden" }); }); await installBrowser(dependencies, browserExecutable); await milestone("disconnect, server restart, reconnect and history deduplication", async () => { await visible(page.getByTestId("composer-editor")); await primary.stop(); - await visible(page.getByText(/Disconnected|Reconnect|Connecting|offline/i).first()); + const unavailable = page.getByText(/\bis (?:offline|reconnecting)$/i).first(); + await visible(unavailable); // Vite reloads after its dev server returns; wait for that navigation // before interacting with panel state that the reload would discard. const reloaded = input.kind === "source" ? page.waitForEvent("load") : undefined; await primary.restart(); await reloaded; + // The composer and cached history stay visible while disconnected. + await unavailable.waitFor({ state: "hidden" }); await visible(page.getByTestId("composer-editor")); NodeAssert.equal( await page.getByText("Finished from environment-a.", { exact: true }).count(), From 859be4c24b2c5eedf1de667445e8a8f4ae449538 Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Thu, 8 Oct 2026 11:47:58 +0200 Subject: [PATCH 29/32] fix(test): decode raw SQLite rows before asserting values --- packages/shared/src/testing/sqliteRuntime.fixture.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/testing/sqliteRuntime.fixture.ts b/packages/shared/src/testing/sqliteRuntime.fixture.ts index 41c6a6380312..0b1f37f7d3cd 100644 --- a/packages/shared/src/testing/sqliteRuntime.fixture.ts +++ b/packages/shared/src/testing/sqliteRuntime.fixture.ts @@ -2,6 +2,7 @@ import * as NodeAssert from "node:assert/strict"; import * as NodeSqlite from "node:sqlite"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; import * as SqliteClient from "../nodeSqliteClient.ts"; @@ -22,7 +23,10 @@ const write = Effect.gen(function* () { (yield* sql<{ disabled: number }>`SELECT ${false} AS disabled`.unprepared)[0]?.disabled, 0, ); - NodeAssert.equal((yield* sql<{ enabled: number }>`SELECT ${true} AS enabled`.raw)[0]?.enabled, 1); + const rawFlags = yield* Schema.decodeUnknownEffect( + Schema.Array(Schema.Struct({ enabled: Schema.Number })), + )(yield* sql`SELECT ${true} AS enabled`.raw); + NodeAssert.equal(rawFlags[0]?.enabled, 1); NodeAssert.deepEqual(yield* sql`SELECT ${true}, ${false}`.values, [[1, 0]]); NodeAssert.deepEqual(yield* sql`SELECT ${false}`.valuesUnprepared, [[0]]); yield* sql`CREATE TABLE flags(enabled INTEGER)`; From 82f8b88012cc80f8da272f0de0e7a1921c989069 Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Thu, 8 Oct 2026 12:34:18 +0200 Subject: [PATCH 30/32] fix(server): preserve stall diagnostics under Bun --- .../observability/EventLoopMonitor.test.ts | 22 +++---- .../src/observability/EventLoopMonitor.ts | 57 ++++++++++--------- 2 files changed, 42 insertions(+), 37 deletions(-) diff --git a/apps/server/src/observability/EventLoopMonitor.test.ts b/apps/server/src/observability/EventLoopMonitor.test.ts index 1b9fd72049b9..ef06e80ed18d 100644 --- a/apps/server/src/observability/EventLoopMonitor.test.ts +++ b/apps/server/src/observability/EventLoopMonitor.test.ts @@ -8,11 +8,9 @@ import * as EventLoopMonitor from "./EventLoopMonitor.ts"; const ms = (value: number) => value * 1e6; -// Node reports a stall of S as a gap of up to S + 1 s, the histogram resolution. const stalled: EventLoopMonitor.EventLoopReadings = { - delayMaxNs: ms(5_950), - activeMs: 6_200, - utilization: 0.176, + delayMaxNs: ms(4_950), + suspendedMs: 0, usage: { userCPUTime: 310_400, systemCPUTime: 95_600, @@ -22,8 +20,7 @@ const stalled: EventLoopMonitor.EventLoopReadings = { }, rssBytes: 1536 * 1024 * 1024, }; -// Over the threshold as read, but not once the resolution is subtracted. -const quiet: EventLoopMonitor.EventLoopReadings = { ...stalled, delayMaxNs: ms(2_950) }; +const quiet: EventLoopMonitor.EventLoopReadings = { ...stalled, delayMaxNs: ms(1_950) }; describe("EventLoopMonitor", () => { it.effect("records a warning span only for samples that saw a stall", () => @@ -55,7 +52,6 @@ describe("EventLoopMonitor", () => { const [span] = spans; assert.deepStrictEqual(Object.fromEntries(span!.attributes), { delayMaxMs: 4_950, - utilization: 0.18, cpuUserMs: 310, cpuSystemMs: 96, majorPageFaults: 8_412, @@ -70,14 +66,18 @@ describe("EventLoopMonitor", () => { }), ); - it("ignores delay the loop spent idle, such as a system sleep", () => { - // Waking from sleep reads as a long gap, but the loop was idle in poll for it. + it("reports Bun timer lateness without subtracting its resolution twice", () => { + assert.strictEqual(EventLoopMonitor.stallMs({ ...stalled, delayMaxNs: ms(2_400) }), 2_400); + assert.isUndefined(EventLoopMonitor.stallMs({ ...stalled, delayMaxNs: ms(2_000) })); + }); + + it("filters host sleep while retaining delay beyond the sleep gap", () => { const asleep: EventLoopMonitor.EventLoopReadings = { ...stalled, delayMaxNs: ms(600_000), - activeMs: 900, + suspendedMs: 600_000, }; assert.isUndefined(EventLoopMonitor.stallMs(asleep)); - assert.strictEqual(EventLoopMonitor.stallMs({ ...asleep, activeMs: 600_000 }), 599_000); + assert.strictEqual(EventLoopMonitor.stallMs({ ...asleep, delayMaxNs: ms(603_500) }), 3_500); }); }); diff --git a/apps/server/src/observability/EventLoopMonitor.ts b/apps/server/src/observability/EventLoopMonitor.ts index 13b7b48c4cf7..b3b985dc25b9 100644 --- a/apps/server/src/observability/EventLoopMonitor.ts +++ b/apps/server/src/observability/EventLoopMonitor.ts @@ -1,24 +1,23 @@ // @effect-diagnostics nodeBuiltinImport:off - only node:perf_hooks exposes the event loop delay histogram. import * as NodePerfHooks from "node:perf_hooks"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import type * as Scope from "effect/Scope"; -// Node's delay histogram wakes a native timer every RESOLUTION_MS and records the -// gap between wakeups, so an idle loop reads about RESOLUTION_MS and a stall of S -// reads between S and S + RESOLUTION_MS. We subtract the resolution, so a delay can -// undercount a stall by up to RESOLUTION_MS. With these values every stall over 3 s -// is caught, at 1 wakeup per second that never enters JS. +// Bun's native histogram records timer lateness, already excluding the resolution. +// It can undercount a stall by up to RESOLUTION_MS. Every stall over 3 s is caught +// with these values, at one native wakeup per second that never enters JS. const RESOLUTION_MS = 1000; const STALL_THRESHOLD_MS = 2000; const SAMPLE_INTERVAL = "30 seconds"; -/** One sample interval as Node reports it. Delay in ns, active time in ms, CPU in µs. */ +/** One sample interval: timer lateness in ns, host suspension in ms, CPU in µs. */ export interface EventLoopReadings { readonly delayMaxNs: number; - readonly activeMs: number; - readonly utilization: number; + readonly suspendedMs: number; readonly usage: Pick< NodeJS.ResourceUsage, | "userCPUTime" @@ -31,9 +30,11 @@ export interface EventLoopReadings { } // Enables the delay histogram for the layer's lifetime. Each read returns the -// readings since the previous read and resets the histogram. Node skips the first -// gap after a reset, so a stall right at a sample boundary can be missed. -const makeNodeSampler = Effect.gen(function* () { +// readings since the previous read and resets the histogram. Bun does not implement +// eventLoopUtilization, so its zero counters cannot distinguish stalls from sleep. +const makeBunSampler = Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const clock = yield* Clock.Clock; const histogram = yield* Effect.acquireRelease( Effect.sync(() => { const histogram = NodePerfHooks.monitorEventLoopDelay({ resolution: RESOLUTION_MS }); @@ -42,18 +43,25 @@ const makeNodeSampler = Effect.gen(function* () { }), (histogram) => Effect.sync(() => histogram.disable()), ); - let elu = NodePerfHooks.performance.eventLoopUtilization(); + let awakeTime = NodePerfHooks.performance.now(); + let wallTime = clock.currentTimeMillisUnsafe(); let usage = process.resourceUsage(); // @effect-diagnostics-next-line returnEffectInGen:off - the read effect is the result. return Effect.sync(() => { - const nextElu = NodePerfHooks.performance.eventLoopUtilization(); + const nextAwakeTime = NodePerfHooks.performance.now(); + const nextWallTime = clock.currentTimeMillisUnsafe(); const nextUsage = process.resourceUsage(); - const loop = NodePerfHooks.performance.eventLoopUtilization(nextElu, elu); + // On macOS Bun's histogram clock includes sleep, but performance.now uses + // Rust's CLOCK_UPTIME_RAW. Their elapsed-time difference excludes sleep (and + // forward wall-clock adjustments) from warnings. Linux's histogram clock + // already excludes host suspension. const readings: EventLoopReadings = { delayMaxNs: histogram.max, - activeMs: loop.active, - utilization: loop.utilization, + suspendedMs: + platform === "darwin" + ? Math.max(0, nextWallTime - wallTime - (nextAwakeTime - awakeTime)) + : 0, usage: { userCPUTime: nextUsage.userCPUTime - usage.userCPUTime, systemCPUTime: nextUsage.systemCPUTime - usage.systemCPUTime, @@ -65,7 +73,8 @@ const makeNodeSampler = Effect.gen(function* () { rssBytes: process.memoryUsage.rss(), }; histogram.reset(); - elu = nextElu; + awakeTime = nextAwakeTime; + wallTime = nextWallTime; usage = nextUsage; return readings; }); @@ -74,12 +83,9 @@ const makeNodeSampler = Effect.gen(function* () { /** * Returns the stall to report for one sample in ms, or undefined when there was none. */ -export const stallMs = ({ delayMaxNs, activeMs }: EventLoopReadings) => { - const delayMs = Math.round(delayMaxNs / 1e6) - RESOLUTION_MS; - // A stall is time the loop spent running code, so it counts as active time. libuv's - // clock keeps running while the system sleeps on macOS and Windows, so a sleep also - // reads as delay, but the loop spent it idle in poll. - if (delayMs <= STALL_THRESHOLD_MS || activeMs < delayMs) return undefined; +export const stallMs = ({ delayMaxNs, suspendedMs }: EventLoopReadings) => { + const delayMs = Math.round(delayMaxNs / 1e6 - suspendedMs); + if (delayMs <= STALL_THRESHOLD_MS) return undefined; return delayMs; }; @@ -99,7 +105,7 @@ export const layerWith = ( const readings = yield* sample; const delayMaxMs = stallMs(readings); if (delayMaxMs === undefined) return; - const { utilization, usage, rssBytes } = readings; + const { usage, rssBytes } = readings; // Root, as the stall has no caller to attach to. Warn level keeps it when // T3CODE_TRACE_MIN_LEVEL is raised to cut trace noise. yield* Effect.logWarning(`event loop stalled for ${delayMaxMs} ms`).pipe( @@ -108,7 +114,6 @@ export const layerWith = ( level: "Warn", attributes: { delayMaxMs, - utilization: Math.round(utilization * 100) / 100, cpuUserMs: Math.round(usage.userCPUTime / 1000), cpuSystemMs: Math.round(usage.systemCPUTime / 1000), majorPageFaults: usage.majorPageFault, @@ -132,4 +137,4 @@ export const layerWith = ( }), ); -export const layer = layerWith(makeNodeSampler); +export const layer = layerWith(makeBunSampler); From 2fdfc2e00fb0ee89437beb3be89c6b4d86b65583 Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Thu, 8 Oct 2026 12:50:28 +0200 Subject: [PATCH 31/32] docs(skills): preserve intentional fork differences during sync --- .agents/skills/sync-upstream/SKILL.md | 119 ++++++++++++++++++++++---- 1 file changed, 100 insertions(+), 19 deletions(-) diff --git a/.agents/skills/sync-upstream/SKILL.md b/.agents/skills/sync-upstream/SKILL.md index 466533a37351..266cc009616c 100644 --- a/.agents/skills/sync-upstream/SKILL.md +++ b/.agents/skills/sync-upstream/SKILL.md @@ -1,6 +1,6 @@ --- name: sync-upstream -description: Import T3 Code upstream changes into iglo.code while preserving fork customizations and its web-only scope. Use for upstream merges, snapshot imports, or removing restored desktop and mobile apps. +description: Import T3 Code upstream changes into iglo.code while preserving its web-only scope, Bun runtime, fork distribution, workflows, and lightweight CI. Use for upstream merges, snapshot imports, or removing restored desktop and mobile apps. --- # Sync upstream into iglo.code @@ -11,11 +11,84 @@ contracts, client runtime, server compatibility, CLI distribution, remote access and the Device panel. Testing a user's mobile project and using a mobile browser are separate features from shipping T3 Code's native clients. +## Intentional fork differences + +Treat these as preservation criteria for every import, including changes that +merge without conflicts. They describe behavior to retain; reconcile upstream +improvements at the affected boundary instead of freezing an old implementation. +Change a fork decision only when the maintainer requests it. Keep this list +current when an authorized change replaces a decision. + +- **Identity and repository tooling.** Keep the iglo.code name, branding, README, + repo-local skills, and fork-specific AGENTS.md guidance. Upstream attribution + and compatible internal `t3` / `@t3tools` names remain valid. +- **Bun application runtime.** The server and first-party JavaScript helpers run + on Bun. Preserve the minimum-version checks and align `.bun-version`, + [bunRuntime.ts](../../../packages/shared/src/bunRuntime.ts), the server engine, + dev/container setup, CI setup, and packaging when intentionally bumping Bun. + Read the pin from the checkout; an upstream Node pin or older Bun pin must not + downgrade it. Vite+/pnpm and their Node contributor toolchain remain separate + from the installed application's runtime. +- **Standalone distribution.** Keep Bun executable archives and the packaged + `runtime/bun` interpreter for helpers. Installed execution works without system + Node, npm, or Bun. Preserve shared source/compiled self-invocation and service + launch, update, checksum rejection, and rollback. The supported archive targets + are macOS arm64, Linux x64, and Linux arm64; expanding them is a separate request. + Keep native assets, disk-backed SDK/browser dependencies, web assets, resource + monitoring, and signing. Packaging remains available for manual use through + [CLI staging](../../../scripts/lib/cli-stage.ts) and + [manual release procedures](../../../docs/operations/release.md). +- **Bun compatibility boundaries.** Retain working Node-compatible APIs and + Effect services. Keep Bun PTY selection, SQLite boolean normalization, and + supported stall diagnostics rather than restoring Node-only assumptions. + Executable bundling preserves one shared Effect context, leaves `ws` to Bun's + compatibility module, and stages file-backed dependencies using the same + [external-package boundary](../../../scripts/lib/cli-external-packages.ts). + [Event-loop monitoring](../../../apps/server/src/observability/EventLoopMonitor.ts) + uses Bun histogram lateness and accounts for macOS sleep; stub utilization + counters cannot gate warnings. Preserve focused runtime regression fixtures. +- **Fork installation and updates.** Default release downloads and lookups stay + on `iglo-tech/iglo.code`; npm launchers/packages stay under + `@iglo-tech/iglo-code`. Keep explicit mirror overrides and channel matching. + Server updates, service updates, install scripts, onboarding, and manual update + commands must agree; none silently falls back to upstream. Sources are + [release naming](../../../packages/shared/src/cliRelease.ts), + [npm packaging](../../../scripts/build-npm-platform-packages.ts), and + [the shared update action](../../../apps/web/src/components/ServerUpdateAction.tsx). +- **Device and remote features.** Retain the Device panel, local/SSH device hosts, + `expo-device-hub`, `agent-device`, provider helpers, Browser streams, and + local/remote/relay/tunnel connections. Device tool installation and execution + use the supported Bun interpreter, including remote version checks; removing + native T3 clients must not remove users' device-project support. +- **Plugin and workflow extensions.** Preserve the fork's compiled plugin host, + Reports fixture, and [workflow plugin](../../../packages/plugin-workflows/src/server.ts), + including provider/model/skill choices, parallel reviews, bounded rework, + human decisions, and durable recovery. Reconcile their contracts, migrations, + permission scopes, MCP tools, scheduler integration, and client contributions + together. Preserve private plugin state and independently built client/server + compatibility. The boundary and recovery constraints live in + [plugins.md](../../../docs/internals/plugins.md). +- **Provider skills and automation delivery.** Preserve fresh workspace-scoped + skill discovery and explicit invocation availability in the composer; see + [providerSkills.ts](../../../packages/client-runtime/src/providerSkills.ts). + Recurring thread automations keep one outstanding automatic check through + usage limits or paused queues, while explicit Run now requests remain distinct. + Retain original queued prompts and truthful queued/dispatched delivery status; + see [recurring automations](../../../docs/user/project-settings.md#recurring-automations). +- **Lightweight CI.** `.github/workflows/ci.yml` is the only workflow, with one + standard Ubuntu x64 job, branch-based cancellation, and the existing small + smoke-test set. Keep its checks and use the repository Bun pin. Remove other + imported workflows, including releases, deployments, previews, bots, Windows + lanes, and runtime matrices. Retained packaging and runtime suites run manually; + importing upstream is not a request to restore removed CI checks. + ## Import upstream changes For a removal-only request, skip importing and go straight to pruning. Read the working tree status and the fork's README and AGENTS.md before importing. +Inspect the implementation of each fork difference touched by the incoming diff +and record its pre-import behavior in temporary notes outside the worktree. Preserve local work and fork customizations. Fetch the configured `upstream` remote and use the requested revision, or its default branch when none was specified. Prefer an ordinary merge; desktop and mobile modify/delete conflicts are resolved @@ -25,7 +98,7 @@ reapplying the fork's changes, rather than choosing one entire side. Git history is optional for this fork. When a snapshot import makes an update simpler, stage upstream in a temporary directory and compare it with the fork before copying. Preserve fork additions and customizations, including this skill, -the CLI packaging helpers and workflow, README, and the fork scope in AGENTS.md. +the intentional differences above, README, and the fork scope in AGENTS.md. Account for upstream deletions as well as additions. Keep `.git`, dependencies, generated output, credentials, and T3 state outside the copy. A snapshot is an alternative import method, not permission to overwrite unrelated local work. @@ -47,10 +120,11 @@ python3 -B .agents/skills/sync-upstream/scripts/remove_native_apps.py The helper needs only Python's standard library and can run before dependency installation. It removes both app trees, their native tools, workflows, patches, -dependency configuration, and known workspace hooks. It also converts upstream's -combined desktop/CLI release jobs to CLI-only builds. It is safe to rerun. It -drops obsolete native app lockfile importers so pnpm can read the remaining -catalog references; the package manager regenerates the graph. +dependency configuration, and known workspace hooks. It rewrites known combined +desktop/CLI packaging integrations; apply the CI policy above to any remaining +workflows. It is safe to rerun. It drops obsolete native app lockfile importers +so pnpm can read the remaining catalog references; the package manager regenerates +the graph. Audit newly imported integrations; the helper covers the known upstream layout. Search for active hooks and contradictory guidance: @@ -67,15 +141,10 @@ entries, broken links, and instructions to implement or test the removed apps. Adapt the helper when upstream adds a native-only integration. Keep the web-only scope prominent in AGENTS.md and review user and developer guidance. -Desktop packaging can contain helpers or jobs also used by the server release. -Move shared helpers into CLI tooling before deleting their desktop module; retain -the standalone archives, npm packages, signing, resource monitor, and web build. -The fork keeps packaging helpers in `scripts/lib/cli-stage.ts`. Reconcile upstream -improvements there when packaging changes; the pruning helper only rewrites known imports. -Preserve the fork's lightweight CI: `.github/workflows/ci.yml` is the only workflow, -with one standard Ubuntu x64 job, branch-based cancellation, and a small smoke-test set. -Remove other imported workflows, including releases, deployments, previews, bots, -and Windows lanes. Packaging helpers remain available for manual use. +Desktop packaging can contain helpers also used by the server release. Move those +into CLI tooling before deleting their desktop module. Reconcile upstream +improvements with the standalone distribution above; the pruning helper only +rewrites known imports and does not preserve all fork customizations automatically. Retain shared implementation code even if it includes desktop/mobile adapters or comments. Preserve remote access, mobile browsers, and users' device projects. @@ -97,12 +166,24 @@ Run the helper again and confirm it reports no changes. Run its focused tests: python3 -B .agents/skills/sync-upstream/scripts/test_remove_native_apps.py ``` +Review the final diff against the intentional fork differences, including files +that merged automatically. Confirm only the existing CI workflow/job/check scope +remains. For each affected difference, compare its behavior with the pre-import +notes and run the smallest relevant proof. Changes to runtime launch, helper +execution, or packaging need source and actual standalone archive smoke checks; +a source-only pass cannot prove packaged helper execution or bundled dependency +boundaries. Preserve the focused tests and fixtures that exercise those paths. + Check affected tooling with targeted lint, package typechecks, and tests. Exercise -release-smoke and CLI staging when packaging changes. Verify that release job -dependencies, reusable-workflow inputs, and artifact paths still agree. Follow -AGENTS.md for verification scope and browser consent. +release-smoke and CLI staging when packaging changes, and keep artifact names and +paths aligned across manual build, installation, and update tools. Bun version or +performance-sensitive changes also need fresh startup, idle CPU, and memory +measurements under comparable source/built forms; distinguish RSS from platform +footprint and avoid treating one sample or forced GC as normal memory usage. +Follow AGENTS.md for verification scope and browser consent. Finish when both native apps and their active hooks are absent, the lockfile matches the remaining workspace, and fork customizations survive. Report the -imported upstream revision (when applicable), checks, and unresolved conflicts. +imported upstream revision (when applicable), checks, affected fork differences +preserved, and unresolved conflicts. Commit, push, and PR creation follow the user's request and repository instructions. From 590bd5f21ecda415d052c1cd8689262c5d1581d0 Mon Sep 17 00:00:00 2001 From: Bartek Igielski Date: Thu, 8 Oct 2026 14:06:38 +0200 Subject: [PATCH 32/32] docs(runtime): retain native Bun migration boundaries --- .agents/skills/sync-upstream/SKILL.md | 3 ++ docs/internals/bun-runtime.md | 73 +++++++++++++++++++++++++++ docs/internals/effect-services.md | 3 ++ docs/operations/observability.md | 4 +- 4 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 docs/internals/bun-runtime.md diff --git a/.agents/skills/sync-upstream/SKILL.md b/.agents/skills/sync-upstream/SKILL.md index 266cc009616c..08ca61d93638 100644 --- a/.agents/skills/sync-upstream/SKILL.md +++ b/.agents/skills/sync-upstream/SKILL.md @@ -47,6 +47,9 @@ current when an authorized change replaces a decision. [Event-loop monitoring](../../../apps/server/src/observability/EventLoopMonitor.ts) uses Bun histogram lateness and accounts for macOS sleep; stub utilization counters cannot gate warnings. Preserve focused runtime regression fixtures. + Read [Bun runtime boundaries](../../../docs/internals/bun-runtime.md) when an + import changes platform adapters; it records native API candidates and the + constraints behind the retained compatibility APIs. - **Fork installation and updates.** Default release downloads and lookups stay on `iglo-tech/iglo.code`; npm launchers/packages stay under `@iglo-tech/iglo-code`. Keep explicit mirror overrides and channel matching. diff --git a/docs/internals/bun-runtime.md b/docs/internals/bun-runtime.md new file mode 100644 index 000000000000..7b04a4166ec5 --- /dev/null +++ b/docs/internals/bun-runtime.md @@ -0,0 +1,73 @@ +# Bun runtime boundaries + +The fork runs its application server and helpers on Bun, while retaining Effect +services and working Node-compatible APIs. The migration changed the runtime and +packaging first, replacing APIs where compatibility failed. Native Bun APIs remain +possible improvements at the adapter boundaries below; their performance benefit +has not been established for this application. + +Keep domain services, typed errors, scoped resource cleanup, and the event-sourced +orchestrator independent of the platform implementation. An adapter change must +preserve those behaviors rather than spread direct Bun calls through features. +Vite+/pnpm and their Node contributor toolchain are a separate concern. + +## Remaining native API candidates + +| Boundary | Native option | Why the current boundary remains / what a replacement must preserve | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| HTTP and WebSocket serving in [server.ts](../../apps/server/src/server.ts) | [`Bun.serve`](https://bun.sh/docs/runtime/http/server), preferably through Effect's Bun HTTP adapter | The existing Effect Node HTTP server composes routing, authentication, RPC, OAuth, MCP, streaming, and graceful shutdown. [Browser streams](../../apps/server/src/preview/ServerBrowserStream.ts) also depend on Node request/socket access for compression control and bounded buffering. A native adapter needs equivalent upgrades, backpressure, disconnect cleanup, and local/remote/relay behavior; changing the server layer alone is insufficient. | +| SQLite behind [nodeSqliteClient.ts](../../packages/shared/src/nodeSqliteClient.ts) | [`bun:sqlite`](https://bun.sh/docs/runtime/sqlite), with an Effect SQL adapter | The current `node:sqlite` implementation preserves the shared `SqlClient` contract. Replacement must retain transaction/savepoint behavior, writer locking, WAL/foreign-key settings, typed SQLite errors, safe integers, and boolean/result-mode normalization. Core events, projections, receipts, and outbox effects must still commit atomically; plugin databases retain their separate transactions. | +| Subprocess spawning behind `ChildProcessSpawner`, [shell.ts](../../packages/shared/src/shell.ts), and [the service launcher](../../apps/server/src/serviceLauncher.ts) | [`Bun.spawn` / `Bun.spawnSync`](https://bun.sh/docs/runtime/child-process) behind the existing services | Provider protocols, Git, helpers, and service lifecycle rely on streaming stdin/stdout/stderr, output bounds, cancellation, process exit and signal semantics, environment/PATH resolution, and IPC. The compiled CLI's self-invocation differs from source scripts. Preserve update handoff and rollback as well as ordinary spawning. PTYs already have their own native Bun adapter. | +| File I/O and HTTP file responses in [http.ts](../../apps/server/src/http.ts) | [`Bun.file` / `Bun.write`](https://bun.sh/docs/runtime/file-io), scoped behind Effect services | Select measured file-serving or read/write paths rather than replacing all filesystem calls. [Media files](../../apps/server/src/assets/MediaFile.ts) and static responses hold validated descriptors; reopening a path loses that guarantee. Preserve non-blocking/no-follow opens, file identity, range/HEAD handling, cache/compression semantics, and closure on cancellation. | + +Effect's read-only reference under `.repos/effect-smol` contains `BunHttpServer` and a +`sql-sqlite-bun` adapter; inspect them before inventing equivalents. Check their +implementation and compatibility against the dependency version being adopted. +In the Effect 4.0.1 reference, `packages/platform/bun/src/BunFileSystem.ts` uses +the shared Node filesystem implementation, and its sibling +`BunChildProcessSpawner.ts` re-exports the shared Node spawner. Switching to +`BunServices` alone therefore does not migrate those operations to `Bun.file` or +`Bun.spawn`. + +## Diagnostics and APIs already backed by Bun + +`node:` imports execute inside Bun; their presence does not mean a Node process +is running. Keep working compatibility APIs unless a native alternative fixes a +demonstrated incompatibility or improves an observed cost. + +- [BunPtyAdapter](../../apps/server/src/terminal/BunPtyAdapter.ts) already uses + Bun's terminal/subprocess API because the inherited PTY implementation failed + under Bun. The source and compiled CLI use that adapter. +- Executable bundles deliberately leave `ws` external to use Bun's compatibility + module. Inlining npm's implementation bypasses Bun's HTTP-upgrade bookkeeping + and can corrupt Browser WebSocket frames. Keep the shared + [bundling/staging boundary](../../scripts/lib/cli-external-packages.ts). +- [HeapSnapshot](../../apps/server/src/observability/HeapSnapshot.ts) still calls + `node:v8.writeHeapSnapshot`, but [Bun 1.4.2 implements it](https://github.com/oven-sh/bun/blob/bun-v1.4.2/src/js/node/v8.ts#L349-L367) + through `Bun.generateHeapSnapshot("v8")`. The file contains JavaScriptCore heap + data in V8-compatible format. Renaming this call would not introduce a new + snapshot engine. Native [`bun:jsc` heap statistics](https://bun.sh/docs/project/benchmarking#javascript-heap-stats) + are a possible addition for diagnostic detail, distinct from OS memory usage. +- [EventLoopMonitor](../../apps/server/src/observability/EventLoopMonitor.ts) + already accounts for Bun histogram lateness and macOS sleep. Bun 1.4.2's + utilization counters are stubs, so they cannot gate stall warnings. + V8-shaped memory counters likewise should not be interpreted as V8 heap layout. + +Host/process resource telemetry observes provider and terminal children as well as +the server. Bun's own heap statistics cannot replace that cross-process view. +RSS, JavaScript heap size, and macOS physical footprint measure different things; +forced collection is a diagnostic operation, not the normal memory baseline. + +## Evidence for a future adapter change + +Use a concrete incompatibility or measured workload to choose an adapter. Compare +startup, idle CPU, memory, and the affected operation using comparable source and +packaged forms with fresh isolated state. The [Bun 1.4.2 measurements](https://gist.githubusercontent.com/Igloczek/1631efc0127b0ad05384482b494e14c6/raw/20b00dce23121c6ee958bf17a18033540313d6e4/bun-1.4.2-performance.md) +record the current migration's tradeoffs and methodology; they do not benchmark +the native replacements above or establish which adapter caused the RSS/CPU gap. + +Keep focused behavioral tests for the changed boundary and verify the actual +standalone archive, including helpers without system Node/npm/Bun. HTTP changes +also need authenticated transport/Browser-stream and real-client coverage. +Follow AGENTS.md for test scope, disposable state, and browser consent. These +candidates retain migration knowledge; implementing one requires a separate task. diff --git a/docs/internals/effect-services.md b/docs/internals/effect-services.md index f5a5f4f75b31..0296d9dc2805 100644 --- a/docs/internals/effect-services.md +++ b/docs/internals/effect-services.md @@ -97,6 +97,9 @@ boundaries: React, native callbacks, the CLI, HTTP adapters. Never in a domain s persistence code, or service constructor. A named adapter may bridge a service into a Promise API, but no Effect service depends on it. +For platform adapter changes in this fork, read [Bun runtime boundaries](./bun-runtime.md). +It records retained compatibility APIs, native Bun candidates, and the behavior each must preserve. + Compose a shared resource once in an application-owned layer and provide its context to integration runtimes. Don't create a managed or Atom runtime per feature to hand it out. When acquisition can fail and callers need a fallback, keep the failure typed: an error on the operation or an explicit diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 59564c067e1a..9a3463161608 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -629,8 +629,8 @@ Current high-value span and metric boundaries include: ## Heap Snapshots -To see what a long-running server holds in memory, send it `SIGUSR2`. The server writes a V8 heap -snapshot to its logs dir and logs the path. This works for `t3` and service installs +To see what a long-running server holds in memory, send it `SIGUSR2`. The server writes a V8-format +heap snapshot of Bun's JavaScriptCore heap to its logs dir and logs the path. This works for `t3` and service installs on macOS and Linux. Windows has no `SIGUSR2`. Send the signal to the server pid in `server-runtime.json`, which sits in the server's state dir