@edspencer/paddock 0.79.1 → 0.81.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 (116) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/app.d.ts +7 -0
  3. package/packages/server/dist/app.js +19 -15
  4. package/packages/server/dist/auth.d.ts +3 -2
  5. package/packages/server/dist/auth.js +11 -0
  6. package/packages/server/dist/background-live.d.ts +17 -0
  7. package/packages/server/dist/background-live.js +24 -0
  8. package/packages/server/dist/busy.d.ts +96 -0
  9. package/packages/server/dist/busy.js +88 -0
  10. package/packages/server/dist/cli/args.d.ts +63 -7
  11. package/packages/server/dist/cli/args.js +227 -20
  12. package/packages/server/dist/cli/busy-command.d.ts +58 -0
  13. package/packages/server/dist/cli/busy-command.js +108 -0
  14. package/packages/server/dist/cli/busy.d.ts +69 -0
  15. package/packages/server/dist/cli/busy.js +150 -0
  16. package/packages/server/dist/cli/exit-codes.d.ts +49 -0
  17. package/packages/server/dist/cli/exit-codes.js +48 -0
  18. package/packages/server/dist/cli/output.d.ts +52 -0
  19. package/packages/server/dist/cli/output.js +84 -0
  20. package/packages/server/dist/cli/paddock.js +98 -37
  21. package/packages/server/dist/cli/service/backend.d.ts +48 -4
  22. package/packages/server/dist/cli/service/index.d.ts +68 -2
  23. package/packages/server/dist/cli/service/index.js +321 -60
  24. package/packages/server/dist/cli/service/launchd.d.ts +23 -1
  25. package/packages/server/dist/cli/service/launchd.js +145 -25
  26. package/packages/server/dist/cli/service/logs.d.ts +39 -0
  27. package/packages/server/dist/cli/service/logs.js +115 -0
  28. package/packages/server/dist/cli/service/path-env.d.ts +27 -0
  29. package/packages/server/dist/cli/service/path-env.js +120 -0
  30. package/packages/server/dist/cli/service/ready.d.ts +22 -0
  31. package/packages/server/dist/cli/service/ready.js +30 -0
  32. package/packages/server/dist/cli/service/spec.d.ts +45 -6
  33. package/packages/server/dist/cli/service/spec.js +34 -7
  34. package/packages/server/dist/cli/service/systemd.d.ts +47 -4
  35. package/packages/server/dist/cli/service/systemd.js +127 -22
  36. package/packages/server/dist/herdctl.d.ts +19 -0
  37. package/packages/server/dist/herdctl.js +26 -0
  38. package/packages/server/dist/management-mcp-server.d.ts +21 -3
  39. package/packages/server/dist/management-mcp-server.js +4 -13
  40. package/packages/server/dist/routes/meta.js +56 -3
  41. package/packages/server/dist/runtime-file.d.ts +26 -0
  42. package/packages/server/dist/runtime-file.js +91 -0
  43. package/packages/server/dist/service-log.d.ts +29 -0
  44. package/packages/server/dist/service-log.js +104 -0
  45. package/packages/server/dist/start.d.ts +33 -0
  46. package/packages/server/dist/start.js +126 -8
  47. package/packages/server/dist/version.d.ts +3 -0
  48. package/packages/server/dist/version.js +32 -0
  49. package/packages/server/dist/ws-context.d.ts +9 -0
  50. package/packages/server/dist/ws-turn.js +1 -0
  51. package/packages/server/package.json +1 -1
  52. package/packages/web/dist/assets/{DeleteProjectDialog-BcxJMA1w.js → DeleteProjectDialog-D8oIJ8HC.js} +1 -1
  53. package/packages/web/dist/assets/DiscoverPage-CQlzxsn4.js +2 -0
  54. package/packages/web/dist/assets/{DiscoverView-DOmTmiVv.js → DiscoverView-o0KCr3aB.js} +1 -1
  55. package/packages/web/dist/assets/{InstanceConfigPage-DWLQqHW0.js → InstanceConfigPage-RSJz7oL3.js} +1 -1
  56. package/packages/web/dist/assets/{MarkdownRenderer-Y25JWlMR.js → MarkdownRenderer-39mafH8b.js} +1 -1
  57. package/packages/web/dist/assets/{ProjectRedirect-CCI1VWM6.js → ProjectRedirect-Bq7GVguY.js} +1 -1
  58. package/packages/web/dist/assets/{ProjectView-DzfVhjya.js → ProjectView-BoBIru1g.js} +3 -3
  59. package/packages/web/dist/assets/{ProjectsGrid-yIyyb2HA.js → ProjectsGrid-mJ4gqbr6.js} +1 -1
  60. package/packages/web/dist/assets/{RootHome-x0Om9-Fd.js → RootHome-C0kwFyYp.js} +1 -1
  61. package/packages/web/dist/assets/{Surfaces-wbcMQIsK.js → Surfaces-MkhXlB8z.js} +1 -1
  62. package/packages/web/dist/assets/{arc-tWN7FYIq.js → arc-DExlwAwO.js} +1 -1
  63. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Ccj6Vlyy.js → architectureDiagram-3BPJPVTR-CZly3W79.js} +1 -1
  64. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-B7iGXwqY.js → blockDiagram-GPEHLZMM-AgES73i3.js} +1 -1
  65. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-SkLKFSso.js → c4Diagram-AAUBKEIU-N9mD8Pfk.js} +1 -1
  66. package/packages/web/dist/assets/channel-ExsAG7qT.js +2 -0
  67. package/packages/web/dist/assets/{chunk-2J33WTMH-BrS8C-YK.js → chunk-2J33WTMH-Dsx_kfUl.js} +1 -1
  68. package/packages/web/dist/assets/{chunk-4BX2VUAB-BUR6nG8w.js → chunk-4BX2VUAB-BQjqcjPj.js} +1 -1
  69. package/packages/web/dist/assets/{chunk-55IACEB6-BRBNJb-u.js → chunk-55IACEB6-CCxpYQpY.js} +1 -1
  70. package/packages/web/dist/assets/{chunk-727SXJPM-DKeNPTXz.js → chunk-727SXJPM-DpFRdnUa.js} +1 -1
  71. package/packages/web/dist/assets/{chunk-AQP2D5EJ-BOS1K-DX.js → chunk-AQP2D5EJ-D2BAlzoO.js} +1 -1
  72. package/packages/web/dist/assets/{chunk-FMBD7UC4-D3PZL8fo.js → chunk-FMBD7UC4-BFaMRzUF.js} +1 -1
  73. package/packages/web/dist/assets/{chunk-ND2GUHAM-DA9Y77Zj.js → chunk-ND2GUHAM-B9NGJ9xZ.js} +1 -1
  74. package/packages/web/dist/assets/{chunk-QZHKN3VN-wPph-weq.js → chunk-QZHKN3VN-CF1IHyMS.js} +1 -1
  75. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-si7g9okq.js +2 -0
  76. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-si7g9okq.js +2 -0
  77. package/packages/web/dist/assets/{core-I1PQdGMJ.js → core-XjElsvQ7.js} +1 -1
  78. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BA7N2vVK.js → cose-bilkent-S5V4N54A-CF972Flx.js} +1 -1
  79. package/packages/web/dist/assets/{dagre-BM42HDAG-DKpON5p2.js → dagre-BM42HDAG-DsneI8YE.js} +1 -1
  80. package/packages/web/dist/assets/{diagram-2AECGRRQ-Db0I-eEL.js → diagram-2AECGRRQ-BHyrVzIk.js} +1 -1
  81. package/packages/web/dist/assets/{diagram-5GNKFQAL-BEuqOTfn.js → diagram-5GNKFQAL-Dj4q8xEi.js} +1 -1
  82. package/packages/web/dist/assets/{diagram-KO2AKTUF-CnyX_0Xk.js → diagram-KO2AKTUF-BHe3Puv_.js} +1 -1
  83. package/packages/web/dist/assets/{diagram-LMA3HP47-CYvknzDd.js → diagram-LMA3HP47-wzt6G2FE.js} +1 -1
  84. package/packages/web/dist/assets/{diagram-OG6HWLK6-DlgKxbYz.js → diagram-OG6HWLK6-CZMbMRJq.js} +1 -1
  85. package/packages/web/dist/assets/{documentTitle-Cvob-HVs.js → documentTitle-BwTIA8rI.js} +1 -1
  86. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-BkO5JmiM.js → erDiagram-TEJ5UH35-Dtu14BL1.js} +1 -1
  87. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-DG0o_zc1.js → flowDiagram-I6XJVG4X-T34ql9j0.js} +1 -1
  88. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-Bs71No2S.js → ganttDiagram-6RSMTGT7-DQLOeCXK.js} +1 -1
  89. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-CxODhRKd.js → gitGraphDiagram-PVQCEYII-Bk11zea6.js} +1 -1
  90. package/packages/web/dist/assets/{index-C4tsPSvG.js → index-CvaWErFv.js} +2 -2
  91. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-C412TQaw.js → infoDiagram-5YYISTIA-CsE83lu0.js} +1 -1
  92. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DUlcZXQQ.js → ishikawaDiagram-YF4QCWOH-DNVgA93n.js} +1 -1
  93. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-iL0klVhn.js → journeyDiagram-JHISSGLW-CLpSNjM3.js} +1 -1
  94. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-CO02Zl_Q.js → kanban-definition-UN3LZRKU-BRzuXcKr.js} +1 -1
  95. package/packages/web/dist/assets/{linear-DtmLY8AG.js → linear-BxOawONt.js} +1 -1
  96. package/packages/web/dist/assets/{mermaid.core-Dvzxug80.js → mermaid.core-Dvs1QCcz.js} +4 -4
  97. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-q950AAWL.js → mindmap-definition-RKZ34NQL-Dmt7pYwF.js} +1 -1
  98. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BVrv8A-H.js → pieDiagram-4H26LBE5-BHiZaJD-.js} +1 -1
  99. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-Bp5D2MFq.js → quadrantDiagram-W4KKPZXB-U0A1noRE.js} +1 -1
  100. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-yZfQU4Ix.js → requirementDiagram-4Y6WPE33-CZLsr6yi.js} +1 -1
  101. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-5R8_OZir.js → sankeyDiagram-5OEKKPKP-BTlLGILU.js} +1 -1
  102. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-C3A9rinG.js → sequenceDiagram-3UESZ5HK-DpSS_Gb1.js} +1 -1
  103. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-Cshrt_XB.js → stateDiagram-AJRCARHV-D9srE-tV.js} +1 -1
  104. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-AxWqsDh-.js +2 -0
  105. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-kNkiddCX.js → timeline-definition-PNZ67QCA-BYQ5CKab.js} +1 -1
  106. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-BiNJm17g.js → vennDiagram-CIIHVFJN-B4W2CtSV.js} +1 -1
  107. package/packages/web/dist/assets/{wardley-L42UT6IY-CjExbCuX.js → wardley-L42UT6IY--XUV7GvD.js} +1 -1
  108. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-CMn3z-G2.js → wardleyDiagram-YWT4CUSO-Bl2VIEGY.js} +1 -1
  109. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-B-UhnooW.js → xychartDiagram-2RQKCTM6-Bj2H9Rcf.js} +1 -1
  110. package/packages/web/dist/index.html +1 -1
  111. package/packages/web/dist/sw.js +1 -1
  112. package/packages/web/dist/assets/DiscoverPage-CSPRrFVk.js +0 -2
  113. package/packages/web/dist/assets/channel-B2NSmlBB.js +0 -2
  114. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-B3tLFeZC.js +0 -2
  115. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-B3tLFeZC.js +0 -2
  116. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-DgiazF78.js +0 -2
