@edspencer/paddock 0.80.0 → 0.82.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 +1 -1
  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 +70 -29
  11. package/packages/server/dist/cli/args.js +306 -7
  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/output.d.ts +52 -0
  17. package/packages/server/dist/cli/output.js +84 -0
  18. package/packages/server/dist/cli/paddock.js +65 -21
  19. package/packages/server/dist/cli/service/index.d.ts +64 -0
  20. package/packages/server/dist/cli/service/index.js +208 -7
  21. package/packages/server/dist/cli/service/launchd.d.ts +15 -1
  22. package/packages/server/dist/cli/service/launchd.js +116 -18
  23. package/packages/server/dist/cli/service/ready.d.ts +30 -0
  24. package/packages/server/dist/cli/service/ready.js +35 -0
  25. package/packages/server/dist/cli/service/systemd.js +27 -0
  26. package/packages/server/dist/cli/update/install-method.d.ts +131 -0
  27. package/packages/server/dist/cli/update/install-method.js +258 -0
  28. package/packages/server/dist/cli/update/lock.d.ts +40 -0
  29. package/packages/server/dist/cli/update/lock.js +108 -0
  30. package/packages/server/dist/cli/update/package-manager.d.ts +109 -0
  31. package/packages/server/dist/cli/update/package-manager.js +221 -0
  32. package/packages/server/dist/cli/update/registry.d.ts +89 -0
  33. package/packages/server/dist/cli/update/registry.js +145 -0
  34. package/packages/server/dist/cli/update/semver.d.ts +68 -0
  35. package/packages/server/dist/cli/update/semver.js +232 -0
  36. package/packages/server/dist/cli/update/update-check.d.ts +80 -0
  37. package/packages/server/dist/cli/update/update-check.js +116 -0
  38. package/packages/server/dist/cli/update/update.d.ts +160 -0
  39. package/packages/server/dist/cli/update/update.js +609 -0
  40. package/packages/server/dist/cli/update-command.d.ts +20 -0
  41. package/packages/server/dist/cli/update-command.js +113 -0
  42. package/packages/server/dist/herdctl.d.ts +19 -0
  43. package/packages/server/dist/herdctl.js +26 -0
  44. package/packages/server/dist/routes/meta.js +45 -0
  45. package/packages/server/dist/runtime-file.d.ts +26 -0
  46. package/packages/server/dist/runtime-file.js +91 -0
  47. package/packages/server/dist/start.d.ts +33 -0
  48. package/packages/server/dist/start.js +115 -8
  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-DHczLhwQ.js → DeleteProjectDialog-CphdGB3R.js} +1 -1
  53. package/packages/web/dist/assets/DiscoverPage-C4OPLNO8.js +2 -0
  54. package/packages/web/dist/assets/{DiscoverView-DtTOpTji.js → DiscoverView-B_kcF4sm.js} +1 -1
  55. package/packages/web/dist/assets/{InstanceConfigPage-BRoBh1C1.js → InstanceConfigPage-CvUK5DKW.js} +1 -1
  56. package/packages/web/dist/assets/{MarkdownRenderer-DJf8IJuk.js → MarkdownRenderer-DvZ-j_Ao.js} +1 -1
  57. package/packages/web/dist/assets/{ProjectRedirect-EioguUKW.js → ProjectRedirect-DayHvUH2.js} +1 -1
  58. package/packages/web/dist/assets/{ProjectView-BiKnGfk6.js → ProjectView-CCw83gUi.js} +3 -3
  59. package/packages/web/dist/assets/{ProjectsGrid-D6kfaIal.js → ProjectsGrid-B3Klg2H4.js} +1 -1
  60. package/packages/web/dist/assets/{RootHome-DpcyuV6e.js → RootHome-BxcDvpgI.js} +1 -1
  61. package/packages/web/dist/assets/{Surfaces-CHM29PhP.js → Surfaces-7UlhFBSG.js} +1 -1
  62. package/packages/web/dist/assets/{arc-fiqD05Hu.js → arc-DNkh5o5Q.js} +1 -1
  63. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-BuuB2Nkf.js → architectureDiagram-3BPJPVTR-BrgN3oLF.js} +1 -1
  64. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-DQMBuzfi.js → blockDiagram-GPEHLZMM-D05XOJSk.js} +1 -1
  65. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-BKN0UxCH.js → c4Diagram-AAUBKEIU-CKaMBhMP.js} +1 -1
  66. package/packages/web/dist/assets/channel-JrEedxB8.js +2 -0
  67. package/packages/web/dist/assets/{chunk-2J33WTMH-DW37EupX.js → chunk-2J33WTMH-8hgadCQc.js} +1 -1
  68. package/packages/web/dist/assets/{chunk-4BX2VUAB-Dyv7zV_c.js → chunk-4BX2VUAB-CBfEC-pu.js} +1 -1
  69. package/packages/web/dist/assets/{chunk-55IACEB6-DzMR0dOk.js → chunk-55IACEB6-DV1eMgHk.js} +1 -1
  70. package/packages/web/dist/assets/{chunk-727SXJPM-BdZ3Sor8.js → chunk-727SXJPM-Cfgl5fxY.js} +1 -1
  71. package/packages/web/dist/assets/{chunk-AQP2D5EJ-B4i7_og0.js → chunk-AQP2D5EJ-BXhqD295.js} +1 -1
  72. package/packages/web/dist/assets/{chunk-FMBD7UC4-jcTr85it.js → chunk-FMBD7UC4-TFGOPtek.js} +1 -1
  73. package/packages/web/dist/assets/{chunk-ND2GUHAM-fgMsadqB.js → chunk-ND2GUHAM-CHFM665R.js} +1 -1
  74. package/packages/web/dist/assets/{chunk-QZHKN3VN-bJNHYS1W.js → chunk-QZHKN3VN-CqEf6MMT.js} +1 -1
  75. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CHJiQp-W.js +2 -0
  76. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CHJiQp-W.js +2 -0
  77. package/packages/web/dist/assets/{core-Bk4KFrmN.js → core-DmEaKxBb.js} +1 -1
  78. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BIqCSlQy.js → cose-bilkent-S5V4N54A-BnGI6jkr.js} +1 -1
  79. package/packages/web/dist/assets/{dagre-BM42HDAG-6nyhtVQz.js → dagre-BM42HDAG-CKoPx61E.js} +1 -1
  80. package/packages/web/dist/assets/{diagram-2AECGRRQ-CjizfipR.js → diagram-2AECGRRQ-mMmtlBl1.js} +1 -1
  81. package/packages/web/dist/assets/{diagram-5GNKFQAL-DlgB5vWb.js → diagram-5GNKFQAL-Cdz9Mtnt.js} +1 -1
  82. package/packages/web/dist/assets/{diagram-KO2AKTUF-CNC9qanV.js → diagram-KO2AKTUF-DQxM3wYj.js} +1 -1
  83. package/packages/web/dist/assets/{diagram-LMA3HP47-DSJ-s9lh.js → diagram-LMA3HP47-BWrinPsq.js} +1 -1
  84. package/packages/web/dist/assets/{diagram-OG6HWLK6-C9FoHBDg.js → diagram-OG6HWLK6-U6MYUve2.js} +1 -1
  85. package/packages/web/dist/assets/{documentTitle-Cj_AY97j.js → documentTitle-DGjRXuTP.js} +1 -1
  86. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-z6gku4Yt.js → erDiagram-TEJ5UH35-xqMCsXxY.js} +1 -1
  87. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-Dl5c_FgU.js → flowDiagram-I6XJVG4X-PMfy_czF.js} +1 -1
  88. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CIscJ-Dt.js → ganttDiagram-6RSMTGT7-whhYYE4N.js} +1 -1
  89. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-CvttOXM_.js → gitGraphDiagram-PVQCEYII-C51JW4tf.js} +1 -1
  90. package/packages/web/dist/assets/{index-DN82do6Z.js → index-CHOtvlnO.js} +2 -2
  91. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-dzWYdDJj.js → infoDiagram-5YYISTIA-SH8cr905.js} +1 -1
  92. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-8yX9k4bo.js → ishikawaDiagram-YF4QCWOH-BjqaoU3U.js} +1 -1
  93. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BmitzJDR.js → journeyDiagram-JHISSGLW-CBmdEVrd.js} +1 -1
  94. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-BZmIkfkW.js → kanban-definition-UN3LZRKU-U8njjMLZ.js} +1 -1
  95. package/packages/web/dist/assets/{linear-BBLXE4o7.js → linear-DF66GPs1.js} +1 -1
  96. package/packages/web/dist/assets/{mermaid.core-mSKff4yu.js → mermaid.core-CouJz7Ph.js} +4 -4
  97. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-BWAUa4CP.js → mindmap-definition-RKZ34NQL-BYIf0oFt.js} +1 -1
  98. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-VmVdiL4C.js → pieDiagram-4H26LBE5-Cm8rgg09.js} +1 -1
  99. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-Dybfv6NU.js → quadrantDiagram-W4KKPZXB-C3Il5kOH.js} +1 -1
  100. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-BPnOvB7w.js → requirementDiagram-4Y6WPE33-D3PE2nTe.js} +1 -1
  101. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-YXIEmKw7.js → sankeyDiagram-5OEKKPKP-DdmvBM18.js} +1 -1
  102. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DintIRUe.js → sequenceDiagram-3UESZ5HK-B8M_GAIq.js} +1 -1
  103. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-eMsjahWp.js → stateDiagram-AJRCARHV-DZkXnK1_.js} +1 -1
  104. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CjCdT9Ls.js +2 -0
  105. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-as4zRCPn.js → timeline-definition-PNZ67QCA-IHXq7rqi.js} +1 -1
  106. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-CPzvUkM0.js → vennDiagram-CIIHVFJN-Dga7tLtT.js} +1 -1
  107. package/packages/web/dist/assets/{wardley-L42UT6IY-q6moXj5g.js → wardley-L42UT6IY-CmcwnEJ-.js} +1 -1
  108. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-BsEEb9Gm.js → wardleyDiagram-YWT4CUSO-zHuSLrD4.js} +1 -1
  109. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-DSgZlcZW.js → xychartDiagram-2RQKCTM6-D93MU_ic.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-BSGSzTEs.js +0 -2
  113. package/packages/web/dist/assets/channel-B7yh6Su_.js +0 -2
  114. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DIlhMPgi.js +0 -2
  115. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DIlhMPgi.js +0 -2
  116. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CpAOGzbO.js +0 -2
