@edspencer/paddock 0.82.0 → 0.83.0

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 (114) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/cli/args.d.ts +29 -4
  3. package/packages/server/dist/cli/args.js +145 -7
  4. package/packages/server/dist/cli/busy-command.js +1 -1
  5. package/packages/server/dist/cli/busy.js +10 -12
  6. package/packages/server/dist/cli/doctor.d.ts +60 -0
  7. package/packages/server/dist/cli/doctor.js +580 -0
  8. package/packages/server/dist/cli/exit-codes.d.ts +21 -45
  9. package/packages/server/dist/cli/exit-codes.js +42 -45
  10. package/packages/server/dist/cli/output.js +5 -6
  11. package/packages/server/dist/cli/paddock.js +37 -12
  12. package/packages/server/dist/cli/service/index.d.ts +30 -2
  13. package/packages/server/dist/cli/service/index.js +163 -31
  14. package/packages/server/dist/cli/service/launchd.d.ts +31 -84
  15. package/packages/server/dist/cli/service/launchd.js +114 -304
  16. package/packages/server/dist/cli/service/logs.d.ts +9 -28
  17. package/packages/server/dist/cli/service/logs.js +33 -75
  18. package/packages/server/dist/cli/service/manager.d.ts +93 -0
  19. package/packages/server/dist/cli/service/manager.js +21 -0
  20. package/packages/server/dist/cli/service/path-env.d.ts +7 -18
  21. package/packages/server/dist/cli/service/path-env.js +30 -81
  22. package/packages/server/dist/cli/service/ready.d.ts +16 -23
  23. package/packages/server/dist/cli/service/ready.js +42 -0
  24. package/packages/server/dist/cli/service/spec.d.ts +4 -31
  25. package/packages/server/dist/cli/service/spec.js +9 -0
  26. package/packages/server/dist/cli/service/systemd.d.ts +27 -101
  27. package/packages/server/dist/cli/service/systemd.js +118 -261
  28. package/packages/server/dist/cli/update/health.d.ts +48 -0
  29. package/packages/server/dist/cli/update/health.js +64 -0
  30. package/packages/server/dist/cli/update/install-method.d.ts +29 -76
  31. package/packages/server/dist/cli/update/install-method.js +103 -178
  32. package/packages/server/dist/cli/update/lock.d.ts +7 -12
  33. package/packages/server/dist/cli/update/lock.js +28 -62
  34. package/packages/server/dist/cli/update/package-manager.d.ts +39 -74
  35. package/packages/server/dist/cli/update/package-manager.js +95 -160
  36. package/packages/server/dist/cli/update/registry.d.ts +17 -54
  37. package/packages/server/dist/cli/update/registry.js +46 -89
  38. package/packages/server/dist/cli/update/semver.d.ts +18 -41
  39. package/packages/server/dist/cli/update/semver.js +74 -124
  40. package/packages/server/dist/cli/update/update-check.d.ts +15 -26
  41. package/packages/server/dist/cli/update/update-check.js +30 -60
  42. package/packages/server/dist/cli/update/update.d.ts +57 -91
  43. package/packages/server/dist/cli/update/update.js +230 -431
  44. package/packages/server/dist/cli/update-command.js +3 -5
  45. package/packages/server/dist/runtime-file.d.ts +1 -1
  46. package/packages/server/dist/runtime-file.js +9 -13
  47. package/packages/server/package.json +1 -1
  48. package/packages/web/dist/assets/{DeleteProjectDialog-CphdGB3R.js → DeleteProjectDialog-B07MKgv4.js} +1 -1
  49. package/packages/web/dist/assets/DiscoverPage-D5C2fkeQ.js +2 -0
  50. package/packages/web/dist/assets/{DiscoverView-B_kcF4sm.js → DiscoverView-DpAAyBEf.js} +1 -1
  51. package/packages/web/dist/assets/{InstanceConfigPage-CvUK5DKW.js → InstanceConfigPage-YX0I_jVA.js} +1 -1
  52. package/packages/web/dist/assets/{MarkdownRenderer-DvZ-j_Ao.js → MarkdownRenderer-C-CUuFhT.js} +1 -1
  53. package/packages/web/dist/assets/{ProjectRedirect-DayHvUH2.js → ProjectRedirect-qyXbTK56.js} +1 -1
  54. package/packages/web/dist/assets/{ProjectView-CCw83gUi.js → ProjectView-CguNOv8k.js} +3 -3
  55. package/packages/web/dist/assets/{ProjectsGrid-B3Klg2H4.js → ProjectsGrid-CH2Lz2Hf.js} +1 -1
  56. package/packages/web/dist/assets/{RootHome-BxcDvpgI.js → RootHome-CUGUc8za.js} +1 -1
  57. package/packages/web/dist/assets/{Surfaces-7UlhFBSG.js → Surfaces-11or1q_3.js} +1 -1
  58. package/packages/web/dist/assets/{arc-DNkh5o5Q.js → arc-CQHeCzrk.js} +1 -1
  59. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-BrgN3oLF.js → architectureDiagram-3BPJPVTR-dGcuFxAa.js} +1 -1
  60. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-D05XOJSk.js → blockDiagram-GPEHLZMM-CGc9h97S.js} +1 -1
  61. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-CKaMBhMP.js → c4Diagram-AAUBKEIU-BpX0sqhH.js} +1 -1
  62. package/packages/web/dist/assets/channel-BAeygx-e.js +2 -0
  63. package/packages/web/dist/assets/{chunk-2J33WTMH-8hgadCQc.js → chunk-2J33WTMH-uORGdO9D.js} +1 -1
  64. package/packages/web/dist/assets/{chunk-4BX2VUAB-CBfEC-pu.js → chunk-4BX2VUAB-C8zI2PFy.js} +1 -1
  65. package/packages/web/dist/assets/{chunk-55IACEB6-DV1eMgHk.js → chunk-55IACEB6-D22Iv6j6.js} +1 -1
  66. package/packages/web/dist/assets/{chunk-727SXJPM-Cfgl5fxY.js → chunk-727SXJPM-DdbHXAtS.js} +1 -1
  67. package/packages/web/dist/assets/{chunk-AQP2D5EJ-BXhqD295.js → chunk-AQP2D5EJ-BeKbob3a.js} +1 -1
  68. package/packages/web/dist/assets/{chunk-FMBD7UC4-TFGOPtek.js → chunk-FMBD7UC4-DtLvFU_y.js} +1 -1
  69. package/packages/web/dist/assets/{chunk-ND2GUHAM-CHFM665R.js → chunk-ND2GUHAM-BWJFo6B1.js} +1 -1
  70. package/packages/web/dist/assets/{chunk-QZHKN3VN-CqEf6MMT.js → chunk-QZHKN3VN-DeFkNg3S.js} +1 -1
  71. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-Cq2-sBMa.js +2 -0
  72. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-Cq2-sBMa.js +2 -0
  73. package/packages/web/dist/assets/{core-DmEaKxBb.js → core-CTNNIHx5.js} +1 -1
  74. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BnGI6jkr.js → cose-bilkent-S5V4N54A-BmtlyA-v.js} +1 -1
  75. package/packages/web/dist/assets/{dagre-BM42HDAG-CKoPx61E.js → dagre-BM42HDAG-oS3rC9CI.js} +1 -1
  76. package/packages/web/dist/assets/{diagram-2AECGRRQ-mMmtlBl1.js → diagram-2AECGRRQ-BzIBqz6A.js} +1 -1
  77. package/packages/web/dist/assets/{diagram-5GNKFQAL-Cdz9Mtnt.js → diagram-5GNKFQAL-BgIT2g8R.js} +1 -1
  78. package/packages/web/dist/assets/{diagram-KO2AKTUF-DQxM3wYj.js → diagram-KO2AKTUF-DACT41Re.js} +1 -1
  79. package/packages/web/dist/assets/{diagram-LMA3HP47-BWrinPsq.js → diagram-LMA3HP47-BstA3UkI.js} +1 -1
  80. package/packages/web/dist/assets/{diagram-OG6HWLK6-U6MYUve2.js → diagram-OG6HWLK6-Dp3Cn5n_.js} +1 -1
  81. package/packages/web/dist/assets/{documentTitle-DGjRXuTP.js → documentTitle-DRSQmA8q.js} +1 -1
  82. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-xqMCsXxY.js → erDiagram-TEJ5UH35-wf8PyGx_.js} +1 -1
  83. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-PMfy_czF.js → flowDiagram-I6XJVG4X-CJ4xx6EQ.js} +1 -1
  84. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-whhYYE4N.js → ganttDiagram-6RSMTGT7-z9obPrPM.js} +1 -1
  85. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-C51JW4tf.js → gitGraphDiagram-PVQCEYII-Cf4Cu7Tk.js} +1 -1
  86. package/packages/web/dist/assets/{index-CHOtvlnO.js → index-Bsv-raz2.js} +2 -2
  87. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-SH8cr905.js → infoDiagram-5YYISTIA-BIfuiYxX.js} +1 -1
  88. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-BjqaoU3U.js → ishikawaDiagram-YF4QCWOH-1pACxMTX.js} +1 -1
  89. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-CBmdEVrd.js → journeyDiagram-JHISSGLW-XGbzfw-v.js} +1 -1
  90. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-U8njjMLZ.js → kanban-definition-UN3LZRKU-Bvl-nMak.js} +1 -1
  91. package/packages/web/dist/assets/{linear-DF66GPs1.js → linear-uNToO4bv.js} +1 -1
  92. package/packages/web/dist/assets/{mermaid.core-CouJz7Ph.js → mermaid.core-CrChvyj6.js} +4 -4
  93. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-BYIf0oFt.js → mindmap-definition-RKZ34NQL-C6DaQ4Fm.js} +1 -1
  94. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-Cm8rgg09.js → pieDiagram-4H26LBE5-CEh8lKyF.js} +1 -1
  95. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-C3Il5kOH.js → quadrantDiagram-W4KKPZXB-z4zvICSM.js} +1 -1
  96. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-D3PE2nTe.js → requirementDiagram-4Y6WPE33-BBltkjsj.js} +1 -1
  97. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-DdmvBM18.js → sankeyDiagram-5OEKKPKP-D0s95wQ4.js} +1 -1
  98. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-B8M_GAIq.js → sequenceDiagram-3UESZ5HK-kQa65Nbt.js} +1 -1
  99. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-DZkXnK1_.js → stateDiagram-AJRCARHV-BqVIoyED.js} +1 -1
  100. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-B6OXxEra.js +2 -0
  101. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-IHXq7rqi.js → timeline-definition-PNZ67QCA-Bo5L62WI.js} +1 -1
  102. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-Dga7tLtT.js → vennDiagram-CIIHVFJN-CN9rH4lT.js} +1 -1
  103. package/packages/web/dist/assets/{wardley-L42UT6IY-CmcwnEJ-.js → wardley-L42UT6IY-BKhnD_W_.js} +1 -1
  104. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-zHuSLrD4.js → wardleyDiagram-YWT4CUSO-XHTbZzXj.js} +1 -1
  105. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-D93MU_ic.js → xychartDiagram-2RQKCTM6--t9XJLbR.js} +1 -1
  106. package/packages/web/dist/index.html +1 -1
  107. package/packages/web/dist/sw.js +1 -1
  108. package/packages/server/dist/cli/service/backend.d.ts +0 -115
  109. package/packages/server/dist/cli/service/backend.js +0 -19
  110. package/packages/web/dist/assets/DiscoverPage-C4OPLNO8.js +0 -2
  111. package/packages/web/dist/assets/channel-JrEedxB8.js +0 -2
  112. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CHJiQp-W.js +0 -2
  113. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CHJiQp-W.js +0 -2
  114. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CjCdT9Ls.js +0 -2
