@jmfederico/pi-web 1.202607.0 → 1.202607.2

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.
Files changed (119) hide show
  1. package/README.md +12 -9
  2. package/dist/cli.js +305 -322
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client/assets/{CodeViewer-tXF3UTsY.js → CodeViewer-B4M13447.js} +1 -1
  5. package/dist/client/assets/{TerminalPanel-D_ihiWAt.js → TerminalPanel-Bmvj-Ecn.js} +1 -1
  6. package/dist/client/assets/{UnifiedDiffViewer-BziirXBJ.js → UnifiedDiffViewer-C0hygwRo.js} +1 -1
  7. package/dist/client/assets/index-TvPsRyS1.js +3545 -0
  8. package/dist/client/index.html +6 -6
  9. package/dist/client/manifest.webmanifest +4 -4
  10. package/dist/config.js +157 -2
  11. package/dist/config.js.map +1 -1
  12. package/dist/nativeServices/serviceDoctor.js +480 -0
  13. package/dist/nativeServices/serviceDoctor.js.map +1 -0
  14. package/dist/nativeServices/serviceInstall.js +35 -0
  15. package/dist/nativeServices/serviceInstall.js.map +1 -0
  16. package/dist/nativeServices/servicePlan.js +358 -0
  17. package/dist/nativeServices/servicePlan.js.map +1 -0
  18. package/dist/nativeServices/serviceProbe.js +447 -0
  19. package/dist/nativeServices/serviceProbe.js.map +1 -0
  20. package/dist/nativeServices/serviceRendering.js +122 -0
  21. package/dist/nativeServices/serviceRendering.js.map +1 -0
  22. package/dist/pi-web-plugins/updates/pi-web-plugin.js +12 -0
  23. package/dist/plugin-api.d.ts +2 -0
  24. package/dist/server/activeAgentProfileProvider.js +25 -0
  25. package/dist/server/activeAgentProfileProvider.js.map +1 -0
  26. package/dist/server/app.js +57 -16
  27. package/dist/server/app.js.map +1 -1
  28. package/dist/server/configRoutes.js +43 -9
  29. package/dist/server/configRoutes.js.map +1 -1
  30. package/dist/server/diagnostics/nodePtyNativeModule.js +36 -0
  31. package/dist/server/diagnostics/nodePtyNativeModule.js.map +1 -0
  32. package/dist/server/git/gitService.js +176 -5
  33. package/dist/server/git/gitService.js.map +1 -1
  34. package/dist/server/machines/machinePluginProxyRoutes.js +7 -4
  35. package/dist/server/machines/machinePluginProxyRoutes.js.map +1 -1
  36. package/dist/server/machines/machineProxyRoutes.js +16 -4
  37. package/dist/server/machines/machineProxyRoutes.js.map +1 -1
  38. package/dist/server/machines/machineRoutes.js +1 -1
  39. package/dist/server/machines/machineRoutes.js.map +1 -1
  40. package/dist/server/machines/machineService.js +2 -2
  41. package/dist/server/machines/machineService.js.map +1 -1
  42. package/dist/server/piPackageRoutes.js +7 -3
  43. package/dist/server/piPackageRoutes.js.map +1 -1
  44. package/dist/server/piPackageService.js +34 -2
  45. package/dist/server/piPackageService.js.map +1 -1
  46. package/dist/server/piWebPluginService.js +48 -12
  47. package/dist/server/piWebPluginService.js.map +1 -1
  48. package/dist/server/piWebReleaseLookupCache.js +37 -0
  49. package/dist/server/piWebReleaseLookupCache.js.map +1 -0
  50. package/dist/server/piWebStatus.js +38 -39
  51. package/dist/server/piWebStatus.js.map +1 -1
  52. package/dist/server/piWebStatusCache.js +19 -6
  53. package/dist/server/piWebStatusCache.js.map +1 -1
  54. package/dist/server/realtime/sessionEventHub.js +37 -7
  55. package/dist/server/realtime/sessionEventHub.js.map +1 -1
  56. package/dist/server/sessiond/sessionDaemonStartup.js +49 -0
  57. package/dist/server/sessiond/sessionDaemonStartup.js.map +1 -0
  58. package/dist/server/sessiond.js +128 -62
  59. package/dist/server/sessiond.js.map +1 -1
  60. package/dist/server/sessions/authProviderOptions.js +30 -32
  61. package/dist/server/sessions/authProviderOptions.js.map +1 -1
  62. package/dist/server/sessions/authRoutes.js +14 -4
  63. package/dist/server/sessions/authRoutes.js.map +1 -1
  64. package/dist/server/sessions/authService.js +140 -29
  65. package/dist/server/sessions/authService.js.map +1 -1
  66. package/dist/server/sessions/globalProviderPolicy.js +98 -0
  67. package/dist/server/sessions/globalProviderPolicy.js.map +1 -0
  68. package/dist/server/sessions/modelCatalogRefresher.js +184 -0
  69. package/dist/server/sessions/modelCatalogRefresher.js.map +1 -0
  70. package/dist/server/sessions/oauthLoginFlowService.js +176 -53
  71. package/dist/server/sessions/oauthLoginFlowService.js.map +1 -1
  72. package/dist/server/sessions/piSessionManagerGateway.js +22 -21
  73. package/dist/server/sessions/piSessionManagerGateway.js.map +1 -1
  74. package/dist/server/sessions/piSessionService.js +936 -111
  75. package/dist/server/sessions/piSessionService.js.map +1 -1
  76. package/dist/server/sessions/plainTextTheme.js +112 -0
  77. package/dist/server/sessions/plainTextTheme.js.map +1 -0
  78. package/dist/server/sessions/sessionArchiveMigration.js +590 -0
  79. package/dist/server/sessions/sessionArchiveMigration.js.map +1 -0
  80. package/dist/server/sessions/sessionArchiveStore.js +7 -4
  81. package/dist/server/sessions/sessionArchiveStore.js.map +1 -1
  82. package/dist/server/sessions/sessionCommandService.js +68 -9
  83. package/dist/server/sessions/sessionCommandService.js.map +1 -1
  84. package/dist/server/sessions/sessionNotificationStore.js +484 -0
  85. package/dist/server/sessions/sessionNotificationStore.js.map +1 -0
  86. package/dist/server/sessions/sessionRoutes.js +202 -2
  87. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  88. package/dist/server/sessions/sessionService.js +2 -0
  89. package/dist/server/sessions/sessionService.js.map +1 -0
  90. package/dist/server/sessions/sessionTreeProjection.js +250 -0
  91. package/dist/server/sessions/sessionTreeProjection.js.map +1 -0
  92. package/dist/server/sessions/sessionUnreadStore.js +520 -0
  93. package/dist/server/sessions/sessionUnreadStore.js.map +1 -0
  94. package/dist/server/sessions/spawnSessionTool.js +2 -2
  95. package/dist/server/sessions/spawnSessionTool.js.map +1 -1
  96. package/dist/server/sessions/spawnSubsessionTool.js +69 -31
  97. package/dist/server/sessions/spawnSubsessionTool.js.map +1 -1
  98. package/dist/server/terminals/terminalService.js +10 -4
  99. package/dist/server/terminals/terminalService.js.map +1 -1
  100. package/dist/sessiond/activeAgentProfile.js +31 -0
  101. package/dist/sessiond/activeAgentProfile.js.map +1 -0
  102. package/dist/sessiond/sessionDaemonClient.js +38 -0
  103. package/dist/sessiond/sessionDaemonClient.js.map +1 -1
  104. package/dist/shared/activeAgentProfile.js +76 -0
  105. package/dist/shared/activeAgentProfile.js.map +1 -0
  106. package/dist/shared/apiTypes.d.ts +256 -4
  107. package/dist/shared/apiTypes.js +12 -0
  108. package/dist/shared/apiTypes.js.map +1 -1
  109. package/dist/shared/capabilities.js +11 -0
  110. package/dist/shared/capabilities.js.map +1 -1
  111. package/dist/shared/federatedRoutes.js +12 -0
  112. package/dist/shared/federatedRoutes.js.map +1 -1
  113. package/dist/shared/piWebStatusParsing.js +6 -0
  114. package/dist/shared/piWebStatusParsing.js.map +1 -1
  115. package/docs/config.md +85 -12
  116. package/docs/plugins.md +29 -11
  117. package/install.sh +1 -1
  118. package/package.json +9 -8
  119. package/dist/client/assets/index-Brfk1lwl.js +0 -2950
