1import type { DeviceToolVersions } from "@t3tools/contracts";
2/**
3 * Pinned installs of the two external tools device support is built on.
4 *
5 * `expo-device-hub` streams simulator and emulator screens and `agent-device`
6 * drives them. Each is npm-installed separately after its matching consent
7 * step into `<baseDir>/tools/<name>/<version>` and executed from there with the
8 * resolved Node runtime, never `npx`: an ephemeral
9 * npx cache would make every first `device_open` after a reboot depend on the
10 * registry, and the pinned versions are part of the contract the injected
11 * agent instructions describe.
12 *
13 * Install follows the pinned-runtime recipe: stage into a temp sibling, write a
14 * sentinel only after npm exits 0, then rename into place. npm extracts files
15 * before it finishes, so an entry file alone does not prove a usable tree.
16 */
17import * as Duration from "effect/Duration";
18import * as Effect from "effect/Effect";
19import * as FileSystem from "effect/FileSystem";
20import * as Option from "effect/Option";
21import * as Path from "effect/Path";
22import * as PlatformError from "effect/PlatformError";
23import * as Schema from "effect/Schema";
24import * as Semaphore from "effect/Semaphore";
25
26import * as ProcessRunner from "../processRunner.ts";
27import DEVICE_TOOL_VERSIONS from "./deviceToolVersions.json" with { type: "json" };
28
29const DEVICE_HUB_PACKAGE = "expo-device-hub";
30export const DEVICE_HUB_VERSION = DEVICE_TOOL_VERSIONS[DEVICE_HUB_PACKAGE];
31const AGENT_DEVICE_PACKAGE = "agent-device";
32export const AGENT_DEVICE_VERSION = DEVICE_TOOL_VERSIONS[AGENT_DEVICE_PACKAGE];
33
34const INSTALL_TIMEOUT = Duration.minutes(10);
35const installLock = Semaphore.makeUnsafe(1);
36
37export interface DeviceToolPaths {
38 readonly installDir: string;
39 /** Absolute path of the tool's entry script, run with a resolved Node runtime. */
40 readonly entryPath: string;
41 readonly sentinelPath: string;
42}
43
44export interface DeviceToolchainPaths {
45 readonly hub: DeviceToolPaths;
46 readonly agentDevice: DeviceToolPaths;
47}
48
49export class DeviceToolchainInstallError extends Schema.TaggedError<DeviceToolchainInstallError>()(
50 "DeviceToolchainInstallError",
51 {
52 tool: Schema.String,
53 step: Schema.String,
54 exitCode: Schema.optional(Schema.Number),
55 cause: Schema.optional(Schema.Defect()),
56 },
57) {
58 override get message(): string {
59 const suffix = this.exitCode === undefined ? "" : ` (exit code ${this.exitCode})`;
60 return `Installing ${this.tool} failed while ${this.step}${suffix}.`;
61 }
62}
63
64interface ToolSpec {
65 readonly name: string;
66 readonly version: string;
67 readonly entry: ReadonlyArray<string>;
68}
69
70const HUB_SPEC: ToolSpec = {
71 name: DEVICE_HUB_PACKAGE,
72 version: DEVICE_HUB_VERSION,
73 entry: ["dist", "server", "cli.mjs"],
74};
75
76const AGENT_DEVICE_SPEC: ToolSpec = {
77 name: AGENT_DEVICE_PACKAGE,
78 version: AGENT_DEVICE_VERSION,
79 entry: ["bin", "agent-device.mjs"],
80};
81
82const toolPaths = (path: Path.Path, baseDir: string, spec: ToolSpec): DeviceToolPaths => {
83 const installDir = path.join(baseDir, "tools", spec.name, spec.version);
84 return {
85 installDir,
86 entryPath: path.join(installDir, "node_modules", spec.name, ...spec.entry),
87 sentinelPath: path.join(installDir, ".install-complete"),
88 };
89};
90
91const deviceToolchainPaths = (path: Path.Path, baseDir: string): DeviceToolchainPaths => ({
92 hub: toolPaths(path, baseDir, HUB_SPEC),
93 agentDevice: toolPaths(path, baseDir, AGENT_DEVICE_SPEC),
94});
95
96/** Keep daemon state (daemon.json, sessions) in userdata, separate from tool installs. */
97export const agentDeviceStateDir = (path: Path.Path, stateDir: string): string =>
98 path.join(stateDir, "device", "agent-device");
99
100const isInstalled = Effect.fn("DeviceToolchain.isInstalled")(function* (
101 fs: FileSystem.FileSystem,
102 paths: DeviceToolPaths,
103 version: string,
104) {
105 const [entryExists, sentinel] = yield* Effect.all([
106 fs.exists(paths.entryPath),
107 fs.readFileString(paths.sentinelPath).pipe(Effect.option),
108 ]).pipe(Effect.orElseSucceed(() => [false, Option.none<string>()] as const));
109 return entryExists && Option.isSome(sentinel) && sentinel.value.trim() === version;
110});
111
112const installTool = Effect.fn("DeviceToolchain.installTool")(function* (
113 spec: ToolSpec,
114 paths: DeviceToolPaths,
115) {
116 const fs = yield* FileSystem.FileSystem;
117 const path = yield* Path.Path;
118 const runner = yield* ProcessRunner.ProcessRunner;
119 const fail = (step: string) => (cause: unknown) =>
120 new DeviceToolchainInstallError({ tool: spec.name, step, cause });
121
122 if (yield* isInstalled(fs, paths, spec.version)) return paths;
123
124 const parentDir = path.dirname(paths.installDir);
125 yield* fs
126 .remove(paths.installDir, { recursive: true, force: true })
127 .pipe(Effect.mapError(fail("removing an incomplete install")));
128 yield* fs
129 .makeDirectory(parentDir, { recursive: true })
130 .pipe(Effect.mapError(fail("preparing the install directory")));
131 const stagingDir = yield* fs
132 .makeTempDirectory({ directory: parentDir, prefix: ".staging-" })
133 .pipe(Effect.mapError(fail("preparing the install directory")));
134
135 return yield* Effect.gen(function* () {
136 const installArgs = [
137 "install",
138 "--prefix",
139 stagingDir,
140 "--no-fund",
141 "--no-audit",
142 `${spec.name}@${spec.version}`,
143 ];
144 const result = yield* runner
145 .run({ command: "npm", args: installArgs, timeout: INSTALL_TIMEOUT })
146 .pipe(
147 Effect.catchTags({
148 ProcessSpawnError: (error) =>
149 error.cause instanceof PlatformError.PlatformError &&
150 error.cause.reason._tag === "NotFound"
151 ? runner.run({
152 command: "pnpm",
153 args: ["--package=npm@11", "dlx", "npm", ...installArgs],
154 timeout: INSTALL_TIMEOUT,
155 })
156 : Effect.fail(error),
157 }),
158 Effect.mapError(fail("running npm install")),
159 );
160 if (result.code !== 0) {
161 return yield* new DeviceToolchainInstallError({
162 tool: spec.name,
163 step: "running npm install",
164 exitCode: Number(result.code),
165 cause: result,
166 });
167 }
168 const stagedEntry = path.join(stagingDir, "node_modules", spec.name, ...spec.entry);
169 if (!(yield* fs.exists(stagedEntry).pipe(Effect.orElseSucceed(() => false)))) {
170 return yield* new DeviceToolchainInstallError({
171 tool: spec.name,
172 step: "verifying the installed entry point",
173 });
174 }
175 yield* fs
176 .writeFileString(path.join(stagingDir, ".install-complete"), `${spec.version}\n`)
177 .pipe(Effect.mapError(fail("recording the completed install")));
178 yield* fs.rename(stagingDir, paths.installDir).pipe(
179 Effect.catch((cause) =>
180 // A concurrent server may have published the same version first.
181 isInstalled(fs, paths, spec.version).pipe(
182 Effect.flatMap((published) =>
183 published ? Effect.void : Effect.fail(fail("publishing the install")(cause)),
184 ),
185 ),
186 ),
187 );
188 return paths;
189 }).pipe(
190 Effect.ensuring(fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore)),
191 );
192});
193
194const ensureTool = Effect.fn("DeviceToolchain.ensureTool")(function* (
195 baseDir: string,
196 spec: ToolSpec,
197 select: (paths: DeviceToolchainPaths) => DeviceToolPaths,
198) {
199 const path = yield* Path.Path;
200 const paths = deviceToolchainPaths(path, baseDir);
201 return yield* installLock.withPermit(installTool(spec, select(paths)));
202});
203
204export const ensureDeviceHub = (baseDir: string) =>
205 ensureTool(baseDir, HUB_SPEC, (paths) => paths.hub);
206
207export const ensureAgentDevice = (baseDir: string) =>
208 ensureTool(baseDir, AGENT_DEVICE_SPEC, (paths) => paths.agentDevice);
209
210const isToolInstalled = Effect.fn("DeviceToolchain.isToolInstalled")(function* (
211 baseDir: string,
212 spec: ToolSpec,
213 select: (paths: DeviceToolchainPaths) => DeviceToolPaths,
214) {
215 const fs = yield* FileSystem.FileSystem;
216 const path = yield* Path.Path;
217 const paths = deviceToolchainPaths(path, baseDir);
218 return yield* isInstalled(fs, select(paths), spec.version);
219});
220
221export const isDeviceHubInstalled = (baseDir: string) =>
222 isToolInstalled(baseDir, HUB_SPEC, (paths) => paths.hub);
223
224export const isAgentDeviceInstalled = (baseDir: string) =>
225 isToolInstalled(baseDir, AGENT_DEVICE_SPEC, (paths) => paths.agentDevice);
226
227/** Read completed installs without downloading or starting either tool. */
228export const deviceToolVersions = Effect.fn("DeviceToolchain.versions")(function* (
229 baseDir: string,
230 running: { hub?: string; agent?: string } = {},
231) {
232 const fs = yield* FileSystem.FileSystem;
233 const path = yield* Path.Path;
234 const inspect = Effect.fn("DeviceToolchain.inspect")(function* (spec: ToolSpec) {
235 const directory = path.join(baseDir, "tools", spec.name);
236 const names = yield* fs.readDirectory(directory).pipe(
237 Effect.catchIf(
238 (error) => error.reason._tag === "NotFound",
239 () => Effect.succeed([]),
240 ),
241 );
242 const versions = yield* Effect.filter(names, (version) =>
243 /^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version)
244 ? Effect.gen(function* () {
245 const paths = toolPaths(path, baseDir, { ...spec, version });
246 const sentinel = yield* fs.readFileString(paths.sentinelPath).pipe(
247 Effect.catchIf(
248 (error) => error.reason._tag === "NotFound",
249 () => Effect.succeed(null),
250 ),
251 );
252 return sentinel?.trim() === version && (yield* fs.exists(paths.entryPath));
253 })
254 : Effect.succeed(false),
255 );
256 return {
257 requiredVersion: spec.version,
258 installedVersions: versions.sort(),
259 runningVersion: (spec.name === DEVICE_HUB_PACKAGE ? running.hub : running.agent) ?? null,
260 };
261 });
262 return yield* Effect.gen(function* () {
263 return {
264 hub: yield* inspect(HUB_SPEC),
265 agent: yield* inspect(AGENT_DEVICE_SPEC),
266 } satisfies DeviceToolVersions;
267 }).pipe(Effect.orElseSucceed(() => undefined));
268});