@jmfederico/pi-web 1.202606.7 → 1.202607.1

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 (135) hide show
  1. package/README.md +14 -5
  2. package/dist/cli.js +291 -320
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client/assets/CodeViewer-uASb28Y1.js +4 -0
  5. package/dist/client/assets/TerminalPanel-DfTmykFw.js +187 -0
  6. package/dist/client/assets/UnifiedDiffViewer-laQN1x7P.js +39 -0
  7. package/dist/client/assets/index-Dhapi0oR.js +3093 -0
  8. package/dist/client/assets/vendor-editor-core-vUi74DnD.js +12 -0
  9. package/dist/client/assets/vendor-editor-languages-DoaXUodB.js +46 -0
  10. package/dist/client/assets/vendor-editor-legacy-CYBnW6ZU.js +1 -0
  11. package/dist/client/assets/vendor-terminal-BrP-ENHg.css +1 -0
  12. package/dist/client/assets/vendor-terminal-D8k4UKM2.js +35 -0
  13. package/dist/client/index.html +9 -6
  14. package/dist/client/manifest.webmanifest +4 -4
  15. package/dist/config.js +144 -2
  16. package/dist/config.js.map +1 -1
  17. package/dist/docker/piWebDockerCommandPlan.js +211 -0
  18. package/dist/docker/piWebDockerCommandPlan.js.map +1 -0
  19. package/dist/nativeServices/serviceDoctor.js +480 -0
  20. package/dist/nativeServices/serviceDoctor.js.map +1 -0
  21. package/dist/nativeServices/serviceInstall.js +35 -0
  22. package/dist/nativeServices/serviceInstall.js.map +1 -0
  23. package/dist/nativeServices/servicePlan.js +358 -0
  24. package/dist/nativeServices/servicePlan.js.map +1 -0
  25. package/dist/nativeServices/serviceProbe.js +447 -0
  26. package/dist/nativeServices/serviceProbe.js.map +1 -0
  27. package/dist/nativeServices/serviceRendering.js +122 -0
  28. package/dist/nativeServices/serviceRendering.js.map +1 -0
  29. package/dist/pi-web-plugins/updates/pi-web-plugin.js +27 -5
  30. package/dist/pi-web-plugins/updates/updatesLogic.js +37 -5
  31. package/dist/piWebVersionReport.js +4 -0
  32. package/dist/piWebVersionReport.js.map +1 -1
  33. package/dist/plugin-api.d.ts +3 -1
  34. package/dist/server/activeAgentProfileProvider.js +25 -0
  35. package/dist/server/activeAgentProfileProvider.js.map +1 -0
  36. package/dist/server/app.js +69 -13
  37. package/dist/server/app.js.map +1 -1
  38. package/dist/server/browserMessageProjection.js +56 -0
  39. package/dist/server/browserMessageProjection.js.map +1 -0
  40. package/dist/server/configRoutes.js +147 -6
  41. package/dist/server/configRoutes.js.map +1 -1
  42. package/dist/server/machines/machineClient.js +19 -9
  43. package/dist/server/machines/machineClient.js.map +1 -1
  44. package/dist/server/machines/machinePluginProxyRoutes.js +7 -4
  45. package/dist/server/machines/machinePluginProxyRoutes.js.map +1 -1
  46. package/dist/server/machines/machineProxyRoutes.js +76 -11
  47. package/dist/server/machines/machineProxyRoutes.js.map +1 -1
  48. package/dist/server/machines/machineRoutes.js +1 -1
  49. package/dist/server/machines/machineRoutes.js.map +1 -1
  50. package/dist/server/machines/machineService.js +6 -28
  51. package/dist/server/machines/machineService.js.map +1 -1
  52. package/dist/server/piPackageRoutes.js +91 -0
  53. package/dist/server/piPackageRoutes.js.map +1 -0
  54. package/dist/server/piPackageService.js +101 -0
  55. package/dist/server/piPackageService.js.map +1 -0
  56. package/dist/server/piWebPluginService.js +91 -20
  57. package/dist/server/piWebPluginService.js.map +1 -1
  58. package/dist/server/piWebReleaseLookupCache.js +37 -0
  59. package/dist/server/piWebReleaseLookupCache.js.map +1 -0
  60. package/dist/server/piWebStatus.js +90 -37
  61. package/dist/server/piWebStatus.js.map +1 -1
  62. package/dist/server/piWebStatusCache.js +19 -6
  63. package/dist/server/piWebStatusCache.js.map +1 -1
  64. package/dist/server/realtime/sessionEventHub.js +34 -7
  65. package/dist/server/realtime/sessionEventHub.js.map +1 -1
  66. package/dist/server/sessiond/sessionDaemonStartup.js +49 -0
  67. package/dist/server/sessiond/sessionDaemonStartup.js.map +1 -0
  68. package/dist/server/sessiond.js +88 -62
  69. package/dist/server/sessiond.js.map +1 -1
  70. package/dist/server/sessions/authProviderOptions.js +30 -32
  71. package/dist/server/sessions/authProviderOptions.js.map +1 -1
  72. package/dist/server/sessions/authRoutes.js +14 -4
  73. package/dist/server/sessions/authRoutes.js.map +1 -1
  74. package/dist/server/sessions/authService.js +89 -29
  75. package/dist/server/sessions/authService.js.map +1 -1
  76. package/dist/server/sessions/builtinCommands.js +1 -1
  77. package/dist/server/sessions/builtinCommands.js.map +1 -1
  78. package/dist/server/sessions/oauthLoginFlowService.js +176 -53
  79. package/dist/server/sessions/oauthLoginFlowService.js.map +1 -1
  80. package/dist/server/sessions/piSessionManagerGateway.js +22 -21
  81. package/dist/server/sessions/piSessionManagerGateway.js.map +1 -1
  82. package/dist/server/sessions/piSessionService.js +715 -127
  83. package/dist/server/sessions/piSessionService.js.map +1 -1
  84. package/dist/server/sessions/sessionArchiveMigration.js +590 -0
  85. package/dist/server/sessions/sessionArchiveMigration.js.map +1 -0
  86. package/dist/server/sessions/sessionArchiveStore.js +58 -26
  87. package/dist/server/sessions/sessionArchiveStore.js.map +1 -1
  88. package/dist/server/sessions/sessionCommandService.js +16 -0
  89. package/dist/server/sessions/sessionCommandService.js.map +1 -1
  90. package/dist/server/sessions/sessionNameGenerator.js +27 -44
  91. package/dist/server/sessions/sessionNameGenerator.js.map +1 -1
  92. package/dist/server/sessions/sessionRoutes.js +65 -3
  93. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  94. package/dist/server/sessions/sessionService.js +2 -0
  95. package/dist/server/sessions/sessionService.js.map +1 -0
  96. package/dist/server/sessions/spawnSessionTool.js +9 -4
  97. package/dist/server/sessions/spawnSessionTool.js.map +1 -1
  98. package/dist/server/sessions/spawnSubsessionTool.js +79 -32
  99. package/dist/server/sessions/spawnSubsessionTool.js.map +1 -1
  100. package/dist/server/terminals/terminalService.js +10 -4
  101. package/dist/server/terminals/terminalService.js.map +1 -1
  102. package/dist/server/workspaces/fileSuggestions.js +7 -3
  103. package/dist/server/workspaces/fileSuggestions.js.map +1 -1
  104. package/dist/sessiond/activeAgentProfile.js +31 -0
  105. package/dist/sessiond/activeAgentProfile.js.map +1 -0
  106. package/dist/sessiond/sessionDaemonClient.js +38 -0
  107. package/dist/sessiond/sessionDaemonClient.js.map +1 -1
  108. package/dist/shared/activeAgentProfile.js +76 -0
  109. package/dist/shared/activeAgentProfile.js.map +1 -0
  110. package/dist/shared/activity.js +0 -15
  111. package/dist/shared/activity.js.map +1 -1
  112. package/dist/shared/apiTypes.d.ts +160 -3
  113. package/dist/shared/apiTypes.js +6 -0
  114. package/dist/shared/apiTypes.js.map +1 -1
  115. package/dist/shared/capabilities.js +33 -2
  116. package/dist/shared/capabilities.js.map +1 -1
  117. package/dist/shared/federatedRoutes.js +14 -0
  118. package/dist/shared/federatedRoutes.js.map +1 -1
  119. package/dist/shared/piWebStatusParsing.js +26 -8
  120. package/dist/shared/piWebStatusParsing.js.map +1 -1
  121. package/dist/shared/thinkingLevels.d.ts +1 -1
  122. package/dist/shared/thinkingLevels.js +1 -1
  123. package/dist/shared/thinkingLevels.js.map +1 -1
  124. package/docs/config.md +75 -19
  125. package/docs/plugins.md +46 -18
  126. package/package.json +36 -32
  127. package/dist/client/assets/CodeViewer-CQU9mJ74.js +0 -4
  128. package/dist/client/assets/TerminalPanel-DtfVBsqI.js +0 -126
  129. package/dist/client/assets/UnifiedDiffViewer-BZQQK99S.js +0 -34
  130. package/dist/client/assets/index-CbE7df66.js +0 -2742
  131. package/dist/client/assets/vendor-editor-core-C7aWiQy0.js +0 -12
  132. package/dist/client/assets/vendor-editor-languages-EW34FfVy.js +0 -26
  133. package/dist/client/assets/vendor-editor-legacy-B4QLsWF8.js +0 -1
  134. package/dist/client/assets/vendor-terminal-DDGTF8rc.css +0 -1
  135. package/dist/client/assets/vendor-terminal-DjQ08hXu.js +0 -16
