@edspencer/paddock 0.81.0 → 0.82.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/cli/args.d.ts +56 -31
  3. package/packages/server/dist/cli/args.js +210 -24
  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/exit-codes.d.ts +21 -45
  7. package/packages/server/dist/cli/exit-codes.js +42 -45
  8. package/packages/server/dist/cli/output.js +5 -6
  9. package/packages/server/dist/cli/paddock.js +60 -30
  10. package/packages/server/dist/cli/service/index.d.ts +54 -2
  11. package/packages/server/dist/cli/service/index.js +198 -25
  12. package/packages/server/dist/cli/service/launchd.d.ts +31 -84
  13. package/packages/server/dist/cli/service/launchd.js +104 -304
  14. package/packages/server/dist/cli/service/logs.d.ts +9 -28
  15. package/packages/server/dist/cli/service/logs.js +33 -75
  16. package/packages/server/dist/cli/service/manager.d.ts +93 -0
  17. package/packages/server/dist/cli/service/manager.js +21 -0
  18. package/packages/server/dist/cli/service/path-env.d.ts +7 -18
  19. package/packages/server/dist/cli/service/path-env.js +30 -81
  20. package/packages/server/dist/cli/service/ready.d.ts +30 -0
  21. package/packages/server/dist/cli/service/ready.js +35 -0
  22. package/packages/server/dist/cli/service/spec.d.ts +4 -31
  23. package/packages/server/dist/cli/service/spec.js +9 -0
  24. package/packages/server/dist/cli/service/systemd.d.ts +27 -101
  25. package/packages/server/dist/cli/service/systemd.js +106 -235
  26. package/packages/server/dist/cli/update/health.d.ts +48 -0
  27. package/packages/server/dist/cli/update/health.js +64 -0
  28. package/packages/server/dist/cli/update/install-method.d.ts +84 -0
  29. package/packages/server/dist/cli/update/install-method.js +183 -0
  30. package/packages/server/dist/cli/update/lock.d.ts +35 -0
  31. package/packages/server/dist/cli/update/lock.js +74 -0
  32. package/packages/server/dist/cli/update/package-manager.d.ts +74 -0
  33. package/packages/server/dist/cli/update/package-manager.js +156 -0
  34. package/packages/server/dist/cli/update/registry.d.ts +52 -0
  35. package/packages/server/dist/cli/update/registry.js +102 -0
  36. package/packages/server/dist/cli/update/semver.d.ts +45 -0
  37. package/packages/server/dist/cli/update/semver.js +182 -0
  38. package/packages/server/dist/cli/update/update-check.d.ts +69 -0
  39. package/packages/server/dist/cli/update/update-check.js +86 -0
  40. package/packages/server/dist/cli/update/update.d.ts +126 -0
  41. package/packages/server/dist/cli/update/update.js +408 -0
  42. package/packages/server/dist/cli/update-command.d.ts +20 -0
  43. package/packages/server/dist/cli/update-command.js +111 -0
  44. package/packages/server/package.json +1 -1
  45. package/packages/web/dist/assets/{DeleteProjectDialog-D8oIJ8HC.js → DeleteProjectDialog-CgHpQctt.js} +1 -1
  46. package/packages/web/dist/assets/DiscoverPage-CG6cf-0i.js +2 -0
  47. package/packages/web/dist/assets/{DiscoverView-o0KCr3aB.js → DiscoverView-BKqI0I1E.js} +1 -1
  48. package/packages/web/dist/assets/{InstanceConfigPage-RSJz7oL3.js → InstanceConfigPage-DL3raCK1.js} +1 -1
  49. package/packages/web/dist/assets/{MarkdownRenderer-39mafH8b.js → MarkdownRenderer-DcjEuf3s.js} +1 -1
  50. package/packages/web/dist/assets/{ProjectRedirect-Bq7GVguY.js → ProjectRedirect-DerkA-nE.js} +1 -1
  51. package/packages/web/dist/assets/{ProjectView-BoBIru1g.js → ProjectView-NwfC3_w7.js} +3 -3
  52. package/packages/web/dist/assets/{ProjectsGrid-mJ4gqbr6.js → ProjectsGrid-BeORa0UH.js} +1 -1
  53. package/packages/web/dist/assets/{RootHome-C0kwFyYp.js → RootHome-BYJs8qJ6.js} +1 -1
  54. package/packages/web/dist/assets/{Surfaces-MkhXlB8z.js → Surfaces-w-kEaAYU.js} +1 -1
  55. package/packages/web/dist/assets/{arc-DExlwAwO.js → arc-Bv56hasM.js} +1 -1
  56. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CZly3W79.js → architectureDiagram-3BPJPVTR-BHEknx9i.js} +1 -1
  57. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-AgES73i3.js → blockDiagram-GPEHLZMM-DtxTMrPX.js} +1 -1
  58. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-N9mD8Pfk.js → c4Diagram-AAUBKEIU-DRpEYOlM.js} +1 -1
  59. package/packages/web/dist/assets/channel-CdIiAqY2.js +2 -0
  60. package/packages/web/dist/assets/{chunk-2J33WTMH-Dsx_kfUl.js → chunk-2J33WTMH-S4s7GCg7.js} +1 -1
  61. package/packages/web/dist/assets/{chunk-4BX2VUAB-BQjqcjPj.js → chunk-4BX2VUAB-BkWVPLf1.js} +1 -1
  62. package/packages/web/dist/assets/{chunk-55IACEB6-CCxpYQpY.js → chunk-55IACEB6-4-FQp01F.js} +1 -1
  63. package/packages/web/dist/assets/{chunk-727SXJPM-DpFRdnUa.js → chunk-727SXJPM-BeyDWdIb.js} +1 -1
  64. package/packages/web/dist/assets/{chunk-AQP2D5EJ-D2BAlzoO.js → chunk-AQP2D5EJ-ZSxKy745.js} +1 -1
  65. package/packages/web/dist/assets/{chunk-FMBD7UC4-BFaMRzUF.js → chunk-FMBD7UC4-Bq8uV9j-.js} +1 -1
  66. package/packages/web/dist/assets/{chunk-ND2GUHAM-B9NGJ9xZ.js → chunk-ND2GUHAM-BOFsRyHk.js} +1 -1
  67. package/packages/web/dist/assets/{chunk-QZHKN3VN-CF1IHyMS.js → chunk-QZHKN3VN-uZ6oNc4m.js} +1 -1
  68. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DCCUHba0.js +2 -0
  69. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DCCUHba0.js +2 -0
  70. package/packages/web/dist/assets/{core-XjElsvQ7.js → core-CYuJAM2U.js} +1 -1
  71. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-CF972Flx.js → cose-bilkent-S5V4N54A-DIzkVSFb.js} +1 -1
  72. package/packages/web/dist/assets/{dagre-BM42HDAG-DsneI8YE.js → dagre-BM42HDAG-CHR6jJBv.js} +1 -1
  73. package/packages/web/dist/assets/{diagram-2AECGRRQ-BHyrVzIk.js → diagram-2AECGRRQ-BAFXCH8Q.js} +1 -1
  74. package/packages/web/dist/assets/{diagram-5GNKFQAL-Dj4q8xEi.js → diagram-5GNKFQAL-BJ5t5gbz.js} +1 -1
  75. package/packages/web/dist/assets/{diagram-KO2AKTUF-BHe3Puv_.js → diagram-KO2AKTUF-D9Xt0IVg.js} +1 -1
  76. package/packages/web/dist/assets/{diagram-LMA3HP47-wzt6G2FE.js → diagram-LMA3HP47-Bq38u6sy.js} +1 -1
  77. package/packages/web/dist/assets/{diagram-OG6HWLK6-CZMbMRJq.js → diagram-OG6HWLK6-CMQVqkpi.js} +1 -1
  78. package/packages/web/dist/assets/{documentTitle-BwTIA8rI.js → documentTitle-M1lrfXRU.js} +1 -1
  79. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-Dtu14BL1.js → erDiagram-TEJ5UH35-C63M1g9b.js} +1 -1
  80. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-T34ql9j0.js → flowDiagram-I6XJVG4X-D3j8ACN7.js} +1 -1
  81. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DQLOeCXK.js → ganttDiagram-6RSMTGT7-B1Fd1A2Y.js} +1 -1
  82. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-Bk11zea6.js → gitGraphDiagram-PVQCEYII-CEhZhDdR.js} +1 -1
  83. package/packages/web/dist/assets/{index-CvaWErFv.js → index-1E9yHciS.js} +2 -2
  84. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-CsE83lu0.js → infoDiagram-5YYISTIA-Ctquaew6.js} +1 -1
  85. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DNVgA93n.js → ishikawaDiagram-YF4QCWOH-DV4GvA1R.js} +1 -1
  86. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-CLpSNjM3.js → journeyDiagram-JHISSGLW-C_Cy9waA.js} +1 -1
  87. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-BRzuXcKr.js → kanban-definition-UN3LZRKU-CGr9ctEf.js} +1 -1
  88. package/packages/web/dist/assets/{linear-BxOawONt.js → linear-D3U8NOkt.js} +1 -1
  89. package/packages/web/dist/assets/{mermaid.core-Dvs1QCcz.js → mermaid.core-BiArdWKV.js} +4 -4
  90. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-Dmt7pYwF.js → mindmap-definition-RKZ34NQL-CZIpzj8Z.js} +1 -1
  91. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BHiZaJD-.js → pieDiagram-4H26LBE5-1MaeS3rt.js} +1 -1
  92. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-U0A1noRE.js → quadrantDiagram-W4KKPZXB-B5pgQ54e.js} +1 -1
  93. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CZLsr6yi.js → requirementDiagram-4Y6WPE33-BNnxrpGr.js} +1 -1
  94. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-BTlLGILU.js → sankeyDiagram-5OEKKPKP-DsxVH-hM.js} +1 -1
  95. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DpSS_Gb1.js → sequenceDiagram-3UESZ5HK-Ke9LcFZI.js} +1 -1
  96. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-D9srE-tV.js → stateDiagram-AJRCARHV-UJzmflND.js} +1 -1
  97. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-DgUc3HkP.js +2 -0
  98. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-BYQ5CKab.js → timeline-definition-PNZ67QCA-BRjNALVI.js} +1 -1
  99. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-B4W2CtSV.js → vennDiagram-CIIHVFJN-ClMAspxz.js} +1 -1
  100. package/packages/web/dist/assets/{wardley-L42UT6IY--XUV7GvD.js → wardley-L42UT6IY-UPv4JEmz.js} +1 -1
  101. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-Bl2VIEGY.js → wardleyDiagram-YWT4CUSO-BgYJbdEx.js} +1 -1
  102. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-Bj2H9Rcf.js → xychartDiagram-2RQKCTM6-Bfk5HjGx.js} +1 -1
  103. package/packages/web/dist/index.html +1 -1
  104. package/packages/web/dist/sw.js +1 -1
  105. package/packages/server/dist/cli/service/backend.d.ts +0 -115
  106. package/packages/server/dist/cli/service/backend.js +0 -19
  107. package/packages/web/dist/assets/DiscoverPage-CQlzxsn4.js +0 -2
  108. package/packages/web/dist/assets/channel-ExsAG7qT.js +0 -2
  109. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-si7g9okq.js +0 -2
  110. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-si7g9okq.js +0 -2
  111. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-AxWqsDh-.js +0 -2
