| author | |
| committer | |
| log | bf55408a5cddbb0f3f7f50d538398e315b5407d0 |
| tree | 23f53d74394ba054ee66d5b2ab0399d8167a15f9 |
| parent | 6ee03240bccd9287a8c2246e9547697aa5857998 |
| signature | Signed by PGP key B5690EEEBB952194 |
19 files changed, 540 insertions(+), 38 deletions(-)
apps/server/src/device/AgentDeviceShim.ts+3-2| ... | @@ -1,11 +1,12 @@ | ... | @@ -1,11 +1,12 @@ |
| 1 | // @effect-diagnostics preferSchemaOverJson:off - JSON string literals embed paths safely into generated JavaScript. | 1 | // @effect-diagnostics preferSchemaOverJson:off - JSON string literals embed paths safely into generated JavaScript. |
| 2 | /** | 2 | /** |
| 3 | * A directory holding an `agent-device` launcher that runs the pinned install | 3 | * A directory holding an `agent-device` launcher that runs the pinned install |
| 4 | * with the server's Node. Prepended to provider subprocess PATHs so the agent | 4 | * with a Node runtime. Prepended to provider subprocess PATHs so the agent |
| 5 | * types `agent-device …` and gets the version the injected instructions were | 5 | * types `agent-device …` and gets the version the injected instructions were |
| 6 | * written for, regardless of what is or is not globally installed. | 6 | * written for, regardless of what is or is not globally installed. |
| 7 | */ | 7 | */ |
| 8 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 8 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 9 | import { resolveNodeExecutable } from "@t3tools/shared/nodeRuntime"; | ||
| 9 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 10 | import * as FileSystem from "effect/FileSystem"; | 11 | import * as FileSystem from "effect/FileSystem"; |
| 11 | import * as Path from "effect/Path"; | 12 | import * as Path from "effect/Path"; |
| ... | @@ -20,9 +21,9 @@ export const ensureAgentDeviceShim = Effect.fn("AgentDeviceShim.ensure")(functio | ... | @@ -20,9 +21,9 @@ export const ensureAgentDeviceShim = Effect.fn("AgentDeviceShim.ensure")(functio |
| 20 | const fs = yield* FileSystem.FileSystem; | 21 | const fs = yield* FileSystem.FileSystem; |
| 21 | const path = yield* Path.Path; | 22 | const path = yield* Path.Path; |
| 22 | const platform = yield* HostProcessPlatform; | 23 | const platform = yield* HostProcessPlatform; |
| 24 | const node = yield* resolveNodeExecutable("Device automation"); | ||
| 23 | const shimDir = path.join(input.stateDir, SHIM_DIR); | 25 | const shimDir = path.join(input.stateDir, SHIM_DIR); |
| 24 | yield* fs.makeDirectory(shimDir, { recursive: true }); | 26 | yield* fs.makeDirectory(shimDir, { recursive: true }); |
| 25 | const node = process.execPath; | ||
| 26 | const launcherPath = path.join(shimDir, "agent-device-launcher.mjs"); | 27 | const launcherPath = path.join(shimDir, "agent-device-launcher.mjs"); |
| 27 | yield* fs.writeFileString( | 28 | yield* fs.writeFileString( |
| 28 | launcherPath, | 29 | launcherPath, |
apps/server/src/device/AgentDeviceTarget.test.ts+11-2| ... | @@ -1,5 +1,9 @@ | ... | @@ -1,5 +1,9 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - exercises concurrent real CLI subprocesses. | 1 | // @effect-diagnostics nodeBuiltinImport:off - exercises concurrent real CLI subprocesses. |
| 2 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 2 | import { |
| 3 | HostProcessExecutablePath, | ||
| 4 | HostProcessIsExecutable, | ||
| 5 | HostProcessPlatform, | ||
| 6 | } from "@t3tools/shared/hostProcess"; | ||
| 3 | import { describe, expect, it } from "@effect/vitest"; | 7 | import { describe, expect, it } from "@effect/vitest"; |
| 4 | import * as NodeChildProcess from "node:child_process"; | 8 | import * as NodeChildProcess from "node:child_process"; |
| 5 | import * as NodeUtil from "node:util"; | 9 | import * as NodeUtil from "node:util"; |
| ... | @@ -84,6 +88,11 @@ if (process.env.AGENT_DEVICE_DAEMON_BASE_URL) process.exit(2);`, | ... | @@ -84,6 +88,11 @@ if (process.env.AGENT_DEVICE_DAEMON_BASE_URL) process.exit(2);`, |
| 84 | ).rejects.toThrow("Call device_open first"), | 88 | ).rejects.toThrow("Call device_open first"), |
| 85 | ); | 89 | ); |
| 86 | } | 90 | } |
| 87 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | 91 | }).pipe( |
| 92 | Effect.scoped, | ||
| 93 | Effect.provideService(HostProcessIsExecutable, true), | ||
| 94 | Effect.provideService(HostProcessExecutablePath, "/packaged/t3"), | ||
| 95 | Effect.provide(NodeServices.layer), | ||
| 96 | ), | ||
| 88 | ); | 97 | ); |
| 89 | }); | 98 | }); |
apps/server/src/device/DeviceHost.ts+6-2| ... | @@ -18,6 +18,7 @@ import type { | ... | @@ -18,6 +18,7 @@ import type { |
| 18 | import * as Context from "effect/Context"; | 18 | import * as Context from "effect/Context"; |
| 19 | import * as Effect from "effect/Effect"; | 19 | import * as Effect from "effect/Effect"; |
| 20 | import * as Schema from "effect/Schema"; | 20 | import * as Schema from "effect/Schema"; |
| 21 | import type { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime"; | ||
| 21 | 22 | ||
| 22 | export class DeviceHostError extends Schema.TaggedError<DeviceHostError>()("DeviceHostError", { | 23 | export class DeviceHostError extends Schema.TaggedError<DeviceHostError>()("DeviceHostError", { |
| 23 | hostId: Schema.String, | 24 | hostId: Schema.String, |
| ... | @@ -88,11 +89,14 @@ export class DeviceHost extends Context.Service< | ... | @@ -88,11 +89,14 @@ export class DeviceHost extends Context.Service< |
| 88 | */ | 89 | */ |
| 89 | readonly ensureReady: ( | 90 | readonly ensureReady: ( |
| 90 | onPhase: (phase: "installing" | "starting") => Effect.Effect<void>, | 91 | onPhase: (phase: "installing" | "starting") => Effect.Effect<void>, |
| 91 | ) => Effect.Effect<DeviceHostReady, DeviceHostError>; | 92 | ) => Effect.Effect<DeviceHostReady, DeviceHostError | NodeRuntimeUnavailableError>; |
| 92 | /** Installs and starts agent-device after the user grants agent access. */ | 93 | /** Installs and starts agent-device after the user grants agent access. */ |
| 93 | readonly ensureAgentReady: ( | 94 | readonly ensureAgentReady: ( |
| 94 | onPhase: (phase: "installing" | "starting") => Effect.Effect<void>, | 95 | onPhase: (phase: "installing" | "starting") => Effect.Effect<void>, |
| 95 | ) => Effect.Effect<DeviceHostAgentReady, DeviceHostError | DeviceHostTimeoutError>; | 96 | ) => Effect.Effect< |
| 97 | DeviceHostAgentReady, | ||
| 98 | DeviceHostError | DeviceHostTimeoutError | NodeRuntimeUnavailableError | ||
| 99 | >; | ||
| 96 | /** Current endpoints when already running, without starting anything. */ | 100 | /** Current endpoints when already running, without starting anything. */ |
| 97 | readonly current: Effect.Effect<DeviceHostReady | null>; | 101 | readonly current: Effect.Effect<DeviceHostReady | null>; |
| 98 | /** Stops only agent-device. Manual viewing through the hub stays available. */ | 102 | /** Stops only agent-device. Manual viewing through the hub stays available. */ |
apps/server/src/device/DeviceService.test.ts+41| ... | @@ -15,6 +15,7 @@ import * as Stream from "effect/Stream"; | ... | @@ -15,6 +15,7 @@ import * as Stream from "effect/Stream"; |
| 15 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | 15 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 16 | import { ServerSettingsService } from "../serverSettings.ts"; | 16 | import { ServerSettingsService } from "../serverSettings.ts"; |
| 17 | import * as DeviceHost from "./DeviceHost.ts"; | 17 | import * as DeviceHost from "./DeviceHost.ts"; |
| 18 | import { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime"; | ||
| 18 | 19 | ||
| 19 | import { type DeviceService, makeWithHosts, stateStream } from "./DeviceService.ts"; | 20 | import { type DeviceService, makeWithHosts, stateStream } from "./DeviceService.ts"; |
| 20 | 21 | ||
| ... | @@ -61,6 +62,7 @@ const fixture = Effect.fn("fixture")(function* ( | ... | @@ -61,6 +62,7 @@ const fixture = Effect.fn("fixture")(function* ( |
| 61 | onBoot: Effect.Effect<void> = Effect.void, | 62 | onBoot: Effect.Effect<void> = Effect.void, |
| 62 | bootError?: string, | 63 | bootError?: string, |
| 63 | failListAfterShutdown = false, | 64 | failListAfterShutdown = false, |
| 65 | runtimeFailure?: NodeRuntimeUnavailableError, | ||
| 64 | ) { | 66 | ) { |
| 65 | const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS); | 67 | const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS); |
| 66 | const starts: string[] = []; | 68 | const starts: string[] = []; |
| ... | @@ -88,12 +90,14 @@ const fixture = Effect.fn("fixture")(function* ( | ... | @@ -88,12 +90,14 @@ const fixture = Effect.fn("fixture")(function* ( |
| 88 | platformAvailability: (platform) => Effect.succeed({ platform, available: true }), | 90 | platformAvailability: (platform) => Effect.succeed({ platform, available: true }), |
| 89 | ensureReady: (onPhase) => | 91 | ensureReady: (onPhase) => |
| 90 | Effect.gen(function* () { | 92 | Effect.gen(function* () { |
| 93 | if (runtimeFailure) return yield* runtimeFailure; | ||
| 91 | starts.push("start"); | 94 | starts.push("start"); |
| 92 | yield* onPhase("starting"); | 95 | yield* onPhase("starting"); |
| 93 | return ready; | 96 | return ready; |
| 94 | }), | 97 | }), |
| 95 | ensureAgentReady: (onPhase) => | 98 | ensureAgentReady: (onPhase) => |
| 96 | Effect.gen(function* () { | 99 | Effect.gen(function* () { |
| 100 | if (runtimeFailure) return yield* runtimeFailure; | ||
| 97 | agentStarts.push("start"); | 101 | agentStarts.push("start"); |
| 98 | yield* onPhase("starting"); | 102 | yield* onPhase("starting"); |
| 99 | return { | 103 | return { |
| ... | @@ -188,6 +192,43 @@ const fixture = Effect.fn("fixture")(function* ( | ... | @@ -188,6 +192,43 @@ const fixture = Effect.fn("fixture")(function* ( |
| 188 | }); | 192 | }); |
| 189 | 193 | ||
| 190 | describe("device setup consent", () => { | 194 | describe("device setup consent", () => { |
| 195 | it.effect( | ||
| 196 | "preserves missing-runtime guidance and causes through manual and agent readiness", | ||
| 197 | () => | ||
| 198 | Effect.gen(function* () { | ||
| 199 | const underlying = new Error("private lookup diagnostics"); | ||
| 200 | const runtimeFailure = new NodeRuntimeUnavailableError({ | ||
| 201 | feature: "Local device support", | ||
| 202 | cause: underlying, | ||
| 203 | }); | ||
| 204 | const { service, settings, requests } = yield* fixture( | ||
| 205 | Effect.void, | ||
| 206 | undefined, | ||
| 207 | false, | ||
| 208 | runtimeFailure, | ||
| 209 | ); | ||
| 210 | yield* Ref.update(settings, (current) => ({ | ||
| 211 | ...current, | ||
| 212 | enableDeviceSupport: true, | ||
| 213 | enableAgentDeviceAccess: true, | ||
| 214 | })); | ||
| 215 | for (const readiness of [service.readiness(), service.agentReadinessIfSupported()]) { | ||
| 216 | const error = yield* readiness.pipe(Effect.flip); | ||
| 217 | expect(error).toMatchObject({ | ||
| 218 | _tag: "DeviceHostUnavailableError", | ||
| 219 | reason: expect.stringContaining("Install Node.js"), | ||
| 220 | cause: runtimeFailure, | ||
| 221 | }); | ||
| 222 | expect(error.message).not.toContain(underlying.message); | ||
| 223 | } | ||
| 224 | expect((yield* service.state).hostStatuses[LOCAL_DEVICE_HOST_ID]).toMatchObject({ | ||
| 225 | status: "failed", | ||
| 226 | detail: expect.stringContaining("Install Node.js"), | ||
| 227 | }); | ||
| 228 | expect(requests).toEqual([]); | ||
| 229 | }).pipe(Effect.scoped), | ||
| 230 | ); | ||
| 231 | |||
| 191 | it.effect("listing and provider startup do not start helpers before consent", () => | 232 | it.effect("listing and provider startup do not start helpers before consent", () => |
| 192 | Effect.gen(function* () { | 233 | Effect.gen(function* () { |
| 193 | const { service, starts, requests } = yield* fixture(); | 234 | const { service, starts, requests } = yield* fixture(); |
apps/server/src/device/DeviceService.ts+35-10| ... | @@ -36,6 +36,7 @@ import { | ... | @@ -36,6 +36,7 @@ import { |
| 36 | type ThreadId, | 36 | type ThreadId, |
| 37 | } from "@t3tools/contracts"; | 37 | } from "@t3tools/contracts"; |
| 38 | import * as FileSystem from "effect/FileSystem"; | 38 | import * as FileSystem from "effect/FileSystem"; |
| 39 | import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; | ||
| 39 | import * as Path from "effect/Path"; | 40 | import * as Path from "effect/Path"; |
| 40 | import { ensureAgentDevice } from "./DeviceToolchain.ts"; | 41 | import { ensureAgentDevice } from "./DeviceToolchain.ts"; |
| 41 | import * as ServerConfig from "../config.ts"; | 42 | import * as ServerConfig from "../config.ts"; |
| ... | @@ -262,7 +263,15 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* | ... | @@ -262,7 +263,15 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* |
| 262 | setHostStatus(host.id, { status: "failed", detail: error.message }), | 263 | setHostStatus(host.id, { status: "failed", detail: error.message }), |
| 263 | ), | 264 | ), |
| 264 | Effect.mapError( | 265 | Effect.mapError( |
| 265 | (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }), | 266 | (error) => |
| 267 | new DeviceHostUnavailableError({ | ||
| 268 | hostId: host.id, | ||
| 269 | reason: | ||
| 270 | error._tag === "NodeRuntimeUnavailableError" | ||
| 271 | ? nodeRuntimeUnavailableMessage("Local device support") | ||
| 272 | : `Device host ${error.hostId} failed while ${error.step}.`, | ||
| 273 | cause: error, | ||
| 274 | }), | ||
| 266 | ), | 275 | ), |
| 267 | ); | 276 | ); |
| 268 | if (hosts.get(host.id) !== host) | 277 | if (hosts.get(host.id) !== host) |
| ... | @@ -305,7 +314,17 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* | ... | @@ -305,7 +314,17 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* |
| 305 | setHostStatus(host.id, { status: "failed", detail: error.message }), | 314 | setHostStatus(host.id, { status: "failed", detail: error.message }), |
| 306 | ), | 315 | ), |
| 307 | Effect.mapError( | 316 | Effect.mapError( |
| 308 | (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }), | 317 | (error) => |
| 318 | new DeviceHostUnavailableError({ | ||
| 319 | hostId: host.id, | ||
| 320 | reason: | ||
| 321 | error._tag === "NodeRuntimeUnavailableError" | ||
| 322 | ? nodeRuntimeUnavailableMessage("Local device support") | ||
| 323 | : error._tag === "DeviceHostTimeoutError" | ||
| 324 | ? `Device host ${error.hostId} did not start agent tools within ${error.timeoutMs} ms.` | ||
| 325 | : `Device host ${error.hostId} failed while ${error.step}.`, | ||
| 326 | cause: error, | ||
| 327 | }), | ||
| 309 | ), | 328 | ), |
| 310 | ); | 329 | ); |
| 311 | const hostSummaries = yield* Effect.forEach(hosts.values(), (candidate) => candidate.summary); | 330 | const hostSummaries = yield* Effect.forEach(hosts.values(), (candidate) => candidate.summary); |
| ... | @@ -1003,18 +1022,24 @@ export const make = Effect.gen(function* () { | ... | @@ -1003,18 +1022,24 @@ export const make = Effect.gen(function* () { |
| 1003 | ); | 1022 | ); |
| 1004 | return { | 1023 | return { |
| 1005 | ...service, | 1024 | ...service, |
| 1006 | agentCli: ensureAgentDevice(config.baseDir).pipe( | 1025 | agentCli: resolveNodeExecutable("Device automation").pipe( |
| 1026 | Effect.flatMap(() => ensureAgentDevice(config.baseDir)), | ||
| 1007 | Effect.provideService(FileSystem.FileSystem, fs), | 1027 | Effect.provideService(FileSystem.FileSystem, fs), |
| 1008 | Effect.provideService(Path.Path, path), | 1028 | Effect.provideService(Path.Path, path), |
| 1009 | Effect.provideService(ProcessRunner.ProcessRunner, runner), | 1029 | Effect.provideService(ProcessRunner.ProcessRunner, runner), |
| 1010 | Effect.map((tool) => tool.entryPath), | 1030 | Effect.map((tool) => tool.entryPath), |
| 1011 | Effect.mapError( | 1031 | Effect.mapError((error) => |
| 1012 | (error) => | 1032 | error._tag === "NodeRuntimeUnavailableError" |
| 1013 | new DeviceOperationError({ | 1033 | ? new DeviceHostUnavailableError({ |
| 1014 | operation: "install agent CLI", | 1034 | hostId: LOCAL_DEVICE_HOST_ID, |
| 1015 | reason: "command_failed", | 1035 | reason: nodeRuntimeUnavailableMessage("Device automation"), |
| 1016 | cause: error, | 1036 | cause: error, |
| 1017 | }), | 1037 | }) |
| 1038 | : new DeviceOperationError({ | ||
| 1039 | operation: "install agent CLI", | ||
| 1040 | reason: "command_failed", | ||
| 1041 | cause: error, | ||
| 1042 | }), | ||
| 1018 | ), | 1043 | ), |
| 1019 | ), | 1044 | ), |
| 1020 | }; | 1045 | }; |
apps/server/src/device/DeviceToolchain.ts+2-2| ... | @@ -4,7 +4,7 @@ | ... | @@ -4,7 +4,7 @@ |
| 4 | * `expo-device-hub` streams simulator and emulator screens and `agent-device` | 4 | * `expo-device-hub` streams simulator and emulator screens and `agent-device` |
| 5 | * drives them. Each is npm-installed separately after its matching consent | 5 | * drives them. Each is npm-installed separately after its matching consent |
| 6 | * step into `<baseDir>/tools/<name>/<version>` and executed from there with the | 6 | * step into `<baseDir>/tools/<name>/<version>` and executed from there with the |
| 7 | * server's own Node, never `npx`: an ephemeral | 7 | * resolved Node runtime, never `npx`: an ephemeral |
| 8 | * npx cache would make every first `device_open` after a reboot depend on the | 8 | * npx cache would make every first `device_open` after a reboot depend on the |
| 9 | * registry, and the pinned versions are part of the contract the injected | 9 | * registry, and the pinned versions are part of the contract the injected |
| 10 | * agent instructions describe. | 10 | * agent instructions describe. |
| ... | @@ -34,7 +34,7 @@ const installLock = Semaphore.makeUnsafe(1); | ... | @@ -34,7 +34,7 @@ const installLock = Semaphore.makeUnsafe(1); |
| 34 | 34 | ||
| 35 | export interface DeviceToolPaths { | 35 | export interface DeviceToolPaths { |
| 36 | readonly installDir: string; | 36 | readonly installDir: string; |
| 37 | /** Absolute path of the tool's entry script, run with the server's Node. */ | 37 | /** Absolute path of the tool's entry script, run with a resolved Node runtime. */ |
| 38 | readonly entryPath: string; | 38 | readonly entryPath: string; |
| 39 | readonly sentinelPath: string; | 39 | readonly sentinelPath: string; |
| 40 | } | 40 | } |
apps/server/src/device/LocalDeviceHost.test.ts+10-1| ... | @@ -1,6 +1,10 @@ | ... | @@ -1,6 +1,10 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import * as NodePath from "@effect/platform-node/NodePath"; | 2 | import * as NodePath from "@effect/platform-node/NodePath"; |
| 3 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 3 | import { |
| 4 | HostProcessEnvironment, | ||
| 5 | HostProcessPlatform, | ||
| 6 | HostProcessIsExecutable, | ||
| 7 | } from "@t3tools/shared/hostProcess"; | ||
| 4 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; | 9 | import * as Layer from "effect/Layer"; |
| 6 | import * as Path from "effect/Path"; | 10 | import * as Path from "effect/Path"; |
| ... | @@ -157,6 +161,11 @@ it.effect( | ... | @@ -157,6 +161,11 @@ it.effect( |
| 157 | ), | 161 | ), |
| 158 | ); | 162 | ); |
| 159 | expect(yield* host.current).toBeNull(); | 163 | expect(yield* host.current).toBeNull(); |
| 164 | const error = yield* host | ||
| 165 | .ensureReady(() => Effect.die("Must not install without Node")) | ||
| 166 | .pipe(Effect.flip, Effect.provideService(HostProcessIsExecutable, true)); | ||
| 167 | expect(error.message).toContain("Local device support requires Node.js"); | ||
| 168 | expect(error.message).toContain("Install Node.js"); | ||
| 160 | yield* host.stop; | 169 | yield* host.stop; |
| 161 | expect(yield* fs.exists(`${baseDir}/tools`)).toBe(false); | 170 | expect(yield* fs.exists(`${baseDir}/tools`)).toBe(false); |
| 162 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | 171 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
apps/server/src/device/LocalDeviceHost.ts+32-13| ... | @@ -18,6 +18,10 @@ import { | ... | @@ -18,6 +18,10 @@ import { |
| 18 | } from "@t3tools/contracts"; | 18 | } from "@t3tools/contracts"; |
| 19 | import { waitForHttpReady } from "@t3tools/shared/httpReadiness"; | 19 | import { waitForHttpReady } from "@t3tools/shared/httpReadiness"; |
| 20 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 20 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 21 | import { | ||
| 22 | resolveNodeExecutable, | ||
| 23 | type NodeRuntimeUnavailableError, | ||
| 24 | } from "@t3tools/shared/nodeRuntime"; | ||
| 21 | import * as NetService from "@t3tools/shared/Net"; | 25 | import * as NetService from "@t3tools/shared/Net"; |
| 22 | import { isCommandAvailable } from "@t3tools/shared/shell"; | 26 | import { isCommandAvailable } from "@t3tools/shared/shell"; |
| 23 | import * as Clock from "effect/Clock"; | 27 | import * as Clock from "effect/Clock"; |
| ... | @@ -76,6 +80,7 @@ const AgentDeviceDaemonFile = Schema.Struct({ | ... | @@ -76,6 +80,7 @@ const AgentDeviceDaemonFile = Schema.Struct({ |
| 76 | const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile)); | 80 | const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile)); |
| 77 | 81 | ||
| 78 | interface HubProcess { | 82 | interface HubProcess { |
| 83 | readonly nodePath: string; | ||
| 79 | readonly child: ChildProcessSpawner.ChildProcessHandle; | 84 | readonly child: ChildProcessSpawner.ChildProcessHandle; |
| 80 | readonly scope: Scope.Closeable; | 85 | readonly scope: Scope.Closeable; |
| 81 | readonly origin: string; | 86 | readonly origin: string; |
| ... | @@ -310,6 +315,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { | ... | @@ -310,6 +315,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { |
| 310 | 315 | ||
| 311 | const spawnHub = Effect.fn("LocalDeviceHost.spawnHub")(function* ( | 316 | const spawnHub = Effect.fn("LocalDeviceHost.spawnHub")(function* ( |
| 312 | hubTool: DeviceToolPaths, | 317 | hubTool: DeviceToolPaths, |
| 318 | nodePath: string, | ||
| 313 | ): Effect.fn.Return<HubProcess, DeviceHost.DeviceHostError> { | 319 | ): Effect.fn.Return<HubProcess, DeviceHost.DeviceHostError> { |
| 314 | yield* reapStaleHub; | 320 | yield* reapStaleHub; |
| 315 | yield* fs | 321 | yield* fs |
| ... | @@ -330,7 +336,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { | ... | @@ -330,7 +336,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { |
| 330 | const child = yield* spawner | 336 | const child = yield* spawner |
| 331 | .spawn( | 337 | .spawn( |
| 332 | ChildProcess.make( | 338 | ChildProcess.make( |
| 333 | process.execPath, | 339 | nodePath, |
| 334 | [ | 340 | [ |
| 335 | hubTool.entryPath, | 341 | hubTool.entryPath, |
| 336 | "--port", | 342 | "--port", |
| ... | @@ -361,7 +367,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { | ... | @@ -361,7 +367,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { |
| 361 | ), | 367 | ), |
| 362 | ); | 368 | ); |
| 363 | const startedAtMillis = yield* Clock.currentTimeMillis; | 369 | const startedAtMillis = yield* Clock.currentTimeMillis; |
| 364 | const hub: HubProcess = { child, scope, origin, startedAtMillis }; | 370 | const hub: HubProcess = { child, scope, origin, startedAtMillis, nodePath }; |
| 365 | yield* Effect.forkIn(observeHubOutput(hub), scope); | 371 | yield* Effect.forkIn(observeHubOutput(hub), scope); |
| 366 | yield* waitForHttpReady({ | 372 | yield* waitForHttpReady({ |
| 367 | baseUrl: origin, | 373 | baseUrl: origin, |
| ... | @@ -418,7 +424,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { | ... | @@ -418,7 +424,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { |
| 418 | Effect.gen(function* () { | 424 | Effect.gen(function* () { |
| 419 | const current = yield* Ref.get(runningRef); | 425 | const current = yield* Ref.get(runningRef); |
| 420 | if (current?.hub.child.pid !== hub.child.pid) return; | 426 | if (current?.hub.child.pid !== hub.child.pid) return; |
| 421 | const replacement = yield* spawnHub(hubTool); | 427 | const replacement = yield* spawnHub(hubTool, hub.nodePath); |
| 422 | yield* Ref.set(runningRef, { ...current, hub: replacement }); | 428 | yield* Ref.set(runningRef, { ...current, hub: replacement }); |
| 423 | yield* Effect.forkDetach(superviseHub(replacement, hubTool)); | 429 | yield* Effect.forkDetach(superviseHub(replacement, hubTool)); |
| 424 | }), | 430 | }), |
| ... | @@ -441,6 +447,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { | ... | @@ -441,6 +447,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { |
| 441 | */ | 447 | */ |
| 442 | const startAgentDeviceDaemon = Effect.fn("LocalDeviceHost.startAgentDeviceDaemon")(function* ( | 448 | const startAgentDeviceDaemon = Effect.fn("LocalDeviceHost.startAgentDeviceDaemon")(function* ( |
| 443 | agentTool: DeviceToolPaths, | 449 | agentTool: DeviceToolPaths, |
| 450 | nodePath: string, | ||
| 444 | ): Effect.fn.Return<DeviceHost.AgentDeviceEndpoint, DeviceHost.DeviceHostTimeoutError> { | 451 | ): Effect.fn.Return<DeviceHost.AgentDeviceEndpoint, DeviceHost.DeviceHostTimeoutError> { |
| 445 | const stateDir = agentDeviceStateDir(path, config.stateDir); | 452 | const stateDir = agentDeviceStateDir(path, config.stateDir); |
| 446 | yield* fs.makeDirectory(stateDir, { recursive: true }).pipe(Effect.ignore); | 453 | yield* fs.makeDirectory(stateDir, { recursive: true }).pipe(Effect.ignore); |
| ... | @@ -481,7 +488,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { | ... | @@ -481,7 +488,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { |
| 481 | // daemon and blocks until it answers. `devices` is the cheapest one. | 488 | // daemon and blocks until it answers. `devices` is the cheapest one. |
| 482 | yield* runner | 489 | yield* runner |
| 483 | .run({ | 490 | .run({ |
| 484 | command: process.execPath, | 491 | command: nodePath, |
| 485 | args: [agentTool.entryPath, "devices", "--json"], | 492 | args: [agentTool.entryPath, "devices", "--json"], |
| 486 | env: daemonEnvironment, | 493 | env: daemonEnvironment, |
| 487 | timeout: Duration.millis(DAEMON_READY_TIMEOUT_MS), | 494 | timeout: Duration.millis(DAEMON_READY_TIMEOUT_MS), |
| ... | @@ -502,11 +509,13 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { | ... | @@ -502,11 +509,13 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { |
| 502 | } | 509 | } |
| 503 | }); | 510 | }); |
| 504 | 511 | ||
| 505 | const stopAgentDeviceDaemon = (agentTool: DeviceToolPaths | null) => | 512 | const stopAgentDeviceDaemon = ( |
| 513 | agentTool: { readonly entryPath: string; readonly nodePath: string } | null, | ||
| 514 | ) => | ||
| 506 | agentTool | 515 | agentTool |
| 507 | ? runner | 516 | ? runner |
| 508 | .run({ | 517 | .run({ |
| 509 | command: process.execPath, | 518 | command: agentTool.nodePath, |
| 510 | args: [ | 519 | args: [ |
| 511 | agentTool.entryPath, | 520 | agentTool.entryPath, |
| 512 | "daemon", | 521 | "daemon", |
| ... | @@ -521,17 +530,22 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { | ... | @@ -521,17 +530,22 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { |
| 521 | .pipe(Effect.ignore) | 530 | .pipe(Effect.ignore) |
| 522 | : Effect.void; | 531 | : Effect.void; |
| 523 | 532 | ||
| 524 | let agentToolRef: DeviceToolPaths | null = null; | 533 | let agentToolRef: { readonly entryPath: string; readonly nodePath: string } | null = null; |
| 525 | 534 | ||
| 526 | const ensureHubReady = Effect.fn("LocalDeviceHost.ensureHubReady")(function* ( | 535 | const ensureHubReady = Effect.fn("LocalDeviceHost.ensureHubReady")(function* ( |
| 527 | onPhase: (phase: "installing" | "starting") => Effect.Effect<void>, | 536 | onPhase: (phase: "installing" | "starting") => Effect.Effect<void>, |
| 528 | ): Effect.fn.Return<RunningHost, DeviceHost.DeviceHostError> { | 537 | ): Effect.fn.Return<RunningHost, DeviceHost.DeviceHostError | NodeRuntimeUnavailableError> { |
| 529 | const running = yield* Ref.get(runningRef); | 538 | const running = yield* Ref.get(runningRef); |
| 530 | if (running) { | 539 | if (running) { |
| 531 | const alive = yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false)); | 540 | const alive = yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false)); |
| 532 | if (alive) return running; | 541 | if (alive) return running; |
| 533 | yield* Ref.set(runningRef, null); | 542 | yield* Ref.set(runningRef, null); |
| 534 | } | 543 | } |
| 544 | const nodePath = yield* resolveNodeExecutable("Local device support", hostEnvironment).pipe( | ||
| 545 | Effect.provideService(FileSystem.FileSystem, fs), | ||
| 546 | Effect.provideService(Path.Path, path), | ||
| 547 | Effect.provideService(HostProcessPlatform, hostPlatform), | ||
| 548 | ); | ||
| 535 | const installed = yield* isDeviceHubInstalled(config.baseDir).pipe( | 549 | const installed = yield* isDeviceHubInstalled(config.baseDir).pipe( |
| 536 | Effect.provideService(FileSystem.FileSystem, fs), | 550 | Effect.provideService(FileSystem.FileSystem, fs), |
| 537 | Effect.provideService(Path.Path, path), | 551 | Effect.provideService(Path.Path, path), |
| ... | @@ -551,7 +565,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { | ... | @@ -551,7 +565,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { |
| 551 | ), | 565 | ), |
| 552 | ); | 566 | ); |
| 553 | yield* onPhase("starting"); | 567 | yield* onPhase("starting"); |
| 554 | const hub = yield* spawnHub(hubTool); | 568 | const hub = yield* spawnHub(hubTool, nodePath); |
| 555 | const candidate = helperPaths(hubTool); | 569 | const candidate = helperPaths(hubTool); |
| 556 | const [axExists, cliExists] = yield* Effect.all([ | 570 | const [axExists, cliExists] = yield* Effect.all([ |
| 557 | fs.exists(candidate.serveSimAxSettings).pipe(Effect.orElseSucceed(() => false)), | 571 | fs.exists(candidate.serveSimAxSettings).pipe(Effect.orElseSucceed(() => false)), |
| ... | @@ -577,7 +591,12 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { | ... | @@ -577,7 +591,12 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { |
| 577 | const ensureAgentReady: DeviceHost.DeviceHost["Service"]["ensureAgentReady"] = (onPhase) => | 591 | const ensureAgentReady: DeviceHost.DeviceHost["Service"]["ensureAgentReady"] = (onPhase) => |
| 578 | startLock.withPermits(1)( | 592 | startLock.withPermits(1)( |
| 579 | Effect.gen(function* (): Generator< | 593 | Effect.gen(function* (): Generator< |
| 580 | Effect.Effect<unknown, DeviceHost.DeviceHostError | DeviceHost.DeviceHostTimeoutError>, | 594 | Effect.Effect< |
| 595 | unknown, | ||
| 596 | | DeviceHost.DeviceHostError | ||
| 597 | | DeviceHost.DeviceHostTimeoutError | ||
| 598 | | NodeRuntimeUnavailableError | ||
| 599 | >, | ||
| 581 | DeviceHost.DeviceHostAgentReady | 600 | DeviceHost.DeviceHostAgentReady |
| 582 | > { | 601 | > { |
| 583 | const running = yield* ensureHubReady(onPhase); | 602 | const running = yield* ensureHubReady(onPhase); |
| ... | @@ -600,9 +619,9 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { | ... | @@ -600,9 +619,9 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { |
| 600 | }), | 619 | }), |
| 601 | ), | 620 | ), |
| 602 | ); | 621 | ); |
| 603 | agentToolRef = agentTool; | 622 | agentToolRef = { entryPath: agentTool.entryPath, nodePath: running.hub.nodePath }; |
| 604 | yield* onPhase("starting"); | 623 | yield* onPhase("starting"); |
| 605 | const agentDevice = yield* startAgentDeviceDaemon(agentTool); | 624 | const agentDevice = yield* startAgentDeviceDaemon(agentTool, running.hub.nodePath); |
| 606 | const next = { ...running, agentDevice }; | 625 | const next = { ...running, agentDevice }; |
| 607 | yield* Ref.set(runningRef, next); | 626 | yield* Ref.set(runningRef, next); |
| 608 | return { ...toReady(next), agentDevice }; | 627 | return { ...toReady(next), agentDevice }; |
| ... | @@ -652,7 +671,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { | ... | @@ -652,7 +671,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { |
| 652 | 671 | ||
| 653 | const toReady = (running: RunningHost): DeviceHost.DeviceHostReady => ({ | 672 | const toReady = (running: RunningHost): DeviceHost.DeviceHostReady => ({ |
| 654 | hub: { origin: running.hub.origin } satisfies DeviceHost.DeviceHubEndpoint, | 673 | hub: { origin: running.hub.origin } satisfies DeviceHost.DeviceHubEndpoint, |
| 655 | nodePath: process.execPath, | 674 | nodePath: running.hub.nodePath, |
| 656 | run, | 675 | run, |
| 657 | helpers: running.helpers, | 676 | helpers: running.helpers, |
| 658 | }); | 677 | }); |
apps/server/src/mcp/toolkits/device/handlers.ts+7-2| ... | @@ -12,6 +12,7 @@ import * as Path from "effect/Path"; | ... | @@ -12,6 +12,7 @@ import * as Path from "effect/Path"; |
| 12 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 12 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 13 | import { ServerConfig } from "../../../config.ts"; | 13 | import { ServerConfig } from "../../../config.ts"; |
| 14 | import { ensureAgentDeviceShim } from "../../../device/AgentDeviceShim.ts"; | 14 | import { ensureAgentDeviceShim } from "../../../device/AgentDeviceShim.ts"; |
| 15 | import { nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; | ||
| 15 | 16 | ||
| 16 | import * as DeviceService from "../../../device/DeviceService.ts"; | 17 | import * as DeviceService from "../../../device/DeviceService.ts"; |
| 17 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; | 18 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| ... | @@ -182,9 +183,13 @@ const handlers = { | ... | @@ -182,9 +183,13 @@ const handlers = { |
| 182 | stateDir: config.stateDir, | 183 | stateDir: config.stateDir, |
| 183 | }).pipe( | 184 | }).pipe( |
| 184 | Effect.mapError( | 185 | Effect.mapError( |
| 185 | () => | 186 | (error) => |
| 186 | new DeviceToolUnavailableError({ | 187 | new DeviceToolUnavailableError({ |
| 187 | reason: "Could not prepare the agent-device launcher.", | 188 | reason: |
| 189 | error._tag === "NodeRuntimeUnavailableError" | ||
| 190 | ? nodeRuntimeUnavailableMessage("Device automation") | ||
| 191 | : "Could not prepare the agent-device launcher.", | ||
| 192 | cause: error, | ||
| 188 | }), | 193 | }), |
| 189 | ), | 194 | ), |
| 190 | ); | 195 | ); |
apps/server/src/provider/AntigravityInstallation.test.ts+14| ... | @@ -3,6 +3,7 @@ import { expect, it } from "@effect/vitest"; | ... | @@ -3,6 +3,7 @@ import { expect, it } from "@effect/vitest"; |
| 3 | import { | 3 | import { |
| 4 | HostProcessArchitecture, | 4 | HostProcessArchitecture, |
| 5 | HostProcessEnvironment, | 5 | HostProcessEnvironment, |
| 6 | HostProcessIsExecutable, | ||
| 6 | HostProcessPlatform, | 7 | HostProcessPlatform, |
| 7 | } from "@t3tools/shared/hostProcess"; | 8 | } from "@t3tools/shared/hostProcess"; |
| 8 | import * as Deferred from "effect/Deferred"; | 9 | import * as Deferred from "effect/Deferred"; |
| ... | @@ -242,6 +243,19 @@ const expectPreviousRelease = Effect.fn("test.expectPreviousAntigravityRelease") | ... | @@ -242,6 +243,19 @@ const expectPreviousRelease = Effect.fn("test.expectPreviousAntigravityRelease") |
| 242 | }); | 243 | }); |
| 243 | 244 | ||
| 244 | it.layer(NodeServices.layer)("Antigravity installation", (it) => { | 245 | it.layer(NodeServices.layer)("Antigravity installation", (it) => { |
| 246 | it.effect("reports missing Node before downloading the standalone provider runtime", () => | ||
| 247 | Effect.gen(function* () { | ||
| 248 | const { installation, requests, validations } = yield* makeHarness(); | ||
| 249 | yield* installation.start; | ||
| 250 | expect(yield* terminalState(installation)).toMatchObject({ | ||
| 251 | phase: "failed", | ||
| 252 | message: expect.stringContaining("Install Node.js"), | ||
| 253 | }); | ||
| 254 | expect(requests).toEqual([]); | ||
| 255 | expect(validations).toEqual([]); | ||
| 256 | }).pipe(Effect.provideService(HostProcessIsExecutable, true)), | ||
| 257 | ); | ||
| 258 | |||
| 245 | it.effect("verifies both files before activating a streamed download", () => | 259 | it.effect("verifies both files before activating a streamed download", () => |
| 246 | Effect.gen(function* () { | 260 | Effect.gen(function* () { |
| 247 | const enteredValidation = yield* Deferred.make<void>(); | 261 | const enteredValidation = yield* Deferred.make<void>(); |
apps/server/src/provider/AntigravityInstallation.ts+9| ... | @@ -6,6 +6,7 @@ import { | ... | @@ -6,6 +6,7 @@ import { |
| 6 | HostProcessEnvironment, | 6 | HostProcessEnvironment, |
| 7 | HostProcessPlatform, | 7 | HostProcessPlatform, |
| 8 | } from "@t3tools/shared/hostProcess"; | 8 | } from "@t3tools/shared/hostProcess"; |
| 9 | import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; | ||
| 9 | import * as Clock from "effect/Clock"; | 10 | import * as Clock from "effect/Clock"; |
| 10 | import * as Cause from "effect/Cause"; | 11 | import * as Cause from "effect/Cause"; |
| 11 | import * as Context from "effect/Context"; | 12 | import * as Context from "effect/Context"; |
| ... | @@ -517,6 +518,14 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma | ... | @@ -517,6 +518,14 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma |
| 517 | 518 | ||
| 518 | const install = Effect.fn("AntigravityInstallation.install")( | 519 | const install = Effect.fn("AntigravityInstallation.install")( |
| 519 | function* (asset: AntigravityReleaseAsset) { | 520 | function* (asset: AntigravityReleaseAsset) { |
| 521 | yield* resolveNodeExecutable("Antigravity", environment).pipe( | ||
| 522 | Effect.provideService(FileSystem.FileSystem, fs), | ||
| 523 | Effect.provideService(Path.Path, path), | ||
| 524 | Effect.provideService(HostProcessPlatform, platform), | ||
| 525 | Effect.mapError((cause) => | ||
| 526 | installationError("verify", nodeRuntimeUnavailableMessage("Antigravity"), cause), | ||
| 527 | ), | ||
| 528 | ); | ||
| 520 | const report = (phase: ProviderInstallState["phase"], message: string | null) => | 529 | const report = (phase: ProviderInstallState["phase"], message: string | null) => |
| 521 | SubscriptionRef.update(state, (current) => ({ ...current, phase, message })); | 530 | SubscriptionRef.update(state, (current) => ({ ...current, phase, message })); |
| 522 | yield* fs.makeDirectory(versionsDirectory, { recursive: true }); | 531 | yield* fs.makeDirectory(versionsDirectory, { recursive: true }); |
apps/server/src/provider/Drivers/AntigravityDriver.test.ts+16| ... | @@ -8,6 +8,7 @@ import { | ... | @@ -8,6 +8,7 @@ import { |
| 8 | import { | 8 | import { |
| 9 | HostProcessEnvironment, | 9 | HostProcessEnvironment, |
| 10 | HostProcessExecutablePath, | 10 | HostProcessExecutablePath, |
| 11 | HostProcessIsExecutable, | ||
| 11 | HostProcessPlatform, | 12 | HostProcessPlatform, |
| 12 | } from "@t3tools/shared/hostProcess"; | 13 | } from "@t3tools/shared/hostProcess"; |
| 13 | import * as Effect from "effect/Effect"; | 14 | import * as Effect from "effect/Effect"; |
| ... | @@ -260,6 +261,21 @@ const testLayer = ServerConfig.layerTest(process.cwd(), { | ... | @@ -260,6 +261,21 @@ const testLayer = ServerConfig.layerTest(process.cwd(), { |
| 260 | ); | 261 | ); |
| 261 | 262 | ||
| 262 | it.layer(testLayer)("AntigravityDriver", (it) => { | 263 | it.layer(testLayer)("AntigravityDriver", (it) => { |
| 264 | it.effect.skipIf(windowsHost)( | ||
| 265 | "preserves the Node install message when starting a standalone provider", | ||
| 266 | () => | ||
| 267 | Effect.gen(function* () { | ||
| 268 | const h = yield* makeHarness(); | ||
| 269 | const error = yield* h.refresh().pipe(Effect.flip); | ||
| 270 | expect(error.detail).toContain("Install Node.js"); | ||
| 271 | expect(h.launches).toEqual([]); | ||
| 272 | }).pipe( | ||
| 273 | Effect.scoped, | ||
| 274 | Effect.provideService(HostProcessIsExecutable, true), | ||
| 275 | Effect.provideService(HostProcessEnvironment, { PATH: "" }), | ||
| 276 | ), | ||
| 277 | ); | ||
| 278 | |||
| 263 | it.effect.skipIf(windowsHost)("does not launch a process for a disabled instance", () => | 279 | it.effect.skipIf(windowsHost)("does not launch a process for a disabled instance", () => |
| 264 | Effect.gen(function* () { | 280 | Effect.gen(function* () { |
| 265 | const h = yield* makeHarness(); | 281 | const h = yield* makeHarness(); |
apps/server/src/provider/Drivers/AntigravityDriver.ts+17-1| ... | @@ -1,6 +1,10 @@ | ... | @@ -1,6 +1,10 @@ |
| 1 | import { withAgentDeviceEnvironment } from "../../mcp/McpProviderSession.ts"; | 1 | import { withAgentDeviceEnvironment } from "../../mcp/McpProviderSession.ts"; |
| 2 | import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; | 2 | import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; |
| 3 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 3 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 4 | import { | ||
| 5 | NodeRuntimeUnavailableError, | ||
| 6 | nodeRuntimeUnavailableMessage, | ||
| 7 | } from "@t3tools/shared/nodeRuntime"; | ||
| 4 | import * as Crypto from "effect/Crypto"; | 8 | import * as Crypto from "effect/Crypto"; |
| 5 | import * as Deferred from "effect/Deferred"; | 9 | import * as Deferred from "effect/Deferred"; |
| 6 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| ... | @@ -59,6 +63,7 @@ import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./Antigra | ... | @@ -59,6 +63,7 @@ import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./Antigra |
| 59 | 63 | ||
| 60 | const DRIVER = ProviderDriverKind.make("antigravity"); | 64 | const DRIVER = ProviderDriverKind.make("antigravity"); |
| 61 | const decodeSettings = Schema.decodeSync(AntigravitySettings); | 65 | const decodeSettings = Schema.decodeSync(AntigravitySettings); |
| 66 | const isNodeRuntimeUnavailableError = Schema.is(NodeRuntimeUnavailableError); | ||
| 62 | 67 | ||
| 63 | export type AntigravityDriverEnv = | 68 | export type AntigravityDriverEnv = |
| 64 | | AntigravityInstallation | 69 | | AntigravityInstallation |
| ... | @@ -164,6 +169,16 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD | ... | @@ -164,6 +169,16 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD |
| 164 | Effect.provideService(FileSystem.FileSystem, fileSystem), | 169 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 165 | Effect.provideService(Path.Path, path), | 170 | Effect.provideService(Path.Path, path), |
| 166 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | 171 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 172 | Effect.mapError((cause) => | ||
| 173 | isNodeRuntimeUnavailableError(cause.cause) | ||
| 174 | ? new ProviderSetupError({ | ||
| 175 | instanceId, | ||
| 176 | operation: "start", | ||
| 177 | detail: nodeRuntimeUnavailableMessage("Antigravity sign-in"), | ||
| 178 | cause, | ||
| 179 | }) | ||
| 180 | : cause, | ||
| 181 | ), | ||
| 167 | ); | 182 | ); |
| 168 | // Each process unpacks into its own directory that dies with the | 183 | // Each process unpacks into its own directory that dies with the |
| 169 | // runtime scope, after the child is killed. A shared directory would | 184 | // runtime scope, after the child is killed. A shared directory would |
| ... | @@ -422,7 +437,8 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD | ... | @@ -422,7 +437,8 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD |
| 422 | instanceId, | 437 | instanceId, |
| 423 | detail: isAntigravitySignInRequiredError(cause) | 438 | detail: isAntigravitySignInRequiredError(cause) |
| 424 | ? "Sign in to Antigravity in provider settings before refreshing models." | 439 | ? "Sign in to Antigravity in provider settings before refreshing models." |
| 425 | : cause._tag === "ProviderSetupError" && cause.operation === "configure" | 440 | : cause._tag === "ProviderSetupError" && |
| 441 | (cause.operation === "configure" || cause.operation === "start") | ||
| 426 | ? cause.detail | 442 | ? cause.detail |
| 427 | : "Could not refresh Antigravity models. The previous model list is unchanged.", | 443 | : "Could not refresh Antigravity models. The previous model list is unchanged.", |
| 428 | cause, | 444 | cause, |
apps/server/src/provider/antigravityAuthSupport.test.ts+47-1| ... | @@ -3,7 +3,11 @@ import * as NodeChildProcess from "node:child_process"; | ... | @@ -3,7 +3,11 @@ import * as NodeChildProcess from "node:child_process"; |
| 3 | 3 | ||
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 5 | import { ProviderInstanceId } from "@t3tools/contracts"; | 5 | import { ProviderInstanceId } from "@t3tools/contracts"; |
| 6 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 6 | import { |
| 7 | HostProcessExecutablePath, | ||
| 8 | HostProcessIsExecutable, | ||
| 9 | HostProcessPlatform, | ||
| 10 | } from "@t3tools/shared/hostProcess"; | ||
| 7 | import { describe, expect, it } from "@effect/vitest"; | 11 | import { describe, expect, it } from "@effect/vitest"; |
| 8 | import * as Effect from "effect/Effect"; | 12 | import * as Effect from "effect/Effect"; |
| 9 | import * as FileSystem from "effect/FileSystem"; | 13 | import * as FileSystem from "effect/FileSystem"; |
| ... | @@ -531,6 +535,48 @@ describe("Antigravity stderr compatibility", () => { | ... | @@ -531,6 +535,48 @@ describe("Antigravity stderr compatibility", () => { |
| 531 | }); | 535 | }); |
| 532 | 536 | ||
| 533 | it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { | 537 | it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { |
| 538 | it.effect("runs the browser helper with installed Node in standalone builds", () => | ||
| 539 | Effect.gen(function* () { | ||
| 540 | const fs = yield* FileSystem.FileSystem; | ||
| 541 | const path = yield* Path.Path; | ||
| 542 | const directory = yield* fs.makeTempDirectoryScoped(); | ||
| 543 | const profile = yield* prepareAntigravityProfile({ | ||
| 544 | profileDirectory: path.join(directory, "profile"), | ||
| 545 | baseEnv: { PATH: path.dirname(process.execPath) }, | ||
| 546 | }); | ||
| 547 | expect(profile.browserCommand).not.toContain("/packaged/t3"); | ||
| 548 | expect(yield* fs.exists(profile.acpDirectory)).toBe(true); | ||
| 549 | }).pipe( | ||
| 550 | Effect.provideService(HostProcessIsExecutable, true), | ||
| 551 | Effect.provideService(HostProcessExecutablePath, "/packaged/t3"), | ||
| 552 | ), | ||
| 553 | ); | ||
| 554 | |||
| 555 | it.effect("reports missing Node before creating the standalone sign-in profile", () => | ||
| 556 | Effect.gen(function* () { | ||
| 557 | const fs = yield* FileSystem.FileSystem; | ||
| 558 | const path = yield* Path.Path; | ||
| 559 | const directory = yield* fs.makeTempDirectoryScoped(); | ||
| 560 | const profileDirectory = path.join(directory, "profile"); | ||
| 561 | const result = yield* prepareAntigravityProfile({ | ||
| 562 | profileDirectory, | ||
| 563 | baseEnv: { PATH: "" }, | ||
| 564 | }).pipe(Effect.result); | ||
| 565 | expect(Result.isFailure(result)).toBe(true); | ||
| 566 | if (Result.isFailure(result)) { | ||
| 567 | expect(result.failure).toMatchObject({ | ||
| 568 | _tag: "AcpTransportError", | ||
| 569 | detail: expect.stringContaining("Install Node.js"), | ||
| 570 | cause: { | ||
| 571 | _tag: "NodeRuntimeUnavailableError", | ||
| 572 | cause: { _tag: "CommandResolutionError" }, | ||
| 573 | }, | ||
| 574 | }); | ||
| 575 | } | ||
| 576 | expect(yield* fs.exists(profileDirectory)).toBe(false); | ||
| 577 | }).pipe(Effect.provideService(HostProcessIsExecutable, true)), | ||
| 578 | ); | ||
| 579 | |||
| 534 | it.effect("preflights the no-browser helper and creates private directories only", () => | 580 | it.effect("preflights the no-browser helper and creates private directories only", () => |
| 535 | Effect.gen(function* () { | 581 | Effect.gen(function* () { |
| 536 | const fs = yield* FileSystem.FileSystem; | 582 | const fs = yield* FileSystem.FileSystem; |
apps/server/src/provider/antigravityAuthSupport.ts+13-2| ... | @@ -5,7 +5,8 @@ import * as NodeFSP from "node:fs/promises"; | ... | @@ -5,7 +5,8 @@ import * as NodeFSP from "node:fs/promises"; |
| 5 | import * as NodePath from "node:path"; | 5 | import * as NodePath from "node:path"; |
| 6 | 6 | ||
| 7 | import type { AntigravityAuthMethod, ProviderInstanceId } from "@t3tools/contracts"; | 7 | import type { AntigravityAuthMethod, ProviderInstanceId } from "@t3tools/contracts"; |
| 8 | import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 8 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 9 | import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; | ||
| 9 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 10 | import * as FileSystem from "effect/FileSystem"; | 11 | import * as FileSystem from "effect/FileSystem"; |
| 11 | import * as Path from "effect/Path"; | 12 | import * as Path from "effect/Path"; |
| ... | @@ -307,7 +308,17 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")( | ... | @@ -307,7 +308,17 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")( |
| 307 | const platform = input.platform ?? (yield* HostProcessPlatform); | 308 | const platform = input.platform ?? (yield* HostProcessPlatform); |
| 308 | const userHome = | 309 | const userHome = |
| 309 | input.userHome ?? resolveAntigravityUserHome(platform, input.baseEnv ?? process.env); | 310 | input.userHome ?? resolveAntigravityUserHome(platform, input.baseEnv ?? process.env); |
| 310 | const runtimeExecutablePath = input.runtimeExecutablePath ?? (yield* HostProcessExecutablePath); | 311 | const runtimeExecutablePath = |
| 312 | input.runtimeExecutablePath ?? | ||
| 313 | (yield* resolveNodeExecutable("Antigravity sign-in", input.baseEnv).pipe( | ||
| 314 | Effect.mapError( | ||
| 315 | (cause) => | ||
| 316 | new AcpErrors.AcpTransportError({ | ||
| 317 | detail: nodeRuntimeUnavailableMessage("Antigravity sign-in"), | ||
| 318 | cause, | ||
| 319 | }), | ||
| 320 | ), | ||
| 321 | )); | ||
| 311 | const helperExecutable = | 322 | const helperExecutable = |
| 312 | platform === "win32" ? runtimeExecutablePath.replaceAll("\\", "/") : runtimeExecutablePath; | 323 | platform === "win32" ? runtimeExecutablePath.replaceAll("\\", "/") : runtimeExecutablePath; |
| 313 | const browserArguments = [helperExecutable, "-e", browserHelperSource, "--", "%s"]; | 324 | const browserArguments = [helperExecutable, "-e", browserHelperSource, "--", "%s"]; |
packages/contracts/src/device.ts+2| ... | @@ -340,6 +340,7 @@ export class DeviceHostUnavailableError extends Schema.TaggedError<DeviceHostUna | ... | @@ -340,6 +340,7 @@ export class DeviceHostUnavailableError extends Schema.TaggedError<DeviceHostUna |
| 340 | { | 340 | { |
| 341 | hostId: DeviceHostId, | 341 | hostId: DeviceHostId, |
| 342 | reason: Schema.String, | 342 | reason: Schema.String, |
| 343 | cause: Schema.optional(Schema.Defect()), | ||
| 343 | }, | 344 | }, |
| 344 | ) { | 345 | ) { |
| 345 | override get message(): string { | 346 | override get message(): string { |
| ... | @@ -527,6 +528,7 @@ export class DeviceToolUnavailableError extends Schema.TaggedError<DeviceToolUna | ... | @@ -527,6 +528,7 @@ export class DeviceToolUnavailableError extends Schema.TaggedError<DeviceToolUna |
| 527 | "DeviceToolUnavailableError", | 528 | "DeviceToolUnavailableError", |
| 528 | { | 529 | { |
| 529 | reason: Schema.String, | 530 | reason: Schema.String, |
| 531 | cause: Schema.optional(Schema.Defect()), | ||
| 530 | }, | 532 | }, |
| 531 | ) { | 533 | ) { |
| 532 | override get message(): string { | 534 | override get message(): string { |
packages/shared/package.json+4| ... | @@ -267,6 +267,10 @@ | ... | @@ -267,6 +267,10 @@ |
| 267 | "types": "./src/chatList.ts", | 267 | "types": "./src/chatList.ts", |
| 268 | "import": "./src/chatList.ts" | 268 | "import": "./src/chatList.ts" |
| 269 | }, | 269 | }, |
| 270 | "./nodeRuntime": { | ||
| 271 | "types": "./src/nodeRuntime.ts", | ||
| 272 | "import": "./src/nodeRuntime.ts" | ||
| 273 | }, | ||
| 270 | "./hostProcess": { | 274 | "./hostProcess": { |
| 271 | "types": "./src/hostProcess.ts", | 275 | "types": "./src/hostProcess.ts", |
| 272 | "import": "./src/hostProcess.ts" | 276 | "import": "./src/hostProcess.ts" |
packages/shared/src/nodeRuntime.test.ts created+193| ... | @@ -0,0 +1,193 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import * as Path from "effect/Path"; | ||
| 6 | import * as Result from "effect/Result"; | ||
| 7 | |||
| 8 | import { | ||
| 9 | HostProcessExecutablePath, | ||
| 10 | HostProcessIsExecutable, | ||
| 11 | HostProcessPlatform, | ||
| 12 | } from "./hostProcess.ts"; | ||
| 13 | import { resolveNodeExecutable } from "./nodeRuntime.ts"; | ||
| 14 | import { symlinksSupported } from "./testing/symlinks.ts"; | ||
| 15 | |||
| 16 | describe("Node runtime selection", () => { | ||
| 17 | it.effect("keeps the current Node or Electron runtime without requiring Node on PATH", () => | ||
| 18 | Effect.gen(function* () { | ||
| 19 | for (const executable of ["/runtime/node", "/Applications/T3 Code.app/Electron"]) { | ||
| 20 | expect( | ||
| 21 | yield* resolveNodeExecutable("Local device support", { PATH: "" }).pipe( | ||
| 22 | Effect.provideService(HostProcessExecutablePath, executable), | ||
| 23 | Effect.provideService(HostProcessIsExecutable, false), | ||
| 24 | ), | ||
| 25 | ).toBe(executable); | ||
| 26 | } | ||
| 27 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 28 | ); | ||
| 29 | |||
| 30 | it.effect("uses installed Node instead of the standalone T3 executable", () => | ||
| 31 | Effect.gen(function* () { | ||
| 32 | const path = yield* Path.Path; | ||
| 33 | expect( | ||
| 34 | yield* resolveNodeExecutable("Local device support", { | ||
| 35 | PATH: path.dirname(process.execPath), | ||
| 36 | }), | ||
| 37 | ).toBe(process.execPath); | ||
| 38 | }).pipe( | ||
| 39 | Effect.provideService(HostProcessExecutablePath, "/packaged/t3"), | ||
| 40 | Effect.provideService(HostProcessIsExecutable, true), | ||
| 41 | Effect.provide(NodeServices.layer), | ||
| 42 | ), | ||
| 43 | ); | ||
| 44 | |||
| 45 | it.effect("explains how to install Node when a standalone helper has no runtime", () => | ||
| 46 | Effect.gen(function* () { | ||
| 47 | const error = yield* resolveNodeExecutable("Local device support", { PATH: "" }).pipe( | ||
| 48 | Effect.flip, | ||
| 49 | ); | ||
| 50 | expect(error._tag).toBe("NodeRuntimeUnavailableError"); | ||
| 51 | expect(error.message).toContain("Local device support requires Node.js"); | ||
| 52 | expect(error.message).toContain("Install Node.js"); | ||
| 53 | }).pipe( | ||
| 54 | Effect.provideService(HostProcessIsExecutable, true), | ||
| 55 | Effect.provide(NodeServices.layer), | ||
| 56 | ), | ||
| 57 | ); | ||
| 58 | |||
| 59 | it.effect("finds a newly installed runtime immediately after a failed lookup", () => | ||
| 60 | Effect.gen(function* () { | ||
| 61 | const fs = yield* FileSystem.FileSystem; | ||
| 62 | const path = yield* Path.Path; | ||
| 63 | const directory = yield* fs.makeTempDirectoryScoped(); | ||
| 64 | const platform = yield* HostProcessPlatform; | ||
| 65 | const node = path.join(directory, platform === "win32" ? "node.exe" : "node"); | ||
| 66 | const env = { PATH: directory }; | ||
| 67 | expect( | ||
| 68 | Result.isFailure( | ||
| 69 | yield* resolveNodeExecutable("Local device support", env).pipe(Effect.result), | ||
| 70 | ), | ||
| 71 | ).toBe(true); | ||
| 72 | yield* fs.copyFile(process.execPath, node); | ||
| 73 | yield* fs.chmod(node, 0o755); | ||
| 74 | expect(yield* resolveNodeExecutable("Local device support", env)).toBe(node); | ||
| 75 | }).pipe( | ||
| 76 | Effect.scoped, | ||
| 77 | Effect.provideService(HostProcessExecutablePath, "/packaged/t3"), | ||
| 78 | Effect.provideService(HostProcessIsExecutable, true), | ||
| 79 | Effect.provide(NodeServices.layer), | ||
| 80 | ), | ||
| 81 | ); | ||
| 82 | |||
| 83 | it.effect("uses node.exe even when Windows batch wrappers appear first on PATH", () => | ||
| 84 | Effect.gen(function* () { | ||
| 85 | const fs = yield* FileSystem.FileSystem; | ||
| 86 | const path = yield* Path.Path; | ||
| 87 | const directory = yield* fs.makeTempDirectoryScoped(); | ||
| 88 | const wrappers = path.join(directory, "wrappers"); | ||
| 89 | const runtime = path.join(directory, "runtime"); | ||
| 90 | yield* fs.makeDirectory(wrappers); | ||
| 91 | yield* fs.makeDirectory(runtime); | ||
| 92 | yield* fs.writeFileString(path.join(wrappers, "node.cmd"), "@echo off"); | ||
| 93 | yield* fs.writeFileString(path.join(wrappers, "node.bat"), "@echo off"); | ||
| 94 | const node = path.join(runtime, "node.exe"); | ||
| 95 | yield* fs.copyFile(process.execPath, node); | ||
| 96 | expect( | ||
| 97 | yield* resolveNodeExecutable("Local device support", { | ||
| 98 | PATH: `${wrappers};${runtime}`, | ||
| 99 | PATHEXT: ".CMD;.BAT", | ||
| 100 | }), | ||
| 101 | ).toBe(node); | ||
| 102 | }).pipe( | ||
| 103 | Effect.scoped, | ||
| 104 | Effect.provideService(HostProcessExecutablePath, "/packaged/t3"), | ||
| 105 | Effect.provideService(HostProcessIsExecutable, true), | ||
| 106 | Effect.provideService(HostProcessPlatform, "win32"), | ||
| 107 | Effect.provide(NodeServices.layer), | ||
| 108 | ), | ||
| 109 | ); | ||
| 110 | |||
| 111 | it.effect("reports install guidance when Windows only has batch runtime wrappers", () => | ||
| 112 | Effect.gen(function* () { | ||
| 113 | const fs = yield* FileSystem.FileSystem; | ||
| 114 | const path = yield* Path.Path; | ||
| 115 | const directory = yield* fs.makeTempDirectoryScoped(); | ||
| 116 | yield* fs.writeFileString(path.join(directory, "node.cmd"), "@echo off"); | ||
| 117 | yield* fs.writeFileString(path.join(directory, "node.bat"), "@echo off"); | ||
| 118 | const error = yield* resolveNodeExecutable("Local device support", { | ||
| 119 | PATH: directory, | ||
| 120 | PATHEXT: ".CMD;.BAT;.EXE", | ||
| 121 | }).pipe(Effect.flip); | ||
| 122 | expect(error._tag).toBe("NodeRuntimeUnavailableError"); | ||
| 123 | expect(error.message).toContain("Install Node.js"); | ||
| 124 | }).pipe( | ||
| 125 | Effect.scoped, | ||
| 126 | Effect.provideService(HostProcessIsExecutable, true), | ||
| 127 | Effect.provideService(HostProcessPlatform, "win32"), | ||
| 128 | Effect.provide(NodeServices.layer), | ||
| 129 | ), | ||
| 130 | ); | ||
| 131 | |||
| 132 | it.effect("rejects a hard-linked node alias pointing back at the standalone app", () => | ||
| 133 | Effect.gen(function* () { | ||
| 134 | const fs = yield* FileSystem.FileSystem; | ||
| 135 | const path = yield* Path.Path; | ||
| 136 | const directory = yield* fs.makeTempDirectoryScoped(); | ||
| 137 | const platform = yield* HostProcessPlatform; | ||
| 138 | const executable = path.join(directory, platform === "win32" ? "t3.exe" : "t3"); | ||
| 139 | const node = path.join(directory, platform === "win32" ? "node.exe" : "node"); | ||
| 140 | yield* fs.writeFileString(executable, "standalone executable fixture"); | ||
| 141 | yield* fs.chmod(executable, 0o755); | ||
| 142 | yield* fs.link(executable, node); | ||
| 143 | const error = yield* resolveNodeExecutable("Local device support", { PATH: directory }).pipe( | ||
| 144 | Effect.provideService(HostProcessExecutablePath, executable), | ||
| 145 | Effect.flip, | ||
| 146 | ); | ||
| 147 | expect(error._tag).toBe("NodeRuntimeUnavailableError"); | ||
| 148 | expect(error.message).toContain("Install Node.js"); | ||
| 149 | }).pipe( | ||
| 150 | Effect.scoped, | ||
| 151 | Effect.provideService(HostProcessIsExecutable, true), | ||
| 152 | Effect.provide(NodeServices.layer), | ||
| 153 | ), | ||
| 154 | ); | ||
| 155 | |||
| 156 | it.effect.skipIf(!symlinksSupported)("preserves the node alias used by runtime launchers", () => | ||
| 157 | Effect.gen(function* () { | ||
| 158 | const fs = yield* FileSystem.FileSystem; | ||
| 159 | const path = yield* Path.Path; | ||
| 160 | const directory = yield* fs.makeTempDirectoryScoped(); | ||
| 161 | const platform = yield* HostProcessPlatform; | ||
| 162 | const node = path.join(directory, platform === "win32" ? "node.exe" : "node"); | ||
| 163 | yield* fs.symlink(process.execPath, node); | ||
| 164 | expect(yield* resolveNodeExecutable("Local device support", { PATH: directory })).toBe(node); | ||
| 165 | }).pipe( | ||
| 166 | Effect.scoped, | ||
| 167 | Effect.provideService(HostProcessExecutablePath, "/packaged/t3"), | ||
| 168 | Effect.provideService(HostProcessIsExecutable, true), | ||
| 169 | Effect.provide(NodeServices.layer), | ||
| 170 | ), | ||
| 171 | ); | ||
| 172 | |||
| 173 | it.effect.skipIf(!symlinksSupported)( | ||
| 174 | "rejects a node alias pointing back at the standalone app", | ||
| 175 | () => | ||
| 176 | Effect.gen(function* () { | ||
| 177 | const fs = yield* FileSystem.FileSystem; | ||
| 178 | const path = yield* Path.Path; | ||
| 179 | const directory = yield* fs.makeTempDirectoryScoped(); | ||
| 180 | const platform = yield* HostProcessPlatform; | ||
| 181 | const node = path.join(directory, platform === "win32" ? "node.exe" : "node"); | ||
| 182 | yield* fs.symlink(process.execPath, node); | ||
| 183 | const error = yield* resolveNodeExecutable("Local device support", { | ||
| 184 | PATH: directory, | ||
| 185 | }).pipe(Effect.flip); | ||
| 186 | expect(error.message).toContain("Install Node.js"); | ||
| 187 | }).pipe( | ||
| 188 | Effect.scoped, | ||
| 189 | Effect.provideService(HostProcessIsExecutable, true), | ||
| 190 | Effect.provide(NodeServices.layer), | ||
| 191 | ), | ||
| 192 | ); | ||
| 193 | }); | ||
packages/shared/src/nodeRuntime.ts created+78| ... | @@ -0,0 +1,78 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as FileSystem from "effect/FileSystem"; | ||
| 3 | import * as Path from "effect/Path"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import * as Schema from "effect/Schema"; | ||
| 6 | |||
| 7 | import { | ||
| 8 | HostProcessEnvironment, | ||
| 9 | HostProcessExecutablePath, | ||
| 10 | HostProcessIsExecutable, | ||
| 11 | HostProcessPlatform, | ||
| 12 | } from "./hostProcess.ts"; | ||
| 13 | import { CommandResolutionCache, resolveCommandPath } from "./shell.ts"; | ||
| 14 | |||
| 15 | const NodeRuntimeFeature = Schema.Literals([ | ||
| 16 | "Local device support", | ||
| 17 | "Device automation", | ||
| 18 | "Antigravity", | ||
| 19 | "Antigravity sign-in", | ||
| 20 | ]); | ||
| 21 | |||
| 22 | export const nodeRuntimeUnavailableMessage = (feature: typeof NodeRuntimeFeature.Type): string => | ||
| 23 | `${feature} requires Node.js. Install Node.js and make sure node is on PATH, then retry.`; | ||
| 24 | |||
| 25 | export class NodeRuntimeUnavailableError extends Schema.TaggedError<NodeRuntimeUnavailableError>()( | ||
| 26 | "NodeRuntimeUnavailableError", | ||
| 27 | { feature: NodeRuntimeFeature, cause: Schema.optional(Schema.Defect()) }, | ||
| 28 | ) { | ||
| 29 | override get message(): string { | ||
| 30 | return nodeRuntimeUnavailableMessage(this.feature); | ||
| 31 | } | ||
| 32 | } | ||
| 33 | |||
| 34 | /** A standalone T3 binary runs its embedded CLI, regardless of script arguments. */ | ||
| 35 | export const resolveNodeExecutable = Effect.fn("nodeRuntime.resolveNodeExecutable")(function* ( | ||
| 36 | feature: typeof NodeRuntimeFeature.Type, | ||
| 37 | environment?: NodeJS.ProcessEnv, | ||
| 38 | ) { | ||
| 39 | const executablePath = yield* HostProcessExecutablePath; | ||
| 40 | if (!(yield* HostProcessIsExecutable)) return executablePath; | ||
| 41 | |||
| 42 | const fs = yield* FileSystem.FileSystem; | ||
| 43 | const path = yield* Path.Path; | ||
| 44 | const platform = yield* HostProcessPlatform; | ||
| 45 | const env = environment ?? (yield* HostProcessEnvironment); | ||
| 46 | const nodePath = yield* resolveCommandPath(platform === "win32" ? "node.exe" : "node", { | ||
| 47 | // Batch wrappers require a shell; helper callers launch the runtime directly. | ||
| 48 | env: platform === "win32" ? { ...env, PATHEXT: ".EXE" } : env, | ||
| 49 | }).pipe( | ||
| 50 | // Refresh immediately after the user installs Node and retries setup. | ||
| 51 | Effect.provideService(CommandResolutionCache, new Map()), | ||
| 52 | Effect.map((commandPath) => path.resolve(commandPath)), | ||
| 53 | Effect.mapError((cause) => new NodeRuntimeUnavailableError({ feature, cause })), | ||
| 54 | ); | ||
| 55 | // A launcher or symlink named node must not point back at the standalone app. | ||
| 56 | const resolvedPath = yield* fs | ||
| 57 | .realPath(nodePath) | ||
| 58 | .pipe(Effect.mapError((cause) => new NodeRuntimeUnavailableError({ feature, cause }))); | ||
| 59 | if (resolvedPath === executablePath) return yield* new NodeRuntimeUnavailableError({ feature }); | ||
| 60 | const [hostInfo, nodeInfo] = yield* Effect.all([ | ||
| 61 | fs.stat(executablePath).pipe(Effect.option), | ||
| 62 | fs.stat(nodePath).pipe(Effect.option), | ||
| 63 | ]); | ||
| 64 | if ( | ||
| 65 | Option.isSome(hostInfo) && | ||
| 66 | Option.isSome(nodeInfo) && | ||
| 67 | hostInfo.value.dev === nodeInfo.value.dev && | ||
| 68 | Option.isSome(hostInfo.value.ino) && | ||
| 69 | Option.isSome(nodeInfo.value.ino) && | ||
| 70 | Number.isSafeInteger(hostInfo.value.ino.value) && | ||
| 71 | hostInfo.value.ino.value > 0 && | ||
| 72 | hostInfo.value.ino.value === nodeInfo.value.ino.value | ||
| 73 | ) { | ||
| 74 | return yield* new NodeRuntimeUnavailableError({ feature }); | ||
| 75 | } | ||
| 76 | // Launchers such as Vite+ dispatch by argv[0]; keep the node name intact. | ||
| 77 | return nodePath; | ||
| 78 | }); | ||