@@ -1,48 +1,45 @@
1
- /**
2
- * The CLI contract's exit codes (SPEC §2), as constants.
3
- *
4
- * From edspencer/cli-contract SPEC.md §2 @ 0.1. Written here rather than copied
5
- * from `reference/exit-codes.ts` because the reference module did not exist yet
6
- * when Paddock aligned; wave C4 re-syncs this file to it and adds the usual
7
- * provenance header. The numbers, and what each means, are the contract.
8
- *
9
- * | 0 | done; running and answering; up to date; not busy
10
- * | 1 | failed
11
- * | 2 | usage: an unknown command or flag, a bad value
12
- * | 3 | not running: installed but not running or not answering
13
- * | 4 | not installed: there is no service unit
14
- * | 5 | this install cannot update itself (`update`, not yet built)
15
- * | 6 | the update failed and was rolled back (`update`, not yet built)
16
- * | 7 | the update failed and could not be rolled back
17
- * | 75 | busy: refused, and why was printed (`busy`, not yet built)
18
- *
19
- * Codes 3 and 4 are what `systemctl status` and LSB init scripts use for "not
20
- * running" and "no such service", so a script that already branches on them
21
- * keeps working. 75 is `EX_TEMPFAIL` from `sysexits.h`, which is also the code
22
- * that makes Watchtower's pre-update hook skip an update.
23
- *
24
- * The three `update` codes are declared now and unused until wave C2. They are
25
- * here because the contract's whole point is that the numbers mean the same
26
- * thing in every tool: adding them later, from a different file, is how two
27
- * tools end up disagreeing about what 5 means.
28
- */
1
+ // From edspencer/cli-contract reference/exit-codes.ts @ 0.2; local changes: none.
2
+ // The exit codes every tool shares (SPEC §2). Copy this file; don't renumber it.
3
+ //
4
+ // Set `process.exitCode`, never call `process.exit()`, so piped output isn't cut short.
29
5
  export const EXIT = {
30
- /** Done. Running and answering. Up to date. Not busy. */
31
- ok: 0,
32
- /** Failed — the catch-all. */
33
- failure: 1,
34
- /** Usage: an unknown command or flag, or a bad value. Usage follows the message. */
35
- usage: 2,
36
- /** Not running: installed, but not running or not answering. */
37
- notRunning: 3,
38
- /** Not installed: there is no service unit. */
39
- notInstalled: 4,
40
- /** This install cannot update itself; the right command was printed. */
41
- cannotSelfUpdate: 5,
6
+ /** Done; running and answering; up to date; not busy. */
7
+ OK: 0,
8
+ /** Failed. */
9
+ FAILED: 1,
10
+ /** Usage: an unknown command or flag, a bad value. The usage is printed after the message. */
11
+ USAGE: 2,
12
+ /** Not running: installed but not running or not answering, or no instance answers. */
13
+ NOT_RUNNING: 3,
14
+ /** Not installed: there's no service unit. */
15
+ NOT_INSTALLED: 4,
16
+ /** This install can't update itself; the right command was printed. */
17
+ CANNOT_UPDATE: 5,
42
18
  /** The update failed and was rolled back; the old version is running again. */
43
- rolledBack: 6,
44
- /** The update failed and could not be rolled back; a person is needed. */
45
- needsHuman: 7,
46
- /** Busy: refused, and why was printed. Try later, or use `--force`. */
47
- busy: 75,
19
+ ROLLED_BACK: 6,
20
+ /** The update failed and couldn't be rolled back; a person is needed. */
21
+ ROLLBACK_FAILED: 7,
22
+ /** Busy: refused, and why was printed. Try later, or use `--force`. `EX_TEMPFAIL` from sysexits.h. */
23
+ BUSY: 75,
48
24
  };