@@ -33,6 +33,8 @@ import { createLaunchdBackend } from "./launchd.js";
33
33
  import { createSystemdBackend } 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 { updateSummary } from "../update/update-check.js";
36
38
  import { waitForReady, fetchHealth, READY_TIMEOUT_LABEL, } from "./ready.js";
37
39
  /**
38
40
  * Pick a backend, or explain why there isn't one.
@@ -63,8 +65,52 @@ function resolveDataDir(opts, ctx) {
63
65
  }
64
66
  return { dataDir: defaultDataDir(ctx.homeDir), explicit: undefined };
65
67
  }
66
- function specFor(opts, ctx) {
67
- const { dataDir, explicit } = resolveDataDir(opts, ctx);
68
+ /** The value of a flag in a recorded argument vector, or undefined. */
69
+ function argValue(argv, names) {
70
+ for (let i = 0; i < argv.length; i++) {
71
+ if (names.includes(argv[i]) && argv[i + 1] !== undefined)
72
+ return argv[i + 1];
73
+ }
74
+ return undefined;
75
+ }
76
+ /**
77
+ * The settings an EXISTING unit carries, as if they had been typed
78
+ * (`service install --refresh`, SPEC §1 and §5 step 11).
79
+ *
80
+ * This is what makes a reinstall idempotent rather than a reset. A unit
81
+ * installed with `--port 9000 --data-dir /srv/paddock` must come back on 9000
82
+ * pointing at /srv/paddock — and `update` is precisely the caller that has no
83
+ * way to know those values, because it runs the NEW version's CLI in a shell
84
+ * that never typed them.
85
+ *
86
+ * Flags still win, so `service install --refresh --port 7300` moves the port.
87
+ * The ENVIRONMENT deliberately does not: `resolveDataDir` normally lets
88
+ * `PADDOCK_DATA_DIR` stand in for `--data-dir`, which would be wrong here —
89
+ * the refresh is a faithful re-render of a unit, and inheriting the updating
90
+ * shell's data dir would silently repoint somebody's service at a different
91
+ * instance. A unit with no recorded `--data-dir` keeps having none, which is
92
+ * how it stays the same `~/.paddock` a bare `paddock` reaches.
93
+ */
94
+ function inherit(opts, state) {
95
+ const port = argValue(state.argv, ["--port", "-p"]);
96
+ const host = argValue(state.argv, ["--host"]);
97
+ const dataDir = argValue(state.argv, ["--data-dir", "-d"]);
98
+ return {
99
+ ...opts,
100
+ ...(opts.port === undefined && port !== undefined ? { port } : {}),
101
+ ...(opts.host === undefined && host !== undefined ? { host } : {}),
102
+ ...(opts.dataDir === undefined && dataDir !== undefined ? { dataDir } : {}),
103
+ verbose: opts.verbose || state.argv.includes("--verbose"),
104
+ };
105
+ }
106
+ function specFor(opts, ctx,
107
+ /** Set on a `--refresh`: the data dir comes from the unit, not the environment. */
108
+ fromUnit = false) {
109
+ const { dataDir, explicit } = fromUnit
110
+ ? opts.dataDir !== undefined
111
+ ? { dataDir: path.resolve(opts.dataDir), explicit: path.resolve(opts.dataDir) }
112
+ : { dataDir: defaultDataDir(ctx.homeDir), explicit: undefined }
113
+ : resolveDataDir(opts, ctx);
68
114
  const spec = buildSpec({
69
115
  nodePath: ctx.nodePath,
70
116
  scriptPath: ctx.scriptPath,
@@ -136,8 +182,27 @@ function lingerNote(linger) {
136
182
  async function install(opts, ctx, backend) {
137
183
  if (isNpxPath(ctx.packageRoot))
138
184
  throw new CliError(NPX_REFUSAL);
139
- const { spec, dataDir } = specFor(opts, ctx);
185
+ // `--refresh` (SPEC §5 step 11): re-render an EXISTING unit, keeping what it
186
+ // records. With nothing installed there is nothing to refresh — exit 4, the
187
+ // same code every other command that needs a unit returns, rather than
188
+ // quietly performing a first install from defaults. That distinction is the
189
+ // whole reason the flag exists: `update` runs this through the new version's
190
+ // CLI, and a silent fallback would move a `--port 9000` service to 7233 on
191
+ // the one run nobody is watching.
192
+ const refreshing = opts.refresh === true;
193
+ let existing;
194
+ if (refreshing) {
195
+ existing = backend.status();
196
+ if (!existing.registered) {
197
+ console.log(notInstalled(backend));
198
+ return EXIT.notInstalled;
199
+ }
200
+ }
201
+ const { spec, dataDir } = specFor(existing !== undefined ? inherit(opts, existing) : opts, ctx, refreshing);
140
202
  const url = `http://${hostFromArgv(spec.args)}:${portFromArgv(spec.args)}`;
203
+ if (refreshing) {
204
+ console.log(`\n Re-writing ${backend.unitPath}\n Keeping: paddock ${spec.args.join(" ")}`);
205
+ }
141
206
  console.log(installProgress(backend));
142
207
  backend.install(spec);
143
208
  // The unit is written and the supervisor has accepted it. That is NOT the
@@ -251,6 +316,11 @@ export function installedVersionOf(cliPath) {
251
316
  dir = parent;
252
317
  }
253
318
  }
319
+ /** `{ update }` when there is a recent check to repeat, and `{}` otherwise. */
320
+ function withUpdate(home) {
321
+ const summary = updateSummary(home);
322
+ return summary !== undefined ? { update: summary } : {};
323
+ }
254
324
  async function buildStatus(ctx, backend) {
255
325
  const state = backend.status();
256
326
  const base = {
@@ -277,6 +347,7 @@ async function buildStatus(ctx, backend) {
277
347
  node: null,
278
348
  cli: null,
279
349
  linger: lingerValue,
350
+ ...withUpdate(path.resolve(ctx.envDataDir ?? defaultDataDir(ctx.homeDir))),
280
351
  warnings: [],
281
352
  };
282
353
  }
@@ -318,6 +389,7 @@ async function buildStatus(ctx, backend) {
318
389
  node: state.nodePath ?? null,
319
390
  cli: state.cliPath ?? null,
320
391
  linger: lingerValue,
392
+ ...withUpdate(dataDir),
321
393
  warnings,
322
394
  };
323
395
  }
@@ -354,6 +426,12 @@ async function status(opts, ctx, backend) {
354
426
  " paddock service install register it and start it now",
355
427
  "",
356
428
  ].join("\n"));
429
+ // The notice belongs here too. Found in QA: it was printed only on the
430
+ // installed path, so a machine with no service got `update` in `--json`
431
+ // and silence in the prose — the exact `--json`-disagrees-with-the-text rot
432
+ // that one StatusReport is supposed to prevent. A newer version is worth
433
+ // knowing about whether or not a unit exists.
434
+ noteUpdate(report);
357
435
  return EXIT.notInstalled;
358
436
  }
359
437
  const versions = report.runningVersion !== null && report.runningVersion !== report.installedVersion
@@ -387,8 +465,33 @@ async function status(opts, ctx, backend) {
387
465
  ...report.warnings.flatMap((w) => ["", ` ⚠ ${w}`]),
388
466
  "",
389
467
  ].join("\n"));
468
+ // The update notice goes to STDERR, and only ever repeats the cache (SPEC
469
+ // §3). stderr because it is prose about something other than what was asked
470
+ // for, and because that keeps every byte of stdout under the `--json`
471
+ // discipline's one rule rather than two.
472
+ noteUpdate(report);
390
473
  return statusExit(report);
391
474
  }
