/** * Pinned installs of the two external tools device support is built on. * * `expo-device-hub` streams simulator and emulator screens and `agent-device` * drives them. Each is npm-installed separately after its matching consent * step into `<baseDir>/tools/<name>/<version>` and executed from there with the * server's own Node, never `npx`: an ephemeral * npx cache would make every first `device_open` after a reboot depend on the * registry, and the pinned versions are part of the contract the injected * agent instructions describe. * * Install follows the pinned-runtime recipe: stage into a temp sibling, write a * sentinel only after npm exits 0, then rename into place. npm extracts files * before it finishes, so an entry file alone does not prove a usable tree. */import * as Duration from "effect/Duration";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Option from "effect/Option";import * as Path from "effect/Path";import * as PlatformError from "effect/PlatformError";import * as Schema from "effect/Schema";import * as Semaphore from "effect/Semaphore"; import * as ProcessRunner from "../processRunner.ts"; const DEVICE_HUB_PACKAGE = "expo-device-hub";export const DEVICE_HUB_VERSION = "0.9.0";const AGENT_DEVICE_PACKAGE = "agent-device";export const AGENT_DEVICE_VERSION = "0.20.10"; const INSTALL_TIMEOUT = Duration.minutes(10);const installLock = Semaphore.makeUnsafe(1); export interface DeviceToolPaths { readonly installDir: string; /** Absolute path of the tool's entry script, run with the server's Node. */ readonly entryPath: string; readonly sentinelPath: string;} export interface DeviceToolchainPaths { readonly hub: DeviceToolPaths; readonly agentDevice: DeviceToolPaths;} export class DeviceToolchainInstallError extends Schema.TaggedError<DeviceToolchainInstallError>()( "DeviceToolchainInstallError", { tool: Schema.String, step: Schema.String, exitCode: Schema.optional(Schema.Number), cause: Schema.optional(Schema.Defect()), },) { override get message(): string { const suffix = this.exitCode === undefined ? "" : ` (exit code ${this.exitCode})`; return `Installing ${this.tool} failed while ${this.step}${suffix}.`; }} interface ToolSpec { readonly name: string; readonly version: string; readonly entry: ReadonlyArray<string>;} const HUB_SPEC: ToolSpec = { name: DEVICE_HUB_PACKAGE, version: DEVICE_HUB_VERSION, entry: ["dist", "server", "cli.mjs"],}; const AGENT_DEVICE_SPEC: ToolSpec = { name: AGENT_DEVICE_PACKAGE, version: AGENT_DEVICE_VERSION, entry: ["bin", "agent-device.mjs"],}; const toolPaths = (path: Path.Path, baseDir: string, spec: ToolSpec): DeviceToolPaths => { const installDir = path.join(baseDir, "tools", spec.name, spec.version); return { installDir, entryPath: path.join(installDir, "node_modules", spec.name, ...spec.entry), sentinelPath: path.join(installDir, ".install-complete"), };}; const deviceToolchainPaths = (path: Path.Path, baseDir: string): DeviceToolchainPaths => ({ hub: toolPaths(path, baseDir, HUB_SPEC), agentDevice: toolPaths(path, baseDir, AGENT_DEVICE_SPEC),}); /** Keep daemon state (daemon.json, sessions) in userdata, separate from tool installs. */export const agentDeviceStateDir = (path: Path.Path, stateDir: string): string => path.join(stateDir, "device", "agent-device"); const isInstalled = Effect.fn("DeviceToolchain.isInstalled")(function* ( fs: FileSystem.FileSystem, paths: DeviceToolPaths, version: string,) { const [entryExists, sentinel] = yield* Effect.all([ fs.exists(paths.entryPath), fs.readFileString(paths.sentinelPath).pipe(Effect.option), ]).pipe(Effect.orElseSucceed(() => [false, Option.none<string>()] as const)); return entryExists && Option.isSome(sentinel) && sentinel.value.trim() === version;}); const installTool = Effect.fn("DeviceToolchain.installTool")(function* ( spec: ToolSpec, paths: DeviceToolPaths,) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const runner = yield* ProcessRunner.ProcessRunner; const fail = (step: string) => (cause: unknown) => new DeviceToolchainInstallError({ tool: spec.name, step, cause }); if (yield* isInstalled(fs, paths, spec.version)) return paths; const parentDir = path.dirname(paths.installDir); yield* fs .remove(paths.installDir, { recursive: true, force: true }) .pipe(Effect.mapError(fail("removing an incomplete install"))); yield* fs .makeDirectory(parentDir, { recursive: true }) .pipe(Effect.mapError(fail("preparing the install directory"))); const stagingDir = yield* fs .makeTempDirectory({ directory: parentDir, prefix: ".staging-" }) .pipe(Effect.mapError(fail("preparing the install directory"))); return yield* Effect.gen(function* () { const installArgs = [ "install", "--prefix", stagingDir, "--no-fund", "--no-audit", `${spec.name}@${spec.version}`, ]; const result = yield* runner .run({ command: "npm", args: installArgs, timeout: INSTALL_TIMEOUT }) .pipe( Effect.catchTags({ ProcessSpawnError: (error) => error.cause instanceof PlatformError.PlatformError && error.cause.reason._tag === "NotFound" ? runner.run({ command: "pnpm", args: ["--package=npm@11", "dlx", "npm", ...installArgs], timeout: INSTALL_TIMEOUT, }) : Effect.fail(error), }), Effect.mapError(fail("running npm install")), ); if (result.code !== 0) { return yield* new DeviceToolchainInstallError({ tool: spec.name, step: "running npm install", exitCode: Number(result.code), cause: result, }); } const stagedEntry = path.join(stagingDir, "node_modules", spec.name, ...spec.entry); if (!(yield* fs.exists(stagedEntry).pipe(Effect.orElseSucceed(() => false)))) { return yield* new DeviceToolchainInstallError({ tool: spec.name, step: "verifying the installed entry point", }); } yield* fs .writeFileString(path.join(stagingDir, ".install-complete"), `${spec.version}\n`) .pipe(Effect.mapError(fail("recording the completed install"))); yield* fs.rename(stagingDir, paths.installDir).pipe( Effect.catch((cause) => // A concurrent server may have published the same version first. isInstalled(fs, paths, spec.version).pipe( Effect.flatMap((published) => published ? Effect.void : Effect.fail(fail("publishing the install")(cause)), ), ), ), ); return paths; }).pipe( Effect.ensuring(fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore)), );}); const ensureTool = Effect.fn("DeviceToolchain.ensureTool")(function* ( baseDir: string, spec: ToolSpec, select: (paths: DeviceToolchainPaths) => DeviceToolPaths,) { const path = yield* Path.Path; const paths = deviceToolchainPaths(path, baseDir); return yield* installLock.withPermit(installTool(spec, select(paths)));}); export const ensureDeviceHub = (baseDir: string) => ensureTool(baseDir, HUB_SPEC, (paths) => paths.hub); export const ensureAgentDevice = (baseDir: string) => ensureTool(baseDir, AGENT_DEVICE_SPEC, (paths) => paths.agentDevice); const isToolInstalled = Effect.fn("DeviceToolchain.isToolInstalled")(function* ( baseDir: string, spec: ToolSpec, select: (paths: DeviceToolchainPaths) => DeviceToolPaths,) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const paths = deviceToolchainPaths(path, baseDir); return yield* isInstalled(fs, select(paths), spec.version);}); export const isDeviceHubInstalled = (baseDir: string) => isToolInstalled(baseDir, HUB_SPEC, (paths) => paths.hub); export const isAgentDeviceInstalled = (baseDir: string) => isToolInstalled(baseDir, AGENT_DEVICE_SPEC, (paths) => paths.agentDevice);
/**
* Pinned installs of the two external tools device support is built on.
*
* `expo-device-hub` streams simulator and emulator screens and `agent-device`
* drives them. Each is npm-installed separately after its matching consent
* step into `<baseDir>/tools/<name>/<version>` and executed from there with the
* server's own Node, never `npx`: an ephemeral
* npx cache would make every first `device_open` after a reboot depend on the
* registry, and the pinned versions are part of the contract the injected
* agent instructions describe.
*
* Install follows the pinned-runtime recipe: stage into a temp sibling, write a
* sentinel only after npm exits 0, then rename into place. npm extracts files
* before it finishes, so an entry file alone does not prove a usable tree.
*/
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";
import * as Semaphore from "effect/Semaphore";
import * as ProcessRunner from "../processRunner.ts";
const DEVICE_HUB_PACKAGE = "expo-device-hub";
export const DEVICE_HUB_VERSION = "0.9.0";
const AGENT_DEVICE_PACKAGE = "agent-device";
export const AGENT_DEVICE_VERSION = "0.20.10";
const INSTALL_TIMEOUT = Duration.minutes(10);
const installLock = Semaphore.makeUnsafe(1);
export interface DeviceToolPaths {
readonly installDir: string;
/** Absolute path of the tool's entry script, run with the server's Node. */
readonly entryPath: string;
readonly sentinelPath: string;
}
export interface DeviceToolchainPaths {
readonly hub: DeviceToolPaths;
readonly agentDevice: DeviceToolPaths;
}
export class DeviceToolchainInstallError extends Schema.TaggedError<DeviceToolchainInstallError>()(
"DeviceToolchainInstallError",
{
tool: Schema.String,
step: Schema.String,
exitCode: Schema.optional(Schema.Number),
cause: Schema.optional(Schema.Defect()),
},
) {
override get message(): string {
const suffix = this.exitCode === undefined ? "" : ` (exit code ${this.exitCode})`;
return `Installing ${this.tool} failed while ${this.step}${suffix}.`;
}
}
interface ToolSpec {
readonly name: string;
readonly version: string;
readonly entry: ReadonlyArray<string>;
}
const HUB_SPEC: ToolSpec = {
name: DEVICE_HUB_PACKAGE,
version: DEVICE_HUB_VERSION,
entry: ["dist", "server", "cli.mjs"],
};
const AGENT_DEVICE_SPEC: ToolSpec = {
name: AGENT_DEVICE_PACKAGE,
version: AGENT_DEVICE_VERSION,
entry: ["bin", "agent-device.mjs"],
};
const toolPaths = (path: Path.Path, baseDir: string, spec: ToolSpec): DeviceToolPaths => {
const installDir = path.join(baseDir, "tools", spec.name, spec.version);
return {
installDir,
entryPath: path.join(installDir, "node_modules", spec.name, ...spec.entry),
sentinelPath: path.join(installDir, ".install-complete"),
};
};
const deviceToolchainPaths = (path: Path.Path, baseDir: string): DeviceToolchainPaths => ({
hub: toolPaths(path, baseDir, HUB_SPEC),
agentDevice: toolPaths(path, baseDir, AGENT_DEVICE_SPEC),
});
/** Keep daemon state (daemon.json, sessions) in userdata, separate from tool installs. */
export const agentDeviceStateDir = (path: Path.Path, stateDir: string): string =>
path.join(stateDir, "device", "agent-device");
const isInstalled = Effect.fn("DeviceToolchain.isInstalled")(function* (
fs: FileSystem.FileSystem,
paths: DeviceToolPaths,
version: string,
) {
const [entryExists, sentinel] = yield* Effect.all([
fs.exists(paths.entryPath),
fs.readFileString(paths.sentinelPath).pipe(Effect.option),
]).pipe(Effect.orElseSucceed(() => [false, Option.none<string>()] as const));
return entryExists && Option.isSome(sentinel) && sentinel.value.trim() === version;
});
const installTool = Effect.fn("DeviceToolchain.installTool")(function* (
spec: ToolSpec,
paths: DeviceToolPaths,
) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const runner = yield* ProcessRunner.ProcessRunner;
const fail = (step: string) => (cause: unknown) =>
new DeviceToolchainInstallError({ tool: spec.name, step, cause });
if (yield* isInstalled(fs, paths, spec.version)) return paths;
const parentDir = path.dirname(paths.installDir);
yield* fs
.remove(paths.installDir, { recursive: true, force: true })
.pipe(Effect.mapError(fail("removing an incomplete install")));
yield* fs
.makeDirectory(parentDir, { recursive: true })
.pipe(Effect.mapError(fail("preparing the install directory")));
const stagingDir = yield* fs
.makeTempDirectory({ directory: parentDir, prefix: ".staging-" })
.pipe(Effect.mapError(fail("preparing the install directory")));
return yield* Effect.gen(function* () {
const installArgs = [
"install",
"--prefix",
stagingDir,
"--no-fund",
"--no-audit",
`${spec.name}@${spec.version}`,
];
const result = yield* runner
.run({ command: "npm", args: installArgs, timeout: INSTALL_TIMEOUT })
.pipe(
Effect.catchTags({
ProcessSpawnError: (error) =>
error.cause instanceof PlatformError.PlatformError &&
error.cause.reason._tag === "NotFound"
? runner.run({
command: "pnpm",
args: ["--package=npm@11", "dlx", "npm", ...installArgs],
timeout: INSTALL_TIMEOUT,
})
: Effect.fail(error),
}),
Effect.mapError(fail("running npm install")),
);
if (result.code !== 0) {
return yield* new DeviceToolchainInstallError({
tool: spec.name,
step: "running npm install",
exitCode: Number(result.code),
cause: result,
});
}
const stagedEntry = path.join(stagingDir, "node_modules", spec.name, ...spec.entry);
if (!(yield* fs.exists(stagedEntry).pipe(Effect.orElseSucceed(() => false)))) {
return yield* new DeviceToolchainInstallError({
tool: spec.name,
step: "verifying the installed entry point",
});
}
yield* fs
.writeFileString(path.join(stagingDir, ".install-complete"), `${spec.version}\n`)
.pipe(Effect.mapError(fail("recording the completed install")));
yield* fs.rename(stagingDir, paths.installDir).pipe(
Effect.catch((cause) =>
// A concurrent server may have published the same version first.
isInstalled(fs, paths, spec.version).pipe(
Effect.flatMap((published) =>
published ? Effect.void : Effect.fail(fail("publishing the install")(cause)),
),
),
),
);
return paths;
}).pipe(
Effect.ensuring(fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore)),
);
});
const ensureTool = Effect.fn("DeviceToolchain.ensureTool")(function* (
baseDir: string,
spec: ToolSpec,
select: (paths: DeviceToolchainPaths) => DeviceToolPaths,
) {
const path = yield* Path.Path;
const paths = deviceToolchainPaths(path, baseDir);
return yield* installLock.withPermit(installTool(spec, select(paths)));
});
export const ensureDeviceHub = (baseDir: string) =>
ensureTool(baseDir, HUB_SPEC, (paths) => paths.hub);
export const ensureAgentDevice = (baseDir: string) =>
ensureTool(baseDir, AGENT_DEVICE_SPEC, (paths) => paths.agentDevice);
const isToolInstalled = Effect.fn("DeviceToolchain.isToolInstalled")(function* (
baseDir: string,
spec: ToolSpec,
select: (paths: DeviceToolchainPaths) => DeviceToolPaths,
) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const paths = deviceToolchainPaths(path, baseDir);
return yield* isInstalled(fs, select(paths), spec.version);
});
export const isDeviceHubInstalled = (baseDir: string) =>
isToolInstalled(baseDir, HUB_SPEC, (paths) => paths.hub);
export const isAgentDeviceInstalled = (baseDir: string) =>
isToolInstalled(baseDir, AGENT_DEVICE_SPEC, (paths) => paths.agentDevice);