@@ -27,12 +27,15 @@ 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 } from "./launchd.js";
33
+ import { createSystemd } from "./systemd.js";
34
34
  import { missingPaths } from "./path-env.js";
35
35
  import { DEFAULT_LOG_LINES, followFile, sizeOf, tailFile } from "./logs.js";
36
+ import { readLiveRuntimeFile } from "../../runtime-file.js";
37
+ import { PADDOCK_VERSION } from "../../version.js";
38
+ import { updateSummary } from "../update/update-check.js";
36
39
  import { waitForReady, fetchHealth, READY_TIMEOUT_LABEL, } from "./ready.js";
37
40
  /**
38
41
  * Pick a backend, or explain why there isn't one.
@@ -44,9 +47,14 @@ import { waitForReady, fetchHealth, READY_TIMEOUT_LABEL, } from "./ready.js";
44
47
  function backendFor(ctx) {
45
48
  const run = ctx.run ?? spawnRunner;
46
49
  if (ctx.platform === "darwin")
47
- return createLaunchdBackend(run, ctx.homeDir);
48
- if (ctx.platform === "linux")
49
- return createSystemdBackend(run, ctx.homeDir, ctx.xdgConfigHome);
50
+ return createLaunchd(PADDOCK_SERVICE, { run, homeDir: ctx.homeDir });
51
+ if (ctx.platform === "linux") {
52
+ return createSystemd(PADDOCK_SERVICE, {
53
+ run,
54
+ homeDir: ctx.homeDir,
55
+ ...(ctx.xdgConfigHome !== undefined ? { xdgConfigHome: ctx.xdgConfigHome } : {}),
56
+ });
57
+ }
50
58
  throw new CliError(`\`paddock service\` supports macOS (launchd) and Linux (systemd --user), not ${ctx.platform}.\n` +
51
59
  "Run `paddock` in a terminal, or keep it up with whatever your platform uses.");
52
60
  }
@@ -63,8 +71,52 @@ function resolveDataDir(opts, ctx) {
63
71
  }
64
72
  return { dataDir: defaultDataDir(ctx.homeDir), explicit: undefined };
65
73
  }
66
- function specFor(opts, ctx) {
67
- const { dataDir, explicit } = resolveDataDir(opts, ctx);
74
+ /** The value of a flag in a recorded argument vector, or undefined. */
75
+ function argValue(argv, names) {
76
+ for (let i = 0; i < argv.length; i++) {
77
+ if (names.includes(argv[i]) && argv[i + 1] !== undefined)
78
+ return argv[i + 1];
79
+ }
80
+ return undefined;
81
+ }
82
+ /**
83
+ * The settings an EXISTING unit carries, as if they had been typed
84
+ * (`service install --refresh`, SPEC §1 and §5 step 11).
85
+ *
86
+ * This is what makes a reinstall idempotent rather than a reset. A unit
87
+ * installed with `--port 9000 --data-dir /srv/paddock` must come back on 9000
88
+ * pointing at /srv/paddock — and `update` is precisely the caller that has no
89
+ * way to know those values, because it runs the NEW version's CLI in a shell
90
+ * that never typed them.
91
+ *
92
+ * Flags still win, so `service install --refresh --port 7300` moves the port.
93
+ * The ENVIRONMENT deliberately does not: `resolveDataDir` normally lets
94
+ * `PADDOCK_DATA_DIR` stand in for `--data-dir`, which would be wrong here —
95
+ * the refresh is a faithful re-render of a unit, and inheriting the updating
96
+ * shell's data dir would silently repoint somebody's service at a different
97
+ * instance. A unit with no recorded `--data-dir` keeps having none, which is
98
+ * how it stays the same `~/.paddock` a bare `paddock` reaches.
99
+ */
100
+ function inherit(opts, state) {
101
+ const port = argValue(state.argv, ["--port", "-p"]);
102
+ const host = argValue(state.argv, ["--host"]);
103
+ const dataDir = argValue(state.argv, ["--data-dir", "-d"]);
104
+ return {
105
+ ...opts,
106
+ ...(opts.port === undefined && port !== undefined ? { port } : {}),
107
+ ...(opts.host === undefined && host !== undefined ? { host } : {}),
108
+ ...(opts.dataDir === undefined && dataDir !== undefined ? { dataDir } : {}),
109
+ verbose: opts.verbose || state.argv.includes("--verbose"),
110
+ };
111
+ }
112
+ function specFor(opts, ctx,
113
+ /** Set on a `--refresh`: the data dir comes from the unit, not the environment. */
114
+ fromUnit = false) {
115
+ const { dataDir, explicit } = fromUnit
116
+ ? opts.dataDir !== undefined
117
+ ? { dataDir: path.resolve(opts.dataDir), explicit: path.resolve(opts.dataDir) }
118
+ : { dataDir: defaultDataDir(ctx.homeDir), explicit: undefined }
119
+ : resolveDataDir(opts, ctx);
68
120
  const spec = buildSpec({
69
121
  nodePath: ctx.nodePath,
70
122
  scriptPath: ctx.scriptPath,
@@ -136,8 +188,27 @@ function lingerNote(linger) {
136
188
  async function install(opts, ctx, backend) {
137
189
  if (isNpxPath(ctx.packageRoot))
138
190
  throw new CliError(NPX_REFUSAL);
139
- const { spec, dataDir } = specFor(opts, ctx);
191
+ // `--refresh` (SPEC §5 step 11): re-render an EXISTING unit, keeping what it
192
+ // records. With nothing installed there is nothing to refresh — exit 4, the
193
+ // same code every other command that needs a unit returns, rather than
194
+ // quietly performing a first install from defaults. That distinction is the
195
+ // whole reason the flag exists: `update` runs this through the new version's
196
+ // CLI, and a silent fallback would move a `--port 9000` service to 7233 on
197
+ // the one run nobody is watching.
198
+ const refreshing = opts.refresh === true;
199
+ let existing;
200
+ if (refreshing) {
201
+ existing = backend.status();
202
+ if (!existing.registered) {
203
+ console.log(notInstalled(backend));
204
+ return EXIT.NOT_INSTALLED;
205
+ }
206
+ }
207
+ const { spec, dataDir } = specFor(existing !== undefined ? inherit(opts, existing) : opts, ctx, refreshing);
140
208
  const url = `http://${hostFromArgv(spec.args)}:${portFromArgv(spec.args)}`;
209
+ if (refreshing) {
210
+ console.log(`\n Re-writing ${backend.unitPath}\n Keeping: paddock ${spec.args.join(" ")}`);
211
+ }
141
212
  console.log(installProgress(backend));
142
213
  backend.install(spec);
143
214
  // The unit is written and the supervisor has accepted it. That is NOT the
@@ -176,7 +247,7 @@ async function install(opts, ctx, backend) {
176
247
  ].join("\n"));
177
248
  // "It is installed but did not answer" is a failure, and used to exit 0 — so
178
249
  // an Ansible task or a CI step could not tell it from a working install.
179
- return readiness === "ready" ? EXIT.ok : EXIT.failure;
250
+ return readiness === "ready" ? EXIT.OK : EXIT.FAILED;
180
251
  }
181
252
  function uninstall(ctx, backend) {
182
253
  const existed = backend.uninstall();
@@ -190,7 +261,7 @@ function uninstall(ctx, backend) {
190
261
  // 0 either way: "there was nothing to remove" is the state the caller asked
191
262
  // for. `uninstall` is idempotent on purpose, so a teardown script can run it
192
263
  // without first asking whether it has anything to do.
193
- return EXIT.ok;
264
+ return EXIT.OK;
194
265
  }
195
266
  /**
196
267
  * The data dir the INSTALLED unit uses, read out of its own argument vector.
@@ -251,6 +322,11 @@ export function installedVersionOf(cliPath) {
251
322
  dir = parent;
252
323
  }
253
324
  }
325
+ /** `{ update }` when there is a recent check to repeat, and `{}` otherwise. */
326
+ function withUpdate(home) {
327
+ const summary = updateSummary(home, { currentVersion: PADDOCK_VERSION });
328
+ return summary !== undefined ? { update: summary } : {};
329
+ }
254
330
  async function buildStatus(ctx, backend) {
255
331
  const state = backend.status();
256
332
  const base = {
@@ -277,6 +353,7 @@ async function buildStatus(ctx, backend) {
277
353
  node: null,
278
354
  cli: null,
279
355
  linger: lingerValue,
356
+ ...withUpdate(path.resolve(ctx.envDataDir ?? defaultDataDir(ctx.homeDir))),
280
357
  warnings: [],
281
358
  };
282
359
  }
@@ -318,6 +395,7 @@ async function buildStatus(ctx, backend) {
318
395
  node: state.nodePath ?? null,
319
396
  cli: state.cliPath ?? null,
320
397
  linger: lingerValue,
398
+ ...withUpdate(dataDir),
321
399
  warnings,
322
400
  };
323
401
  }
@@ -354,7 +432,13 @@ async function status(opts, ctx, backend) {
354
432
  " paddock service install register it and start it now",
355
433
  "",
356
434
  ].join("\n"));
357
- return EXIT.notInstalled;
435
+ // The notice belongs here too. Found in QA: it was printed only on the
436
+ // installed path, so a machine with no service got `update` in `--json`
437
+ // and silence in the prose — the exact `--json`-disagrees-with-the-text rot
438
+ // that one StatusReport is supposed to prevent. A newer version is worth
439
+ // knowing about whether or not a unit exists.
440
+ noteUpdate(report);
441
+ return EXIT.NOT_INSTALLED;
358
442
  }
359
443
  const versions = report.runningVersion !== null && report.runningVersion !== report.installedVersion
360
444
  ? ` Version: ${report.runningVersion} running, ${report.installedVersion ?? "unknown"} installed`
@@ -387,15 +471,40 @@ async function status(opts, ctx, backend) {
387
471
  ...report.warnings.flatMap((w) => ["", ` ⚠ ${w}`]),
388
472
  "",
389
473
  ].join("\n"));
474
+ // The update notice goes to STDERR, and only ever repeats the cache (SPEC
475
+ // §3). stderr because it is prose about something other than what was asked
476
+ // for, and because that keeps every byte of stdout under the `--json`
477
+ // discipline's one rule rather than two.
478
+ noteUpdate(report);
390
479
  return statusExit(report);
391
480
  }
