@edspencer/paddock 0.81.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 (91) 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 +208 -22
  4. package/packages/server/dist/cli/paddock.js +51 -21
  5. package/packages/server/dist/cli/service/index.d.ts +52 -0
  6. package/packages/server/dist/cli/service/index.js +170 -3
  7. package/packages/server/dist/cli/service/ready.d.ts +30 -0
  8. package/packages/server/dist/cli/service/ready.js +35 -0
  9. package/packages/server/dist/cli/service/systemd.js +27 -0
  10. package/packages/server/dist/cli/update/install-method.d.ts +131 -0
  11. package/packages/server/dist/cli/update/install-method.js +258 -0
  12. package/packages/server/dist/cli/update/lock.d.ts +40 -0
  13. package/packages/server/dist/cli/update/lock.js +108 -0
  14. package/packages/server/dist/cli/update/package-manager.d.ts +109 -0
  15. package/packages/server/dist/cli/update/package-manager.js +221 -0
  16. package/packages/server/dist/cli/update/registry.d.ts +89 -0
  17. package/packages/server/dist/cli/update/registry.js +145 -0
  18. package/packages/server/dist/cli/update/semver.d.ts +68 -0
  19. package/packages/server/dist/cli/update/semver.js +232 -0
  20. package/packages/server/dist/cli/update/update-check.d.ts +80 -0
  21. package/packages/server/dist/cli/update/update-check.js +116 -0
  22. package/packages/server/dist/cli/update/update.d.ts +160 -0
  23. package/packages/server/dist/cli/update/update.js +609 -0
  24. package/packages/server/dist/cli/update-command.d.ts +20 -0
  25. package/packages/server/dist/cli/update-command.js +113 -0
  26. package/packages/server/package.json +1 -1
  27. package/packages/web/dist/assets/{DeleteProjectDialog-D8oIJ8HC.js → DeleteProjectDialog-CphdGB3R.js} +1 -1
  28. package/packages/web/dist/assets/DiscoverPage-C4OPLNO8.js +2 -0
  29. package/packages/web/dist/assets/{DiscoverView-o0KCr3aB.js → DiscoverView-B_kcF4sm.js} +1 -1
  30. package/packages/web/dist/assets/{InstanceConfigPage-RSJz7oL3.js → InstanceConfigPage-CvUK5DKW.js} +1 -1
  31. package/packages/web/dist/assets/{MarkdownRenderer-39mafH8b.js → MarkdownRenderer-DvZ-j_Ao.js} +1 -1
  32. package/packages/web/dist/assets/{ProjectRedirect-Bq7GVguY.js → ProjectRedirect-DayHvUH2.js} +1 -1
  33. package/packages/web/dist/assets/{ProjectView-BoBIru1g.js → ProjectView-CCw83gUi.js} +3 -3
  34. package/packages/web/dist/assets/{ProjectsGrid-mJ4gqbr6.js → ProjectsGrid-B3Klg2H4.js} +1 -1
  35. package/packages/web/dist/assets/{RootHome-C0kwFyYp.js → RootHome-BxcDvpgI.js} +1 -1
  36. package/packages/web/dist/assets/{Surfaces-MkhXlB8z.js → Surfaces-7UlhFBSG.js} +1 -1
  37. package/packages/web/dist/assets/{arc-DExlwAwO.js → arc-DNkh5o5Q.js} +1 -1
  38. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CZly3W79.js → architectureDiagram-3BPJPVTR-BrgN3oLF.js} +1 -1
  39. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-AgES73i3.js → blockDiagram-GPEHLZMM-D05XOJSk.js} +1 -1
  40. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-N9mD8Pfk.js → c4Diagram-AAUBKEIU-CKaMBhMP.js} +1 -1
  41. package/packages/web/dist/assets/channel-JrEedxB8.js +2 -0
  42. package/packages/web/dist/assets/{chunk-2J33WTMH-Dsx_kfUl.js → chunk-2J33WTMH-8hgadCQc.js} +1 -1
  43. package/packages/web/dist/assets/{chunk-4BX2VUAB-BQjqcjPj.js → chunk-4BX2VUAB-CBfEC-pu.js} +1 -1
  44. package/packages/web/dist/assets/{chunk-55IACEB6-CCxpYQpY.js → chunk-55IACEB6-DV1eMgHk.js} +1 -1
  45. package/packages/web/dist/assets/{chunk-727SXJPM-DpFRdnUa.js → chunk-727SXJPM-Cfgl5fxY.js} +1 -1
  46. package/packages/web/dist/assets/{chunk-AQP2D5EJ-D2BAlzoO.js → chunk-AQP2D5EJ-BXhqD295.js} +1 -1
  47. package/packages/web/dist/assets/{chunk-FMBD7UC4-BFaMRzUF.js → chunk-FMBD7UC4-TFGOPtek.js} +1 -1
  48. package/packages/web/dist/assets/{chunk-ND2GUHAM-B9NGJ9xZ.js → chunk-ND2GUHAM-CHFM665R.js} +1 -1
  49. package/packages/web/dist/assets/{chunk-QZHKN3VN-CF1IHyMS.js → chunk-QZHKN3VN-CqEf6MMT.js} +1 -1
  50. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CHJiQp-W.js +2 -0
  51. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CHJiQp-W.js +2 -0
  52. package/packages/web/dist/assets/{core-XjElsvQ7.js → core-DmEaKxBb.js} +1 -1
  53. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-CF972Flx.js → cose-bilkent-S5V4N54A-BnGI6jkr.js} +1 -1
  54. package/packages/web/dist/assets/{dagre-BM42HDAG-DsneI8YE.js → dagre-BM42HDAG-CKoPx61E.js} +1 -1
  55. package/packages/web/dist/assets/{diagram-2AECGRRQ-BHyrVzIk.js → diagram-2AECGRRQ-mMmtlBl1.js} +1 -1
  56. package/packages/web/dist/assets/{diagram-5GNKFQAL-Dj4q8xEi.js → diagram-5GNKFQAL-Cdz9Mtnt.js} +1 -1
  57. package/packages/web/dist/assets/{diagram-KO2AKTUF-BHe3Puv_.js → diagram-KO2AKTUF-DQxM3wYj.js} +1 -1
  58. package/packages/web/dist/assets/{diagram-LMA3HP47-wzt6G2FE.js → diagram-LMA3HP47-BWrinPsq.js} +1 -1
  59. package/packages/web/dist/assets/{diagram-OG6HWLK6-CZMbMRJq.js → diagram-OG6HWLK6-U6MYUve2.js} +1 -1
  60. package/packages/web/dist/assets/{documentTitle-BwTIA8rI.js → documentTitle-DGjRXuTP.js} +1 -1
  61. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-Dtu14BL1.js → erDiagram-TEJ5UH35-xqMCsXxY.js} +1 -1
  62. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-T34ql9j0.js → flowDiagram-I6XJVG4X-PMfy_czF.js} +1 -1
  63. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DQLOeCXK.js → ganttDiagram-6RSMTGT7-whhYYE4N.js} +1 -1
  64. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-Bk11zea6.js → gitGraphDiagram-PVQCEYII-C51JW4tf.js} +1 -1
  65. package/packages/web/dist/assets/{index-CvaWErFv.js → index-CHOtvlnO.js} +2 -2
  66. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-CsE83lu0.js → infoDiagram-5YYISTIA-SH8cr905.js} +1 -1
  67. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DNVgA93n.js → ishikawaDiagram-YF4QCWOH-BjqaoU3U.js} +1 -1
  68. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-CLpSNjM3.js → journeyDiagram-JHISSGLW-CBmdEVrd.js} +1 -1
  69. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-BRzuXcKr.js → kanban-definition-UN3LZRKU-U8njjMLZ.js} +1 -1
  70. package/packages/web/dist/assets/{linear-BxOawONt.js → linear-DF66GPs1.js} +1 -1
  71. package/packages/web/dist/assets/{mermaid.core-Dvs1QCcz.js → mermaid.core-CouJz7Ph.js} +4 -4
  72. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-Dmt7pYwF.js → mindmap-definition-RKZ34NQL-BYIf0oFt.js} +1 -1
  73. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BHiZaJD-.js → pieDiagram-4H26LBE5-Cm8rgg09.js} +1 -1
  74. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-U0A1noRE.js → quadrantDiagram-W4KKPZXB-C3Il5kOH.js} +1 -1
  75. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CZLsr6yi.js → requirementDiagram-4Y6WPE33-D3PE2nTe.js} +1 -1
  76. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-BTlLGILU.js → sankeyDiagram-5OEKKPKP-DdmvBM18.js} +1 -1
  77. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DpSS_Gb1.js → sequenceDiagram-3UESZ5HK-B8M_GAIq.js} +1 -1
  78. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-D9srE-tV.js → stateDiagram-AJRCARHV-DZkXnK1_.js} +1 -1
  79. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CjCdT9Ls.js +2 -0
  80. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-BYQ5CKab.js → timeline-definition-PNZ67QCA-IHXq7rqi.js} +1 -1
  81. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-B4W2CtSV.js → vennDiagram-CIIHVFJN-Dga7tLtT.js} +1 -1
  82. package/packages/web/dist/assets/{wardley-L42UT6IY--XUV7GvD.js → wardley-L42UT6IY-CmcwnEJ-.js} +1 -1
  83. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-Bl2VIEGY.js → wardleyDiagram-YWT4CUSO-zHuSLrD4.js} +1 -1
  84. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-Bj2H9Rcf.js → xychartDiagram-2RQKCTM6-D93MU_ic.js} +1 -1
  85. package/packages/web/dist/index.html +1 -1
  86. package/packages/web/dist/sw.js +1 -1
  87. package/packages/web/dist/assets/DiscoverPage-CQlzxsn4.js +0 -2
  88. package/packages/web/dist/assets/channel-ExsAG7qT.js +0 -2
  89. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-si7g9okq.js +0 -2
  90. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-si7g9okq.js +0 -2
  91. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-AxWqsDh-.js +0 -2