package/dist/cli.js CHANGED
@@ -1,44 +1,29 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from "node:child_process";
3
- import { existsSync, readFileSync, realpathSync } from "node:fs";
3
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
4
4
  import { mkdir, rm, writeFile } from "node:fs/promises";
5
5
  import { homedir, userInfo } from "node:os";
6
6
  import { basename, dirname, join, resolve } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
- import { defaultPiWebConfigPath, defaultPiWebDataDir, examplePiWebConfig } from "./config.js";
8
+ import { defaultPiWebConfigPath, defaultPiWebDataDir, effectivePiWebConfig, examplePiWebConfig } from "./config.js";
9
9
  import { packageVersion, printPiWebVersionReport } from "./piWebVersionReport.js";
10
10
  import { checkNodePtyDarwinSpawnHelper, formatNodePtyDarwinSpawnHelperCheck } from "./server/diagnostics/nodePtySpawnHelper.js";
11
+ import { checkNodePtyNativeModule, formatNodePtyNativeModuleCheck } from "./server/diagnostics/nodePtyNativeModule.js";
12
+ import { installNativeServiceCandidate, nativeServiceInstallFailureNeedsPathAdvice, } from "./nativeServices/serviceInstall.js";
13
+ import { minimumSupportedNodeVersion, nativeServiceManagerRefs, productionNativeServiceIds, } from "./nativeServices/servicePlan.js";
14
+ import { formatNativeServiceDoctorResult, inferInstalledNativeServiceMode, inspectInstalledDevelopmentServiceInput, inspectInstalledProductionServiceContext, runNativeServiceDoctor, } from "./nativeServices/serviceDoctor.js";
15
+ import { createNativeServiceAuthoritativeProbe, nativeServicePrerequisiteShellCheck, } from "./nativeServices/serviceProbe.js";
16
+ import { renderLaunchdPlist, renderSystemdUnit } from "./nativeServices/serviceRendering.js";
11
17
  const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
12
18
  const systemdServiceDir = join(homedir(), ".config", "systemd", "user");
13
19
  const launchdServiceDir = join(homedir(), "Library", "LaunchAgents");
14
20
  const logDir = join(defaultPiWebDataDir(), "logs");
15
- const sessiondServiceName = "pi-web-sessiond.service";
16
- const webServiceName = "pi-web.service";
17
- const uiDevServiceName = "pi-web-ui-dev.service";
18
21
  const serviceRefs = {
19
- sessiond: {
20
- id: "sessiond",
21
- systemdName: sessiondServiceName,
22
- launchdLabel: "com.pi-web.sessiond",
23
- launchdPlistName: "com.pi-web.sessiond.plist",
24
- logName: "sessiond.log",
25
- },
26
- web: {
27
- id: "web",
28
- systemdName: webServiceName,
29
- launchdLabel: "com.pi-web.web",
30
- launchdPlistName: "com.pi-web.web.plist",
31
- logName: "web.log",
32
- },
33
- uiDev: {
34
- id: "uiDev",
35
- systemdName: uiDevServiceName,
36
- launchdLabel: "com.pi-web.ui-dev",
37
- launchdPlistName: "com.pi-web.ui-dev.plist",
38
- logName: "ui-dev.log",
39
- },
22
+ sessiond: { id: "sessiond", ...nativeServiceManagerRefs.sessiond },
23
+ web: { id: "web", ...nativeServiceManagerRefs.web },
24
+ uiDev: { id: "uiDev", ...nativeServiceManagerRefs.uiDev },
40
25
  };
41
- const productionServiceIds = ["sessiond", "web"];
26
+ const productionServiceIds = [...productionNativeServiceIds];
42
27
  const startServiceOrder = ["sessiond", "web", "uiDev"];
43
28
  const stopServiceOrder = ["web", "uiDev", "sessiond"];
44
29
  // Restart web/UI before sessiond: when `pi-web restart` runs in a pi-web
@@ -54,13 +39,16 @@ function platformLabel() {
54
39
  return "Windows";
55
40
  return process.platform;
56
41
  }
