@jmfederico/pi-web 1.202605.11 → 1.202605.13

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 (48) hide show
  1. package/README.md +47 -50
  2. package/dist/cli.js +514 -115
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client/apple-touch-icon.png +0 -0
  5. package/dist/client/assets/{CodeViewer-0NuLmnBL.js → CodeViewer-ncgSvOQM.js} +1 -1
  6. package/dist/client/assets/TerminalPanel-DjuVNsp5.js +75 -0
  7. package/dist/client/assets/index-CDGTKzLD.js +1239 -0
  8. package/dist/client/favicon.svg +11 -0
  9. package/dist/client/index.html +6 -2
  10. package/dist/client/manifest.webmanifest +24 -0
  11. package/dist/client/pwa-icon-192.png +0 -0
  12. package/dist/client/pwa-icon-512.png +0 -0
  13. package/dist/config.js +4 -4
  14. package/dist/pi-web-plugins/pi-web/pi-web-plugin.js +13 -9
  15. package/dist/server/diagnostics/nodePtySpawnHelper.js +135 -0
  16. package/dist/server/diagnostics/nodePtySpawnHelper.js.map +1 -0
  17. package/dist/server/piWebPluginService.js +9 -9
  18. package/dist/server/piWebStatus.js +8 -8
  19. package/dist/server/piWebStatus.js.map +1 -1
  20. package/dist/server/sessions/piSessionService.js +169 -19
  21. package/dist/server/sessions/piSessionService.js.map +1 -1
  22. package/dist/server/sessions/sessionArchiveTree.js +35 -0
  23. package/dist/server/sessions/sessionArchiveTree.js.map +1 -0
  24. package/dist/server/sessions/sessionCommandService.js +88 -2
  25. package/dist/server/sessions/sessionCommandService.js.map +1 -1
  26. package/dist/server/sessions/sessionRoutes.js +8 -0
  27. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  28. package/dist/server/terminalProxyRoutes.js +70 -0
  29. package/dist/server/terminalProxyRoutes.js.map +1 -1
  30. package/dist/server/terminals/terminalRoutes.js +65 -1
  31. package/dist/server/terminals/terminalRoutes.js.map +1 -1
  32. package/dist/server/terminals/terminalService.js +202 -37
  33. package/dist/server/terminals/terminalService.js.map +1 -1
  34. package/dist/server/workspaceExplorerRoutes.js +18 -0
  35. package/dist/server/workspaceExplorerRoutes.js.map +1 -1
  36. package/dist/server/workspaces/fileContentService.js +23 -3
  37. package/dist/server/workspaces/fileContentService.js.map +1 -1
  38. package/dist/server/workspaces/imagePreviewService.js +40 -0
  39. package/dist/server/workspaces/imagePreviewService.js.map +1 -0
  40. package/dist/shared/workspaceFiles.js +3 -0
  41. package/dist/shared/workspaceFiles.js.map +1 -0
  42. package/docs/assets/favicon.svg +11 -0
  43. package/docs/plugins.md +43 -42
  44. package/extensions/pi-web.ts +12 -5
  45. package/package.json +1 -1
  46. package/plugin-api.d.ts +2 -0
  47. package/dist/client/assets/TerminalPanel-BG6_9svt.js +0 -47
  48. package/dist/client/assets/index-DmVN7DQr.js +0 -1143
package/dist/cli.js CHANGED
@@ -1,14 +1,85 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, readFileSync } from "node:fs";
3
4
  import { mkdir, rm, writeFile } from "node:fs/promises";
4
5
  import { homedir, userInfo } from "node:os";
5
6
  import { basename, dirname, join, resolve } from "node:path";
6
- import { spawnSync } from "node:child_process";
7
7
  import { fileURLToPath } from "node:url";
8
- import { defaultPiWebConfigPath, examplePiWebConfig } from "./config.js";
9
- const serviceDir = join(homedir(), ".config", "systemd", "user");
8
+ import { defaultPiWebConfigPath, defaultPiWebDataDir, examplePiWebConfig } from "./config.js";
9
+ import { checkNodePtyDarwinSpawnHelper, formatNodePtyDarwinSpawnHelperCheck } from "./server/diagnostics/nodePtySpawnHelper.js";
10
+ const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
11
+ const systemdServiceDir = join(homedir(), ".config", "systemd", "user");
12
+ const launchdServiceDir = join(homedir(), "Library", "LaunchAgents");
13
+ const logDir = join(defaultPiWebDataDir(), "logs");
10
14
  const sessiondServiceName = "pi-web-sessiond.service";
11
15
  const webServiceName = "pi-web.service";
