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..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"; @@ -6,7 +7,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 +43,57 @@ 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)), +); + +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 0493902302b3..81cdb79217fe 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,46 @@ 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.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) + ? 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 { + requiredVersion: spec.version, + installedVersions: versions.sort(), + runningVersion: (spec.name === DEVICE_HUB_PACKAGE ? running.hub : running.agent) ?? null, + }; + }); + 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 b7341e04f8ad..e84851d0722b 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"; @@ -50,6 +51,8 @@ import { ensureDeviceHub, isAgentDeviceInstalled, isDeviceHubInstalled, + deviceToolVersions, + DEVICE_HUB_VERSION, } from "./DeviceToolchain.ts"; const HUB_READY_TIMEOUT_MS = 30_000; @@ -76,6 +79,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 +236,26 @@ 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 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, { + ...(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), + ); return { + tools, id: hostId, kind: "local", label: "This machine", 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 4ccda0fdefe9..dadb38a02e6c 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({ @@ -70,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) => @@ -82,8 +99,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; }); @@ -106,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); @@ -183,6 +201,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..89021d8c0474 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -83,15 +83,16 @@ 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; 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 +108,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")); @@ -114,7 +120,7 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr 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 }); @@ -135,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 bbdb828c1a74..4c156870a4f0 100644 --- a/apps/server/src/device/sshDeviceScript.ts +++ b/apps/server/src/device/sshDeviceScript.ts @@ -39,6 +39,35 @@ 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 (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 (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 || ''; + 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 }; +}; +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 || ''; @@ -111,7 +140,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 +214,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..2ed11f7d9204 --- /dev/null +++ b/apps/web/src/components/device/DeviceToolVersions.tsx @@ -0,0 +1,71 @@ +import type { DeviceToolVersions as ToolVersions } from "@t3tools/contracts"; +import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "~/components/ui/popover"; + +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 ( + + + {kind + ? version + ? `v${version}` + : selected + ? "Not installed" + : "Version unknown" + : "Versions"} + + + {kind ? label : "Device tools"} + {tools ? ( +
+ {( + [ + ["Device hub", tools.hub], + ["Agent device", tools.agent], + ] as const + ) + .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/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..70c67853e903 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"; @@ -692,6 +693,10 @@ function DeviceIntegrationControls({ description={deviceHubDescription} control={ <> + host.kind === "local")?.tools} + /> {pending === "hub" ? : null} + host.kind === "local")?.tools} + /> {pending === "agent" ? : null} } + | { + 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, });