@edspencer/paddock 0.79.1 → 0.80.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 (96) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/app.js +18 -14
  3. package/packages/server/dist/cli/args.d.ts +47 -7
  4. package/packages/server/dist/cli/args.js +112 -18
  5. package/packages/server/dist/cli/exit-codes.d.ts +49 -0
  6. package/packages/server/dist/cli/exit-codes.js +48 -0
  7. package/packages/server/dist/cli/paddock.js +84 -37
  8. package/packages/server/dist/cli/service/backend.d.ts +48 -4
  9. package/packages/server/dist/cli/service/index.d.ts +56 -2
  10. package/packages/server/dist/cli/service/index.js +283 -56
  11. package/packages/server/dist/cli/service/launchd.d.ts +8 -0
  12. package/packages/server/dist/cli/service/launchd.js +29 -7
  13. package/packages/server/dist/cli/service/logs.d.ts +39 -0
  14. package/packages/server/dist/cli/service/logs.js +115 -0
  15. package/packages/server/dist/cli/service/path-env.d.ts +27 -0
  16. package/packages/server/dist/cli/service/path-env.js +120 -0
  17. package/packages/server/dist/cli/service/ready.d.ts +22 -0
  18. package/packages/server/dist/cli/service/ready.js +30 -0
  19. package/packages/server/dist/cli/service/spec.d.ts +45 -6
  20. package/packages/server/dist/cli/service/spec.js +34 -7
  21. package/packages/server/dist/cli/service/systemd.d.ts +47 -4
  22. package/packages/server/dist/cli/service/systemd.js +127 -22
  23. package/packages/server/dist/management-mcp-server.d.ts +21 -3
  24. package/packages/server/dist/management-mcp-server.js +4 -13
  25. package/packages/server/dist/routes/meta.js +11 -3
  26. package/packages/server/dist/service-log.d.ts +29 -0
  27. package/packages/server/dist/service-log.js +104 -0
  28. package/packages/server/dist/start.js +11 -0
  29. package/packages/server/dist/version.d.ts +3 -0
  30. package/packages/server/dist/version.js +32 -0
  31. package/packages/server/package.json +1 -1
  32. package/packages/web/dist/assets/{DeleteProjectDialog-BcxJMA1w.js → DeleteProjectDialog-DHczLhwQ.js} +1 -1
  33. package/packages/web/dist/assets/DiscoverPage-BSGSzTEs.js +2 -0
  34. package/packages/web/dist/assets/{DiscoverView-DOmTmiVv.js → DiscoverView-DtTOpTji.js} +1 -1
  35. package/packages/web/dist/assets/{InstanceConfigPage-DWLQqHW0.js → InstanceConfigPage-BRoBh1C1.js} +1 -1
  36. package/packages/web/dist/assets/{MarkdownRenderer-Y25JWlMR.js → MarkdownRenderer-DJf8IJuk.js} +1 -1
  37. package/packages/web/dist/assets/{ProjectRedirect-CCI1VWM6.js → ProjectRedirect-EioguUKW.js} +1 -1
  38. package/packages/web/dist/assets/{ProjectView-DzfVhjya.js → ProjectView-BiKnGfk6.js} +3 -3
  39. package/packages/web/dist/assets/{ProjectsGrid-yIyyb2HA.js → ProjectsGrid-D6kfaIal.js} +1 -1
  40. package/packages/web/dist/assets/{RootHome-x0Om9-Fd.js → RootHome-DpcyuV6e.js} +1 -1
  41. package/packages/web/dist/assets/{Surfaces-wbcMQIsK.js → Surfaces-CHM29PhP.js} +1 -1
  42. package/packages/web/dist/assets/{arc-tWN7FYIq.js → arc-fiqD05Hu.js} +1 -1
  43. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Ccj6Vlyy.js → architectureDiagram-3BPJPVTR-BuuB2Nkf.js} +1 -1
  44. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-B7iGXwqY.js → blockDiagram-GPEHLZMM-DQMBuzfi.js} +1 -1
  45. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-SkLKFSso.js → c4Diagram-AAUBKEIU-BKN0UxCH.js} +1 -1
  46. package/packages/web/dist/assets/channel-B7yh6Su_.js +2 -0
  47. package/packages/web/dist/assets/{chunk-2J33WTMH-BrS8C-YK.js → chunk-2J33WTMH-DW37EupX.js} +1 -1
  48. package/packages/web/dist/assets/{chunk-4BX2VUAB-BUR6nG8w.js → chunk-4BX2VUAB-Dyv7zV_c.js} +1 -1
  49. package/packages/web/dist/assets/{chunk-55IACEB6-BRBNJb-u.js → chunk-55IACEB6-DzMR0dOk.js} +1 -1
  50. package/packages/web/dist/assets/{chunk-727SXJPM-DKeNPTXz.js → chunk-727SXJPM-BdZ3Sor8.js} +1 -1
  51. package/packages/web/dist/assets/{chunk-AQP2D5EJ-BOS1K-DX.js → chunk-AQP2D5EJ-B4i7_og0.js} +1 -1
  52. package/packages/web/dist/assets/{chunk-FMBD7UC4-D3PZL8fo.js → chunk-FMBD7UC4-jcTr85it.js} +1 -1
  53. package/packages/web/dist/assets/{chunk-ND2GUHAM-DA9Y77Zj.js → chunk-ND2GUHAM-fgMsadqB.js} +1 -1
  54. package/packages/web/dist/assets/{chunk-QZHKN3VN-wPph-weq.js → chunk-QZHKN3VN-bJNHYS1W.js} +1 -1
  55. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DIlhMPgi.js +2 -0
  56. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DIlhMPgi.js +2 -0
  57. package/packages/web/dist/assets/{core-I1PQdGMJ.js → core-Bk4KFrmN.js} +1 -1
  58. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BA7N2vVK.js → cose-bilkent-S5V4N54A-BIqCSlQy.js} +1 -1
  59. package/packages/web/dist/assets/{dagre-BM42HDAG-DKpON5p2.js → dagre-BM42HDAG-6nyhtVQz.js} +1 -1
  60. package/packages/web/dist/assets/{diagram-2AECGRRQ-Db0I-eEL.js → diagram-2AECGRRQ-CjizfipR.js} +1 -1
  61. package/packages/web/dist/assets/{diagram-5GNKFQAL-BEuqOTfn.js → diagram-5GNKFQAL-DlgB5vWb.js} +1 -1
  62. package/packages/web/dist/assets/{diagram-KO2AKTUF-CnyX_0Xk.js → diagram-KO2AKTUF-CNC9qanV.js} +1 -1
  63. package/packages/web/dist/assets/{diagram-LMA3HP47-CYvknzDd.js → diagram-LMA3HP47-DSJ-s9lh.js} +1 -1
  64. package/packages/web/dist/assets/{diagram-OG6HWLK6-DlgKxbYz.js → diagram-OG6HWLK6-C9FoHBDg.js} +1 -1
  65. package/packages/web/dist/assets/{documentTitle-Cvob-HVs.js → documentTitle-Cj_AY97j.js} +1 -1
  66. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-BkO5JmiM.js → erDiagram-TEJ5UH35-z6gku4Yt.js} +1 -1
  67. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-DG0o_zc1.js → flowDiagram-I6XJVG4X-Dl5c_FgU.js} +1 -1
  68. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-Bs71No2S.js → ganttDiagram-6RSMTGT7-CIscJ-Dt.js} +1 -1
  69. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-CxODhRKd.js → gitGraphDiagram-PVQCEYII-CvttOXM_.js} +1 -1
  70. package/packages/web/dist/assets/{index-C4tsPSvG.js → index-DN82do6Z.js} +2 -2
  71. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-C412TQaw.js → infoDiagram-5YYISTIA-dzWYdDJj.js} +1 -1
  72. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DUlcZXQQ.js → ishikawaDiagram-YF4QCWOH-8yX9k4bo.js} +1 -1
  73. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-iL0klVhn.js → journeyDiagram-JHISSGLW-BmitzJDR.js} +1 -1
  74. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-CO02Zl_Q.js → kanban-definition-UN3LZRKU-BZmIkfkW.js} +1 -1
  75. package/packages/web/dist/assets/{linear-DtmLY8AG.js → linear-BBLXE4o7.js} +1 -1
  76. package/packages/web/dist/assets/{mermaid.core-Dvzxug80.js → mermaid.core-mSKff4yu.js} +4 -4
  77. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-q950AAWL.js → mindmap-definition-RKZ34NQL-BWAUa4CP.js} +1 -1
  78. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BVrv8A-H.js → pieDiagram-4H26LBE5-VmVdiL4C.js} +1 -1
  79. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-Bp5D2MFq.js → quadrantDiagram-W4KKPZXB-Dybfv6NU.js} +1 -1
  80. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-yZfQU4Ix.js → requirementDiagram-4Y6WPE33-BPnOvB7w.js} +1 -1
  81. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-5R8_OZir.js → sankeyDiagram-5OEKKPKP-YXIEmKw7.js} +1 -1
  82. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-C3A9rinG.js → sequenceDiagram-3UESZ5HK-DintIRUe.js} +1 -1
  83. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-Cshrt_XB.js → stateDiagram-AJRCARHV-eMsjahWp.js} +1 -1
  84. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CpAOGzbO.js +2 -0
  85. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-kNkiddCX.js → timeline-definition-PNZ67QCA-as4zRCPn.js} +1 -1
  86. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-BiNJm17g.js → vennDiagram-CIIHVFJN-CPzvUkM0.js} +1 -1
  87. package/packages/web/dist/assets/{wardley-L42UT6IY-CjExbCuX.js → wardley-L42UT6IY-q6moXj5g.js} +1 -1
  88. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-CMn3z-G2.js → wardleyDiagram-YWT4CUSO-BsEEb9Gm.js} +1 -1
  89. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-B-UhnooW.js → xychartDiagram-2RQKCTM6-DSgZlcZW.js} +1 -1
  90. package/packages/web/dist/index.html +1 -1
  91. package/packages/web/dist/sw.js +1 -1
  92. package/packages/web/dist/assets/DiscoverPage-CSPRrFVk.js +0 -2
  93. package/packages/web/dist/assets/channel-B2NSmlBB.js +0 -2
  94. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-B3tLFeZC.js +0 -2
  95. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-B3tLFeZC.js +0 -2
  96. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-DgiazF78.js +0 -2
