@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
@@ -0,0 +1,609 @@
1
+ /**
2
+ * `<tool> update` — the whole of CLI contract SPEC §5, once, for every tool.
3
+ *
4
+ * ## Why this file is generic
5
+ *
6
+ * Six tools have to do this identically, and the sequence is the part where
7
+ * being almost right is worst: an update that stops the service and then fails
8
+ * to install has to put the old one back, and a tool that gets that wrong
9
+ * leaves a machine with nothing running and no explanation. So §5 lives here
10
+ * once, as a state machine over injected hooks, and each product supplies the
11
+ * four things that are genuinely its own — how to ask whether it is busy, how
12
+ * to stop and refresh its service, how to ask its health endpoint, and what to
13
+ * call itself. Wave C4 lifts this into `reference/update.ts`.
14
+ *
15
+ * ## The order, and why each step is where it is
16
+ *
17
+ * 1. the lock, so two updates cannot interleave;
18
+ * 2. the install method, so an update that cannot work says so before it tries;
19
+ * 3. the registry, for the target and its `engines`;
20
+ * 4. the comparison — and `--check` stops here;
21
+ * 5. the writability check, which turns "npm EACCES, half-installed" into a
22
+ * `sudo` line printed while everything is still running;
23
+ * 6. the busy check, **before anything is stopped**;
24
+ * 7. the question, which `--yes` answers and no TTY forbids;
25
+ * 8. `npm cache add` for BOTH versions, so a network failure happens while the
26
+ * old version still serves and the rollback has its package to hand;
27
+ * 9. stop — before installing, because npm moves the old package aside before
28
+ * unpacking and a running server then lazily loads files that are gone;
29
+ * 10. install with the manager this install came from;
30
+ * 11. hand over to the NEW CLI (`service install --refresh`), which re-renders
31
+ * the unit in its own format;
32
+ * 12. poll health until the TARGET version answers, not merely until something
33
+ * answers;
34
+ * 13. and on any failure from step 10 on, roll back and report 6 or 7.
35
+ *
36
+ * Node built-ins only. Nothing here imports the server.
37
+ */
38
+ import path from "node:path";
39
+ import fsDefault from "node:fs";
40
+ import { createInterface } from "node:readline";
41
+ import { EXIT } from "../exit-codes.js";
42
+ import { detectInstall, describeInstall, updateCommand, } from "./install-method.js";
43
+ import { acquireLock, LockHeldError } from "./lock.js";
44
+ import { RegistryError, resolveRegistry, resolveTarget, } from "./registry.js";
45
+ import { cacheAdd, cliVersion, globalRootFor, installArgs, installPackage, isWritable, npmConfigGet, npmGlobalRoot, } from "./package-manager.js";
46
+ import { compareVersions, isPrerelease, satisfiesEngines } from "./semver.js";
47
+ import { updateNotice, writeUpdateCheck, } from "./update-check.js";
48
+ /** SPEC §5 step 12: 30 seconds unless the tool sets its own. */
49
+ export const HEALTH_TIMEOUT_MS = 30_000;
50
+ /** How many log lines a failure prints (SPEC §5 step 13). */
51
+ export const LOG_LINES_ON_FAILURE = 20;
52
+ /**
53
+ * Run `update`, and return the exit code (SPEC §2: the caller assigns
54
+ * `process.exitCode`; nothing here calls `process.exit`).
55
+ */
56
+ export async function runUpdate(product, opts, envp) {
57
+ const out = envp.out;
58
+ const env = envp.env ?? process.env;
59
+ const fs = envp.fs ?? fsDefault;
60
+ let emitted = false;
61
+ /** `out.result` promises exactly one document, so guard it here rather than at ten call sites. */
62
+ const emit = (doc) => {
63
+ if (emitted)
64
+ return;
65
+ emitted = true;
66
+ out.result(doc);
67
+ };
68
+ // ---- 1. the lock -------------------------------------------------------
69
+ let lock;
70
+ try {
71
+ lock = acquireLock({
72
+ home: product.home,
73
+ command: `${product.tool} update${opts.to !== undefined ? ` --to ${opts.to}` : ""}`,
74
+ });
75
+ }
76
+ catch (err) {
77
+ if (err instanceof LockHeldError) {
78
+ emit({ outcome: "failed", message: err.message });
79
+ return out.error(err.message);
80
+ }
81
+ const message = err instanceof Error ? err.message : String(err);
82
+ emit({ outcome: "failed", message });
83
+ return out.error(`could not take the update lock: ${message}`);
84
+ }
85
+ try {
86
+ return await sequence();
87
+ }
88
+ finally {
89
+ lock.release();
90
+ }
91
+ async function sequence() {
92
+ const spawnCtx = {
93
+ ...(envp.spawn !== undefined ? { spawn: envp.spawn } : {}),
94
+ ...(envp.execPath !== undefined ? { execPath: envp.execPath } : {}),
95
+ ...(envp.platform !== undefined ? { platform: envp.platform } : {}),
96
+ env,
97
+ };
98
+ // ---- 2. how was it installed? (SPEC §6) ------------------------------
99
+ //
100
+ // Detected now, reported now, but NOT acted on yet: the notice an install
101
+ // that cannot update itself needs (`docker compose pull … (image …:0.81.0)`)
102
+ // names the target version, so the exit-5 refusal waits until step 4 has
103
+ // one. An install that is already up to date exits 0 instead, which is the
104
+ // right answer in a container too.
105
+ const info = detectInstall({
106
+ packageName: product.packageName,
107
+ envPrefix: product.envPrefix,
108
+ entry: product.entry,
109
+ env,
110
+ npmGlobalRoot: () => npmGlobalRoot(spawnCtx),
111
+ fs,
112
+ });
113
+ // ---- 3. the registry -------------------------------------------------
114
+ const registry = resolveRegistry(env, (key) => npmConfigGet(key, spawnCtx));
115
+ let target;
116
+ try {
117
+ target = await resolveTarget({
118
+ packageName: product.packageName,
119
+ registry,
120
+ ...(opts.to !== undefined ? { to: opts.to } : {}),
121
+ ...(envp.fetch !== undefined ? { fetch: envp.fetch } : {}),
122
+ });
123
+ }
124
+ catch (err) {
125
+ const message = err instanceof RegistryError ? `${err.message}\n ${err.url}` : String(err);
126
+ emit({ outcome: "failed", message });
127
+ return out.error(message);
128
+ }
129
+ // ---- 4. compare ------------------------------------------------------
130
+ const cmp = compareVersions(target.version, product.currentVersion);
131
+ const nodeVersion = envp.nodeVersion ?? process.versions.node;
132
+ const engines = satisfiesEngines(nodeVersion, {
133
+ ...(target.engines !== null ? { node: target.engines } : {}),
134
+ });
135
+ const command = commandFor(product, info, target.version);
136
+ const check = {
137
+ name: product.packageName,
138
+ current: product.currentVersion,
139
+ target: target.version,
140
+ tag: target.tag,
141
+ updateAvailable: cmp > 0,
142
+ installMethod: info.method,
143
+ canUpdate: info.canUpdate,
144
+ command: info.canUpdate ? `${product.tool} update` : command.command,
145
+ engines: target.engines,
146
+ // `undefined` from the parser means "this range could not be read", and
147
+ // SPEC's field is a boolean — report the permissive answer, which is also
148
+ // the one the code acts on. The range itself is in `engines` either way.
149
+ nodeOk: engines.ok !== false,
150
+ changelog: product.changelogUrl(target.version),
151
+ checkedAt: new Date().toISOString(),
152
+ };
153
+ // Written whatever the outcome below, because the cache's whole job is to
154
+ // let `service status` repeat the last answer (SPEC §3) — including "you
155
+ // are up to date", which is the answer that stops it nagging.
156
+ writeUpdateCheck(product.home, check);
157
+ // A prerelease only counts when `--to` names it (SPEC §5 step 4). `latest`
158
+ // pointing at one is a mis-tagged release, and silently installing it would
159
+ // be worse than saying nothing happened.
160
+ if (opts.to === undefined && isPrerelease(target.version)) {
161
+ const message = `the registry's \`latest\` is a prerelease (${target.version}); not installing it.\n` +
162
+ ` To go there deliberately: ${product.tool} update --to ${target.version}`;
163
+ emit({ ...check, outcome: "ahead", message });
164
+ out.say(message);
165
+ return EXIT.ok;
166
+ }
167
+ if (cmp === 0) {
168
+ const message = `${product.name} ${product.currentVersion} is up to date.`;
169
+ emit({ ...check, outcome: "up-to-date", message });
170
+ out.say(message);
171
+ return EXIT.ok;
172
+ }
173
+ // A lower version needs `--to` (SPEC §5 step 4). Without it this is the
174
+ // ordinary "you are on a prerelease or a hand-built version" case, and
175
+ // there is nothing to do.
176
+ if (cmp < 0 && opts.to === undefined) {
177
+ const message = `${product.name} ${product.currentVersion} is newer than the registry's ` +
178
+ `${target.tag ?? "latest"} (${target.version}). Nothing to do.\n` +
179
+ ` To go back deliberately: ${product.tool} update --to ${target.version}`;
180
+ emit({ ...check, outcome: "ahead", message });
181
+ out.say(message);
182
+ return EXIT.ok;
183
+ }
184
+ const direction = cmp < 0 ? "Downgrade" : "Update";
185
+ const arrow = `${product.currentVersion} → ${target.version}`;
186
+ if (opts.check === true) {
187
+ // Report, change nothing, exit 0 whether or not there is an update
188
+ // (SPEC §2). Scripts read `updateAvailable` from `--json`.
189
+ emit({ ...check, outcome: "available" });
190
+ out.say(updateNotice({
191
+ name: product.name,
192
+ current: product.currentVersion,
193
+ target: target.version,
194
+ command: check.command ?? `${product.tool} update`,
195
+ ...(command.note !== undefined && !info.canUpdate ? { note: command.note } : {}),
196
+ changelog: check.changelog,
197
+ }));
198
+ if (engines.ok === false) {
199
+ out.warn(` Note: ${target.version} needs Node ${target.engines ?? "?"}; this is Node ${nodeVersion}.`);
200
+ }
201
+ return EXIT.ok;
202
+ }
203
+ // Now that there IS a target, an install that cannot replace itself prints
204
+ // the right command and exits 5 (SPEC §5 step 2, §6).
205
+ if (!info.canUpdate) {
206
+ out.say(updateNotice({
207
+ name: product.name,
208
+ current: product.currentVersion,
209
+ target: target.version,
210
+ command: command.command,
211
+ ...(command.note !== undefined ? { note: command.note } : {}),
212
+ changelog: check.changelog,
213
+ }));
214
+ const message = `this install cannot update itself — it is ${describeInstall(info, product.packageName)}.`;
215
+ emit({ ...check, outcome: "cannot-self-update", message });
216
+ return out.error(message, EXIT.cannotSelfUpdate);
217
+ }
218
+ const manager = info.manager ?? "npm";
219
+ if (engines.ok === false) {
220
+ const message = `${target.version} needs Node ${target.engines ?? "?"}, and this is Node ${nodeVersion}.\n` +
221
+ ` Upgrade Node first, then re-run \`${product.tool} update\`.`;
222
+ emit({ ...check, outcome: "node-too-old", message });
223
+ return out.error(message);
224
+ }
225
+ // ---- 5. can we write there? (SPEC §5 step 5) -------------------------
226
+ const globalRoot = globalRootFor(manager, spawnCtx);
227
+ const writable = envp.writable ?? isWritable;
228
+ if (globalRoot !== undefined && !writable(globalRoot)) {
229
+ const sudo = updateCommand(info, {
230
+ packageName: product.packageName,
231
+ target: target.version,
232
+ sudo: true,
233
+ });
234
+ out.say(updateNotice({
235
+ name: product.name,
236
+ current: product.currentVersion,
237
+ target: target.version,
238
+ command: sudo.command,
239
+ note: sudo.note ?? "the global folder isn't writable",
240
+ changelog: check.changelog,
241
+ }));
242
+ const message = `${globalRoot} is not writable by this user, so the install would fail half-way.`;
243
+ emit({ ...check, outcome: "not-writable", message, command: sudo.command });
244
+ // Never run sudo ourselves (SPEC §5 step 5).
245
+ return out.error(message, EXIT.cannotSelfUpdate);
246
+ }
247
+ // ---- 6. busy? (SPEC §5 step 6) --------------------------------------
248
+ //
249
+ // Before ANYTHING is stopped, and before the question is asked: being told
250
+ // "it is busy" after answering yes to "the service will restart" is the
251
+ // wrong order to learn it in.
252
+ const refused = await product.guardBusy({ force: opts.force === true, home: product.home });
253
+ if (refused !== null) {
254
+ emit({ ...check, outcome: "busy" });
255
+ return refused;
256
+ }
257
+ const service = product.service.inspect();
258
+ // ---- 7. ask (SPEC §5 step 7) ----------------------------------------
259
+ const question = `${direction} ${product.name} ${arrow}?` +
260
+ (service.installed ? " The service will restart." : "");
261
+ if (opts.yes !== true) {
262
+ const tty = envp.isTTY ?? (process.stdin.isTTY === true && process.stderr.isTTY === true);
263
+ if (!tty) {
264
+ const message = "there is no terminal to ask, so `--yes` is required.\n" +
265
+ ` ${product.tool} update --yes`;
266
+ emit({ ...check, outcome: "needs-yes", message });
267
+ // Exit 2, as SPEC §3 requires of a command that would have asked, with
268
+ // the usage after the message, as SPEC §2 requires of exit 2.
269
+ const code = out.error(message, EXIT.usage);
270
+ if (product.usage !== undefined)
271
+ out.warn(`\n${product.usage}`);
272
+ return code;
273
+ }
274
+ out.say(`${question}\n Changes: ${check.changelog ?? ""}`);
275
+ const ok = await (envp.confirm ?? askOnTty)(` Continue? [y/N] `);
276
+ if (!ok) {
277
+ const message = "nothing was changed.";
278
+ emit({ ...check, outcome: "declined", message });
279
+ out.say(message);
280
+ return EXIT.ok;
281
+ }
282
+ }
283
+ // ---- 8. fetch first (SPEC §5 step 8) --------------------------------
284
+ //
285
+ // Both versions, and the CURRENT one matters most: it is what the rollback
286
+ // reinstalls, and a rollback that has to reach the network to find the
287
+ // version that was working ten seconds ago is the rollback that fails.
288
+ out.warn(` Fetching ${product.packageName}@${target.version}…`);
289
+ const fetched = cacheAdd(product.packageName, target.version, spawnCtx);
290
+ if (fetched.status !== 0) {
291
+ const message = `could not fetch ${product.packageName}@${target.version}:\n` +
292
+ indent(fetched.stderr.trim() || fetched.stdout.trim());
293
+ emit({ ...check, outcome: "failed", message });
294
+ // Nothing has been stopped or changed, so this is a plain failure.
295
+ return out.error(message);
296
+ }
297
+ const cachedCurrent = cacheAdd(product.packageName, product.currentVersion, spawnCtx);
298
+ if (cachedCurrent.status !== 0) {
299
+ out.warn(` Note: ${product.packageName}@${product.currentVersion} is not in the npm cache, so a ` +
300
+ "rollback would need the network.");
301
+ }
302
+ // ---- 9. stop (SPEC §5 step 9) ---------------------------------------
303
+ if (service.installed) {
304
+ out.warn(` Stopping the ${product.name} service…`);
305
+ try {
306
+ product.service.stop();
307
+ }
308
+ catch (err) {
309
+ const message = `could not stop the service: ${err instanceof Error ? err.message : String(err)}`;
310
+ emit({ ...check, outcome: "failed", message });
311
+ return out.error(message);
312
+ }
313
+ }
314
+ else if (service.running && service.url !== undefined) {
315
+ // A foreground `serve`. We cannot stop somebody's terminal for them, and
316
+ // installing under a running server is the failure mode step 9 exists to
317
+ // avoid — so say so and stop (SPEC §5 step 9).
318
+ const verdict = await product.waitForVersion({
319
+ url: service.url,
320
+ version: product.currentVersion,
321
+ timeoutMs: 2_000,
322
+ });
323
+ if (verdict.state !== "no-answer") {
324
+ const message = `something is serving ${product.name} at ${service.url} and there is no service to stop.\n` +
325
+ " Stop it (Ctrl-C in its terminal), then run this again.";
326
+ emit({ ...check, outcome: "failed", message });
327
+ return out.error(message, EXIT.notRunning);
328
+ }
329
+ }
330
+ // ---- 10. install (SPEC §5 step 10) ----------------------------------
331
+ out.warn(` Installing ${product.packageName}@${target.version} with ${manager}…`);
332
+ const installed = installPackage({
333
+ manager,
334
+ packageName: product.packageName,
335
+ version: target.version,
336
+ ...spawnCtx,
337
+ });
338
+ if (installed.status !== 0) {
339
+ // npm has already put the old package back. Start the old one again and
340
+ // report 6 (SPEC §5 step 10).
341
+ out.warn(indent(installed.stderr.trim() || installed.stdout.trim()));
342
+ return await rollback({
343
+ why: `installing ${target.version} failed`,
344
+ check,
345
+ emit,
346
+ product,
347
+ envp,
348
+ spawnCtx,
349
+ manager,
350
+ service,
351
+ alreadyRestored: true,
352
+ });
353
+ }
354
+ // Prove the install took, by asking the CLI on disk what it is. See
355
+ // `cliVersion` for why this is not read out of a manifest.
356
+ const newCli = findCli(product, info, target.version, { ...spawnCtx, fs });
357
+ if (newCli === undefined) {
358
+ return await rollback({
359
+ why: `${target.version} was installed, but no CLI on disk reports that version`,
360
+ check,
361
+ emit,
362
+ product,
363
+ envp,
364
+ spawnCtx,
365
+ manager,
366
+ service,
367
+ });
368
+ }
369
+ // ---- Without a service: steps 1–8 and 10 only (SPEC §5) -------------
370
+ if (!service.installed) {
371
+ noteInstalled(product.home, check);
372
+ const message = `Updated ${product.name} ${arrow}.` +
373
+ (service.running ? `\n Restart ${product.name} to use ${target.version}.` : "");
374
+ emit({ ...check, outcome: service.running ? "restart-required" : "updated", message });
375
+ out.say(message);
376
+ return EXIT.ok;
377
+ }
378
+ // ---- 11. hand over to the new version (SPEC §5 step 11) -------------
379
+ out.warn(" Refreshing the service unit with the new version…");
380
+ const refreshed = product.service.refresh(newCli);
381
+ if (refreshed.status !== 0) {
382
+ out.warn(indent(refreshed.stderr.trim() || refreshed.stdout.trim()));
383
+ return await rollback({
384
+ why: `${target.version} could not refresh the service unit`,
385
+ check,
386
+ emit,
387
+ product,
388
+ envp,
389
+ spawnCtx,
390
+ manager,
391
+ service,
392
+ });
393
+ }
394
+ // ---- 12. health (SPEC §5 step 12) -----------------------------------
395
+ const url = product.service.inspect().url ?? service.url;
396
+ if (url === undefined) {
397
+ // Nothing to poll. `refresh` waited for health itself, so treat its
398
+ // success as the answer rather than inventing a URL to ask.
399
+ noteInstalled(product.home, check);
400
+ const message = `Updated ${product.name} ${arrow}.`;
401
+ emit({ ...check, outcome: "updated", message });
402
+ out.say(message);
403
+ return EXIT.ok;
404
+ }
405
+ out.warn(` Waiting for ${target.version} to answer at ${url}…`);
406
+ const verdict = await product.waitForVersion({
407
+ url,
408
+ version: target.version,
409
+ timeoutMs: product.healthTimeoutMs ?? HEALTH_TIMEOUT_MS,
410
+ });
411
+ if (verdict.state !== "ok") {
412
+ const detail = verdict.state === "wrong-version"
413
+ ? `${url} answers with ${verdict.version}, not ${target.version}`
414
+ : `${url} did not answer within ${(product.healthTimeoutMs ?? HEALTH_TIMEOUT_MS) / 1000} seconds`;
415
+ return await rollback({
416
+ why: detail,
417
+ check,
418
+ emit,
419
+ product,
420
+ envp,
421
+ spawnCtx,
422
+ manager,
423
+ service,
424
+ showLog: true,
425
+ });
426
+ }
427
+ // ---- 15. done --------------------------------------------------------
428
+ noteInstalled(product.home, check);
429
+ const message = `Updated ${product.name} ${arrow}. Running at ${url}`;
430
+ emit({ ...check, outcome: "updated", message, url });
431
+ out.say(message);
432
+ return EXIT.ok;
433
+ }
434
+ }
435
+ /**
436
+ * Rewrite the cache now that the target IS the installed version (SPEC §3).
437
+ *
438
+ * Step 4 wrote a cache saying an update was available, which was true then and
439
+ * is a lie the moment the install succeeds. `service status` reads that file
440
+ * and would go on advertising a version the machine is already running — the
441
+ * one piece of nagging nobody could act on, because the action had already been
442
+ * taken.
443
+ */
444
+ function noteInstalled(home, check) {
445
+ writeUpdateCheck(home, {
446
+ ...check,
447
+ current: check.target,
448
+ updateAvailable: false,
449
+ command: null,
450
+ checkedAt: new Date().toISOString(),
451
+ });
452
+ }
453
+ /** Ask on the terminal. Reads stdin; writes the prompt to stderr, per SPEC §3. */
454
+ async function askOnTty(question) {
455
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
456
+ try {
457
+ const answer = await new Promise((resolve) => rl.question(question, resolve));
458
+ return /^y(es)?$/i.test(answer.trim());
459
+ }
460
+ finally {
461
+ rl.close();
462
+ }
463
+ }
464
+ /**
465
+ * SPEC §5 step 13: put the old version back and say which of 6 or 7 happened.
466
+ *
467
+ * The distinction is the whole value of the code. **6** means a human has
468
+ * nothing to do: the old version is installed and answering, and the only cost
469
+ * was a minute of downtime. **7** means the machine is not serving and no
470
+ * further automation will fix it, so the output has to be the commands to run
471
+ * by hand. Collapsing the two into "the update failed" is what makes an
472
+ * operator read logs to find out whether their service is up.
473
+ */
474
+ async function rollback(o) {
475
+ const { product, envp, check } = o;
476
+ const out = envp.out;
477
+ const arrow = `${product.currentVersion} → ${check.target}`;
478
+ out.error(`${o.why}.`);
479
+ if (o.showLog === true) {
480
+ const tail = product.service.tail(LOG_LINES_ON_FAILURE);
481
+ if (tail.trim() !== "") {
482
+ out.warn(`\n The last ${LOG_LINES_ON_FAILURE} lines of ${product.service.inspect().log ?? "the log"}:`);
483
+ out.warn(indent(tail));
484
+ }
485
+ }
486
+ out.warn(`\n Rolling back to ${product.currentVersion}…`);
487
+ const back = installArgs(o.manager, `${product.packageName}@${product.currentVersion}`);
488
+ const manual = [
489
+ ` ${back.cmd} ${back.args.join(" ")}`,
490
+ ` ${product.tool} service install --refresh`,
491
+ ` ${product.tool} service status`,
492
+ ].join("\n");
493
+ if (o.alreadyRestored !== true) {
494
+ const reinstalled = installPackage({
495
+ manager: o.manager,
496
+ packageName: product.packageName,
497
+ version: product.currentVersion,
498
+ ...o.spawnCtx,
499
+ });
500
+ if (reinstalled.status !== 0) {
501
+ out.warn(indent(reinstalled.stderr.trim() || reinstalled.stdout.trim()));
502
+ const message = `${check.target} did not start, and ${product.currentVersion} could not be reinstalled.\n` +
503
+ ` Run these by hand:\n${manual}`;
504
+ o.emit({ ...check, outcome: "needs-human", message });
505
+ return out.error(message, EXIT.needsHuman);
506
+ }
507
+ }
508
+ // The unit has to be refreshed by the OLD CLI: step 11's hand-over wrote a
509
+ // unit in the NEW version's format, which the old one may not understand.
510
+ if (o.service.installed) {
511
+ const oldCli = findCli(product, undefined, product.currentVersion, {
512
+ ...o.spawnCtx,
513
+ ...(envp.fs !== undefined ? { fs: envp.fs } : {}),
514
+ });
515
+ if (oldCli === undefined) {
516
+ const message = `${check.target} did not start, and ${product.currentVersion}'s CLI could not be found ` +
517
+ `to restore the service unit.\n Run these by hand:\n${manual}`;
518
+ o.emit({ ...check, outcome: "needs-human", message });
519
+ return out.error(message, EXIT.needsHuman);
520
+ }
521
+ const restored = product.service.refresh(oldCli);
522
+ if (restored.status !== 0) {
523
+ out.warn(indent(restored.stderr.trim() || restored.stdout.trim()));
524
+ const message = `${check.target} did not start, and the service could not be restarted on ` +
525
+ `${product.currentVersion}.\n Run these by hand:\n${manual}`;
526
+ o.emit({ ...check, outcome: "needs-human", message });
527
+ return out.error(message, EXIT.needsHuman);
528
+ }
529
+ const url = product.service.inspect().url ?? o.service.url;
530
+ if (url !== undefined) {
531
+ const verdict = await product.waitForVersion({
532
+ url,
533
+ version: product.currentVersion,
534
+ timeoutMs: product.healthTimeoutMs ?? HEALTH_TIMEOUT_MS,
535
+ });
536
+ if (verdict.state !== "ok") {
537
+ const message = `${check.target} did not start, and ${product.currentVersion} is not answering at ${url} ` +
538
+ "either.\n Run these by hand:\n" +
539
+ manual;
540
+ o.emit({ ...check, outcome: "needs-human", message });
541
+ return out.error(message, EXIT.needsHuman);
542
+ }
543
+ }
544
+ }
545
+ const message = `${check.target} didn't start; ${product.currentVersion} is running again.`;
546
+ o.emit({ ...check, outcome: "rolled-back", message, ...(arrow !== "" ? {} : {}) });
547
+ return out.error(message, EXIT.rolledBack);
548
+ }
549
+ /**
550
+ * Find the CLI on disk that reports `wanted`, trying the places it can be.
551
+ *
552
+ * Three candidates, in the order that copes with the most layouts:
553
+ *
554
+ * 1. **`realpath(argv[1])`, resolved NOW.** The bin shim is a symlink, and a
555
+ * package manager that puts the version in the package's real path (pnpm's
556
+ * store) repoints that symlink on install. Re-resolving it after the install
557
+ * is the only candidate that follows.
558
+ * 2. `<npm root -g>/<name>/<binSubpath>` — the npm-global layout, asked of npm
559
+ * rather than assumed.
560
+ * 3. The package root §6 found, plus the same subpath — right for every layout
561
+ * whose path does not move.
562
+ *
563
+ * Each is run (`node <cli> --version`) and the first that reports `wanted`
564
+ * wins. That is deliberately stricter than "the file exists": the thing this
565
+ * guards against is handing `service install --refresh` to a CLI that is still
566
+ * the old version, which would produce a green update that changed nothing.
567
+ */
568
+ export function findCli(product, info, wanted, ctx) {
569
+ const fs = ctx.fs ?? fsDefault;
570
+ const candidates = [];
571
+ try {
572
+ candidates.push(fs.realpathSync(product.entry));
573
+ }
574
+ catch {
575
+ candidates.push(product.entry);
576
+ }
577
+ const globalRoot = npmGlobalRoot(ctx);
578
+ if (globalRoot !== undefined) {
579
+ candidates.push(path.join(globalRoot, product.packageName, product.binSubpath));
580
+ }
581
+ if (info?.packageRoot !== undefined) {
582
+ candidates.push(path.join(info.packageRoot, product.binSubpath));
583
+ }
584
+ const seen = new Set();
585
+ for (const candidate of candidates) {
586
+ if (seen.has(candidate))
587
+ continue;
588
+ seen.add(candidate);
589
+ if (!fs.existsSync(candidate))
590
+ continue;
591
+ if (cliVersion(candidate, ctx) === wanted)
592
+ return candidate;
593
+ }
594
+ return undefined;
595
+ }
596
+ /** The command SPEC §3's notice prints for this install, with the product's own details filled in. */
597
+ function commandFor(product, info, target) {
598
+ return updateCommand(info, {
599
+ packageName: product.packageName,
600
+ target,
601
+ ...(product.image !== undefined ? { image: product.image } : {}),
602
+ });
603
+ }
604
+ function indent(text) {
605
+ return text
606
+ .split("\n")
607
+ .map((l) => (l === "" ? l : ` ${l}`))
608
+ .join("\n");
609
+ }
@@ -0,0 +1,20 @@
1
+ import { type CliOptions } from "./args.js";
2
+ import { type ServiceContext } from "./service/index.js";
3
+ /** The published package. Used for the registry, the install detection and the install command. */
4
+ export declare const PADDOCK_PACKAGE = "@edspencer/paddock";
5
+ /** Where the CLI sits inside that package. See the layout note in make-npm-package.mjs. */
6
+ export declare const PADDOCK_BIN_SUBPATH: string;
7
+ /** The image an `update` inside a container tells you to pull (SPEC §6, §9). */
8
+ export declare const PADDOCK_IMAGE = "ghcr.io/edspencer/paddock";
9
+ /** Release notes for a version — the `Changes:` line of SPEC §3's notice. */
10
+ export declare function paddockChangelogUrl(version: string): string;
11
+ /**
12
+ * `paddock update [--check] [--to <version|tag>] [--yes] [--force] [--json]`
13
+ * (SPEC §1, §5). `upgrade` reaches the same place.
14
+ *
15
+ * `ctx` is the same {@link ServiceContext} `paddock service` is handed, so the
16
+ * two agree about which Node, which script and which unit — an `update` that
17
+ * refreshed a different unit from the one `status` reports would be the worst
18
+ * possible kind of nearly-working.
19
+ */
20
+ export declare function runUpdateCommand(opts: CliOptions, ctx: ServiceContext): Promise<number>;