481
+ /**
482
+ * Repeat the cached update answer on stderr, when there is one worth repeating
483
+ * (SPEC §3).
484
+ *
485
+ * Nothing is fetched here. The `Run:` line is the plain `paddock update`
486
+ * rather than §6's per-install variant, because working that out needs the
487
+ * install method and the target's tag — and `status` has deliberately not
488
+ * asked the registry, so it has neither. Anyone whose install cannot update
489
+ * itself gets the right command from `update --check`, which has.
490
+ */
491
+ function noteUpdate(report) {
492
+ const update = report.update;
493
+ if (update === undefined || !update.updateAvailable)
494
+ return;
495
+ console.error("");
496
+ const have = report.runningVersion ?? report.installedVersion;
497
+ console.error(` ${update.latest} is available${have !== null && have !== undefined ? ` (you have ${have})` : ""}.`);
498
+ console.error(" Run: paddock update");
499
+ console.error(` Checked: ${update.checkedAt}`);
500
+ }
392
501
  /** The exit code a report means (SPEC §2). */
393
502
  function statusExit(report) {
394
503
  if (!report.installed)
395
- return EXIT.notInstalled;
504
+ return EXIT.NOT_INSTALLED;
396
505
  if (!report.running || !report.answering)
397
- return EXIT.notRunning;
398
- return EXIT.ok;
506
+ return EXIT.NOT_RUNNING;
507
+ return EXIT.OK;
399
508
  }