@@ -26,11 +26,14 @@ import fs from "node:fs";
26
26
  import os from "node:os";
27
27
  import path from "node:path";
28
28
  import { CliError } from "../args.js";
29
- import { buildSpec, defaultDataDir, hostFromArgv, isNpxPath, NPX_REFUSAL, portFromArgv, serviceDir, } from "./spec.js";
29
+ import { EXIT } from "../exit-codes.js";
30
+ import { buildSpec, defaultDataDir, hostFromArgv, isNpxPath, NPX_REFUSAL, portFromArgv, serviceDir, serviceLogPath, } from "./spec.js";
30
31
  import { spawnRunner, } from "./backend.js";
31
32
  import { createLaunchdBackend } from "./launchd.js";
32
33
  import { createSystemdBackend } from "./systemd.js";
33
- import { waitForReady, READY_TIMEOUT_LABEL } from "./ready.js";
34
+ import { missingPaths } from "./path-env.js";
35
+ import { DEFAULT_LOG_LINES, followFile, sizeOf, tailFile } from "./logs.js";
36
+ import { waitForReady, fetchHealth, READY_TIMEOUT_LABEL, } from "./ready.js";
34
37
  /**
35
38
  * Pick a backend, or explain why there isn't one.
36
39
  *
@@ -87,15 +90,13 @@ const AT_LOGIN = " Paddock starts when you LOG IN, not when the machine boots
87
90
  * signalled rather than crashed. Naming the fix here is the only path from that
88
91
  * symptom back to a working service.
89
92
  */