package/dist/cli.js CHANGED
@@ -1,44 +1,28 @@
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 { installNativeServiceCandidate, nativeServiceInstallFailureNeedsPathAdvice, } from "./nativeServices/serviceInstall.js";
12
+ import { minimumSupportedNodeVersion, nativeServiceManagerRefs, productionNativeServiceIds, } from "./nativeServices/servicePlan.js";
13
+ import { formatNativeServiceDoctorResult, inferInstalledNativeServiceMode, inspectInstalledDevelopmentServiceInput, inspectInstalledProductionServiceContext, runNativeServiceDoctor, } from "./nativeServices/serviceDoctor.js";
14
+ import { createNativeServiceAuthoritativeProbe, nativeServicePrerequisiteShellCheck, } from "./nativeServices/serviceProbe.js";
15
+ import { renderLaunchdPlist, renderSystemdUnit } from "./nativeServices/serviceRendering.js";
11
16
  const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
12
17
  const systemdServiceDir = join(homedir(), ".config", "systemd", "user");
13
18
  const launchdServiceDir = join(homedir(), "Library", "LaunchAgents");
14
19
  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
20
  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
- },
21
+ sessiond: { id: "sessiond", ...nativeServiceManagerRefs.sessiond },
22
+ web: { id: "web", ...nativeServiceManagerRefs.web },
23
+ uiDev: { id: "uiDev", ...nativeServiceManagerRefs.uiDev },
40
24
  };