400
509
  /**
401
510
  * The URL an installed unit is actually serving, or undefined if none is.
@@ -436,12 +545,12 @@ async function startOrRestart(action, opts, ctx, backend) {
436
545
  const before = backend.status();
437
546
  if (!before.registered) {
438
547
  console.log(notInstalled(backend));
439
- return EXIT.notInstalled;
548
+ return EXIT.NOT_INSTALLED;
440
549
  }
441
550
  const url = installedUrl(before);
442
551
  if (action === "start" && before.running) {
443
552
  console.log(`\n Paddock is already running${pid(before)}.\n\n URL: ${url}\n`);
444
- return EXIT.ok;
553
+ return EXIT.OK;
445
554
  }
446
555
  // A restart stops the instance first, so it asks the same question `stop`
447
556
  // asks (SPEC §1): refuse with 75 unless `--force`. `start` does not — there
@@ -475,7 +584,7 @@ async function startOrRestart(action, opts, ctx, backend) {
475
584
  ].join("\n"));
476
585
  // A start that never answered is a failure, not a success with a caveat: the
477
586
  // caller — often an Ansible task — has no other way to find out.
478
- return readiness === "ready" ? EXIT.ok : EXIT.failure;
587
+ return readiness === "ready" ? EXIT.OK : EXIT.FAILED;
479
588
  }
480
589
  /**
481
590
  * The busy check, asked of the instance the UNIT describes (SPEC §1, §4).
@@ -500,11 +609,11 @@ async function stop(opts, ctx, backend) {
500
609
  const state = backend.status();
501
610
  if (!state.registered) {
502
611
  console.log(notInstalled(backend));
503
- return EXIT.notInstalled;
612
+ return EXIT.NOT_INSTALLED;
504
613
  }
505
614
  if (!state.running) {
506
615
  console.log(`\n Paddock is already stopped.\n Unit: ${backend.unitPath}\n`);
507
- return EXIT.ok;
616
+ return EXIT.OK;
508
617
  }
509
618
  // Refuse with 75 if it is in the middle of something, unless --force (SPEC
510
619
  // §4). Deliberately AFTER the two cases above: an unit that is not there, or
@@ -523,7 +632,7 @@ async function stop(opts, ctx, backend) {
523
632
  " paddock service start",
524
633
  "",
525
634
  ].join("\n"));
526
- return EXIT.ok;
635
+ return EXIT.OK;
527
636
  }
528
637
  function pid(state) {
529
638
  return state.pid !== undefined ? ` (pid ${state.pid})` : "";
@@ -545,7 +654,7 @@ async function logs(opts, ctx, backend) {
545
654
  const state = backend.status();
546
655
  if (!state.registered) {
547
656
  console.log(notInstalled(backend));
548
- return EXIT.notInstalled;
657
+ return EXIT.NOT_INSTALLED;
549
658
  }
550
659
  const file = logPathOf(state, dataDirOf(state, ctx));
551
660
  const lines = opts.lines ?? DEFAULT_LOG_LINES;
@@ -554,7 +663,7 @@ async function logs(opts, ctx, backend) {
554
663
  // would be mixed into the log output itself.
555
664
  console.error(`paddock: nothing has been written to ${file} yet.`);
556
665
  if (opts.follow !== true)
557
- return EXIT.ok;
666
+ return EXIT.OK;
558
667
  }
559
668
  else {
560
669
  const tail = tailFile(file, lines);
@@ -565,7 +674,7 @@ async function logs(opts, ctx, backend) {
565
674
  console.error(`paddock: following ${file} — Ctrl-C to stop.`);
566
675
  await followFile(file, { from: sizeOf(file) });
567
676
  }
568
- return EXIT.ok;
677
+ return EXIT.OK;
569
678
  }
570
679
  /**
571
680
  * Dispatch, returning the exit code rather than setting one (SPEC §2).
@@ -589,6 +698,70 @@ export async function runService(action, opts, ctx) {
589
698
  return logs(opts, ctx, backend);
590
699
  return status(opts, ctx, backend);
591
700
  }
701
+ /**
702
+ * What `paddock update` needs to know and do about the service (SPEC §5).
703
+ *
704
+ * Built here rather than in the update module so that everything which reads a
705
+ * unit file stays in one place: `update` gets the same answer `status` and
706
+ * `logs` get, from the same backend, including the #824 rule that the instance
707
+ * in question is the one the UNIT describes and not whatever `--data-dir` the
708
+ * invocation carries.
709
+ *
710
+ * Returns `home` too, which is the data dir `update.lock` and
711
+ * `update-check.json` go in. It has to be the unit's: the cache's reader is
712
+ * `service status`, which looks in the unit's data dir, so writing it anywhere
713
+ * else would be writing a file nothing reads.
714
+ */
715
+ export function updateServiceHooks(ctx) {
716
+ const backend = backendFor(ctx);
717
+ const first = backend.status();
718
+ const home = first.registered
719
+ ? dataDirOf(first, ctx)
720
+ : path.resolve(ctx.envDataDir ?? defaultDataDir(ctx.homeDir));
721
+ const inspect = () => {
722
+ const state = backend.status();
723
+ if (!state.registered) {
724
+ // No unit. A foreground `paddock serve` is still worth finding, because
725
+ // SPEC §5's "without a service" path has to be able to say "restart
726
+ // Paddock to use the new version" — and `runtime.json` is the only honest
727
+ // way to find it (no guessing at a default port, the busy client's rule).
728
+ const live = readLiveRuntimeFile(home);
729
+ return {
730
+ installed: false,
731
+ running: live !== null,
732
+ ...(live !== null ? { url: live.url } : {}),
733
+ };
734
+ }
735
+ return {
736
+ installed: true,
737
+ running: state.running,
738
+ url: urlOf(state),
739
+ log: logPathOf(state, dataDirOf(state, ctx)),
740
+ };
741
+ };
742
+ return {
743
+ installed: first.registered,
744
+ home,
745
+ hooks: {
746
+ inspect,
747
+ stop: () => backend.stop(),
748
+ // The NEW version's CLI writes the unit (SPEC §5 step 11). Run with the
749
+ // interpreter we are running, not the bin shim: the shim's shebang has to
750
+ // find `node` on PATH, and under a version manager there may not be one.
751
+ refresh: (cliPath) => {
752
+ const run = ctx.run ?? spawnRunner;
753
+ return run(process.execPath, [cliPath, "service", "install", "--refresh"]);
754
+ },
755
+ tail: (lines) => {
756
+ const state = backend.status();
757
+ if (!state.registered)
758
+ return "";
759
+ const file = logPathOf(state, dataDirOf(state, ctx));
760
+ return fs.existsSync(file) ? tailFile(file, lines) : "";
761
+ },
762
+ },
763
+ };
764
+ }
592
765
  /** Exported for the entrypoint's context assembly, and for tests. */
