@edspencer/paddock 0.81.0 → 0.82.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/cli/args.d.ts +56 -31
  3. package/packages/server/dist/cli/args.js +210 -24
  4. package/packages/server/dist/cli/busy-command.js +1 -1
  5. package/packages/server/dist/cli/busy.js +10 -12
  6. package/packages/server/dist/cli/exit-codes.d.ts +21 -45
  7. package/packages/server/dist/cli/exit-codes.js +42 -45
  8. package/packages/server/dist/cli/output.js +5 -6
  9. package/packages/server/dist/cli/paddock.js +60 -30
  10. package/packages/server/dist/cli/service/index.d.ts +54 -2
  11. package/packages/server/dist/cli/service/index.js +198 -25
  12. package/packages/server/dist/cli/service/launchd.d.ts +31 -84
  13. package/packages/server/dist/cli/service/launchd.js +104 -304
  14. package/packages/server/dist/cli/service/logs.d.ts +9 -28
  15. package/packages/server/dist/cli/service/logs.js +33 -75
  16. package/packages/server/dist/cli/service/manager.d.ts +93 -0
  17. package/packages/server/dist/cli/service/manager.js +21 -0
  18. package/packages/server/dist/cli/service/path-env.d.ts +7 -18
  19. package/packages/server/dist/cli/service/path-env.js +30 -81
  20. package/packages/server/dist/cli/service/ready.d.ts +30 -0
  21. package/packages/server/dist/cli/service/ready.js +35 -0
  22. package/packages/server/dist/cli/service/spec.d.ts +4 -31
  23. package/packages/server/dist/cli/service/spec.js +9 -0
  24. package/packages/server/dist/cli/service/systemd.d.ts +27 -101
  25. package/packages/server/dist/cli/service/systemd.js +106 -235
  26. package/packages/server/dist/cli/update/health.d.ts +48 -0
  27. package/packages/server/dist/cli/update/health.js +64 -0
  28. package/packages/server/dist/cli/update/install-method.d.ts +84 -0
  29. package/packages/server/dist/cli/update/install-method.js +183 -0
  30. package/packages/server/dist/cli/update/lock.d.ts +35 -0
  31. package/packages/server/dist/cli/update/lock.js +74 -0
  32. package/packages/server/dist/cli/update/package-manager.d.ts +74 -0
  33. package/packages/server/dist/cli/update/package-manager.js +156 -0
  34. package/packages/server/dist/cli/update/registry.d.ts +52 -0
  35. package/packages/server/dist/cli/update/registry.js +102 -0
  36. package/packages/server/dist/cli/update/semver.d.ts +45 -0
  37. package/packages/server/dist/cli/update/semver.js +182 -0
  38. package/packages/server/dist/cli/update/update-check.d.ts +69 -0
  39. package/packages/server/dist/cli/update/update-check.js +86 -0
  40. package/packages/server/dist/cli/update/update.d.ts +126 -0
  41. package/packages/server/dist/cli/update/update.js +408 -0
  42. package/packages/server/dist/cli/update-command.d.ts +20 -0
  43. package/packages/server/dist/cli/update-command.js +111 -0
  44. package/packages/server/package.json +1 -1
  45. package/packages/web/dist/assets/{DeleteProjectDialog-D8oIJ8HC.js → DeleteProjectDialog-CgHpQctt.js} +1 -1
  46. package/packages/web/dist/assets/DiscoverPage-CG6cf-0i.js +2 -0
  47. package/packages/web/dist/assets/{DiscoverView-o0KCr3aB.js → DiscoverView-BKqI0I1E.js} +1 -1
  48. package/packages/web/dist/assets/{InstanceConfigPage-RSJz7oL3.js → InstanceConfigPage-DL3raCK1.js} +1 -1
  49. package/packages/web/dist/assets/{MarkdownRenderer-39mafH8b.js → MarkdownRenderer-DcjEuf3s.js} +1 -1
  50. package/packages/web/dist/assets/{ProjectRedirect-Bq7GVguY.js → ProjectRedirect-DerkA-nE.js} +1 -1
  51. package/packages/web/dist/assets/{ProjectView-BoBIru1g.js → ProjectView-NwfC3_w7.js} +3 -3
  52. package/packages/web/dist/assets/{ProjectsGrid-mJ4gqbr6.js → ProjectsGrid-BeORa0UH.js} +1 -1
  53. package/packages/web/dist/assets/{RootHome-C0kwFyYp.js → RootHome-BYJs8qJ6.js} +1 -1
  54. package/packages/web/dist/assets/{Surfaces-MkhXlB8z.js → Surfaces-w-kEaAYU.js} +1 -1
  55. package/packages/web/dist/assets/{arc-DExlwAwO.js → arc-Bv56hasM.js} +1 -1
  56. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CZly3W79.js → architectureDiagram-3BPJPVTR-BHEknx9i.js} +1 -1
  57. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-AgES73i3.js → blockDiagram-GPEHLZMM-DtxTMrPX.js} +1 -1
  58. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-N9mD8Pfk.js → c4Diagram-AAUBKEIU-DRpEYOlM.js} +1 -1
  59. package/packages/web/dist/assets/channel-CdIiAqY2.js +2 -0
  60. package/packages/web/dist/assets/{chunk-2J33WTMH-Dsx_kfUl.js → chunk-2J33WTMH-S4s7GCg7.js} +1 -1
  61. package/packages/web/dist/assets/{chunk-4BX2VUAB-BQjqcjPj.js → chunk-4BX2VUAB-BkWVPLf1.js} +1 -1
  62. package/packages/web/dist/assets/{chunk-55IACEB6-CCxpYQpY.js → chunk-55IACEB6-4-FQp01F.js} +1 -1
  63. package/packages/web/dist/assets/{chunk-727SXJPM-DpFRdnUa.js → chunk-727SXJPM-BeyDWdIb.js} +1 -1
  64. package/packages/web/dist/assets/{chunk-AQP2D5EJ-D2BAlzoO.js → chunk-AQP2D5EJ-ZSxKy745.js} +1 -1
  65. package/packages/web/dist/assets/{chunk-FMBD7UC4-BFaMRzUF.js → chunk-FMBD7UC4-Bq8uV9j-.js} +1 -1
  66. package/packages/web/dist/assets/{chunk-ND2GUHAM-B9NGJ9xZ.js → chunk-ND2GUHAM-BOFsRyHk.js} +1 -1
  67. package/packages/web/dist/assets/{chunk-QZHKN3VN-CF1IHyMS.js → chunk-QZHKN3VN-uZ6oNc4m.js} +1 -1
  68. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DCCUHba0.js +2 -0
  69. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DCCUHba0.js +2 -0
  70. package/packages/web/dist/assets/{core-XjElsvQ7.js → core-CYuJAM2U.js} +1 -1
  71. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-CF972Flx.js → cose-bilkent-S5V4N54A-DIzkVSFb.js} +1 -1
  72. package/packages/web/dist/assets/{dagre-BM42HDAG-DsneI8YE.js → dagre-BM42HDAG-CHR6jJBv.js} +1 -1
  73. package/packages/web/dist/assets/{diagram-2AECGRRQ-BHyrVzIk.js → diagram-2AECGRRQ-BAFXCH8Q.js} +1 -1
  74. package/packages/web/dist/assets/{diagram-5GNKFQAL-Dj4q8xEi.js → diagram-5GNKFQAL-BJ5t5gbz.js} +1 -1
  75. package/packages/web/dist/assets/{diagram-KO2AKTUF-BHe3Puv_.js → diagram-KO2AKTUF-D9Xt0IVg.js} +1 -1
  76. package/packages/web/dist/assets/{diagram-LMA3HP47-wzt6G2FE.js → diagram-LMA3HP47-Bq38u6sy.js} +1 -1
  77. package/packages/web/dist/assets/{diagram-OG6HWLK6-CZMbMRJq.js → diagram-OG6HWLK6-CMQVqkpi.js} +1 -1
  78. package/packages/web/dist/assets/{documentTitle-BwTIA8rI.js → documentTitle-M1lrfXRU.js} +1 -1
  79. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-Dtu14BL1.js → erDiagram-TEJ5UH35-C63M1g9b.js} +1 -1
  80. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-T34ql9j0.js → flowDiagram-I6XJVG4X-D3j8ACN7.js} +1 -1
  81. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DQLOeCXK.js → ganttDiagram-6RSMTGT7-B1Fd1A2Y.js} +1 -1
  82. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-Bk11zea6.js → gitGraphDiagram-PVQCEYII-CEhZhDdR.js} +1 -1
  83. package/packages/web/dist/assets/{index-CvaWErFv.js → index-1E9yHciS.js} +2 -2
  84. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-CsE83lu0.js → infoDiagram-5YYISTIA-Ctquaew6.js} +1 -1
  85. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DNVgA93n.js → ishikawaDiagram-YF4QCWOH-DV4GvA1R.js} +1 -1
  86. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-CLpSNjM3.js → journeyDiagram-JHISSGLW-C_Cy9waA.js} +1 -1
  87. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-BRzuXcKr.js → kanban-definition-UN3LZRKU-CGr9ctEf.js} +1 -1
  88. package/packages/web/dist/assets/{linear-BxOawONt.js → linear-D3U8NOkt.js} +1 -1
  89. package/packages/web/dist/assets/{mermaid.core-Dvs1QCcz.js → mermaid.core-BiArdWKV.js} +4 -4
  90. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-Dmt7pYwF.js → mindmap-definition-RKZ34NQL-CZIpzj8Z.js} +1 -1
  91. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BHiZaJD-.js → pieDiagram-4H26LBE5-1MaeS3rt.js} +1 -1
  92. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-U0A1noRE.js → quadrantDiagram-W4KKPZXB-B5pgQ54e.js} +1 -1
  93. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CZLsr6yi.js → requirementDiagram-4Y6WPE33-BNnxrpGr.js} +1 -1
  94. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-BTlLGILU.js → sankeyDiagram-5OEKKPKP-DsxVH-hM.js} +1 -1
  95. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DpSS_Gb1.js → sequenceDiagram-3UESZ5HK-Ke9LcFZI.js} +1 -1
  96. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-D9srE-tV.js → stateDiagram-AJRCARHV-UJzmflND.js} +1 -1
  97. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-DgUc3HkP.js +2 -0
  98. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-BYQ5CKab.js → timeline-definition-PNZ67QCA-BRjNALVI.js} +1 -1
  99. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-B4W2CtSV.js → vennDiagram-CIIHVFJN-ClMAspxz.js} +1 -1
  100. package/packages/web/dist/assets/{wardley-L42UT6IY--XUV7GvD.js → wardley-L42UT6IY-UPv4JEmz.js} +1 -1
  101. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-Bl2VIEGY.js → wardleyDiagram-YWT4CUSO-BgYJbdEx.js} +1 -1
  102. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-Bj2H9Rcf.js → xychartDiagram-2RQKCTM6-Bfk5HjGx.js} +1 -1
  103. package/packages/web/dist/index.html +1 -1
  104. package/packages/web/dist/sw.js +1 -1
  105. package/packages/server/dist/cli/service/backend.d.ts +0 -115
  106. package/packages/server/dist/cli/service/backend.js +0 -19
  107. package/packages/web/dist/assets/DiscoverPage-CQlzxsn4.js +0 -2
  108. package/packages/web/dist/assets/channel-ExsAG7qT.js +0 -2
  109. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-si7g9okq.js +0 -2
  110. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-si7g9okq.js +0 -2
  111. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-AxWqsDh-.js +0 -2
@@ -0,0 +1,86 @@
1
+ // From edspencer/cli-contract reference/update-check.ts @ 0.2; local changes: `.ts` import specifiers → `.js`, and `exit-codes`, `output` and `runtime-file` imported from where Paddock keeps them (`../`, `../../`).
2
+ // `<home>/update-check.json`: what the last registry question found (SPEC §3). Only `update`, `update --check` and
3
+ // `doctor` ask the registry; they write the answer here, and `service status` repeats it without opening a socket.
4
+ // It's the document `update --check --json` prints (`schemas/update-check.schema.json`), and `service status --json`'s
5
+ // `update` is a projection of it. Node built-ins only.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ export const UPDATE_CHECK_FILE = 'update-check.json';
9
+ export function updateCheckPath(home) {
10
+ return path.join(home, UPDATE_CHECK_FILE);
11
+ }
12
+ /**
13
+ * Writes the cache atomically, so `status` never reads half a file and says nothing. It never throws: a cache that
14
+ * couldn't be written isn't a failed update.
15
+ */
16
+ export function writeUpdateCheck(home, check) {
17
+ try {
18
+ fs.mkdirSync(home, { recursive: true });
19
+ const file = updateCheckPath(home);
20
+ const tmp = `${file}.${process.pid}.tmp`;
21
+ fs.writeFileSync(tmp, `${JSON.stringify(check, null, 2)}\n`, { mode: 0o600 });
22
+ try {
23
+ fs.renameSync(tmp, file);
24
+ }
25
+ catch (err) {
26
+ fs.rmSync(tmp, { force: true });
27
+ throw err;
28
+ }
29
+ }
30
+ catch {
31
+ // best effort
32
+ }
33
+ }
34
+ /** The cache, or null when there isn't one (or it isn't one). */
35
+ export function readUpdateCheck(home) {
36
+ try {
37
+ const raw = JSON.parse(fs.readFileSync(updateCheckPath(home), 'utf8'));
38
+ if (typeof raw.name === 'string' &&
39
+ typeof raw.current === 'string' &&
40
+ typeof raw.target === 'string' &&
41
+ typeof raw.updateAvailable === 'boolean' &&
42
+ typeof raw.checkedAt === 'string') {
43
+ return raw;
44
+ }
45
+ }
46
+ catch {
47
+ // missing, half-written, or something else's
48
+ }
49
+ return null;
50
+ }
51
+ /**
52
+ * How long a cached answer is worth repeating: long enough for "I checked on Monday, it's Friday", short enough that
53
+ * `status` doesn't advertise a version that's since been superseded twice.
54
+ */
55
+ export const UPDATE_CHECK_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
56
+ /**
57
+ * What `service status` repeats, or undefined when there's nothing worth repeating: no cache, a cache for another
58
+ * version than this one, or one that's too old. Leave `update` out of `status --json` then: the schema types it as an
59
+ * object, so it's absent, never null.
60
+ */
61
+ export function updateSummary(home, o = {}) {
62
+ const check = readUpdateCheck(home);
63
+ if (check === null)
64
+ return undefined;
65
+ if (o.currentVersion !== undefined && check.current !== o.currentVersion)
66
+ return undefined;
67
+ const at = Date.parse(check.checkedAt);
68
+ if (!Number.isFinite(at) || (o.now ?? Date.now()) - at > (o.maxAgeMs ?? UPDATE_CHECK_MAX_AGE_MS))
69
+ return undefined;
70
+ return { latest: check.target, updateAvailable: check.updateAvailable, checkedAt: check.checkedAt };
71
+ }
72
+ /**
73
+ * SPEC §3's notice, for the caller to put on stderr:
74
+ *
75
+ * ```
76
+ * Paddock 0.80.0 is available (you have 0.79.1).
77
+ * Run: paddock update
78
+ * Changes: https://github.com/edspencer/paddock/releases/tag/v0.80.0
79
+ * ```
80
+ */
81
+ export function updateNotice(o) {
82
+ const lines = [`${o.name} ${o.target} is available (you have ${o.current}).`, ` Run: ${o.command}${o.note !== undefined ? ` (${o.note})` : ''}`];
83
+ if (o.changelog !== undefined && o.changelog !== null)
84
+ lines.push(` Changes: ${o.changelog}`);
85
+ return lines.join('\n');
86
+ }
@@ -0,0 +1,126 @@
1
+ import fsDefault from 'node:fs';
2
+ import { type HealthVerdict } from './health.js';
3
+ import { type InstallInfo } from './install-method.js';
4
+ import type { Output } from '../output.js';
5
+ import { type NpmContext, type RunResult, type Spawn } from './package-manager.js';
6
+ import { type UpdateCheck } from './update-check.js';
7
+ export type { HealthVerdict } from './health.js';
8
+ /** SPEC §5 step 12: 30 seconds unless the tool sets its own. */
9
+ export declare const HEALTH_TIMEOUT_MS = 30000;
10
+ /** How long to wait for the old version to stop answering after the stop (step 9). */
11
+ export declare const STOP_TIMEOUT_MS = 15000;
12
+ /** How many log lines a failure prints (SPEC §5 step 13). */
13
+ export declare const LOG_LINES_ON_FAILURE = 20;
14
+ /** What `update` was asked (SPEC §1). */
15
+ export interface UpdateOptions {
16
+ /** Report, and change nothing. */
17
+ check?: boolean;
18
+ /** A version or a dist-tag: the only way to move to a lower version. */
19
+ to?: string;
20
+ /** Skip the question. Needed without a TTY. */
21
+ yes?: boolean;
22
+ /** Skip the busy check, and nothing else. */
23
+ force?: boolean;
24
+ json?: boolean;
25
+ }
26
+ /** The service, as `update` sees it. */
27
+ export interface ServiceSnapshot {
28
+ installed: boolean;
29
+ running: boolean;
30
+ /** The URL the installed unit serves, or a foreground `serve` found through `runtime.json`. */
31
+ url?: string;
32
+ /** The log the unit names, for the failure's tail. */
33
+ log?: string;
34
+ }
35
+ export interface UpdateServiceHooks {
36
+ /** Is a unit installed, is it running, and where. With no unit, report a foreground `serve` from `runtime.json`. */
37
+ inspect(): ServiceSnapshot;
38
+ /** Stops the service. Throws, with the service manager's own words, if it can't. */
39
+ stop(): void;
40
+ /** Runs `<node> <cliPath> service install --refresh`: the new CLI on the way up, the old one on a rollback. */
41
+ refresh(cliPath: string): RunResult;
42
+ /** The last `lines` lines of the service's log, or ''. */
43
+ tail(lines: number): string;
44
+ }
45
+ export interface UpdateProduct {
46
+ /** `Paddock`. */
47
+ name: string;
48
+ /** `paddock`: the bin, for `<tool>: <message>` and the printed commands. */
49
+ tool: string;
50
+ /** `@edspencer/paddock`. */
51
+ packageName: string;
52
+ /** `PADDOCK`: gives `PADDOCK_IN_DOCKER` and `PADDOCK_UPDATE_HINT`. */
53
+ envPrefix: string;
54
+ /** The data home, for `update.lock` and `update-check.json`: the one `service status` reads the cache from. */
55
+ home: string;
56
+ /** This CLI's version, from its `package.json`. */
57
+ currentVersion: string;
58
+ /**
59
+ * `process.argv[1]`, not realpath'd. Re-resolving the bin's symlink after the install is what finds the new CLI
60
+ * under a manager that puts the version in the real path (pnpm). §6's walk-up realpaths it itself.
61
+ */
62
+ entry: string;
63
+ /** Where the CLI sits in the package: `dist/server/cli.js`. */
64
+ binSubpath: string;
65
+ /** The image, for the Docker notice: `ghcr.io/edspencer/paddock`. */
66
+ image?: string;
67
+ /** `update`'s own usage, printed after the exit-2 refusal (SPEC §2). */
68
+ usage?: string;
69
+ /** The release notes for a version: the notice's `Changes:` line. */
70
+ changelogUrl(version: string): string;
71
+ healthTimeoutMs?: number;
72
+ /** The busy check (SPEC §5 step 6): null to go ahead, or 75 having printed why (`guardBusy` in busy-client.ts). */
73
+ guardBusy(o: {
74
+ force: boolean;
75
+ home: string;
76
+ }): Promise<number | null>;
77
+ service: UpdateServiceHooks;
78
+ /** Defaults to `waitForVersion` in health.ts. */
79
+ waitForVersion?(o: {
80
+ url: string;
81
+ version: string;
82
+ timeoutMs: number;
83
+ }): Promise<HealthVerdict>;
84
+ }
85
+ /** Everything that touches the machine. */
86
+ export interface UpdateEnvironment {
87
+ out: Output;
88
+ env?: NodeJS.ProcessEnv;
89
+ spawn?: Spawn;
90
+ execPath?: string;
91
+ platform?: NodeJS.Platform;
92
+ fetch?: typeof globalThis.fetch;
93
+ fs?: Pick<typeof fsDefault, 'existsSync' | 'readFileSync' | 'realpathSync'>;
94
+ /** Is there a person at a keyboard? (SPEC §3.) Defaults to stdin and stderr both being TTYs. */
95
+ isTTY?: boolean;
96
+ /** The Node to check the target's `engines.node` against. */
97
+ nodeVersion?: string;
98
+ /** Asks the question; only called on a TTY without `--yes`. */
99
+ confirm?: (question: string) => Promise<boolean>;
100
+ writable?: (dir: string) => boolean;
101
+ /** Injected for tests: waits between the stop's health polls. */
102
+ sleep?: (ms: number) => Promise<void>;
103
+ }
104
+ export type UpdateOutcome = 'up-to-date' | 'ahead' | 'available' | 'updated' | 'restart-required' | 'cannot-self-update' | 'not-writable' | 'node-too-old' | 'busy' | 'needs-yes' | 'declined' | 'rolled-back' | 'needs-human' | 'failed';
105
+ /** `update --json`'s document: the check, plus what happened. `--check --json` matches the update-check schema. */
106
+ export interface UpdateDoc extends Partial<UpdateCheck> {
107
+ outcome: UpdateOutcome;
108
+ message?: string;
109
+ url?: string;
110
+ }
111
+ /** Runs `update` and returns the exit code; the caller sets `process.exitCode`. */
112
+ export declare function runUpdate(product: UpdateProduct, opts: UpdateOptions, envp: UpdateEnvironment): Promise<number>;
113
+ /**
114
+ * The CLI on disk that reports `wanted`, tried in this order (keep it):
115
+ *
116
+ * 1. `realpath(argv[1])`, resolved now: a manager whose real path carries the version (pnpm's store) repoints the
117
+ * bin's symlink on install, and only this candidate follows it;
118
+ * 2. `<npm root -g>/<name>/<binSubpath>`;
119
+ * 3. the package root §6 found, plus the same subpath.
120
+ *
121
+ * Each is run with `--version`, and the first that reports `wanted` wins. "The file exists" isn't enough: handing the
122
+ * refresh to a CLI that's still the old version would make a green update that changed nothing.
123
+ */
124
+ export declare function findCli(product: Pick<UpdateProduct, 'entry' | 'packageName' | 'binSubpath'>, info: InstallInfo | undefined, wanted: string, ctx: NpmContext & {
125
+ fs?: Pick<typeof fsDefault, 'existsSync' | 'realpathSync'>;
126
+ }): string | undefined;
@@ -0,0 +1,408 @@
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';
34
+ /** SPEC §5 step 12: 30 seconds unless the tool sets its own. */
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;
38
+ /** How many log lines a failure prints (SPEC §5 step 13). */
39
+ export const LOG_LINES_ON_FAILURE = 20;
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`. */
46
+ export async function runUpdate(product, opts, envp) {
47
+ const out = envp.out;
48
+ const env = envp.env ?? process.env;
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
+ };
56
+ let emitted = false;
57
+ const emit = (doc) => {
58
+ if (emitted)
59
+ return;
60
+ emitted = true;
61
+ out.result(doc);
62
+ };
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
68
+ let lock;
69
+ try {
70
+ lock = acquireLock({ home: product.home, command: `${product.tool} update${opts.to !== undefined ? ` --to ${opts.to}` : ''}` });
71
+ }
72
+ catch (err) {
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);
76
+ }
77
+ try {
78
+ return await sequence();
79
+ }
80
+ finally {
81
+ lock.release();
82
+ }
83
+ async function sequence() {
84
+ // ---- 2. how was it installed? Acted on after step 4, which has the target its notice names.
85
+ const info = detectInstall({
86
+ packageName: product.packageName,
87
+ envPrefix: product.envPrefix,
88
+ entry: product.entry,
89
+ env,
90
+ npmGlobalRoot: () => npmGlobalRoot(npm),
91
+ fs,
92
+ });
93
+ // ---- 3. the registry
94
+ let target;
95
+ try {
96
+ target = await resolveTarget({
97
+ packageName: product.packageName,
98
+ registry: resolveRegistry(env, (key) => npmConfigGet(key, npm)),
99
+ ...(opts.to !== undefined ? { to: opts.to } : {}),
100
+ ...(envp.fetch !== undefined ? { fetch: envp.fetch } : {}),
101
+ });
102
+ }
103
+ catch (err) {
104
+ const message = err instanceof RegistryError ? `${err.message}\n ${err.url}` : String(err);
105
+ emit({ outcome: 'failed', message });
106
+ return out.error(message);
107
+ }
108
+ // ---- 4. compare
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);
112
+ const nodeVersion = envp.nodeVersion ?? process.versions.node;
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 } : {}),
118
+ });
119
+ const check = {
120
+ name: product.packageName,
121
+ current: product.currentVersion,
122
+ target: target.version,
123
+ tag: target.tag,
124
+ updateAvailable: cmp > 0 && !misTagged,
125
+ installMethod: info.method,
126
+ canUpdate: info.canUpdate,
127
+ command: info.canUpdate ? `${product.tool} update` : command.command,
128
+ engines: target.engines,
129
+ // A range that couldn't be read is a pass (semver.ts), and the field is a boolean.
130
+ nodeOk: engines.ok !== false,
131
+ changelog: product.changelogUrl(target.version),
132
+ checkedAt: new Date().toISOString(),
133
+ };
134
+ // Written whatever happens next: "you're up to date" is the answer that stops `status` repeating an old notice.
135
+ writeUpdateCheck(product.home, check);
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 });
147
+ out.say(message);
148
+ return EXIT.OK;
149
+ }
150
+ if (cmp === 0) {
151
+ const message = `${product.name} ${product.currentVersion} is up to date.`;
152
+ emit({ ...check, outcome: 'up-to-date', message });
153
+ out.say(message);
154
+ return EXIT.OK;
155
+ }
156
+ if (cmp < 0 && opts.to === undefined) {
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 });
160
+ out.say(message);
161
+ return EXIT.OK;
162
+ }
163
+ if (opts.check === true) {
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;
170
+ }
171
+ if (!info.canUpdate) {
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);
176
+ }
177
+ const manager = info.manager ?? 'npm';
178
+ if (engines.ok === false) {
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 });
181
+ return out.error(message);
182
+ }
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);
191
+ }
192
+ // ---- 6. busy? Before anything stops, and before the question.
193
+ const refused = await product.guardBusy({ force: opts.force === true, home: product.home });
194
+ if (refused !== null) {
195
+ emit({ ...check, outcome: 'busy' });
196
+ return refused;
197
+ }
198
+ const service = product.service.inspect();
199
+ const arrow = `${product.currentVersion} → ${target.version}`;
200
+ // ---- 7. ask
201
+ if (opts.yes !== true) {
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);
206
+ if (product.usage !== undefined)
207
+ out.warn(`\n${product.usage.trimEnd()}`);
208
+ return code;
209
+ }
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;
216
+ }
217
+ }
218
+ // ---- 8. fetch first, both versions: the current one is what a rollback reinstalls.
219
+ out.warn(` Fetching ${product.packageName}@${target.version}…`);
220
+ const fetched = cacheAdd(product.packageName, target.version, npm);
221
+ if (fetched.status !== 0) {
222
+ const message = `couldn't fetch ${product.packageName}@${target.version}:\n${said(fetched)}`;
223
+ emit({ ...check, outcome: 'failed', message });
224
+ return out.error(message);
225
+ }
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.`);
228
+ }
229
+ // ---- 9. stop
230
+ if (service.installed) {
231
+ out.warn(` Stopping the ${product.name} service…`);
232
+ try {
233
+ product.service.stop();
234
+ }
235
+ catch (err) {
236
+ const message = `couldn't stop the service: ${err.message}`;
237
+ emit({ ...check, outcome: 'failed', message });
238
+ return out.error(message);
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
+ }
243
+ }
244
+ else if (service.running && service.url !== undefined) {
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);
250
+ }
251
+ }
252
+ // ---- 10. install. If npm fails, it has already put the old package back.
253
+ out.warn(` Installing ${product.packageName}@${target.version} with ${manager}…`);
254
+ const installed = installPackage({ ...npm, manager, packageName: product.packageName, version: target.version });
255
+ const back = { check, emit, product, envp, npm, manager, service, waitFor };
256
+ if (installed.status !== 0) {
257
+ out.warn(said(installed));
258
+ return rollback({ ...back, why: `installing ${target.version} failed`, alreadyRestored: true });
259
+ }
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).
264
+ if (!service.installed) {
265
+ noteInstalled(product.home, check);
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;
270
+ }
271
+ // ---- 11. hand over to the new version
272
+ out.warn(' Refreshing the service unit with the new version…');
273
+ const refreshed = product.service.refresh(newCli);
274
+ if (refreshed.status !== 0) {
275
+ out.warn(said(refreshed));
276
+ return rollback({ ...back, why: `${target.version} couldn't refresh the service unit` });
277
+ }
278
+ // ---- 12. health
279
+ const url = product.service.inspect().url ?? service.url;
280
+ if (url === undefined) {
281
+ // Nothing to poll; the refresh waited for health itself.
282
+ noteInstalled(product.home, check);
283
+ emit({ ...check, outcome: 'updated', message: `Updated ${product.name} ${arrow}.` });
284
+ out.say(`Updated ${product.name} ${arrow}.`);
285
+ return EXIT.OK;
286
+ }
287
+ out.warn(` Waiting for ${target.version} to answer at ${url}…`);
288
+ const verdict = await waitFor(url, target.version);
289
+ if (verdict.state !== 'ok') {
290
+ const why = verdict.state === 'wrong-version'
291
+ ? `${url} answers with ${verdict.version}, not ${target.version}`
292
+ : `${url} didn't answer within ${healthTimeoutMs / 1000} seconds`;
293
+ return rollback({ ...back, why, showLog: true });
294
+ }
295
+ // ---- 15. done
296
+ noteInstalled(product.home, check);
297
+ const message = `Updated ${product.name} ${arrow}. Running at ${url}`;
298
+ emit({ ...check, outcome: 'updated', message, url });
299
+ out.say(message);
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;
311
+ }
312
+ }
313
+ /**
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.
316
+ */
317
+ function noteInstalled(home, check) {
318
+ writeUpdateCheck(home, { ...check, current: check.target, updateAvailable: false, command: null, checkedAt: new Date().toISOString() });
319
+ }
320
+ /** Asks on the terminal: the question on stderr (SPEC §3), the answer from stdin. */
321
+ async function askOnTty(question) {
322
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
323
+ try {
324
+ return /^y(es)?$/i.test((await new Promise((resolve) => rl.question(question, resolve))).trim());
325
+ }
326
+ finally {
327
+ rl.close();
328
+ }
329
+ }
330
+ /**
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.
334
+ */
335
+ async function rollback(o) {
336
+ const { product, check } = o;
337
+ const out = o.envp.out;
338
+ const current = product.currentVersion;
339
+ out.error(`${o.why}.`);
340
+ if (o.showLog === true) {
341
+ const tail = product.service.tail(LOG_LINES_ON_FAILURE);
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)}`);
344
+ }
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
+ };
353
+ if (o.alreadyRestored !== true) {
354
+ const reinstalled = installPackage({ ...o.npm, manager: o.manager, packageName: product.packageName, version: current });
355
+ if (reinstalled.status !== 0) {
356
+ out.warn(said(reinstalled));
357
+ return needsHuman(`${current} couldn't be reinstalled`);
358
+ }
359
+ }
360
+ // The unit is refreshed by the old CLI: the new one may have written a format the old one doesn't read.
361
+ if (o.service.installed) {
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`);
365
+ const restored = product.service.refresh(oldCli);
366
+ if (restored.status !== 0) {
367
+ out.warn(said(restored));
368
+ return needsHuman(`the service couldn't be started again on ${current}`);
369
+ }
370
+ const url = product.service.inspect().url ?? o.service.url;
371
+ if (url !== undefined && (await o.waitFor(url, current)).state !== 'ok')
372
+ return needsHuman(`${current} isn't answering at ${url} either`);
373
+ }
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);
377
+ }
378
+ /**
379
+ * The CLI on disk that reports `wanted`, tried in this order (keep it):
380
+ *
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.
385
+ *
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.
388
+ */
389
+ export function findCli(product, info, wanted, ctx) {
390
+ const fs = ctx.fs ?? fsDefault;
391
+ const candidates = [];
392
+ try {
393
+ candidates.push(fs.realpathSync(product.entry));
394
+ }
395
+ catch {
396
+ candidates.push(product.entry);
397
+ }
398
+ const globalRoot = npmGlobalRoot(ctx);
399
+ if (globalRoot !== undefined)
400
+ candidates.push(path.join(globalRoot, product.packageName, product.binSubpath));
401
+ if (info?.packageRoot !== undefined)
402
+ candidates.push(path.join(info.packageRoot, product.binSubpath));
403
+ for (const candidate of new Set(candidates)) {
404
+ if (fs.existsSync(candidate) && cliVersion(candidate, ctx) === wanted)
405
+ return candidate;
406
+ }
407
+ return undefined;
408
+ }
@@ -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>;