authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-20 21:45:18-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-20 21:45:18-07:00
log5c5fa5ffb6ab45cadb6f0d873af0242f4f6d2c1c
tree4475de017a12de9cb0d2ab2b5d4cc6b821f82132
parent6cc7f7006e24283072cc3e0b3ed8ced80c6d71a5
signature Signed by PGP key B5690EEEBB952194

feat(devices): show installed and running tool versions per host (#12816)


14 files changed, 349 insertions(+), 21 deletions(-)

apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx+13
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { deviceToolVersionLabels } from "@t3tools/client-runtime/state/device";
1import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";2import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";
2import { createNativeStackNavigator } from "@react-navigation/native-stack";3import { createNativeStackNavigator } from "@react-navigation/native-stack";
3import { EnvironmentId, ThreadId } from "@t3tools/contracts";4import { EnvironmentId, ThreadId } from "@t3tools/contracts";
...@@ -116,6 +117,18 @@ function DevicePreviewScreen({...@@ -116,6 +117,18 @@ function DevicePreviewScreen({
116 };117 };
117118
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 @@
1import * as PlatformError from "effect/PlatformError";
1import { expect, it } from "@effect/vitest";2import { expect, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";4import * 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";
6import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";7import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
78
8import * as ProcessRunner from "../processRunner.ts";9import * as ProcessRunner from "../processRunner.ts";
9import { ensureDeviceHub, isDeviceHubInstalled } from "./DeviceToolchain.ts";10import {
11 deviceToolVersions,
12 DEVICE_HUB_VERSION,
13 ensureDeviceHub,
14 isDeviceHubInstalled,
15} from "./DeviceToolchain.ts";
1016
11it.effect("failed installation cleans staging and exposes only a safe failure message", () =>17it.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
47it.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
78it.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 @@
1import 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) =>
221222
222export const isAgentDeviceInstalled = (baseDir: string) =>223export 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. */
227export 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";
31import * as FileSystem from "effect/FileSystem";31import * as FileSystem from "effect/FileSystem";
32import * as Layer from "effect/Layer";32import * as Layer from "effect/Layer";
33import * as Path from "effect/Path";33import * as Path from "effect/Path";
34import * as Option from "effect/Option";
34import * as Ref from "effect/Ref";35import * as Ref from "effect/Ref";
35import * as Schema from "effect/Schema";36import * as Schema from "effect/Schema";
36import * as Scope from "effect/Scope";37import * 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";
5457
55const HUB_READY_TIMEOUT_MS = 30_000;58const 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});
80const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile));84const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile));
8185
...@@ -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";
2import {2import {
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";
7import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command";8import { 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
2425
25const Probe = Schema.Struct({26const 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});
29const Started = Schema.Struct({31const Started = Schema.Struct({
...@@ -70,8 +72,23 @@ const bootstrap = (...@@ -70,8 +72,23 @@ const bootstrap = (
70 ),72 ),
71 );73 );
7274
73export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDeviceHostConfig) {75const 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
87export 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* fs129 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.ChildProcessSpawner134 | 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
83const file=path.join(state,'daemon.json');83const file=path.join(state,'daemon.json');
84if(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 {} }84if(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 {} }
85else 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?.();}); }85else 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?.();}); }
86else { 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(); }86else { 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);
39const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', timeout: 30000, ...options });39const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', timeout: 30000, ...options });
40const read = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };40const read = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
41const write = (file, value) => { const tmp = file + '.' + process.pid; fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 }); fs.renameSync(tmp, file); };41const write = (file, value) => { const tmp = file + '.' + process.pid; fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 }); fs.renameSync(tmp, file); };
42const 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};
64const 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};
42const stopHub = hub => {71const 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 @@
1import type { DeviceToolVersions as ToolVersions } from "@t3tools/contracts";
2import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "~/components/ui/popover";
3
4export 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 @@
1import { DeviceToolVersions } from "../device/DeviceToolVersions";
1import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";2import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
2import { AppleIcon, AndroidIcon } from "../Icons";3import { AppleIcon, AndroidIcon } from "../Icons";
3import { Spinner } from "../ui/spinner";4import { 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 @@
1import { DeviceToolVersions } from "../device/DeviceToolVersions";
1import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings";2import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings";
2import { ScopedSwitch } from "./ScopedSwitch";3import { ScopedSwitch } from "./ScopedSwitch";
3import { DeviceHostsSettings } from "./DeviceHostsSettings";4import { 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 <ScopedSwitch701 <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 <ScopedSwitch764 <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 {
14export type DeviceHostCheck =14export 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 };
1923
20const decodeDeviceHostDraft = Schema.decodeUnknownOption(SshDeviceHostConfig);24const 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 @@
1import { WS_METHODS } from "@t3tools/contracts";1import { type DeviceToolVersions, WS_METHODS } from "@t3tools/contracts";
2import { Atom } from "effect/unstable/reactivity";2import { Atom } from "effect/unstable/reactivity";
33
4import type { EnvironmentRegistry } from "../connection/registry.ts";4import 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. */
75export 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});
75export type DevicePlatformAvailability = typeof DevicePlatformAvailability.Type;75export type DevicePlatformAvailability = typeof DevicePlatformAvailability.Type;
7676
77export const DeviceToolVersion = Schema.Struct({
78 requiredVersion: Schema.String,
79 installedVersions: Schema.Array(Schema.String),
80 runningVersion: Schema.NullOr(Schema.String),
81});
82export type DeviceToolVersion = typeof DeviceToolVersion.Type;
83
84export const DeviceToolVersions = Schema.Struct({
85 hub: DeviceToolVersion,
86 agent: DeviceToolVersion,
87});
88export type DeviceToolVersions = typeof DeviceToolVersions.Type;
89
77export const DeviceHostSummary = Schema.Struct({90export 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});