593
766
  export { serviceDir, isNpxPath };
594
767
  /** Best-effort home dir; `os.homedir()` throws on a machine with no passwd entry. */
@@ -1,92 +1,39 @@
1
- import { type ServiceSpec } from "./spec.js";
2
- import type { Runner, ServiceBackend } from "./backend.js";
3
- /** `~/Library/LaunchAgents/net.edspencer.paddock.plist`. */
4
- export declare function plistPath(homeDir?: string): string;
1
+ import { type Runner, type ServiceIdentity, type ServiceManager, type ServiceSpec } from './manager.js';
2
+ /** `~/Library/LaunchAgents/<label>.plist`. */
3
+ export declare function plistPath(id: Pick<ServiceIdentity, 'label'>, homeDir?: string): string;
5
4
  /**
6
- * Render the plist.
7
- *
8
- * `KeepAlive` is unconditional `<true/>`, and that is a deliberate reversal
9
- * (#872). It used to be `{ SuccessfulExit: false }` — "restart it if it exits
10
- * nonzero, leave it alone if it exits cleanly" — which reads like the polite
11
- * choice and is, for a service, a trap. `start.ts` handles SIGTERM by shutting
12
- * down cleanly and exiting `0`, so **every** SIGTERM the OS routinely sends a
13
- * LaunchAgent (sleep, logout, a stray `kill`) looked to launchd like "it meant
14
- * to stop", and Paddock stayed down until the next login. A hard crash was the
15
- * survivable case; the graceful stop was the terminal one.
16
- *
17
- * The worry that motivated the dict — that `<true/>` fights a deliberate stop —
18
- * does not hold for the stop this CLI actually performs. `launchctl bootout`
19
- * unloads the job from the domain, and KeepAlive only applies to a job that is
20
- * still loaded, so `uninstall` still stops Paddock in one call. What `<true/>`
21
- * does fight is `launchctl stop`, which is why nothing here uses it.
22
- *
23
- * Intent belongs in an explicit command, not in an exit code.
24
- *
25
- * `EnvironmentVariables` carries PATH and nothing else. It carries PATH because
26
- * launchd hands a process a stub PATH that a version manager's `node`, and the
27
- * `git` an agent will want, are both missing from. It carries nothing else
28
- * because a plist is a world-readable file in the user's home, and the one
29
- * category of value that would be tempting to put here — a credential — is
30
- * precisely what the agent shape exists to avoid needing.
31
- *
32
- * No `PADDOCK_DATA_DIR`: see `buildSpec`. One `~/.paddock` instance, reachable
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.
40
- */
41
- export declare function renderPlist(spec: ServiceSpec): string;
42
- /**
43
- * Pull `ProgramArguments` back out of a plist we wrote.
44
- *
45
- * `status` reports the port the service is ACTUALLY installed with, which is
46
- * only knowable from the installed unit — recomputing it from today's flags
47
- * would print a confident URL for a server listening somewhere else. Regex
48
- * rather than a plist parser because the input is a file this module wrote, in
49
- * a shape this module controls, and adding an XML dependency to read it back
50
- * would be the larger risk.
5
+ * The plist.
6
+ *
7
+ * - `KeepAlive` is an unconditional `<true/>`. `{ SuccessfulExit: false }` reads as the polite choice, but a server
8
+ * exits 0 on SIGTERM, so every SIGTERM launchd sends an agent (sleep, logout) would leave it down until the next
9
+ * login. `bootout` unloads the job, so `KeepAlive` doesn't fight a deliberate stop.
10
+ * - `StandardOutPath` and `StandardErrorPath` name the same file.
11
+ * - `EnvironmentVariables` has PATH (launchd's is a stub) and the spec's `env`. Never a credential: a plist is a
12
+ * readable file.
51
13
  */
