@elpapi42/pi-fleet 0.1.0-beta.0 → 0.1.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,28 +1,252 @@
1
1
  // src/entry/installer.ts
2
2
  import { execFile } from "node:child_process";
3
- import { dirname as dirname2, join as join5 } from "node:path";
3
+ import { dirname as dirname3, join as join6 } from "node:path";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import { promisify } from "node:util";
6
6
 
7
- // src/platform/install/service-installer.ts
7
+ // src/pi/external-installation.ts
8
+ import { spawn } from "node:child_process";
9
+ import { createHash } from "node:crypto";
8
10
  import { constants } from "node:fs";
9
- import { access, chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
11
+ import { createReadStream } from "node:fs";
12
+ import { access, realpath, stat } from "node:fs/promises";
13
+ import { delimiter, dirname, isAbsolute, join } from "node:path";
14
+ var VERSION_TIMEOUT_MS = 3e3;
15
+ var MAX_VERSION_OUTPUT_BYTES = 4 * 1024;
16
+ var ExternalPiResolutionError = class extends Error {
17
+ constructor(code, message) {
18
+ super(message);
19
+ this.code = code;
20
+ this.name = "ExternalPiResolutionError";
21
+ }
22
+ };
23
+ async function resolveExternalPiInstallation(options = {}) {
24
+ const env = options.env ?? process.env;
25
+ const selectedPath = await resolveSelectedPath(env);
26
+ const nodePath = options.nodePath ?? await resolveNodePath(env);
27
+ await inspectNode(nodePath);
28
+ const executionEnv = externalPiExecutionEnvironment(env, selectedPath, nodePath);
29
+ for (let attempt = 0; attempt < 2; attempt += 1) {
30
+ const observation = await observeInstallation(selectedPath, executionEnv, options);
31
+ if (observation !== null) {
32
+ return {
33
+ selectedPath,
34
+ nodePath,
35
+ ...observation
36
+ };
37
+ }
38
+ }
39
+ throw new ExternalPiResolutionError(
40
+ "pi_installation_changed",
41
+ "Pi changed while its installation was being observed."
42
+ );
43
+ }
44
+ function externalPiExecutionEnvironment(env, selectedPath, nodePath) {
45
+ return {
46
+ ...env,
47
+ PATH: [dirname(nodePath), dirname(selectedPath), env.PATH].filter((value) => value !== void 0 && value.length > 0).join(delimiter)
48
+ };
49
+ }
50
+ async function observeInstallation(selectedPath, env, options) {
51
+ const beforeRealPath = await inspectExecutable(selectedPath, "Pi");
52
+ const beforeHash = await hashFile(beforeRealPath);
53
+ const versionOutput = await (options.versionCommand ?? ((executable) => readVersion(executable, env, options.versionTimeoutMs, options.maxVersionOutputBytes)))(selectedPath);
54
+ const version = parseVersion(versionOutput);
55
+ const afterRealPath = await inspectExecutable(selectedPath, "Pi");
56
+ const afterHash = await hashFile(afterRealPath);
57
+ if (beforeRealPath !== afterRealPath || beforeHash !== afterHash) return null;
58
+ return {
59
+ realPath: afterRealPath,
60
+ version,
61
+ fingerprint: createHash("sha256").update(JSON.stringify([selectedPath, afterRealPath, version, afterHash])).digest("hex")
62
+ };
63
+ }
64
+ async function resolveSelectedPath(env) {
65
+ const explicit = env.PIFLEET_PI_EXECUTABLE;
66
+ if (explicit !== void 0) {
67
+ if (!isAbsolute(explicit)) {
68
+ throw new ExternalPiResolutionError(
69
+ "invalid_arguments",
70
+ "PIFLEET_PI_EXECUTABLE must be an absolute path."
71
+ );
72
+ }
73
+ return explicit;
74
+ }
75
+ return resolvePathExecutable(env, "pi", "Pi", "pi_not_found");
76
+ }
77
+ async function resolveNodePath(env) {
78
+ return resolvePathExecutable(env, "node", "Node", "pi_not_executable");
79
+ }
80
+ async function resolvePathExecutable(env, name, label, missingCode) {
81
+ const path = env.PATH;
82
+ if (path === void 0 || path.length === 0) {
83
+ throw new ExternalPiResolutionError(missingCode, `${label} was not found on PATH.`);
84
+ }
85
+ for (const directory of path.split(delimiter)) {
86
+ if (!isAbsolute(directory)) {
87
+ throw new ExternalPiResolutionError(
88
+ "invalid_arguments",
89
+ `PATH entries used to select ${label} must be absolute paths.`
90
+ );
91
+ }
92
+ const candidate = join(directory, name);
93
+ try {
94
+ await inspectExecutable(candidate, label);
95
+ return candidate;
96
+ } catch (error) {
97
+ if (!(error instanceof ExternalPiResolutionError) || error.code !== "pi_not_executable" && error.code !== "pi_not_found") {
98
+ throw error;
99
+ }
100
+ }
101
+ }
102
+ throw new ExternalPiResolutionError(missingCode, `${label} was not found on PATH.`);
103
+ }
104
+ async function inspectExecutable(path, label) {
105
+ try {
106
+ const target = await realpath(path);
107
+ const metadata = await stat(target);
108
+ if (!metadata.isFile()) {
109
+ throw new ExternalPiResolutionError("pi_not_executable", `${label} is not a file: ${path}`);
110
+ }
111
+ await access(path, constants.X_OK);
112
+ return target;
113
+ } catch (error) {
114
+ if (error instanceof ExternalPiResolutionError) throw error;
115
+ if (error.code === "ENOENT") {
116
+ throw new ExternalPiResolutionError("pi_not_found", `${label} was not found: ${path}`);
117
+ }
118
+ throw new ExternalPiResolutionError("pi_not_executable", `${label} is not executable: ${path}`);
119
+ }
120
+ }
121
+ async function inspectNode(nodePath) {
122
+ if (!isAbsolute(nodePath)) {
123
+ throw new ExternalPiResolutionError("invalid_arguments", "Node must be an absolute path.");
124
+ }
125
+ try {
126
+ await inspectExecutable(nodePath, "Node");
127
+ } catch {
128
+ throw new ExternalPiResolutionError("pi_not_executable", `Node is not executable: ${nodePath}`);
129
+ }
130
+ }
131
+ async function hashFile(path) {
132
+ const hash = createHash("sha256");
133
+ await new Promise((resolveHash, rejectHash) => {
134
+ const stream = createReadStream(path);
135
+ stream.on("data", (chunk) => hash.update(chunk));
136
+ stream.once("error", rejectHash);
137
+ stream.once("end", resolveHash);
138
+ });
139
+ return hash.digest("hex");
140
+ }
141
+ async function readVersion(executable, env, timeoutMs = VERSION_TIMEOUT_MS, maxOutputBytes = MAX_VERSION_OUTPUT_BYTES) {
142
+ const child = spawn(executable, ["--version"], {
143
+ detached: process.platform !== "win32",
144
+ env,
145
+ shell: false,
146
+ stdio: ["ignore", "pipe", "pipe"]
147
+ });
148
+ const chunks = [];
149
+ let outputBytes = 0;
150
+ let terminalError = null;
151
+ let terminated = false;
152
+ const terminate = () => {
153
+ if (terminated) return;
154
+ terminated = true;
155
+ if (process.platform !== "win32" && child.pid !== void 0) {
156
+ try {
157
+ process.kill(-child.pid, "SIGKILL");
158
+ return;
159
+ } catch {
160
+ }
161
+ }
162
+ child.kill("SIGKILL");
163
+ };
164
+ child.stdout.on("data", (chunk) => {
165
+ if (terminalError !== null) return;
166
+ outputBytes += chunk.byteLength;
167
+ if (outputBytes > maxOutputBytes) {
168
+ terminalError = new ExternalPiResolutionError(
169
+ "pi_version_unavailable",
170
+ "Pi version output exceeded its limit."
171
+ );
172
+ terminate();
173
+ return;
174
+ }
175
+ chunks.push(chunk);
176
+ });
177
+ child.stderr.resume();
178
+ return new Promise((resolveVersion, rejectVersion) => {
179
+ const timer = setTimeout(() => {
180
+ terminalError = new ExternalPiResolutionError(
181
+ "pi_version_unavailable",
182
+ "Pi version command timed out."
183
+ );
184
+ terminate();
185
+ }, timeoutMs);
186
+ child.once("error", () => {
187
+ terminalError ??= new ExternalPiResolutionError(
188
+ "pi_version_unavailable",
189
+ "Pi version command failed."
190
+ );
191
+ });
192
+ child.once("close", (code) => {
193
+ clearTimeout(timer);
194
+ if (terminalError !== null) {
195
+ rejectVersion(terminalError);
196
+ return;
197
+ }
198
+ if (code !== 0) {
199
+ rejectVersion(
200
+ new ExternalPiResolutionError("pi_version_unavailable", "Pi version command failed.")
201
+ );
202
+ return;
203
+ }
204
+ try {
205
+ resolveVersion(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)));
206
+ } catch {
207
+ rejectVersion(
208
+ new ExternalPiResolutionError(
209
+ "pi_version_unavailable",
210
+ "Pi version command returned invalid UTF-8."
211
+ )
212
+ );
213
+ }
214
+ });
215
+ });
216
+ }
217
+ function parseVersion(output) {
218
+ const match = /^(?:pi\s+)?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\s*$/i.exec(output);
219
+ if (match?.[1] === void 0) {
220
+ throw new ExternalPiResolutionError(
221
+ "pi_version_unavailable",
222
+ "Pi version command returned an invalid version."
223
+ );
224
+ }
225
+ return match[1];
226
+ }
227
+
228
+ // src/platform/install/service-installer.ts
229
+ import { constants as constants2 } from "node:fs";
230
+ import { access as access2, chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
10
231
  import { homedir } from "node:os";
11
- import { dirname, join } from "node:path";
232
+ import { dirname as dirname2, join as join2 } from "node:path";
12
233
 
13
234
  // src/platform/install/service-definition.ts
14
- import { isAbsolute } from "node:path";
235
+ import { isAbsolute as isAbsolute2 } from "node:path";
15
236
  function systemdUserUnit(options) {
16
237
  validate(options);
17
- const environment = options.stateRoot === void 0 ? "" : `Environment=PIFLEET_STATE_ROOT=${systemdEscape(options.stateRoot)}
238
+ const environment = environmentEntries(options).map(([key, value]) => {
239
+ const assignment = `${key}=${value}`;
240
+ return `Environment=${/^[A-Za-z0-9_./:@=-]+$/.test(assignment) ? assignment : systemdQuote(assignment)}
18
241
  `;
242
+ }).join("");
19
243
  return `[Unit]
20
- Description=Pi Fleet user runtime
244
+ Description=pi-fleet user runtime
21
245
  After=default.target
22
246
 
23
247
  [Service]
24
248
  Type=simple
25
- ExecStart=${systemdEscape(options.nodePath)} ${systemdEscape(options.runtimePath)}
249
+ ExecStart=${systemdArgument(options.nodePath)} ${systemdArgument(options.runtimePath)}
26
250
  ${environment}Restart=on-failure
27
251
  RestartSec=1
28
252
  TimeoutStopSec=10
@@ -35,8 +259,9 @@ WantedBy=default.target
35
259
  }
36
260
  function launchdAgentPlist(options) {
37
261
  validate(options);
38
- const environment = options.stateRoot === void 0 ? "" : ` <key>EnvironmentVariables</key>
39
- <dict><key>PIFLEET_STATE_ROOT</key><string>${xmlEscape(options.stateRoot)}</string></dict>
262
+ const entries = environmentEntries(options);
263
+ const environment = entries.length === 0 ? "" : ` <key>EnvironmentVariables</key>
264
+ <dict>${entries.map(([key, value]) => `<key>${xmlEscape(key)}</key><string>${xmlEscape(value)}</string>`).join("")}</dict>
40
265
  `;
41
266
  return `<?xml version="1.0" encoding="UTF-8"?>
42
267
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -55,27 +280,58 @@ ${environment} <key>RunAtLoad</key><true/>
55
280
  </plist>
56
281
  `;
57
282
  }
283
+ function environmentEntries(options) {
284
+ return [
285
+ ...options.stateRoot === void 0 ? [] : [["PIFLEET_STATE_ROOT", options.stateRoot]],
286
+ ...options.piExecutablePath === void 0 ? [] : [["PIFLEET_PI_EXECUTABLE", options.piExecutablePath]],
287
+ ...options.piNodePath === void 0 ? [] : [["PIFLEET_PI_NODE", options.piNodePath]]
288
+ ];
289
+ }
58
290
  function validate(options) {
59
- if (!isAbsolute(options.nodePath) || !isAbsolute(options.runtimePath)) {
291
+ const paths = [
292
+ options.nodePath,
293
+ options.runtimePath,
294
+ options.piExecutablePath,
295
+ options.piNodePath
296
+ ].filter((value) => value !== void 0);
297
+ if (paths.some((path) => !isAbsolute2(path))) {
60
298
  throw new Error("Service executables must use absolute paths");
61
299
  }
300
+ if (paths.some((path) => /[\0\n\r]/.test(path))) {
301
+ throw new Error("Service paths cannot contain control characters");
302
+ }
62
303
  }
63
- function systemdEscape(value) {
64
- if (!/^[A-Za-z0-9_./:@-]+$/.test(value)) throw new Error(`Unsafe systemd path ${value}`);
65
- return value;
304
+ function systemdArgument(value) {
305
+ return /^[A-Za-z0-9_./:@-]+$/.test(value) ? value : systemdQuote(value);
306
+ }
307
+ function systemdQuote(value) {
308
+ if (/[\0\n\r]/.test(value)) throw new Error("Unsafe systemd value");
309
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
66
310
  }
67
311
  function xmlEscape(value) {
68
312
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
69
313
  }
70
314
 
71
315
  // src/platform/install/service-installer.ts
316
+ var ServiceRepairDeferredError = class extends Error {
317
+ code = "runtime_upgrade_deferred";
318
+ constructor() {
319
+ super(
320
+ "The installed pi-fleet service differs from the requested runtime or Pi selection. Automatic replacement is deferred because runtime quiescence cannot be proven; finish active work, stop the existing service explicitly, then install or repair again."
321
+ );
322
+ this.name = "ServiceRepairDeferredError";
323
+ }
324
+ };
72
325
  async function installUserService(options) {
73
326
  const home = options.home ?? homedir();
74
327
  if (options.platform === "linux") {
75
- const path = join(home, ".config", "systemd", "user", "pi-fleet.service");
328
+ const path = join2(home, ".config", "systemd", "user", "pi-fleet.service");
76
329
  const effectiveDefinition = await preserveInstalledStateRoot(path, options.definition, "linux");
77
330
  const definition = systemdUserUnit(effectiveDefinition);
78
331
  const needsRepair = await serviceDefinitionNeedsRepair(path, definition, effectiveDefinition);
332
+ if (needsRepair && options.replaceExisting === false && await exists(path)) {
333
+ throw new ServiceRepairDeferredError();
334
+ }
79
335
  if (needsRepair) {
80
336
  await atomicWrite(path, definition);
81
337
  await options.executor.run("systemctl", ["--user", "daemon-reload"]);
@@ -87,38 +343,43 @@ async function installUserService(options) {
87
343
  return path;
88
344
  }
89
345
  if (options.platform === "darwin") {
90
- const path = join(home, "Library", "LaunchAgents", "works.elpapi.pifleet.plist");
346
+ const path = join2(home, "Library", "LaunchAgents", "works.elpapi.pifleet.plist");
91
347
  const effectiveDefinition = await preserveInstalledStateRoot(
92
348
  path,
93
349
  options.definition,
94
350
  "darwin"
95
351
  );
96
- await atomicWrite(path, launchdAgentPlist(effectiveDefinition));
352
+ const definition = launchdAgentPlist(effectiveDefinition);
353
+ const needsRepair = await serviceDefinitionNeedsRepair(path, definition, effectiveDefinition);
354
+ if (needsRepair && options.replaceExisting === false && await exists(path)) {
355
+ throw new ServiceRepairDeferredError();
356
+ }
357
+ await atomicWrite(path, definition);
97
358
  const domain = `gui/${options.uid ?? process.getuid?.() ?? 0}`;
98
359
  await options.executor.run("launchctl", ["bootout", domain, path]).catch(() => void 0);
99
360
  await options.executor.run("launchctl", ["bootstrap", domain, path]);
100
361
  await options.executor.run("launchctl", ["kickstart", `${domain}/works.elpapi.pifleet`]);
101
362
  return path;
102
363
  }
103
- throw new Error(`Native Pi Fleet supervision is unsupported on ${options.platform}`);
364
+ throw new Error(`Native pi-fleet supervision is unsupported on ${options.platform}`);
104
365
  }
105
366
  async function uninstallUserService(options) {
106
367
  const home = options.home ?? homedir();
107
368
  if (options.platform === "linux") {
108
- const path = join(home, ".config", "systemd", "user", "pi-fleet.service");
369
+ const path = join2(home, ".config", "systemd", "user", "pi-fleet.service");
109
370
  await options.executor.run("systemctl", ["--user", "disable", "--now", "pi-fleet.service"]);
110
371
  await rm(path, { force: true });
111
372
  await options.executor.run("systemctl", ["--user", "daemon-reload"]);
112
373
  return;
113
374
  }
114
375
  if (options.platform === "darwin") {
115
- const path = join(home, "Library", "LaunchAgents", "works.elpapi.pifleet.plist");
376
+ const path = join2(home, "Library", "LaunchAgents", "works.elpapi.pifleet.plist");
116
377
  const domain = `gui/${options.uid ?? process.getuid?.() ?? 0}`;
117
378
  await options.executor.run("launchctl", ["bootout", domain, path]).catch(() => void 0);
118
379
  await rm(path, { force: true });
119
380
  return;
120
381
  }
121
- throw new Error(`Native Pi Fleet supervision is unsupported on ${options.platform}`);
382
+ throw new Error(`Native pi-fleet supervision is unsupported on ${options.platform}`);
122
383
  }
123
384
  async function serviceDefinitionNeedsRepair(path, expectedContents, definition) {
124
385
  const current = await readFile(path, "utf8").catch((error) => {
@@ -127,8 +388,8 @@ async function serviceDefinitionNeedsRepair(path, expectedContents, definition)
127
388
  });
128
389
  if (current !== expectedContents) return true;
129
390
  try {
130
- await access(definition.nodePath, constants.X_OK);
131
- await access(definition.runtimePath, constants.R_OK);
391
+ await access2(definition.nodePath, constants2.X_OK);
392
+ await access2(definition.runtimePath, constants2.R_OK);
132
393
  return false;
133
394
  } catch {
134
395
  return true;
@@ -146,8 +407,16 @@ async function preserveInstalledStateRoot(path, definition, platform) {
146
407
  const stateRoot = platform === "linux" ? encoded : encoded.replaceAll("&apos;", "'").replaceAll("&quot;", '"').replaceAll("&gt;", ">").replaceAll("&lt;", "<").replaceAll("&amp;", "&");
147
408
  return { ...definition, stateRoot };
148
409
  }
410
+ async function exists(path) {
411
+ try {
412
+ await access2(path);
413
+ return true;
414
+ } catch {
415
+ return false;
416
+ }
417
+ }
149
418
  async function atomicWrite(path, contents) {
150
- await mkdir(dirname(path), { recursive: true, mode: 448 });
419
+ await mkdir(dirname2(path), { recursive: true, mode: 448 });
151
420
  const temporary = `${path}.tmp-${process.pid}`;
152
421
  await writeFile(temporary, contents, { mode: 384 });
153
422
  await chmod(temporary, 384);
@@ -155,18 +424,18 @@ async function atomicWrite(path, contents) {
155
424
  }
156
425
 
157
426
  // src/platform/install/runtime-release.ts
158
- import { createHash as createHash2, randomUUID } from "node:crypto";
159
- import { chmod as chmod2, cp, lstat as lstat2, mkdir as mkdir2, readFile as readFile3, rename as rename2, rm as rm2, stat as stat2 } from "node:fs/promises";
160
- import { join as join3, resolve as resolve2, sep as sep2 } from "node:path";
427
+ import { createHash as createHash3, randomUUID } from "node:crypto";
428
+ import { chmod as chmod2, cp, lstat as lstat2, mkdir as mkdir2, readFile as readFile3, rename as rename2, rm as rm2, writeFile as writeFile2 } from "node:fs/promises";
429
+ import { join as join4, posix, resolve as resolve2, sep as sep2 } from "node:path";
161
430
 
162
431
  // src/platform/install/tree-integrity.ts
163
- import { createHash } from "node:crypto";
164
- import { lstat, readFile as readFile2, readdir, stat } from "node:fs/promises";
165
- import { join as join2, relative, resolve, sep } from "node:path";
432
+ import { createHash as createHash2 } from "node:crypto";
433
+ import { lstat, readFile as readFile2, readdir, realpath as realpath2, stat as stat2 } from "node:fs/promises";
434
+ import { join as join3, relative, resolve, sep } from "node:path";
166
435
  async function hashDirectoryTree(root, manifestPath) {
167
436
  const resolvedRoot = resolve(root);
168
437
  const entries = await collectFiles(resolvedRoot, resolvedRoot);
169
- const hash = createHash("sha256");
438
+ const hash = createHash2("sha256");
170
439
  let bytes = 0;
171
440
  for (const entry of entries) {
172
441
  const contents = await readFile2(entry.absolutePath);
@@ -188,9 +457,9 @@ async function hashDirectoryTree(root, manifestPath) {
188
457
  async function collectFiles(root, directory) {
189
458
  const output = [];
190
459
  for (const name of (await readdir(directory)).sort()) {
191
- const absolutePath = join2(directory, name);
460
+ const absolutePath = join3(directory, name);
192
461
  const linkInfo = await lstat(absolutePath);
193
- const info = linkInfo.isSymbolicLink() ? await stat(absolutePath) : linkInfo;
462
+ const info = linkInfo.isSymbolicLink() ? await stat2(absolutePath) : linkInfo;
194
463
  if (info.isDirectory()) {
195
464
  if (linkInfo.isSymbolicLink()) {
196
465
  throw new Error(`Runtime dependency tree contains a directory symlink: ${absolutePath}`);
@@ -201,6 +470,14 @@ async function collectFiles(root, directory) {
201
470
  if (!info.isFile()) {
202
471
  throw new Error(`Runtime dependency tree contains an unsupported entry: ${absolutePath}`);
203
472
  }
473
+ if (linkInfo.isSymbolicLink()) {
474
+ const target = await realpath2(absolutePath);
475
+ if (target !== root && !target.startsWith(`${root}${sep}`)) {
476
+ throw new Error(
477
+ `Runtime dependency tree contains an external file symlink: ${absolutePath}`
478
+ );
479
+ }
480
+ }
204
481
  const relativePath = relative(root, absolutePath).split(sep).join("/");
205
482
  if (relativePath.length === 0 || relativePath.startsWith("../")) {
206
483
  throw new Error(`Runtime dependency path escapes its root: ${absolutePath}`);
@@ -211,75 +488,277 @@ async function collectFiles(root, directory) {
211
488
  }
212
489
 
213
490
  // src/platform/install/runtime-release.ts
491
+ var requiredRuntimeArtifacts = /* @__PURE__ */ new Set([
492
+ "package.json",
493
+ "bin/pifleet.mjs",
494
+ "bin/pifleet-runtime.mjs",
495
+ "dist/cli.mjs",
496
+ "dist/runtime.mjs",
497
+ "dist/sqlite-worker.mjs"
498
+ ]);
499
+ var dependencyTreePath = "node_modules";
214
500
  async function materializeRuntime(options) {
215
501
  const sourceRoot = resolve2(options.sourceRoot);
502
+ const manifestBytes = await readFile3(join4(sourceRoot, "dist", "runtime-manifest.json"));
503
+ const sourceManifest = await parseRuntimeManifest(manifestBytes, sourceRoot);
504
+ if (sourceManifest.closure !== void 0) {
505
+ await verifyRuntime(sourceRoot);
506
+ return sourceRoot;
507
+ }
508
+ await verifyRuntimeFiles(sourceRoot, sourceManifest);
509
+ await verifyDependencyIdentities(sourceRoot, sourceManifest.dependencies);
510
+ const sourceTreeRoot = resolveInside(sourceRoot, dependencyTreePath);
511
+ const sourceTreeBefore = await hashDirectoryTree(sourceTreeRoot, dependencyTreePath);
512
+ const sourceManifestSha256 = createHash3("sha256").update(manifestBytes).digest("hex");
513
+ const materializedManifest = {
514
+ ...sourceManifest,
515
+ closure: { sourceManifestSha256, tree: sourceTreeBefore }
516
+ };
517
+ const materializedManifestBytes = serializeManifest(materializedManifest);
518
+ const closureHash = createHash3("sha256").update(sourceManifestSha256).update("\0").update(JSON.stringify(sourceTreeBefore)).digest("hex").slice(0, 16);
216
519
  const applicationRoot = resolve2(options.applicationRoot);
217
520
  await ensurePrivateDirectory(applicationRoot);
218
- const manifestBytes = await readFile3(join3(sourceRoot, "dist", "runtime-manifest.json"));
219
- const manifest = JSON.parse(manifestBytes.toString("utf8"));
220
- const manifestHash = createHash2("sha256").update(manifestBytes).digest("hex").slice(0, 16);
221
- const releasesRoot = join3(applicationRoot, "releases");
521
+ const releasesRoot = join4(applicationRoot, "releases");
222
522
  await ensurePrivateDirectory(releasesRoot);
223
- const destination = join3(releasesRoot, `${manifest.package.version}-${manifestHash}`);
523
+ const destination = join4(releasesRoot, `${sourceManifest.package.version}-${closureHash}`);
224
524
  if (await pathExists(destination)) {
225
- await verifyRuntime(destination, manifest);
525
+ await verifyExpectedRuntime(destination, materializedManifest, materializedManifestBytes);
226
526
  return destination;
227
527
  }
228
- const staging = join3(releasesRoot, `.staging-${randomUUID()}`);
528
+ const staging = join4(releasesRoot, `.staging-${randomUUID()}`);
229
529
  await mkdir2(staging, { mode: 448 });
230
530
  try {
231
- await cp(join3(sourceRoot, "dist"), join3(staging, "dist"), {
531
+ await cp(join4(sourceRoot, "dist"), join4(staging, "dist"), {
232
532
  recursive: true,
233
533
  dereference: true
234
534
  });
235
- await cp(join3(sourceRoot, "bin"), join3(staging, "bin"), { recursive: true, dereference: true });
236
- await cp(join3(sourceRoot, "package.json"), join3(staging, "package.json"));
237
- for (const tree of manifest.trees) {
238
- const source = resolveInside(sourceRoot, tree.path);
239
- const destination2 = resolveInside(staging, tree.path);
240
- await mkdir2(resolve2(destination2, ".."), { recursive: true, mode: 448 });
241
- await cp(source, destination2, { recursive: true, dereference: true });
242
- }
243
- await verifyRuntime(staging, manifest);
535
+ await cp(join4(sourceRoot, "bin"), join4(staging, "bin"), {
536
+ recursive: true,
537
+ dereference: true
538
+ });
539
+ await cp(join4(sourceRoot, "package.json"), join4(staging, "package.json"));
540
+ await cp(sourceTreeRoot, join4(staging, dependencyTreePath), {
541
+ recursive: true,
542
+ dereference: true
543
+ });
544
+ await options.hooks?.afterDependencyCopy?.();
545
+ const stagedTree = await hashDirectoryTree(
546
+ join4(staging, dependencyTreePath),
547
+ dependencyTreePath
548
+ );
549
+ assertSameTree(sourceTreeBefore, stagedTree, "copied dependency closure");
550
+ const sourceTreeAfter = await hashDirectoryTree(sourceTreeRoot, dependencyTreePath);
551
+ assertSameTree(sourceTreeBefore, sourceTreeAfter, "source dependency closure");
552
+ await writeFile2(join4(staging, "dist", "runtime-manifest.json"), materializedManifestBytes);
553
+ await verifyExpectedRuntime(staging, materializedManifest, materializedManifestBytes);
244
554
  await chmod2(staging, 448);
245
- await rename2(staging, destination);
555
+ try {
556
+ await rename2(staging, destination);
557
+ } catch (error) {
558
+ if (!isDestinationRace(error) || !await pathExists(destination)) throw error;
559
+ await verifyExpectedRuntime(destination, materializedManifest, materializedManifestBytes);
560
+ }
246
561
  return destination;
247
- } catch (error) {
562
+ } finally {
248
563
  await rm2(staging, { recursive: true, force: true });
249
- throw error;
250
564
  }
251
565
  }
252
- async function verifyRuntime(root, manifest) {
253
- const expected = manifest ?? JSON.parse(
254
- await readFile3(join3(root, "dist", "runtime-manifest.json"), "utf8")
255
- );
566
+ async function verifyRuntime(root) {
256
567
  const resolvedRoot = resolve2(root);
257
- for (const file of expected.files) {
258
- const path = resolveInside(resolvedRoot, file.path);
259
- const info = await stat2(path);
568
+ const manifestBytes = await readFile3(join4(resolvedRoot, "dist", "runtime-manifest.json"));
569
+ const manifest = await parseRuntimeManifest(manifestBytes, resolvedRoot);
570
+ if (manifest.closure === void 0) {
571
+ throw new Error("Runtime release manifest is missing its materialized closure");
572
+ }
573
+ await verifyRuntimeFiles(resolvedRoot, manifest);
574
+ await verifyDependencyIdentities(resolvedRoot, manifest.dependencies);
575
+ const actualTree = await hashDirectoryTree(
576
+ resolveInside(resolvedRoot, dependencyTreePath),
577
+ dependencyTreePath
578
+ );
579
+ assertSameTree(manifest.closure.tree, actualTree, "materialized dependency closure");
580
+ }
581
+ async function verifyExpectedRuntime(root, expected, expectedBytes) {
582
+ const manifestPath = join4(root, "dist", "runtime-manifest.json");
583
+ const actualBytes = await readFile3(manifestPath);
584
+ if (!actualBytes.equals(expectedBytes)) {
585
+ throw new Error("Materialized runtime manifest does not match the expected closure");
586
+ }
587
+ const actual = await parseRuntimeManifest(actualBytes, root);
588
+ if (actual.closure === void 0 || expected.closure === void 0) {
589
+ throw new Error("Materialized runtime manifest is missing its closure");
590
+ }
591
+ await verifyRuntimeFiles(root, actual);
592
+ await verifyDependencyIdentities(root, actual.dependencies);
593
+ const actualTree = await hashDirectoryTree(
594
+ resolveInside(root, dependencyTreePath),
595
+ dependencyTreePath
596
+ );
597
+ assertSameTree(expected.closure.tree, actualTree, "materialized dependency closure");
598
+ }
599
+ async function verifyRuntimeFiles(root, manifest) {
600
+ for (const file of manifest.files) {
601
+ const path = resolveInside(root, file.path);
602
+ const info = await lstat2(path);
603
+ if (info.isSymbolicLink()) {
604
+ throw new Error(`Runtime artifact ${file.path} must not be a symbolic link`);
605
+ }
260
606
  if (!info.isFile() || info.size !== file.bytes) {
261
607
  throw new Error(`Runtime artifact ${file.path} has changed`);
262
608
  }
263
- const hash = createHash2("sha256").update(await readFile3(path)).digest("hex");
609
+ const hash = createHash3("sha256").update(await readFile3(path)).digest("hex");
264
610
  if (hash !== file.sha256) {
265
611
  throw new Error(`Runtime artifact ${file.path} failed verification`);
266
612
  }
267
613
  }
268
- for (const expectedTree of expected.trees) {
269
- const treeRoot = resolveInside(resolvedRoot, expectedTree.path);
270
- const actualTree = await hashDirectoryTree(treeRoot, expectedTree.path);
271
- if (actualTree.files !== expectedTree.files || actualTree.bytes !== expectedTree.bytes || actualTree.sha256 !== expectedTree.sha256) {
272
- throw new Error(`Runtime dependency tree ${expectedTree.path} failed verification`);
614
+ }
615
+ async function verifyDependencyIdentities(root, dependencies) {
616
+ const nodeModules = resolveInside(root, dependencyTreePath);
617
+ const modulesInfo = await lstat2(nodeModules);
618
+ if (!modulesInfo.isDirectory() || modulesInfo.isSymbolicLink()) {
619
+ throw new Error("Runtime dependency closure must be a regular directory");
620
+ }
621
+ for (const dependency of dependencies) {
622
+ const packageRoot = resolveInside(root, dependency.path);
623
+ const packageInfo = await lstat2(packageRoot);
624
+ if (!packageInfo.isDirectory() || packageInfo.isSymbolicLink()) {
625
+ throw new Error(`Runtime dependency ${dependency.name} must be a regular directory`);
626
+ }
627
+ const packageJsonPath = join4(packageRoot, "package.json");
628
+ const packageJsonInfo = await lstat2(packageJsonPath);
629
+ if (!packageJsonInfo.isFile() || packageJsonInfo.isSymbolicLink()) {
630
+ throw new Error(`Runtime dependency ${dependency.name} has an unsafe package.json`);
631
+ }
632
+ const identity = await readPackageMetadata(packageRoot);
633
+ if (identity.name !== dependency.name || identity.version !== dependency.version) {
634
+ throw new Error(`Runtime dependency ${dependency.name} has an unexpected identity`);
635
+ }
636
+ }
637
+ }
638
+ async function parseRuntimeManifest(bytes, root) {
639
+ let candidate;
640
+ try {
641
+ candidate = JSON.parse(bytes.toString("utf8"));
642
+ } catch {
643
+ throw new Error("Runtime manifest is not valid JSON");
644
+ }
645
+ await validateRuntimeManifest(candidate, root);
646
+ return candidate;
647
+ }
648
+ async function validateRuntimeManifest(candidate, root) {
649
+ if (!isRecord(candidate) || candidate.schemaVersion !== 4) {
650
+ throw new Error("Runtime manifest has an unsupported schema version");
651
+ }
652
+ if (!isRecord(candidate.package) || typeof candidate.package.name !== "string" || typeof candidate.package.version !== "string" || !isRecord(candidate.piRuntime) || candidate.piRuntime.mode !== "external") {
653
+ throw new Error("Runtime manifest has an invalid package identity");
654
+ }
655
+ const packageMetadata = await readPackageMetadata(root);
656
+ if (candidate.package.name !== packageMetadata.name || candidate.package.version !== packageMetadata.version) {
657
+ throw new Error("Runtime manifest package identity does not match package.json");
658
+ }
659
+ if (!Array.isArray(candidate.files) || !Array.isArray(candidate.dependencies)) {
660
+ throw new Error("Runtime manifest has invalid artifact lists");
661
+ }
662
+ const filePaths = /* @__PURE__ */ new Set();
663
+ for (const file of candidate.files) {
664
+ if (!isRecord(file) || !isManifestPath(file.path) || !isValidSize(file.bytes) || !isSha256(file.sha256)) {
665
+ throw new Error("Runtime manifest contains an invalid artifact");
666
+ }
667
+ if (filePaths.has(file.path)) {
668
+ throw new Error(`Runtime manifest has duplicate path ${file.path}`);
669
+ }
670
+ filePaths.add(file.path);
671
+ }
672
+ for (const required of requiredRuntimeArtifacts) {
673
+ if (!filePaths.has(required)) {
674
+ throw new Error(`Runtime manifest is missing required artifact ${required}`);
675
+ }
676
+ }
677
+ const dependencyPaths = /* @__PURE__ */ new Set();
678
+ const dependencyNames = /* @__PURE__ */ new Set();
679
+ for (const dependency of candidate.dependencies) {
680
+ if (!isRecord(dependency) || !isManifestPath(dependency.path) || typeof dependency.name !== "string" || dependency.name.length === 0 || typeof dependency.version !== "string" || dependency.version.length === 0 || dependency.path !== `node_modules/${dependency.name}`) {
681
+ throw new Error("Runtime manifest contains an invalid dependency declaration");
682
+ }
683
+ if (dependencyPaths.has(dependency.path) || dependencyNames.has(dependency.name)) {
684
+ throw new Error(`Runtime manifest has duplicate dependency ${dependency.name}`);
685
+ }
686
+ dependencyPaths.add(dependency.path);
687
+ dependencyNames.add(dependency.name);
688
+ }
689
+ const expectedDependencies = packageMetadata.dependencies;
690
+ if (dependencyNames.size !== Object.keys(expectedDependencies).length || [...dependencyNames].some(
691
+ (name) => expectedDependencies[name] !== candidate.dependencies.find(
692
+ (dependency) => dependency.name === name
693
+ )?.version
694
+ )) {
695
+ throw new Error("Runtime manifest dependencies do not match package.json");
696
+ }
697
+ for (const filePath of filePaths) {
698
+ for (const dependencyPath of dependencyPaths) {
699
+ if (filePath === dependencyPath || filePath.startsWith(`${dependencyPath}/`) || dependencyPath.startsWith(`${filePath}/`)) {
700
+ throw new Error(`Runtime manifest has overlapping paths ${filePath} and ${dependencyPath}`);
701
+ }
273
702
  }
274
703
  }
704
+ if (candidate.closure !== void 0) {
705
+ if (!isRecord(candidate.closure) || !isSha256(candidate.closure.sourceManifestSha256) || !isTreeIntegrity(candidate.closure.tree) || candidate.closure.tree.path !== dependencyTreePath) {
706
+ throw new Error("Runtime manifest contains an invalid materialized closure");
707
+ }
708
+ }
709
+ }
710
+ async function readPackageMetadata(root) {
711
+ let candidate;
712
+ try {
713
+ candidate = JSON.parse(await readFile3(join4(root, "package.json"), "utf8"));
714
+ } catch {
715
+ throw new Error("Runtime package.json is not valid JSON");
716
+ }
717
+ if (!isRecord(candidate) || typeof candidate.name !== "string" || typeof candidate.version !== "string" || candidate.dependencies !== void 0 && !isRecord(candidate.dependencies) || isRecord(candidate.dependencies) && Object.values(candidate.dependencies).some((version) => typeof version !== "string")) {
718
+ throw new Error("Runtime package.json has invalid package metadata");
719
+ }
720
+ return {
721
+ name: candidate.name,
722
+ version: candidate.version,
723
+ dependencies: isRecord(candidate.dependencies) ? candidate.dependencies : {}
724
+ };
725
+ }
726
+ function serializeManifest(manifest) {
727
+ return Buffer.from(`${JSON.stringify(manifest, null, 2)}
728
+ `);
729
+ }
730
+ function assertSameTree(expected, actual, label) {
731
+ if (expected.path !== actual.path || expected.files !== actual.files || expected.bytes !== actual.bytes || expected.sha256 !== actual.sha256) {
732
+ throw new Error(`Runtime ${label} changed during materialization`);
733
+ }
275
734
  }
276
735
  function resolveInside(root, path) {
736
+ if (!isManifestPath(path)) throw new Error(`Runtime manifest contains an unsafe path ${path}`);
277
737
  const resolved = resolve2(root, path);
278
738
  if (!resolved.startsWith(`${root}${sep2}`)) {
279
739
  throw new Error(`Runtime manifest contains an unsafe path ${path}`);
280
740
  }
281
741
  return resolved;
282
742
  }
743
+ function isRecord(value) {
744
+ return typeof value === "object" && value !== null;
745
+ }
746
+ function isManifestPath(value) {
747
+ return typeof value === "string" && value.length > 0 && !value.includes("\\") && !value.startsWith("/") && posix.normalize(value) === value && value.split("/").every((part) => part.length > 0 && part !== "." && part !== "..");
748
+ }
749
+ function isValidSize(value) {
750
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
751
+ }
752
+ function isSha256(value) {
753
+ return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
754
+ }
755
+ function isTreeIntegrity(value) {
756
+ return isRecord(value) && isManifestPath(value.path) && isValidSize(value.files) && isValidSize(value.bytes) && isSha256(value.sha256);
757
+ }
758
+ function isDestinationRace(error) {
759
+ const code = error.code;
760
+ return code === "EEXIST" || code === "ENOTEMPTY";
761
+ }
283
762
  async function ensurePrivateDirectory(path) {
284
763
  await mkdir2(path, { recursive: true, mode: 448 });
285
764
  const info = await lstat2(path);
@@ -303,32 +782,9 @@ async function pathExists(path) {
303
782
 
304
783
  // src/platform/shared/paths.ts
305
784
  import { homedir as homedir2, tmpdir } from "node:os";
306
- import { join as join4, resolve as resolve3 } from "node:path";
785
+ import { join as join5, resolve as resolve3 } from "node:path";
307
786
  function resolveApplicationRoot(env = process.env) {
308
- return env.PIFLEET_APPLICATION_ROOT ?? (process.platform === "darwin" ? join4(homedir2(), "Library", "Application Support", "Pi Fleet", "runtime") : join4(env.XDG_DATA_HOME ?? join4(homedir2(), ".local", "share"), "pi-fleet"));
309
- }
310
- function resolveFleetPaths(env = process.env) {
311
- const explicitRoot = env.PIFLEET_STATE_ROOT;
312
- if (explicitRoot !== void 0) {
313
- const root = resolve3(explicitRoot);
314
- return {
315
- runtimeRoot: root,
316
- stateRoot: root,
317
- socketPath: join4(root, "control.sock"),
318
- databasePath: join4(root, "fleet.sqlite")
319
- };
320
- }
321
- const runtimeRoot = join4(
322
- env.XDG_RUNTIME_DIR ?? tmpdir(),
323
- `pifleet-${process.getuid?.() ?? "user"}`
324
- );
325
- const stateRoot = process.platform === "darwin" ? join4(homedir2(), "Library", "Application Support", "Pi Fleet") : join4(env.XDG_STATE_HOME ?? join4(homedir2(), ".local", "state"), "pi-fleet");
326
- return {
327
- runtimeRoot,
328
- stateRoot,
329
- socketPath: join4(runtimeRoot, "control.sock"),
330
- databasePath: join4(stateRoot, "fleet.sqlite")
331
- };
787
+ return env.PIFLEET_APPLICATION_ROOT ?? (process.platform === "darwin" ? join5(homedir2(), "Library", "Application Support", "pi-fleet", "runtime") : join5(env.XDG_DATA_HOME ?? join5(homedir2(), ".local", "share"), "pi-fleet"));
332
788
  }
333
789
 
334
790
  // src/entry/installer.ts
@@ -338,25 +794,29 @@ var executor = {
338
794
  }
339
795
  };
340
796
  async function installRuntimeService() {
341
- return installMaterializedService(resolveFleetPaths().stateRoot);
797
+ return installMaterializedService(process.env.PIFLEET_STATE_ROOT);
342
798
  }
343
799
  async function repairRuntimeService() {
344
800
  return installMaterializedService(process.env.PIFLEET_STATE_ROOT);
345
801
  }
346
802
  async function installMaterializedService(stateRoot) {
347
- const sourceRoot = dirname2(dirname2(fileURLToPath(import.meta.url)));
803
+ const sourceRoot = dirname3(dirname3(fileURLToPath(import.meta.url)));
348
804
  const release = await materializeRuntime({
349
805
  sourceRoot,
350
806
  applicationRoot: resolveApplicationRoot()
351
807
  });
808
+ const pi = await resolveExternalPiInstallation({ env: process.env });
352
809
  return installUserService({
353
810
  platform: process.platform,
354
811
  definition: {
355
812
  nodePath: process.execPath,
356
- runtimePath: join5(release, "bin", "pifleet-runtime.mjs"),
813
+ runtimePath: join6(release, "bin", "pifleet-runtime.mjs"),
814
+ piExecutablePath: pi.selectedPath,
815
+ piNodePath: pi.nodePath,
357
816
  ...stateRoot === void 0 ? {} : { stateRoot }
358
817
  },
359
- executor
818
+ executor,
819
+ replaceExisting: false
360
820
  });
361
821
  }
362
822
  async function uninstallRuntimeService() {