25
+ /** A tool may use 8 to 63 for its own commands (SPEC §2). */
26
+ export function isToolOwnCode(code) {
27
+ return Number.isInteger(code) && code >= 8 && code <= 63;
28
+ }
29
+ /** The SPEC's name for a shared code, for messages ("expected 4 (not installed), got 0 (ok)"). */
30
+ export function describeExitCode(code) {
31
+ if (code === null)
32
+ return 'no exit code (killed by a signal)';
33
+ const names = {
34
+ 0: 'ok',
35
+ 1: 'failed',
36
+ 2: 'usage',
37
+ 3: 'not running',
38
+ 4: 'not installed',
39
+ 5: "can't update itself",
40
+ 6: 'rolled back',
41
+ 7: "couldn't roll back",
42
+ 75: 'busy',
43
+ };
44
+ return names[code] ? `${code} (${names[code]})` : String(code);
45
+ }
@@ -1,8 +1,7 @@
1
- // From edspencer/cli-contract reference/output.ts @ SPEC 0.1; local changes: `./exit-codes.ts` → `./exit-codes.js`
2
- // (Paddock compiles to ESM-with-extensions, not a TS runtime); `EXIT.FAILED`/`EXIT.USAGE` → Paddock's lowercase
3
- // `EXIT.failure`/`EXIT.usage`, because Paddock's own `exit-codes.ts` predates the reference module and wave C4
4
- // re-syncs that file; and the reference's `UsageError` / `reportUsageError` are NOT copied, because `cli/args.ts`
5
- // already has a `UsageError` (a `CliError` subclass carrying its own code) that the entrypoint already reports.
1
+ // From edspencer/cli-contract reference/output.ts @ 0.2; local changes: `./exit-codes.ts` → `./exit-codes.js`
2
+ // (Paddock compiles to ESM-with-extensions, not a TS runtime); and the reference's `UsageError` / `reportUsageError`
3
+ // are NOT copied, because `cli/args.ts` already has a `UsageError` (a `CliError` subclass carrying its own code) that
4
+ // the entrypoint already reports.
6
5
  //
