| 1 | // @effect-diagnostics preferSchemaOverJson:off - JSON string literals safely embed paths and arguments in generated JavaScript. |
| 2 | import * as Schema from "effect/Schema"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Path from "effect/Path"; |
| 5 | import * as ProcessRunner from "../processRunner.ts"; |
| 6 | import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts"; |
| 7 | |
| 8 | /** Shared with the SSH bootstrap. Cleanup runs only after successful startup. */ |
| 9 | export const deviceToolMaintenanceScript = String.raw` |
| 10 | const maintenanceFs = require('node:fs'); |
| 11 | const maintenancePath = require('node:path'); |
| 12 | const maintenanceAlive = pid => { |
| 13 | try { process.kill(pid, 0); return true; } |
| 14 | catch (error) { return error.code !== 'ESRCH'; } |
| 15 | }; |
| 16 | async 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 | } |
| 65 | function 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 | |
| 99 | class 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 | |
| 113 | const 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 | |
| 139 | export 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 | }); |