475
+ /**
476
+ * Repeat the cached update answer on stderr, when there is one worth repeating
477
+ * (SPEC §3).
478
+ *
479
+ * Nothing is fetched here. The `Run:` line is the plain `paddock update`
480
+ * rather than §6's per-install variant, because working that out needs the
481
+ * install method and the target's tag — and `status` has deliberately not
482
+ * asked the registry, so it has neither. Anyone whose install cannot update
483
+ * itself gets the right command from `update --check`, which has.
484
+ */
485
+ function noteUpdate(report) {
486
+ const update = report.update;
487
+ if (update === undefined || !update.updateAvailable)
488
+ return;
489
+ console.error("");
490
+ const have = report.runningVersion ?? report.installedVersion;
491
+ console.error(` ${update.latest} is available${have !== null && have !== undefined ? ` (you have ${have})` : ""}.`);
492
+ console.error(" Run: paddock update");
493
+ console.error(` Checked: ${update.checkedAt}`);
494
+ }
392
495
  /** The exit code a report means (SPEC §2). */
393
496
  function statusExit(report) {
394
497
  if (!report.installed)
@@ -432,7 +535,7 @@ function notInstalled(backend) {
432
535
  * `ThrottleInterval`, and `launchctl print` says `running` for most of that
433
536
  * window). The wait is what makes the last line true.
434
537
  */
435
- async function startOrRestart(action, ctx, backend) {
538
+ async function startOrRestart(action, opts, ctx, backend) {
436
539
  const before = backend.status();
437
540
  if (!before.registered) {
438
541
  console.log(notInstalled(backend));
@@ -443,6 +546,14 @@ async function startOrRestart(action, ctx, backend) {
443
546
  console.log(`\n Paddock is already running${pid(before)}.\n\n URL: ${url}\n`);
444
547
  return EXIT.ok;
445
548
  }
549
+ // A restart stops the instance first, so it asks the same question `stop`
550
+ // asks (SPEC §1): refuse with 75 unless `--force`. `start` does not — there
551
+ // is nothing running for it to cut short, and asking would be theatre.
552
+ if (action === "restart") {
553
+ const refused = await busyCheck(action, opts, ctx, before);
554
+ if (refused !== null)
555
+ return refused;
556
+ }
446
557
  console.log(`\n ${action === "start" ? "Starting" : "Restarting"} Paddock…`);
447
558
  if (action === "start")
448
559
  backend.start();
@@ -469,8 +580,26 @@ async function startOrRestart(action, ctx, backend) {
469
580
  // caller — often an Ansible task — has no other way to find out.
470
581
  return readiness === "ready" ? EXIT.ok : EXIT.failure;
471
582
  }
583
+ /**
584
+ * The busy check, asked of the instance the UNIT describes (SPEC §1, §4).
585
+ *
586
+ * `home` is `dataDirOf(state, ctx)` — the unit's own data dir, not whatever
587
+ * `--data-dir` this invocation carries. Same rule as `status` and `logs`
588
+ * (#824): the instance about to be stopped is the one the unit points at, and
589
+ * reading `runtime.json` from somewhere else would ask a different Paddock
590
+ * whether this one is busy.
591
+ *
592
+ * Returns null to go ahead, or the code to return (75).
593
+ */
594
+ async function busyCheck(action, opts, ctx, state) {
595
+ const guard = ctx.guardBusy ?? (await import("../busy-command.js")).guardServiceBusy;
596
+ return guard(action, dataDirOf(state, ctx), {
597
+ ...(opts.force === true ? { force: true } : {}),
598
+ ...(opts.json === true ? { json: true } : {}),
599
+ });
600
+ }
472
601
  /** `stop` (#873): put it down, leave it installed. */
473
- function stop(backend) {
602
+ async function stop(opts, ctx, backend) {
474
603
  const state = backend.status();
475
604
  if (!state.registered) {
476
605
  console.log(notInstalled(backend));
@@ -480,6 +609,12 @@ function stop(backend) {
480
609
  console.log(`\n Paddock is already stopped.\n Unit: ${backend.unitPath}\n`);
481
610
  return EXIT.ok;
482
611
  }
612
+ // Refuse with 75 if it is in the middle of something, unless --force (SPEC
613
+ // §4). Deliberately AFTER the two cases above: an unit that is not there, or
614
+ // a service already stopped, has no instance to ask and no work to cut short.
615
+ const refused = await busyCheck("stop", opts, ctx, state);
616
+ if (refused !== null)
617
+ return refused;
483
618
  backend.stop();
484
619
  console.log([
485
620
  "",
@@ -542,19 +677,85 @@ async function logs(opts, ctx, backend) {
542
677
  * never `process.exit()`, which truncates piped output mid-write.
543
678
  */
544
679
  export async function runService(action, opts, ctx) {
680
+ // `--force` is rejected on every action but `stop` and `restart` — in
681
+ // `parseCommand`, once, for every verb. See the note there.
545
682
  const backend = backendFor(ctx);
546
683
  if (action === "install")
547
684
  return install(opts, ctx, backend);
548
685
  if (action === "uninstall")
549
686
  return uninstall(ctx, backend);
550
687
  if (action === "start" || action === "restart")
551
- return startOrRestart(action, ctx, backend);
688
+ return startOrRestart(action, opts, ctx, backend);
552
689
  if (action === "stop")
553
- return stop(backend);
690
+ return stop(opts, ctx, backend);
554
691
  if (action === "logs")
555
692
  return logs(opts, ctx, backend);
556
693
  return status(opts, ctx, backend);
557
694
  }
695
+ /**
696
+ * What `paddock update` needs to know and do about the service (SPEC §5).
697
+ *
698
+ * Built here rather than in the update module so that everything which reads a
699
+ * unit file stays in one place: `update` gets the same answer `status` and
700
+ * `logs` get, from the same backend, including the #824 rule that the instance
701
+ * in question is the one the UNIT describes and not whatever `--data-dir` the
702
+ * invocation carries.
703
+ *
704
+ * Returns `home` too, which is the data dir `update.lock` and
705
+ * `update-check.json` go in. It has to be the unit's: the cache's reader is
706
+ * `service status`, which looks in the unit's data dir, so writing it anywhere
707
+ * else would be writing a file nothing reads.
708
+ */
709
+ export function updateServiceHooks(ctx) {
710
+ const backend = backendFor(ctx);
711
+ const first = backend.status();
712
+ const home = first.registered
713
+ ? dataDirOf(first, ctx)
714
+ : path.resolve(ctx.envDataDir ?? defaultDataDir(ctx.homeDir));
715
+ const inspect = () => {
716
+ const state = backend.status();
717
+ if (!state.registered) {
718
+ // No unit. A foreground `paddock serve` is still worth finding, because
719
+ // SPEC §5's "without a service" path has to be able to say "restart
720
+ // Paddock to use the new version" — and `runtime.json` is the only honest
721
+ // way to find it (no guessing at a default port, the busy client's rule).
722
+ const live = readLiveRuntimeFile(home);
723
+ return {
724
+ installed: false,
725
+ running: live !== null,
726
+ ...(live !== null ? { url: live.url } : {}),
727
+ };
728
+ }
729
+ return {
730
+ installed: true,
731
+ running: state.running,
732
+ url: urlOf(state),
733
+ log: logPathOf(state, dataDirOf(state, ctx)),
734
+ };
735
+ };
736
+ return {
737
+ installed: first.registered,
738
+ home,
739
+ hooks: {
740
+ inspect,
741
+ stop: () => backend.stop(),
742
+ // The NEW version's CLI writes the unit (SPEC §5 step 11). Run with the
743
+ // interpreter we are running, not the bin shim: the shim's shebang has to
744
+ // find `node` on PATH, and under a version manager there may not be one.
745
+ refresh: (cliPath) => {
746
+ const run = ctx.run ?? spawnRunner;
747
+ return run(process.execPath, [cliPath, "service", "install", "--refresh"]);
748
+ },
749
+ tail: (lines) => {
750
+ const state = backend.status();
751
+ if (!state.registered)
752
+ return "";
753
+ const file = logPathOf(state, dataDirOf(state, ctx));
754
+ return fs.existsSync(file) ? tailFile(file, lines) : "";
755
+ },
756
+ },
757
+ };
758
+ }
558
759
  /** Exported for the entrypoint's context assembly, and for tests. */
559
760
  export { serviceDir, isNpxPath };
560
761
  /** Best-effort home dir; `os.homedir()` throws on a machine with no passwd entry. */
@@ -75,4 +75,18 @@ export declare function parseLaunchctlPrint(out: string): {
75
75
  running: boolean;
76
76
  pid?: string;
77
77
  };
78
- 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;
@@ -188,8 +188,107 @@ function target() {
188
188
  function domain() {
189
189
  return `gui/${process.getuid?.() ?? 0}`;
190
190
  }
191
- 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) {
192
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
+ };
193
292
  return {
194
293
  platform: "darwin",
195
294
  unitPath: file,
@@ -202,16 +301,12 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
202
301
  fs.mkdirSync(path.dirname(spec.logPath), { recursive: true });
203
302
  fs.writeFileSync(file, renderPlist(spec), "utf8");
204
303
  // `bootstrap` fails outright if the label is already loaded, so a
205
- // reinstall has to unload first. A first install has nothing to unload,
206
- // and says so on stderr — ignored rather than reported, because "there
207
- // was no previous service" is the expected case, not a problem.
208
- run("launchctl", ["bootout", target()]);
209
- // NOT `load`/`unload`: those are the legacy API, and on a modern macOS
210
- // they are shims over this one that report success in cases where it
211
- // reports the actual error.
212
- const boot = run("launchctl", ["bootstrap", domain(), file]);
213
- if (boot.status !== 0)
214
- 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();
215
310
  // `RunAtLoad` should already have started it; `kickstart -k` makes that
216
311
  // true rather than probable, and is the same command a later restart uses.
217
312
  run("launchctl", ["kickstart", "-k", target()]);
@@ -230,11 +325,13 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
230
325
  * Paddock is not merely idle, it is absent from the domain — and `start` on
231
326
  * a label launchd has never heard of fails. Bootstrapping is also what a
232
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.
233
332
  */
234
333
  start() {
235
- const boot = run("launchctl", ["bootstrap", domain(), file]);
236
- if (boot.status !== 0)
237
- throw new Error(launchctlFailure("bootstrap", boot));
334
+ bootstrapOrThrow();
238
335
  run("launchctl", ["kickstart", target()]);
239
336
  },
240
337
  /**
@@ -263,12 +360,13 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
263
360
  * service that is stopped. This sequence is identical to `install`'s minus
264
361
  * writing the plist, which also gives `restart` the property the issue asks
265
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.
266
366
  */
267
367
  restart() {
268
- run("launchctl", ["bootout", target()]);
269
- const boot = run("launchctl", ["bootstrap", domain(), file]);
270
- if (boot.status !== 0)
271
- throw new Error(launchctlFailure("bootstrap", boot));
368
+ bootoutAndWait();
369
+ bootstrapOrThrow();
272
370
  run("launchctl", ["kickstart", "-k", target()]);
273
371
  },
274
372
  status() {
@@ -81,3 +81,33 @@ export declare const READY_TIMEOUT_LABEL = "20 seconds";
81
81
  * would park here well past `timeoutMs`.
82
82
  */
83
83
  export declare function waitForReady(opts: ReadyOptions): Promise<Readiness>;
84
+ /** What {@link waitForVersion} concluded (CLI contract SPEC §5 step 12). */
85
+ export type VersionVerdict = {
86
+ state: "ok";
87
+ version: string;
88
+ }
89
+ /** Something answers, and it is not the version we were waiting for. */
90
+ | {
91
+ state: "wrong-version";
92
+ version: string;
93
+ } | {
94
+ state: "no-answer";
95
+ };
96
+ /**
97
+ * Poll until `/api/health` reports exactly `version` (SPEC §5 step 12).
98
+ *
99
+ * The version is the point, and it is what makes `update`'s health check mean
100
+ * something. "The URL answers" is satisfied by the OLD server that has not
101
+ * finished shutting down, by a sibling instance on the same port, and by a
102
+ * supervisor that restarted the old unit because the new one crashed — all
103
+ * three of which are exactly the failures step 13's rollback exists for. An
104
+ * update that polled for a 200 would call every one of them a success.
105
+ *
106
+ * The distinction between "no answer" and "the wrong version answers" is kept
107
+ * because the two read completely differently in a failure report: one is a
108
+ * server that did not come up, the other is a server that came up as something
109
+ * else.
110
+ */
111
+ export declare function waitForVersion(opts: ReadyOptions & {
112
+ version: string;
113
+ }): Promise<VersionVerdict>;
@@ -99,3 +99,38 @@ export async function waitForReady(opts) {
99
99
  await sleep(intervalMs);
100
100
  }
101
101
  }
102
+ /**
103
+ * Poll until `/api/health` reports exactly `version` (SPEC §5 step 12).
104
+ *
105
+ * The version is the point, and it is what makes `update`'s health check mean
106
+ * something. "The URL answers" is satisfied by the OLD server that has not
107
+ * finished shutting down, by a sibling instance on the same port, and by a
108
+ * supervisor that restarted the old unit because the new one crashed — all
109
+ * three of which are exactly the failures step 13's rollback exists for. An
110
+ * update that polled for a 200 would call every one of them a success.
111
+ *
112
+ * The distinction between "no answer" and "the wrong version answers" is kept
113
+ * because the two read completely differently in a failure report: one is a
114
+ * server that did not come up, the other is a server that came up as something
115
+ * else.
116
+ */
117
+ export async function waitForVersion(opts) {
118
+ const { url, version, timeoutMs = READY_TIMEOUT_MS, intervalMs = DEFAULT_INTERVAL_MS, fetchImpl = fetch, now = () => Date.now(), sleep = (ms) => new Promise((r) => setTimeout(r, ms)), } = opts;
119
+ const deadline = now() + timeoutMs;
120
+ let last;
121
+ for (;;) {
122
+ const health = await fetchHealth(url, {
123
+ fetchImpl,
124
+ timeoutMs: Math.max(250, Math.min(2_000, deadline - now())),
125
+ });
126
+ if (health?.version !== undefined) {
127
+ if (health.version === version)
128
+ return { state: "ok", version };
129
+ last = health.version;
130
+ }
131
+ if (deadline - now() <= 0) {
132
+ return last !== undefined ? { state: "wrong-version", version: last } : { state: "no-answer" };
133
+ }
134
+ await sleep(intervalMs);
135
+ }
136
+ }
@@ -224,6 +224,9 @@ export function createSystemdBackend(run, homeDir, xdgConfigHome) {
224
224
  unitPath: file,
225
225
  label: SYSTEMD_UNIT,
226
226
  install(spec) {
227
+ // Asked BEFORE the unit is overwritten, because the answer decides whether a restart is
228
+ // needed below and the file is about to stop describing what is running.
229
+ const wasActive = fs.existsSync(file) && run("systemctl", ["--user", "is-active", SYSTEMD_UNIT]).stdout.trim() === "active";
227
230
  fs.mkdirSync(path.dirname(file), { recursive: true });
228
231
  fs.mkdirSync(spec.workingDirectory, { recursive: true });
229
232
  // systemd creates the log FILE for `append:` but not its directory, and a
@@ -239,6 +242,30 @@ export function createSystemdBackend(run, homeDir, xdgConfigHome) {
239
242
  const enabled = run("systemctl", args);
240
243
  if (enabled.status !== 0)
241
244
  throw new Error(systemctlFailure(args, enabled));
245
+ // And then RESTART, but only if it was ALREADY active, because `enable --now` does nothing
246
+ // to a unit that is already running — systemd starts it only if it is stopped. So a
247
+ // reinstall over a running service wrote a new unit, left the old process serving, and
248
+ // reported success.
249
+ //
250
+ // That is not a theoretical gap: it is the one `paddock update`'s ROLLBACK walks into. The
251
+ // forward path stops the service first (SPEC §5 step 9), so `enable --now` starts the new
252
+ // version cleanly; the rollback runs `service install --refresh` while the FAILED version
253
+ // is still up, and without this the "restored" service was the broken one — which turns a
254
+ // clean exit 6 into an exit 7 and a machine nobody has fixed.
255
+ //
256
+ // Conditional rather than unconditional, even though `restart` would converge both states
257
+ // in one call: on a stopped unit that would be TWO starts milliseconds apart, and systemd's
258
+ // default start limit is five in ten seconds. Halving the headroom of a unit whose whole
259
+ // job is to come back is not worth saving a question.
260
+ //
261
+ // launchd needs no equivalent — its install is a bootout followed by a bootstrap, which
262
+ // already replaces a running job.
263
+ if (wasActive) {
264
+ const restartArgs = ["--user", "restart", SYSTEMD_UNIT];
265
+ const restarted = run("systemctl", restartArgs);
266
+ if (restarted.status !== 0)
267
+ throw new Error(systemctlFailure(restartArgs, restarted));
268
+ }
242
269
  },
243
270
  uninstall() {
244
271
  const existed = fs.existsSync(file);