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 @@
1import { deviceToolVersionLabels } from "@t3tools/client-runtime/state/device";
12import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";
23import { createNativeStackNavigator } from "@react-navigation/native-stack";
34import { EnvironmentId, ThreadId } from "@t3tools/contracts";
......@@ -116,6 +117,18 @@ function DevicePreviewScreen({
116117 };
117118
118119 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 },
119132 {
120133 id: "reload",
121134 title: "Reload stream",
apps/server/src/device/DeviceToolchain.test.ts+61-1
......@@ -1,3 +1,4 @@
1import * as PlatformError from "effect/PlatformError";
12import { expect, it } from "@effect/vitest";
23import * as NodeServices from "@effect/platform-node/NodeServices";
34import * as Effect from "effect/Effect";
......@@ -6,7 +7,12 @@ import * as Path from "effect/Path";
67import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
78
89import * 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
1117it.effect("failed installation cleans staging and exposes only a safe failure message", () =>
1218 Effect.gen(function* () {
......@@ -37,3 +43,57 @@ it.effect("failed installation cleans staging and exposes only a safe failure me
3743 expect(yield* fs.readDirectory(path.join(baseDir, "tools", "expo-device-hub"))).toEqual([]);
3844 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
3945);
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 @@
1import type { DeviceToolVersions } from "@t3tools/contracts";
12/**
23 * Pinned installs of the two external tools device support is built on.
34 *
......@@ -221,3 +222,46 @@ export const isDeviceHubInstalled = (baseDir: string) =>
221222
222223export const isAgentDeviceInstalled = (baseDir: string) =>
223224 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";
3131import * as FileSystem from "effect/FileSystem";
3232import * as Layer from "effect/Layer";
3333import * as Path from "effect/Path";
34import * as Option from "effect/Option";
3435import * as Ref from "effect/Ref";
3536import * as Schema from "effect/Schema";
3637import * as Scope from "effect/Scope";
......@@ -50,6 +51,8 @@ import {
5051 ensureDeviceHub,
5152 isAgentDeviceInstalled,
5253 isDeviceHubInstalled,
54 deviceToolVersions,
55 DEVICE_HUB_VERSION,
5356} from "./DeviceToolchain.ts";
5457
5558const HUB_READY_TIMEOUT_MS = 30_000;
......@@ -76,6 +79,7 @@ const AgentDeviceDaemonFile = Schema.Struct({
7679 httpPort: Schema.Int,
7780 token: Schema.String,
7881 pid: Schema.optional(Schema.Int),
82 version: Schema.optional(Schema.String),
7983});
8084const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile));
8185
......@@ -232,7 +236,26 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
232236 Effect.provideService(Path.Path, path),
233237 ),
234238 ]);
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 );
235257 return {
258 tools,
236259 id: hostId,
237260 kind: "local",
238261 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
2020 const fs = yield* FileSystem.FileSystem;
2121 const home = yield* fs.makeTempDirectoryScoped();
2222 const modes: string[] = [];
23 const owners: string[] = [];
2324 let forwards = 0;
2425 let failForward = true;
2526 let rejectConfig = true;
......@@ -62,6 +63,7 @@ it.effect("preserves installed status after probes and cleans failed agent activ
6263 );
6364 const mode = /const mode = "([^"]+)"/.exec(script)?.[1] ?? "";
6465 modes.push(mode);
66 owners.push(/const owner = "([^"]+)"/.exec(script)?.[1] ?? "");
6567 output = JSON.stringify({
6668 nodePath: "/node",
6769 platforms: [{ platform: "ios", available: true }],
......@@ -110,6 +112,12 @@ it.effect("preserves installed status after probes and cleans failed agent activ
110112 ),
111113 );
112114 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}$/);
113121 expect(forwards).toBe(1);
114122 expect(modes.filter((mode) => mode === "start")).toHaveLength(2);
115123 yield* host.platformAvailability("ios");
apps/server/src/device/SshDeviceHost.ts+31-12
......@@ -2,6 +2,7 @@ import * as NodeCrypto from "node:crypto";
22import {
33 type DeviceHostSummary,
44 DevicePlatformAvailability,
5 DeviceToolVersions,
56 type SshDeviceHostConfig,
67} from "@t3tools/contracts";
78import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command";
......@@ -24,6 +25,7 @@ import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./s
2425
2526const Probe = Schema.Struct({
2627 nodePath: Schema.String,
28 tools: Schema.optional(DeviceToolVersions),
2729 platforms: Schema.Array(DevicePlatformAvailability),
2830});
2931const Started = Schema.Struct({
......@@ -70,8 +72,23 @@ const bootstrap = (
7072 ),
7173 );
7274
73export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDeviceHostConfig) {
74 const result = yield* bootstrap(config, "probe", "probe");
75const 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
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");
7592 const value = yield* decodeProbe(result.stdout.trim()).pipe(
7693 Effect.mapError(
7794 (cause) =>
......@@ -82,8 +99,11 @@ export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDevi
8299 id: config.id,
83100 label: config.label,
84101 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,
87107 platforms: value.platforms,
88108 } satisfies DeviceHostSummary;
89109});
......@@ -106,23 +126,21 @@ export const make = Effect.fn("SshDeviceHost.make")(function* (
106126 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
107127 const parentScope = yield* Scope.Scope;
108128 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);
116130 const provide = <A, E>(
117131 effect: Effect.Effect<
118132 A,
119133 E,
120 FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
134 | FileSystem.FileSystem
135 | Path.Path
136 | ChildProcessSpawner.ChildProcessSpawner
137 | ServerConfig.ServerConfig
121138 >,
122139 ) =>
123140 effect.pipe(
124141 Effect.provideService(FileSystem.FileSystem, fs),
125142 Effect.provideService(Path.Path, path),
143 Effect.provideService(ServerConfig.ServerConfig, server),
126144 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
127145 );
128146 const lock = yield* Semaphore.make(1);
......@@ -183,6 +201,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* (
183201 summary = {
184202 ...summary,
185203 platforms: remote.platforms,
204 tools: remote.tools,
186205 hubInstalled: true,
187206 agentDeviceInstalled: wantsAgent || summary.agentDeviceInstalled,
188207 };
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
8383const file=path.join(state,'daemon.json');
8484if(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 {} }
8585else 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(); }
8787`,
8888 );
89 await NodeFSP.copyFile(agent, NodePath.join(NodePath.dirname(agent), "daemon.mjs"));
8990 const nextHubVersion = DEVICE_HUB_VERSION + "-upgrade";
9091 const nextAgentVersion = AGENT_DEVICE_VERSION + "-upgrade";
9192 let invocation = 0;
9293 const invoke = async (
9394 owner: string,
94 mode: "start" | "agent-start" | "stop-agent" | "stop",
95 mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop",
9596 upgraded = false,
9697 ) => {
9798 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
107108 });
108109 return result.stdout ? JSON.parse(result.stdout) : null;
109110 };
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();
110116 const template = NodePath.join(home, "hub-template");
111117 await NodeFSP.cp(hubDir, template, { recursive: true });
112118 await NodeFSP.rm(NodePath.join(hubDir, ".install-complete"));
......@@ -114,7 +120,7 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr
114120 await NodeFSP.symlink("2147483647:exited-installer", installLock);
115121 await NodeFSP.writeFile(
116122 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});`,
118124 { mode: 0o755 },
119125 );
120126 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
135141 ]);
136142 expect(concurrentAgent.hubPort).toBe(first.hubPort);
137143 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);
138147 const second = await invoke("two", "agent-start");
139148 const reused = await invoke("one", "agent-start");
140149 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);
3939const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', timeout: 30000, ...options });
4040const read = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
4141const 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};
4271const stopHub = hub => {
4372 if (!hub || hub.owner !== owner) return;
4473 const command = run('ps', ['-p', String(hub.pid), '-o', 'command=']).stdout || '';
......@@ -111,7 +140,7 @@ async function install(name, version, entry) {
111140 if (mode === 'probe') {
112141 if (Number(process.versions.node.split('.')[0]) < 22) throw Error('Node 22 or newer is required on the device host.');
113142 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;
115144 }
116145 fs.mkdirSync(state, { recursive: true, mode: 0o700 });
117146 // Serialize starts and stops for this environment/host owner, including agent startup.
......@@ -185,7 +214,7 @@ async function install(name, version, entry) {
185214 }
186215 const vendor = path.resolve(path.dirname(hubEntry), '../../vendor/serve-sim/dist');
187216 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,
189218 helpers: { serveSimAxSettings: optional(path.join(vendor, 'simax/serve-sim-ax-settings')), serveSimCli: optional(path.join(vendor, 'serve-sim.js')) } }));
190219 } finally { releaseHost(); }
191220})().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 @@
1import { DeviceToolVersions } from "../device/DeviceToolVersions";
12import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
23import { AppleIcon, AndroidIcon } from "../Icons";
34import { Spinner } from "../ui/spinner";
......@@ -236,6 +237,12 @@ function DeviceHostList({
236237 ))}
237238 </div>
238239 <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 />
239246 {check?.status === "local" ? (
240247 <p className="mt-1 text-xs text-muted-foreground">Already available locally</p>
241248 ) : null}
apps/web/src/components/settings/IntegrationsSettings.tsx+9
......@@ -1,3 +1,4 @@
1import { DeviceToolVersions } from "../device/DeviceToolVersions";
12import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings";
23import { ScopedSwitch } from "./ScopedSwitch";
34import { DeviceHostsSettings } from "./DeviceHostsSettings";
......@@ -692,6 +693,10 @@ function DeviceIntegrationControls({
692693 description={deviceHubDescription}
693694 control={
694695 <>
696 <DeviceToolVersions
697 kind="hub"
698 tools={state.hosts.find((host) => host.kind === "local")?.tools}
699 />
695700 {pending === "hub" ? <DeviceHubSetupStatus state={state} pending compact /> : null}
696701 <ScopedSwitch
697702 settingKeys={["enableDeviceSupport"]}
......@@ -751,6 +756,10 @@ function DeviceIntegrationControls({
751756 description={agentDeviceDescription}
752757 control={
753758 <>
759 <DeviceToolVersions
760 kind="agent"
761 tools={state.hosts.find((host) => host.kind === "local")?.tools}
762 />
754763 {pending === "agent" ? <AgentDeviceSetupStatus state={state} pending compact /> : null}
755764 <ScopedSwitch
756765 settingKeys={["enableAgentDeviceAccess"]}
apps/web/src/components/settings/deviceHostConnectionChecks.ts+10-2
......@@ -14,7 +14,11 @@ export interface DeviceHostCheckTarget {
1414export type DeviceHostCheck =
1515 | { status: "pending" }
1616 | { status: "local" }
17 | { status: "connected"; platforms: ReadonlyArray<DevicePlatformAvailability> }
17 | {
18 status: "connected";
19 platforms: ReadonlyArray<DevicePlatformAvailability>;
20 tools?: DeviceHostSummary["tools"];
21 }
1822 | { status: "failed"; error: string };
1923
2024const decodeDeviceHostDraft = Schema.decodeUnknownOption(SshDeviceHostConfig);
......@@ -48,7 +52,11 @@ export async function checkDeviceHostConnections(
4852 target.environmentId,
4953 result.kind === "local"
5054 ? { status: "local" }
51 : { status: "connected", platforms: result.platforms },
55 : {
56 status: "connected",
57 platforms: result.platforms,
58 ...(result.tools ? { tools: result.tools } : {}),
59 },
5260 );
5361 } catch (error) {
5462 report(target.environmentId, {
packages/client-runtime/src/state/device.ts+15-1
......@@ -1,4 +1,4 @@
1import { WS_METHODS } from "@t3tools/contracts";
1import { type DeviceToolVersions, WS_METHODS } from "@t3tools/contracts";
22import { Atom } from "effect/unstable/reactivity";
33
44import type { EnvironmentRegistry } from "../connection/registry.ts";
......@@ -70,3 +70,17 @@ export function createDeviceEnvironmentAtoms<R, E>(
7070 }),
7171 };
7272}
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({
7474});
7575export 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
7790export const DeviceHostSummary = Schema.Struct({
7891 id: DeviceHostId,
7992 kind: Schema.Literals(["local", "ssh"]),
8093 label: TrimmedNonEmptyString,
8194 platforms: Schema.Array(DevicePlatformAvailability),
95 tools: Schema.optional(DeviceToolVersions),
8296 hubInstalled: Schema.Boolean,
8397 agentDeviceInstalled: Schema.Boolean,
8498});