41
- const productionServiceIds = ["sessiond", "web"];
25
+ const productionServiceIds = [...productionNativeServiceIds];
42
26
  const startServiceOrder = ["sessiond", "web", "uiDev"];
43
27
  const stopServiceOrder = ["web", "uiDev", "sessiond"];
44
28
  // Restart web/UI before sessiond: when `pi-web restart` runs in a pi-web
@@ -54,13 +38,16 @@ function platformLabel() {
54
38
  return "Windows";
55
39
  return process.platform;
56
40
  }
57
- function currentServiceBackend() {
58
- if (process.platform === "linux")
41
+ export function serviceBackendForPlatform(platform) {
42
+ if (platform === "linux")
59
43
  return { kind: "systemd", label: "systemd user services" };
60
- if (process.platform === "darwin")
44
+ if (platform === "darwin")
61
45
  return { kind: "launchd", label: "LaunchAgents" };
62
46
  return undefined;
63
47
  }
48
+ function currentServiceBackend() {
49
+ return serviceBackendForPlatform(process.platform);
50
+ }
64
51
  function requireServiceBackend(command) {
65
52
  const backend = currentServiceBackend();
66
53
  if (backend !== undefined)
@@ -105,7 +92,7 @@ function runQuiet(command, args) {
105
92
  return capture(command, args).status;
106
93
  }
107
94
  function hasCommand(command) {
108
- return capture("/usr/bin/env", ["sh", "-c", `command -v ${command}`]).status === 0;
95
+ return capture("/usr/bin/env", ["sh", "-c", `command -v ${shellSingleQuote(command)}`]).status === 0;
109
96
  }
110
97
  function isLingerEnabled() {
111
98
  if (!hasCommand("loginctl"))
@@ -174,105 +161,50 @@ function shellSingleQuote(value) {
174
161
  function fishSingleQuote(value) {
175
162
  return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
176
163
  }
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
164
  function packageRootPath() {
192
165
  return dirname(dirname(fileURLToPath(import.meta.url)));
193
166
  }
194
167
  function packageEntrypointPath(name) {
195
168
  return join(packageRootPath(), "dist", "server", name === "server" ? "index.js" : "sessiond.js");
196
169
  }
170
+ export function regularFileExists(path) {
171
+ return existsSync(path) && statSync(path).isFile();
172
+ }
197
173
  function detectServiceShell() {
198
174
  const userShell = userInfo().shell ?? undefined;
199
175
  const envShell = process.env["SHELL"]?.trim();
200
176
  const detected = envShell === undefined || envShell === "" ? userShell : envShell;
201
177
  const name = basename(detected ?? "").replace(/^-/, "");
202
178
  if (name === "bash" || name === "zsh" || name === "fish") {
203
- return { name, executable: detected ?? name, detected: detected ?? name, fallback: false };
179
+ return {
180
+ name,
181
+ executable: detected ?? name,
182
+ source: "detected",
183
+ detectedExecutable: detected ?? name,
184
+ };
204
185
  }
205
- return { name: "bash", executable: "bash", ...(detected === undefined ? {} : { detected }), fallback: true };
186
+ return {
187
+ name: "bash",
188
+ executable: "bash",
189
+ source: "fallback",
190
+ detectedExecutable: detected ?? null,
191
+ };
206
192
  }
207
193
  function serviceShellCommand(command, cwd) {
208
194
  const fullCommand = cwd === undefined ? command : `cd ${serviceShellQuote(cwd)} && ${command}`;
209
195
  return ["/usr/bin/env", detectServiceShell().executable, "-lc", fullCommand];
210
196
  }
211
- function serviceShellExecPrefix() {
212
- return `/usr/bin/env ${detectServiceShell().executable} -lc`;
213
- }
214
197
  function serviceShellQuote(value) {
215
198
  return detectServiceShell().name === "fish" ? fishSingleQuote(value) : shellSingleQuote(value);
216
199
  }
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
200
  function describeServiceShell() {
269
201
  const shell = detectServiceShell();
270
- if (shell.fallback) {
271
- return shell.detected === undefined
202
+ if (shell.source === "fallback") {
203
+ return shell.detectedExecutable === null
272
204
  ? "could not detect a supported login shell; using bash"
273
- : `detected ${shell.detected}; using bash because PI WEB currently supports bash, zsh, and fish`;
205
+ : `detected ${shell.detectedExecutable}; using bash because PI WEB currently supports bash, zsh, and fish`;
274
206
  }
275
- return shell.detected === undefined ? shell.name : `${shell.name} (${shell.detected})`;
207
+ return shell.detectedExecutable === null ? shell.name : `${shell.name} (${shell.detectedExecutable})`;
276
208
  }
277
209
  function configEnvironment(options, configPath) {
278
210
  return options.config === undefined ? {} : { PI_WEB_CONFIG: configPath };
@@ -302,27 +234,6 @@ function stopOrder(refs) {
302
234
  function restartOrder(refs) {
303
235
  return orderServiceRefs(refs, restartServiceOrder);
304
236
  }
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
237
  function devRootPath() {
327
238
  return resolve(process.cwd());
328
239
  }
@@ -335,97 +246,18 @@ function validateDevCheckout(root) {
335
246
  if (!isRecord(parsed) || parsed["name"] !== PI_WEB_PACKAGE_NAME) {
336
247
  throw new Error(`Development mode must be installed from a PI WEB checkout. ${packageJsonPath} is not ${PI_WEB_PACKAGE_NAME}.`);
337
248
  }
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
249
  }
406
250
  function launchdLogPath(ref) {
407
251
  return join(logDir, ref.logName);
408
252
  }
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);
253
+ function installConfigPath(options) {
254
+ return options.config === undefined ? defaultPiWebConfigPath() : resolve(options.config);
255
+ }
256
+ async function writeInitialConfig(options, configPath) {
424
257
  await mkdir(dirname(configPath), { recursive: true });
425
258
  if (!existsSync(configPath)) {
426
259
  await writeFile(configPath, examplePiWebConfig({ host: options.host, port: Number(options.port) }));
427
260
  }
428
- return configPath;
429
261
  }
430
262
  function systemdServicePath(ref) {
431
263
  return join(systemdServiceDir, ref.systemdName);
@@ -446,18 +278,18 @@ function installedServiceRefs(backend) {
446
278
  const installed = startOrder(allServiceRefs().filter((ref) => serviceFileExists(backend, ref)));
447
279
  return installed.length === 0 ? productionServiceRefs() : installed;
448
280
  }
449
- async function installSystemdServices(services) {
450
- const selected = new Set(services.map((service) => service.id));
281
+ async function installSystemdServices(plan) {
282
+ const selected = new Set(plan.services.map((service) => service.id));
451
283
  const obsolete = stopOrder(allServiceRefs().filter((ref) => !selected.has(ref.id)));
452
284
  for (const ref of obsolete) {
453
285
  runQuiet("systemctl", ["--user", "disable", "--now", ref.systemdName]);
454
286
  await rm(systemdServicePath(ref), { force: true });
455
287
  }
456
288
  await mkdir(systemdServiceDir, { recursive: true });
457
- for (const service of services) {
458
- await writeFile(systemdServicePath(service), systemdUnit(service));
289
+ for (const service of plan.services) {
290
+ await writeFile(join(systemdServiceDir, service.manager.systemdName), renderSystemdUnit(plan, service));
459
291
  }
460
- const names = services.map((service) => service.systemdName);
292
+ const names = plan.services.map((service) => service.manager.systemdName);
461
293
  run("systemctl", ["--user", "daemon-reload"], { check: true });
462
294
  run("systemctl", ["--user", "enable", ...names], { check: true });
463
295
  run("systemctl", ["--user", "restart", ...names], { check: true });
@@ -483,8 +315,8 @@ function launchdStart(ref) {
483
315
  launchdBootstrap(ref);
484
316
  run("launchctl", ["kickstart", launchdServiceTarget(ref)], { check: true });
485
317
  }
486
- async function installLaunchdServices(services) {
487
- const selected = new Set(services.map((service) => service.id));
318
+ async function installLaunchdServices(plan) {
319
+ const selected = new Set(plan.services.map((service) => service.id));
488
320
  await mkdir(launchdServiceDir, { recursive: true });
489
321
  await mkdir(logDir, { recursive: true });
490
322
  for (const ref of stopOrder(allServiceRefs()))
@@ -492,17 +324,21 @@ async function installLaunchdServices(services) {
492
324
  for (const ref of allServiceRefs().filter((candidate) => !selected.has(candidate.id))) {
493
325
  await rm(launchdPlistPath(ref), { force: true });
494
326
  }
495
- for (const service of services) {
496
- await writeFile(launchdPlistPath(service), launchdPlist(service));
327
+ for (const service of plan.services) {
328
+ const plistPath = join(launchdServiceDir, service.manager.launchdPlistName);
329
+ await writeFile(plistPath, renderLaunchdPlist(plan, service, logDir));
497
330
  }
498
- for (const service of services)
499
- launchdStart(service);
331
+ for (const service of plan.services)
332
+ launchdStart(serviceRefFromPlan(service.id, service.manager));
500
333
  }
501
- async function installNativeServices(backend, services) {
502
- if (backend.kind === "systemd")
503
- await installSystemdServices(services);
334
+ async function installNativeServices(plan) {
335
+ if (plan.backend.kind === "systemd")
336
+ await installSystemdServices(plan);
504
337
  else
505
- await installLaunchdServices(services);
338
+ await installLaunchdServices(plan);
339
+ }
340
+ function serviceRefFromPlan(id, manager) {
341
+ return { id, ...manager };
506
342
  }
507
343
  async function uninstallSystemdServices() {
508
344
  for (const ref of stopOrder(allServiceRefs())) {
@@ -597,6 +433,15 @@ function parseLaunchdField(output, field) {
597
433
  const match = new RegExp(`^\\s*${field}\\s=\\s(.+)$`, "m").exec(output);
598
434
  return match?.[1]?.trim();
599
435
  }
436
+ export function launchdRuntimeDetails(output) {
437
+ const state = parseLaunchdField(output, "state") ?? "unknown";
438
+ const pid = parseLaunchdField(output, "pid");
439
+ const lastExitCode = parseLaunchdField(output, "last exit code");
440
+ const detail = state === "running"
441
+ ? "running"
442
+ : lastExitCode === undefined ? state : `${state} (last exit code ${lastExitCode})`;
443
+ return { state, detail, pid };
444
+ }
600
445
  function launchdRuntimeStatus(backend, ref) {
601
446
  const target = launchdServiceTarget(ref);
602
447
  const filePath = serviceFilePath(backend, ref);
@@ -606,10 +451,9 @@ function launchdRuntimeStatus(backend, ref) {
606
451
  if (result.status !== 0) {
607
452
  return makeServiceRuntimeStatus(ref, "stopped", firstOutputLine(result.stderr, result.stdout) ?? "not loaded", target, filePath);
608
453
  }
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);
454
+ const details = launchdRuntimeDetails(result.stdout);
455
+ const health = details.state === "running" ? "running" : details.state === "unknown" ? "unknown" : "stopped";
456
+ return makeServiceRuntimeStatus(ref, health, details.detail, target, filePath, details.pid);
613
457
  }
614
458
  function runtimeStatus(backend, ref) {
615
459
  return backend.kind === "systemd" ? systemdRuntimeStatus(backend, ref) : launchdRuntimeStatus(backend, ref);
@@ -635,36 +479,77 @@ function printServiceStatusReport(backend) {
635
479
  console.log("\nUse `pi-web logs` for service logs.");
636
480
  return statuses.every((status) => status.health === "running");
637
481
  }
638
- function backendAvailabilityChecks(backend) {
639
- if (backend.kind === "systemd")
640
- return [["systemctl --user", ["systemctl", "--user", "--version"]]];
641
- return [[`launchctl ${launchdDomain()}`, ["launchctl", "print", launchdDomain()]]];
482
+ function configuredServiceCommand(name) {
483
+ const value = process.env[name];
484
+ return value === undefined || value.trim() === "" ? undefined : value;
642
485
  }
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;
486
+ function productionNativeServicePlanInput(backend, shell, environment) {
487
+ return {
488
+ backend,
489
+ shell,
490
+ environment,
491
+ executables: {
492
+ sessiond: {
493
+ configuredCommand: configuredServiceCommand("PI_WEB_SESSIOND_EXEC"),
494
+ namedCommand: "pi-web-sessiond",
495
+ bundledEntrypointPath: packageEntrypointPath("sessiond"),
496
+ },
497
+ web: {
498
+ configuredCommand: configuredServiceCommand("PI_WEB_SERVER_EXEC"),
499
+ namedCommand: "pi-web-server",
500
+ bundledEntrypointPath: packageEntrypointPath("server"),
501
+ },
502
+ },
503
+ };
649
504
  }
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)]);
505
+ function nativeServiceInstallCandidate(options, backend, configPath, devRoot) {
506
+ const shell = detectServiceShell();
507
+ const environment = configEnvironment(options, configPath);
508
+ if (options.mode === "production") {
509
+ return {
510
+ mode: "production",
511
+ input: productionNativeServicePlanInput(backend, shell, environment),
512
+ };
658
513
  }
659
- return checks;
514
+ const root = devRoot ?? devRootPath();
515
+ return {
516
+ mode: "development",
517
+ input: {
518
+ backend,
519
+ shell,
520
+ environment,
521
+ workingDirectory: root,
522
+ packageJsonPath: join(root, "package.json"),
523
+ },
524
+ };
660
525
  }
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
- ];
526
+ function printNativeServiceInstallFailure(failure) {
527
+ if (failure.kind === "plan-resolution") {
528
+ for (const item of failure.failures) {
529
+ if (item.kind === "probe-infrastructure") {
530
+ console.log(`✗ Service-manager probe infrastructure failure (${item.reason}): ${item.message}`);
531
+ }
532
+ else if (item.kind === "entrypoint-inspection-failure") {
533
+ console.log(`✗ Could not inspect bundled ${item.serviceId} entrypoint ${item.entrypointPath}: ${item.message}`);
534
+ }
535
+ else {
536
+ console.log(`✗ ${item.namedCommand} is unavailable to the service manager, and bundled entrypoint ${item.bundledEntrypointPath} is missing.`);
537
+ if (item.namedCommandFailure !== null)
538
+ console.log(` ${item.namedCommandFailure}`);
539
+ }
540
+ }
541
+ return;
542
+ }
543
+ for (const item of failure.failures) {
544
+ if (item.kind === "probe-infrastructure") {
545
+ console.log(`✗ Service-manager probe infrastructure failure (${item.reason}): ${item.message}`);
546
+ }
547
+ else {
548
+ console.log(`✗ ${item.prerequisite.description}`);
549
+ if (item.detail !== null && item.detail !== item.prerequisite.description)
550
+ console.log(` ${item.detail}`);
551
+ }
552
+ }
668
553
  }
669
554
  async function install(args) {
670
555
  const backend = requireServiceBackend("pi-web install");
@@ -672,19 +557,26 @@ async function install(args) {
672
557
  const devRoot = options.mode === "dev" ? devRootPath() : undefined;
673
558
  if (devRoot !== undefined)
674
559
  validateDevCheckout(devRoot);
675
- const executables = options.mode === "production" ? resolveServiceExecutables(backend) : undefined;
560
+ const configPath = installConfigPath(options);
561
+ const candidate = nativeServiceInstallCandidate(options, backend, configPath, devRoot);
676
562
  console.log(`Running PI WEB ${options.mode} install preflight checks...`);
677
563
  console.log(`Service backend: ${backend.label}`);
678
564
  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);
565
+ const result = await installNativeServiceCandidate(candidate, {
566
+ probe: createNativeServiceAuthoritativeProbe(),
567
+ fileExists: regularFileExists,
568
+ writeInitialConfig: () => writeInitialConfig(options, configPath),
569
+ replaceServices: installNativeServices,
570
+ });
571
+ if (!result.ok) {
572
+ printNativeServiceInstallFailure(result.failure);
573
+ if (nativeServiceInstallFailureNeedsPathAdvice(result.failure))
574
+ printPathSetupAdvice();
575
+ throw new Error("Install preflight checks failed without changing config or services. Fix the failure above, then run `pi-web doctor` for more detail.");
576
+ }
577
+ for (const service of result.plan.services.filter((item) => item.strategy.kind === "configured-override")) {
578
+ console.log(`! ${service.description} uses a configured command override; preflight did not execute that arbitrary command.`);
579
+ }
688
580
  console.log(`\nPI WEB ${options.mode} services are installed and starting.`);
689
581
  console.log(`Config: ${configPath}`);
690
582
  if (options.mode === "dev") {
@@ -761,55 +653,37 @@ function logs() {
761
653
  function serviceShellLabel() {
762
654
  return `${detectServiceShell().name} -lc`;
763
655
  }
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
656
  function commandCheck(command) {
776
- return `command -v ${command}`;
657
+ return `command -v ${serviceShellQuote(command)}`;
777
658
  }
778
659
  export function commandWithVersionCheck(command) {
779
660
  const found = commandCheck(command);
661
+ const commandWord = serviceShellQuote(command);
780
662
  if (detectServiceShell().name === "fish") {
781
- return `${found} && begin; ${command} --version 2>&1 || true; end`;
663
+ return `${found} && begin; ${commandWord} --version 2>&1 || true; end`;
782
664
  }
783
- return `${found} && (${command} --version 2>&1 || true)`;
665
+ return `${found} && (${commandWord} --version 2>&1 || true)`;
784
666
  }
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(" && ");
667
+ export function nodeVersionCheck() {
668
+ return nativeServicePrerequisiteShellCheck(detectServiceShell().name, {
669
+ id: "caller.node",
670
+ kind: "node-version",
671
+ command: "node",
672
+ minimumVersion: minimumSupportedNodeVersion,
673
+ description: `node >= ${minimumSupportedNodeVersion}`,
674
+ });
675
+ }
676
+ export function agentCommandForChecks(env = process.env) {
677
+ return effectivePiWebConfig({ env }).config.agent.command;
790
678
  }
791
- function doctorChecks() {
679
+ function generalDoctorChecks() {
792
680
  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"))],
681
+ const agentCommand = agentCommandForChecks();
682
+ return [
683
+ [`Caller login ${shell} can find node >= ${minimumSupportedNodeVersion}`, serviceShellCommand(nodeVersionCheck())],
684
+ [`Caller login ${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))],
685
+ [`Caller login ${shell} can find ${agentCommand}`, serviceShellCommand(commandWithVersionCheck(agentCommand))],
806
686
  ];
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
687
  }
814
688
  function runChecks(checks) {
815
689
  let failed = false;
@@ -837,11 +711,7 @@ function printCheckOutput(output) {
837
711
  }
838
712
  function optionalDoctorChecks() {
839
713
  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;
714
+ return [[`Caller login ${shell} can find optional ripgrep (rg)`, serviceShellCommand(commandCheck("rg"))]];
845
715
  }
846
716
  function printOptionalDoctorChecks() {
847
717
  let missingOptionalTool = false;
@@ -860,8 +730,98 @@ function printOptionalDoctorChecks() {
860
730
  console.log(" PI WEB falls back to a bounded filesystem scan when rg is unavailable.");
861
731
  }
862
732
  }
863
- function printPathSetupAdvice() {
864
- const shell = detectServiceShell();
733
+ function installedServiceDefinitions(backend, ids) {
734
+ return ids.map((id) => ({
735
+ id,
736
+ contents: readFileSync(serviceFilePath(backend, serviceRefs[id]), "utf8"),
737
+ }));
738
+ }
739
+ function nativeServiceDoctorTarget(backend) {
740
+ const ids = installedServiceIds(backend);
741
+ const mode = inferInstalledNativeServiceMode(ids);
742
+ if (mode === "ambiguous") {
743
+ return {
744
+ kind: "inspection-failure",
745
+ message: `installed service IDs do not identify one mode (${[...ids].join(", ") || "none"}).`,
746
+ };
747
+ }
748
+ if (mode === "none") {
749
+ return {
750
+ kind: "prospective-production",
751
+ input: productionNativeServicePlanInput(backend, detectServiceShell(), {}),
752
+ reason: "no installed service strategy is available",
753
+ };
754
+ }
755
+ const expectedIds = mode === "production"
756
+ ? productionNativeServiceIds
757
+ : ["sessiond", "uiDev"];
758
+ const missingId = expectedIds.find((id) => !ids.has(id));
759
+ if (missingId !== undefined) {
760
+ return {
761
+ kind: "inspection-failure",
762
+ message: `installed ${mode} service set is incomplete; ${missingId} is missing.`,
763
+ };
764
+ }
765
+ let definitions;
766
+ try {
767
+ definitions = installedServiceDefinitions(backend, expectedIds);
768
+ }
769
+ catch (error) {
770
+ return {
771
+ kind: "inspection-failure",
772
+ message: error instanceof Error ? error.message : String(error),
773
+ };
774
+ }
775
+ if (mode === "development") {
776
+ const inspection = inspectInstalledDevelopmentServiceInput(backend, definitions);
777
+ return inspection.ok
778
+ ? { kind: "installed-development", input: inspection.value }
779
+ : { kind: "inspection-failure", message: inspection.message };
780
+ }
781
+ const inspection = inspectInstalledProductionServiceContext(backend, definitions);
782
+ return inspection.ok
783
+ ? {
784
+ kind: "prospective-production",
785
+ input: productionNativeServicePlanInput(backend, inspection.value.shell, inspection.value.environment),
786
+ reason: "installed executable strategy is not recorded",
787
+ }
788
+ : { kind: "inspection-failure", message: inspection.message };
789
+ }
790
+ async function printNativeServiceDoctorChecks(backend) {
791
+ const result = await runNativeServiceDoctor(nativeServiceDoctorTarget(backend), {
792
+ probe: createNativeServiceAuthoritativeProbe(),
793
+ fileExists: regularFileExists,
794
+ });
795
+ const report = formatNativeServiceDoctorResult(result);
796
+ for (const line of report.lines)
797
+ console.log(line);
798
+ printCallerContextComparisons(report);
799
+ return report;
800
+ }
801
+ function printCallerContextComparisons(report) {
802
+ if (report.plan === null || report.failedPrerequisites.length === 0)
803
+ return;
804
+ const seen = new Set();
805
+ for (const prerequisite of report.failedPrerequisites) {
806
+ if (seen.has(prerequisite.id))
807
+ continue;
808
+ seen.add(prerequisite.id);
809
+ const service = report.plan.services.find((candidate) => candidate.prerequisites.some((item) => item.id === prerequisite.id));
810
+ const command = nativeServicePrerequisiteShellCheck(report.plan.shell.name, prerequisite);
811
+ const result = captureServiceShell(report.plan.shell, command, service?.workingDirectory ?? null);
812
+ 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.`);
813
+ }
814
+ }
815
+ function captureServiceShell(shell, command, workingDirectory) {
816
+ const fullCommand = workingDirectory === null
817
+ ? command
818
+ : `cd ${shellQuoteFor(shell.name, workingDirectory)} && ${command}`;
819
+ return capture("/usr/bin/env", [shell.executable, "-lc", fullCommand]);
820
+ }
821
+ function shellQuoteFor(shell, value) {
822
+ return shell === "fish" ? fishSingleQuote(value) : shellSingleQuote(value);
823
+ }
824
+ function printPathSetupAdvice(shell = detectServiceShell()) {
865
825
  console.log("\nPATH setup advice:");
866
826
  if (shell.name === "bash") {
867
827
  console.log(" Detected bash. Put PATH setup for node/version managers/tools in ~/.bash_profile or ~/.profile.");
@@ -877,20 +837,28 @@ function printPathSetupAdvice() {
877
837
  console.log(" Avoid relying on prompt hooks; PI WEB services run non-interactive login shells.");
878
838
  }
879
839
  }
840
+ export function doctorExitCode(generalReadinessOk, nativeServicePlanOk, nodePtySpawnHelperOk) {
841
+ return generalReadinessOk && nativeServicePlanOk && nodePtySpawnHelperOk ? 0 : 1;
842
+ }
880
843
  async function doctor() {
881
844
  const backend = currentServiceBackend();
882
845
  console.log(`Platform: ${platformLabel()}`);
883
846
  console.log(`Service backend: ${backend?.label ?? "manual run only"}`);
884
847
  console.log(`Service shell: ${describeServiceShell()}`);
885
848
  if (backend === undefined) {
886
- console.log(`- Native user service checks skipped on ${platformLabel()}`);
849
+ console.log(`- Native user service plan checks skipped on ${platformLabel()}; no native-service drift is reported.`);
887
850
  }
888
851
  console.log("");
889
852
  await printPiWebVersionReport();
890
- console.log("\nDoctor checks:");
891
- const ok = runChecks(doctorChecks());
853
+ console.log("\nGeneral login-shell readiness (separate from native-service requirements):");
854
+ const generalReadinessOk = runChecks(generalDoctorChecks());
892
855
  printOptionalDoctorChecks();
893
856
  const nodePtySpawnHelperOk = printNodePtyDarwinSpawnHelperCheck();
857
+ let nativeServiceReport = null;
858
+ if (backend !== undefined) {
859
+ console.log("\nNative service plan checks (service-manager context):");
860
+ nativeServiceReport = await printNativeServiceDoctorChecks(backend);
861
+ }
894
862
  if (supportsSystemdUserServices()) {
895
863
  const linger = isLingerEnabled();
896
864
  if (linger === true) {
@@ -911,16 +879,19 @@ async function doctor() {
911
879
  else {
912
880
  console.log(`- systemd user lingering skipped on ${platformLabel()}`);
913
881
  }
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();
882
+ const nativeServicePlanOk = nativeServiceReport?.ok ?? true;
883
+ const pathFailure = !generalReadinessOk || nativeServiceReport?.pathAdviceRecommended === true;
884
+ if (pathFailure) {
885
+ console.log("\nIf a command works in your terminal but fails in the service-manager check, compare the caller and manager contexts above.");
886
+ const adviceShell = nativeServiceReport?.pathAdviceRecommended === true && nativeServiceReport.adviceShell !== null
887
+ ? nativeServiceReport.adviceShell
888
+ : detectServiceShell();
889
+ printPathSetupAdvice(adviceShell);
919
890
  }
920
- if (ok && backend === undefined) {
891
+ if (generalReadinessOk && backend === undefined) {
921
892
  console.log(`\n${manualRunAdvice()}`);
922
893
  }
923
- if (!ok || !nodePtySpawnHelperOk)
894
+ if (doctorExitCode(generalReadinessOk, nativeServicePlanOk, nodePtySpawnHelperOk) !== 0)
924
895
  process.exitCode = 1;
925
896
  }
926
897
  function printNodePtyDarwinSpawnHelperCheck() {