7
6
  // A CLI's output, the way SPEC §2 and §3 say: errors as `<tool>: <message>` on stderr, colour only on a TTY (honouring
8
7
  // NO_COLOR and FORCE_COLOR), and with `--json`, one JSON document on stdout and every word of prose on stderr.
@@ -56,7 +55,7 @@ export function createOutput(o) {
56
55
  warn(s) {
57
56
  stderr.write(line(s));
58
57
  },
59
- error(message, code = EXIT.failure) {
58
+ error(message, code = EXIT.FAILED) {
60
59
  stderr.write(line(`${o.tool}: ${message}`));
61
60
  return code;
62
61
  },
@@ -23,7 +23,7 @@ import os from "node:os";
23
23
  import path from "node:path";
24
24
  import { spawn } from "node:child_process";
25
25
  import { fileURLToPath } from "node:url";
26
- import { CliError, UsageError, USAGE, USAGE_SYNOPSIS, SERVICE_USAGE, CONFIG_USAGE, BUSY_USAGE, UPDATE_USAGE, parseCommand, nodeVersionProblem, explainListenError, } from "./args.js";
26
+ import { CliError, UsageError, USAGE, USAGE_SYNOPSIS, SERVICE_USAGE, CONFIG_USAGE, BUSY_USAGE, UPDATE_USAGE, DOCTOR_USAGE, parseCommand, nodeVersionProblem, explainListenError, } from "./args.js";
27
27
  import { EXIT } from "./exit-codes.js";
28
28
  import { runService, safeHomeDir, realpathOr } from "./service/index.js";
29
29
  import { PADDOCK_NAME, PADDOCK_VERSION } from "../version.js";
@@ -78,9 +78,9 @@ const packageRoot = findUp("package.json") ?? path.resolve(moduleDir, "../..");
78
78
  * says the usage follows the message — the short one, not {@link USAGE}'s
79
79
  * thousand words, which would bury the line that says what was wrong.
80
80
  */
81
- function fail(message, code = EXIT.failure) {
81
+ function fail(message, code = EXIT.FAILED) {
82
82
  console.error(`paddock: ${message}`);
83
- if (code === EXIT.usage) {
83
+ if (code === EXIT.USAGE) {
84
84
  console.error("");
85
85
  console.error(USAGE_SYNOPSIS);
86
86
  }
@@ -175,7 +175,7 @@ async function service(command) {
175
175
  // which `main` has already handled. Typed rather than asserted.
176
176
  if (action === undefined) {
177
177
  console.log(SERVICE_USAGE);
178
- return EXIT.ok;
178
+ return EXIT.OK;
179
179
  }
180
180
  try {
181
181
  return await runService(action, opts, serviceContext());
@@ -246,13 +246,13 @@ async function config(command) {
246
246
  // which `main` has already handled. Typed rather than asserted.
247
247
  if (action === undefined) {
248
248
  console.log(CONFIG_USAGE);
249
- return EXIT.ok;
249
+ return EXIT.OK;
250
250
  }
251
251
  process.env.PADDOCK_DATA_DIR = resolveDataDir(opts);
252
252
  const { runConfig } = await import("./config.js");
253
253
  try {
254
254
  await runConfig(action, opts);
255
- return EXIT.ok;
255
+ return EXIT.OK;
256
256
  }
257
257
  catch (err) {
258
258
  // A config that will not load reaches here as a CliError already carrying
@@ -289,13 +289,15 @@ async function main() {
289
289
  ? BUSY_USAGE
290
290
  : command.verb === "update"
291
291
  ? UPDATE_USAGE
292
- : USAGE);
293
- return EXIT.ok;
292
+ : command.verb === "doctor"
293
+ ? DOCTOR_USAGE
294
+ : USAGE);
295
+ return EXIT.OK;
294
296
  }
295
297
  if (opts.version) {
296
298
  // Bare: the version and a newline, nothing else (SPEC §1). Scripts parse this.
297
299
  console.log(PADDOCK_VERSION);
298
- return EXIT.ok;
300
+ return EXIT.OK;
299
301
  }
300
302
  const problem = nodeVersionProblem(process.versions.node);
301
303
  if (problem !== undefined)
@@ -329,6 +331,21 @@ async function main() {
329
331
  throw err;
330
332
  }
331
333
  }
334
+ // `doctor` (SPEC §1). Dynamic for the same reason `busy` and `update` are; it
335
+ // loads the config only to check it, and starts nothing.
336
+ if (command.verb === "doctor") {
337
+ const { runDoctor } = await import("./doctor.js");
338
+ try {
339
+ return await runDoctor(opts, serviceContext());
340
+ }
341
+ catch (err) {
342
+ if (err instanceof CliError)
343
+ return fail(err.message, err.exitCode);
344
+ if (err instanceof Error)
345
+ return fail(err.message);
346
+ throw err;
347
+ }
348
+ }
332
349
  addBundledBinsToPath();
333
350
  // NOTHING here reads `process.cwd()`, and that is the point (#798). `--here`
334
351
  // used to make the current directory the workspace by setting
@@ -352,7 +369,10 @@ async function main() {
352
369
  // dynamic import below is picked up with no special-casing in config.ts.
353
370
  // Precedence is explicit flag > existing env > our default.
354
371
  const dataDir = resolveDataDir(opts);
355
- noteFirstRun(dataDir);
372
+ // `--quiet` (SPEC §3): the banner line and nothing else, so not the
373
+ // first-run note either (it is prose on stderr, but prose all the same).
374
+ if (opts.quiet !== true)
375
+ noteFirstRun(dataDir);
356
376
  process.env.PADDOCK_DATA_DIR = dataDir;
357
377
  if (opts.port)
358
378
  process.env.PORT = opts.port;
@@ -404,6 +424,11 @@ async function main() {
404
424
  // runner, and by anything that wants the URL without guessing the port — so
405
425
  // the friendly block below it stays, but below it.
406
426
  console.log(`${PADDOCK_NAME} ${PADDOCK_VERSION}: ${url}`);
427
+ if (opts.quiet === true) {
428
+ if (opts.open)
429
+ openBrowser(url);
430
+ return EXIT.OK;
431
+ }
407
432
  // Still name the data dir, even though it no longer varies with cwd: "which
408
433
  // instance am I looking at" is a real question the moment anyone passes
409
434
  // `--data-dir`, and the answer should be readable off the two lines the tool
@@ -420,7 +445,7 @@ async function main() {
420
445
  // 0, but NOT yet: a foreground serve keeps running because the server holds
421
446
  // the event loop open. `process.exitCode` is set and never acted on until the
422
447
  // listener closes, which is what SIGTERM does (start.ts).
423
- return EXIT.ok;
448
+ return EXIT.OK;
424
449
  }
425
450
  /**
426
451
  * A closed pipe exits quietly (SPEC §2).
@@ -440,5 +465,5 @@ main().then((code) => {
440
465
  process.exitCode = code;
441
466
  }, (err) => {
442
467
  console.error("fatal:", err);
443
- process.exitCode = EXIT.failure;
468
+ process.exitCode = EXIT.FAILED;
444
469
  });
@@ -1,6 +1,6 @@
1
1
  import { type CliOptions, type ServiceAction } from "../args.js";
2
- import { isNpxPath, serviceDir, type ServiceSpec } from "./spec.js";
3
- import { type Runner } from "./backend.js";
2
+ import { isNpxPath, serviceDir } from "./spec.js";
3
+ import { type Runner, type ServiceManager as ServiceBackend, type ServiceSpec } from "./manager.js";
4
4
  import { type UpdateSummary } from "../update/update-check.js";
5
5
  import { type HealthAnswer, type Readiness } from "./ready.js";
6
6
  export interface ServiceContext {
@@ -44,7 +44,33 @@ export interface ServiceContext {
44
44
  force?: boolean;
45
45
  json?: boolean;
46
46
  }) => Promise<number | null>;
47
+ /**
48
+ * Who holds `host:port` — nothing, a Paddock, or something else — asked by
49
+ * `install` before it writes a unit (SPEC §1). Injected so a test's answer
50
+ * does not depend on what happens to be listening on the box running it.
51
+ */
52
+ portOwner?: (host: string, port: number) => Promise<PortOwner>;
47
53
  }
54
+ export type PortOwner = "free" | "paddock" | "other";
55
+ /** The real {@link ServiceContext.portOwner}: a TCP connect, then `/api/health` and `/api/busy`. */
56
+ export declare function portOwner(host: string, port: number): Promise<PortOwner>;
57
+ /**
58
+ * Why `install` won't write a unit that runs a checkout (SPEC §1, §6).
59
+ *
60
+ * A checkout (or an `npm link`) is a working tree: it gets rebuilt, switched to
61
+ * another branch, cleaned and moved, and a unit that runs it follows every one
62
+ * of those — half-built `dist/` included — at the next login, unattended. The
63
+ * flag is there for working on Paddock itself, where that is the point.
64
+ */
65
+ export declare function checkoutRefusal(packageRoot: string): string;
66
+ /**
67
+ * Pick a backend, or explain why there isn't one.
68
+ *
69
+ * Windows is the honest gap. It has no per-user service concept that maps onto
70
+ * either of these without a scheduled task or a service wrapper, and shipping
71
+ * an untested third writer would be worse than saying so.
72
+ */
73
+ export declare function serviceBackend(ctx: ServiceContext): ServiceBackend;
48
74
  /**
49
75
  * The version of the Paddock the unit points at.
50
76
  *
@@ -104,6 +130,8 @@ export interface StatusReport {
104
130
  /** A stale unit, a missing Node — things that are wrong but not fatal. */
105
131
  warnings: string[];
106
132
  }
133
+ /** What `status` reports, and what `doctor`'s service checks read: one report, so the two agree. */
134
+ export declare function buildStatus(ctx: ServiceContext, backend: ServiceBackend): Promise<StatusReport>;
107
135
  /**
108
136
  * Dispatch, returning the exit code rather than setting one (SPEC §2).
109
137
  *
@@ -27,15 +27,41 @@ import os from "node:os";
27
27
  import path from "node:path";
28
28
  import { CliError } from "../args.js";
29
29
  import { EXIT } from "../exit-codes.js";
30
- import { buildSpec, defaultDataDir, hostFromArgv, isNpxPath, NPX_REFUSAL, portFromArgv, serviceDir, serviceLogPath, } from "./spec.js";
31
- import { spawnRunner, } from "./backend.js";
32
- import { createLaunchdBackend } from "./launchd.js";
33
- import { createSystemdBackend } from "./systemd.js";
30
+ import { buildSpec, defaultDataDir, hostFromArgv, isNpxPath, NPX_REFUSAL, portFromArgv, serviceDir, serviceLogPath, PADDOCK_SERVICE, } from "./spec.js";
31
+ import { spawnRunner, } from "./manager.js";
32
+ import { createLaunchd, renderPlist } from "./launchd.js";
33
+ import { createSystemd, renderUnit } from "./systemd.js";
34
+ import { underNodeModules } from "../update/install-method.js";
34
35
  import { missingPaths } from "./path-env.js";
35
36
  import { DEFAULT_LOG_LINES, followFile, sizeOf, tailFile } from "./logs.js";
36
37
  import { readLiveRuntimeFile } from "../../runtime-file.js";
38
+ import { PADDOCK_VERSION } from "../../version.js";
37
39
  import { updateSummary } from "../update/update-check.js";
38
- import { waitForReady, fetchHealth, READY_TIMEOUT_LABEL, } from "./ready.js";
40
+ import { waitForReady, fetchHealth, identifyPaddock, isListening, READY_TIMEOUT_LABEL, } from "./ready.js";
41
+ /** The real {@link ServiceContext.portOwner}: a TCP connect, then `/api/health` and `/api/busy`. */
42
+ export async function portOwner(host, port) {
43
+ const probe = host === "0.0.0.0" || host === "" ? "127.0.0.1" : host === "::" ? "::1" : host;
44
+ if (!(await isListening(probe, port)))
45
+ return "free";
46
+ const who = await identifyPaddock(`http://${probe.includes(":") ? `[${probe}]` : probe}:${port}`);
47
+ return who?.paddock === true ? "paddock" : "other";
48
+ }
49
+ /**
50
+ * Why `install` won't write a unit that runs a checkout (SPEC §1, §6).
51
+ *
52
+ * A checkout (or an `npm link`) is a working tree: it gets rebuilt, switched to
53
+ * another branch, cleaned and moved, and a unit that runs it follows every one
54
+ * of those — half-built `dist/` included — at the next login, unattended. The
55
+ * flag is there for working on Paddock itself, where that is the point.
56
+ */
57
+ export function checkoutRefusal(packageRoot) {
58
+ return (`this copy of Paddock is a checkout (${packageRoot}), so \`service install\` won't use it.\n` +
59
+ " A unit that runs a checkout follows every rebuild, branch switch and move of\n" +
60
+ " it, at your next login, unattended. Install it properly first:\n\n" +
61
+ " npm i -g @edspencer/paddock && paddock service install\n\n" +
62
+ " Or run this checkout as the service anyway (when working on Paddock):\n" +
63
+ " paddock service install --from-checkout\n");
64
+ }
39
65
  /**
40
66
  * Pick a backend, or explain why there isn't one.
41
67
  *
@@ -43,12 +69,20 @@ import { waitForReady, fetchHealth, READY_TIMEOUT_LABEL, } from "./ready.js";
43
69
  * either of these without a scheduled task or a service wrapper, and shipping
44
70
  * an untested third writer would be worse than saying so.
45
71
  */
72
+ export function serviceBackend(ctx) {
73
+ return backendFor(ctx);
74
+ }
46
75
  function backendFor(ctx) {
47
76
  const run = ctx.run ?? spawnRunner;
48
77
  if (ctx.platform === "darwin")
49
- return createLaunchdBackend(run, ctx.homeDir);
50
- if (ctx.platform === "linux")
51
- return createSystemdBackend(run, ctx.homeDir, ctx.xdgConfigHome);
78
+ return createLaunchd(PADDOCK_SERVICE, { run, homeDir: ctx.homeDir });
79
+ if (ctx.platform === "linux") {
80
+ return createSystemd(PADDOCK_SERVICE, {
81
+ run,
82
+ homeDir: ctx.homeDir,
83
+ ...(ctx.xdgConfigHome !== undefined ? { xdgConfigHome: ctx.xdgConfigHome } : {}),
84
+ });
85
+ }
52
86
  throw new CliError(`\`paddock service\` supports macOS (launchd) and Linux (systemd --user), not ${ctx.platform}.\n` +
53
87
  "Run `paddock` in a terminal, or keep it up with whatever your platform uses.");
54
88
  }
@@ -182,6 +216,12 @@ function lingerNote(linger) {
182
216
  async function install(opts, ctx, backend) {
183
217
  if (isNpxPath(ctx.packageRoot))
184
218
  throw new CliError(NPX_REFUSAL);
219
+ // A checkout is a package root that isn't under any `node_modules` (SPEC §6):
220
+ // `packages/server` in the repo, `<prefix>/lib/node_modules/@edspencer/paddock/
221
+ // packages/server` from npm. `npm link` reads the same, on purpose.
222
+ if (!underNodeModules(ctx.packageRoot) && opts.fromCheckout !== true) {
223
+ throw new CliError(checkoutRefusal(ctx.packageRoot));
224
+ }
185
225
  // `--refresh` (SPEC §5 step 11): re-render an EXISTING unit, keeping what it
186
226
  // records. With nothing installed there is nothing to refresh — exit 4, the
187
227
  // same code every other command that needs a unit returns, rather than
@@ -190,17 +230,57 @@ async function install(opts, ctx, backend) {
190
230
  // CLI, and a silent fallback would move a `--port 9000` service to 7233 on
191
231
  // the one run nobody is watching.
192
232
  const refreshing = opts.refresh === true;
193
- let existing;
233
+ // A reinstall keeps what the installed unit records — its port, host, data
234
+ // dir and verbosity — unless a flag says otherwise (SPEC §1). It used to do
235
+ // that only with `--refresh`, so `service install` over a `--port 9000`
236
+ // service quietly moved it to 7233. `--refresh` still differs in two ways:
237
+ // it ignores `PADDOCK_DATA_DIR` in this shell, and with no unit it exits 4.
238
+ const current = backend.status();
239
+ const existing = current.registered ? current : undefined;
194
240
  if (refreshing) {
195
- existing = backend.status();
196
- if (!existing.registered) {
197
- console.log(notInstalled(backend));
198
- return EXIT.notInstalled;
241
+ if (existing === undefined) {
242
+ if (opts.dryRun === true && opts.json === true) {
243
+ // `--dry-run --json` prints its own document (runService leaves it be).
244
+ console.error(notInstalled(backend));
245
+ console.log(JSON.stringify({ ok: false, action: "install", dryRun: true, error: "not installed", unit: backend.unitPath }, null, 2));
246
+ }
247
+ else {
248
+ console.log(notInstalled(backend));
249
+ }
250
+ return EXIT.NOT_INSTALLED;
199
251
  }
200
252
  }
201
253
  const { spec, dataDir } = specFor(existing !== undefined ? inherit(opts, existing) : opts, ctx, refreshing);
202
254
  const url = `http://${hostFromArgv(spec.args)}:${portFromArgv(spec.args)}`;
203
- if (refreshing) {
255
+ // `--dry-run` (SPEC §1): the unit, on stdout, and nothing written — not the
256
+ // unit, not the service or log folders `backend.install` would make. The
257
+ // where-it-would-go line is on stderr, so `> paddock.service` captures the
258
+ // unit alone.
259
+ if (opts.dryRun === true) {
260
+ const contents = backend.platform === "darwin" ? renderPlist(PADDOCK_SERVICE, spec) : renderUnit(PADDOCK_SERVICE, spec);
261
+ if (opts.json === true) {
262
+ console.log(JSON.stringify({ ok: true, action: "install", dryRun: true, unit: backend.unitPath, contents }, null, 2));
263
+ }
264
+ else {
265
+ console.error(`paddock: dry run: this is the unit \`service install\` would write to ${backend.unitPath}. Nothing was written.`);
266
+ console.log(contents.trimEnd());
267
+ }
268
+ return EXIT.OK;
269
+ }
270
+ // Refuse a port something else holds (SPEC §1): the service would start,
271
+ // fail to bind, and restart every ten seconds, for good. A Paddock holding it
272
+ // is fine — that's the reinstall this replaces — and so is the unit's own
273
+ // running service when it's too old to say it's Paddock (before 0.81).
274
+ const port = Number(portFromArgv(spec.args));
275
+ const owner = await (ctx.portOwner ?? portOwner)(hostFromArgv(spec.args), port);
276
+ if (owner === "other") {
277
+ const itsOwn = current.registered && current.running && portFromArgv(current.argv) === String(port);
278
+ if (!itsOwn) {
279
+ throw new CliError(`port ${port} is held by something that isn't Paddock, so the service could not start.\n` +
280
+ ` Stop it, or pick another port: paddock service install --port ${port + 1}`);
281
+ }
282
+ }
283
+ if (existing !== undefined) {
204
284
  console.log(`\n Re-writing ${backend.unitPath}\n Keeping: paddock ${spec.args.join(" ")}`);
205
285
  }
206
286
  console.log(installProgress(backend));
@@ -241,7 +321,7 @@ async function install(opts, ctx, backend) {
241
321
  ].join("\n"));
242
322
  // "It is installed but did not answer" is a failure, and used to exit 0 — so
243
323
  // an Ansible task or a CI step could not tell it from a working install.
244
- return readiness === "ready" ? EXIT.ok : EXIT.failure;
324
+ return readiness === "ready" ? EXIT.OK : EXIT.FAILED;
245
325
  }
246
326
  function uninstall(ctx, backend) {
247
327
  const existed = backend.uninstall();
@@ -255,7 +335,7 @@ function uninstall(ctx, backend) {
255
335
  // 0 either way: "there was nothing to remove" is the state the caller asked
256
336
  // for. `uninstall` is idempotent on purpose, so a teardown script can run it
257
337
  // without first asking whether it has anything to do.
258
- return EXIT.ok;
338
+ return EXIT.OK;
259
339
  }
260
340
  /**
261
341
  * The data dir the INSTALLED unit uses, read out of its own argument vector.
@@ -318,10 +398,11 @@ export function installedVersionOf(cliPath) {
318
398
  }
319
399
  /** `{ update }` when there is a recent check to repeat, and `{}` otherwise. */
320
400
  function withUpdate(home) {
321
- const summary = updateSummary(home);
401
+ const summary = updateSummary(home, { currentVersion: PADDOCK_VERSION });
322
402
  return summary !== undefined ? { update: summary } : {};
323
403
  }
324
- async function buildStatus(ctx, backend) {
404
+ /** What `status` reports, and what `doctor`'s service checks read: one report, so the two agree. */
405
+ export async function buildStatus(ctx, backend) {
325
406
  const state = backend.status();
326
407
  const base = {
327
408
  installed: state.registered,
@@ -432,7 +513,7 @@ async function status(opts, ctx, backend) {
432
513
  // that one StatusReport is supposed to prevent. A newer version is worth
433
514
  // knowing about whether or not a unit exists.
434
515
  noteUpdate(report);
435
- return EXIT.notInstalled;
516
+ return EXIT.NOT_INSTALLED;
436
517
  }
437
518
  const versions = report.runningVersion !== null && report.runningVersion !== report.installedVersion
438
519
  ? ` Version: ${report.runningVersion} running, ${report.installedVersion ?? "unknown"} installed`
@@ -495,10 +576,10 @@ function noteUpdate(report) {
495
576
  /** The exit code a report means (SPEC §2). */
496
577
  function statusExit(report) {
497
578
  if (!report.installed)
498
- return EXIT.notInstalled;
579
+ return EXIT.NOT_INSTALLED;
499
580
  if (!report.running || !report.answering)
500
- return EXIT.notRunning;
501
- return EXIT.ok;
581
+ return EXIT.NOT_RUNNING;
582
+ return EXIT.OK;
502
583
  }
503
584
  /**
504
585
  * The URL an installed unit is actually serving, or undefined if none is.
@@ -539,12 +620,12 @@ async function startOrRestart(action, opts, ctx, backend) {
539
620
  const before = backend.status();
540
621
  if (!before.registered) {
541
622
  console.log(notInstalled(backend));
542
- return EXIT.notInstalled;
623
+ return EXIT.NOT_INSTALLED;
543
624
  }
544
625
  const url = installedUrl(before);
545
626
  if (action === "start" && before.running) {
546
627
  console.log(`\n Paddock is already running${pid(before)}.\n\n URL: ${url}\n`);
547
- return EXIT.ok;
628
+ return EXIT.OK;
548
629
  }
549
630
  // A restart stops the instance first, so it asks the same question `stop`
550
631
  // asks (SPEC §1): refuse with 75 unless `--force`. `start` does not — there
@@ -578,7 +659,7 @@ async function startOrRestart(action, opts, ctx, backend) {
578
659
  ].join("\n"));
579
660
  // A start that never answered is a failure, not a success with a caveat: the
580
661
  // caller — often an Ansible task — has no other way to find out.
581
- return readiness === "ready" ? EXIT.ok : EXIT.failure;
662
+ return readiness === "ready" ? EXIT.OK : EXIT.FAILED;
582
663
  }
583
664
  /**
584
665
  * The busy check, asked of the instance the UNIT describes (SPEC §1, §4).
@@ -603,11 +684,11 @@ async function stop(opts, ctx, backend) {
603
684
  const state = backend.status();
604
685
  if (!state.registered) {
605
686
  console.log(notInstalled(backend));
606
- return EXIT.notInstalled;
687
+ return EXIT.NOT_INSTALLED;
607
688
  }
608
689
  if (!state.running) {
609
690
  console.log(`\n Paddock is already stopped.\n Unit: ${backend.unitPath}\n`);
610
- return EXIT.ok;
691
+ return EXIT.OK;
611
692
  }
612
693
  // Refuse with 75 if it is in the middle of something, unless --force (SPEC
613
694
  // §4). Deliberately AFTER the two cases above: an unit that is not there, or
@@ -626,7 +707,7 @@ async function stop(opts, ctx, backend) {
626
707
  " paddock service start",
627
708
  "",
628
709
  ].join("\n"));
629
- return EXIT.ok;
710
+ return EXIT.OK;
630
711
  }
631
712
  function pid(state) {
632
713
  return state.pid !== undefined ? ` (pid ${state.pid})` : "";
@@ -648,7 +729,7 @@ async function logs(opts, ctx, backend) {
648
729
  const state = backend.status();
649
730
  if (!state.registered) {
650
731
  console.log(notInstalled(backend));
651
- return EXIT.notInstalled;
732
+ return EXIT.NOT_INSTALLED;
652
733
  }
653
734
  const file = logPathOf(state, dataDirOf(state, ctx));
654
735
  const lines = opts.lines ?? DEFAULT_LOG_LINES;
@@ -657,7 +738,7 @@ async function logs(opts, ctx, backend) {
657
738
  // would be mixed into the log output itself.
658
739
  console.error(`paddock: nothing has been written to ${file} yet.`);
659
740
  if (opts.follow !== true)
660
- return EXIT.ok;
741
+ return EXIT.OK;
661
742
  }
662
743
  else {
663
744
  const tail = tailFile(file, lines);
@@ -668,7 +749,7 @@ async function logs(opts, ctx, backend) {
668
749
  console.error(`paddock: following ${file} — Ctrl-C to stop.`);
669
750
  await followFile(file, { from: sizeOf(file) });
670
751
  }
671
- return EXIT.ok;
752
+ return EXIT.OK;
672
753
  }
673
754
  /**
674
755
  * Dispatch, returning the exit code rather than setting one (SPEC §2).
@@ -680,6 +761,57 @@ export async function runService(action, opts, ctx) {
680
761
  // `--force` is rejected on every action but `stop` and `restart` — in
681
762
  // `parseCommand`, once, for every verb. See the note there.
682
763
  const backend = backendFor(ctx);
764
+ // `--json` on the actions that change something (SPEC §1): their prose goes
765
+ // to stderr, and stdout gets one document — what was done, and the status
766
+ // afterwards. `status` has its own; `--dry-run` prints its own.
767
+ if (opts.json === true && action !== "status" && action !== "logs" && opts.dryRun !== true) {
768
+ return withJson(action, () => act(action, opts, ctx, backend), ctx, backend);
769
+ }
770
+ return act(action, opts, ctx, backend);
771
+ }
772
+ /**
773
+ * Run `act` with its prose on stderr, then print `{ ok, action, error?,
774
+ * ...status }` on stdout (SPEC §1, §3) — the shape Hallway's `service … --json`
775
+ * prints, and `status --json`'s fields after `ok` and `action`, so a script
776
+ * reads one shape whatever it asked.
777
+ *
778
+ * Every line in this file goes through `console.log`, so the prose is moved by
779
+ * pointing that at stderr for the duration, rather than by threading a writer
780
+ * through every function. A failure still prints its document, and is then
781
+ * re-thrown, so `paddock: <message>` and the exit code are what they are
782
+ * without `--json`.
783
+ */
784
+ async function withJson(action, run, ctx, backend) {
785
+ const log = console.log;
786
+ console.log = (...a) => console.error(...a);
787
+ let code;
788
+ let failure;
789
+ try {
790
+ code = await run();
791
+ }
792
+ catch (err) {
793
+ failure = err;
794
+ code = err instanceof CliError ? err.exitCode : EXIT.FAILED;
795
+ }
796
+ finally {
797
+ console.log = log;
798
+ }
799
+ const error = failure !== undefined
800
+ ? (failure instanceof Error ? failure.message : String(failure)).split("\n")[0]
801
+ : code === EXIT.BUSY
802
+ ? "busy"
803
+ : code === EXIT.NOT_INSTALLED
804
+ ? "not installed"
805
+ : code !== EXIT.OK
806
+ ? "failed"
807
+ : undefined;
808
+ const report = await buildStatus(ctx, backend);
809
+ console.log(JSON.stringify({ ok: code === EXIT.OK, action, ...(error !== undefined ? { error } : {}), ...report }, null, 2));
810
+ if (failure !== undefined)
811
+ throw failure;
812
+ return code;
813
+ }
814
+ async function act(action, opts, ctx, backend) {
683
815
  if (action === "install")
684
816
  return install(opts, ctx, backend);
685
817
  if (action === "uninstall")