57
- function currentServiceBackend() {
58
- if (process.platform === "linux")
42
+ export function serviceBackendForPlatform(platform) {
43
+ if (platform === "linux")
59
44
  return { kind: "systemd", label: "systemd user services" };
60
- if (process.platform === "darwin")
45
+ if (platform === "darwin")
61
46
  return { kind: "launchd", label: "LaunchAgents" };
62
47
  return undefined;
63
48
  }
49
+ function currentServiceBackend() {
50
+ return serviceBackendForPlatform(process.platform);
51
+ }
64
52
  function requireServiceBackend(command) {
65
53
  const backend = currentServiceBackend();
66
54
  if (backend !== undefined)
@@ -105,7 +93,7 @@ function runQuiet(command, args) {
105
93
  return capture(command, args).status;
106
94
  }
107
95
  function hasCommand(command) {
108
- return capture("/usr/bin/env", ["sh", "-c", `command -v ${command}`]).status === 0;
96
+ return capture("/usr/bin/env", ["sh", "-c", `command -v ${shellSingleQuote(command)}`]).status === 0;
109
97
  }
110
98
  function isLingerEnabled() {
111
99
  if (!hasCommand("loginctl"))
@@ -174,105 +162,50 @@ function shellSingleQuote(value) {
174
162
  function fishSingleQuote(value) {
175
163
  return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
176
164
  }
177
- function systemdEscape(value) {
178
- return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
179
- }
180
- function systemdQuotedValue(value) {
181
- return `"${systemdEscape(value)}"`;
182
- }
183
- function xmlEscape(value) {
184
- return value
185
- .replaceAll("&", "&")
186
- .replaceAll("<", "&lt;")
187
- .replaceAll(">", "&gt;")
188
- .replaceAll('"', "&quot;")
189
- .replaceAll("'", "&apos;");
190
- }
191
165
  function packageRootPath() {
192
166
  return dirname(dirname(fileURLToPath(import.meta.url)));
193
167
  }
194
168
  function packageEntrypointPath(name) {
195
169
  return join(packageRootPath(), "dist", "server", name === "server" ? "index.js" : "sessiond.js");
196
170
  }
171
+ export function regularFileExists(path) {
172
+ return existsSync(path) && statSync(path).isFile();
173
+ }
197
174
  function detectServiceShell() {
198
175
  const userShell = userInfo().shell ?? undefined;
199
176
  const envShell = process.env["SHELL"]?.trim();
200
177
  const detected = envShell === undefined || envShell === "" ? userShell : envShell;
201
178
  const name = basename(detected ?? "").replace(/^-/, "");
202
179
  if (name === "bash" || name === "zsh" || name === "fish") {
203
- return { name, executable: detected ?? name, detected: detected ?? name, fallback: false };
180
+ return {
181
+ name,
182
+ executable: detected ?? name,
183
+ source: "detected",
184
+ detectedExecutable: detected ?? name,
185
+ };
204
186
  }
205
- return { name: "bash", executable: "bash", ...(detected === undefined ? {} : { detected }), fallback: true };
187
+ return {
188
+ name: "bash",
189
+ executable: "bash",
190
+ source: "fallback",
191
+ detectedExecutable: detected ?? null,
192
+ };
206
193
  }
207
194
  function serviceShellCommand(command, cwd) {
208
195
  const fullCommand = cwd === undefined ? command : `cd ${serviceShellQuote(cwd)} && ${command}`;
209
196
  return ["/usr/bin/env", detectServiceShell().executable, "-lc", fullCommand];
210
197
  }
211
- function serviceShellExecPrefix() {
212
- return `/usr/bin/env ${detectServiceShell().executable} -lc`;
213
- }
214
198
  function serviceShellQuote(value) {
215
199
  return detectServiceShell().name === "fish" ? fishSingleQuote(value) : shellSingleQuote(value);
216
200
  }
217
- function systemdServiceShellQuote(value) {
218
- return serviceShellQuote(value.replaceAll("%", "%%").replaceAll("$", "$$"));
219
- }
220
- function checkSucceeds(command) {
221
- const [bin, ...args] = command;
222
- return bin !== undefined && capture(bin, args).status === 0;
223
- }
224
- function serviceShellCanFindCommand(command, backend) {
225
- if (!checkSucceeds(serviceShellCommand(commandCheck(command))))
226
- return false;
227
- if (backend.kind === "systemd")
228
- return checkSucceeds(systemdUserServiceShellCommand(commandCheck(command)));
229
- return true;
230
- }
231
- function readableFileCheck(path) {
232
- const quoted = serviceShellQuote(path);
233
- return `test -r ${quoted} && printf '%s\\n' ${quoted}`;
234
- }
235
- function commandExecutable(command, backend) {
236
- const shell = serviceShellLabel();
237
- const checks = [[`${shell} can find ${command}`, serviceShellCommand(commandCheck(command))]];
238
- if (backend.kind === "systemd") {
239
- checks.push([`systemd user ${shell} can find ${command}`, systemdUserServiceShellCommand(commandCheck(command))]);
240
- }
241
- return { command, checks };
242
- }
243
- function bundledExecutable(command, entrypointPath, backend) {
244
- const shell = serviceShellLabel();
245
- const check = readableFileCheck(entrypointPath);
246
- const checks = [[`${shell} can access bundled ${command} entrypoint`, serviceShellCommand(check)]];
247
- if (backend.kind === "systemd") {
248
- checks.push([`systemd user ${shell} can access bundled ${command} entrypoint`, systemdUserServiceShellCommand(check)]);
249
- }
250
- return { command: `node ${serviceShellQuote(entrypointPath)}`, checks };
251
- }
252
- function serviceExecutable(envName, command, entrypointPath, backend) {
253
- const configured = process.env[envName]?.trim();
254
- if (configured !== undefined && configured !== "")
255
- return { command: configured, checks: [] };
256
- if (serviceShellCanFindCommand(command, backend))
257
- return commandExecutable(command, backend);
258
- if (existsSync(entrypointPath))
259
- return bundledExecutable(command, entrypointPath, backend);
260
- return commandExecutable(command, backend);
261
- }
262
- function resolveServiceExecutables(backend) {
263
- return {
264
- sessiond: serviceExecutable("PI_WEB_SESSIOND_EXEC", "pi-web-sessiond", packageEntrypointPath("sessiond"), backend),
265
- web: serviceExecutable("PI_WEB_SERVER_EXEC", "pi-web-server", packageEntrypointPath("server"), backend),
266
- };
267
- }
268
201
  function describeServiceShell() {
269
202
  const shell = detectServiceShell();
270
- if (shell.fallback) {
271
- return shell.detected === undefined
203
+ if (shell.source === "fallback") {
204
+ return shell.detectedExecutable === null
272
205
  ? "could not detect a supported login shell; using bash"
273
- : `detected ${shell.detected}; using bash because PI WEB currently supports bash, zsh, and fish`;
206
+ : `detected ${shell.detectedExecutable}; using bash because PI WEB currently supports bash, zsh, and fish`;
274
207
  }
275
- return shell.detected === undefined ? shell.name : `${shell.name} (${shell.detected})`;
208
+ return shell.detectedExecutable === null ? shell.name : `${shell.name} (${shell.detectedExecutable})`;
276
209
  }
277
210
  function configEnvironment(options, configPath) {
278
211
  return options.config === undefined ? {} : { PI_WEB_CONFIG: configPath };
@@ -302,27 +235,6 @@ function stopOrder(refs) {
302
235
  function restartOrder(refs) {
303
236
  return orderServiceRefs(refs, restartServiceOrder);
304
237
  }
305
- function productionServiceDefinitions(options, configPath, executables) {
306
- const environment = configEnvironment(options, configPath);
307
- return [
308
- {
309
- ...serviceRefs.sessiond,
310
- description: "PI WEB session daemon",
311
- shellCommand: `exec ${executables.sessiond.command}`,
312
- restart: "on-failure",
313
- environment,
314
- },
315
- {
316
- ...serviceRefs.web,
317
- description: "PI WEB server",
318
- shellCommand: `exec ${executables.web.command}`,
319
- restart: "on-failure",
320
- environment,
321
- after: ["sessiond"],
322
- wants: ["sessiond"],
323
- },
324
- ];
325
- }
326
238
  function devRootPath() {
327
239
  return resolve(process.cwd());
328
240
  }
@@ -335,97 +247,18 @@ function validateDevCheckout(root) {
335
247
  if (!isRecord(parsed) || parsed["name"] !== PI_WEB_PACKAGE_NAME) {
336
248
  throw new Error(`Development mode must be installed from a PI WEB checkout. ${packageJsonPath} is not ${PI_WEB_PACKAGE_NAME}.`);
337
249
  }
338
- const scripts = parsed["scripts"];
339
- if (!isRecord(scripts))
340
- throw new Error(`Development mode requires npm scripts in ${packageJsonPath}.`);
341
- const requiredScripts = ["start:sessiond", "dev:web", "dev:client"];
342
- const missing = requiredScripts.filter((script) => typeof scripts[script] !== "string");
343
- if (missing.length > 0)
344
- throw new Error(`Development mode requires missing npm scripts: ${missing.join(", ")}.`);
345
- }
346
- function devServiceDefinitions(options, configPath, root) {
347
- const environment = configEnvironment(options, configPath);
348
- return [
349
- {
350
- ...serviceRefs.sessiond,
351
- description: "PI WEB session daemon (dev)",
352
- shellCommand: "exec npm run start:sessiond",
353
- restart: "never",
354
- environment,
355
- workingDirectory: root,
356
- },
357
- {
358
- ...serviceRefs.uiDev,
359
- description: "PI WEB UI dev server",
360
- shellCommand: `exec /usr/bin/env bash -c ${serviceShellQuote('trap "kill 0" EXIT; npm run dev:web & npm run dev:client & wait')}`,
361
- restart: "never",
362
- environment,
363
- after: ["sessiond"],
364
- wants: ["sessiond"],
365
- workingDirectory: root,
366
- },
367
- ];
368
- }
369
- function dependencyLine(name, ids) {
370
- if (ids === undefined || ids.length === 0)
371
- return "";
372
- return `${name}=${ids.map((id) => serviceRefs[id].systemdName).join(" ")}\n`;
373
- }
374
- function environmentLines(environment) {
375
- return Object.entries(environment)
376
- .map(([key, value]) => `Environment="${key}=${systemdEscape(value)}"\n`)
377
- .join("");
378
- }
379
- function systemdUnit(service) {
380
- const workingDirectory = service.workingDirectory === undefined ? "" : `WorkingDirectory=${systemdQuotedValue(service.workingDirectory)}\n`;
381
- const restart = service.restart === "on-failure" ? "Restart=on-failure\nRestartSec=2\n" : "Restart=no\n";
382
- return `[Unit]
383
- Description=${service.description}
384
- ${dependencyLine("After", service.after)}${dependencyLine("Wants", service.wants)}
385
- [Service]
386
- Type=simple
387
- ${workingDirectory}${environmentLines(service.environment)}ExecStart=${serviceShellExecPrefix()} ${systemdServiceShellQuote(service.shellCommand)}
388
- ${restart}
389
- [Install]
390
- WantedBy=default.target
391
- `;
392
- }
393
- function plistString(key, value, indent = " ") {
394
- return `${indent}<key>${xmlEscape(key)}</key>\n${indent}<string>${xmlEscape(value)}</string>\n`;
395
- }
396
- function plistProgramArguments(service) {
397
- const args = ["/usr/bin/env", detectServiceShell().executable, "-lc", service.shellCommand];
398
- return ` <key>ProgramArguments</key>\n <array>\n${args.map((arg) => ` <string>${xmlEscape(arg)}</string>`).join("\n")}\n </array>\n`;
399
- }
400
- function plistEnvironment(environment) {
401
- const entries = Object.entries(environment);
402
- if (entries.length === 0)
403
- return "";
404
- return ` <key>EnvironmentVariables</key>\n <dict>\n${entries.map(([key, value]) => plistString(key, value, " ")).join("")} </dict>\n`;
405
250
  }
406
251
  function launchdLogPath(ref) {
407
252
  return join(logDir, ref.logName);
408
253
  }
409
- function launchdPlist(service) {
410
- const workingDirectory = service.workingDirectory === undefined ? "" : plistString("WorkingDirectory", service.workingDirectory);
411
- const keepAlive = service.restart === "on-failure" ? " <key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n </dict>\n" : "";
412
- return `<?xml version="1.0" encoding="UTF-8"?>
413
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
414
- <plist version="1.0">
415
- <dict>
416
- ${plistString("Label", service.launchdLabel)}${plistProgramArguments(service)}${workingDirectory}${plistEnvironment(service.environment)} <key>RunAtLoad</key>
417
- <true/>
418
- ${keepAlive}${plistString("StandardOutPath", launchdLogPath(service))}${plistString("StandardErrorPath", launchdLogPath(service))}</dict>
419
- </plist>
420
- `;
421
- }
422
- async function writeInitialConfig(options) {
423
- const configPath = options.config === undefined ? defaultPiWebConfigPath() : resolve(options.config);
254
+ function installConfigPath(options) {
255
+ return options.config === undefined ? defaultPiWebConfigPath() : resolve(options.config);
256
+ }
257
+ async function writeInitialConfig(options, configPath) {
424
258
  await mkdir(dirname(configPath), { recursive: true });
425
259
  if (!existsSync(configPath)) {
426
260
  await writeFile(configPath, examplePiWebConfig({ host: options.host, port: Number(options.port) }));
427
261
  }
428
- return configPath;
429
262
  }
430
263
  function systemdServicePath(ref) {
431
264
  return join(systemdServiceDir, ref.systemdName);
@@ -446,18 +279,18 @@ function installedServiceRefs(backend) {
446
279
  const installed = startOrder(allServiceRefs().filter((ref) => serviceFileExists(backend, ref)));
447
280
  return installed.length === 0 ? productionServiceRefs() : installed;
448
281
  }
449
- async function installSystemdServices(services) {
450
- const selected = new Set(services.map((service) => service.id));
282
+ async function installSystemdServices(plan) {
283
+ const selected = new Set(plan.services.map((service) => service.id));
451
284
  const obsolete = stopOrder(allServiceRefs().filter((ref) => !selected.has(ref.id)));
452
285
  for (const ref of obsolete) {
453
286
  runQuiet("systemctl", ["--user", "disable", "--now", ref.systemdName]);
454
287
  await rm(systemdServicePath(ref), { force: true });
455
288
  }
456
289
  await mkdir(systemdServiceDir, { recursive: true });
457
- for (const service of services) {
458
- await writeFile(systemdServicePath(service), systemdUnit(service));
290
+ for (const service of plan.services) {
291
+ await writeFile(join(systemdServiceDir, service.manager.systemdName), renderSystemdUnit(plan, service));
459
292
  }
460
- const names = services.map((service) => service.systemdName);
293
+ const names = plan.services.map((service) => service.manager.systemdName);
461
294
  run("systemctl", ["--user", "daemon-reload"], { check: true });
462
295
  run("systemctl", ["--user", "enable", ...names], { check: true });
463
296
  run("systemctl", ["--user", "restart", ...names], { check: true });
@@ -483,8 +316,8 @@ function launchdStart(ref) {
483
316
  launchdBootstrap(ref);
484
317
  run("launchctl", ["kickstart", launchdServiceTarget(ref)], { check: true });
485
318
  }
486
- async function installLaunchdServices(services) {
487
- const selected = new Set(services.map((service) => service.id));
319
+ async function installLaunchdServices(plan) {
320
+ const selected = new Set(plan.services.map((service) => service.id));
488
321
  await mkdir(launchdServiceDir, { recursive: true });
489
322
  await mkdir(logDir, { recursive: true });
490
323
  for (const ref of stopOrder(allServiceRefs()))
@@ -492,17 +325,21 @@ async function installLaunchdServices(services) {
492
325
  for (const ref of allServiceRefs().filter((candidate) => !selected.has(candidate.id))) {
493
326
  await rm(launchdPlistPath(ref), { force: true });
494
327
  }
495
- for (const service of services) {
496
- await writeFile(launchdPlistPath(service), launchdPlist(service));
328
+ for (const service of plan.services) {
329
+ const plistPath = join(launchdServiceDir, service.manager.launchdPlistName);
330
+ await writeFile(plistPath, renderLaunchdPlist(plan, service, logDir));
497
331
  }
498
- for (const service of services)
499
- launchdStart(service);
332
+ for (const service of plan.services)
333
+ launchdStart(serviceRefFromPlan(service.id, service.manager));
500
334
  }
501
- async function installNativeServices(backend, services) {
502
- if (backend.kind === "systemd")
503
- await installSystemdServices(services);
335
+ async function installNativeServices(plan) {
336
+ if (plan.backend.kind === "systemd")
337
+ await installSystemdServices(plan);
504
338
  else
505
- await installLaunchdServices(services);
339
+ await installLaunchdServices(plan);
340
+ }
341
+ function serviceRefFromPlan(id, manager) {
342
+ return { id, ...manager };
506
343
  }
507
344
  async function uninstallSystemdServices() {
508
345
  for (const ref of stopOrder(allServiceRefs())) {
@@ -597,6 +434,15 @@ function parseLaunchdField(output, field) {
597
434
  const match = new RegExp(`^\\s*${field}\\s=\\s(.+)$`, "m").exec(output);
598
435
  return match?.[1]?.trim();
599
436
  }
437
+ export function launchdRuntimeDetails(output) {
438
+ const state = parseLaunchdField(output, "state") ?? "unknown";
439
+ const pid = parseLaunchdField(output, "pid");
440
+ const lastExitCode = parseLaunchdField(output, "last exit code");
441
+ const detail = state === "running"
442
+ ? "running"
443
+ : lastExitCode === undefined ? state : `${state} (last exit code ${lastExitCode})`;
444
+ return { state, detail, pid };
445
+ }
600
446
  function launchdRuntimeStatus(backend, ref) {
601
447
  const target = launchdServiceTarget(ref);
602
448
  const filePath = serviceFilePath(backend, ref);
@@ -606,10 +452,9 @@ function launchdRuntimeStatus(backend, ref) {
606
452
  if (result.status !== 0) {
607
453
  return makeServiceRuntimeStatus(ref, "stopped", firstOutputLine(result.stderr, result.stdout) ?? "not loaded", target, filePath);
608
454
  }
609
- const state = parseLaunchdField(result.stdout, "state") ?? "unknown";
610
- const pid = parseLaunchdField(result.stdout, "pid");
611
- const health = state === "running" ? "running" : state === "unknown" ? "unknown" : "stopped";
612
- return makeServiceRuntimeStatus(ref, health, state === "running" ? "running" : state, target, filePath, pid);
455
+ const details = launchdRuntimeDetails(result.stdout);
456
+ const health = details.state === "running" ? "running" : details.state === "unknown" ? "unknown" : "stopped";
457
+ return makeServiceRuntimeStatus(ref, health, details.detail, target, filePath, details.pid);
613
458
  }
614
459
  function runtimeStatus(backend, ref) {
615
460
  return backend.kind === "systemd" ? systemdRuntimeStatus(backend, ref) : launchdRuntimeStatus(backend, ref);
@@ -635,36 +480,77 @@ function printServiceStatusReport(backend) {
635
480
  console.log("\nUse `pi-web logs` for service logs.");
636
481
  return statuses.every((status) => status.health === "running");
637
482
  }
638
- function backendAvailabilityChecks(backend) {
639
- if (backend.kind === "systemd")
640
- return [["systemctl --user", ["systemctl", "--user", "--version"]]];
641
- return [[`launchctl ${launchdDomain()}`, ["launchctl", "print", launchdDomain()]]];
483
+ function configuredServiceCommand(name) {
484
+ const value = process.env[name];
485
+ return value === undefined || value.trim() === "" ? undefined : value;
642
486
  }
643
- function baseShellChecks(backend) {
644
- const shell = serviceShellLabel();
645
- const checks = [[`${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())]];
646
- if (backend.kind === "systemd")
647
- checks.push([`systemd user ${shell} can find node >= 22`, systemdUserServiceShellCommand(nodeVersionCheck())]);
648
- return checks;
487
+ function productionNativeServicePlanInput(backend, shell, environment) {
488
+ return {
489
+ backend,
490
+ shell,
491
+ environment,
492
+ executables: {
493
+ sessiond: {
494
+ configuredCommand: configuredServiceCommand("PI_WEB_SESSIOND_EXEC"),
495
+ namedCommand: "pi-web-sessiond",
496
+ bundledEntrypointPath: packageEntrypointPath("sessiond"),
497
+ },
498
+ web: {
499
+ configuredCommand: configuredServiceCommand("PI_WEB_SERVER_EXEC"),
500
+ namedCommand: "pi-web-server",
501
+ bundledEntrypointPath: packageEntrypointPath("server"),
502
+ },
503
+ },
504
+ };
649
505
  }
650
- function devInstallChecks(backend, root) {
651
- const shell = serviceShellLabel();
652
- const checks = [
653
- [`${shell} can find npm`, serviceShellCommand(commandCheck("npm"), root)],
654
- [`${shell} can find bash`, serviceShellCommand(commandCheck("bash"), root)],
655
- ];
656
- if (backend.kind === "systemd") {
657
- checks.push([`systemd user ${shell} can find npm`, systemdUserServiceShellCommand(commandCheck("npm"), root)], [`systemd user ${shell} can find bash`, systemdUserServiceShellCommand(commandCheck("bash"), root)]);
506
+ function nativeServiceInstallCandidate(options, backend, configPath, devRoot) {
507
+ const shell = detectServiceShell();
508
+ const environment = configEnvironment(options, configPath);
509
+ if (options.mode === "production") {
510
+ return {
511
+ mode: "production",
512
+ input: productionNativeServicePlanInput(backend, shell, environment),
513
+ };
658
514
  }
659
- return checks;
515
+ const root = devRoot ?? devRootPath();
516
+ return {
517
+ mode: "development",
518
+ input: {
519
+ backend,
520
+ shell,
521
+ environment,
522
+ workingDirectory: root,
523
+ packageJsonPath: join(root, "package.json"),
524
+ },
525
+ };
660
526
  }
661
- function installPreflightChecks(backend, mode, executables, devRoot) {
662
- return [
663
- ...backendAvailabilityChecks(backend),
664
- ...baseShellChecks(backend),
665
- ...(mode === "dev" && devRoot !== undefined ? devInstallChecks(backend, devRoot) : []),
666
- ...(mode === "production" && executables !== undefined ? [...executables.web.checks, ...executables.sessiond.checks] : []),
667
- ];
527
+ function printNativeServiceInstallFailure(failure) {
528
+ if (failure.kind === "plan-resolution") {
529
+ for (const item of failure.failures) {
530
+ if (item.kind === "probe-infrastructure") {
531
+ console.log(`✗ Service-manager probe infrastructure failure (${item.reason}): ${item.message}`);
532
+ }
533
+ else if (item.kind === "entrypoint-inspection-failure") {
534
+ console.log(`✗ Could not inspect bundled ${item.serviceId} entrypoint ${item.entrypointPath}: ${item.message}`);
535
+ }
536
+ else {
537
+ console.log(`✗ ${item.namedCommand} is unavailable to the service manager, and bundled entrypoint ${item.bundledEntrypointPath} is missing.`);
538
+ if (item.namedCommandFailure !== null)
539
+ console.log(` ${item.namedCommandFailure}`);
540
+ }
541
+ }
542
+ return;
543
+ }
544
+ for (const item of failure.failures) {
545
+ if (item.kind === "probe-infrastructure") {
546
+ console.log(`✗ Service-manager probe infrastructure failure (${item.reason}): ${item.message}`);
547
+ }
548
+ else {
549
+ console.log(`✗ ${item.prerequisite.description}`);
550
+ if (item.detail !== null && item.detail !== item.prerequisite.description)
551
+ console.log(` ${item.detail}`);
552
+ }
553
+ }
668
554
  }
669
555
  async function install(args) {
670
556
  const backend = requireServiceBackend("pi-web install");
@@ -672,19 +558,29 @@ async function install(args) {
672
558
  const devRoot = options.mode === "dev" ? devRootPath() : undefined;
673
559
  if (devRoot !== undefined)
674
560
  validateDevCheckout(devRoot);
675
- const executables = options.mode === "production" ? resolveServiceExecutables(backend) : undefined;
561
+ const configPath = installConfigPath(options);
562
+ const candidate = nativeServiceInstallCandidate(options, backend, configPath, devRoot);
676
563
  console.log(`Running PI WEB ${options.mode} install preflight checks...`);
677
564
  console.log(`Service backend: ${backend.label}`);
678
565
  console.log(`Service shell: ${describeServiceShell()}`);
679
- if (!runChecks(installPreflightChecks(backend, options.mode, executables, devRoot))) {
680
- printPathSetupAdvice();
681
- throw new Error("Install preflight checks failed. Fix the failed checks above, then run `pi-web doctor` for more detail.");
682
- }
683
- const configPath = await writeInitialConfig(options);
684
- const services = options.mode === "dev"
685
- ? devServiceDefinitions(options, configPath, devRoot ?? devRootPath())
686
- : productionServiceDefinitions(options, configPath, executables ?? resolveServiceExecutables(backend));
687
- await installNativeServices(backend, services);
566
+ if (!printNodePtyNativeModuleCheck()) {
567
+ throw new Error("Install preflight checks failed without changing config or services. Fix the failure above, then run `pi-web doctor` for more detail.");
568
+ }
569
+ const result = await installNativeServiceCandidate(candidate, {
570
+ probe: createNativeServiceAuthoritativeProbe(),
571
+ fileExists: regularFileExists,
572
+ writeInitialConfig: () => writeInitialConfig(options, configPath),
573
+ replaceServices: installNativeServices,
574
+ });
575
+ if (!result.ok) {
576
+ printNativeServiceInstallFailure(result.failure);
577
+ if (nativeServiceInstallFailureNeedsPathAdvice(result.failure))
578
+ printPathSetupAdvice();
579
+ throw new Error("Install preflight checks failed without changing config or services. Fix the failure above, then run `pi-web doctor` for more detail.");
580
+ }
581
+ for (const service of result.plan.services.filter((item) => item.strategy.kind === "configured-override")) {
582
+ console.log(`! ${service.description} uses a configured command override; preflight did not execute that arbitrary command.`);
583
+ }
688
584
  console.log(`\nPI WEB ${options.mode} services are installed and starting.`);
689
585
  console.log(`Config: ${configPath}`);
690
586
  if (options.mode === "dev") {
@@ -761,55 +657,37 @@ function logs() {
761
657
  function serviceShellLabel() {
762
658
  return `${detectServiceShell().name} -lc`;
763
659
  }
764
- function systemdUserServiceShellCommand(command, cwd) {
765
- return [
766
- "systemd-run",
767
- "--user",
768
- "--wait",
769
- "--collect",
770
- "--pipe",
771
- "--quiet",
772
- ...serviceShellCommand(command, cwd),
773
- ];
774
- }
775
660
  function commandCheck(command) {
776
- return `command -v ${command}`;
661
+ return `command -v ${serviceShellQuote(command)}`;
777
662
  }
778
663
  export function commandWithVersionCheck(command) {
779
664
  const found = commandCheck(command);
665
+ const commandWord = serviceShellQuote(command);
780
666
  if (detectServiceShell().name === "fish") {
781
- return `${found} && begin; ${command} --version 2>&1 || true; end`;
667
+ return `${found} && begin; ${commandWord} --version 2>&1 || true; end`;
782
668
  }
783
- return `${found} && (${command} --version 2>&1 || true)`;
669
+ return `${found} && (${commandWord} --version 2>&1 || true)`;
784
670
  }
785
- function nodeVersionCheck() {
786
- return [
787
- commandCheck("node"),
788
- "node -e \"const major = Number(process.versions.node.split('.')[0]); console.log(process.version); process.exit(major >= 22 ? 0 : 1);\"",
789
- ].join(" && ");
671
+ export function nodeVersionCheck() {
672
+ return nativeServicePrerequisiteShellCheck(detectServiceShell().name, {
673
+ id: "caller.node",
674
+ kind: "node-version",
675
+ command: "node",
676
+ minimumVersion: minimumSupportedNodeVersion,
677
+ description: `node >= ${minimumSupportedNodeVersion}`,
678
+ });
790
679
  }
791
- function doctorChecks() {
680
+ export function agentCommandForChecks(env = process.env) {
681
+ return effectivePiWebConfig({ env }).config.agent.command;
682
+ }
683
+ function generalDoctorChecks() {
792
684
  const shell = serviceShellLabel();
793
- const backend = currentServiceBackend();
794
- if (backend === undefined) {
795
- return [
796
- [`${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())],
797
- [`${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))],
798
- [`${shell} can find pi`, serviceShellCommand(commandWithVersionCheck("pi"))],
799
- ];
800
- }
801
- const checks = [
802
- ...backendAvailabilityChecks(backend),
803
- ...baseShellChecks(backend),
804
- [`${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))],
805
- [`${shell} can find pi`, serviceShellCommand(commandWithVersionCheck("pi"))],
685
+ const agentCommand = agentCommandForChecks();
686
+ return [
687
+ [`Caller login ${shell} can find node >= ${minimumSupportedNodeVersion}`, serviceShellCommand(nodeVersionCheck())],
688
+ [`Caller login ${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))],
689
+ [`Caller login ${shell} can find ${agentCommand}`, serviceShellCommand(commandWithVersionCheck(agentCommand))],
806
690
  ];
807
- const executables = resolveServiceExecutables(backend);
808
- checks.push(...executables.web.checks, ...executables.sessiond.checks);
809
- if (backend.kind === "systemd") {
810
- checks.push([`systemd user ${shell} can find pi`, systemdUserServiceShellCommand(commandWithVersionCheck("pi"))]);
811
- }
812
- return checks;
813
691
  }
814
692
  function runChecks(checks) {
815
693
  let failed = false;
@@ -837,11 +715,7 @@ function printCheckOutput(output) {
837
715
  }
838
716
  function optionalDoctorChecks() {
839
717
  const shell = serviceShellLabel();
840
- const backend = currentServiceBackend();
841
- const checks = [[`${shell} can find optional ripgrep (rg)`, serviceShellCommand(commandCheck("rg"))]];
842
- if (backend?.kind === "systemd")
843
- checks.push([`systemd user ${shell} can find optional ripgrep (rg)`, systemdUserServiceShellCommand(commandCheck("rg"))]);
844
- return checks;
718
+ return [[`Caller login ${shell} can find optional ripgrep (rg)`, serviceShellCommand(commandCheck("rg"))]];
845
719
  }
846
720
  function printOptionalDoctorChecks() {
847
721
  let missingOptionalTool = false;
@@ -860,8 +734,98 @@ function printOptionalDoctorChecks() {
860
734
  console.log(" PI WEB falls back to a bounded filesystem scan when rg is unavailable.");
861
735
  }
862
736
  }
863
- function printPathSetupAdvice() {
864
- const shell = detectServiceShell();
737
+ function installedServiceDefinitions(backend, ids) {
738
+ return ids.map((id) => ({
739
+ id,
740
+ contents: readFileSync(serviceFilePath(backend, serviceRefs[id]), "utf8"),
741
+ }));
742
+ }
743
+ function nativeServiceDoctorTarget(backend) {
744
+ const ids = installedServiceIds(backend);
745
+ const mode = inferInstalledNativeServiceMode(ids);
746
+ if (mode === "ambiguous") {
747
+ return {
748
+ kind: "inspection-failure",
749
+ message: `installed service IDs do not identify one mode (${[...ids].join(", ") || "none"}).`,
750
+ };
751
+ }
752
+ if (mode === "none") {
753
+ return {
754
+ kind: "prospective-production",
755
+ input: productionNativeServicePlanInput(backend, detectServiceShell(), {}),
756
+ reason: "no installed service strategy is available",
757
+ };
758
+ }
759
+ const expectedIds = mode === "production"
760
+ ? productionNativeServiceIds
761
+ : ["sessiond", "uiDev"];
762
+ const missingId = expectedIds.find((id) => !ids.has(id));
763
+ if (missingId !== undefined) {
764
+ return {
765
+ kind: "inspection-failure",
766
+ message: `installed ${mode} service set is incomplete; ${missingId} is missing.`,
767
+ };
768
+ }
769
+ let definitions;
770
+ try {
771
+ definitions = installedServiceDefinitions(backend, expectedIds);
772
+ }
773
+ catch (error) {
774
+ return {
775
+ kind: "inspection-failure",
776
+ message: error instanceof Error ? error.message : String(error),
777
+ };
778
+ }
779
+ if (mode === "development") {
780
+ const inspection = inspectInstalledDevelopmentServiceInput(backend, definitions);
781
+ return inspection.ok
782
+ ? { kind: "installed-development", input: inspection.value }
783
+ : { kind: "inspection-failure", message: inspection.message };
784
+ }
785
+ const inspection = inspectInstalledProductionServiceContext(backend, definitions);
786
+ return inspection.ok
787
+ ? {
788
+ kind: "prospective-production",
789
+ input: productionNativeServicePlanInput(backend, inspection.value.shell, inspection.value.environment),
790
+ reason: "installed executable strategy is not recorded",
791
+ }
792
+ : { kind: "inspection-failure", message: inspection.message };
793
+ }
794
+ async function printNativeServiceDoctorChecks(backend) {
795
+ const result = await runNativeServiceDoctor(nativeServiceDoctorTarget(backend), {
796
+ probe: createNativeServiceAuthoritativeProbe(),
797
+ fileExists: regularFileExists,
798
+ });
799
+ const report = formatNativeServiceDoctorResult(result);
800
+ for (const line of report.lines)
801
+ console.log(line);
802
+ printCallerContextComparisons(report);
803
+ return report;
804
+ }
805
+ function printCallerContextComparisons(report) {
806
+ if (report.plan === null || report.failedPrerequisites.length === 0)
807
+ return;
808
+ const seen = new Set();
809
+ for (const prerequisite of report.failedPrerequisites) {
810
+ if (seen.has(prerequisite.id))
811
+ continue;
812
+ seen.add(prerequisite.id);
813
+ const service = report.plan.services.find((candidate) => candidate.prerequisites.some((item) => item.id === prerequisite.id));
814
+ const command = nativeServicePrerequisiteShellCheck(report.plan.shell.name, prerequisite);
815
+ const result = captureServiceShell(report.plan.shell, command, service?.workingDirectory ?? null);
816
+ console.log(` Caller-invoked ${report.plan.shell.name} -lc ${result.status === 0 ? "satisfies" : "also does not satisfy"} ${prerequisite.description}; the service-manager result is authoritative.`);
817
+ }
818
+ }
819
+ function captureServiceShell(shell, command, workingDirectory) {
820
+ const fullCommand = workingDirectory === null
821
+ ? command
822
+ : `cd ${shellQuoteFor(shell.name, workingDirectory)} && ${command}`;
823
+ return capture("/usr/bin/env", [shell.executable, "-lc", fullCommand]);
824
+ }
825
+ function shellQuoteFor(shell, value) {
826
+ return shell === "fish" ? fishSingleQuote(value) : shellSingleQuote(value);
827
+ }
828
+ function printPathSetupAdvice(shell = detectServiceShell()) {
865
829
  console.log("\nPATH setup advice:");
866
830
  if (shell.name === "bash") {
867
831
  console.log(" Detected bash. Put PATH setup for node/version managers/tools in ~/.bash_profile or ~/.profile.");
@@ -877,20 +841,30 @@ function printPathSetupAdvice() {
877
841
  console.log(" Avoid relying on prompt hooks; PI WEB services run non-interactive login shells.");
878
842
  }
879
843
  }
844
+ export function doctorExitCode(generalReadinessOk, nativeServicePlanOk, nodePtyRuntimeOk) {
845
+ return generalReadinessOk && nativeServicePlanOk && nodePtyRuntimeOk ? 0 : 1;
846
+ }
880
847
  async function doctor() {
881
848
  const backend = currentServiceBackend();
882
849
  console.log(`Platform: ${platformLabel()}`);
883
850
  console.log(`Service backend: ${backend?.label ?? "manual run only"}`);
884
851
  console.log(`Service shell: ${describeServiceShell()}`);
885
852
  if (backend === undefined) {
886
- console.log(`- Native user service checks skipped on ${platformLabel()}`);
853
+ console.log(`- Native user service plan checks skipped on ${platformLabel()}; no native-service drift is reported.`);
887
854
  }
888
855
  console.log("");
889
856
  await printPiWebVersionReport();
890
- console.log("\nDoctor checks:");
891
- const ok = runChecks(doctorChecks());
857
+ console.log("\nGeneral login-shell readiness (separate from native-service requirements):");
858
+ const generalReadinessOk = runChecks(generalDoctorChecks());
892
859
  printOptionalDoctorChecks();
893
- const nodePtySpawnHelperOk = printNodePtyDarwinSpawnHelperCheck();
860
+ console.log("\nNative terminal runtime readiness:");
861
+ const nodePtyNativeModuleOk = printNodePtyNativeModuleCheck();
862
+ const nodePtySpawnHelperOk = nodePtyNativeModuleOk ? printNodePtyDarwinSpawnHelperCheck() : true;
863
+ let nativeServiceReport = null;
864
+ if (backend !== undefined) {
865
+ console.log("\nNative service plan checks (service-manager context):");
866
+ nativeServiceReport = await printNativeServiceDoctorChecks(backend);
867
+ }
894
868
  if (supportsSystemdUserServices()) {
895
869
  const linger = isLingerEnabled();
896
870
  if (linger === true) {
@@ -911,18 +885,27 @@ async function doctor() {
911
885
  else {
912
886
  console.log(`- systemd user lingering skipped on ${platformLabel()}`);
913
887
  }
914
- if (!ok) {
915
- console.log("\nIf a command works in your terminal but fails here, make sure your service shell login files set PATH the same way.");
916
- if (backend?.kind === "systemd")
917
- console.log("If a bundled entrypoint is not accessible, reinstall or update the PI WEB package.");
918
- printPathSetupAdvice();
888
+ const nativeServicePlanOk = nativeServiceReport?.ok ?? true;
889
+ const pathFailure = !generalReadinessOk || nativeServiceReport?.pathAdviceRecommended === true;
890
+ if (pathFailure) {
891
+ console.log("\nIf a command works in your terminal but fails in the service-manager check, compare the caller and manager contexts above.");
892
+ const adviceShell = nativeServiceReport?.pathAdviceRecommended === true && nativeServiceReport.adviceShell !== null
893
+ ? nativeServiceReport.adviceShell
894
+ : detectServiceShell();
895
+ printPathSetupAdvice(adviceShell);
919
896
  }
920
- if (ok && backend === undefined) {
897
+ if (generalReadinessOk && backend === undefined) {
921
898
  console.log(`\n${manualRunAdvice()}`);
922
899
  }
923
- if (!ok || !nodePtySpawnHelperOk)
900
+ if (doctorExitCode(generalReadinessOk, nativeServicePlanOk, nodePtyNativeModuleOk && nodePtySpawnHelperOk) !== 0)
924
901
  process.exitCode = 1;
925
902
  }
903
+ function printNodePtyNativeModuleCheck() {
904
+ const result = formatNodePtyNativeModuleCheck(checkNodePtyNativeModule());
905
+ for (const line of result.lines)
906
+ console.log(line);
907
+ return result.ok;
908
+ }
926
909
  function printNodePtyDarwinSpawnHelperCheck() {
927
910
  const result = formatNodePtyDarwinSpawnHelperCheck(checkNodePtyDarwinSpawnHelper());
928
911
  for (const line of result.lines)
@@ -943,7 +926,7 @@ Usage:
943
926
  pi-web version
944
927
 
945
928
  Recommended install:
946
- npm install -g @jmfederico/pi-web
929
+ npm install -g @jmfederico/pi-web --allow-scripts=node-pty
947
930
  pi-web install
948
931
 
949
932
  Development service install from a checkout: