@edspencer/paddock 0.82.0 → 0.82.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/cli/args.js +2 -2
  3. package/packages/server/dist/cli/busy-command.js +1 -1
  4. package/packages/server/dist/cli/busy.js +10 -12
  5. package/packages/server/dist/cli/exit-codes.d.ts +21 -45
  6. package/packages/server/dist/cli/exit-codes.js +42 -45
  7. package/packages/server/dist/cli/output.js +5 -6
  8. package/packages/server/dist/cli/paddock.js +9 -9
  9. package/packages/server/dist/cli/service/index.d.ts +2 -2
  10. package/packages/server/dist/cli/service/index.js +30 -24
  11. package/packages/server/dist/cli/service/launchd.d.ts +31 -84
  12. package/packages/server/dist/cli/service/launchd.js +104 -304
  13. package/packages/server/dist/cli/service/logs.d.ts +9 -28
  14. package/packages/server/dist/cli/service/logs.js +33 -75
  15. package/packages/server/dist/cli/service/manager.d.ts +93 -0
  16. package/packages/server/dist/cli/service/manager.js +21 -0
  17. package/packages/server/dist/cli/service/path-env.d.ts +7 -18
  18. package/packages/server/dist/cli/service/path-env.js +30 -81
  19. package/packages/server/dist/cli/service/spec.d.ts +4 -31
  20. package/packages/server/dist/cli/service/spec.js +9 -0
  21. package/packages/server/dist/cli/service/systemd.d.ts +27 -101
  22. package/packages/server/dist/cli/service/systemd.js +106 -262
  23. package/packages/server/dist/cli/update/health.d.ts +48 -0
  24. package/packages/server/dist/cli/update/health.js +64 -0
  25. package/packages/server/dist/cli/update/install-method.d.ts +29 -76
  26. package/packages/server/dist/cli/update/install-method.js +103 -178
  27. package/packages/server/dist/cli/update/lock.d.ts +7 -12
  28. package/packages/server/dist/cli/update/lock.js +28 -62
  29. package/packages/server/dist/cli/update/package-manager.d.ts +39 -74
  30. package/packages/server/dist/cli/update/package-manager.js +95 -160
  31. package/packages/server/dist/cli/update/registry.d.ts +17 -54
  32. package/packages/server/dist/cli/update/registry.js +46 -89
  33. package/packages/server/dist/cli/update/semver.d.ts +18 -41
  34. package/packages/server/dist/cli/update/semver.js +74 -124
  35. package/packages/server/dist/cli/update/update-check.d.ts +15 -26
  36. package/packages/server/dist/cli/update/update-check.js +30 -60
  37. package/packages/server/dist/cli/update/update.d.ts +57 -91
  38. package/packages/server/dist/cli/update/update.js +230 -431
  39. package/packages/server/dist/cli/update-command.js +3 -5
  40. package/packages/server/package.json +1 -1
  41. package/packages/web/dist/assets/{DeleteProjectDialog-CphdGB3R.js → DeleteProjectDialog-CgHpQctt.js} +1 -1
  42. package/packages/web/dist/assets/DiscoverPage-CG6cf-0i.js +2 -0
  43. package/packages/web/dist/assets/{DiscoverView-B_kcF4sm.js → DiscoverView-BKqI0I1E.js} +1 -1
  44. package/packages/web/dist/assets/{InstanceConfigPage-CvUK5DKW.js → InstanceConfigPage-DL3raCK1.js} +1 -1
  45. package/packages/web/dist/assets/{MarkdownRenderer-DvZ-j_Ao.js → MarkdownRenderer-DcjEuf3s.js} +1 -1
  46. package/packages/web/dist/assets/{ProjectRedirect-DayHvUH2.js → ProjectRedirect-DerkA-nE.js} +1 -1
  47. package/packages/web/dist/assets/{ProjectView-CCw83gUi.js → ProjectView-NwfC3_w7.js} +3 -3
  48. package/packages/web/dist/assets/{ProjectsGrid-B3Klg2H4.js → ProjectsGrid-BeORa0UH.js} +1 -1
  49. package/packages/web/dist/assets/{RootHome-BxcDvpgI.js → RootHome-BYJs8qJ6.js} +1 -1
  50. package/packages/web/dist/assets/{Surfaces-7UlhFBSG.js → Surfaces-w-kEaAYU.js} +1 -1
  51. package/packages/web/dist/assets/{arc-DNkh5o5Q.js → arc-Bv56hasM.js} +1 -1
  52. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-BrgN3oLF.js → architectureDiagram-3BPJPVTR-BHEknx9i.js} +1 -1
  53. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-D05XOJSk.js → blockDiagram-GPEHLZMM-DtxTMrPX.js} +1 -1
  54. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-CKaMBhMP.js → c4Diagram-AAUBKEIU-DRpEYOlM.js} +1 -1
  55. package/packages/web/dist/assets/channel-CdIiAqY2.js +2 -0
  56. package/packages/web/dist/assets/{chunk-2J33WTMH-8hgadCQc.js → chunk-2J33WTMH-S4s7GCg7.js} +1 -1
  57. package/packages/web/dist/assets/{chunk-4BX2VUAB-CBfEC-pu.js → chunk-4BX2VUAB-BkWVPLf1.js} +1 -1
  58. package/packages/web/dist/assets/{chunk-55IACEB6-DV1eMgHk.js → chunk-55IACEB6-4-FQp01F.js} +1 -1
  59. package/packages/web/dist/assets/{chunk-727SXJPM-Cfgl5fxY.js → chunk-727SXJPM-BeyDWdIb.js} +1 -1
  60. package/packages/web/dist/assets/{chunk-AQP2D5EJ-BXhqD295.js → chunk-AQP2D5EJ-ZSxKy745.js} +1 -1
  61. package/packages/web/dist/assets/{chunk-FMBD7UC4-TFGOPtek.js → chunk-FMBD7UC4-Bq8uV9j-.js} +1 -1
  62. package/packages/web/dist/assets/{chunk-ND2GUHAM-CHFM665R.js → chunk-ND2GUHAM-BOFsRyHk.js} +1 -1
  63. package/packages/web/dist/assets/{chunk-QZHKN3VN-CqEf6MMT.js → chunk-QZHKN3VN-uZ6oNc4m.js} +1 -1
  64. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DCCUHba0.js +2 -0
  65. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DCCUHba0.js +2 -0
  66. package/packages/web/dist/assets/{core-DmEaKxBb.js → core-CYuJAM2U.js} +1 -1
  67. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BnGI6jkr.js → cose-bilkent-S5V4N54A-DIzkVSFb.js} +1 -1
  68. package/packages/web/dist/assets/{dagre-BM42HDAG-CKoPx61E.js → dagre-BM42HDAG-CHR6jJBv.js} +1 -1
  69. package/packages/web/dist/assets/{diagram-2AECGRRQ-mMmtlBl1.js → diagram-2AECGRRQ-BAFXCH8Q.js} +1 -1
  70. package/packages/web/dist/assets/{diagram-5GNKFQAL-Cdz9Mtnt.js → diagram-5GNKFQAL-BJ5t5gbz.js} +1 -1
  71. package/packages/web/dist/assets/{diagram-KO2AKTUF-DQxM3wYj.js → diagram-KO2AKTUF-D9Xt0IVg.js} +1 -1
  72. package/packages/web/dist/assets/{diagram-LMA3HP47-BWrinPsq.js → diagram-LMA3HP47-Bq38u6sy.js} +1 -1
  73. package/packages/web/dist/assets/{diagram-OG6HWLK6-U6MYUve2.js → diagram-OG6HWLK6-CMQVqkpi.js} +1 -1
  74. package/packages/web/dist/assets/{documentTitle-DGjRXuTP.js → documentTitle-M1lrfXRU.js} +1 -1
  75. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-xqMCsXxY.js → erDiagram-TEJ5UH35-C63M1g9b.js} +1 -1
  76. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-PMfy_czF.js → flowDiagram-I6XJVG4X-D3j8ACN7.js} +1 -1
  77. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-whhYYE4N.js → ganttDiagram-6RSMTGT7-B1Fd1A2Y.js} +1 -1
  78. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-C51JW4tf.js → gitGraphDiagram-PVQCEYII-CEhZhDdR.js} +1 -1
  79. package/packages/web/dist/assets/{index-CHOtvlnO.js → index-1E9yHciS.js} +2 -2
  80. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-SH8cr905.js → infoDiagram-5YYISTIA-Ctquaew6.js} +1 -1
  81. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-BjqaoU3U.js → ishikawaDiagram-YF4QCWOH-DV4GvA1R.js} +1 -1
  82. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-CBmdEVrd.js → journeyDiagram-JHISSGLW-C_Cy9waA.js} +1 -1
  83. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-U8njjMLZ.js → kanban-definition-UN3LZRKU-CGr9ctEf.js} +1 -1
  84. package/packages/web/dist/assets/{linear-DF66GPs1.js → linear-D3U8NOkt.js} +1 -1
  85. package/packages/web/dist/assets/{mermaid.core-CouJz7Ph.js → mermaid.core-BiArdWKV.js} +4 -4
  86. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-BYIf0oFt.js → mindmap-definition-RKZ34NQL-CZIpzj8Z.js} +1 -1
  87. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-Cm8rgg09.js → pieDiagram-4H26LBE5-1MaeS3rt.js} +1 -1
  88. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-C3Il5kOH.js → quadrantDiagram-W4KKPZXB-B5pgQ54e.js} +1 -1
  89. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-D3PE2nTe.js → requirementDiagram-4Y6WPE33-BNnxrpGr.js} +1 -1
  90. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-DdmvBM18.js → sankeyDiagram-5OEKKPKP-DsxVH-hM.js} +1 -1
  91. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-B8M_GAIq.js → sequenceDiagram-3UESZ5HK-Ke9LcFZI.js} +1 -1
  92. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-DZkXnK1_.js → stateDiagram-AJRCARHV-UJzmflND.js} +1 -1
  93. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-DgUc3HkP.js +2 -0
  94. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-IHXq7rqi.js → timeline-definition-PNZ67QCA-BRjNALVI.js} +1 -1
  95. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-Dga7tLtT.js → vennDiagram-CIIHVFJN-ClMAspxz.js} +1 -1
  96. package/packages/web/dist/assets/{wardley-L42UT6IY-CmcwnEJ-.js → wardley-L42UT6IY-UPv4JEmz.js} +1 -1
  97. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-zHuSLrD4.js → wardleyDiagram-YWT4CUSO-BgYJbdEx.js} +1 -1
  98. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-D93MU_ic.js → xychartDiagram-2RQKCTM6-Bfk5HjGx.js} +1 -1
  99. package/packages/web/dist/index.html +1 -1
  100. package/packages/web/dist/sw.js +1 -1
  101. package/packages/server/dist/cli/service/backend.d.ts +0 -115
  102. package/packages/server/dist/cli/service/backend.js +0 -19
  103. package/packages/web/dist/assets/DiscoverPage-C4OPLNO8.js +0 -2
  104. package/packages/web/dist/assets/channel-JrEedxB8.js +0 -2
  105. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CHJiQp-W.js +0 -2
  106. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CHJiQp-W.js +0 -2
  107. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CjCdT9Ls.js +0 -2
@@ -1,86 +1,78 @@
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";
1
+ // From edspencer/cli-contract reference/update.ts @ 0.2; local changes: `.ts` import specifiers → `.js`, and `exit-codes`, `output` and `runtime-file` imported from where Paddock keeps them (`../`, `../../`).
2
+ // `<tool> update`: SPEC §5, step by step, for every tool. Node built-ins only; nothing here imports a server.
3
+ //
4
+ // It's a sequence over injected hooks. A tool supplies what's its own (`UpdateProduct`): its names, its busy check,
5
+ // and its service (inspect, stop, refresh, tail). Everything that touches the machine comes in through
6
+ // `UpdateEnvironment`, so every branch, including the rollbacks to 6 and 7, can be tested without one.
7
+ //
8
+ // 1. the lock, so two updates can't interleave;
9
+ // 2. the install method (§6), so one that can't work says so first (but after step 4, since its notice names the
10
+ // target);
11
+ // 3. the registry, for the target and its `engines`;
12
+ // 4. the comparison, and `--check` stops here;
13
+ // 5. the writability check, which turns "EACCES half-way through" into a `sudo` line while everything still runs;
14
+ // 6. the busy check, before anything stops;
15
+ // 7. the question, which `--yes` answers and no TTY forbids;
16
+ // 8. `npm cache add` for both versions, so a network failure happens while the old version still serves;
17
+ // 9. stop, before installing: npm moves the old package aside before unpacking, and a running server would then
18
+ // load files that are gone;
19
+ // 10. install, with the manager this install came from;
20
+ // 11. hand over to the new CLI (`service install --refresh`), which writes the unit in its own format;
21
+ // 12. wait until the target version answers, not merely until something does;
22
+ // 13. and from step 10 on, roll back on any failure, and exit 6 or 7.
23
+ import fsDefault from 'node:fs';
24
+ import path from 'node:path';
25
+ import { createInterface } from 'node:readline';
26
+ import { EXIT } from '../exit-codes.js';
27
+ import { fetchHealth, waitForVersion as pollVersion } from './health.js';
28
+ import { describeInstall, detectInstall, updateCommand } from './install-method.js';
29
+ import { acquireLock, LockHeldError } from './lock.js';
30
+ import { cacheAdd, cliVersion, globalRootFor, installArgs, installPackage, isWritable, npmConfigGet, npmGlobalRoot, } from './package-manager.js';
31
+ import { RegistryError, resolveRegistry, resolveTarget } from './registry.js';
32
+ import { compareVersions, isPrerelease, satisfiesEngines } from './semver.js';
33
+ import { updateNotice, writeUpdateCheck } from './update-check.js';
48
34
  /** SPEC §5 step 12: 30 seconds unless the tool sets its own. */
49
35
  export const HEALTH_TIMEOUT_MS = 30_000;
36
+ /** How long to wait for the old version to stop answering after the stop (step 9). */
37
+ export const STOP_TIMEOUT_MS = 15_000;
50
38
  /** How many log lines a failure prints (SPEC §5 step 13). */
51
39
  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
- */
40
+ const indent = (text) => text
41
+ .split('\n')
42
+ .map((l) => (l === '' ? l : ` ${l}`))
43
+ .join('\n');
44
+ const said = (r) => indent(r.stderr.trim() || r.stdout.trim());
45
+ /** Runs `update` and returns the exit code; the caller sets `process.exitCode`. */
56
46
  export async function runUpdate(product, opts, envp) {
57
47
  const out = envp.out;
58
48
  const env = envp.env ?? process.env;
59
49
  const fs = envp.fs ?? fsDefault;
50
+ const npm = {
51
+ env,
52
+ ...(envp.spawn !== undefined ? { spawn: envp.spawn } : {}),
53
+ ...(envp.execPath !== undefined ? { execPath: envp.execPath } : {}),
54
+ ...(envp.platform !== undefined ? { platform: envp.platform } : {}),
55
+ };
60
56
  let emitted = false;
61
- /** `out.result` promises exactly one document, so guard it here rather than at ten call sites. */
62
57
  const emit = (doc) => {
63
58
  if (emitted)
64
59
  return;
65
60
  emitted = true;
66
61
  out.result(doc);
67
62
  };
68
- // ---- 1. the lock -------------------------------------------------------
63
+ const healthTimeoutMs = product.healthTimeoutMs ?? HEALTH_TIMEOUT_MS;
64
+ const waitFor = (url, version, timeoutMs = healthTimeoutMs) => product.waitForVersion
65
+ ? product.waitForVersion({ url, version, timeoutMs })
66
+ : pollVersion({ url, version, timeoutMs, ...(envp.fetch !== undefined ? { fetch: envp.fetch } : {}) });
67
+ // ---- 1. the lock
69
68
  let lock;
70
69
  try {
71
- lock = acquireLock({
72
- home: product.home,
73
- command: `${product.tool} update${opts.to !== undefined ? ` --to ${opts.to}` : ""}`,
74
- });
70
+ lock = acquireLock({ home: product.home, command: `${product.tool} update${opts.to !== undefined ? ` --to ${opts.to}` : ''}` });
75
71
  }
76
72
  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}`);
73
+ const message = err instanceof LockHeldError ? err.message : `couldn't take the update lock: ${err.message}`;
74
+ emit({ outcome: 'failed', message });
75
+ return out.error(message);
84
76
  }
85
77
  try {
86
78
  return await sequence();
@@ -89,481 +81,310 @@ export async function runUpdate(product, opts, envp) {
89
81
  lock.release();
90
82
  }
91
83
  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.
84
+ // ---- 2. how was it installed? Acted on after step 4, which has the target its notice names.
105
85
  const info = detectInstall({
106
86
  packageName: product.packageName,
107
87
  envPrefix: product.envPrefix,
108
88
  entry: product.entry,
109
89
  env,
110
- npmGlobalRoot: () => npmGlobalRoot(spawnCtx),
90
+ npmGlobalRoot: () => npmGlobalRoot(npm),
111
91
  fs,
112
92
  });
113
- // ---- 3. the registry -------------------------------------------------
114
- const registry = resolveRegistry(env, (key) => npmConfigGet(key, spawnCtx));
93
+ // ---- 3. the registry
115
94
  let target;
116
95
  try {
117
96
  target = await resolveTarget({
118
97
  packageName: product.packageName,
119
- registry,
98
+ registry: resolveRegistry(env, (key) => npmConfigGet(key, npm)),
120
99
  ...(opts.to !== undefined ? { to: opts.to } : {}),
121
100
  ...(envp.fetch !== undefined ? { fetch: envp.fetch } : {}),
122
101
  });
123
102
  }
124
103
  catch (err) {
125
- const message = err instanceof RegistryError ? `${err.message}\n ${err.url}` : String(err);
126
- emit({ outcome: "failed", message });
104
+ const message = err instanceof RegistryError ? `${err.message}\n ${err.url}` : String(err);
105
+ emit({ outcome: 'failed', message });
127
106
  return out.error(message);
128
107
  }
129
- // ---- 4. compare ------------------------------------------------------
108
+ // ---- 4. compare
130
109
  const cmp = compareVersions(target.version, product.currentVersion);
110
+ // A prerelease counts only when `--to` names it. `latest` pointing at one is a mis-tagged release.
111
+ const misTagged = opts.to === undefined && isPrerelease(target.version);
131
112
  const nodeVersion = envp.nodeVersion ?? process.versions.node;
132
- const engines = satisfiesEngines(nodeVersion, {
133
- ...(target.engines !== null ? { node: target.engines } : {}),
113
+ const engines = satisfiesEngines(nodeVersion, target.engines !== null ? { node: target.engines } : undefined);
114
+ const command = updateCommand(info, {
115
+ packageName: product.packageName,
116
+ target: target.version,
117
+ ...(product.image !== undefined ? { image: product.image } : {}),
134
118
  });
135
- const command = commandFor(product, info, target.version);
136
119
  const check = {
137
120
  name: product.packageName,
138
121
  current: product.currentVersion,
139
122
  target: target.version,
140
123
  tag: target.tag,
141
- updateAvailable: cmp > 0,
124
+ updateAvailable: cmp > 0 && !misTagged,
142
125
  installMethod: info.method,
143
126
  canUpdate: info.canUpdate,
144
127
  command: info.canUpdate ? `${product.tool} update` : command.command,
145
128
  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.
129
+ // A range that couldn't be read is a pass (semver.ts), and the field is a boolean.
149
130
  nodeOk: engines.ok !== false,
150
131
  changelog: product.changelogUrl(target.version),
151
132
  checkedAt: new Date().toISOString(),
152
133
  };
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.
134
+ // Written whatever happens next: "you're up to date" is the answer that stops `status` repeating an old notice.
156
135
  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 });
136
+ const notice = (cmd, note) => updateNotice({
137
+ name: product.name,
138
+ current: product.currentVersion,
139
+ target: target.version,
140
+ command: cmd,
141
+ ...(note !== undefined ? { note } : {}),
142
+ changelog: check.changelog,
143
+ });
144
+ if (misTagged) {
145
+ const message = `The registry's latest is a prerelease (${target.version}), so it isn't installed.\n To go there: ${product.tool} update --to ${target.version}`;
146
+ emit({ ...check, outcome: 'ahead', message });
164
147
  out.say(message);
165
- return EXIT.ok;
148
+ return EXIT.OK;
166
149
  }
167
150
  if (cmp === 0) {
168
151
  const message = `${product.name} ${product.currentVersion} is up to date.`;
169
- emit({ ...check, outcome: "up-to-date", message });
152
+ emit({ ...check, outcome: 'up-to-date', message });
170
153
  out.say(message);
171
- return EXIT.ok;
154
+ return EXIT.OK;
172
155
  }
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
156
  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 });
157
+ const message = `${product.name} ${product.currentVersion} is newer than the registry's ${target.tag ?? 'latest'} (${target.version}). Nothing to do.\n` +
158
+ ` To go back to it: ${product.tool} update --to ${target.version}`;
159
+ emit({ ...check, outcome: 'ahead', message });
181
160
  out.say(message);
182
- return EXIT.ok;
161
+ return EXIT.OK;
183
162
  }
184
- const direction = cmp < 0 ? "Downgrade" : "Update";
185
- const arrow = `${product.currentVersion} → ${target.version}`;
186
163
  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;
164
+ // Exit 0 whether or not there's an update (SPEC §2); scripts read `updateAvailable`. The notice goes to stderr.
165
+ emit({ ...check, outcome: 'available' });
166
+ out.warn(notice(check.command ?? `${product.tool} update`, info.canUpdate ? undefined : command.note));
167
+ if (engines.ok === false)
168
+ out.warn(` Note: ${target.version} needs Node ${target.engines}; this is Node ${nodeVersion}.`);
169
+ return EXIT.OK;
202
170
  }
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
171
  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);
172
+ out.warn(notice(command.command, command.note));
173
+ const message = `this install can't update itself: it's ${describeInstall(info, product.packageName)}.`;
174
+ emit({ ...check, outcome: 'cannot-self-update', message });
175
+ return out.error(message, EXIT.CANNOT_UPDATE);
217
176
  }
218
- const manager = info.manager ?? "npm";
177
+ const manager = info.manager ?? 'npm';
219
178
  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 });
179
+ const message = `${target.version} needs Node ${target.engines}, and this is Node ${nodeVersion}. Upgrade Node, then run \`${product.tool} update\` again.`;
180
+ emit({ ...check, outcome: 'node-too-old', message });
223
181
  return out.error(message);
224
182
  }
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);
183
+ // ---- 5. can we write there? Never run sudo.
184
+ const globalRoot = globalRootFor(manager, npm);
185
+ if (globalRoot !== undefined && !(envp.writable ?? isWritable)(globalRoot)) {
186
+ const sudo = updateCommand(info, { packageName: product.packageName, target: target.version, sudo: true });
187
+ out.warn(notice(sudo.command, sudo.note));
188
+ const message = `${globalRoot} isn't writable by this user, so the install would fail half-way.`;
189
+ emit({ ...check, outcome: 'not-writable', message, command: sudo.command });
190
+ return out.error(message, EXIT.CANNOT_UPDATE);
246
191
  }
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.
192
+ // ---- 6. busy? Before anything stops, and before the question.
252
193
  const refused = await product.guardBusy({ force: opts.force === true, home: product.home });
253
194
  if (refused !== null) {
254
- emit({ ...check, outcome: "busy" });
195
+ emit({ ...check, outcome: 'busy' });
255
196
  return refused;
256
197
  }
257
198
  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." : "");
199
+ const arrow = `${product.currentVersion} → ${target.version}`;
200
+ // ---- 7. ask
261
201
  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);
202
+ if (!(envp.isTTY ?? (process.stdin.isTTY === true && process.stderr.isTTY === true))) {
203
+ const message = `there's no terminal to ask, so --yes is needed: ${product.tool} update --yes`;
204
+ emit({ ...check, outcome: 'needs-yes', message });
205
+ const code = out.error(message, EXIT.USAGE);
270
206
  if (product.usage !== undefined)
271
- out.warn(`\n${product.usage}`);
207
+ out.warn(`\n${product.usage.trimEnd()}`);
272
208
  return code;
273
209
  }
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;
210
+ const question = `${cmp < 0 ? 'Downgrade' : 'Update'} ${product.name} ${arrow}?${service.installed ? ' The service will restart.' : ''}`;
211
+ out.warn(`${question}\n Changes: ${check.changelog ?? ''}`);
212
+ if (!(await (envp.confirm ?? askOnTty)(' Continue? [y/N] '))) {
213
+ emit({ ...check, outcome: 'declined', message: 'Nothing was changed.' });
214
+ out.say('Nothing was changed.');
215
+ return EXIT.OK;
281
216
  }
282
217
  }
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.
218
+ // ---- 8. fetch first, both versions: the current one is what a rollback reinstalls.
288
219
  out.warn(` Fetching ${product.packageName}@${target.version}…`);
289
- const fetched = cacheAdd(product.packageName, target.version, spawnCtx);
220
+ const fetched = cacheAdd(product.packageName, target.version, npm);
290
221
  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.
222
+ const message = `couldn't fetch ${product.packageName}@${target.version}:\n${said(fetched)}`;
223
+ emit({ ...check, outcome: 'failed', message });
295
224
  return out.error(message);
296
225
  }
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.");
226
+ if (cacheAdd(product.packageName, product.currentVersion, npm).status !== 0) {
227
+ out.warn(` Note: ${product.packageName}@${product.currentVersion} isn't in npm's cache, so a rollback would need the network.`);
301
228
  }
302
- // ---- 9. stop (SPEC §5 step 9) ---------------------------------------
229
+ // ---- 9. stop
303
230
  if (service.installed) {
304
231
  out.warn(` Stopping the ${product.name} service…`);
305
232
  try {
306
233
  product.service.stop();
307
234
  }
308
235
  catch (err) {
309
- const message = `could not stop the service: ${err instanceof Error ? err.message : String(err)}`;
310
- emit({ ...check, outcome: "failed", message });
236
+ const message = `couldn't stop the service: ${err.message}`;
237
+ emit({ ...check, outcome: 'failed', message });
311
238
  return out.error(message);
312
239
  }
240
+ if (service.url !== undefined && !(await stopped(service.url))) {
241
+ out.warn(` Note: ${service.url} still answered ${STOP_TIMEOUT_MS / 1000} seconds after the stop.`);
242
+ }
313
243
  }
314
244
  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);
245
+ // A foreground `serve`: we can't stop someone's terminal, and installing under it is what step 9 is for.
246
+ if ((await fetchHealth({ url: service.url, ...(envp.fetch !== undefined ? { fetch: envp.fetch } : {}) })) !== undefined) {
247
+ const message = `${product.name} is running at ${service.url}, and there's no service to stop. Stop it (Ctrl-C in its terminal), then run this again.`;
248
+ emit({ ...check, outcome: 'failed', message });
249
+ return out.error(message, EXIT.NOT_RUNNING);
328
250
  }
329
251
  }
330
- // ---- 10. install (SPEC §5 step 10) ----------------------------------
252
+ // ---- 10. install. If npm fails, it has already put the old package back.
331
253
  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
- });
254
+ const installed = installPackage({ ...npm, manager, packageName: product.packageName, version: target.version });
255
+ const back = { check, emit, product, envp, npm, manager, service, waitFor };
338
256
  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
- });
257
+ out.warn(said(installed));
258
+ return rollback({ ...back, why: `installing ${target.version} failed`, alreadyRestored: true });
368
259
  }
369
- // ---- Without a service: steps 1–8 and 10 only (SPEC §5) -------------
260
+ const newCli = findCli(product, info, target.version, { ...npm, fs });
261
+ if (newCli === undefined)
262
+ return rollback({ ...back, why: `${target.version} was installed, but no CLI on disk reports that version` });
263
+ // Without a service: steps 1 to 8 and 10 (SPEC §5).
370
264
  if (!service.installed) {
371
265
  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;
266
+ const restart = service.running ? `\n Restart ${product.name} to use ${target.version}.` : '';
267
+ emit({ ...check, outcome: service.running ? 'restart-required' : 'updated', message: `Updated ${product.name} ${arrow}.${restart}` });
268
+ out.say(`Updated ${product.name} ${arrow}.${restart}`);
269
+ return EXIT.OK;
377
270
  }
378
- // ---- 11. hand over to the new version (SPEC §5 step 11) -------------
379
- out.warn(" Refreshing the service unit with the new version…");
271
+ // ---- 11. hand over to the new version
272
+ out.warn(' Refreshing the service unit with the new version…');
380
273
  const refreshed = product.service.refresh(newCli);
381
274
  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
- });
275
+ out.warn(said(refreshed));
276
+ return rollback({ ...back, why: `${target.version} couldn't refresh the service unit` });
393
277
  }
394
- // ---- 12. health (SPEC §5 step 12) -----------------------------------
278
+ // ---- 12. health
395
279
  const url = product.service.inspect().url ?? service.url;
396
280
  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.
281
+ // Nothing to poll; the refresh waited for health itself.
399
282
  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;
283
+ emit({ ...check, outcome: 'updated', message: `Updated ${product.name} ${arrow}.` });
284
+ out.say(`Updated ${product.name} ${arrow}.`);
285
+ return EXIT.OK;
404
286
  }
405
287
  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"
288
+ const verdict = await waitFor(url, target.version);
289
+ if (verdict.state !== 'ok') {
290
+ const why = verdict.state === 'wrong-version'
413
291
  ? `${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
- });
292
+ : `${url} didn't answer within ${healthTimeoutMs / 1000} seconds`;
293
+ return rollback({ ...back, why, showLog: true });
426
294
  }
427
- // ---- 15. done --------------------------------------------------------
295
+ // ---- 15. done
428
296
  noteInstalled(product.home, check);
429
297
  const message = `Updated ${product.name} ${arrow}. Running at ${url}`;
430
- emit({ ...check, outcome: "updated", message, url });
298
+ emit({ ...check, outcome: 'updated', message, url });
431
299
  out.say(message);
432
- return EXIT.ok;
300
+ return EXIT.OK;
301
+ }
302
+ /** Polls until the stopped service's URL stops answering (step 9: wait until the port is free). */
303
+ async function stopped(url) {
304
+ const sleep = envp.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
305
+ for (let i = 0; i < STOP_TIMEOUT_MS / 250; i++) {
306
+ if ((await fetchHealth({ url, timeoutMs: 1000, ...(envp.fetch !== undefined ? { fetch: envp.fetch } : {}) })) === undefined)
307
+ return true;
308
+ await sleep(250);
309
+ }
310
+ return false;
433
311
  }
434
312
  }
435
313
  /**
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.
314
+ * After a successful update the target is the installed version, so the cache step 4 wrote ("an update is
315
+ * available") is rewritten; otherwise `status` goes on advertising the version the machine is already running.
443
316
  */
444
317
  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
- });
318
+ writeUpdateCheck(home, { ...check, current: check.target, updateAvailable: false, command: null, checkedAt: new Date().toISOString() });
452
319
  }
453
- /** Ask on the terminal. Reads stdin; writes the prompt to stderr, per SPEC §3. */
320
+ /** Asks on the terminal: the question on stderr (SPEC §3), the answer from stdin. */
454
321
  async function askOnTty(question) {
455
322
  const rl = createInterface({ input: process.stdin, output: process.stderr });
456
323
  try {
457
- const answer = await new Promise((resolve) => rl.question(question, resolve));
458
- return /^y(es)?$/i.test(answer.trim());
324
+ return /^y(es)?$/i.test((await new Promise((resolve) => rl.question(question, resolve))).trim());
459
325
  }
460
326
  finally {
461
327
  rl.close();
462
328
  }
463
329
  }
464
330
  /**
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.
331
+ * SPEC §5 step 13: put the old version back, and say which of 6 or 7 happened. 6 means nobody has to do anything:
332
+ * the old version is installed and answering. 7 means the machine isn't serving and nothing automatic will fix it, so
333
+ * the output is the commands to run by hand.
473
334
  */
474
335
  async function rollback(o) {
475
- const { product, envp, check } = o;
476
- const out = envp.out;
477
- const arrow = `${product.currentVersion} → ${check.target}`;
336
+ const { product, check } = o;
337
+ const out = o.envp.out;
338
+ const current = product.currentVersion;
478
339
  out.error(`${o.why}.`);
479
340
  if (o.showLog === true) {
480
341
  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
- }
342
+ if (tail.trim() !== '')
343
+ out.warn(`\n The last ${LOG_LINES_ON_FAILURE} lines of ${product.service.inspect().log ?? 'the log'}:\n${indent(tail)}`);
485
344
  }
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");
345
+ out.warn(`\n Rolling back to ${current}…`);
346
+ const reinstall = installArgs(o.manager, `${product.packageName}@${current}`);
347
+ const byHand = [` ${reinstall.cmd} ${reinstall.args.join(' ')}`, ` ${product.tool} service install --refresh`, ` ${product.tool} service status`].join('\n');
348
+ const needsHuman = (what) => {
349
+ const message = `${check.target} didn't start, and ${what}. Run these by hand:\n${byHand}`;
350
+ o.emit({ ...check, outcome: 'needs-human', message });
351
+ return out.error(message, EXIT.ROLLBACK_FAILED);
352
+ };
493
353
  if (o.alreadyRestored !== true) {
494
- const reinstalled = installPackage({
495
- manager: o.manager,
496
- packageName: product.packageName,
497
- version: product.currentVersion,
498
- ...o.spawnCtx,
499
- });
354
+ const reinstalled = installPackage({ ...o.npm, manager: o.manager, packageName: product.packageName, version: current });
500
355
  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);
356
+ out.warn(said(reinstalled));
357
+ return needsHuman(`${current} couldn't be reinstalled`);
506
358
  }
507
359
  }
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.
360
+ // The unit is refreshed by the old CLI: the new one may have written a format the old one doesn't read.
510
361
  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
- }
362
+ const oldCli = findCli(product, undefined, current, { ...o.npm, ...(o.envp.fs !== undefined ? { fs: o.envp.fs } : {}) });
363
+ if (oldCli === undefined)
364
+ return needsHuman(`${current}'s CLI couldn't be found to restore the service unit`);
521
365
  const restored = product.service.refresh(oldCli);
522
366
  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);
367
+ out.warn(said(restored));
368
+ return needsHuman(`the service couldn't be started again on ${current}`);
528
369
  }
529
370
  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
- }
371
+ if (url !== undefined && (await o.waitFor(url, current)).state !== 'ok')
372
+ return needsHuman(`${current} isn't answering at ${url} either`);
544
373
  }
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);
374
+ const message = `${check.target} didn't start; ${current} is running again.`;
375
+ o.emit({ ...check, outcome: 'rolled-back', message });
376
+ return out.error(message, EXIT.ROLLED_BACK);
548
377
  }
549
378
  /**
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:
379
+ * The CLI on disk that reports `wanted`, tried in this order (keep it):
553
380
  *
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.
381
+ * 1. `realpath(argv[1])`, resolved now: a manager whose real path carries the version (pnpm's store) repoints the
382
+ * bin's symlink on install, and only this candidate follows it;
383
+ * 2. `<npm root -g>/<name>/<binSubpath>`;
384
+ * 3. the package root §6 found, plus the same subpath.
562
385
  *
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.
386
+ * Each is run with `--version`, and the first that reports `wanted` wins. "The file exists" isn't enough: handing the
387
+ * refresh to a CLI that's still the old version would make a green update that changed nothing.
567
388
  */
568
389
  export function findCli(product, info, wanted, ctx) {
569
390
  const fs = ctx.fs ?? fsDefault;
@@ -575,35 +396,13 @@ export function findCli(product, info, wanted, ctx) {
575
396
  candidates.push(product.entry);
576
397
  }
577
398
  const globalRoot = npmGlobalRoot(ctx);
578
- if (globalRoot !== undefined) {
399
+ if (globalRoot !== undefined)
579
400
  candidates.push(path.join(globalRoot, product.packageName, product.binSubpath));
580
- }
581
- if (info?.packageRoot !== undefined) {
401
+ if (info?.packageRoot !== undefined)
582
402
  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)
403
+ for (const candidate of new Set(candidates)) {
404
+ if (fs.existsSync(candidate) && cliVersion(candidate, ctx) === wanted)
592
405
  return candidate;
593
406
  }
594
407
  return undefined;
595
408
  }
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
- }