@@ -1,6 +1,7 @@
1
1
  import { type CliOptions, type ServiceAction } from "../args.js";
2
2
  import { isNpxPath, serviceDir, type ServiceSpec } from "./spec.js";
3
3
  import { type Runner } from "./backend.js";
4
+ import { type UpdateSummary } from "../update/update-check.js";
4
5
  import { type HealthAnswer, type Readiness } from "./ready.js";
5
6
  export interface ServiceContext {
6
7
  /** `process.platform`. */
@@ -84,6 +85,22 @@ export interface StatusReport {
84
85
  cli: string | null;
85
86
  /** Linux only: null where the platform has no such concept. */
86
87
  linger: boolean | null;
88
+ /**
89
+ * What the last `update --check` found, from `<data-dir>/update-check.json`
90
+ * (SPEC §3).
91
+ *
92
+ * `status` never asks the registry itself — the contract is explicit that
93
+ * nothing contacts it by itself, and a status command run by a monitoring
94
+ * loop would be exactly the background check §3 forbids. So this repeats a
95
+ * file somebody's `update --check` or `doctor` wrote.
96
+ *
97
+ * **Absent, not null, when there is nothing to repeat.** The contract's
98
+ * `schemas/service-status.schema.json` types this key as an OBJECT, so a
99
+ * `null` fails the schema and the conformance runner says so — which is how
100
+ * this was found. The other optional fields here are `| null` because the
101
+ * schema types THOSE as nullable; this one it does not.
102
+ */
103
+ update?: UpdateSummary;
87
104
  /** A stale unit, a missing Node — things that are wrong but not fatal. */
88
105
  warnings: string[];
89
106
  }
@@ -94,6 +111,41 @@ export interface StatusReport {
94
111
  * never `process.exit()`, which truncates piped output mid-write.
95
112
  */
96
113
  export declare function runService(action: ServiceAction, opts: CliOptions, ctx: ServiceContext): Promise<number>;
114
+ /**
115
+ * What `paddock update` needs to know and do about the service (SPEC §5).
116
+ *
117
+ * Built here rather than in the update module so that everything which reads a
118
+ * unit file stays in one place: `update` gets the same answer `status` and
119
+ * `logs` get, from the same backend, including the #824 rule that the instance
120
+ * in question is the one the UNIT describes and not whatever `--data-dir` the
121
+ * invocation carries.
122
+ *
123
+ * Returns `home` too, which is the data dir `update.lock` and
124
+ * `update-check.json` go in. It has to be the unit's: the cache's reader is
125
+ * `service status`, which looks in the unit's data dir, so writing it anywhere
126
+ * else would be writing a file nothing reads.
127
+ */
128
+ export declare function updateServiceHooks(ctx: ServiceContext): {
129
+ hooks: ServiceHooksForUpdate;
130
+ home: string;
131
+ installed: boolean;
132
+ };
133
+ /** The shape {@link updateServiceHooks} returns — structurally `UpdateServiceHooks`. */
134
+ export interface ServiceHooksForUpdate {
135
+ inspect(): {
136
+ installed: boolean;
137
+ running: boolean;
138
+ url?: string;
139
+ log?: string;
140
+ };
141
+ stop(): void;
142
+ refresh(cliPath: string): {
143
+ status: number | null;
144
+ stdout: string;
145
+ stderr: string;
146
+ };
147
+ tail(lines: number): string;
148
+ }
97
149
  /** Exported for the entrypoint's context assembly, and for tests. */
98
150
  export { serviceDir, isNpxPath };
99
151
  export type { ServiceSpec };
@@ -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)
@@ -589,6 +692,70 @@ export async function runService(action, opts, ctx) {
589
692
  return logs(opts, ctx, backend);
590
693
  return status(opts, ctx, backend);
591
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
+ }
592
759
  /** Exported for the entrypoint's context assembly, and for tests. */
593
760
  export { serviceDir, isNpxPath };
594
761
  /** Best-effort home dir; `os.homedir()` throws on a machine with no passwd entry. */
@@ -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);
@@ -0,0 +1,131 @@
1
+ /** The ten rows of SPEC §6's table, in its order. Matches `schemas/update-check.schema.json`. */
2
+ export type InstallMethod = "docker" | "managed" | "npx" | "checkout" | "npm-global" | "pnpm-global" | "bun-global" | "volta" | "yarn-global" | "dependency";
3
+ /** The package managers that can replace an install in place. */
4
+ export type PackageManager = "npm" | "pnpm" | "bun" | "volta";
5
+ export interface FsLike {
6
+ existsSync(p: string): boolean;
7
+ readFileSync(p: string, encoding: "utf8"): string;
8
+ realpathSync(p: string): string;
9
+ }
10
+ export interface DetectInput {
11
+ /** The published package's name, e.g. `@edspencer/paddock`. */
12
+ packageName: string;
13
+ /** The environment prefix: `PADDOCK` gives `PADDOCK_IN_DOCKER` and `PADDOCK_UPDATE_HINT`. */
14
+ envPrefix: string;
15
+ /**
16
+ * The script to walk up from — `process.argv[1]`, or this module's own file.
17
+ *
18
+ * SPEC §6 says `realpath(process.argv[1])`. Realpathing is done HERE rather
19
+ * than asked of the caller, because npm installs a `bin` as a symlink and the
20
+ * whole table is about where the package really sits (args.ts has the long
21
+ * version of why that distinction has bitten this CLI before).
22
+ */
23
+ entry: string;
24
+ env: NodeJS.ProcessEnv;
25
+ /**
26
+ * `npm root -g`, asked of the `npm` next to `process.execPath` (SPEC §6).
27
+ *
28
+ * Injected because it spawns a process: a unit test of the table must not
29
+ * depend on what the machine running it happens to have installed globally.
30
+ * Returning undefined (no npm, or it failed) is a normal answer — the row
31
+ * simply does not match.
32
+ */
33
+ npmGlobalRoot?: () => string | undefined;
34
+ fs?: FsLike;
35
+ }
36
+ export interface InstallInfo {
37
+ method: InstallMethod;
38
+ /**
39
+ * The folder whose `package.json` names {@link DetectInput.packageName}, or —
40
+ * when no ancestor does — the nearest folder with a `package.json` at all.
41
+ *
42
+ * The fallback is what makes a checkout detectable. Paddock's repo publishes
43
+ * a SYNTHESIZED package (`scripts/make-npm-package.mjs`), so in the checkout
44
+ * no manifest is called `@edspencer/paddock`: the root one is `paddock` and
45
+ * the workspace one is `@paddock/server`. A strict search would find nothing
46
+ * and have nothing to say; the nearest root finds `packages/server` and the
47
+ * "not under node_modules" test then correctly reports a checkout.
48
+ */
49
+ packageRoot: string | undefined;
50
+ /** True when the name was actually found, rather than fallen back to. */
51
+ named: boolean;
52
+ /** Can `update` install a new version itself, or must it print a command and exit 5? */
53
+ canUpdate: boolean;
54
+ /** The package manager to install with, when it can. */
55
+ manager?: PackageManager;
56
+ /** `<TOOL>_UPDATE_HINT`, when the environment set one. */
57
+ hint?: string;
58
+ }
59
+ /** `PADDOCK` → `PADDOCK_IN_DOCKER`. */
60
+ export declare const inDockerVar: (prefix: string) => string;
61
+ /** `PADDOCK` → `PADDOCK_UPDATE_HINT`. */
62
+ export declare const updateHintVar: (prefix: string) => string;
63
+ /**
64
+ * Walk up from `entry` looking for the package root.
65
+ *
66
+ * Returns the first ancestor whose `package.json` has our name, and the nearest
67
+ * ancestor with any `package.json` as a fallback — see
68
+ * {@link InstallInfo.packageRoot}.
69
+ */
70
+ export declare function findPackageRoot(entry: string, packageName: string, fs: FsLike): {
71
+ root: string | undefined;
72
+ named: boolean;
73
+ };
74
+ /**
75
+ * Is `p` inside a `node_modules` directory?
76
+ *
77
+ * Split on the separator rather than matched as a substring, so a project
78
+ * genuinely called `my-node_modules` is not mistaken for one (and so Windows
79
+ * paths work, since `path.sep` is what split them). SPEC §6's "checkout or npm
80
+ * link" row is exactly the negation of this.
81
+ */
82
+ export declare function underNodeModules(p: string): boolean;
83
+ /**
84
+ * The npx / pnpm dlx / bunx / yarn dlx caches (SPEC §6).
85
+ *
86
+ * `_npx` and `bunx-` are matched between separators for the reason above.
87
+ * `dlx` has to be looser — pnpm's is `~/.local/share/pnpm/store/.../dlx/<hash>`
88
+ * and yarn's is `$TMPDIR/xfs-<hash>/dlx-<n>` — so a path SEGMENT that is `dlx`
89
+ * or starts `dlx-` counts.
90
+ */
91
+ export declare function isEphemeralCachePath(p: string): boolean;
92
+ /**
93
+ * Which of SPEC §6's rows this install is, checked in the table's own order.
94
+ *
95
+ * The order is the contract, and two pairs of rows genuinely overlap:
96
+ *
97
+ * - **Docker beats managed.** A container that an Ansible role also labelled
98
+ * has both variables set, and the honest answer is "this is an image" — but
99
+ * the hint is still carried on {@link InstallInfo.hint} and
100
+ * {@link updateCommand} prefers it, because a hint is a sentence an operator
101
+ * deliberately wrote and our guess at a `docker compose` line is not.
102
+ * - **Checkout beats every global.** They cannot both be true (every global
103
+ * layout is under a `node_modules`), so the order between them never decides
104
+ * anything; it is kept as the table has it so the two can be read side by
105
+ * side.
106
+ */
107
+ export declare function detectInstall(input: DetectInput): InstallInfo;
108
+ export interface CommandInput {
109
+ packageName: string;
110
+ /** The version to install, when one has been resolved. */
111
+ target?: string;
112
+ /** The container image to pull, for the Docker row: `ghcr.io/edspencer/paddock`. */
113
+ image?: string;
114
+ /** Prefix the npm command with `sudo` — SPEC §5 step 5's not-writable case. */
115
+ sudo?: boolean;
116
+ /** The command that brings a checkout up to date. */
117
+ checkoutCommand?: string;
118
+ }
119
+ /**
120
+ * The one command to print (SPEC §3, §6): what the reader should run instead.
121
+ *
122
+ * Returns the command and, where it helps, the parenthetical the spec's example
123
+ * carries (`(the global folder isn't writable)`). An install that CAN update
124
+ * itself gets `<tool> update`, which is the line the update notice prints.
125
+ */
126
+ export declare function updateCommand(info: InstallInfo, input: CommandInput): {
127
+ command: string;
128
+ note?: string;
129
+ };
130
+ /** One line a person can act on, for every row of the table. */
131
+ export declare function describeInstall(info: InstallInfo, packageName: string): string;