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