From 9cce787fa90833c8b1948bc39dca6d5c91c30690 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 20 Sep 2026 17:27:29 -0700 Subject: [PATCH 1/8] feat(devices): report installed and required tool versions per host --- .../devices/DevicePreviewRouteScreen.tsx | 13 +++++++ .../server/src/device/DeviceToolchain.test.ts | 38 ++++++++++++++++++- apps/server/src/device/DeviceToolchain.ts | 28 ++++++++++++++ apps/server/src/device/LocalDeviceHost.ts | 15 ++++++++ apps/server/src/device/SshDeviceHost.ts | 10 ++++- .../server/src/device/sshDeviceScript.test.ts | 7 +++- apps/server/src/device/sshDeviceScript.ts | 25 +++++++++++- .../components/device/DeviceToolVersions.tsx | 12 ++++++ .../settings/DeviceHostsSettings.tsx | 7 ++++ .../settings/IntegrationsSettings.tsx | 8 ++++ .../settings/deviceHostConnectionChecks.ts | 12 +++++- packages/client-runtime/src/state/device.ts | 16 +++++++- packages/contracts/src/device.ts | 14 +++++++ 13 files changed, 196 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/components/device/DeviceToolVersions.tsx diff --git a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx index 824164dc2e2b..628b30cd6032 100644 --- a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx +++ b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx @@ -1,3 +1,4 @@ +import { deviceToolVersionLabels } from "@t3tools/client-runtime/state/device"; import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { createNativeStackNavigator } from "@react-navigation/native-stack"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; @@ -116,6 +117,18 @@ function DevicePreviewScreen({ }; const controls: ScreenHeaderMenuItem[] = [ + { + id: "device-tools", + title: "Device tool versions", + icon: "info.circle", + onPress: () => + Alert.alert( + "Device tool versions", + deviceToolVersionLabels( + state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools, + ).join("\n"), + ), + }, { id: "reload", title: "Reload stream", diff --git a/apps/server/src/device/DeviceToolchain.test.ts b/apps/server/src/device/DeviceToolchain.test.ts index 9f70ca91a8e1..8b8babed3269 100644 --- a/apps/server/src/device/DeviceToolchain.test.ts +++ b/apps/server/src/device/DeviceToolchain.test.ts @@ -6,7 +6,12 @@ import * as Path from "effect/Path"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ProcessRunner from "../processRunner.ts"; -import { ensureDeviceHub, isDeviceHubInstalled } from "./DeviceToolchain.ts"; +import { + deviceToolVersions, + DEVICE_HUB_VERSION, + ensureDeviceHub, + isDeviceHubInstalled, +} from "./DeviceToolchain.ts"; it.effect("failed installation cleans staging and exposes only a safe failure message", () => Effect.gen(function* () { @@ -37,3 +42,34 @@ it.effect("failed installation cleans staging and exposes only a safe failure me expect(yield* fs.readDirectory(path.join(baseDir, "tools", "expo-device-hub"))).toEqual([]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +it.effect("inventory reports only completed versions without installing the required version", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* fs.makeTempDirectoryScoped(); + for (const [version, sentinel] of [ + ["0.9.0", "0.9.0"], + [DEVICE_HUB_VERSION, "wrong"], + [".staging-123", ".staging-123"], + ]) { + const dir = path.join(base, "tools", "expo-device-hub", version!); + yield* fs.makeDirectory(path.join(dir, "node_modules/expo-device-hub/dist/server"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(dir, "node_modules/expo-device-hub/dist/server/cli.mjs"), + "", + ); + yield* fs.writeFileString(path.join(dir, ".install-complete"), sentinel!); + } + const tools = yield* deviceToolVersions(base); + expect(tools.hub).toEqual({ + requiredVersion: DEVICE_HUB_VERSION, + installedVersions: ["0.9.0"], + runningVersion: null, + }); + expect(tools.agent.installedVersions).toEqual([]); + expect(yield* isDeviceHubInstalled(base)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts index 0493902302b3..efc6b2eead8e 100644 --- a/apps/server/src/device/DeviceToolchain.ts +++ b/apps/server/src/device/DeviceToolchain.ts @@ -1,3 +1,4 @@ +import type { DeviceToolVersions } from "@t3tools/contracts"; /** * Pinned installs of the two external tools device support is built on. * @@ -221,3 +222,30 @@ export const isDeviceHubInstalled = (baseDir: string) => export const isAgentDeviceInstalled = (baseDir: string) => isToolInstalled(baseDir, AGENT_DEVICE_SPEC, (paths) => paths.agentDevice); + +/** Read completed installs without downloading or starting either tool. */ +export const deviceToolVersions = Effect.fn("DeviceToolchain.versions")(function* ( + baseDir: string, + running: { hub?: string; agent?: string } = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const inspect = Effect.fn("DeviceToolchain.inspect")(function* (spec: ToolSpec) { + const directory = path.join(baseDir, "tools", spec.name); + const names = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); + const versions = yield* Effect.filter(names, (version) => + /^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version) + ? isInstalled(fs, toolPaths(path, baseDir, { ...spec, version }), version) + : Effect.succeed(false), + ); + return { + requiredVersion: spec.version, + installedVersions: versions.sort(), + runningVersion: (spec.name === DEVICE_HUB_PACKAGE ? running.hub : running.agent) ?? null, + }; + }); + return { + hub: yield* inspect(HUB_SPEC), + agent: yield* inspect(AGENT_DEVICE_SPEC), + } satisfies DeviceToolVersions; +}); diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index b7341e04f8ad..0900bd80fa8e 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -50,6 +50,8 @@ import { ensureDeviceHub, isAgentDeviceInstalled, isDeviceHubInstalled, + deviceToolVersions, + DEVICE_HUB_VERSION, } from "./DeviceToolchain.ts"; const HUB_READY_TIMEOUT_MS = 30_000; @@ -76,6 +78,7 @@ const AgentDeviceDaemonFile = Schema.Struct({ httpPort: Schema.Int, token: Schema.String, pid: Schema.optional(Schema.Int), + version: Schema.optional(Schema.String), }); const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile)); @@ -232,7 +235,19 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { Effect.provideService(Path.Path, path), ), ]); + const running = yield* Ref.get(runningRef); + const daemon = running?.agentDevice + ? yield* readDaemonFile().pipe(Effect.option) + : Option.none(); + const tools = yield* deviceToolVersions(config.baseDir, { + ...(running ? { hub: DEVICE_HUB_VERSION } : {}), + ...(Option.isSome(daemon) && daemon.value.version ? { agent: daemon.value.version } : {}), + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); return { + tools, id: hostId, kind: "local", label: "This machine", diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts index 4ccda0fdefe9..5a42cbcbf5bb 100644 --- a/apps/server/src/device/SshDeviceHost.ts +++ b/apps/server/src/device/SshDeviceHost.ts @@ -2,6 +2,7 @@ import * as NodeCrypto from "node:crypto"; import { type DeviceHostSummary, DevicePlatformAvailability, + DeviceToolVersions, type SshDeviceHostConfig, } from "@t3tools/contracts"; import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command"; @@ -24,6 +25,7 @@ import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./s const Probe = Schema.Struct({ nodePath: Schema.String, + tools: Schema.optional(DeviceToolVersions), platforms: Schema.Array(DevicePlatformAvailability), }); const Started = Schema.Struct({ @@ -82,8 +84,11 @@ export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDevi id: config.id, label: config.label, kind: "ssh", - hubInstalled: false, - agentDeviceInstalled: false, + tools: value.tools, + hubInstalled: + value.tools?.hub.installedVersions.includes(value.tools.hub.requiredVersion) ?? false, + agentDeviceInstalled: + value.tools?.agent.installedVersions.includes(value.tools.agent.requiredVersion) ?? false, platforms: value.platforms, } satisfies DeviceHostSummary; }); @@ -183,6 +188,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( summary = { ...summary, platforms: remote.platforms, + tools: remote.tools, hubInstalled: true, agentDeviceInstalled: wantsAgent || summary.agentDeviceInstalled, }; diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index eadd85fd047d..fcd2f9087234 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -91,7 +91,7 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr let invocation = 0; const invoke = async ( owner: string, - mode: "start" | "agent-start" | "stop-agent" | "stop", + mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop", upgraded = false, ) => { const file = NodePath.join(home, `${owner}-${mode}-${invocation++}.cjs`); @@ -107,6 +107,11 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr }); return result.stdout ? JSON.parse(result.stdout) : null; }; + const inventory = await invoke("one", "probe"); + expect(inventory.tools.hub.installedVersions).toEqual([DEVICE_HUB_VERSION]); + expect(inventory.tools.hub.runningVersion).toBeNull(); + expect(inventory.tools.agent.installedVersions).toEqual([AGENT_DEVICE_VERSION]); + await expect(NodeFSP.stat(NodePath.join(root, "hosts/one/hub.json"))).rejects.toThrow(); const template = NodePath.join(home, "hub-template"); await NodeFSP.cp(hubDir, template, { recursive: true }); await NodeFSP.rm(NodePath.join(hubDir, ".install-complete")); diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts index bbdb828c1a74..c86a8f2fb4b3 100644 --- a/apps/server/src/device/sshDeviceScript.ts +++ b/apps/server/src/device/sshDeviceScript.ts @@ -39,6 +39,27 @@ const state = path.join(root, 'hosts', owner); const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', timeout: 30000, ...options }); const read = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } }; const write = (file, value) => { const tmp = file + '.' + process.pid; fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 }); fs.renameSync(tmp, file); }; +const toolVersions = (name, requiredVersion, entry, record) => { + const directory = path.join(root, 'tools'); + const prefix = name + '@'; + let names = []; + try { names = fs.readdirSync(directory); } catch {} + const installedVersions = names.filter(name => name.startsWith(prefix)).map(name => name.slice(prefix.length)).filter(version => { + if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version)) return false; + const dir = path.join(directory, prefix + version); + try { return fs.readFileSync(path.join(dir, '.install-complete'), 'utf8').trim() === version && fs.existsSync(path.join(dir, 'node_modules', name, entry)); } catch { return false; } + }).sort(); + let runningVersion = null; + if (record?.entryPath && record?.pid) { + const command = run('ps', ['-p', String(record.pid), '-o', 'command=']).stdout || ''; + if (command.includes(record.entryPath)) runningVersion = installedVersions.find(version => record.entryPath === path.join(directory, prefix + version, 'node_modules', name, entry)) ?? null; + } + return { requiredVersion, installedVersions, runningVersion }; +}; +const versions = () => ({ + hub: toolVersions('expo-device-hub', hubVersion, 'dist/server/cli.mjs', read(path.join(state, 'hub.json'))), + agent: toolVersions('agent-device', agentVersion, 'bin/agent-device.mjs', { ...read(path.join(state, 'agent.json')), ...read(path.join(state, 'daemon.json')) }), +}); const stopHub = hub => { if (!hub || hub.owner !== owner) return; const command = run('ps', ['-p', String(hub.pid), '-o', 'command=']).stdout || ''; @@ -111,7 +132,7 @@ async function install(name, version, entry) { if (mode === 'probe') { if (Number(process.versions.node.split('.')[0]) < 22) throw Error('Node 22 or newer is required on the device host.'); if (run('npm', ['--version']).status !== 0) throw Error('npm is missing from the non-interactive SSH PATH.'); - console.log(JSON.stringify({ nodePath: process.execPath, platforms })); return; + console.log(JSON.stringify({ nodePath: process.execPath, platforms, tools: versions() })); return; } fs.mkdirSync(state, { recursive: true, mode: 0o700 }); // Serialize starts and stops for this environment/host owner, including agent startup. @@ -185,7 +206,7 @@ async function install(name, version, entry) { } const vendor = path.resolve(path.dirname(hubEntry), '../../vendor/serve-sim/dist'); const optional = file => fs.existsSync(file) ? file : null; - console.log(JSON.stringify({ nodePath: process.execPath, platforms, hubPort: hub.port, ...agentResult, + console.log(JSON.stringify({ nodePath: process.execPath, platforms, tools: versions(), hubPort: hub.port, ...agentResult, helpers: { serveSimAxSettings: optional(path.join(vendor, 'simax/serve-sim-ax-settings')), serveSimCli: optional(path.join(vendor, 'serve-sim.js')) } })); } finally { releaseHost(); } })().catch(error => { console.error(error.message); process.exitCode = 1; }); diff --git a/apps/web/src/components/device/DeviceToolVersions.tsx b/apps/web/src/components/device/DeviceToolVersions.tsx new file mode 100644 index 000000000000..817862d0915e --- /dev/null +++ b/apps/web/src/components/device/DeviceToolVersions.tsx @@ -0,0 +1,12 @@ +import type { DeviceToolVersions as ToolVersions } from "@t3tools/contracts"; +import { deviceToolVersionLabels } from "@t3tools/client-runtime/state/device"; + +export function DeviceToolVersions({ tools }: { tools: ToolVersions | undefined }) { + return ( +
+ {deviceToolVersionLabels(tools).map((label) => ( +

{label}

+ ))} +
+ ); +} diff --git a/apps/web/src/components/settings/DeviceHostsSettings.tsx b/apps/web/src/components/settings/DeviceHostsSettings.tsx index 50841ee5b53a..d81ce316705b 100644 --- a/apps/web/src/components/settings/DeviceHostsSettings.tsx +++ b/apps/web/src/components/settings/DeviceHostsSettings.tsx @@ -1,3 +1,4 @@ +import { DeviceToolVersions } from "../device/DeviceToolVersions"; import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; import { AppleIcon, AndroidIcon } from "../Icons"; import { Spinner } from "../ui/spinner"; @@ -236,6 +237,12 @@ function DeviceHostList({ ))}

{host.target}

+ value.id === host.id)?.tools + } + /> {check?.status === "local" ? (

Already available locally

) : null} diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 60757724f2e4..f933abd403c4 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -1,3 +1,4 @@ +import { DeviceToolVersions } from "../device/DeviceToolVersions"; import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; import { ScopedSwitch } from "./ScopedSwitch"; import { DeviceHostsSettings } from "./DeviceHostsSettings"; @@ -775,6 +776,13 @@ function DeviceIntegrationControls({ {state.hostStatusDetail}

) : null} + host.kind === "local")?.tools} /> + } + /> ); diff --git a/apps/web/src/components/settings/deviceHostConnectionChecks.ts b/apps/web/src/components/settings/deviceHostConnectionChecks.ts index 97312b671513..0626d1854336 100644 --- a/apps/web/src/components/settings/deviceHostConnectionChecks.ts +++ b/apps/web/src/components/settings/deviceHostConnectionChecks.ts @@ -14,7 +14,11 @@ export interface DeviceHostCheckTarget { export type DeviceHostCheck = | { status: "pending" } | { status: "local" } - | { status: "connected"; platforms: ReadonlyArray } + | { + status: "connected"; + platforms: ReadonlyArray; + tools?: DeviceHostSummary["tools"]; + } | { status: "failed"; error: string }; const decodeDeviceHostDraft = Schema.decodeUnknownOption(SshDeviceHostConfig); @@ -48,7 +52,11 @@ export async function checkDeviceHostConnections( target.environmentId, result.kind === "local" ? { status: "local" } - : { status: "connected", platforms: result.platforms }, + : { + status: "connected", + platforms: result.platforms, + ...(result.tools ? { tools: result.tools } : {}), + }, ); } catch (error) { report(target.environmentId, { diff --git a/packages/client-runtime/src/state/device.ts b/packages/client-runtime/src/state/device.ts index 1e6523497ef3..5e08de264de1 100644 --- a/packages/client-runtime/src/state/device.ts +++ b/packages/client-runtime/src/state/device.ts @@ -1,4 +1,4 @@ -import { WS_METHODS } from "@t3tools/contracts"; +import { type DeviceToolVersions, WS_METHODS } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; @@ -70,3 +70,17 @@ export function createDeviceEnvironmentAtoms( }), }; } + +/** Unknown inventory is distinct from a completed check that found no install. */ +export function deviceToolVersionLabels(tools: DeviceToolVersions | undefined) { + if (!tools) return ["Device tool versions have not been checked."]; + return ( + [ + ["Device hub", tools.hub], + ["Agent tools", tools.agent], + ] as const + ).map(([name, tool]) => { + const installed = tool.installedVersions.length ? tool.installedVersions.join(", ") : "none"; + return `${name}: installed ${installed}; required ${tool.requiredVersion}${tool.runningVersion ? `; running ${tool.runningVersion}` : ""}.`; + }); +} diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index a93198a1e639..4c1b47862f37 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -74,11 +74,25 @@ export const DevicePlatformAvailability = Schema.Struct({ }); export type DevicePlatformAvailability = typeof DevicePlatformAvailability.Type; +export const DeviceToolVersion = Schema.Struct({ + requiredVersion: Schema.String, + installedVersions: Schema.Array(Schema.String), + runningVersion: Schema.NullOr(Schema.String), +}); +export type DeviceToolVersion = typeof DeviceToolVersion.Type; + +export const DeviceToolVersions = Schema.Struct({ + hub: DeviceToolVersion, + agent: DeviceToolVersion, +}); +export type DeviceToolVersions = typeof DeviceToolVersions.Type; + export const DeviceHostSummary = Schema.Struct({ id: DeviceHostId, kind: Schema.Literals(["local", "ssh"]), label: TrimmedNonEmptyString, platforms: Schema.Array(DevicePlatformAvailability), + tools: Schema.optional(DeviceToolVersions), hubInstalled: Schema.Boolean, agentDeviceInstalled: Schema.Boolean, }); From a1e26be90691a4b01730f14a8466f4704cb68ad6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 20 Sep 2026 17:29:12 -0700 Subject: [PATCH 2/8] fix(devices): import optional daemon version helpers --- apps/server/src/device/LocalDeviceHost.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index 0900bd80fa8e..5ad5db0b915d 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -31,6 +31,7 @@ import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; From 80e35d642e9dbce0a9bec285a06efe13dde1f589 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 20 Sep 2026 17:41:26 -0700 Subject: [PATCH 3/8] fix(devices): detect agent daemon version across entrypoints --- apps/server/src/device/sshDeviceScript.test.ts | 6 +++++- apps/server/src/device/sshDeviceScript.ts | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index fcd2f9087234..566c4a21a372 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -83,9 +83,10 @@ const state=process.env.AGENT_DEVICE_STATE_DIR || args[args.indexOf('--state-dir const file=path.join(state,'daemon.json'); 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 {} } 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?.();}); } -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(); } +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(); } `, ); + await NodeFSP.copyFile(agent, NodePath.join(NodePath.dirname(agent), "daemon.mjs")); const nextHubVersion = DEVICE_HUB_VERSION + "-upgrade"; const nextAgentVersion = AGENT_DEVICE_VERSION + "-upgrade"; let invocation = 0; @@ -140,6 +141,9 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr ]); expect(concurrentAgent.hubPort).toBe(first.hubPort); expect(concurrentAgent.daemonPort).toBe(first.daemonPort); + const running = await invoke("one", "probe"); + expect(running.tools.hub.runningVersion).toBe(DEVICE_HUB_VERSION); + expect(running.tools.agent.runningVersion).toBe(AGENT_DEVICE_VERSION); const second = await invoke("two", "agent-start"); const reused = await invoke("one", "agent-start"); expect(reused.hubPort).toBe(first.hubPort); diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts index c86a8f2fb4b3..e86f0413d89e 100644 --- a/apps/server/src/device/sshDeviceScript.ts +++ b/apps/server/src/device/sshDeviceScript.ts @@ -52,7 +52,10 @@ const toolVersions = (name, requiredVersion, entry, record) => { let runningVersion = null; if (record?.entryPath && record?.pid) { const command = run('ps', ['-p', String(record.pid), '-o', 'command=']).stdout || ''; - if (command.includes(record.entryPath)) runningVersion = installedVersions.find(version => record.entryPath === path.join(directory, prefix + version, 'node_modules', name, entry)) ?? null; + runningVersion = installedVersions.find(version => { + const install = path.join(directory, prefix + version); + return record.entryPath === path.join(install, 'node_modules', name, entry) && command.includes(install + path.sep); + }) ?? null; } return { requiredVersion, installedVersions, runningVersion }; }; From 95cddcaeb5d1ea983516887baa4c4823110f8ce7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 20 Sep 2026 17:46:41 -0700 Subject: [PATCH 4/8] test(devices): isolate npm version probes in SSH fixture --- apps/server/src/device/sshDeviceScript.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index 566c4a21a372..89021d8c0474 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -120,7 +120,7 @@ else { const child=spawn(process.execPath,[path.join(path.dirname(process.argv[1 await NodeFSP.symlink("2147483647:exited-installer", installLock); await NodeFSP.writeFile( NodePath.join(bin, "npm"), - `#!${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});`, + `#!${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});`, { mode: 0o755 }, ); await NodeFSP.mkdir(NodePath.join(root, "hosts/one"), { recursive: true }); From 4ec01f9a4ec8660066eac4b5f2b6730b47fe6861 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 20 Sep 2026 17:49:02 -0700 Subject: [PATCH 5/8] fix(devices): inspect the active SSH host owner --- apps/server/src/device/SshDeviceHost.test.ts | 8 +++++ apps/server/src/device/SshDeviceHost.ts | 33 ++++++++++++++------ 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/apps/server/src/device/SshDeviceHost.test.ts b/apps/server/src/device/SshDeviceHost.test.ts index d968d3383fae..71a15190ddc8 100644 --- a/apps/server/src/device/SshDeviceHost.test.ts +++ b/apps/server/src/device/SshDeviceHost.test.ts @@ -20,6 +20,7 @@ it.effect("preserves installed status after probes and cleans failed agent activ const fs = yield* FileSystem.FileSystem; const home = yield* fs.makeTempDirectoryScoped(); const modes: string[] = []; + const owners: string[] = []; let forwards = 0; let failForward = true; let rejectConfig = true; @@ -62,6 +63,7 @@ it.effect("preserves installed status after probes and cleans failed agent activ ); const mode = /const mode = "([^"]+)"/.exec(script)?.[1] ?? ""; modes.push(mode); + owners.push(/const owner = "([^"]+)"/.exec(script)?.[1] ?? ""); output = JSON.stringify({ nodePath: "/node", platforms: [{ platform: "ios", available: true }], @@ -110,6 +112,12 @@ it.effect("preserves installed status after probes and cleans failed agent activ ), ); yield* host.ensureReady(() => Effect.void); + yield* SshDeviceHost.probe({ id: "test", label: "Test", target: "test.example" }).pipe( + Effect.provide(ServerConfig.layerTest(home, home)), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + expect(new Set(owners).size).toBe(1); + expect(owners[0]).toMatch(/^[a-f0-9]{24}$/); expect(forwards).toBe(1); expect(modes.filter((mode) => mode === "start")).toHaveLength(2); yield* host.platformAvailability("ios"); diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts index 5a42cbcbf5bb..dadb38a02e6c 100644 --- a/apps/server/src/device/SshDeviceHost.ts +++ b/apps/server/src/device/SshDeviceHost.ts @@ -72,8 +72,23 @@ const bootstrap = ( ), ); -export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDeviceHostConfig) { - const result = yield* bootstrap(config, "probe", "probe"); +const ownerFor = Effect.fn("SshDeviceHost.ownerFor")(function* (hostId: string) { + const fs = yield* FileSystem.FileSystem; + const server = yield* ServerConfig.ServerConfig; + const environmentId = yield* fs + .readFileString(server.environmentIdPath) + .pipe(Effect.orElseSucceed(() => server.stateDir)); + return NodeCrypto.createHash("sha256") + .update(`${environmentId}\0${server.stateDir}\0${hostId}`) + .digest("hex") + .slice(0, 24); +}); + +export const probe = Effect.fn("SshDeviceHost.probe")(function* ( + config: SshDeviceHostConfig, + owner?: string, +) { + const result = yield* bootstrap(config, owner ?? (yield* ownerFor(config.id)), "probe"); const value = yield* decodeProbe(result.stdout.trim()).pipe( Effect.mapError( (cause) => @@ -111,23 +126,21 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const parentScope = yield* Scope.Scope; const ssh = yield* resolveSshCommand; - const environmentId = yield* fs - .readFileString(server.environmentIdPath) - .pipe(Effect.orElseSucceed(() => server.stateDir)); - const owner = NodeCrypto.createHash("sha256") - .update(`${environmentId}\0${server.stateDir}\0${config.id}`) - .digest("hex") - .slice(0, 24); + const owner = yield* ownerFor(config.id); const provide = ( effect: Effect.Effect< A, E, - FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | ServerConfig.ServerConfig >, ) => effect.pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), + Effect.provideService(ServerConfig.ServerConfig, server), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const lock = yield* Semaphore.make(1); From 785f679b9b1690f86322d037e27b70847afbabc4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 20 Sep 2026 17:51:08 -0700 Subject: [PATCH 6/8] fix(devices): distinguish unknown inventory and stopped helpers --- .../server/src/device/DeviceToolchain.test.ts | 28 +++++++++++++++++-- apps/server/src/device/DeviceToolchain.ts | 28 +++++++++++++++---- apps/server/src/device/LocalDeviceHost.ts | 11 ++++++-- apps/server/src/device/sshDeviceScript.ts | 13 ++++++--- 4 files changed, 66 insertions(+), 14 deletions(-) diff --git a/apps/server/src/device/DeviceToolchain.test.ts b/apps/server/src/device/DeviceToolchain.test.ts index 8b8babed3269..d7812a92b48e 100644 --- a/apps/server/src/device/DeviceToolchain.test.ts +++ b/apps/server/src/device/DeviceToolchain.test.ts @@ -1,3 +1,4 @@ +import * as PlatformError from "effect/PlatformError"; import { expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; @@ -64,12 +65,35 @@ it.effect("inventory reports only completed versions without installing the requ yield* fs.writeFileString(path.join(dir, ".install-complete"), sentinel!); } const tools = yield* deviceToolVersions(base); - expect(tools.hub).toEqual({ + expect(tools?.hub).toEqual({ requiredVersion: DEVICE_HUB_VERSION, installedVersions: ["0.9.0"], runningVersion: null, }); - expect(tools.agent.installedVersions).toEqual([]); + expect(tools?.agent.installedVersions).toEqual([]); expect(yield* isDeviceHubInstalled(base)).toBe(false); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +it.effect("unreadable inventory stays unknown instead of reporting no installs", () => + Effect.gen(function* () { + const tools = yield* deviceToolVersions("/unreadable"); + expect(tools).toBeUndefined(); + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readDirectory: () => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readDirectory", + description: "denied", + }), + ), + }), + ), + Effect.provide(NodeServices.layer), + ), +); diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts index efc6b2eead8e..81cdb79217fe 100644 --- a/apps/server/src/device/DeviceToolchain.ts +++ b/apps/server/src/device/DeviceToolchain.ts @@ -232,10 +232,24 @@ export const deviceToolVersions = Effect.fn("DeviceToolchain.versions")(function const path = yield* Path.Path; const inspect = Effect.fn("DeviceToolchain.inspect")(function* (spec: ToolSpec) { const directory = path.join(baseDir, "tools", spec.name); - const names = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); + const names = yield* fs.readDirectory(directory).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed([]), + ), + ); const versions = yield* Effect.filter(names, (version) => /^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version) - ? isInstalled(fs, toolPaths(path, baseDir, { ...spec, version }), version) + ? Effect.gen(function* () { + const paths = toolPaths(path, baseDir, { ...spec, version }); + const sentinel = yield* fs.readFileString(paths.sentinelPath).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed(null), + ), + ); + return sentinel?.trim() === version && (yield* fs.exists(paths.entryPath)); + }) : Effect.succeed(false), ); return { @@ -244,8 +258,10 @@ export const deviceToolVersions = Effect.fn("DeviceToolchain.versions")(function runningVersion: (spec.name === DEVICE_HUB_PACKAGE ? running.hub : running.agent) ?? null, }; }); - return { - hub: yield* inspect(HUB_SPEC), - agent: yield* inspect(AGENT_DEVICE_SPEC), - } satisfies DeviceToolVersions; + return yield* Effect.gen(function* () { + return { + hub: yield* inspect(HUB_SPEC), + agent: yield* inspect(AGENT_DEVICE_SPEC), + } satisfies DeviceToolVersions; + }).pipe(Effect.orElseSucceed(() => undefined)); }); diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index 5ad5db0b915d..e84851d0722b 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -240,9 +240,16 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const daemon = running?.agentDevice ? yield* readDaemonFile().pipe(Effect.option) : Option.none(); + const hubAlive = running + ? yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false)) + : false; + const agentAlive = + Option.isSome(daemon) && daemon.value.pid ? yield* isProcessAlive(daemon.value.pid) : false; const tools = yield* deviceToolVersions(config.baseDir, { - ...(running ? { hub: DEVICE_HUB_VERSION } : {}), - ...(Option.isSome(daemon) && daemon.value.version ? { agent: daemon.value.version } : {}), + ...(hubAlive ? { hub: DEVICE_HUB_VERSION } : {}), + ...(agentAlive && Option.isSome(daemon) && daemon.value.version + ? { agent: daemon.value.version } + : {}), }).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts index e86f0413d89e..4c156870a4f0 100644 --- a/apps/server/src/device/sshDeviceScript.ts +++ b/apps/server/src/device/sshDeviceScript.ts @@ -43,12 +43,14 @@ const toolVersions = (name, requiredVersion, entry, record) => { const directory = path.join(root, 'tools'); const prefix = name + '@'; let names = []; - try { names = fs.readdirSync(directory); } catch {} + try { names = fs.readdirSync(directory); } catch (error) { if (error.code !== 'ENOENT') return null; } + let unreadable = false; const installedVersions = names.filter(name => name.startsWith(prefix)).map(name => name.slice(prefix.length)).filter(version => { if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version)) return false; const dir = path.join(directory, prefix + version); - try { return fs.readFileSync(path.join(dir, '.install-complete'), 'utf8').trim() === version && fs.existsSync(path.join(dir, 'node_modules', name, entry)); } catch { return false; } + 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; } }).sort(); + if (unreadable) return null; let runningVersion = null; if (record?.entryPath && record?.pid) { const command = run('ps', ['-p', String(record.pid), '-o', 'command=']).stdout || ''; @@ -59,10 +61,13 @@ const toolVersions = (name, requiredVersion, entry, record) => { } return { requiredVersion, installedVersions, runningVersion }; }; -const versions = () => ({ +const versions = () => { + const result = { hub: toolVersions('expo-device-hub', hubVersion, 'dist/server/cli.mjs', read(path.join(state, 'hub.json'))), agent: toolVersions('agent-device', agentVersion, 'bin/agent-device.mjs', { ...read(path.join(state, 'agent.json')), ...read(path.join(state, 'daemon.json')) }), -}); + }; + return result.hub && result.agent ? result : undefined; +}; const stopHub = hub => { if (!hub || hub.owner !== owner) return; const command = run('ps', ['-p', String(hub.pid), '-o', 'command=']).stdout || ''; From 49a29a62dafb006dbf6962e2a5303bb33de22a6b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 20 Sep 2026 18:31:58 -0700 Subject: [PATCH 7/8] fix(web): put device version details behind a compact control --- .../components/device/DeviceToolVersions.tsx | 41 ++++++++++++++++--- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/device/DeviceToolVersions.tsx b/apps/web/src/components/device/DeviceToolVersions.tsx index 817862d0915e..b394361e1d01 100644 --- a/apps/web/src/components/device/DeviceToolVersions.tsx +++ b/apps/web/src/components/device/DeviceToolVersions.tsx @@ -1,12 +1,41 @@ import type { DeviceToolVersions as ToolVersions } from "@t3tools/contracts"; -import { deviceToolVersionLabels } from "@t3tools/client-runtime/state/device"; +import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "~/components/ui/popover"; export function DeviceToolVersions({ tools }: { tools: ToolVersions | undefined }) { return ( -
- {deviceToolVersionLabels(tools).map((label) => ( -

{label}

- ))} -
+ + + Versions + + + Device tools + {tools ? ( +
+ {( + [ + ["Device hub", tools.hub], + ["Agent device", tools.agent], + ] as const + ).map(([name, tool]) => ( +
+

{name}

+
+
Running
+
{tool.runningVersion ?? "Not running"}
+
Required
+
{tool.requiredVersion}
+
Installed
+
+ {tool.installedVersions.join(", ") || "None"} +
+
+
+ ))} +
+ ) : ( +

Versions have not been checked.

+ )} +
+
); } From 0fef40be9e1edf04b62bef556c6fc15e9276d928 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 20 Sep 2026 20:21:49 -0700 Subject: [PATCH 8/8] fix(web): show device versions on their corresponding settings rows --- .../components/device/DeviceToolVersions.tsx | 68 +++++++++++++------ .../settings/IntegrationsSettings.tsx | 15 ++-- 2 files changed, 57 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/device/DeviceToolVersions.tsx b/apps/web/src/components/device/DeviceToolVersions.tsx index b394361e1d01..2ed11f7d9204 100644 --- a/apps/web/src/components/device/DeviceToolVersions.tsx +++ b/apps/web/src/components/device/DeviceToolVersions.tsx @@ -1,14 +1,42 @@ import type { DeviceToolVersions as ToolVersions } from "@t3tools/contracts"; import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "~/components/ui/popover"; -export function DeviceToolVersions({ tools }: { tools: ToolVersions | undefined }) { +export function DeviceToolVersions({ + tools, + kind, +}: { + tools: ToolVersions | undefined; + kind?: keyof ToolVersions; +}) { + const selected = kind ? tools?.[kind] : undefined; + const version = + selected?.runningVersion ?? + (selected?.installedVersions.includes(selected.requiredVersion) + ? selected.requiredVersion + : selected?.installedVersions + .toSorted((a, b) => a.localeCompare(b, undefined, { numeric: true })) + .at(-1)); + const label = kind === "hub" ? "Device hub" : "Agent device"; return ( - - Versions + + {kind + ? version + ? `v${version}` + : selected + ? "Not installed" + : "Version unknown" + : "Versions"} - Device tools + {kind ? label : "Device tools"} {tools ? (
{( @@ -16,21 +44,23 @@ export function DeviceToolVersions({ tools }: { tools: ToolVersions | undefined ["Device hub", tools.hub], ["Agent device", tools.agent], ] as const - ).map(([name, tool]) => ( -
-

{name}

-
-
Running
-
{tool.runningVersion ?? "Not running"}
-
Required
-
{tool.requiredVersion}
-
Installed
-
- {tool.installedVersions.join(", ") || "None"} -
-
-
- ))} + ) + .filter(([name]) => !kind || name === label) + .map(([name, tool]) => ( +
+

{name}

+
+
Running
+
{tool.runningVersion ?? "Not running"}
+
Required
+
{tool.requiredVersion}
+
Installed
+
+ {tool.installedVersions.join(", ") || "None"} +
+
+
+ ))}
) : (

Versions have not been checked.

diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index f933abd403c4..70c67853e903 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -693,6 +693,10 @@ function DeviceIntegrationControls({ description={deviceHubDescription} control={ <> + host.kind === "local")?.tools} + /> {pending === "hub" ? : null} + host.kind === "local")?.tools} + /> {pending === "agent" ? : null} ) : null} - host.kind === "local")?.tools} /> - } - /> );