16
+ const uiDevServiceName = "pi-web-ui-dev.service";
17
+ const serviceRefs = {
18
+ sessiond: {
19
+ id: "sessiond",
20
+ systemdName: sessiondServiceName,
21
+ launchdLabel: "com.pi-web.sessiond",
22
+ launchdPlistName: "com.pi-web.sessiond.plist",
23
+ logName: "sessiond.log",
24
+ },
25
+ web: {
26
+ id: "web",
27
+ systemdName: webServiceName,
28
+ launchdLabel: "com.pi-web.web",
29
+ launchdPlistName: "com.pi-web.web.plist",
30
+ logName: "web.log",
31
+ },
32
+ uiDev: {
33
+ id: "uiDev",
34
+ systemdName: uiDevServiceName,
35
+ launchdLabel: "com.pi-web.ui-dev",
36
+ launchdPlistName: "com.pi-web.ui-dev.plist",
37
+ logName: "ui-dev.log",
38
+ },
39
+ };
40
+ const productionServiceIds = ["sessiond", "web"];
41
+ const startServiceOrder = ["sessiond", "web", "uiDev"];
42
+ const stopServiceOrder = ["web", "uiDev", "sessiond"];
43
+ function platformLabel() {
44
+ if (process.platform === "darwin")
45
+ return "macOS";
46
+ if (process.platform === "linux")
47
+ return "Linux";
48
+ if (process.platform === "win32")
49
+ return "Windows";
50
+ return process.platform;
51
+ }
52
+ function currentServiceBackend() {
53
+ if (process.platform === "linux")
54
+ return { kind: "systemd", label: "systemd user services" };
55
+ if (process.platform === "darwin")
56
+ return { kind: "launchd", label: "LaunchAgents" };
57
+ return undefined;
58
+ }
59
+ function requireServiceBackend(command) {
60
+ const backend = currentServiceBackend();
61
+ if (backend !== undefined)
62
+ return backend;
63
+ throw new Error(`\`${command}\` requires a supported per-user service manager (systemd user services or LaunchAgents) and is not supported on ${platformLabel()}.\n\n${manualRunAdvice()}`);
64
+ }
65
+ function supportsSystemdUserServices() {
66
+ return currentServiceBackend()?.kind === "systemd";
67
+ }
68
+ function manualRunAdvice() {
69
+ return [
70
+ "Run PI WEB manually from a checkout:",
71
+ " npm run start:sessiond",
72
+ " PI_WEB_PORT=8504 npm start",
73
+ "",
74
+ "For development in one terminal:",
75
+ " npm run dev",
76
+ "",
77
+ "For split development, keep sessiond separate and run web/API plus Vite UI separately:",
78
+ " npm run dev:sessiond",
79
+ " npm run dev:web",
80
+ " npm run dev:client",
81
+ ].join("\n");
82
+ }
12
83
  function run(command, args, options = {}) {
13
84
  const result = spawnSync(command, args, { stdio: "inherit" });
14
85
  const status = result.status ?? 1;
@@ -16,9 +87,17 @@ function run(command, args, options = {}) {
16
87
  process.exit(status);
17
88
  return status;
18
89
  }
90
+ function outputText(value) {
91
+ return typeof value === "string" ? value : "";
92
+ }
19
93
  function capture(command, args) {
20
94
  const result = spawnSync(command, args, { encoding: "utf8" });
21
- return { status: result.status ?? 1, stdout: result.stdout, stderr: result.stderr };
95
+ const errorMessage = result.error instanceof Error ? result.error.message : "";
96
+ const stderr = outputText(result.stderr);
97
+ return { status: result.status ?? 1, stdout: outputText(result.stdout), stderr: stderr === "" ? errorMessage : stderr };
98
+ }
99
+ function runQuiet(command, args) {
100
+ return capture(command, args).status;
22
101
  }
23
102
  function hasCommand(command) {
24
103
  return capture("/usr/bin/env", ["sh", "-c", `command -v ${command}`]).status === 0;
@@ -37,7 +116,7 @@ function isLingerEnabled() {
37
116
  return undefined;
38
117
  }
39
118
  function parseInstallOptions(args) {
40
- const options = { host: "127.0.0.1", port: "8504" };
119
+ const options = { host: "127.0.0.1", port: "8504", mode: "production" };
41
120
  for (let i = 0; i < args.length; i += 1) {
42
121
  const arg = args[i];
43
122
  if (arg === undefined)
@@ -72,8 +151,11 @@ function parseInstallOptions(args) {
72
151
  else if (arg.startsWith("--config=")) {
73
152
  options.config = arg.slice("--config=".length);
74
153
  }
154
+ else if (arg === "--dev") {
155
+ options.mode = "dev";
156
+ }
75
157
  else if (arg === "--user-systemd") {
76
- // Accepted for readability; user systemd is the only installer target for now.
158
+ // Accepted for backwards-compatible readability; PI WEB chooses the native user service backend automatically.
77
159
  }
78
160
  else {
79
161
  throw new Error(`Unknown install option: ${arg}`);
@@ -90,6 +172,17 @@ function fishSingleQuote(value) {
90
172
  function systemdEscape(value) {
91
173
  return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
92
174
  }
175
+ function systemdQuotedValue(value) {
176
+ return `"${systemdEscape(value)}"`;
177
+ }
178
+ function xmlEscape(value) {
179
+ return value
180
+ .replaceAll("&", "&amp;")
181
+ .replaceAll("<", "&lt;")
182
+ .replaceAll(">", "&gt;")
183
+ .replaceAll('"', "&quot;")
184
+ .replaceAll("'", "&apos;");
185
+ }
93
186
  function packageRootPath() {
94
187
  return dirname(dirname(fileURLToPath(import.meta.url)));
95
188
  }
@@ -100,14 +193,15 @@ function detectServiceShell() {
100
193
  const userShell = userInfo().shell ?? undefined;
101
194
  const envShell = process.env["SHELL"]?.trim();
102
195
  const detected = envShell === undefined || envShell === "" ? userShell : envShell;
103
- const name = basename(detected ?? "").replace(/^-/u, "");
196
+ const name = basename(detected ?? "").replace(/^-/, "");
104
197
  if (name === "bash" || name === "zsh" || name === "fish") {
105
198
  return { name, executable: detected ?? name, detected: detected ?? name, fallback: false };
106
199
  }
107
200
  return { name: "bash", executable: "bash", ...(detected === undefined ? {} : { detected }), fallback: true };
108
201
  }
109
- function serviceShellCommand(command) {
110
- return ["/usr/bin/env", detectServiceShell().executable, "-lc", command];
202
+ function serviceShellCommand(command, cwd) {
203
+ const fullCommand = cwd === undefined ? command : `cd ${serviceShellQuote(cwd)} && ${command}`;
204
+ return ["/usr/bin/env", detectServiceShell().executable, "-lc", fullCommand];
111
205
  }
112
206
  function serviceShellExecPrefix() {
113
207
  return `/usr/bin/env ${detectServiceShell().executable} -lc`;
@@ -122,50 +216,48 @@ function checkSucceeds(command) {
122
216
  const [bin, ...args] = command;
123
217
  return bin !== undefined && capture(bin, args).status === 0;
124
218
  }
125
- function serviceShellCanFindCommand(command) {
219
+ function serviceShellCanFindCommand(command, backend) {
126
220
  if (!checkSucceeds(serviceShellCommand(commandCheck(command))))
127
221
  return false;
128
- return checkSucceeds(systemdUserServiceShellCommand(commandCheck(command)));
222
+ if (backend.kind === "systemd")
223
+ return checkSucceeds(systemdUserServiceShellCommand(commandCheck(command)));
224
+ return true;
129
225
  }
130
226
  function readableFileCheck(path) {
131
227
  const quoted = serviceShellQuote(path);
132
228
  return `test -r ${quoted} && printf '%s\\n' ${quoted}`;
133
229
  }
134
- function commandExecutable(command) {
230
+ function commandExecutable(command, backend) {
135
231
  const shell = serviceShellLabel();
136
- return {
137
- command,
138
- checks: [
139
- [`${shell} can find ${command}`, serviceShellCommand(commandCheck(command))],
140
- [`systemd user ${shell} can find ${command}`, systemdUserServiceShellCommand(commandCheck(command))],
141
- ],
142
- };
232
+ const checks = [[`${shell} can find ${command}`, serviceShellCommand(commandCheck(command))]];
233
+ if (backend.kind === "systemd") {
234
+ checks.push([`systemd user ${shell} can find ${command}`, systemdUserServiceShellCommand(commandCheck(command))]);
235
+ }
236
+ return { command, checks };
143
237
  }
144
- function bundledExecutable(command, entrypointPath) {
238
+ function bundledExecutable(command, entrypointPath, backend) {
145
239
  const shell = serviceShellLabel();
146
240
  const check = readableFileCheck(entrypointPath);
147
- return {
148
- command: `node ${serviceShellQuote(entrypointPath)}`,
149
- checks: [
150
- [`${shell} can access bundled ${command} entrypoint`, serviceShellCommand(check)],
151
- [`systemd user ${shell} can access bundled ${command} entrypoint`, systemdUserServiceShellCommand(check)],
152
- ],
153
- };
241
+ const checks = [[`${shell} can access bundled ${command} entrypoint`, serviceShellCommand(check)]];
242
+ if (backend.kind === "systemd") {
243
+ checks.push([`systemd user ${shell} can access bundled ${command} entrypoint`, systemdUserServiceShellCommand(check)]);
244
+ }
245
+ return { command: `node ${serviceShellQuote(entrypointPath)}`, checks };
154
246
  }
155
- function serviceExecutable(envName, command, entrypointPath) {
247
+ function serviceExecutable(envName, command, entrypointPath, backend) {
156
248
  const configured = process.env[envName]?.trim();
157
249
  if (configured !== undefined && configured !== "")
158
250
  return { command: configured, checks: [] };
159
- if (serviceShellCanFindCommand(command))
160
- return commandExecutable(command);
251
+ if (serviceShellCanFindCommand(command, backend))
252
+ return commandExecutable(command, backend);
161
253
  if (existsSync(entrypointPath))
162
- return bundledExecutable(command, entrypointPath);
163
- return commandExecutable(command);
254
+ return bundledExecutable(command, entrypointPath, backend);
255
+ return commandExecutable(command, backend);
164
256
  }
165
- function resolveServiceExecutables() {
257
+ function resolveServiceExecutables(backend) {
166
258
  return {
167
- sessiond: serviceExecutable("PI_WEB_SESSIOND_EXEC", "pi-web-sessiond", packageEntrypointPath("sessiond")),
168
- web: serviceExecutable("PI_WEB_SERVER_EXEC", "pi-web-server", packageEntrypointPath("server")),
259
+ sessiond: serviceExecutable("PI_WEB_SESSIOND_EXEC", "pi-web-sessiond", packageEntrypointPath("sessiond"), backend),
260
+ web: serviceExecutable("PI_WEB_SERVER_EXEC", "pi-web-server", packageEntrypointPath("server"), backend),
169
261
  };
170
262
  }
171
263
  function describeServiceShell() {
@@ -173,39 +265,148 @@ function describeServiceShell() {
173
265
  if (shell.fallback) {
174
266
  return shell.detected === undefined
175
267
  ? "could not detect a supported login shell; using bash"
176
- : `detected ${shell.detected}; using bash because Pi Web currently supports bash, zsh, and fish`;
268
+ : `detected ${shell.detected}; using bash because PI WEB currently supports bash, zsh, and fish`;
177
269
  }
178
270
  return shell.detected === undefined ? shell.name : `${shell.name} (${shell.detected})`;
179
271
  }
180
- function sessiondUnit(executables) {
272
+ function configEnvironment(options, configPath) {
273
+ return options.config === undefined ? {} : { PI_WEB_CONFIG: configPath };
274
+ }
275
+ function serviceRefList(ids) {
276
+ return ids.map((id) => serviceRefs[id]);
277
+ }
278
+ function allServiceRefs() {
279
+ return serviceRefList(["sessiond", "web", "uiDev"]);
280
+ }
281
+ function productionServiceRefs() {
282
+ return serviceRefList(productionServiceIds);
283
+ }
284
+ function orderServiceRefs(refs, order) {
285
+ const byId = new Map(refs.map((ref) => [ref.id, ref]));
286
+ return order.flatMap((id) => {
287
+ const ref = byId.get(id);
288
+ return ref === undefined ? [] : [ref];
289
+ });
290
+ }
291
+ function startOrder(refs) {
292
+ return orderServiceRefs(refs, startServiceOrder);
293
+ }
294
+ function stopOrder(refs) {
295
+ return orderServiceRefs(refs, stopServiceOrder);
296
+ }
297
+ function productionServiceDefinitions(options, configPath, executables) {
298
+ return [
299
+ {
300
+ ...serviceRefs.sessiond,
301
+ description: "PI WEB session daemon",
302
+ shellCommand: `exec ${executables.sessiond.command}`,
303
+ restart: "on-failure",
304
+ environment: {},
305
+ },
306
+ {
307
+ ...serviceRefs.web,
308
+ description: "PI WEB server",
309
+ shellCommand: `exec ${executables.web.command}`,
310
+ restart: "on-failure",
311
+ environment: configEnvironment(options, configPath),
312
+ after: ["sessiond"],
313
+ wants: ["sessiond"],
314
+ },
315
+ ];
316
+ }
317
+ function devRootPath() {
318
+ return resolve(process.cwd());
319
+ }
320
+ function validateDevCheckout(root) {
321
+ const packageJsonPath = join(root, "package.json");
322
+ if (!existsSync(packageJsonPath)) {
323
+ throw new Error(`Development mode must be installed from a PI WEB checkout. Missing package.json: ${packageJsonPath}`);
324
+ }
325
+ const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8"));
326
+ if (!isRecord(parsed) || parsed["name"] !== PI_WEB_PACKAGE_NAME) {
327
+ throw new Error(`Development mode must be installed from a PI WEB checkout. ${packageJsonPath} is not ${PI_WEB_PACKAGE_NAME}.`);
328
+ }
329
+ const scripts = parsed["scripts"];
330
+ if (!isRecord(scripts))
331
+ throw new Error(`Development mode requires npm scripts in ${packageJsonPath}.`);
332
+ const requiredScripts = ["start:sessiond", "dev:web", "dev:client"];
333
+ const missing = requiredScripts.filter((script) => typeof scripts[script] !== "string");
334
+ if (missing.length > 0)
335
+ throw new Error(`Development mode requires missing npm scripts: ${missing.join(", ")}.`);
336
+ }
337
+ function devServiceDefinitions(options, configPath, root) {
338
+ return [
339
+ {
340
+ ...serviceRefs.sessiond,
341
+ description: "PI WEB session daemon (dev)",
342
+ shellCommand: "exec npm run start:sessiond",
343
+ restart: "never",
344
+ environment: {},
345
+ workingDirectory: root,
346
+ },
347
+ {
348
+ ...serviceRefs.uiDev,
349
+ description: "PI WEB UI dev server",
350
+ shellCommand: `exec /usr/bin/env bash -c ${serviceShellQuote('trap "kill 0" EXIT; npm run dev:web & npm run dev:client & wait')}`,
351
+ restart: "never",
352
+ environment: configEnvironment(options, configPath),
353
+ after: ["sessiond"],
354
+ wants: ["sessiond"],
355
+ workingDirectory: root,
356
+ },
357
+ ];
358
+ }
359
+ function dependencyLine(name, ids) {
360
+ if (ids === undefined || ids.length === 0)
361
+ return "";
362
+ return `${name}=${ids.map((id) => serviceRefs[id].systemdName).join(" ")}\n`;
363
+ }
364
+ function environmentLines(environment) {
365
+ return Object.entries(environment)
366
+ .map(([key, value]) => `Environment="${key}=${systemdEscape(value)}"\n`)
367
+ .join("");
368
+ }
369
+ function systemdUnit(service) {
370
+ const workingDirectory = service.workingDirectory === undefined ? "" : `WorkingDirectory=${systemdQuotedValue(service.workingDirectory)}\n`;
371
+ const restart = service.restart === "on-failure" ? "Restart=on-failure\nRestartSec=2\n" : "Restart=no\n";
181
372
  return `[Unit]
182
- Description=Pi Web session daemon
183
-
373
+ Description=${service.description}
374
+ ${dependencyLine("After", service.after)}${dependencyLine("Wants", service.wants)}
184
375
  [Service]
185
376
  Type=simple
186
- ExecStart=${serviceShellExecPrefix()} ${systemdServiceShellQuote(`exec ${executables.sessiond.command}`)}
187
- Restart=on-failure
188
- RestartSec=2
189
-
377
+ ${workingDirectory}${environmentLines(service.environment)}ExecStart=${serviceShellExecPrefix()} ${systemdServiceShellQuote(service.shellCommand)}
378
+ ${restart}
190
379
  [Install]
191
380
  WantedBy=default.target
192
381
  `;
193
382
  }
194
- function webUnit(options, executables) {
195
- const configEnvironment = options.config === undefined ? "" : `Environment="PI_WEB_CONFIG=${systemdEscape(resolve(options.config))}"\n`;
196
- return `[Unit]
197
- Description=Pi Web server
198
- After=${sessiondServiceName}
199
- Wants=${sessiondServiceName}
200
-
201
- [Service]
202
- Type=simple
203
- ${configEnvironment}ExecStart=${serviceShellExecPrefix()} ${systemdServiceShellQuote(`exec ${executables.web.command}`)}
204
- Restart=on-failure
205
- RestartSec=2
206
-
207
- [Install]
208
- WantedBy=default.target
383
+ function plistString(key, value, indent = " ") {
384
+ return `${indent}<key>${xmlEscape(key)}</key>\n${indent}<string>${xmlEscape(value)}</string>\n`;
385
+ }
386
+ function plistProgramArguments(service) {
387
+ const args = ["/usr/bin/env", detectServiceShell().executable, "-lc", service.shellCommand];
388
+ return ` <key>ProgramArguments</key>\n <array>\n${args.map((arg) => ` <string>${xmlEscape(arg)}</string>`).join("\n")}\n </array>\n`;
389
+ }
390
+ function plistEnvironment(environment) {
391
+ const entries = Object.entries(environment);
392
+ if (entries.length === 0)
393
+ return "";
394
+ return ` <key>EnvironmentVariables</key>\n <dict>\n${entries.map(([key, value]) => plistString(key, value, " ")).join("")} </dict>\n`;
395
+ }
396
+ function launchdLogPath(ref) {
397
+ return join(logDir, ref.logName);
398
+ }
399
+ function launchdPlist(service) {
400
+ const workingDirectory = service.workingDirectory === undefined ? "" : plistString("WorkingDirectory", service.workingDirectory);
401
+ const keepAlive = service.restart === "on-failure" ? " <key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n </dict>\n" : "";
402
+ return `<?xml version="1.0" encoding="UTF-8"?>
403
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
404
+ <plist version="1.0">
405
+ <dict>
406
+ ${plistString("Label", service.launchdLabel)}${plistProgramArguments(service)}${workingDirectory}${plistEnvironment(service.environment)} <key>RunAtLoad</key>
407
+ <true/>
408
+ ${keepAlive}${plistString("StandardOutPath", launchdLogPath(service))}${plistString("StandardErrorPath", launchdLogPath(service))}</dict>
409
+ </plist>
209
410
  `;
210
411
  }
211
412
  async function writeInitialConfig(options) {
@@ -216,33 +417,164 @@ async function writeInitialConfig(options) {
216
417
  }
217
418
  return configPath;
218
419
  }
420
+ function systemdServicePath(ref) {
421
+ return join(systemdServiceDir, ref.systemdName);
422
+ }
423
+ function launchdPlistPath(ref) {
424
+ return join(launchdServiceDir, ref.launchdPlistName);
425
+ }
426
+ function serviceFileExists(backend, ref) {
427
+ return existsSync(backend.kind === "systemd" ? systemdServicePath(ref) : launchdPlistPath(ref));
428
+ }
429
+ function installedServiceRefs(backend) {
430
+ const installed = startOrder(allServiceRefs().filter((ref) => serviceFileExists(backend, ref)));
431
+ return installed.length === 0 ? productionServiceRefs() : installed;
432
+ }
433
+ async function installSystemdServices(services) {
434
+ const selected = new Set(services.map((service) => service.id));
435
+ const obsolete = stopOrder(allServiceRefs().filter((ref) => !selected.has(ref.id)));
436
+ for (const ref of obsolete) {
437
+ runQuiet("systemctl", ["--user", "disable", "--now", ref.systemdName]);
438
+ await rm(systemdServicePath(ref), { force: true });
439
+ }
440
+ await mkdir(systemdServiceDir, { recursive: true });
441
+ for (const service of services) {
442
+ await writeFile(systemdServicePath(service), systemdUnit(service));
443
+ }
444
+ const names = services.map((service) => service.systemdName);
445
+ run("systemctl", ["--user", "daemon-reload"], { check: true });
446
+ run("systemctl", ["--user", "enable", ...names], { check: true });
447
+ run("systemctl", ["--user", "restart", ...names], { check: true });
448
+ }
449
+ function launchdDomain() {
450
+ return `gui/${String(userInfo().uid)}`;
451
+ }
452
+ function launchdServiceTarget(ref) {
453
+ return `${launchdDomain()}/${ref.launchdLabel}`;
454
+ }
455
+ function launchdIsLoaded(ref) {
456
+ return capture("launchctl", ["print", launchdServiceTarget(ref)]).status === 0;
457
+ }
458
+ function launchdBootout(ref) {
459
+ runQuiet("launchctl", ["bootout", launchdServiceTarget(ref)]);
460
+ }
461
+ function launchdBootstrap(ref) {
462
+ run("launchctl", ["bootstrap", launchdDomain(), launchdPlistPath(ref)], { check: true });
463
+ run("launchctl", ["enable", launchdServiceTarget(ref)], { check: true });
464
+ }
465
+ function launchdStart(ref) {
466
+ if (!launchdIsLoaded(ref))
467
+ launchdBootstrap(ref);
468
+ run("launchctl", ["kickstart", launchdServiceTarget(ref)], { check: true });
469
+ }
470
+ async function installLaunchdServices(services) {
471
+ const selected = new Set(services.map((service) => service.id));
472
+ await mkdir(launchdServiceDir, { recursive: true });
473
+ await mkdir(logDir, { recursive: true });
474
+ for (const ref of stopOrder(allServiceRefs()))
475
+ launchdBootout(ref);
476
+ for (const ref of allServiceRefs().filter((candidate) => !selected.has(candidate.id))) {
477
+ await rm(launchdPlistPath(ref), { force: true });
478
+ }
479
+ for (const service of services) {
480
+ await writeFile(launchdPlistPath(service), launchdPlist(service));
481
+ }
482
+ for (const service of services)
483
+ launchdStart(service);
484
+ }
485
+ async function installNativeServices(backend, services) {
486
+ if (backend.kind === "systemd")
487
+ await installSystemdServices(services);
488
+ else
489
+ await installLaunchdServices(services);
490
+ }
491
+ async function uninstallSystemdServices() {
492
+ for (const ref of stopOrder(allServiceRefs())) {
493
+ runQuiet("systemctl", ["--user", "disable", "--now", ref.systemdName]);
494
+ await rm(systemdServicePath(ref), { force: true });
495
+ }
496
+ runQuiet("systemctl", ["--user", "daemon-reload"]);
497
+ }
498
+ async function uninstallLaunchdServices() {
499
+ for (const ref of stopOrder(allServiceRefs())) {
500
+ launchdBootout(ref);
501
+ await rm(launchdPlistPath(ref), { force: true });
502
+ }
503
+ }
504
+ async function uninstallNativeServices(backend) {
505
+ if (backend.kind === "systemd")
506
+ await uninstallSystemdServices();
507
+ else
508
+ await uninstallLaunchdServices();
509
+ }
510
+ function backendAvailabilityChecks(backend) {
511
+ if (backend.kind === "systemd")
512
+ return [["systemctl --user", ["systemctl", "--user", "--version"]]];
513
+ return [[`launchctl ${launchdDomain()}`, ["launchctl", "print", launchdDomain()]]];
514
+ }
515
+ function baseShellChecks(backend) {
516
+ const shell = serviceShellLabel();
517
+ const checks = [[`${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())]];
518
+ if (backend.kind === "systemd")
519
+ checks.push([`systemd user ${shell} can find node >= 22`, systemdUserServiceShellCommand(nodeVersionCheck())]);
520
+ return checks;
521
+ }
522
+ function devInstallChecks(backend, root) {
523
+ const shell = serviceShellLabel();
524
+ const checks = [
525
+ [`${shell} can find npm`, serviceShellCommand(commandCheck("npm"), root)],
526
+ [`${shell} can find bash`, serviceShellCommand(commandCheck("bash"), root)],
527
+ ];
528
+ if (backend.kind === "systemd") {
529
+ checks.push([`systemd user ${shell} can find npm`, systemdUserServiceShellCommand(commandCheck("npm"), root)], [`systemd user ${shell} can find bash`, systemdUserServiceShellCommand(commandCheck("bash"), root)]);
530
+ }
531
+ return checks;
532
+ }
533
+ function installPreflightChecks(backend, mode, executables, devRoot) {
534
+ return [
535
+ ...backendAvailabilityChecks(backend),
536
+ ...baseShellChecks(backend),
537
+ ...(mode === "dev" && devRoot !== undefined ? devInstallChecks(backend, devRoot) : []),
538
+ ...(mode === "production" && executables !== undefined ? [...executables.web.checks, ...executables.sessiond.checks] : []),
539
+ ];
540
+ }
219
541
  async function install(args) {
542
+ const backend = requireServiceBackend("pi-web install");
220
543
  const options = parseInstallOptions(args);
221
- const executables = resolveServiceExecutables();
222
- console.log("Running Pi Web install preflight checks...");
544
+ const devRoot = options.mode === "dev" ? devRootPath() : undefined;
545
+ if (devRoot !== undefined)
546
+ validateDevCheckout(devRoot);
547
+ const executables = options.mode === "production" ? resolveServiceExecutables(backend) : undefined;
548
+ console.log(`Running PI WEB ${options.mode} install preflight checks...`);
549
+ console.log(`Service backend: ${backend.label}`);
223
550
  console.log(`Service shell: ${describeServiceShell()}`);
224
- if (!runChecks(installPreflightChecks(executables))) {
551
+ if (!runChecks(installPreflightChecks(backend, options.mode, executables, devRoot))) {
225
552
  printPathSetupAdvice();
226
553
  throw new Error("Install preflight checks failed. Fix the failed checks above, then run `pi-web doctor` for more detail.");
227
554
  }
228
555
  const configPath = await writeInitialConfig(options);
229
- await mkdir(serviceDir, { recursive: true });
230
- await writeFile(join(serviceDir, sessiondServiceName), sessiondUnit(executables));
231
- await writeFile(join(serviceDir, webServiceName), webUnit(options, executables));
232
- run("systemctl", ["--user", "daemon-reload"], { check: true });
233
- run("systemctl", ["--user", "enable", "--now", sessiondServiceName], { check: true });
234
- run("systemctl", ["--user", "enable", "--now", webServiceName], { check: true });
235
- console.log(`\nPi Web is installed and starting.`);
556
+ const services = options.mode === "dev"
557
+ ? devServiceDefinitions(options, configPath, devRoot ?? devRootPath())
558
+ : productionServiceDefinitions(options, configPath, executables ?? resolveServiceExecutables(backend));
559
+ await installNativeServices(backend, services);
560
+ console.log(`\nPI WEB ${options.mode} services are installed and starting.`);
236
561
  console.log(`Config: ${configPath}`);
237
- console.log(`Open: http://${options.host === "0.0.0.0" ? "127.0.0.1" : options.host}:${options.port}`);
238
- const linger = isLingerEnabled();
239
- if (linger === false) {
240
- console.log("\nRecommended for server use: keep user services running after logout/reboot:");
241
- console.log(` sudo loginctl enable-linger ${userInfo().username}`);
562
+ if (options.mode === "dev") {
563
+ console.log("Open: http://127.0.0.1:8505");
242
564
  }
243
- else if (linger === undefined) {
244
- console.log("\nRecommended for server use: enable systemd user lingering so services survive logout/reboot:");
245
- console.log(` sudo loginctl enable-linger ${userInfo().username}`);
565
+ else {
566
+ console.log(`Open: http://${options.host === "0.0.0.0" ? "127.0.0.1" : options.host}:${options.port}`);
567
+ }
568
+ if (backend.kind === "systemd") {
569
+ const linger = isLingerEnabled();
570
+ if (linger === false) {
571
+ console.log("\nRecommended for server use: keep user services running after logout/reboot:");
572
+ console.log(` sudo loginctl enable-linger ${userInfo().username}`);
573
+ }
574
+ else if (linger === undefined) {
575
+ console.log("\nRecommended for server use: enable systemd user lingering so services survive logout/reboot:");
576
+ console.log(` sudo loginctl enable-linger ${userInfo().username}`);
577
+ }
246
578
  }
247
579
  console.log("\nUseful commands:");
248
580
  console.log(" pi-web status");
@@ -250,23 +582,55 @@ async function install(args) {
250
582
  console.log(" pi-web restart");
251
583
  }
252
584
  async function uninstall() {
253
- run("systemctl", ["--user", "disable", "--now", webServiceName]);
254
- run("systemctl", ["--user", "disable", "--now", sessiondServiceName]);
255
- await rm(join(serviceDir, webServiceName), { force: true });
256
- await rm(join(serviceDir, sessiondServiceName), { force: true });
257
- run("systemctl", ["--user", "daemon-reload"]);
258
- console.log("Pi Web systemd user services removed.");
585
+ const backend = requireServiceBackend("pi-web uninstall");
586
+ await uninstallNativeServices(backend);
587
+ console.log(`PI WEB ${backend.label} removed. Production and development service files were removed; config and data were left in place.`);
588
+ }
589
+ function systemdServiceAction(action, refs) {
590
+ const orderedRefs = action === "stop" ? stopOrder(refs) : startOrder(refs);
591
+ run("systemctl", ["--user", action, ...orderedRefs.map((ref) => ref.systemdName)], { check: action !== "status" });
592
+ }
593
+ function launchdServiceAction(action, refs) {
594
+ if (action === "status") {
595
+ for (const ref of refs) {
596
+ console.log(`\n== ${ref.launchdLabel} ==`);
597
+ run("launchctl", ["print", launchdServiceTarget(ref)]);
598
+ }
599
+ return;
600
+ }
601
+ if (action === "stop") {
602
+ for (const ref of stopOrder(refs))
603
+ launchdBootout(ref);
604
+ return;
605
+ }
606
+ if (action === "restart") {
607
+ for (const ref of stopOrder(refs))
608
+ launchdBootout(ref);
609
+ }
610
+ for (const ref of startOrder(refs))
611
+ launchdStart(ref);
259
612
  }
260
613
  function serviceAction(action) {
261
- run("systemctl", ["--user", action, sessiondServiceName, webServiceName], { check: action !== "status" });
614
+ const backend = requireServiceBackend(`pi-web ${action}`);
615
+ const refs = installedServiceRefs(backend);
616
+ if (backend.kind === "systemd")
617
+ systemdServiceAction(action, refs);
618
+ else
619
+ launchdServiceAction(action, refs);
262
620
  }
263
621
  function logs() {
264
- run("journalctl", ["--user", "-u", sessiondServiceName, "-u", webServiceName, "-f"]);
622
+ const backend = requireServiceBackend("pi-web logs");
623
+ const refs = installedServiceRefs(backend);
624
+ if (backend.kind === "systemd") {
625
+ run("journalctl", ["--user", ...refs.flatMap((ref) => ["-u", ref.systemdName]), "-f"]);
626
+ return;
627
+ }
628
+ run("tail", ["-F", ...refs.map((ref) => launchdLogPath(ref))]);
265
629
  }
266
630
  function serviceShellLabel() {
267
631
  return `${detectServiceShell().name} -lc`;
268
632
  }
269
- function systemdUserServiceShellCommand(command) {
633
+ function systemdUserServiceShellCommand(command, cwd) {
270
634
  return [
271
635
  "systemd-run",
272
636
  "--user",
@@ -274,7 +638,7 @@ function systemdUserServiceShellCommand(command) {
274
638
  "--collect",
275
639
  "--pipe",
276
640
  "--quiet",
277
- ...serviceShellCommand(command),
641
+ ...serviceShellCommand(command, cwd),
278
642
  ];
279
643
  }
280
644
  function commandCheck(command) {
@@ -286,25 +650,28 @@ function nodeVersionCheck() {
286
650
  "node -e \"const major = Number(process.versions.node.split('.')[0]); console.log(process.version); process.exit(major >= 22 ? 0 : 1);\"",
287
651
  ].join(" && ");
288
652
  }
289
- function installPreflightChecks(executables = resolveServiceExecutables()) {
290
- const shell = serviceShellLabel();
291
- return [
292
- ["systemctl --user", ["systemctl", "--user", "--version"]],
293
- [`${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())],
294
- [`systemd user ${shell} can find node >= 22`, systemdUserServiceShellCommand(nodeVersionCheck())],
295
- ...executables.web.checks,
296
- ...executables.sessiond.checks,
297
- ];
298
- }
299
653
  function doctorChecks() {
300
654
  const shell = serviceShellLabel();
301
- const executables = resolveServiceExecutables();
302
- return [
303
- ...installPreflightChecks(executables),
655
+ const backend = currentServiceBackend();
656
+ if (backend === undefined) {
657
+ return [
658
+ [`${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())],
659
+ [`${shell} can find npm`, serviceShellCommand(commandCheck("npm"))],
660
+ [`${shell} can find pi`, serviceShellCommand(commandCheck("pi"))],
661
+ ];
662
+ }
663
+ const checks = [
664
+ ...backendAvailabilityChecks(backend),
665
+ ...baseShellChecks(backend),
304
666
  [`${shell} can find npm`, serviceShellCommand(commandCheck("npm"))],
305
667
  [`${shell} can find pi`, serviceShellCommand(commandCheck("pi"))],
306
- [`systemd user ${shell} can find pi`, systemdUserServiceShellCommand(commandCheck("pi"))],
307
668
  ];
669
+ const executables = resolveServiceExecutables(backend);
670
+ checks.push(...executables.web.checks, ...executables.sessiond.checks);
671
+ if (backend.kind === "systemd") {
672
+ checks.push([`systemd user ${shell} can find pi`, systemdUserServiceShellCommand(commandCheck("pi"))]);
673
+ }
674
+ return checks;
308
675
  }
309
676
  function runChecks(checks) {
310
677
  let failed = false;
@@ -337,40 +704,69 @@ function printPathSetupAdvice() {
337
704
  }
338
705
  else if (shell.name === "zsh") {
339
706
  console.log(" Detected zsh. Put PATH setup for node/version managers/tools in ~/.zprofile, not only ~/.zshrc.");
340
- console.log(" Avoid relying on prompt hooks; Pi Web services run non-interactive login shells.");
707
+ console.log(" Avoid relying on prompt hooks; PI WEB services run non-interactive login shells.");
341
708
  }
342
709
  else {
343
710
  console.log(" Detected fish. Prefer universal PATH setup such as `fish_add_path -U ...` for tools needed by services or agents.");
344
- console.log(" Avoid relying on prompt hooks; Pi Web services run non-interactive login shells.");
711
+ console.log(" Avoid relying on prompt hooks; PI WEB services run non-interactive login shells.");
345
712
  }
346
713
  }
347
714
  function doctor() {
715
+ const backend = currentServiceBackend();
716
+ console.log(`Platform: ${platformLabel()}`);
717
+ console.log(`Service backend: ${backend?.label ?? "manual run only"}`);
348
718
  console.log(`Service shell: ${describeServiceShell()}`);
719
+ if (backend === undefined) {
720
+ console.log(`- Native user service checks skipped on ${platformLabel()}`);
721
+ }
349
722
  const ok = runChecks(doctorChecks());
350
- const linger = isLingerEnabled();
351
- if (linger === true) {
352
- console.log("✓ systemd user lingering enabled");
723
+ const nodePtySpawnHelperOk = printNodePtyDarwinSpawnHelperCheck();
724
+ if (supportsSystemdUserServices()) {
725
+ const linger = isLingerEnabled();
726
+ if (linger === true) {
727
+ console.log("✓ systemd user lingering enabled");
728
+ }
729
+ else if (linger === false) {
730
+ console.log("✗ systemd user lingering disabled");
731
+ console.log(` Recommended on servers: sudo loginctl enable-linger ${userInfo().username}`);
732
+ }
733
+ else {
734
+ console.log("? systemd user lingering unknown");
735
+ console.log(` Recommended on servers: sudo loginctl enable-linger ${userInfo().username}`);
736
+ }
353
737
  }
354
- else if (linger === false) {
355
- console.log("✗ systemd user lingering disabled");
356
- console.log(` Recommended on servers: sudo loginctl enable-linger ${userInfo().username}`);
738
+ else if (backend?.kind === "launchd") {
739
+ console.log("- user services start at login with LaunchAgents");
357
740
  }
358
741
  else {
359
- console.log("? systemd user lingering unknown");
360
- console.log(` Recommended on servers: sudo loginctl enable-linger ${userInfo().username}`);
742
+ console.log(`- systemd user lingering skipped on ${platformLabel()}`);
361
743
  }
362
744
  if (!ok) {
363
745
  console.log("\nIf a command works in your terminal but fails here, make sure your service shell login files set PATH the same way.");
364
- console.log("If a bundled entrypoint is not accessible, reinstall or update the Pi Web package.");
746
+ if (backend?.kind === "systemd")
747
+ console.log("If a bundled entrypoint is not accessible, reinstall or update the PI WEB package.");
365
748
  printPathSetupAdvice();
366
- process.exitCode = 1;
367
749
  }
750
+ if (ok && backend === undefined) {
751
+ console.log(`\n${manualRunAdvice()}`);
752
+ }
753
+ if (!ok || !nodePtySpawnHelperOk)
754
+ process.exitCode = 1;
755
+ }
756
+ function printNodePtyDarwinSpawnHelperCheck() {
757
+ const result = formatNodePtyDarwinSpawnHelperCheck(checkNodePtyDarwinSpawnHelper());
758
+ for (const line of result.lines)
759
+ console.log(line);
760
+ return result.ok;
761
+ }
762
+ function isRecord(value) {
763
+ return typeof value === "object" && value !== null && !Array.isArray(value);
368
764
  }
369
765
  function help() {
370
- console.log(`Pi Web
766
+ console.log(`PI WEB
371
767
 
372
768
  Usage:
373
- pi-web install [--host 127.0.0.1] [--port 8504] [--config ~/.config/pi-web/config.json]
769
+ pi-web install [--dev] [--host 127.0.0.1] [--port 8504] [--config ~/.config/pi-web/config.json]
374
770
  pi-web uninstall
375
771
  pi-web start|stop|restart|status|logs
376
772
  pi-web doctor
@@ -378,6 +774,9 @@ Usage:
378
774
  Recommended install:
379
775
  npm install -g @jmfederico/pi-web
380
776
  pi-web install
777
+
778
+ Development service install from a checkout:
779
+ pi-web install --dev
381
780
  `);
382
781
  }
383
782
  async function main() {