1// @effect-diagnostics preferSchemaOverJson:off - JSON string literals safely embed paths and arguments in generated JavaScript.
2import * as Schema from "effect/Schema";
3import * as Effect from "effect/Effect";
4import * as Path from "effect/Path";
5import * as ProcessRunner from "../processRunner.ts";
6import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts";
7
8/** Shared with the SSH bootstrap. Cleanup runs only after successful startup. */
9export const deviceToolMaintenanceScript = String.raw`
10const maintenanceFs = require('node:fs');
11const maintenancePath = require('node:path');
12const maintenanceAlive = pid => {
13 try { process.kill(pid, 0); return true; }
14 catch (error) { return error.code !== 'ESRCH'; }
15};
16async function withToolMaintenance(root, operation) {
17 maintenanceFs.mkdirSync(root, { recursive: true });
18 const lock = maintenancePath.join(root, '.maintenance-lock');
19 const nonce = require('node:crypto').randomUUID();
20 const ownerFile = process.pid + '.' + nonce + '.json';
21 const candidate = lock + '.' + nonce;
22 const holder = { pid: process.pid };
23 const deadline = Date.now() + 30000;
24 const removeEmptyLock = () => {
25 try { maintenanceFs.rmdirSync(lock); }
26 catch (error) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST', 'EPERM'].includes(error.code)) throw error; }
27 };
28 maintenanceFs.mkdirSync(candidate);
29 try {
30 maintenanceFs.writeFileSync(maintenancePath.join(candidate, ownerFile), JSON.stringify(holder));
31 while (true) {
32 try {
33 // Publish a populated directory atomically; rename cannot replace another populated lock.
34 maintenanceFs.renameSync(candidate, lock);
35 break;
36 }
37 catch (error) {
38 if (!['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES'].includes(error.code)) throw error;
39 let files = [];
40 try { files = maintenanceFs.readdirSync(lock); } catch (error) { if (error.code !== 'ENOENT') throw error; }
41 if (files.length === 1) {
42 const previousFile = maintenancePath.join(lock, files[0]);
43 let previous;
44 try { previous = JSON.parse(maintenanceFs.readFileSync(previousFile, 'utf8')); } catch {}
45 if (Number.isSafeInteger(previous?.pid) && previous.pid > 0 && !maintenanceAlive(previous.pid)) {
46 // The unique filename belongs only to that owner. Never unlink a replacement owner's file.
47 try { maintenanceFs.unlinkSync(previousFile); } catch (error) { if (error.code !== 'ENOENT') throw error; }
48 }
49 }
50 // A concurrent acquirer publishes its owner file with the directory, so this cannot remove it.
51 removeEmptyLock();
52 if (Date.now() >= deadline) throw Error('Device tool maintenance is locked. Retry when the other operation finishes.');
53 await new Promise(resolve => setTimeout(resolve, 50));
54 }
55 }
56 try { return operation(); }
57 finally {
58 maintenanceFs.unlinkSync(maintenancePath.join(lock, ownerFile));
59 removeEmptyLock();
60 }
61 } finally {
62 maintenanceFs.rmSync(candidate, { recursive: true, force: true });
63 }
64}
65function pruneTools(root, specs, flat) {
66 return withToolMaintenance(root, () => {
67 // Keep installs used by any running helper, including older T3 releases.
68 const scan = process.platform === 'win32'
69 ? require('node:child_process').spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'Get-CimInstance Win32_Process | Select-Object -ExpandProperty CommandLine'], { encoding: 'utf8', timeout: 10000 })
70 : require('node:child_process').spawnSync('ps', ['-ax', '-o', 'command='], { encoding: 'utf8', timeout: 10000 });
71 if (scan.status !== 0 || !scan.stdout) return;
72 for (const [name, required] of specs) {
73 const parent = flat ? root : maintenancePath.join(root, name);
74 let names;
75 try { names = maintenanceFs.readdirSync(parent); } catch { continue; }
76 const completed = [];
77 for (const item of names) {
78 const version = flat ? (item.startsWith(name + '@') ? item.slice(name.length + 1) : '') : item;
79 if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version)) continue;
80 const directory = maintenancePath.join(parent, item);
81 try {
82 if (!maintenanceFs.lstatSync(directory).isDirectory()) continue;
83 if (maintenanceFs.readFileSync(maintenancePath.join(directory, '.install-complete'), 'utf8').trim() !== version) continue;
84 completed.push({ version, directory, modified: maintenanceFs.statSync(maintenancePath.join(directory, '.install-complete')).mtimeMs });
85 } catch {}
86 }
87 // Never prune until the required install has completed. Retain the last other successful install.
88 if (!completed.some(value => value.version === required)) continue;
89 const previous = completed.filter(value => value.version !== required).sort((a, b) => b.modified - a.modified || b.version.localeCompare(a.version, 'en', { numeric: true }))[0]?.version;
90 for (const { version, directory } of completed) {
91 if (version === required || version === previous || scan.stdout.includes(directory + maintenancePath.sep)) continue;
92 maintenanceFs.rmSync(directory, { recursive: true, force: true });
93 }
94 }
95 });
96}
97`;
98
99class DeviceToolMaintenanceError extends Schema.TaggedError<DeviceToolMaintenanceError>()(
100 "DeviceToolMaintenanceError",
101 {
102 operation: Schema.Literal("prune"),
103 tool: Schema.Literals(["hub", "agent"]),
104 exitCode: Schema.NullOr(Schema.Int),
105 cause: Schema.Defect(),
106 },
107) {
108 override get message() {
109 return `Device tool ${this.operation} failed for ${this.tool} (exit code ${this.exitCode ?? "unknown"}).`;
110 }
111}
112
113const runMaintenance = Effect.fn("DeviceToolchain.maintenance")(function* (
114 nodePath: string,
115 script: string,
116 operation: "prune",
117 tool: "hub" | "agent",
118) {
119 const runner = yield* ProcessRunner.ProcessRunner;
120 const result = yield* runner.run({
121 command: nodePath,
122 args: [
123 "-e",
124 deviceToolMaintenanceScript +
125 "\n" +
126 script +
127 ".catch(error => { console.error(error.message); process.exitCode = 1; });",
128 ],
129 });
130 if (result.code !== 0)
131 return yield* new DeviceToolMaintenanceError({
132 operation,
133 tool,
134 exitCode: result.code,
135 cause: result,
136 });
137});
138
139export const pruneLocalDeviceTools = Effect.fn("DeviceToolchain.prune")(function* (
140 baseDir: string,
141 nodePath: string,
142 tool: "hub" | "agent",
143) {
144 const path = yield* Path.Path;
145 yield* runMaintenance(
146 nodePath,
147 `pruneTools(${JSON.stringify(path.join(baseDir, "tools"))}, ${JSON.stringify(tool === "hub" ? [["expo-device-hub", DEVICE_HUB_VERSION]] : [["agent-device", AGENT_DEVICE_VERSION]])}, false)`,
148 "prune",
149 tool,
150 );
151});