| author | |
| committer | |
| log | 5c5fa5ffb6ab45cadb6f0d873af0242f4f6d2c1c |
| tree | 4475de017a12de9cb0d2ab2b5d4cc6b821f82132 |
| parent | 6cc7f7006e24283072cc3e0b3ed8ced80c6d71a5 |
| signature | Signed by PGP key B5690EEEBB952194 |
14 files changed, 349 insertions(+), 21 deletions(-)
apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx+13| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { deviceToolVersionLabels } from "@t3tools/client-runtime/state/device"; | ||
| 1 | import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; | 2 | import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 2 | import { createNativeStackNavigator } from "@react-navigation/native-stack"; | 3 | import { createNativeStackNavigator } from "@react-navigation/native-stack"; |
| 3 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 4 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| ... | @@ -116,6 +117,18 @@ function DevicePreviewScreen({ | ... | @@ -116,6 +117,18 @@ function DevicePreviewScreen({ |
| 116 | }; | 117 | }; |
| 117 | 118 | ||
| 118 | const controls: ScreenHeaderMenuItem[] = [ | 119 | const controls: ScreenHeaderMenuItem[] = [ |
| 120 | { | ||
| 121 | id: "device-tools", | ||
| 122 | title: "Device tool versions", | ||
| 123 | icon: "info.circle", | ||
| 124 | onPress: () => | ||
| 125 | Alert.alert( | ||
| 126 | "Device tool versions", | ||
| 127 | deviceToolVersionLabels( | ||
| 128 | state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools, | ||
| 129 | ).join("\n"), | ||
| 130 | ), | ||
| 131 | }, | ||
| 119 | { | 132 | { |
| 120 | id: "reload", | 133 | id: "reload", |
| 121 | title: "Reload stream", | 134 | title: "Reload stream", |
apps/server/src/device/DeviceToolchain.test.ts+61-1| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as PlatformError from "effect/PlatformError"; | ||
| 1 | import { expect, it } from "@effect/vitest"; | 2 | import { expect, it } from "@effect/vitest"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| ... | @@ -6,7 +7,12 @@ import * as Path from "effect/Path"; | ... | @@ -6,7 +7,12 @@ import * as Path from "effect/Path"; |
| 6 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | 7 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; |
| 7 | 8 | ||
| 8 | import * as ProcessRunner from "../processRunner.ts"; | 9 | import * as ProcessRunner from "../processRunner.ts"; |
| 9 | import { ensureDeviceHub, isDeviceHubInstalled } from "./DeviceToolchain.ts"; | 10 | import { |
| 11 | deviceToolVersions, | ||
| 12 | DEVICE_HUB_VERSION, | ||
| 13 | ensureDeviceHub, | ||
| 14 | isDeviceHubInstalled, | ||
| 15 | } from "./DeviceToolchain.ts"; | ||
| 10 | 16 | ||
| 11 | it.effect("failed installation cleans staging and exposes only a safe failure message", () => | 17 | it.effect("failed installation cleans staging and exposes only a safe failure message", () => |
| 12 | Effect.gen(function* () { | 18 | Effect.gen(function* () { |
| ... | @@ -37,3 +43,57 @@ it.effect("failed installation cleans staging and exposes only a safe failure me | ... | @@ -37,3 +43,57 @@ it.effect("failed installation cleans staging and exposes only a safe failure me |
| 37 | expect(yield* fs.readDirectory(path.join(baseDir, "tools", "expo-device-hub"))).toEqual([]); | 43 | expect(yield* fs.readDirectory(path.join(baseDir, "tools", "expo-device-hub"))).toEqual([]); |
| 38 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | 44 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 39 | ); | 45 | ); |
| 46 | |||
| 47 | it.effect("inventory reports only completed versions without installing the required version", () => | ||
| 48 | Effect.gen(function* () { | ||
| 49 | const fs = yield* FileSystem.FileSystem; | ||
| 50 | const path = yield* Path.Path; | ||
| 51 | const base = yield* fs.makeTempDirectoryScoped(); | ||
| 52 | for (const [version, sentinel] of [ | ||
| 53 | ["0.9.0", "0.9.0"], | ||
| 54 | [DEVICE_HUB_VERSION, "wrong"], | ||
| 55 | [".staging-123", ".staging-123"], | ||
| 56 | ]) { | ||
| 57 | const dir = path.join(base, "tools", "expo-device-hub", version!); | ||
| 58 | yield* fs.makeDirectory(path.join(dir, "node_modules/expo-device-hub/dist/server"), { | ||
| 59 | recursive: true, | ||
| 60 | }); | ||
| 61 | yield* fs.writeFileString( | ||
| 62 | path.join(dir, "node_modules/expo-device-hub/dist/server/cli.mjs"), | ||
| 63 | "", | ||
| 64 | ); | ||
| 65 | yield* fs.writeFileString(path.join(dir, ".install-complete"), sentinel!); | ||
| 66 | } | ||
| 67 | const tools = yield* deviceToolVersions(base); | ||
| 68 | expect(tools?.hub).toEqual({ | ||
| 69 | requiredVersion: DEVICE_HUB_VERSION, | ||
| 70 | installedVersions: ["0.9.0"], | ||
| 71 | runningVersion: null, | ||
| 72 | }); | ||
| 73 | expect(tools?.agent.installedVersions).toEqual([]); | ||
| 74 | expect(yield* isDeviceHubInstalled(base)).toBe(false); | ||
| 75 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 76 | ); | ||
| 77 | |||
| 78 | it.effect("unreadable inventory stays unknown instead of reporting no installs", () => | ||
| 79 | Effect.gen(function* () { | ||
| 80 | const tools = yield* deviceToolVersions("/unreadable"); | ||
| 81 | expect(tools).toBeUndefined(); | ||
| 82 | }).pipe( | ||
| 83 | Effect.provideService( | ||
| 84 | FileSystem.FileSystem, | ||
| 85 | FileSystem.makeNoop({ | ||
| 86 | readDirectory: () => | ||
| 87 | Effect.fail( | ||
| 88 | PlatformError.systemError({ | ||
| 89 | _tag: "PermissionDenied", | ||
| 90 | module: "FileSystem", | ||
| 91 | method: "readDirectory", | ||
| 92 | description: "denied", | ||
| 93 | }), | ||
| 94 | ), | ||
| 95 | }), | ||
| 96 | ), | ||
| 97 | Effect.provide(NodeServices.layer), | ||
| 98 | ), | ||
| 99 | ); |
apps/server/src/device/DeviceToolchain.ts+44| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import type { DeviceToolVersions } from "@t3tools/contracts"; | ||
| 1 | /** | 2 | /** |
| 2 | * Pinned installs of the two external tools device support is built on. | 3 | * Pinned installs of the two external tools device support is built on. |
| 3 | * | 4 | * |
| ... | @@ -221,3 +222,46 @@ export const isDeviceHubInstalled = (baseDir: string) => | ... | @@ -221,3 +222,46 @@ export const isDeviceHubInstalled = (baseDir: string) => |
| 221 | 222 | ||
| 222 | export const isAgentDeviceInstalled = (baseDir: string) => | 223 | export const isAgentDeviceInstalled = (baseDir: string) => |
| 223 | isToolInstalled(baseDir, AGENT_DEVICE_SPEC, (paths) => paths.agentDevice); | 224 | isToolInstalled(baseDir, AGENT_DEVICE_SPEC, (paths) => paths.agentDevice); |
| 225 | |||
| 226 | /** Read completed installs without downloading or starting either tool. */ | ||
| 227 | export const deviceToolVersions = Effect.fn("DeviceToolchain.versions")(function* ( | ||
| 228 | baseDir: string, | ||
| 229 | running: { hub?: string; agent?: string } = {}, | ||
| 230 | ) { | ||
| 231 | const fs = yield* FileSystem.FileSystem; | ||
| 232 | const path = yield* Path.Path; | ||
| 233 | const inspect = Effect.fn("DeviceToolchain.inspect")(function* (spec: ToolSpec) { | ||
| 234 | const directory = path.join(baseDir, "tools", spec.name); | ||
| 235 | const names = yield* fs.readDirectory(directory).pipe( | ||
| 236 | Effect.catchIf( | ||
| 237 | (error) => error.reason._tag === "NotFound", | ||
| 238 | () => Effect.succeed([]), | ||
| 239 | ), | ||
| 240 | ); | ||
| 241 | const versions = yield* Effect.filter(names, (version) => | ||
| 242 | /^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version) | ||
| 243 | ? Effect.gen(function* () { | ||
| 244 | const paths = toolPaths(path, baseDir, { ...spec, version }); | ||
| 245 | const sentinel = yield* fs.readFileString(paths.sentinelPath).pipe( | ||
| 246 | Effect.catchIf( | ||
| 247 | (error) => error.reason._tag === "NotFound", | ||
| 248 | () => Effect.succeed(null), | ||
| 249 | ), | ||
| 250 | ); | ||
| 251 | return sentinel?.trim() === version && (yield* fs.exists(paths.entryPath)); | ||
| 252 | }) | ||
| 253 | : Effect.succeed(false), | ||
| 254 | ); | ||
| 255 | return { | ||
| 256 | requiredVersion: spec.version, | ||
| 257 | installedVersions: versions.sort(), | ||
| 258 | runningVersion: (spec.name === DEVICE_HUB_PACKAGE ? running.hub : running.agent) ?? null, | ||
| 259 | }; | ||
| 260 | }); | ||
| 261 | return yield* Effect.gen(function* () { | ||
| 262 | return { | ||
| 263 | hub: yield* inspect(HUB_SPEC), | ||
| 264 | agent: yield* inspect(AGENT_DEVICE_SPEC), | ||
| 265 | } satisfies DeviceToolVersions; | ||
| 266 | }).pipe(Effect.orElseSucceed(() => undefined)); | ||
| 267 | }); |
apps/server/src/device/LocalDeviceHost.ts+23| ... | @@ -31,6 +31,7 @@ import * as Exit from "effect/Exit"; | ... | @@ -31,6 +31,7 @@ import * as Exit from "effect/Exit"; |
| 31 | import * as FileSystem from "effect/FileSystem"; | 31 | import * as FileSystem from "effect/FileSystem"; |
| 32 | import * as Layer from "effect/Layer"; | 32 | import * as Layer from "effect/Layer"; |
| 33 | import * as Path from "effect/Path"; | 33 | import * as Path from "effect/Path"; |
| 34 | import * as Option from "effect/Option"; | ||
| 34 | import * as Ref from "effect/Ref"; | 35 | import * as Ref from "effect/Ref"; |
| 35 | import * as Schema from "effect/Schema"; | 36 | import * as Schema from "effect/Schema"; |
| 36 | import * as Scope from "effect/Scope"; | 37 | import * as Scope from "effect/Scope"; |
| ... | @@ -50,6 +51,8 @@ import { | ... | @@ -50,6 +51,8 @@ import { |
| 50 | ensureDeviceHub, | 51 | ensureDeviceHub, |
| 51 | isAgentDeviceInstalled, | 52 | isAgentDeviceInstalled, |
| 52 | isDeviceHubInstalled, | 53 | isDeviceHubInstalled, |
| 54 | deviceToolVersions, | ||
| 55 | DEVICE_HUB_VERSION, | ||
| 53 | } from "./DeviceToolchain.ts"; | 56 | } from "./DeviceToolchain.ts"; |
| 54 | 57 | ||
| 55 | const HUB_READY_TIMEOUT_MS = 30_000; | 58 | const HUB_READY_TIMEOUT_MS = 30_000; |
| ... | @@ -76,6 +79,7 @@ const AgentDeviceDaemonFile = Schema.Struct({ | ... | @@ -76,6 +79,7 @@ const AgentDeviceDaemonFile = Schema.Struct({ |
| 76 | httpPort: Schema.Int, | 79 | httpPort: Schema.Int, |
| 77 | token: Schema.String, | 80 | token: Schema.String, |
| 78 | pid: Schema.optional(Schema.Int), | 81 | pid: Schema.optional(Schema.Int), |
| 82 | version: Schema.optional(Schema.String), | ||
| 79 | }); | 83 | }); |
| 80 | const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile)); | 84 | const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile)); |
| 81 | 85 | ||
| ... | @@ -232,7 +236,26 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { | ... | @@ -232,7 +236,26 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { |
| 232 | Effect.provideService(Path.Path, path), | 236 | Effect.provideService(Path.Path, path), |
| 233 | ), | 237 | ), |
| 234 | ]); | 238 | ]); |
| 239 | const running = yield* Ref.get(runningRef); | ||
| 240 | const daemon = running?.agentDevice | ||
| 241 | ? yield* readDaemonFile().pipe(Effect.option) | ||
| 242 | : Option.none(); | ||
| 243 | const hubAlive = running | ||
| 244 | ? yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false)) | ||
| 245 | : false; | ||
| 246 | const agentAlive = | ||
| 247 | Option.isSome(daemon) && daemon.value.pid ? yield* isProcessAlive(daemon.value.pid) : false; | ||
| 248 | const tools = yield* deviceToolVersions(config.baseDir, { | ||
| 249 | ...(hubAlive ? { hub: DEVICE_HUB_VERSION } : {}), | ||
| 250 | ...(agentAlive && Option.isSome(daemon) && daemon.value.version | ||
| 251 | ? { agent: daemon.value.version } | ||
| 252 | : {}), | ||
| 253 | }).pipe( | ||
| 254 | Effect.provideService(FileSystem.FileSystem, fs), | ||
| 255 | Effect.provideService(Path.Path, path), | ||
| 256 | ); | ||
| 235 | return { | 257 | return { |
| 258 | tools, | ||
| 236 | id: hostId, | 259 | id: hostId, |
| 237 | kind: "local", | 260 | kind: "local", |
| 238 | label: "This machine", | 261 | label: "This machine", |
apps/server/src/device/SshDeviceHost.test.ts+8| ... | @@ -20,6 +20,7 @@ it.effect("preserves installed status after probes and cleans failed agent activ | ... | @@ -20,6 +20,7 @@ it.effect("preserves installed status after probes and cleans failed agent activ |
| 20 | const fs = yield* FileSystem.FileSystem; | 20 | const fs = yield* FileSystem.FileSystem; |
| 21 | const home = yield* fs.makeTempDirectoryScoped(); | 21 | const home = yield* fs.makeTempDirectoryScoped(); |
| 22 | const modes: string[] = []; | 22 | const modes: string[] = []; |
| 23 | const owners: string[] = []; | ||
| 23 | let forwards = 0; | 24 | let forwards = 0; |
| 24 | let failForward = true; | 25 | let failForward = true; |
| 25 | let rejectConfig = true; | 26 | let rejectConfig = true; |
| ... | @@ -62,6 +63,7 @@ it.effect("preserves installed status after probes and cleans failed agent activ | ... | @@ -62,6 +63,7 @@ it.effect("preserves installed status after probes and cleans failed agent activ |
| 62 | ); | 63 | ); |
| 63 | const mode = /const mode = "([^"]+)"/.exec(script)?.[1] ?? ""; | 64 | const mode = /const mode = "([^"]+)"/.exec(script)?.[1] ?? ""; |
| 64 | modes.push(mode); | 65 | modes.push(mode); |
| 66 | owners.push(/const owner = "([^"]+)"/.exec(script)?.[1] ?? ""); | ||
| 65 | output = JSON.stringify({ | 67 | output = JSON.stringify({ |
| 66 | nodePath: "/node", | 68 | nodePath: "/node", |
| 67 | platforms: [{ platform: "ios", available: true }], | 69 | platforms: [{ platform: "ios", available: true }], |
| ... | @@ -110,6 +112,12 @@ it.effect("preserves installed status after probes and cleans failed agent activ | ... | @@ -110,6 +112,12 @@ it.effect("preserves installed status after probes and cleans failed agent activ |
| 110 | ), | 112 | ), |
| 111 | ); | 113 | ); |
| 112 | yield* host.ensureReady(() => Effect.void); | 114 | yield* host.ensureReady(() => Effect.void); |
| 115 | yield* SshDeviceHost.probe({ id: "test", label: "Test", target: "test.example" }).pipe( | ||
| 116 | Effect.provide(ServerConfig.layerTest(home, home)), | ||
| 117 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | ||
| 118 | ); | ||
| 119 | expect(new Set(owners).size).toBe(1); | ||
| 120 | expect(owners[0]).toMatch(/^[a-f0-9]{24}$/); | ||
| 113 | expect(forwards).toBe(1); | 121 | expect(forwards).toBe(1); |
| 114 | expect(modes.filter((mode) => mode === "start")).toHaveLength(2); | 122 | expect(modes.filter((mode) => mode === "start")).toHaveLength(2); |
| 115 | yield* host.platformAvailability("ios"); | 123 | yield* host.platformAvailability("ios"); |
apps/server/src/device/SshDeviceHost.ts+31-12| ... | @@ -2,6 +2,7 @@ import * as NodeCrypto from "node:crypto"; | ... | @@ -2,6 +2,7 @@ import * as NodeCrypto from "node:crypto"; |
| 2 | import { | 2 | import { |
| 3 | type DeviceHostSummary, | 3 | type DeviceHostSummary, |
| 4 | DevicePlatformAvailability, | 4 | DevicePlatformAvailability, |
| 5 | DeviceToolVersions, | ||
| 5 | type SshDeviceHostConfig, | 6 | type SshDeviceHostConfig, |
| 6 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 7 | import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command"; | 8 | import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command"; |
| ... | @@ -24,6 +25,7 @@ import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./s | ... | @@ -24,6 +25,7 @@ import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./s |
| 24 | 25 | ||
| 25 | const Probe = Schema.Struct({ | 26 | const Probe = Schema.Struct({ |
| 26 | nodePath: Schema.String, | 27 | nodePath: Schema.String, |
| 28 | tools: Schema.optional(DeviceToolVersions), | ||
| 27 | platforms: Schema.Array(DevicePlatformAvailability), | 29 | platforms: Schema.Array(DevicePlatformAvailability), |
| 28 | }); | 30 | }); |
| 29 | const Started = Schema.Struct({ | 31 | const Started = Schema.Struct({ |
| ... | @@ -70,8 +72,23 @@ const bootstrap = ( | ... | @@ -70,8 +72,23 @@ const bootstrap = ( |
| 70 | ), | 72 | ), |
| 71 | ); | 73 | ); |
| 72 | 74 | ||
| 73 | export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDeviceHostConfig) { | 75 | const ownerFor = Effect.fn("SshDeviceHost.ownerFor")(function* (hostId: string) { |
| 74 | const result = yield* bootstrap(config, "probe", "probe"); | 76 | const fs = yield* FileSystem.FileSystem; |
| 77 | const server = yield* ServerConfig.ServerConfig; | ||
| 78 | const environmentId = yield* fs | ||
| 79 | .readFileString(server.environmentIdPath) | ||
| 80 | .pipe(Effect.orElseSucceed(() => server.stateDir)); | ||
| 81 | return NodeCrypto.createHash("sha256") | ||
| 82 | .update(`${environmentId}\0${server.stateDir}\0${hostId}`) | ||
| 83 | .digest("hex") | ||
| 84 | .slice(0, 24); | ||
| 85 | }); | ||
| 86 | |||
| 87 | export const probe = Effect.fn("SshDeviceHost.probe")(function* ( | ||
| 88 | config: SshDeviceHostConfig, | ||
| 89 | owner?: string, | ||
| 90 | ) { | ||
| 91 | const result = yield* bootstrap(config, owner ?? (yield* ownerFor(config.id)), "probe"); | ||
| 75 | const value = yield* decodeProbe(result.stdout.trim()).pipe( | 92 | const value = yield* decodeProbe(result.stdout.trim()).pipe( |
| 76 | Effect.mapError( | 93 | Effect.mapError( |
| 77 | (cause) => | 94 | (cause) => |
| ... | @@ -82,8 +99,11 @@ export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDevi | ... | @@ -82,8 +99,11 @@ export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDevi |
| 82 | id: config.id, | 99 | id: config.id, |
| 83 | label: config.label, | 100 | label: config.label, |
| 84 | kind: "ssh", | 101 | kind: "ssh", |
| 85 | hubInstalled: false, | 102 | tools: value.tools, |
| 86 | agentDeviceInstalled: false, | 103 | hubInstalled: |
| 104 | value.tools?.hub.installedVersions.includes(value.tools.hub.requiredVersion) ?? false, | ||
| 105 | agentDeviceInstalled: | ||
| 106 | value.tools?.agent.installedVersions.includes(value.tools.agent.requiredVersion) ?? false, | ||
| 87 | platforms: value.platforms, | 107 | platforms: value.platforms, |
| 88 | } satisfies DeviceHostSummary; | 108 | } satisfies DeviceHostSummary; |
| 89 | }); | 109 | }); |
| ... | @@ -106,23 +126,21 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( | ... | @@ -106,23 +126,21 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( |
| 106 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 126 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 107 | const parentScope = yield* Scope.Scope; | 127 | const parentScope = yield* Scope.Scope; |
| 108 | const ssh = yield* resolveSshCommand; | 128 | const ssh = yield* resolveSshCommand; |
| 109 | const environmentId = yield* fs | 129 | const owner = yield* ownerFor(config.id); |
| 110 | .readFileString(server.environmentIdPath) | ||
| 111 | .pipe(Effect.orElseSucceed(() => server.stateDir)); | ||
| 112 | const owner = NodeCrypto.createHash("sha256") | ||
| 113 | .update(`${environmentId}\0${server.stateDir}\0${config.id}`) | ||
| 114 | .digest("hex") | ||
| 115 | .slice(0, 24); | ||
| 116 | const provide = <A, E>( | 130 | const provide = <A, E>( |
| 117 | effect: Effect.Effect< | 131 | effect: Effect.Effect< |
| 118 | A, | 132 | A, |
| 119 | E, | 133 | E, |
| 120 | FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner | 134 | | FileSystem.FileSystem |
| 135 | | Path.Path | ||
| 136 | | ChildProcessSpawner.ChildProcessSpawner | ||
| 137 | | ServerConfig.ServerConfig | ||
| 121 | >, | 138 | >, |
| 122 | ) => | 139 | ) => |
| 123 | effect.pipe( | 140 | effect.pipe( |
| 124 | Effect.provideService(FileSystem.FileSystem, fs), | 141 | Effect.provideService(FileSystem.FileSystem, fs), |
| 125 | Effect.provideService(Path.Path, path), | 142 | Effect.provideService(Path.Path, path), |
| 143 | Effect.provideService(ServerConfig.ServerConfig, server), | ||
| 126 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | 144 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 127 | ); | 145 | ); |
| 128 | const lock = yield* Semaphore.make(1); | 146 | const lock = yield* Semaphore.make(1); |
| ... | @@ -183,6 +201,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( | ... | @@ -183,6 +201,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( |
| 183 | summary = { | 201 | summary = { |
| 184 | ...summary, | 202 | ...summary, |
| 185 | platforms: remote.platforms, | 203 | platforms: remote.platforms, |
| 204 | tools: remote.tools, | ||
| 186 | hubInstalled: true, | 205 | hubInstalled: true, |
| 187 | agentDeviceInstalled: wantsAgent || summary.agentDeviceInstalled, | 206 | agentDeviceInstalled: wantsAgent || summary.agentDeviceInstalled, |
| 188 | }; | 207 | }; |
apps/server/src/device/sshDeviceScript.test.ts+12-3| ... | @@ -83,15 +83,16 @@ const state=process.env.AGENT_DEVICE_STATE_DIR || args[args.indexOf('--state-dir | ... | @@ -83,15 +83,16 @@ const state=process.env.AGENT_DEVICE_STATE_DIR || args[args.indexOf('--state-dir |
| 83 | const file=path.join(state,'daemon.json'); | 83 | const file=path.join(state,'daemon.json'); |
| 84 | if(args[0]==='daemon') { const data=JSON.parse(fs.readFileSync(file,'utf8')); fs.writeFileSync(path.join(state,'stopped-agent'),String(data.pid)); try {process.kill(data.pid,'SIGTERM')} catch {} } | 84 | if(args[0]==='daemon') { const data=JSON.parse(fs.readFileSync(file,'utf8')); fs.writeFileSync(path.join(state,'stopped-agent'),String(data.pid)); try {process.kill(data.pid,'SIGTERM')} catch {} } |
| 85 | else if(args[0]==='serve') { const server=http.createServer((req,res)=>{res.statusCode=fs.existsSync(path.join(state,'unhealthy-agent-'+process.pid))?503:200;res.end('ok');}); server.listen(0,'127.0.0.1',()=>{fs.writeFileSync(file,JSON.stringify({httpPort:server.address().port,pid:process.pid,token:'test'}));process.send?.('ready');process.disconnect?.();}); } | 85 | else if(args[0]==='serve') { const server=http.createServer((req,res)=>{res.statusCode=fs.existsSync(path.join(state,'unhealthy-agent-'+process.pid))?503:200;res.end('ok');}); server.listen(0,'127.0.0.1',()=>{fs.writeFileSync(file,JSON.stringify({httpPort:server.address().port,pid:process.pid,token:'test'}));process.send?.('ready');process.disconnect?.();}); } |
| 86 | else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:true,stdio:['ignore','ignore','ignore','ipc'],env:process.env});await new Promise((resolve,reject)=>{child.once('message',resolve);child.once('error',reject);});child.unref(); } | 86 | else { const child=spawn(process.execPath,[path.join(path.dirname(process.argv[1]),'daemon.mjs'),'serve'],{detached:true,stdio:['ignore','ignore','ignore','ipc'],env:process.env});await new Promise((resolve,reject)=>{child.once('message',resolve);child.once('error',reject);});child.unref(); } |
| 87 | `, | 87 | `, |
| 88 | ); | 88 | ); |
| 89 | await NodeFSP.copyFile(agent, NodePath.join(NodePath.dirname(agent), "daemon.mjs")); | ||
| 89 | const nextHubVersion = DEVICE_HUB_VERSION + "-upgrade"; | 90 | const nextHubVersion = DEVICE_HUB_VERSION + "-upgrade"; |
| 90 | const nextAgentVersion = AGENT_DEVICE_VERSION + "-upgrade"; | 91 | const nextAgentVersion = AGENT_DEVICE_VERSION + "-upgrade"; |
| 91 | let invocation = 0; | 92 | let invocation = 0; |
| 92 | const invoke = async ( | 93 | const invoke = async ( |
| 93 | owner: string, | 94 | owner: string, |
| 94 | mode: "start" | "agent-start" | "stop-agent" | "stop", | 95 | mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop", |
| 95 | upgraded = false, | 96 | upgraded = false, |
| 96 | ) => { | 97 | ) => { |
| 97 | const file = NodePath.join(home, `${owner}-${mode}-${invocation++}.cjs`); | 98 | const file = NodePath.join(home, `${owner}-${mode}-${invocation++}.cjs`); |
| ... | @@ -107,6 +108,11 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr | ... | @@ -107,6 +108,11 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr |
| 107 | }); | 108 | }); |
| 108 | return result.stdout ? JSON.parse(result.stdout) : null; | 109 | return result.stdout ? JSON.parse(result.stdout) : null; |
| 109 | }; | 110 | }; |
| 111 | const inventory = await invoke("one", "probe"); | ||
| 112 | expect(inventory.tools.hub.installedVersions).toEqual([DEVICE_HUB_VERSION]); | ||
| 113 | expect(inventory.tools.hub.runningVersion).toBeNull(); | ||
| 114 | expect(inventory.tools.agent.installedVersions).toEqual([AGENT_DEVICE_VERSION]); | ||
| 115 | await expect(NodeFSP.stat(NodePath.join(root, "hosts/one/hub.json"))).rejects.toThrow(); | ||
| 110 | const template = NodePath.join(home, "hub-template"); | 116 | const template = NodePath.join(home, "hub-template"); |
| 111 | await NodeFSP.cp(hubDir, template, { recursive: true }); | 117 | await NodeFSP.cp(hubDir, template, { recursive: true }); |
| 112 | await NodeFSP.rm(NodePath.join(hubDir, ".install-complete")); | 118 | await NodeFSP.rm(NodePath.join(hubDir, ".install-complete")); |
| ... | @@ -114,7 +120,7 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr | ... | @@ -114,7 +120,7 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr |
| 114 | await NodeFSP.symlink("2147483647:exited-installer", installLock); | 120 | await NodeFSP.symlink("2147483647:exited-installer", installLock); |
| 115 | await NodeFSP.writeFile( | 121 | await NodeFSP.writeFile( |
| 116 | NodePath.join(bin, "npm"), | 122 | NodePath.join(bin, "npm"), |
| 117 | `#!${process.execPath}\nconst fs=require('node:fs');const args=process.argv.slice(2);fs.cpSync(${JSON.stringify(template)},args[args.indexOf('--prefix')+1],{recursive:true});`, | 123 | `#!${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});`, |
| 118 | { mode: 0o755 }, | 124 | { mode: 0o755 }, |
| 119 | ); | 125 | ); |
| 120 | await NodeFSP.mkdir(NodePath.join(root, "hosts/one"), { recursive: true }); | 126 | await NodeFSP.mkdir(NodePath.join(root, "hosts/one"), { recursive: true }); |
| ... | @@ -135,6 +141,9 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr | ... | @@ -135,6 +141,9 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr |
| 135 | ]); | 141 | ]); |
| 136 | expect(concurrentAgent.hubPort).toBe(first.hubPort); | 142 | expect(concurrentAgent.hubPort).toBe(first.hubPort); |
| 137 | expect(concurrentAgent.daemonPort).toBe(first.daemonPort); | 143 | expect(concurrentAgent.daemonPort).toBe(first.daemonPort); |
| 144 | const running = await invoke("one", "probe"); | ||
| 145 | expect(running.tools.hub.runningVersion).toBe(DEVICE_HUB_VERSION); | ||
| 146 | expect(running.tools.agent.runningVersion).toBe(AGENT_DEVICE_VERSION); | ||
| 138 | const second = await invoke("two", "agent-start"); | 147 | const second = await invoke("two", "agent-start"); |
| 139 | const reused = await invoke("one", "agent-start"); | 148 | const reused = await invoke("one", "agent-start"); |
| 140 | expect(reused.hubPort).toBe(first.hubPort); | 149 | expect(reused.hubPort).toBe(first.hubPort); |
apps/server/src/device/sshDeviceScript.ts+31-2| ... | @@ -39,6 +39,35 @@ const state = path.join(root, 'hosts', owner); | ... | @@ -39,6 +39,35 @@ const state = path.join(root, 'hosts', owner); |
| 39 | const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', timeout: 30000, ...options }); | 39 | const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', timeout: 30000, ...options }); |
| 40 | const read = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } }; | 40 | const read = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } }; |
| 41 | const write = (file, value) => { const tmp = file + '.' + process.pid; fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 }); fs.renameSync(tmp, file); }; | 41 | const write = (file, value) => { const tmp = file + '.' + process.pid; fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 }); fs.renameSync(tmp, file); }; |
| 42 | const toolVersions = (name, requiredVersion, entry, record) => { | ||
| 43 | const directory = path.join(root, 'tools'); | ||
| 44 | const prefix = name + '@'; | ||
| 45 | let names = []; | ||
| 46 | try { names = fs.readdirSync(directory); } catch (error) { if (error.code !== 'ENOENT') return null; } | ||
| 47 | let unreadable = false; | ||
| 48 | const installedVersions = names.filter(name => name.startsWith(prefix)).map(name => name.slice(prefix.length)).filter(version => { | ||
| 49 | if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version)) return false; | ||
| 50 | const dir = path.join(directory, prefix + version); | ||
| 51 | try { return fs.readFileSync(path.join(dir, '.install-complete'), 'utf8').trim() === version && fs.existsSync(path.join(dir, 'node_modules', name, entry)); } catch (error) { if (error.code !== 'ENOENT') unreadable = true; return false; } | ||
| 52 | }).sort(); | ||
| 53 | if (unreadable) return null; | ||
| 54 | let runningVersion = null; | ||
| 55 | if (record?.entryPath && record?.pid) { | ||
| 56 | const command = run('ps', ['-p', String(record.pid), '-o', 'command=']).stdout || ''; | ||
| 57 | runningVersion = installedVersions.find(version => { | ||
| 58 | const install = path.join(directory, prefix + version); | ||
| 59 | return record.entryPath === path.join(install, 'node_modules', name, entry) && command.includes(install + path.sep); | ||
| 60 | }) ?? null; | ||
| 61 | } | ||
| 62 | return { requiredVersion, installedVersions, runningVersion }; | ||
| 63 | }; | ||
| 64 | const versions = () => { | ||
| 65 | const result = { | ||
| 66 | hub: toolVersions('expo-device-hub', hubVersion, 'dist/server/cli.mjs', read(path.join(state, 'hub.json'))), | ||
| 67 | agent: toolVersions('agent-device', agentVersion, 'bin/agent-device.mjs', { ...read(path.join(state, 'agent.json')), ...read(path.join(state, 'daemon.json')) }), | ||
| 68 | }; | ||
| 69 | return result.hub && result.agent ? result : undefined; | ||
| 70 | }; | ||
| 42 | const stopHub = hub => { | 71 | const stopHub = hub => { |
| 43 | if (!hub || hub.owner !== owner) return; | 72 | if (!hub || hub.owner !== owner) return; |
| 44 | const command = run('ps', ['-p', String(hub.pid), '-o', 'command=']).stdout || ''; | 73 | const command = run('ps', ['-p', String(hub.pid), '-o', 'command=']).stdout || ''; |
| ... | @@ -111,7 +140,7 @@ async function install(name, version, entry) { | ... | @@ -111,7 +140,7 @@ async function install(name, version, entry) { |
| 111 | if (mode === 'probe') { | 140 | if (mode === 'probe') { |
| 112 | if (Number(process.versions.node.split('.')[0]) < 22) throw Error('Node 22 or newer is required on the device host.'); | 141 | if (Number(process.versions.node.split('.')[0]) < 22) throw Error('Node 22 or newer is required on the device host.'); |
| 113 | if (run('npm', ['--version']).status !== 0) throw Error('npm is missing from the non-interactive SSH PATH.'); | 142 | if (run('npm', ['--version']).status !== 0) throw Error('npm is missing from the non-interactive SSH PATH.'); |
| 114 | console.log(JSON.stringify({ nodePath: process.execPath, platforms })); return; | 143 | console.log(JSON.stringify({ nodePath: process.execPath, platforms, tools: versions() })); return; |
| 115 | } | 144 | } |
| 116 | fs.mkdirSync(state, { recursive: true, mode: 0o700 }); | 145 | fs.mkdirSync(state, { recursive: true, mode: 0o700 }); |
| 117 | // Serialize starts and stops for this environment/host owner, including agent startup. | 146 | // Serialize starts and stops for this environment/host owner, including agent startup. |
| ... | @@ -185,7 +214,7 @@ async function install(name, version, entry) { | ... | @@ -185,7 +214,7 @@ async function install(name, version, entry) { |
| 185 | } | 214 | } |
| 186 | const vendor = path.resolve(path.dirname(hubEntry), '../../vendor/serve-sim/dist'); | 215 | const vendor = path.resolve(path.dirname(hubEntry), '../../vendor/serve-sim/dist'); |
| 187 | const optional = file => fs.existsSync(file) ? file : null; | 216 | const optional = file => fs.existsSync(file) ? file : null; |
| 188 | console.log(JSON.stringify({ nodePath: process.execPath, platforms, hubPort: hub.port, ...agentResult, | 217 | console.log(JSON.stringify({ nodePath: process.execPath, platforms, tools: versions(), hubPort: hub.port, ...agentResult, |
| 189 | helpers: { serveSimAxSettings: optional(path.join(vendor, 'simax/serve-sim-ax-settings')), serveSimCli: optional(path.join(vendor, 'serve-sim.js')) } })); | 218 | helpers: { serveSimAxSettings: optional(path.join(vendor, 'simax/serve-sim-ax-settings')), serveSimCli: optional(path.join(vendor, 'serve-sim.js')) } })); |
| 190 | } finally { releaseHost(); } | 219 | } finally { releaseHost(); } |
| 191 | })().catch(error => { console.error(error.message); process.exitCode = 1; }); | 220 | })().catch(error => { console.error(error.message); process.exitCode = 1; }); |
apps/web/src/components/device/DeviceToolVersions.tsx created+71| ... | @@ -0,0 +1,71 @@ | ||
| 1 | import type { DeviceToolVersions as ToolVersions } from "@t3tools/contracts"; | ||
| 2 | import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "~/components/ui/popover"; | ||
| 3 | |||
| 4 | export function DeviceToolVersions({ | ||
| 5 | tools, | ||
| 6 | kind, | ||
| 7 | }: { | ||
| 8 | tools: ToolVersions | undefined; | ||
| 9 | kind?: keyof ToolVersions; | ||
| 10 | }) { | ||
| 11 | const selected = kind ? tools?.[kind] : undefined; | ||
| 12 | const version = | ||
| 13 | selected?.runningVersion ?? | ||
| 14 | (selected?.installedVersions.includes(selected.requiredVersion) | ||
| 15 | ? selected.requiredVersion | ||
| 16 | : selected?.installedVersions | ||
| 17 | .toSorted((a, b) => a.localeCompare(b, undefined, { numeric: true })) | ||
| 18 | .at(-1)); | ||
| 19 | const label = kind === "hub" ? "Device hub" : "Agent device"; | ||
| 20 | return ( | ||
| 21 | <Popover> | ||
| 22 | <PopoverTrigger | ||
| 23 | aria-label={ | ||
| 24 | kind | ||
| 25 | ? `${label}: ${version ? `version ${version}` : selected ? "not installed" : "version unknown"}. Show details` | ||
| 26 | : undefined | ||
| 27 | } | ||
| 28 | className="rounded text-xs text-muted-foreground underline decoration-muted-foreground/40 underline-offset-4 hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring" | ||
| 29 | > | ||
| 30 | {kind | ||
| 31 | ? version | ||
| 32 | ? `v${version}` | ||
| 33 | : selected | ||
| 34 | ? "Not installed" | ||
| 35 | : "Version unknown" | ||
| 36 | : "Versions"} | ||
| 37 | </PopoverTrigger> | ||
| 38 | <PopoverPopup align="end" className="w-80"> | ||
| 39 | <PopoverTitle className="text-sm">{kind ? label : "Device tools"}</PopoverTitle> | ||
| 40 | {tools ? ( | ||
| 41 | <div className="mt-4 divide-y divide-border/50"> | ||
| 42 | {( | ||
| 43 | [ | ||
| 44 | ["Device hub", tools.hub], | ||
| 45 | ["Agent device", tools.agent], | ||
| 46 | ] as const | ||
| 47 | ) | ||
| 48 | .filter(([name]) => !kind || name === label) | ||
| 49 | .map(([name, tool]) => ( | ||
| 50 | <div key={name} className="space-y-2 py-3 first:pt-0 last:pb-0"> | ||
| 51 | <p className="text-xs font-medium">{name}</p> | ||
| 52 | <dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1 text-xs"> | ||
| 53 | <dt className="text-muted-foreground">Running</dt> | ||
| 54 | <dd className="text-right font-mono">{tool.runningVersion ?? "Not running"}</dd> | ||
| 55 | <dt className="text-muted-foreground">Required</dt> | ||
| 56 | <dd className="text-right font-mono">{tool.requiredVersion}</dd> | ||
| 57 | <dt className="text-muted-foreground">Installed</dt> | ||
| 58 | <dd className="text-right font-mono break-words"> | ||
| 59 | {tool.installedVersions.join(", ") || "None"} | ||
| 60 | </dd> | ||
| 61 | </dl> | ||
| 62 | </div> | ||
| 63 | ))} | ||
| 64 | </div> | ||
| 65 | ) : ( | ||
| 66 | <p className="mt-3 text-xs text-muted-foreground">Versions have not been checked.</p> | ||
| 67 | )} | ||
| 68 | </PopoverPopup> | ||
| 69 | </Popover> | ||
| 70 | ); | ||
| 71 | } | ||
apps/web/src/components/settings/DeviceHostsSettings.tsx+7| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { DeviceToolVersions } from "../device/DeviceToolVersions"; | ||
| 1 | import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; | 2 | import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; |
| 2 | import { AppleIcon, AndroidIcon } from "../Icons"; | 3 | import { AppleIcon, AndroidIcon } from "../Icons"; |
| 3 | import { Spinner } from "../ui/spinner"; | 4 | import { Spinner } from "../ui/spinner"; |
| ... | @@ -236,6 +237,12 @@ function DeviceHostList({ | ... | @@ -236,6 +237,12 @@ function DeviceHostList({ |
| 236 | ))} | 237 | ))} |
| 237 | </div> | 238 | </div> |
| 238 | <p className="truncate text-xs text-muted-foreground">{host.target}</p> | 239 | <p className="truncate text-xs text-muted-foreground">{host.target}</p> |
| 240 | <DeviceToolVersions | ||
| 241 | tools={ | ||
| 242 | (check?.status === "connected" ? check.tools : undefined) ?? | ||
| 243 | state.hosts.find((value) => value.id === host.id)?.tools | ||
| 244 | } | ||
| 245 | /> | ||
| 239 | {check?.status === "local" ? ( | 246 | {check?.status === "local" ? ( |
| 240 | <p className="mt-1 text-xs text-muted-foreground">Already available locally</p> | 247 | <p className="mt-1 text-xs text-muted-foreground">Already available locally</p> |
| 241 | ) : null} | 248 | ) : null} |
apps/web/src/components/settings/IntegrationsSettings.tsx+9| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { DeviceToolVersions } from "../device/DeviceToolVersions"; | ||
| 1 | import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; | 2 | import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; |
| 2 | import { ScopedSwitch } from "./ScopedSwitch"; | 3 | import { ScopedSwitch } from "./ScopedSwitch"; |
| 3 | import { DeviceHostsSettings } from "./DeviceHostsSettings"; | 4 | import { DeviceHostsSettings } from "./DeviceHostsSettings"; |
| ... | @@ -692,6 +693,10 @@ function DeviceIntegrationControls({ | ... | @@ -692,6 +693,10 @@ function DeviceIntegrationControls({ |
| 692 | description={deviceHubDescription} | 693 | description={deviceHubDescription} |
| 693 | control={ | 694 | control={ |
| 694 | <> | 695 | <> |
| 696 | <DeviceToolVersions | ||
| 697 | kind="hub" | ||
| 698 | tools={state.hosts.find((host) => host.kind === "local")?.tools} | ||
| 699 | /> | ||
| 695 | {pending === "hub" ? <DeviceHubSetupStatus state={state} pending compact /> : null} | 700 | {pending === "hub" ? <DeviceHubSetupStatus state={state} pending compact /> : null} |
| 696 | <ScopedSwitch | 701 | <ScopedSwitch |
| 697 | settingKeys={["enableDeviceSupport"]} | 702 | settingKeys={["enableDeviceSupport"]} |
| ... | @@ -751,6 +756,10 @@ function DeviceIntegrationControls({ | ... | @@ -751,6 +756,10 @@ function DeviceIntegrationControls({ |
| 751 | description={agentDeviceDescription} | 756 | description={agentDeviceDescription} |
| 752 | control={ | 757 | control={ |
| 753 | <> | 758 | <> |
| 759 | <DeviceToolVersions | ||
| 760 | kind="agent" | ||
| 761 | tools={state.hosts.find((host) => host.kind === "local")?.tools} | ||
| 762 | /> | ||
| 754 | {pending === "agent" ? <AgentDeviceSetupStatus state={state} pending compact /> : null} | 763 | {pending === "agent" ? <AgentDeviceSetupStatus state={state} pending compact /> : null} |
| 755 | <ScopedSwitch | 764 | <ScopedSwitch |
| 756 | settingKeys={["enableAgentDeviceAccess"]} | 765 | settingKeys={["enableAgentDeviceAccess"]} |
apps/web/src/components/settings/deviceHostConnectionChecks.ts+10-2| ... | @@ -14,7 +14,11 @@ export interface DeviceHostCheckTarget { | ... | @@ -14,7 +14,11 @@ export interface DeviceHostCheckTarget { |
| 14 | export type DeviceHostCheck = | 14 | export type DeviceHostCheck = |
| 15 | | { status: "pending" } | 15 | | { status: "pending" } |
| 16 | | { status: "local" } | 16 | | { status: "local" } |
| 17 | | { status: "connected"; platforms: ReadonlyArray<DevicePlatformAvailability> } | 17 | | { |
| 18 | status: "connected"; | ||
| 19 | platforms: ReadonlyArray<DevicePlatformAvailability>; | ||
| 20 | tools?: DeviceHostSummary["tools"]; | ||
| 21 | } | ||
| 18 | | { status: "failed"; error: string }; | 22 | | { status: "failed"; error: string }; |
| 19 | 23 | ||
| 20 | const decodeDeviceHostDraft = Schema.decodeUnknownOption(SshDeviceHostConfig); | 24 | const decodeDeviceHostDraft = Schema.decodeUnknownOption(SshDeviceHostConfig); |
| ... | @@ -48,7 +52,11 @@ export async function checkDeviceHostConnections( | ... | @@ -48,7 +52,11 @@ export async function checkDeviceHostConnections( |
| 48 | target.environmentId, | 52 | target.environmentId, |
| 49 | result.kind === "local" | 53 | result.kind === "local" |
| 50 | ? { status: "local" } | 54 | ? { status: "local" } |
| 51 | : { status: "connected", platforms: result.platforms }, | 55 | : { |
| 56 | status: "connected", | ||
| 57 | platforms: result.platforms, | ||
| 58 | ...(result.tools ? { tools: result.tools } : {}), | ||
| 59 | }, | ||
| 52 | ); | 60 | ); |
| 53 | } catch (error) { | 61 | } catch (error) { |
| 54 | report(target.environmentId, { | 62 | report(target.environmentId, { |
packages/client-runtime/src/state/device.ts+15-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { WS_METHODS } from "@t3tools/contracts"; | 1 | import { type DeviceToolVersions, WS_METHODS } from "@t3tools/contracts"; |
| 2 | import { Atom } from "effect/unstable/reactivity"; | 2 | import { Atom } from "effect/unstable/reactivity"; |
| 3 | 3 | ||
| 4 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | 4 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| ... | @@ -70,3 +70,17 @@ export function createDeviceEnvironmentAtoms<R, E>( | ... | @@ -70,3 +70,17 @@ export function createDeviceEnvironmentAtoms<R, E>( |
| 70 | }), | 70 | }), |
| 71 | }; | 71 | }; |
| 72 | } | 72 | } |
| 73 | |||
| 74 | /** Unknown inventory is distinct from a completed check that found no install. */ | ||
| 75 | export function deviceToolVersionLabels(tools: DeviceToolVersions | undefined) { | ||
| 76 | if (!tools) return ["Device tool versions have not been checked."]; | ||
| 77 | return ( | ||
| 78 | [ | ||
| 79 | ["Device hub", tools.hub], | ||
| 80 | ["Agent tools", tools.agent], | ||
| 81 | ] as const | ||
| 82 | ).map(([name, tool]) => { | ||
| 83 | const installed = tool.installedVersions.length ? tool.installedVersions.join(", ") : "none"; | ||
| 84 | return `${name}: installed ${installed}; required ${tool.requiredVersion}${tool.runningVersion ? `; running ${tool.runningVersion}` : ""}.`; | ||
| 85 | }); | ||
| 86 | } |
packages/contracts/src/device.ts+14| ... | @@ -74,11 +74,25 @@ export const DevicePlatformAvailability = Schema.Struct({ | ... | @@ -74,11 +74,25 @@ export const DevicePlatformAvailability = Schema.Struct({ |
| 74 | }); | 74 | }); |
| 75 | export type DevicePlatformAvailability = typeof DevicePlatformAvailability.Type; | 75 | export type DevicePlatformAvailability = typeof DevicePlatformAvailability.Type; |
| 76 | 76 | ||
| 77 | export const DeviceToolVersion = Schema.Struct({ | ||
| 78 | requiredVersion: Schema.String, | ||
| 79 | installedVersions: Schema.Array(Schema.String), | ||
| 80 | runningVersion: Schema.NullOr(Schema.String), | ||
| 81 | }); | ||
| 82 | export type DeviceToolVersion = typeof DeviceToolVersion.Type; | ||
| 83 | |||
| 84 | export const DeviceToolVersions = Schema.Struct({ | ||
| 85 | hub: DeviceToolVersion, | ||
| 86 | agent: DeviceToolVersion, | ||
| 87 | }); | ||
| 88 | export type DeviceToolVersions = typeof DeviceToolVersions.Type; | ||
| 89 | |||
| 77 | export const DeviceHostSummary = Schema.Struct({ | 90 | export const DeviceHostSummary = Schema.Struct({ |
| 78 | id: DeviceHostId, | 91 | id: DeviceHostId, |
| 79 | kind: Schema.Literals(["local", "ssh"]), | 92 | kind: Schema.Literals(["local", "ssh"]), |
| 80 | label: TrimmedNonEmptyString, | 93 | label: TrimmedNonEmptyString, |
| 81 | platforms: Schema.Array(DevicePlatformAvailability), | 94 | platforms: Schema.Array(DevicePlatformAvailability), |
| 95 | tools: Schema.optional(DeviceToolVersions), | ||
| 82 | hubInstalled: Schema.Boolean, | 96 | hubInstalled: Schema.Boolean, |
| 83 | agentDeviceInstalled: Schema.Boolean, | 97 | agentDeviceInstalled: Schema.Boolean, |
| 84 | }); | 98 | }); |