90
- function staleUnitNote(backend) {
93
+ function staleUnitWarning(backend) {
91
94
  const key = backend.platform === "darwin"
92
95
  ? "KeepAlive is conditional on a non-zero exit"
93
96
  : "Restart=on-failure";
94
- return (` ⚠ This unit predates a fix (#872): ${key}, so a graceful stop —\n` +
95
- " the kind sleep, logout or a stray `kill` sends — puts Paddock down\n" +
96
- " until you start it again by hand. Re-run to update the unit:\n" +
97
- "\n" +
98
- " paddock service install");
97
+ return (`This unit predates a fix (#872): ${key}, so a graceful stop — the kind ` +
98
+ "sleep, logout or a stray `kill` sends — puts Paddock down until you start " +
99
+ "it again by hand. Re-run `paddock service install` to update the unit.");
99
100
  }
100
101
  /**
101
102
  * Say what is about to happen, before the part that looks like a hang (#861).
@@ -121,6 +122,17 @@ function installProgress(backend) {
121
122
  " macOS may take a few seconds to approve the background item."
122
123
  : `\n Installing ${kind} and starting Paddock…`;
123
124
  }
125
+ /** The `enable-linger` line, printed only to the people it applies to (SPEC §7). */
126
+ function lingerNote(linger) {
127
+ if (!linger.applies || linger.enabled === true)
128
+ return undefined;
129
+ const head = linger.enabled === false
130
+ ? " Lingering is off, so this service stops when you log out — including"
131
+ : " If you log out (this check could not reach loginctl), a user service";
132
+ return (`${head}\n` +
133
+ " out of an SSH session. To keep Paddock up, once:\n" +
134
+ ` sudo loginctl enable-linger ${linger.user ?? "$USER"}`);
135
+ }
124
136
  async function install(opts, ctx, backend) {
125
137
  if (isNpxPath(ctx.packageRoot))
126
138
  throw new CliError(NPX_REFUSAL);
@@ -137,7 +149,10 @@ async function install(opts, ctx, backend) {
137
149
  console.log(` Waiting for ${url} to answer…`);
138
150
  const readiness = await (ctx.checkReady ?? ((u) => waitForReady({ url: u })))(url);
139
151
  const kind = backend.platform === "darwin" ? "a launchd LaunchAgent" : "a systemd --user service";
140
- const linger = backend.lingerNote();
152
+ // Asked of the OS now, rather than printed at everyone (SPEC §7): the people
153
+ // who have already enabled lingering were being told to enable it at every
154
+ // install, which is how the line stopped being read by the people it is for.
155
+ const linger = lingerNote(backend.linger());
141
156
  console.log([
142
157
  "",
143
158
  readiness === "ready"
@@ -149,7 +164,7 @@ async function install(opts, ctx, backend) {
149
164
  ...(readiness === "ready" ? [] : [` URL: ${url}`]),
150
165
  ` Data: ${dataDir}`,
151
166
  ` Unit: ${backend.unitPath}`,
152
- backend.logsHint(spec),
167
+ ` Logs: ${spec.logPath} (paddock service logs -f)`,
153
168
  "",
154
169
  AT_LOGIN,
155
170
  ...(linger !== undefined ? ["", linger] : []),
@@ -159,66 +174,228 @@ async function install(opts, ctx, backend) {
159
174
  " paddock service uninstall stop it and remove the unit",
160
175
  "",
161
176
  ].join("\n"));
177
+ // "It is installed but did not answer" is a failure, and used to exit 0 — so
178
+ // an Ansible task or a CI step could not tell it from a working install.
179
+ return readiness === "ready" ? EXIT.ok : EXIT.failure;
162
180
  }
163
181
  function uninstall(ctx, backend) {
164
182
  const existed = backend.uninstall();
165
183
  console.log(existed
166
184
  ? `\n Paddock's ${backend.platform === "darwin" ? "LaunchAgent" : "user service"} is stopped and removed.\n ${backend.unitPath}\n\n Your data is untouched — it was never in there.\n`
167
185
  : `\n No Paddock service was installed (${backend.unitPath}).\n Nothing to do.\n`);
168
- // Leave the service directory: it holds logs, and the last thing anyone wants
169
- // from `uninstall` is for the record of why they are uninstalling to vanish.
186
+ // Leave the service directory and the logs: the last thing anyone wants from
187
+ // `uninstall` is for the record of why they are uninstalling to vanish (and
188
+ // SPEC §1 requires it — `uninstall` keeps the data home and the logs).
170
189
  void ctx;
190
+ // 0 either way: "there was nothing to remove" is the state the caller asked
191
+ // for. `uninstall` is idempotent on purpose, so a teardown script can run it
192
+ // without first asking whether it has anything to do.
193
+ return EXIT.ok;
194
+ }
195
+ /**
196
+ * The data dir the INSTALLED unit uses, read out of its own argument vector.
197
+ *
198
+ * Today's `--data-dir` flag is deliberately ignored (#824): every reporting
199
+ * surface here describes the unit on disk, and letting a flag through printed
200
+ * the unit's data dir beside a log path under a directory the service never
201
+ * writes to. `--data-dir` is documented as install-only (SERVICE_USAGE).
202
+ */
203
+ function dataDirOf(state, ctx) {
204
+ const i = state.argv.indexOf("--data-dir");
205
+ return i >= 0 && state.argv[i + 1] !== undefined
206
+ ? state.argv[i + 1]
207
+ : defaultDataDir(ctx.homeDir);
208
+ }
209
+ /** The URL the installed unit serves, read back out of the unit. */
210
+ function urlOf(state) {
211
+ return `http://${hostFromArgv(state.argv)}:${portFromArgv(state.argv)}`;
212
+ }
213
+ /**
214
+ * The log file to read: the one the unit names, else where we would write it.
215
+ *
216
+ * A unit written before the logs moved to `<dataDir>/logs/service.log` names
217
+ * the old `<dataDir>/service/paddock.log`, and that is the file its service is
218
+ * still appending to — so that is the file `logs` has to read. The fallback
219
+ * covers a unit we could not parse.
220
+ */
221
+ function logPathOf(state, dataDir) {
222
+ return state.logPath ?? serviceLogPath(dataDir);
171
223
  }
172
- function status(opts, ctx, backend) {
224
+ /**
225
+ * The version of the Paddock the unit points at.
226
+ *
227
+ * Found by walking up from the `cli.js` in the unit to the nearest
228
+ * `package.json`, which is `packages/server/package.json` in both the repo and
229
+ * the published tarball — the same hop the CLI itself makes to answer
230
+ * `--version`. Undefined when the unit names a path that is no longer there,
231
+ * which is a state `status` reports rather than guesses around.
232
+ */
233
+ export function installedVersionOf(cliPath) {
234
+ if (cliPath === undefined)
235
+ return undefined;
236
+ let dir = path.dirname(cliPath);
237
+ for (;;) {
238
+ const manifest = path.join(dir, "package.json");
239
+ if (fs.existsSync(manifest)) {
240
+ try {
241
+ const raw = JSON.parse(fs.readFileSync(manifest, "utf8"));
242
+ return raw.version;
243
+ }
244
+ catch {
245
+ return undefined;
246
+ }
247
+ }
248
+ const parent = path.dirname(dir);
249
+ if (parent === dir)
250
+ return undefined;
251
+ dir = parent;
252
+ }
253
+ }
254
+ async function buildStatus(ctx, backend) {
173
255
  const state = backend.status();
256
+ const base = {
257
+ installed: state.registered,
258
+ args: state.argv,
259
+ manager: backend.platform === "darwin" ? "launchd" : "systemd",
260
+ unit: backend.unitPath,
261
+ };
262
+ const linger = backend.linger();
263
+ const lingerValue = linger.applies ? (linger.enabled ?? null) : null;
174
264
  if (!state.registered) {
265
+ return {
266
+ ...base,
267
+ running: false,
268
+ answering: false,
269
+ installedVersion: null,
270
+ runningVersion: null,
271
+ port: null,
272
+ host: null,
273
+ url: null,
274
+ pid: null,
275
+ log: null,
276
+ dataDir: null,
277
+ node: null,
278
+ cli: null,
279
+ linger: lingerValue,
280
+ warnings: [],
281
+ };
282
+ }
283
+ const dataDir = dataDirOf(state, ctx);
284
+ const url = urlOf(state);
285
+ const health = await (ctx.probeHealth ?? ((u) => fetchHealth(u)))(url);
286
+ const installedVersion = installedVersionOf(state.cliPath);
287
+ const warnings = [];
288
+ if (!state.keepsAlive)
289
+ warnings.push(staleUnitWarning(backend));
290
+ for (const gone of missingPaths([state.nodePath, state.cliPath])) {
291
+ warnings.push(`The unit runs ${gone}, which is not there any more. ` +
292
+ "That happens when a Node version is removed (nvm, fnm, volta) or the " +
293
+ "package is reinstalled elsewhere. Re-run `paddock service install`.");
294
+ }
295
+ if (installedVersion !== undefined &&
296
+ health?.version !== undefined &&
297
+ installedVersion !== health.version) {
298
+ warnings.push(`It is running ${health.version}, but ${installedVersion} is installed. ` +
299
+ "The package was upgraded under a service that has not restarted: " +
300
+ "`paddock service restart`.");
301
+ }
302
+ if (linger.applies && linger.enabled === false) {
303
+ warnings.push("Lingering is off, so this service stops when you log out — including out " +
304
+ `of an SSH session. To keep it up: sudo loginctl enable-linger ${linger.user ?? "$USER"}`);
305
+ }
306
+ return {
307
+ ...base,
308
+ running: state.running,
309
+ answering: health?.ok === true,
310
+ installedVersion: installedVersion ?? null,
311
+ runningVersion: health?.version ?? null,
312
+ port: Number(portFromArgv(state.argv)),
313
+ host: hostFromArgv(state.argv),
314
+ url,
315
+ pid: state.pid !== undefined ? Number(state.pid) : null,
316
+ log: logPathOf(state, dataDir),
317
+ dataDir,
318
+ node: state.nodePath ?? null,
319
+ cli: state.cliPath ?? null,
320
+ linger: lingerValue,
321
+ warnings,
322
+ };
323
+ }
324
+ /**
325
+ * `status` (SPEC §1, §2).
326
+ *
327
+ * The exit code is the headline and the reason this command is worth scripting:
328
+ *
329
+ * - **4** — no unit. Nothing to start, nothing to ask.
330
+ * - **3** — installed, but not running or not answering. "Not answering"
331
+ * counts, because a supervisor reports a crash-looping job as `running` for
332
+ * most of the window between restarts, and a service nothing can reach is not
333
+ * up whatever the supervisor thinks.
334
+ * - **0** — installed, running and answering.
335
+ *
336
+ * Those are the numbers `systemctl status` and LSB init scripts already use, so
337
+ * an Ansible task or a shell `if` written against either keeps working.
338
+ */
339
+ async function status(opts, ctx, backend) {
340
+ const report = await buildStatus(ctx, backend);
341
+ if (opts.json === true) {
342
+ // Exactly one JSON document on stdout, and nothing else (SPEC §1). Every
343
+ // other line this command can print goes to stderr under --json or is not
344
+ // printed at all.
345
+ console.log(JSON.stringify(report, null, 2));
346
+ return statusExit(report);
347
+ }
348
+ if (!report.installed) {
175
349
  console.log([
176
350
  "",
177
351
  " Paddock is not installed as a service.",
178
- ` Looked for: ${backend.unitPath}`,
352
+ ` Looked for: ${report.unit}`,
179
353
  "",
180
354
  " paddock service install register it and start it now",
181
355
  "",
182
356
  ].join("\n"));
183
- return;
357
+ return EXIT.notInstalled;
184
358
  }
185
- // Everything below is read back out of the INSTALLED unit, not recomputed
186
- // from today's flags — the installed port is the one the URL has to name.
187
- const url = `http://${hostFromArgv(state.argv)}:${portFromArgv(state.argv)}`;
188
- const dataFromUnit = state.argv.indexOf("--data-dir");
189
- const dataDir = dataFromUnit >= 0 && state.argv[dataFromUnit + 1] !== undefined
190
- ? state.argv[dataFromUnit + 1]
191
- : defaultDataDir(ctx.homeDir);
192
- // Drop `--data-dir` before building the log-path spec (#824). `resolveDataDir`
193
- // gives `opts.dataDir` precedence over `ctx.envDataDir`, which is right for
194
- // `install` (see the comment there) and wrong here: this whole block reports
195
- // the INSTALLED unit, so letting today's flag through printed the unit's data
196
- // dir beside a `Logs:` path under a directory the service never writes to. On
197
- // macOS that log path is real and load-bearing, not just cosmetic.
198
- //
199
- // `--data-dir` is documented as install-only (SERVICE_USAGE), so ignoring it
200
- // here matches both the help text and the stated intent of this block.
201
- const statusOpts = { ...opts };
202
- delete statusOpts.dataDir;
203
- const { spec } = specFor(statusOpts, { ...ctx, envDataDir: dataDir });
204
- const linger = backend.lingerNote();
359
+ const versions = report.runningVersion !== null && report.runningVersion !== report.installedVersion
360
+ ? ` Version: ${report.runningVersion} running, ${report.installedVersion ?? "unknown"} installed`
361
+ : ` Version: ${report.runningVersion ?? report.installedVersion ?? "unknown"}`;
205
362
  console.log([
206
363
  "",
207
- state.running
208
- ? ` Paddock is running${state.pid !== undefined ? ` (pid ${state.pid})` : ""}.`
209
- : " Paddock is registered as a service, but not running right now.",
364
+ report.running
365
+ ? report.answering
366
+ ? ` Paddock is running${report.pid !== null ? ` (pid ${String(report.pid)})` : ""} and answering.`
367
+ : ` Paddock is running${report.pid !== null ? ` (pid ${String(report.pid)})` : ""}, but ${report.url ?? "its URL"} is not answering.`
368
+ : report.answering
369
+ ? // Found in QA, and worth its own line: the supervisor does not have
370
+ // the job, yet something IS serving that URL — usually a `paddock
371
+ // serve` in a terminal on the same port. "Not running" on its own
372
+ // reads as "nothing there", and then `service start` fails on a
373
+ // port clash for reasons the status screen had the answer to.
374
+ ` The service is not running, but something IS answering at ${report.url ?? "its URL"}\n` +
375
+ " — most likely a `paddock serve` in a terminal. The service cannot take\n" +
376
+ " that port while it is held."
377
+ : " Paddock is registered as a service, but not running right now.",
210
378
  "",
211
- ` URL: ${url}`,
212
- ` Data: ${dataDir}`,
213
- ` Unit: ${backend.unitPath}`,
214
- ` Args: paddock ${state.argv.join(" ")}`,
215
- backend.logsHint(spec),
379
+ ` URL: ${report.url ?? "unknown"}`,
380
+ versions,
381
+ ` Data: ${report.dataDir ?? "unknown"}`,
382
+ ` Unit: ${report.unit}`,
383
+ ` Args: paddock ${report.args.join(" ")}`,
384
+ ` Logs: ${report.log ?? "unknown"} (paddock service logs -f)`,
216
385
  "",
217
386
  AT_LOGIN,
218
- ...(state.keepsAlive ? [] : ["", staleUnitNote(backend)]),
219
- ...(linger !== undefined && !state.running ? ["", linger] : []),
387
+ ...report.warnings.flatMap((w) => ["", ` ⚠ ${w}`]),
220
388
  "",
221
389
  ].join("\n"));
390
+ return statusExit(report);
391
+ }
392
+ /** The exit code a report means (SPEC §2). */
393
+ function statusExit(report) {
394
+ if (!report.installed)
395
+ return EXIT.notInstalled;
396
+ if (!report.running || !report.answering)
397
+ return EXIT.notRunning;
398
+ return EXIT.ok;
222
399
  }
223
400
  /**
224
401
  * The URL an installed unit is actually serving, or undefined if none is.
@@ -259,12 +436,12 @@ async function startOrRestart(action, ctx, backend) {
259
436
  const before = backend.status();
260
437
  if (!before.registered) {
261
438
  console.log(notInstalled(backend));
262
- return;
439
+ return EXIT.notInstalled;
263
440
  }
264
441
  const url = installedUrl(before);
265
442
  if (action === "start" && before.running) {
266
443
  console.log(`\n Paddock is already running${pid(before)}.\n\n URL: ${url}\n`);
267
- return;
444
+ return EXIT.ok;
268
445
  }
269
446
  console.log(`\n ${action === "start" ? "Starting" : "Restarting"} Paddock…`);
270
447
  if (action === "start")
@@ -279,20 +456,29 @@ async function startOrRestart(action, ctx, backend) {
279
456
  ? ` ✓ Paddock is running${pid(after)} at ${url}`
280
457
  : ` Paddock was ${action === "start" ? "started" : "restarted"}, but ${url} did not\n` +
281
458
  ` answer within ${READY_TIMEOUT_LABEL}. It may still be coming up — check:`,
282
- ...(readiness === "ready" ? [] : ["", " paddock service status", backend.logsHint(specForLogs(ctx, after))]),
459
+ ...(readiness === "ready"
460
+ ? []
461
+ : [
462
+ "",
463
+ " paddock service status",
464
+ ` paddock service logs (${logPathOf(after, dataDirOf(after, ctx))})`,
465
+ ]),
283
466
  "",
284
467
  ].join("\n"));
468
+ // A start that never answered is a failure, not a success with a caveat: the
469
+ // caller — often an Ansible task — has no other way to find out.
470
+ return readiness === "ready" ? EXIT.ok : EXIT.failure;
285
471
  }
286
472
  /** `stop` (#873): put it down, leave it installed. */
287
473
  function stop(backend) {
288
474
  const state = backend.status();
289
475
  if (!state.registered) {
290
476
  console.log(notInstalled(backend));
291
- return;
477
+ return EXIT.notInstalled;
292
478
  }
293
479
  if (!state.running) {
294
480
  console.log(`\n Paddock is already stopped.\n Unit: ${backend.unitPath}\n`);
295
- return;
481
+ return EXIT.ok;
296
482
  }
297
483
  backend.stop();
298
484
  console.log([
@@ -305,17 +491,56 @@ function stop(backend) {
305
491
  " paddock service start",
306
492
  "",
307
493
  ].join("\n"));
494
+ return EXIT.ok;
308
495
  }
309
496
  function pid(state) {
310
497
  return state.pid !== undefined ? ` (pid ${state.pid})` : "";
311
498
  }
312
- /** The spec `logsHint` needs, built from the INSTALLED unit's data dir. */
313
- function specForLogs(ctx, state) {
314
- const i = state.argv.indexOf("--data-dir");
315
- const dataDir = i >= 0 && state.argv[i + 1] !== undefined ? state.argv[i + 1] : undefined;
316
- const { spec } = specFor({ open: false, verbose: false, help: false, version: false }, { ...ctx, ...(dataDir !== undefined ? { envDataDir: dataDir } : {}) });
317
- return spec;
499
+ /**
500
+ * `logs [-f] [-n <lines>]` (SPEC §1, §7).
501
+ *
502
+ * Reads the file the INSTALLED unit names, which is the only file its service
503
+ * is actually appending to — a unit written before the logs moved still names
504
+ * the old path, and sending its owner to a file nothing writes to would be
505
+ * worse than sending them nowhere.
506
+ *
507
+ * Exit 4 with no unit, like every other command that needs one. A unit whose
508
+ * log file does not exist yet is NOT an error: a service installed thirty
509
+ * seconds ago and still booting has nothing to show, and that is worth saying
510
+ * rather than failing over.
511
+ */
512
+ async function logs(opts, ctx, backend) {
513
+ const state = backend.status();
514
+ if (!state.registered) {
515
+ console.log(notInstalled(backend));
516
+ return EXIT.notInstalled;
517
+ }
518
+ const file = logPathOf(state, dataDirOf(state, ctx));
519
+ const lines = opts.lines ?? DEFAULT_LOG_LINES;
520
+ if (!fs.existsSync(file)) {
521
+ // stderr: with `-f` this is a caveat ahead of a stream, and prose on stdout
522
+ // would be mixed into the log output itself.
523
+ console.error(`paddock: nothing has been written to ${file} yet.`);
524
+ if (opts.follow !== true)
525
+ return EXIT.ok;
526
+ }
527
+ else {
528
+ const tail = tailFile(file, lines);
529
+ if (tail !== "")
530
+ console.log(tail);
531
+ }
532
+ if (opts.follow === true) {
533
+ console.error(`paddock: following ${file} — Ctrl-C to stop.`);
534
+ await followFile(file, { from: sizeOf(file) });
535
+ }
536
+ return EXIT.ok;
318
537
  }
538
+ /**
539
+ * Dispatch, returning the exit code rather than setting one (SPEC §2).
540
+ *
541
+ * The code is returned up to `paddock.ts`, which assigns `process.exitCode` —
542
+ * never `process.exit()`, which truncates piped output mid-write.
543
+ */
319
544
  export async function runService(action, opts, ctx) {
320
545
  const backend = backendFor(ctx);
321
546
  if (action === "install")
@@ -326,6 +551,8 @@ export async function runService(action, opts, ctx) {
326
551
  return startOrRestart(action, ctx, backend);
327
552
  if (action === "stop")
328
553
  return stop(backend);
554
+ if (action === "logs")
555
+ return logs(opts, ctx, backend);
329
556
  return status(opts, ctx, backend);
330
557
  }
331
558
  /** Exported for the entrypoint's context assembly, and for tests. */
@@ -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
  *
@@ -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
  *
@@ -180,6 +197,9 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
180
197
  install(spec) {
181
198
  fs.mkdirSync(path.dirname(file), { recursive: true });
182
199
  fs.mkdirSync(spec.workingDirectory, { recursive: true });
200
+ // launchd creates neither the log file's directory nor, reliably, a log
201
+ // file whose parent is missing — the job just fails to spawn.
202
+ fs.mkdirSync(path.dirname(spec.logPath), { recursive: true });
183
203
  fs.writeFileSync(file, renderPlist(spec), "utf8");
184
204
  // `bootstrap` fails outright if the label is already loaded, so a
185
205
  // reinstall has to unload first. A first install has nothing to unload,
@@ -260,6 +280,7 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
260
280
  const parsed = printed.status === 0
261
281
  ? parseLaunchctlPrint(printed.stdout)
262
282
  : { running: false, pid: undefined };
283
+ const logPath = parsePlistLogPath(xml);
263
284
  return {
264
285
  registered: true,
265
286
  running: parsed.running,
@@ -267,14 +288,15 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
267
288
  // Strip the interpreter and script: what a reader wants from `status`
268
289
  // is the Paddock invocation, not the absolute path to node twice.
269
290
  argv: argv.slice(2),
291
+ ...(argv[0] !== undefined ? { nodePath: argv[0] } : {}),
292
+ ...(argv[1] !== undefined ? { cliPath: argv[1] } : {}),
293
+ ...(logPath !== undefined ? { logPath } : {}),
270
294
  keepsAlive: plistKeepsAlive(xml),
271
295
  };
272
296
  },
273
- logsHint(spec) {
274
- return ` Logs: ${spec.stdoutPath}\n ${spec.stderrPath}`;
275
- },
276
- lingerNote() {
277
- return undefined;
297
+ /** launchd has no linger: a LaunchAgent's lifetime IS the login session. */
298
+ linger() {
299
+ return { applies: false };
278
300
  },
279
301
  };
280
302
  }
@@ -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;