1import type { DeviceToolVersions } from "@t3tools/contracts";
2// npm exposes entry files before extraction completes; their presence does not prove a usable tree.
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
8import * as PlatformError from "effect/PlatformError";
9import * as Schema from "effect/Schema";
10import * as Semaphore from "effect/Semaphore";
11
12import * as ProcessRunner from "../processRunner.ts";
13import DEVICE_TOOL_VERSIONS from "./deviceToolVersions.json" with { type: "json" };
14
15const DEVICE_HUB_PACKAGE = "expo-device-hub";
16export const DEVICE_HUB_VERSION = DEVICE_TOOL_VERSIONS[DEVICE_HUB_PACKAGE];
17const AGENT_DEVICE_PACKAGE = "agent-device";
18export const AGENT_DEVICE_VERSION = DEVICE_TOOL_VERSIONS[AGENT_DEVICE_PACKAGE];
19
20const INSTALL_TIMEOUT = Duration.minutes(10);
21const installLock = Semaphore.makeUnsafe(1);
22
23export interface DeviceToolPaths {
24 readonly installDir: string;
25 readonly entryPath: string;
26 readonly sentinelPath: string;
27}
28
29export interface DeviceToolchainPaths {
30 readonly hub: DeviceToolPaths;
31 readonly agentDevice: DeviceToolPaths;
32}
33
34export class DeviceToolchainInstallError extends Schema.TaggedError<DeviceToolchainInstallError>()(
35 "DeviceToolchainInstallError",
36 {
37 tool: Schema.String,
38 step: Schema.String,
39 exitCode: Schema.optional(Schema.Number),
40 reason: Schema.optional(Schema.Literal("npm_not_found")),
41 cause: Schema.optional(Schema.Defect()),
42 },
43) {
44 override get message(): string {
45 if (this.reason === "npm_not_found")
46 return "Device support needs npm, which is not on the T3 server's PATH. Install npm (it comes with Node.js from nodejs.org), restart T3 Code, then retry.";
47 const suffix = this.exitCode === undefined ? "" : ` (exit code ${this.exitCode})`;
48 return `Installing ${this.tool} failed while ${this.step}${suffix}.`;
49 }
50}
51
52interface ToolSpec {
53 readonly name: string;
54 readonly version: string;
55 readonly entry: ReadonlyArray<string>;
56}
57
58const HUB_SPEC: ToolSpec = {
59 name: DEVICE_HUB_PACKAGE,
60 version: DEVICE_HUB_VERSION,
61 entry: ["dist", "server", "cli.mjs"],
62};
63
64const AGENT_DEVICE_SPEC: ToolSpec = {
65 name: AGENT_DEVICE_PACKAGE,
66 version: AGENT_DEVICE_VERSION,
67 entry: ["bin", "agent-device.mjs"],
68};
69
70const toolPaths = (path: Path.Path, baseDir: string, spec: ToolSpec): DeviceToolPaths => {
71 const installDir = path.join(baseDir, "tools", spec.name, spec.version);
72 return {
73 installDir,
74 entryPath: path.join(installDir, "node_modules", spec.name, ...spec.entry),
75 sentinelPath: path.join(installDir, ".install-complete"),
76 };
77};
78
79const deviceToolchainPaths = (path: Path.Path, baseDir: string): DeviceToolchainPaths => ({
80 hub: toolPaths(path, baseDir, HUB_SPEC),
81 agentDevice: toolPaths(path, baseDir, AGENT_DEVICE_SPEC),
82});
83
84export const agentDeviceStateDir = (path: Path.Path, stateDir: string): string =>
85 path.join(stateDir, "device", "agent-device");
86
87const isInstalled = Effect.fn("DeviceToolchain.isInstalled")(function* (
88 fs: FileSystem.FileSystem,
89 paths: DeviceToolPaths,
90 version: string,
91) {
92 const [entryExists, sentinel] = yield* Effect.all([
93 fs.exists(paths.entryPath),
94 fs.readFileString(paths.sentinelPath).pipe(Effect.option),
95 ]).pipe(Effect.orElseSucceed(() => [false, Option.none<string>()] as const));
96 return entryExists && Option.isSome(sentinel) && sentinel.value.trim() === version;
97});
98
99const isCommandNotFound = (error: ProcessRunner.ProcessRunError) =>
100 error._tag === "ProcessSpawnError" &&
101 error.cause instanceof PlatformError.PlatformError &&
102 error.cause.reason._tag === "NotFound";
103
104const installTool = Effect.fn("DeviceToolchain.installTool")(function* (
105 spec: ToolSpec,
106 paths: DeviceToolPaths,
107) {
108 const fs = yield* FileSystem.FileSystem;
109 const path = yield* Path.Path;
110 const runner = yield* ProcessRunner.ProcessRunner;
111 const fail = (step: string) => (cause: unknown) =>
112 new DeviceToolchainInstallError({ tool: spec.name, step, cause });
113
114 if (yield* isInstalled(fs, paths, spec.version)) return paths;
115
116 const parentDir = path.dirname(paths.installDir);
117 yield* fs
118 .remove(paths.installDir, { recursive: true, force: true })
119 .pipe(Effect.mapError(fail("removing an incomplete install")));
120 yield* fs
121 .makeDirectory(parentDir, { recursive: true })
122 .pipe(Effect.mapError(fail("preparing the install directory")));
123 const stagingDir = yield* fs
124 .makeTempDirectory({ directory: parentDir, prefix: ".staging-" })
125 .pipe(Effect.mapError(fail("preparing the install directory")));
126
127 return yield* Effect.gen(function* () {
128 const installArgs = [
129 "install",
130 "--prefix",
131 stagingDir,
132 "--no-fund",
133 "--no-audit",
134 `${spec.name}@${spec.version}`,
135 ];
136 const result = yield* runner
137 .run({ command: "npm", args: installArgs, timeout: INSTALL_TIMEOUT })
138 .pipe(
139 Effect.catchTags({
140 ProcessSpawnError: (error) =>
141 isCommandNotFound(error)
142 ? runner.run({
143 command: "pnpm",
144 args: ["--package=npm@11", "dlx", "npm", ...installArgs],
145 timeout: INSTALL_TIMEOUT,
146 })
147 : Effect.fail(error),
148 }),
149 Effect.mapError((cause) =>
150 isCommandNotFound(cause)
151 ? new DeviceToolchainInstallError({
152 tool: spec.name,
153 step: "running npm install",
154 reason: "npm_not_found",
155 cause,
156 })
157 : fail("running npm install")(cause),
158 ),
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
179 .rename(stagingDir, paths.installDir)
180 .pipe(
181 Effect.catch((cause) =>
182 isInstalled(fs, paths, spec.version).pipe(
183 Effect.flatMap((published) =>
184 published ? Effect.void : Effect.fail(fail("publishing the install")(cause)),
185 ),
186 ),
187 ),
188 );
189 return paths;
190 }).pipe(
191 Effect.ensuring(fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore)),
192 );
193});
194
195const ensureTool = Effect.fn("DeviceToolchain.ensureTool")(function* (
196 baseDir: string,
197 spec: ToolSpec,
198 select: (paths: DeviceToolchainPaths) => DeviceToolPaths,
199) {
200 const path = yield* Path.Path;
201 const paths = deviceToolchainPaths(path, baseDir);
202 return yield* installLock.withPermit(installTool(spec, select(paths)));
203});
204
205export const ensureDeviceHub = (baseDir: string) =>
206 ensureTool(baseDir, HUB_SPEC, (paths) => paths.hub);
207
208export const ensureAgentDevice = (baseDir: string) =>
209 ensureTool(baseDir, AGENT_DEVICE_SPEC, (paths) => paths.agentDevice);
210
211const isToolInstalled = Effect.fn("DeviceToolchain.isToolInstalled")(function* (
212 baseDir: string,
213 spec: ToolSpec,
214 select: (paths: DeviceToolchainPaths) => DeviceToolPaths,
215) {
216 const fs = yield* FileSystem.FileSystem;
217 const path = yield* Path.Path;
218 const paths = deviceToolchainPaths(path, baseDir);
219 return yield* isInstalled(fs, select(paths), spec.version);
220});
221
222export const isDeviceHubInstalled = (baseDir: string) =>
223 isToolInstalled(baseDir, HUB_SPEC, (paths) => paths.hub);
224
225export const isAgentDeviceInstalled = (baseDir: string) =>
226 isToolInstalled(baseDir, AGENT_DEVICE_SPEC, (paths) => paths.agentDevice);
227
228/** Entry of the first installed agent-device among `versions`; never installs. */
229export const installedAgentDeviceEntry = Effect.fn("DeviceToolchain.installedAgentDeviceEntry")(
230 function* (baseDir: string, versions: ReadonlyArray<string>) {
231 const fs = yield* FileSystem.FileSystem;
232 const path = yield* Path.Path;
233 for (const version of versions) {
234 const paths = toolPaths(path, baseDir, { ...AGENT_DEVICE_SPEC, version });
235 if (yield* isInstalled(fs, paths, version)) return paths.entryPath;
236 }
237 return null;
238 },
239);
240
241export const deviceToolVersions = Effect.fn("DeviceToolchain.versions")(function* (
242 baseDir: string,
243 running: { hub?: string; agent?: string } = {},
244) {
245 const fs = yield* FileSystem.FileSystem;
246 const path = yield* Path.Path;
247 const inspect = Effect.fn("DeviceToolchain.inspect")(function* (spec: ToolSpec) {
248 const directory = path.join(baseDir, "tools", spec.name);
249 const names = yield* fs.readDirectory(directory).pipe(
250 Effect.catchIf(
251 (error) => error.reason._tag === "NotFound",
252 () => Effect.succeed([]),
253 ),
254 );
255 const versions = yield* Effect.filter(names, (version) =>
256 /^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version)
257 ? Effect.gen(function* () {
258 const paths = toolPaths(path, baseDir, { ...spec, version });
259 const sentinel = yield* fs.readFileString(paths.sentinelPath).pipe(
260 Effect.catchIf(
261 (error) => error.reason._tag === "NotFound",
262 () => Effect.succeed(null),
263 ),
264 );
265 return sentinel?.trim() === version && (yield* fs.exists(paths.entryPath));
266 })
267 : Effect.succeed(false),
268 );
269 return {
270 requiredVersion: spec.version,
271 installedVersions: versions.sort(),
272 runningVersion: (spec.name === DEVICE_HUB_PACKAGE ? running.hub : running.agent) ?? null,
273 };
274 });
275 return yield* Effect.gen(function* () {
276 return {
277 hub: yield* inspect(HUB_SPEC),
278 agent: yield* inspect(AGENT_DEVICE_SPEC),
279 } satisfies DeviceToolVersions;
280 }).pipe(Effect.orElseSucceed(() => undefined));
281});