| 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 | import { deviceToolVersionLabels } from "@t3tools/client-runtime/state/device"; | |
| 1 | 2 | import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 2 | 3 | import { createNativeStackNavigator } from "@react-navigation/native-stack"; |
| 3 | 4 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| ... | ... | @@ -116,6 +117,18 @@ function DevicePreviewScreen({ |
| 116 | 117 | }; |
| 117 | 118 | |
| 118 | 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 | 133 | id: "reload", |
| 121 | 134 | title: "Reload stream", |
apps/server/src/device/DeviceToolchain.test.ts+61-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as PlatformError from "effect/PlatformError"; | |
| 1 | 2 | import { expect, it } from "@effect/vitest"; |
| 2 | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | 4 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -6,7 +7,12 @@ import * as Path from "effect/Path"; |
| 6 | 7 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; |
| 7 | 8 | |
| 8 | 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 | 17 | it.effect("failed installation cleans staging and exposes only a safe failure message", () => |
| 12 | 18 | Effect.gen(function* () { |
| ... | ... | @@ -37,3 +43,57 @@ it.effect("failed installation cleans staging and exposes only a safe failure me |
| 37 | 43 | expect(yield* fs.readDirectory(path.join(baseDir, "tools", "expo-device-hub"))).toEqual([]); |
| 38 | 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 | import type { DeviceToolVersions } from "@t3tools/contracts"; | |
| 1 | 2 | /** |
| 2 | 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 | 222 | |
| 222 | 223 | export const isAgentDeviceInstalled = (baseDir: string) => |
| 223 | 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 | 31 | import * as FileSystem from "effect/FileSystem"; |
| 32 | 32 | import * as Layer from "effect/Layer"; |
| 33 | 33 | import * as Path from "effect/Path"; |
| 34 | import * as Option from "effect/Option"; | |
| 34 | 35 | import * as Ref from "effect/Ref"; |
| 35 | 36 | import * as Schema from "effect/Schema"; |
| 36 | 37 | import * as Scope from "effect/Scope"; |
| ... | ... | @@ -50,6 +51,8 @@ import { |
| 50 | 51 | ensureDeviceHub, |
| 51 | 52 | isAgentDeviceInstalled, |
| 52 | 53 | isDeviceHubInstalled, |
| 54 | deviceToolVersions, | |
| 55 | DEVICE_HUB_VERSION, | |
| 53 | 56 | } from "./DeviceToolchain.ts"; |
| 54 | 57 | |
| 55 | 58 | const HUB_READY_TIMEOUT_MS = 30_000; |
| ... | ... | @@ -76,6 +79,7 @@ const AgentDeviceDaemonFile = Schema.Struct({ |
| 76 | 79 | httpPort: Schema.Int, |
| 77 | 80 | token: Schema.String, |
| 78 | 81 | pid: Schema.optional(Schema.Int), |
| 82 | version: Schema.optional(Schema.String), | |
| 79 | 83 | }); |
| 80 | 84 | const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile)); |
| 81 | 85 | |
| ... | ... | @@ -232,7 +236,26 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { |
| 232 | 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 | 257 | return { |
| 258 | tools, | |
| 236 | 259 | id: hostId, |
| 237 | 260 | kind: "local", |
| 238 | 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 | 20 | const fs = yield* FileSystem.FileSystem; |
| 21 | 21 | const home = yield* fs.makeTempDirectoryScoped(); |
| 22 | 22 | const modes: string[] = []; |
| 23 | const owners: string[] = []; | |
| 23 | 24 | let forwards = 0; |
| 24 | 25 | let failForward = true; |
| 25 | 26 | let rejectConfig = true; |
| ... | ... | @@ -62,6 +63,7 @@ it.effect("preserves installed status after probes and cleans failed agent activ |
| 62 | 63 | ); |
| 63 | 64 | const mode = /const mode = "([^"]+)"/.exec(script)?.[1] ?? ""; |
| 64 | 65 | modes.push(mode); |
| 66 | owners.push(/const owner = "([^"]+)"/.exec(script)?.[1] ?? ""); | |
| 65 | 67 | output = JSON.stringify({ |
| 66 | 68 | nodePath: "/node", |
| 67 | 69 | platforms: [{ platform: "ios", available: true }], |
| ... | ... | @@ -110,6 +112,12 @@ it.effect("preserves installed status after probes and cleans failed agent activ |
| 110 | 112 | ), |
| 111 | 113 | ); |
| 112 | 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 | 121 | expect(forwards).toBe(1); |
| 114 | 122 | expect(modes.filter((mode) => mode === "start")).toHaveLength(2); |
| 115 | 123 | yield* host.platformAvailability("ios"); |
apps/server/src/device/SshDeviceHost.ts+31-12| ... | ... | @@ -2,6 +2,7 @@ import * as NodeCrypto from "node:crypto"; |
| 2 | 2 | import { |
| 3 | 3 | type DeviceHostSummary, |
| 4 | 4 | DevicePlatformAvailability, |
| 5 | DeviceToolVersions, | |
| 5 | 6 | type SshDeviceHostConfig, |
| 6 | 7 | } from "@t3tools/contracts"; |
| 7 | 8 | import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command"; |
| ... | ... | @@ -24,6 +25,7 @@ import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./s |
| 24 | 25 | |
| 25 | 26 | const Probe = Schema.Struct({ |
| 26 | 27 | nodePath: Schema.String, |
| 28 | tools: Schema.optional(DeviceToolVersions), | |
| 27 | 29 | platforms: Schema.Array(DevicePlatformAvailability), |
| 28 | 30 | }); |
| 29 | 31 | const Started = Schema.Struct({ |
| ... | ... | @@ -70,8 +72,23 @@ const bootstrap = ( |
| 70 | 72 | ), |
| 71 | 73 | ); |
| 72 | 74 | |
| 73 | export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDeviceHostConfig) { | |
| 74 | const result = yield* bootstrap(config, "probe", "probe"); | |
| 75 | const ownerFor = Effect.fn("SshDeviceHost.ownerFor")(function* (hostId: string) { | |
| 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 | 92 | const value = yield* decodeProbe(result.stdout.trim()).pipe( |
| 76 | 93 | Effect.mapError( |
| 77 | 94 | (cause) => |
| ... | ... | @@ -82,8 +99,11 @@ export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDevi |
| 82 | 99 | id: config.id, |
| 83 | 100 | label: config.label, |
| 84 | 101 | kind: "ssh", |
| 85 | hubInstalled: false, | |
| 86 | agentDeviceInstalled: false, | |
| 102 | tools: value.tools, | |
| 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 | 107 | platforms: value.platforms, |
| 88 | 108 | } satisfies DeviceHostSummary; |
| 89 | 109 | }); |
| ... | ... | @@ -106,23 +126,21 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( |
| 106 | 126 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 107 | 127 | const parentScope = yield* Scope.Scope; |
| 108 | 128 | const ssh = yield* resolveSshCommand; |
| 109 | const environmentId = yield* fs | |
| 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); | |
| 129 | const owner = yield* ownerFor(config.id); | |
| 116 | 130 | const provide = <A, E>( |
| 117 | 131 | effect: Effect.Effect< |
| 118 | 132 | A, |
| 119 | 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 | 140 | effect.pipe( |
| 124 | 141 | Effect.provideService(FileSystem.FileSystem, fs), |
| 125 | 142 | Effect.provideService(Path.Path, path), |
| 143 | Effect.provideService(ServerConfig.ServerConfig, server), | |
| 126 | 144 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 127 | 145 | ); |
| 128 | 146 | const lock = yield* Semaphore.make(1); |
| ... | ... | @@ -183,6 +201,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( |
| 183 | 201 | summary = { |
| 184 | 202 | ...summary, |
| 185 | 203 | platforms: remote.platforms, |
| 204 | tools: remote.tools, | |
| 186 | 205 | hubInstalled: true, |
| 187 | 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 | 83 | const file=path.join(state,'daemon.json'); |
| 84 | 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 | 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 | 90 | const nextHubVersion = DEVICE_HUB_VERSION + "-upgrade"; |
| 90 | 91 | const nextAgentVersion = AGENT_DEVICE_VERSION + "-upgrade"; |
| 91 | 92 | let invocation = 0; |
| 92 | 93 | const invoke = async ( |
| 93 | 94 | owner: string, |
| 94 | mode: "start" | "agent-start" | "stop-agent" | "stop", | |
| 95 | mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop", | |
| 95 | 96 | upgraded = false, |
| 96 | 97 | ) => { |
| 97 | 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 | 108 | }); |
| 108 | 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 | 116 | const template = NodePath.join(home, "hub-template"); |
| 111 | 117 | await NodeFSP.cp(hubDir, template, { recursive: true }); |
| 112 | 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 | 120 | await NodeFSP.symlink("2147483647:exited-installer", installLock); |
| 115 | 121 | await NodeFSP.writeFile( |
| 116 | 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 | 124 | { mode: 0o755 }, |
| 119 | 125 | ); |
| 120 | 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 | 141 | ]); |
| 136 | 142 | expect(concurrentAgent.hubPort).toBe(first.hubPort); |
| 137 | 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 | 147 | const second = await invoke("two", "agent-start"); |
| 139 | 148 | const reused = await invoke("one", "agent-start"); |
| 140 | 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 | 39 | const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', timeout: 30000, ...options }); |
| 40 | 40 | const read = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } }; |
| 41 | 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 | 71 | const stopHub = hub => { |
| 43 | 72 | if (!hub || hub.owner !== owner) return; |
| 44 | 73 | const command = run('ps', ['-p', String(hub.pid), '-o', 'command=']).stdout || ''; |
| ... | ... | @@ -111,7 +140,7 @@ async function install(name, version, entry) { |
| 111 | 140 | if (mode === 'probe') { |
| 112 | 141 | if (Number(process.versions.node.split('.')[0]) < 22) throw Error('Node 22 or newer is required on the device host.'); |
| 113 | 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 | 145 | fs.mkdirSync(state, { recursive: true, mode: 0o700 }); |
| 117 | 146 | // Serialize starts and stops for this environment/host owner, including agent startup. |
| ... | ... | @@ -185,7 +214,7 @@ async function install(name, version, entry) { |
| 185 | 214 | } |
| 186 | 215 | const vendor = path.resolve(path.dirname(hubEntry), '../../vendor/serve-sim/dist'); |
| 187 | 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 | 218 | helpers: { serveSimAxSettings: optional(path.join(vendor, 'simax/serve-sim-ax-settings')), serveSimCli: optional(path.join(vendor, 'serve-sim.js')) } })); |
| 190 | 219 | } finally { releaseHost(); } |
| 191 | 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 | import { DeviceToolVersions } from "../device/DeviceToolVersions"; | |
| 1 | 2 | import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; |
| 2 | 3 | import { AppleIcon, AndroidIcon } from "../Icons"; |
| 3 | 4 | import { Spinner } from "../ui/spinner"; |
| ... | ... | @@ -236,6 +237,12 @@ function DeviceHostList({ |
| 236 | 237 | ))} |
| 237 | 238 | </div> |
| 238 | 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 | 246 | {check?.status === "local" ? ( |
| 240 | 247 | <p className="mt-1 text-xs text-muted-foreground">Already available locally</p> |
| 241 | 248 | ) : null} |
apps/web/src/components/settings/IntegrationsSettings.tsx+9| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { DeviceToolVersions } from "../device/DeviceToolVersions"; | |
| 1 | 2 | import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; |
| 2 | 3 | import { ScopedSwitch } from "./ScopedSwitch"; |
| 3 | 4 | import { DeviceHostsSettings } from "./DeviceHostsSettings"; |
| ... | ... | @@ -692,6 +693,10 @@ function DeviceIntegrationControls({ |
| 692 | 693 | description={deviceHubDescription} |
| 693 | 694 | control={ |
| 694 | 695 | <> |
| 696 | <DeviceToolVersions | |
| 697 | kind="hub" | |
| 698 | tools={state.hosts.find((host) => host.kind === "local")?.tools} | |
| 699 | /> | |
| 695 | 700 | {pending === "hub" ? <DeviceHubSetupStatus state={state} pending compact /> : null} |
| 696 | 701 | <ScopedSwitch |
| 697 | 702 | settingKeys={["enableDeviceSupport"]} |
| ... | ... | @@ -751,6 +756,10 @@ function DeviceIntegrationControls({ |
| 751 | 756 | description={agentDeviceDescription} |
| 752 | 757 | control={ |
| 753 | 758 | <> |
| 759 | <DeviceToolVersions | |
| 760 | kind="agent" | |
| 761 | tools={state.hosts.find((host) => host.kind === "local")?.tools} | |
| 762 | /> | |
| 754 | 763 | {pending === "agent" ? <AgentDeviceSetupStatus state={state} pending compact /> : null} |
| 755 | 764 | <ScopedSwitch |
| 756 | 765 | settingKeys={["enableAgentDeviceAccess"]} |
apps/web/src/components/settings/deviceHostConnectionChecks.ts+10-2| ... | ... | @@ -14,7 +14,11 @@ export interface DeviceHostCheckTarget { |
| 14 | 14 | export type DeviceHostCheck = |
| 15 | 15 | | { status: "pending" } |
| 16 | 16 | | { status: "local" } |
| 17 | | { status: "connected"; platforms: ReadonlyArray<DevicePlatformAvailability> } | |
| 17 | | { | |
| 18 | status: "connected"; | |
| 19 | platforms: ReadonlyArray<DevicePlatformAvailability>; | |
| 20 | tools?: DeviceHostSummary["tools"]; | |
| 21 | } | |
| 18 | 22 | | { status: "failed"; error: string }; |
| 19 | 23 | |
| 20 | 24 | const decodeDeviceHostDraft = Schema.decodeUnknownOption(SshDeviceHostConfig); |
| ... | ... | @@ -48,7 +52,11 @@ export async function checkDeviceHostConnections( |
| 48 | 52 | target.environmentId, |
| 49 | 53 | result.kind === "local" |
| 50 | 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 | 61 | } catch (error) { |
| 54 | 62 | report(target.environmentId, { |
packages/client-runtime/src/state/device.ts+15-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { WS_METHODS } from "@t3tools/contracts"; | |
| 1 | import { type DeviceToolVersions, WS_METHODS } from "@t3tools/contracts"; | |
| 2 | 2 | import { Atom } from "effect/unstable/reactivity"; |
| 3 | 3 | |
| 4 | 4 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| ... | ... | @@ -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 | 74 | }); |
| 75 | 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 | 90 | export const DeviceHostSummary = Schema.Struct({ |
| 78 | 91 | id: DeviceHostId, |
| 79 | 92 | kind: Schema.Literals(["local", "ssh"]), |
| 80 | 93 | label: TrimmedNonEmptyString, |
| 81 | 94 | platforms: Schema.Array(DevicePlatformAvailability), |
| 95 | tools: Schema.optional(DeviceToolVersions), | |
| 82 | 96 | hubInstalled: Schema.Boolean, |
| 83 | 97 | agentDeviceInstalled: Schema.Boolean, |
| 84 | 98 | }); |