14
+ export declare function renderPlist(id: Pick<ServiceIdentity, 'label'>, spec: ServiceSpec): string;
15
+ /** `ProgramArguments`, read back from a plist this wrote. A regex is enough for a file whose shape we control. */
52
16
  export declare function parsePlistArgv(xml: string): string[];
53
- /**
54
- * Does an installed plist carry the unconditional `KeepAlive` from #872?
55
- *
56
- * Deliberately narrow: it matches `<key>KeepAlive</key>` followed by `<true/>`
57
- * and nothing else. The shape it must NOT accept is the old
58
- * `<dict><key>SuccessfulExit</key><false/></dict>`, which is the one that
59
- * leaves a service dead after a graceful stop — so a `false` here is the signal
60
- * `status` turns into "re-run install", and defaulting to `true` on anything
61
- * unrecognised would silence exactly the case this exists to catch.
62
- */
63
- export declare function plistKeepsAlive(xml: string): boolean;
64
- /** The log file an installed plist appends to, read back from `StandardOutPath`. */
17
+ /** The log the plist names (`StandardOutPath`). */
65
18
  export declare function parsePlistLogPath(xml: string): string | undefined;
66
- /**
67
- * Read liveness out of `launchctl print gui/<uid>/<label>`.
68
- *
69
- * That command is the honest answer to "is it running": it reports the service
70
- * as launchd currently understands it, including `state = running` and the pid.
71
- * The alternative — checking whether the plist file exists — answers "is it
72
- * registered", which is a different and much weaker question.
73
- */
19
+ /** Whether the plist has an unconditional `KeepAlive` (the old `SuccessfulExit` dict doesn't count). */
20
+ export declare function plistKeepsAlive(xml: string): boolean;
21
+ /** `state = running` and the pid, from `launchctl print gui/<uid>/<label>`. */
74
22
  export declare function parseLaunchctlPrint(out: string): {
75
23
  running: boolean;
76
24
  pid?: string;
77
25
  };
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;
26
+ export declare const BOOTOUT_POLLS = 40;
27
+ export declare const BOOTOUT_POLL_MS = 250;
28
+ export declare const BOOTSTRAP_ATTEMPTS = 6;
29
+ export declare const BOOTSTRAP_BACKOFF_MS = 500;
30
+ /** 5 is `EIO`; 37 is "operation already in progress". Both mean the last bootout hasn't finished. */
31
+ export declare const BOOTSTRAP_RACE_CODES: ReadonlySet<number>;
32
+ export interface LaunchdOptions {
33
+ run: Runner;
34
+ homeDir?: string;
35
+ uid?: number;
36
+ /** Injected for tests. */
37
+ sleep?: (ms: number) => void;
38
+ }
39
+ export declare function createLaunchd(id: ServiceIdentity, o: LaunchdOptions): ServiceManager;