@@ -31,6 +31,12 @@ export declare function plistPath(homeDir?: string): string;
31
31
  *
32
32
  * No `PADDOCK_DATA_DIR`: see `buildSpec`. One `~/.paddock` instance, reachable
33
33
  * from the service and from a terminal.
34
+ *
35
+ * `StandardOutPath` and `StandardErrorPath` name the SAME file (SPEC §7).
36
+ * launchd opens both in append mode, so interleaving is what a reader wants
37
+ * anyway — a warning and the stack trace that followed it used to land in two
38
+ * separate files, in an order nothing recorded. One file is also what makes
39
+ * `paddock service logs` one implementation across platforms.
34
40
  */
35
41
  export declare function renderPlist(spec: ServiceSpec): string;
36
42
  /**
@@ -55,6 +61,8 @@ export declare function parsePlistArgv(xml: string): string[];
55
61
  * unrecognised would silence exactly the case this exists to catch.
56
62
  */
57
63
  export declare function plistKeepsAlive(xml: string): boolean;
64
+ /** The log file an installed plist appends to, read back from `StandardOutPath`. */
65
+ export declare function parsePlistLogPath(xml: string): string | undefined;
58
66
  /**
59
67
  * Read liveness out of `launchctl print gui/<uid>/<label>`.
60
68
  *
@@ -67,4 +75,18 @@ export declare function parseLaunchctlPrint(out: string): {
67
75
  running: boolean;
68
76
  pid?: string;
69
77
  };
70
- export declare function createLaunchdBackend(run: Runner, homeDir?: string): ServiceBackend;
78
+ /**
79
+ * Block this thread for `ms`.
80
+ *
81
+ * The backend's {@link Runner} is synchronous (`execFileSync`) and so is every
82
+ * `ServiceBackend` method, so the wait has to be too — making `install` async
83
+ * would ripple through the whole service CLI for a sleep. `Atomics.wait` on a
84
+ * throwaway buffer is the built-in way to do it; `setTimeout` cannot help here
85
+ * and spinning on `Date.now()` would burn a core.
86
+ *
87
+ * Injectable because the unit tests drive the retry path with a fake runner
88
+ * that fails every attempt: with the real sleep, pinning the retry count would
89
+ * cost three seconds per case.
90
+ */
91
+ export declare function sleepSync(ms: number): void;
92
+ export declare function createLaunchdBackend(run: Runner, homeDir?: string, sleep?: (ms: number) => void): ServiceBackend;
@@ -77,6 +77,12 @@ function xmlEscape(s) {
77
77
  *
78
78
  * No `PADDOCK_DATA_DIR`: see `buildSpec`. One `~/.paddock` instance, reachable
79
79
  * from the service and from a terminal.
80
+ *
81
+ * `StandardOutPath` and `StandardErrorPath` name the SAME file (SPEC §7).
82
+ * launchd opens both in append mode, so interleaving is what a reader wants
83
+ * anyway — a warning and the stack trace that followed it used to land in two
84
+ * separate files, in an order nothing recorded. One file is also what makes
85
+ * `paddock service logs` one implementation across platforms.
80
86
  */
81
87
  export function renderPlist(spec) {
82
88
  const argv = [spec.nodePath, spec.scriptPath, ...spec.args];
@@ -100,9 +106,9 @@ ${strings}
100
106
  <key>WorkingDirectory</key>
101
107
  <string>${xmlEscape(spec.workingDirectory)}</string>
102
108
  <key>StandardOutPath</key>
103
- <string>${xmlEscape(spec.stdoutPath)}</string>
109
+ <string>${xmlEscape(spec.logPath)}</string>
104
110
  <key>StandardErrorPath</key>
105
- <string>${xmlEscape(spec.stderrPath)}</string>
111
+ <string>${xmlEscape(spec.logPath)}</string>
106
112
  <key>EnvironmentVariables</key>
107
113
  <dict>
108
114
  <key>PATH</key>
@@ -145,6 +151,17 @@ export function parsePlistArgv(xml) {
145
151
  export function plistKeepsAlive(xml) {
146
152
  return /<key>KeepAlive<\/key>\s*<true\s*\/>/.test(xml);
147
153
  }
154
+ /** The log file an installed plist appends to, read back from `StandardOutPath`. */
155
+ export function parsePlistLogPath(xml) {
156
+ const m = /<key>StandardOutPath<\/key>\s*<string>([\s\S]*?)<\/string>/.exec(xml);
157
+ if (m === null)
158
+ return undefined;
159
+ return m[1]
160
+ .replace(/&quot;/g, '"')
161
+ .replace(/&gt;/g, ">")
162
+ .replace(/&lt;/g, "<")
163
+ .replace(/&amp;/g, "&");
164
+ }
148
165
  /**
149
166
  * Read liveness out of `launchctl print gui/<uid>/<label>`.
150
167
  *
@@ -171,8 +188,107 @@ function target() {
171
188
  function domain() {
172
189
  return `gui/${process.getuid?.() ?? 0}`;
173
190
  }
174
- export function createLaunchdBackend(run, homeDir = os.homedir()) {
191
+ /**
192
+ * ## The bootout/bootstrap race (#984)
193
+ *
194
+ * `launchctl bootout` returns as soon as launchd has ACCEPTED the request, not
195
+ * once the job has gone. Every sequence below that boots out and then
196
+ * bootstraps the same label therefore races launchd's own teardown, and when it
197
+ * loses, `bootstrap` fails with:
198
+ *
199
+ * ```
200
+ * launchctl bootstrap failed (exit 5): Input/output error
201
+ * ```
202
+ *
203
+ * Exit 5 is `EIO`, which is launchd's generic "I cannot do that right now" and
204
+ * the classic symptom of this race. It is timing-dependent, so it passes
205
+ * locally and on a PR and then fails on a release: Paddock 0.80.0's first
206
+ * release run failed exactly here, in CI's real-launchd job, on
207
+ * `paddock service restart`.
208
+ *
209
+ * Both halves of the fix are needed, and neither is sufficient:
210
+ *
211
+ * - **Wait for the job to go.** After a bootout, poll `launchctl print` until
212
+ * it reports the label gone (a non-zero exit), up to {@link BOOTOUT_POLLS}
213
+ * times. That removes the race in the case we caused it.
214
+ * - **Retry the bootstrap.** A bootout we did NOT issue can still be in
215
+ * flight — `service start` after a separate process's `service stop`, or a
216
+ * login racing a logout — and there is nothing to poll for there, because
217
+ * from this process's point of view the job was already gone. So a
218
+ * bootstrap that fails with a race code is retried with a short backoff.
219
+ *
220
+ * Timing out the poll is not an error: the retry is the backstop, and a job
221
+ * that will not go is a real failure the bootstrap reports in its own words.
222
+ */
223
+ const BOOTOUT_POLLS = 40;
224
+ const BOOTOUT_POLL_MS = 250; // 40 × 250ms = 10s
225
+ const BOOTSTRAP_ATTEMPTS = 6;
226
+ const BOOTSTRAP_BACKOFF_MS = 500;
227
+ /**
228
+ * launchctl exits that mean "try again", as opposed to "no".
229
+ *
230
+ * `5` is `EIO`, what the release failure printed. `37` is launchd's "operation
231
+ * already in progress", which is the same race seen from the other side: the
232
+ * bootout is still being processed. Anything else — a malformed plist, a label
233
+ * genuinely already loaded, a permission problem — is reported, not retried,
234
+ * because retrying it only delays the message by three seconds.
235
+ */
236
+ const BOOTSTRAP_RACE_CODES = new Set([5, 37]);
237
+ /**
238
+ * Block this thread for `ms`.
239
+ *
240
+ * The backend's {@link Runner} is synchronous (`execFileSync`) and so is every
241
+ * `ServiceBackend` method, so the wait has to be too — making `install` async
242
+ * would ripple through the whole service CLI for a sleep. `Atomics.wait` on a
243
+ * throwaway buffer is the built-in way to do it; `setTimeout` cannot help here
244
+ * and spinning on `Date.now()` would burn a core.
245
+ *
246
+ * Injectable because the unit tests drive the retry path with a fake runner
247
+ * that fails every attempt: with the real sleep, pinning the retry count would
248
+ * cost three seconds per case.
249
+ */
250
+ export function sleepSync(ms) {
251
+ if (ms <= 0)
252
+ return;
253
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
254
+ }
255
+ export function createLaunchdBackend(run, homeDir = os.homedir(), sleep = sleepSync) {
175
256
  const file = plistPath(homeDir);
257
+ /**
258
+ * Boot the job out and wait until launchd agrees it has gone — see the race
259
+ * note above. The bootout's own result is deliberately not inspected: "there
260
+ * was no previous service" is the expected case on a first install, and a
261
+ * bootout that genuinely failed shows up as a job that is still there, which
262
+ * is what the poll below is looking at anyway.
263
+ */
264
+ const bootoutAndWait = () => {
265
+ run("launchctl", ["bootout", target()]);
266
+ // Counted, not timed: an injected no-op sleep has to terminate the loop
267
+ // too, and a wall-clock deadline with a no-op sleep is a ten-second spin.
268
+ for (let poll = 1; poll <= BOOTOUT_POLLS; poll++) {
269
+ // A non-zero `print` is launchd saying it has never heard of the label,
270
+ // which is precisely the state `bootstrap` needs.
271
+ if (run("launchctl", ["print", target()]).status !== 0)
272
+ return;
273
+ if (poll < BOOTOUT_POLLS)
274
+ sleep(BOOTOUT_POLL_MS);
275
+ }
276
+ };
277
+ /** Bootstrap the plist, retrying the race codes. Throws with launchctl's own words. */
278
+ const bootstrapOrThrow = () => {
279
+ for (let attempt = 1;; attempt++) {
280
+ // NOT `load`/`unload`: those are the legacy API, and on a modern macOS
281
+ // they are shims over this one that report success in cases where it
282
+ // reports the actual error.
283
+ const boot = run("launchctl", ["bootstrap", domain(), file]);
284
+ if (boot.status === 0)
285
+ return;
286
+ if (attempt >= BOOTSTRAP_ATTEMPTS || !BOOTSTRAP_RACE_CODES.has(boot.status ?? -1)) {
287
+ throw new Error(launchctlFailure("bootstrap", boot));
288
+ }
289
+ sleep(BOOTSTRAP_BACKOFF_MS);
290
+ }
291
+ };
176
292
  return {
177
293
  platform: "darwin",
178
294
  unitPath: file,
@@ -180,18 +296,17 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
180
296
  install(spec) {
181
297
  fs.mkdirSync(path.dirname(file), { recursive: true });
182
298
  fs.mkdirSync(spec.workingDirectory, { recursive: true });
299
+ // launchd creates neither the log file's directory nor, reliably, a log
300
+ // file whose parent is missing — the job just fails to spawn.
301
+ fs.mkdirSync(path.dirname(spec.logPath), { recursive: true });
183
302
  fs.writeFileSync(file, renderPlist(spec), "utf8");
184
303
  // `bootstrap` fails outright if the label is already loaded, so a
185
- // reinstall has to unload first. A first install has nothing to unload,
186
- // and says so on stderr — ignored rather than reported, because "there
187
- // was no previous service" is the expected case, not a problem.
188
- run("launchctl", ["bootout", target()]);
189
- // NOT `load`/`unload`: those are the legacy API, and on a modern macOS
190
- // they are shims over this one that report success in cases where it
191
- // reports the actual error.
192
- const boot = run("launchctl", ["bootstrap", domain(), file]);
193
- if (boot.status !== 0)
194
- throw new Error(launchctlFailure("bootstrap", boot));
304
+ // reinstall has to unload first — and has to WAIT for that, which is what
305
+ // `bootoutAndWait` is for (see the race note). A first install has
306
+ // nothing to unload, and says so on stderr: ignored rather than reported,
307
+ // because "there was no previous service" is the expected case.
308
+ bootoutAndWait();
309
+ bootstrapOrThrow();
195
310
  // `RunAtLoad` should already have started it; `kickstart -k` makes that
196
311
  // true rather than probable, and is the same command a later restart uses.
197
312
  run("launchctl", ["kickstart", "-k", target()]);
@@ -210,11 +325,13 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
210
325
  * Paddock is not merely idle, it is absent from the domain — and `start` on
211
326
  * a label launchd has never heard of fails. Bootstrapping is also what a
212
327
  * login does, so `service start` and the next login take the same path.
328
+ *
329
+ * No bootout to wait on here, but the retry in `bootstrapOrThrow` still
330
+ * earns its keep: the `stop` that booted it out may have been a different
331
+ * `paddock` process seconds ago, and launchd may not have finished with it.
213
332
  */
214
333
  start() {
215
- const boot = run("launchctl", ["bootstrap", domain(), file]);
216
- if (boot.status !== 0)
217
- throw new Error(launchctlFailure("bootstrap", boot));
334
+ bootstrapOrThrow();
218
335
  run("launchctl", ["kickstart", target()]);
219
336
  },
220
337
  /**
@@ -243,12 +360,13 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
243
360
  * service that is stopped. This sequence is identical to `install`'s minus
244
361
  * writing the plist, which also gives `restart` the property the issue asks
245
362
  * for — the unit is re-read on the way up, so an edited plist takes effect.
363
+ *
364
+ * This is the sequence that failed Paddock 0.80.0's release run: bootout
365
+ * and bootstrap back to back, with nothing between them. See the race note.
246
366
  */
247
367
  restart() {
248
- run("launchctl", ["bootout", target()]);
249
- const boot = run("launchctl", ["bootstrap", domain(), file]);
250
- if (boot.status !== 0)
251
- throw new Error(launchctlFailure("bootstrap", boot));
368
+ bootoutAndWait();
369
+ bootstrapOrThrow();
252
370
  run("launchctl", ["kickstart", "-k", target()]);
253
371
  },
254
372
  status() {
@@ -260,6 +378,7 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
260
378
  const parsed = printed.status === 0
261
379
  ? parseLaunchctlPrint(printed.stdout)
262
380
  : { running: false, pid: undefined };
381
+ const logPath = parsePlistLogPath(xml);
263
382
  return {
264
383
  registered: true,
265
384
  running: parsed.running,
@@ -267,14 +386,15 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
267
386
  // Strip the interpreter and script: what a reader wants from `status`
268
387
  // is the Paddock invocation, not the absolute path to node twice.
269
388
  argv: argv.slice(2),
389
+ ...(argv[0] !== undefined ? { nodePath: argv[0] } : {}),
390
+ ...(argv[1] !== undefined ? { cliPath: argv[1] } : {}),
391
+ ...(logPath !== undefined ? { logPath } : {}),
270
392
  keepsAlive: plistKeepsAlive(xml),
271
393
  };
272
394
  },
273
- logsHint(spec) {
274
- return ` Logs: ${spec.stdoutPath}\n ${spec.stderrPath}`;
275
- },
276
- lingerNote() {
277
- return undefined;
395
+ /** launchd has no linger: a LaunchAgent's lifetime IS the login session. */
396
+ linger() {
397
+ return { applies: false };
278
398
  },
279
399
  };
280
400
  }
@@ -0,0 +1,39 @@
1
+ /** How many lines `logs` prints when nobody says otherwise (SPEC §1). */
2
+ export declare const DEFAULT_LOG_LINES = 50;
3
+ /**
4
+ * The last `lines` lines of `file`.
5
+ *
6
+ * Only the last {@link TAIL_WINDOW} bytes are read. A service log is append-only
7
+ * and can reach tens of megabytes between rotations, and reading all of it to
8
+ * print fifty lines would make `logs` slower than the thing it is reporting on.
9
+ * A window that happens to start mid-line drops that partial first line rather
10
+ * than printing half of one.
11
+ */
12
+ export declare function tailFile(file: string, lines: number): string;
13
+ export interface FollowOptions {
14
+ /** Where to start reading from — normally the file's size after the tail. */
15
+ from: number;
16
+ /** Injected for tests. */
17
+ intervalMs?: number;
18
+ /** Injected for tests: return false to stop the loop. */
19
+ keepGoing?: () => boolean;
20
+ write?: (s: string) => void;
21
+ }
22
+ /**
23
+ * Print what gets appended to `file`, from `from` onwards, until the process is
24
+ * interrupted.
25
+ *
26
+ * Polling rather than `fs.watch`: the writer here is the supervisor's own file
27
+ * descriptor, and `fs.watch` on an appended-to file is unreliable across
28
+ * platforms (no events at all on some network filesystems, and a stream of
29
+ * them on others). A 400 ms poll of the size is both portable and cheap.
30
+ *
31
+ * Rotation is handled by the one case that can actually happen: the server
32
+ * rotates by COPY-AND-TRUNCATE (see `service-log.ts`), so the inode is stable
33
+ * and the size goes DOWN. Seeing that, this resets to the new start rather than
34
+ * reading past the end forever, which is what a naive follower does — it goes
35
+ * quiet after the first rotation and never recovers.
36
+ */
37
+ export declare function followFile(file: string, opts: FollowOptions): Promise<void>;
38
+ /** The file's size, or 0 if it is not there — where a follow should start. */
39
+ export declare function sizeOf(file: string): number;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * `paddock service logs [-f] [-n <lines>]` (SPEC §1, §7).
3
+ *
4
+ * One implementation for every platform, which is the whole reason the units
5
+ * write to a file rather than to the journal: `journalctl --user -u paddock` on
6
+ * Linux and a pair of files on macOS meant "where are the logs" had two
7
+ * answers, neither of which Windows could ever have.
8
+ *
9
+ * Reading the tail of a file is deliberately done by hand rather than by
10
+ * spawning `tail`: `tail -f` is not on every box this runs on (and is `Get-
11
+ * Content -Wait` on the platform the contract requires us not to rule out),
12
+ * and shelling out would make the output depend on which `tail` won a PATH
13
+ * lookup.
14
+ */
15
+ import fs from "node:fs";
16
+ /** How many lines `logs` prints when nobody says otherwise (SPEC §1). */
17
+ export const DEFAULT_LOG_LINES = 50;
18
+ /** How much of the end of the file to read for a tail, in bytes. */
19
+ const TAIL_WINDOW = 256 * 1024;
20
+ /** The gap between size checks when following. */
21
+ const FOLLOW_INTERVAL_MS = 400;
22
+ /**
23
+ * The last `lines` lines of `file`.
24
+ *
25
+ * Only the last {@link TAIL_WINDOW} bytes are read. A service log is append-only
26
+ * and can reach tens of megabytes between rotations, and reading all of it to
27
+ * print fifty lines would make `logs` slower than the thing it is reporting on.
28
+ * A window that happens to start mid-line drops that partial first line rather
29
+ * than printing half of one.
30
+ */
31
+ export function tailFile(file, lines) {
32
+ const fd = fs.openSync(file, "r");
33
+ try {
34
+ const size = fs.fstatSync(fd).size;
35
+ const start = Math.max(0, size - TAIL_WINDOW);
36
+ const length = size - start;
37
+ if (length === 0)
38
+ return "";
39
+ const buf = Buffer.alloc(length);
40
+ fs.readSync(fd, buf, 0, length, start);
41
+ let text = buf.toString("utf8");
42
+ // A window that starts mid-line: drop the fragment.
43
+ if (start > 0) {
44
+ const nl = text.indexOf("\n");
45
+ text = nl === -1 ? "" : text.slice(nl + 1);
46
+ }
47
+ const all = text.split("\n");
48
+ // A trailing newline produces a final empty element that is not a line.
49
+ if (all[all.length - 1] === "")
50
+ all.pop();
51
+ return all.slice(-lines).join("\n");
52
+ }
53
+ finally {
54
+ fs.closeSync(fd);
55
+ }
56
+ }
57
+ /**
58
+ * Print what gets appended to `file`, from `from` onwards, until the process is
59
+ * interrupted.
60
+ *
61
+ * Polling rather than `fs.watch`: the writer here is the supervisor's own file
62
+ * descriptor, and `fs.watch` on an appended-to file is unreliable across
63
+ * platforms (no events at all on some network filesystems, and a stream of
64
+ * them on others). A 400 ms poll of the size is both portable and cheap.
65
+ *
66
+ * Rotation is handled by the one case that can actually happen: the server
67
+ * rotates by COPY-AND-TRUNCATE (see `service-log.ts`), so the inode is stable
68
+ * and the size goes DOWN. Seeing that, this resets to the new start rather than
69
+ * reading past the end forever, which is what a naive follower does — it goes
70
+ * quiet after the first rotation and never recovers.
71
+ */
72
+ export async function followFile(file, opts) {
73
+ const interval = opts.intervalMs ?? FOLLOW_INTERVAL_MS;
74
+ const write = opts.write ?? ((s) => process.stdout.write(s));
75
+ const keepGoing = opts.keepGoing ?? (() => true);
76
+ let offset = opts.from;
77
+ while (keepGoing()) {
78
+ await new Promise((r) => setTimeout(r, interval));
79
+ let size;
80
+ try {
81
+ size = fs.statSync(file).size;
82
+ }
83
+ catch {
84
+ // The file can vanish: an uninstall, or a rotation that renames. Wait for
85
+ // it to come back rather than giving up — `logs -f` is left running.
86
+ continue;
87
+ }
88
+ if (size < offset) {
89
+ // Truncated: the rotation above, or a hand-run `: > service.log`.
90
+ offset = 0;
91
+ }
92
+ if (size === offset)
93
+ continue;
94
+ const fd = fs.openSync(file, "r");
95
+ try {
96
+ const length = size - offset;
97
+ const buf = Buffer.alloc(length);
98
+ fs.readSync(fd, buf, 0, length, offset);
99
+ write(buf.toString("utf8"));
100
+ offset = size;
101
+ }
102
+ finally {
103
+ fs.closeSync(fd);
104
+ }
105
+ }
106
+ }
107
+ /** The file's size, or 0 if it is not there — where a follow should start. */
108
+ export function sizeOf(file) {
109
+ try {
110
+ return fs.statSync(file).size;
111
+ }
112
+ catch {
113
+ return 0;
114
+ }
115
+ }
@@ -0,0 +1,27 @@
1
+ /** PATH entries that belong to one shell session or one npm invocation. */
2
+ export declare const PER_SHELL_PATTERNS: readonly RegExp[];
3
+ /** Where the usual tools live, after the inherited PATH. Only existing ones are kept. */
4
+ export declare const SYSTEM_DIRS: readonly string[];
5
+ export interface ServicePathOptions {
6
+ /** Injected for tests: "is this an existing directory?". */
7
+ exists?: (p: string) => boolean;
8
+ /** Injected for tests: the system directories to append. */
9
+ systemDirs?: readonly string[];
10
+ }
11
+ /**
12
+ * The job's PATH: Node's bin directory, then the usable part of `inherited`,
13
+ * then the system directories. De-duplicated, order preserved.
14
+ */
15
+ export declare function servicePath(nodeBin: string, inherited: string | undefined, opts?: ServicePathOptions): string;
16
+ /** `which`: the first executable called `name` on `pathVar`, or undefined. */
17
+ export declare function which(name: string, pathVar: string, isExec?: (p: string) => boolean): string | undefined;
18
+ /**
19
+ * Of `paths`, those that are no longer on disk.
20
+ *
21
+ * This is the check behind `status`'s and (from wave G) `doctor`'s "the Node it
22
+ * runs has gone" warning. It happens under nvm, fnm and volta the moment a Node
23
+ * version is uninstalled: the unit still names an absolute interpreter path,
24
+ * the supervisor still dutifully tries to exec it, and the failure arrives as
25
+ * Paddock being absent with nothing in its own logs — because it never ran.
26
+ */
27
+ export declare function missingPaths(paths: readonly (string | undefined)[], exists?: (p: string) => boolean): string[];
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The PATH a generated unit gives the service (SPEC §7).
3
+ *
4
+ * From edspencer/cli-contract — Local Review's `server/service/spec.ts`
5
+ * (`PER_SHELL_PATTERNS`, `SYSTEM_DIRS`, `servicePath`, `which`) @ spec 0.1;
6
+ * local changes: Paddock's own doc comments, `node:path`'s platform-aware
7
+ * `delimiter`/`join` instead of hard-coded `:` and `path.posix`, and a
8
+ * `missingFromPath` helper the CLI uses for its unit warnings. Wave C4 moves
9
+ * this to `reference/service/` and re-syncs.
10
+ *
11
+ * ## Why a unit cannot just inherit the installing shell's PATH
12
+ *
13
+ * Paddock used to record `process.env.PATH` verbatim. That is the PATH of one
14
+ * terminal at one moment, and several of its entries are *per-session*:
15
+ *
16
+ * - **fnm** puts a symlink per terminal under `fnm_multishells/<pid>_<ts>/bin`.
17
+ * The directory is deleted when that shell exits — and fnm never cleans up
18
+ * the stale ones, so a unit installed from an fnm shell names a `node`
19
+ * directory that stops existing the moment the tab closes.
20
+ * - **npx** adds its own per-invocation cache; `npm run` adds `node-gyp-bin`
21
+ * and the package's `node_modules/.bin`. A long-lived background job has no
22
+ * business looking in any of them for `git`.
23
+ *
24
+ * So the job's PATH is rebuilt: Node's own bin directory first (so the `node`
25
+ * the unit runs can resolve its neighbours), then whatever of the installing
26
+ * shell's PATH is worth keeping, then the system directories. Entries that are
27
+ * relative, per-shell, or simply not there are dropped — a PATH naming
28
+ * directories that do not exist is how `git` goes missing at some later login,
29
+ * silently, with the symptom appearing in an agent's turn rather than at
30
+ * install time.
31
+ */
32
+ import fs from "node:fs";
33
+ import path from "node:path";
34
+ /** PATH entries that belong to one shell session or one npm invocation. */
35
+ export const PER_SHELL_PATTERNS = [
36
+ /[/\\]fnm_multishells[/\\]/, // fnm: a symlink per terminal, never cleaned up
37
+ /[/\\]_npx[/\\]/, // npx's per-invocation package cache
38
+ /[/\\]node-gyp-bin$/, // added by `npm run` for the script it is running
39
+ /[/\\]node_modules[/\\]\.bin$/, // likewise (and by npx): a package's tools, not the user's
40
+ ];
41
+ /** Where the usual tools live, after the inherited PATH. Only existing ones are kept. */
42
+ export const SYSTEM_DIRS = [
43
+ "/opt/homebrew/bin",
44
+ "/usr/local/bin",
45
+ "/usr/bin",
46
+ "/bin",
47
+ "/usr/sbin",
48
+ "/sbin",
49
+ ];
50
+ function isDir(p) {
51
+ try {
52
+ return fs.statSync(p).isDirectory();
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
58
+ function isExecutableFile(p) {
59
+ try {
60
+ if (!fs.statSync(p).isFile())
61
+ return false;
62
+ fs.accessSync(p, fs.constants.X_OK);
63
+ return true;
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
69
+ /** Kept on the job's PATH: non-empty, absolute, not per-shell, and an existing directory. */
70
+ function keep(dir, exists) {
71
+ if (dir === "" || !path.isAbsolute(dir))
72
+ return false;
73
+ if (PER_SHELL_PATTERNS.some((re) => re.test(dir) || re.test(`${dir}${path.sep}`)))
74
+ return false;
75
+ return exists(dir);
76
+ }
77
+ /**
78
+ * The job's PATH: Node's bin directory, then the usable part of `inherited`,
79
+ * then the system directories. De-duplicated, order preserved.
80
+ */
81
+ export function servicePath(nodeBin, inherited, opts = {}) {
82
+ const exists = opts.exists ?? isDir;
83
+ const system = opts.systemDirs ?? SYSTEM_DIRS;
84
+ const out = [];
85
+ const add = (d) => {
86
+ // Trailing separators would make `/usr/bin/` and `/usr/bin` two entries.
87
+ const clean = d.length > 1 ? d.replace(/[/\\]+$/, "") : d;
88
+ if (!out.includes(clean) && keep(clean, exists))
89
+ out.push(clean);
90
+ };
91
+ add(nodeBin);
92
+ for (const d of (inherited ?? "").split(path.delimiter))
93
+ add(d);
94
+ for (const d of system)
95
+ add(d);
96
+ return out.join(path.delimiter);
97
+ }
98
+ /** `which`: the first executable called `name` on `pathVar`, or undefined. */
99
+ export function which(name, pathVar, isExec = isExecutableFile) {
100
+ for (const dir of pathVar.split(path.delimiter)) {
101
+ if (dir === "")
102
+ continue;
103
+ const p = path.join(dir, name);
104
+ if (isExec(p))
105
+ return p;
106
+ }
107
+ return undefined;
108
+ }
109
+ /**
110
+ * Of `paths`, those that are no longer on disk.
111
+ *
112
+ * This is the check behind `status`'s and (from wave G) `doctor`'s "the Node it
113
+ * runs has gone" warning. It happens under nvm, fnm and volta the moment a Node
114
+ * version is uninstalled: the unit still names an absolute interpreter path,
115
+ * the supervisor still dutifully tries to exec it, and the failure arrives as
116
+ * Paddock being absent with nothing in its own logs — because it never ran.
117
+ */
118
+ export function missingPaths(paths, exists = (p) => fs.existsSync(p)) {
119
+ return paths.filter((p) => p !== undefined && p !== "" && !exists(p));
120
+ }
@@ -23,6 +23,28 @@
23
23
  */
24
24
  /** Where the poll got to when it stopped. */
25
25
  export type Readiness = "ready" | "timeout";
26
+ /** What `/api/health` said, when it said anything (SPEC §8). */
27
+ export interface HealthAnswer {
28
+ ok: boolean;
29
+ /** The version the RUNNING server reports. Absent from a pre-contract Paddock. */
30
+ version?: string;
31
+ }
32
+ /**
33
+ * Ask `<url>/api/health` once, and say what it answered.
34
+ *
35
+ * Distinct from {@link waitForReady}, which only needs a boolean and polls for
36
+ * it. `status` needs the body: the version in it is the only way to tell a
37
+ * server that is running the installed Paddock from one that has been up since
38
+ * before an upgrade — the single most confusing state a service can be in, and
39
+ * invisible from the outside until something reports both numbers.
40
+ *
41
+ * `undefined` means nothing answered. That is a real answer (exit 3), not an
42
+ * error, so nothing is thrown.
43
+ */
44
+ export declare function fetchHealth(url: string, opts?: {
45
+ timeoutMs?: number;
46
+ fetchImpl?: typeof fetch;
47
+ }): Promise<HealthAnswer | undefined>;
26
48
  export interface ReadyOptions {
27
49
  /** Base URL of the server, e.g. `http://127.0.0.1:7233` — no trailing slash. */
28
50
  url: string;
@@ -21,6 +21,36 @@
21
21
  * same claim as "it failed". Callers print it as a caveat, not an error, and
22
22
  * point at the logs.
23
23
  */
24
+ /**
25
+ * Ask `<url>/api/health` once, and say what it answered.
26
+ *
27
+ * Distinct from {@link waitForReady}, which only needs a boolean and polls for
28
+ * it. `status` needs the body: the version in it is the only way to tell a
29
+ * server that is running the installed Paddock from one that has been up since
30
+ * before an upgrade — the single most confusing state a service can be in, and
31
+ * invisible from the outside until something reports both numbers.
32
+ *
33
+ * `undefined` means nothing answered. That is a real answer (exit 3), not an
34
+ * error, so nothing is thrown.
35
+ */
36
+ export async function fetchHealth(url, opts = {}) {
37
+ const fetchImpl = opts.fetchImpl ?? fetch;
38
+ try {
39
+ const res = await fetchImpl(`${url}/api/health`, {
40
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 2_000),
41
+ });
42
+ if (!res.ok)
43
+ return undefined;
44
+ const body = (await res.json());
45
+ return {
46
+ ok: body.ok === true,
47
+ ...(typeof body.version === "string" ? { version: body.version } : {}),
48
+ };
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ }
24
54
  /**
25
55
  * How long to wait before saying "could not confirm".
26
56
  *