@edspencer/paddock 0.81.0 → 0.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/cli/args.d.ts +56 -31
  3. package/packages/server/dist/cli/args.js +208 -22
  4. package/packages/server/dist/cli/paddock.js +51 -21
  5. package/packages/server/dist/cli/service/index.d.ts +52 -0
  6. package/packages/server/dist/cli/service/index.js +170 -3
  7. package/packages/server/dist/cli/service/ready.d.ts +30 -0
  8. package/packages/server/dist/cli/service/ready.js +35 -0
  9. package/packages/server/dist/cli/service/systemd.js +27 -0
  10. package/packages/server/dist/cli/update/install-method.d.ts +131 -0
  11. package/packages/server/dist/cli/update/install-method.js +258 -0
  12. package/packages/server/dist/cli/update/lock.d.ts +40 -0
  13. package/packages/server/dist/cli/update/lock.js +108 -0
  14. package/packages/server/dist/cli/update/package-manager.d.ts +109 -0
  15. package/packages/server/dist/cli/update/package-manager.js +221 -0
  16. package/packages/server/dist/cli/update/registry.d.ts +89 -0
  17. package/packages/server/dist/cli/update/registry.js +145 -0
  18. package/packages/server/dist/cli/update/semver.d.ts +68 -0
  19. package/packages/server/dist/cli/update/semver.js +232 -0
  20. package/packages/server/dist/cli/update/update-check.d.ts +80 -0
  21. package/packages/server/dist/cli/update/update-check.js +116 -0
  22. package/packages/server/dist/cli/update/update.d.ts +160 -0
  23. package/packages/server/dist/cli/update/update.js +609 -0
  24. package/packages/server/dist/cli/update-command.d.ts +20 -0
  25. package/packages/server/dist/cli/update-command.js +113 -0
  26. package/packages/server/package.json +1 -1
  27. package/packages/web/dist/assets/{DeleteProjectDialog-D8oIJ8HC.js → DeleteProjectDialog-CphdGB3R.js} +1 -1
  28. package/packages/web/dist/assets/DiscoverPage-C4OPLNO8.js +2 -0
  29. package/packages/web/dist/assets/{DiscoverView-o0KCr3aB.js → DiscoverView-B_kcF4sm.js} +1 -1
  30. package/packages/web/dist/assets/{InstanceConfigPage-RSJz7oL3.js → InstanceConfigPage-CvUK5DKW.js} +1 -1
  31. package/packages/web/dist/assets/{MarkdownRenderer-39mafH8b.js → MarkdownRenderer-DvZ-j_Ao.js} +1 -1
  32. package/packages/web/dist/assets/{ProjectRedirect-Bq7GVguY.js → ProjectRedirect-DayHvUH2.js} +1 -1
  33. package/packages/web/dist/assets/{ProjectView-BoBIru1g.js → ProjectView-CCw83gUi.js} +3 -3
  34. package/packages/web/dist/assets/{ProjectsGrid-mJ4gqbr6.js → ProjectsGrid-B3Klg2H4.js} +1 -1
  35. package/packages/web/dist/assets/{RootHome-C0kwFyYp.js → RootHome-BxcDvpgI.js} +1 -1
  36. package/packages/web/dist/assets/{Surfaces-MkhXlB8z.js → Surfaces-7UlhFBSG.js} +1 -1
  37. package/packages/web/dist/assets/{arc-DExlwAwO.js → arc-DNkh5o5Q.js} +1 -1
  38. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CZly3W79.js → architectureDiagram-3BPJPVTR-BrgN3oLF.js} +1 -1
  39. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-AgES73i3.js → blockDiagram-GPEHLZMM-D05XOJSk.js} +1 -1
  40. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-N9mD8Pfk.js → c4Diagram-AAUBKEIU-CKaMBhMP.js} +1 -1
  41. package/packages/web/dist/assets/channel-JrEedxB8.js +2 -0
  42. package/packages/web/dist/assets/{chunk-2J33WTMH-Dsx_kfUl.js → chunk-2J33WTMH-8hgadCQc.js} +1 -1
  43. package/packages/web/dist/assets/{chunk-4BX2VUAB-BQjqcjPj.js → chunk-4BX2VUAB-CBfEC-pu.js} +1 -1
  44. package/packages/web/dist/assets/{chunk-55IACEB6-CCxpYQpY.js → chunk-55IACEB6-DV1eMgHk.js} +1 -1
  45. package/packages/web/dist/assets/{chunk-727SXJPM-DpFRdnUa.js → chunk-727SXJPM-Cfgl5fxY.js} +1 -1
  46. package/packages/web/dist/assets/{chunk-AQP2D5EJ-D2BAlzoO.js → chunk-AQP2D5EJ-BXhqD295.js} +1 -1
  47. package/packages/web/dist/assets/{chunk-FMBD7UC4-BFaMRzUF.js → chunk-FMBD7UC4-TFGOPtek.js} +1 -1
  48. package/packages/web/dist/assets/{chunk-ND2GUHAM-B9NGJ9xZ.js → chunk-ND2GUHAM-CHFM665R.js} +1 -1
  49. package/packages/web/dist/assets/{chunk-QZHKN3VN-CF1IHyMS.js → chunk-QZHKN3VN-CqEf6MMT.js} +1 -1
  50. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CHJiQp-W.js +2 -0
  51. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CHJiQp-W.js +2 -0
  52. package/packages/web/dist/assets/{core-XjElsvQ7.js → core-DmEaKxBb.js} +1 -1
  53. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-CF972Flx.js → cose-bilkent-S5V4N54A-BnGI6jkr.js} +1 -1
  54. package/packages/web/dist/assets/{dagre-BM42HDAG-DsneI8YE.js → dagre-BM42HDAG-CKoPx61E.js} +1 -1
  55. package/packages/web/dist/assets/{diagram-2AECGRRQ-BHyrVzIk.js → diagram-2AECGRRQ-mMmtlBl1.js} +1 -1
  56. package/packages/web/dist/assets/{diagram-5GNKFQAL-Dj4q8xEi.js → diagram-5GNKFQAL-Cdz9Mtnt.js} +1 -1
  57. package/packages/web/dist/assets/{diagram-KO2AKTUF-BHe3Puv_.js → diagram-KO2AKTUF-DQxM3wYj.js} +1 -1
  58. package/packages/web/dist/assets/{diagram-LMA3HP47-wzt6G2FE.js → diagram-LMA3HP47-BWrinPsq.js} +1 -1
  59. package/packages/web/dist/assets/{diagram-OG6HWLK6-CZMbMRJq.js → diagram-OG6HWLK6-U6MYUve2.js} +1 -1
  60. package/packages/web/dist/assets/{documentTitle-BwTIA8rI.js → documentTitle-DGjRXuTP.js} +1 -1
  61. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-Dtu14BL1.js → erDiagram-TEJ5UH35-xqMCsXxY.js} +1 -1
  62. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-T34ql9j0.js → flowDiagram-I6XJVG4X-PMfy_czF.js} +1 -1
  63. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DQLOeCXK.js → ganttDiagram-6RSMTGT7-whhYYE4N.js} +1 -1
  64. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-Bk11zea6.js → gitGraphDiagram-PVQCEYII-C51JW4tf.js} +1 -1
  65. package/packages/web/dist/assets/{index-CvaWErFv.js → index-CHOtvlnO.js} +2 -2
  66. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-CsE83lu0.js → infoDiagram-5YYISTIA-SH8cr905.js} +1 -1
  67. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DNVgA93n.js → ishikawaDiagram-YF4QCWOH-BjqaoU3U.js} +1 -1
  68. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-CLpSNjM3.js → journeyDiagram-JHISSGLW-CBmdEVrd.js} +1 -1
  69. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-BRzuXcKr.js → kanban-definition-UN3LZRKU-U8njjMLZ.js} +1 -1
  70. package/packages/web/dist/assets/{linear-BxOawONt.js → linear-DF66GPs1.js} +1 -1
  71. package/packages/web/dist/assets/{mermaid.core-Dvs1QCcz.js → mermaid.core-CouJz7Ph.js} +4 -4
  72. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-Dmt7pYwF.js → mindmap-definition-RKZ34NQL-BYIf0oFt.js} +1 -1
  73. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BHiZaJD-.js → pieDiagram-4H26LBE5-Cm8rgg09.js} +1 -1
  74. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-U0A1noRE.js → quadrantDiagram-W4KKPZXB-C3Il5kOH.js} +1 -1
  75. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CZLsr6yi.js → requirementDiagram-4Y6WPE33-D3PE2nTe.js} +1 -1
  76. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-BTlLGILU.js → sankeyDiagram-5OEKKPKP-DdmvBM18.js} +1 -1
  77. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DpSS_Gb1.js → sequenceDiagram-3UESZ5HK-B8M_GAIq.js} +1 -1
  78. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-D9srE-tV.js → stateDiagram-AJRCARHV-DZkXnK1_.js} +1 -1
  79. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CjCdT9Ls.js +2 -0
  80. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-BYQ5CKab.js → timeline-definition-PNZ67QCA-IHXq7rqi.js} +1 -1
  81. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-B4W2CtSV.js → vennDiagram-CIIHVFJN-Dga7tLtT.js} +1 -1
  82. package/packages/web/dist/assets/{wardley-L42UT6IY--XUV7GvD.js → wardley-L42UT6IY-CmcwnEJ-.js} +1 -1
  83. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-Bl2VIEGY.js → wardleyDiagram-YWT4CUSO-zHuSLrD4.js} +1 -1
  84. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-Bj2H9Rcf.js → xychartDiagram-2RQKCTM6-D93MU_ic.js} +1 -1
  85. package/packages/web/dist/index.html +1 -1
  86. package/packages/web/dist/sw.js +1 -1
  87. package/packages/web/dist/assets/DiscoverPage-CQlzxsn4.js +0 -2
  88. package/packages/web/dist/assets/channel-ExsAG7qT.js +0 -2
  89. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-si7g9okq.js +0 -2
  90. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-si7g9okq.js +0 -2
  91. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-AxWqsDh-.js +0 -2
@@ -0,0 +1,258 @@
1
+ /**
2
+ * How was this copy installed? (CLI contract SPEC §6.)
3
+ *
4
+ * `update` cannot sensibly run `npm install -g` on a checkout, inside a
5
+ * container, or out of npx's cache — so before it touches anything it works out
6
+ * which of the ten layouts it is living in, and either picks the matching
7
+ * package manager or prints the one command that WOULD work and exits 5.
8
+ *
9
+ * ## Generic on purpose
10
+ *
11
+ * Nothing in this file knows about Paddock. The product arrives as data — the
12
+ * package name, the environment prefix, the entry script — and every call that
13
+ * touches the world (spawning `npm root -g`, reading a manifest, resolving a
14
+ * symlink) is injected. That is what lets the §6 table be tested against
15
+ * fabricated directory layouts for all ten rows on one machine, and it is what
16
+ * wave C4 lifts into `reference/install-method.ts` unchanged.
17
+ *
18
+ * Node built-ins only.
19
+ */
20
+ import fsDefault from "node:fs";
21
+ import path from "node:path";
22
+ /** `PADDOCK` → `PADDOCK_IN_DOCKER`. */
23
+ export const inDockerVar = (prefix) => `${prefix}_IN_DOCKER`;
24
+ /** `PADDOCK` → `PADDOCK_UPDATE_HINT`. */
25
+ export const updateHintVar = (prefix) => `${prefix}_UPDATE_HINT`;
26
+ /**
27
+ * Walk up from `entry` looking for the package root.
28
+ *
29
+ * Returns the first ancestor whose `package.json` has our name, and the nearest
30
+ * ancestor with any `package.json` as a fallback — see
31
+ * {@link InstallInfo.packageRoot}.
32
+ */
33
+ export function findPackageRoot(entry, packageName, fs) {
34
+ let dir = path.dirname(realpathOr(entry, fs));
35
+ let nearest;
36
+ for (;;) {
37
+ const manifest = path.join(dir, "package.json");
38
+ if (fs.existsSync(manifest)) {
39
+ nearest ??= dir;
40
+ try {
41
+ const raw = JSON.parse(fs.readFileSync(manifest, "utf8"));
42
+ if (raw.name === packageName)
43
+ return { root: dir, named: true };
44
+ }
45
+ catch {
46
+ // An unreadable or malformed manifest is not our package. Keep walking:
47
+ // a broken `package.json` in some intermediate directory must not stop
48
+ // the search before it reaches the real root.
49
+ }
50
+ }
51
+ const parent = path.dirname(dir);
52
+ if (parent === dir)
53
+ return { root: nearest, named: false };
54
+ dir = parent;
55
+ }
56
+ }
57
+ function realpathOr(p, fs) {
58
+ try {
59
+ return fs.realpathSync(p);
60
+ }
61
+ catch {
62
+ return p;
63
+ }
64
+ }
65
+ /**
66
+ * Is `p` inside a `node_modules` directory?
67
+ *
68
+ * Split on the separator rather than matched as a substring, so a project
69
+ * genuinely called `my-node_modules` is not mistaken for one (and so Windows
70
+ * paths work, since `path.sep` is what split them). SPEC §6's "checkout or npm
71
+ * link" row is exactly the negation of this.
72
+ */
73
+ export function underNodeModules(p) {
74
+ return p.split(/[/\\]/).includes("node_modules");
75
+ }
76
+ /**
77
+ * The npx / pnpm dlx / bunx / yarn dlx caches (SPEC §6).
78
+ *
79
+ * `_npx` and `bunx-` are matched between separators for the reason above.
80
+ * `dlx` has to be looser — pnpm's is `~/.local/share/pnpm/store/.../dlx/<hash>`
81
+ * and yarn's is `$TMPDIR/xfs-<hash>/dlx-<n>` — so a path SEGMENT that is `dlx`
82
+ * or starts `dlx-` counts.
83
+ */
84
+ export function isEphemeralCachePath(p) {
85
+ const parts = p.split(/[/\\]/);
86
+ return parts.some((seg) => seg === "_npx" || seg === "dlx" || seg.startsWith("dlx-") || seg.startsWith("bunx-"));
87
+ }
88
+ /** A path segment-aware "does this path contain this run of segments". */
89
+ function hasSegments(p, segments) {
90
+ const parts = p.split(/[/\\]/);
91
+ for (let i = 0; i + segments.length <= parts.length; i++) {
92
+ if (segments.every((s, j) => parts[i + j] === s))
93
+ return true;
94
+ }
95
+ return false;
96
+ }
97
+ /**
98
+ * Which of SPEC §6's rows this install is, checked in the table's own order.
99
+ *
100
+ * The order is the contract, and two pairs of rows genuinely overlap:
101
+ *
102
+ * - **Docker beats managed.** A container that an Ansible role also labelled
103
+ * has both variables set, and the honest answer is "this is an image" — but
104
+ * the hint is still carried on {@link InstallInfo.hint} and
105
+ * {@link updateCommand} prefers it, because a hint is a sentence an operator
106
+ * deliberately wrote and our guess at a `docker compose` line is not.
107
+ * - **Checkout beats every global.** They cannot both be true (every global
108
+ * layout is under a `node_modules`), so the order between them never decides
109
+ * anything; it is kept as the table has it so the two can be read side by
110
+ * side.
111
+ */
112
+ export function detectInstall(input) {
113
+ const fs = input.fs ?? fsDefault;
114
+ const env = input.env;
115
+ const { root, named } = findPackageRoot(input.entry, input.packageName, fs);
116
+ const hint = env[updateHintVar(input.envPrefix)];
117
+ const base = {
118
+ packageRoot: root,
119
+ named,
120
+ ...(hint !== undefined && hint !== "" ? { hint } : {}),
121
+ };
122
+ // 1. Docker. The tool's own Dockerfile sets this; `/.dockerenv` is NOT used,
123
+ // because dev containers have it too and a global npm install inside one
124
+ // is a legitimate thing to update (SPEC §6).
125
+ if (env[inDockerVar(input.envPrefix)] === "1") {
126
+ return { ...base, method: "docker", canUpdate: false };
127
+ }
128
+ // 2. Managed: something outside owns this install and said so.
129
+ if (base.hint !== undefined)
130
+ return { ...base, method: "managed", canUpdate: false };
131
+ // The rows below are all about WHERE the package sits, so with no package
132
+ // root there is nothing to say. Treat it as a checkout: it is the row whose
133
+ // advice ("you are running from source") is least wrong when we cannot tell,
134
+ // and it refuses to install rather than guessing a package manager.
135
+ if (root === undefined)
136
+ return { ...base, method: "checkout", canUpdate: false };
137
+ // 3. npx and friends: a hash-keyed cache that gets pruned.
138
+ if (isEphemeralCachePath(root))
139
+ return { ...base, method: "npx", canUpdate: false };
140
+ // 4. A checkout or an `npm link`: not under a node_modules at all.
141
+ if (!underNodeModules(root))
142
+ return { ...base, method: "checkout", canUpdate: false };
143
+ // 5. Global npm: the package root IS `<npm root -g>/<name>`.
144
+ const globalRoot = (input.npmGlobalRoot ?? (() => undefined))();
145
+ if (globalRoot !== undefined && samePath(root, path.join(globalRoot, input.packageName), fs)) {
146
+ return { ...base, method: "npm-global", canUpdate: true, manager: "npm" };
147
+ }
148
+ // 6–9. The other global layouts, each identified by a marker in its path.
149
+ if (hasSegments(root, ["pnpm", "global"])) {
150
+ return { ...base, method: "pnpm-global", canUpdate: true, manager: "pnpm" };
151
+ }
152
+ if (hasSegments(root, [".bun", "install", "global"])) {
153
+ return { ...base, method: "bun-global", canUpdate: true, manager: "bun" };
154
+ }
155
+ if (hasSegments(root, [".volta", "tools", "image", "packages"])) {
156
+ return { ...base, method: "volta", canUpdate: true, manager: "volta" };
157
+ }
158
+ // Yarn classic's global folder. `canUpdate: false`: `yarn global add` works,
159
+ // but classic is unmaintained and berry has no equivalent, so SPEC §6 prints
160
+ // the command rather than running it.
161
+ if (hasSegments(root, ["yarn", "global", "node_modules"])) {
162
+ return { ...base, method: "yarn-global", canUpdate: false };
163
+ }
164
+ // 10. Under somebody else's node_modules: a dependency of a project.
165
+ return { ...base, method: "dependency", canUpdate: false };
166
+ }
167
+ function samePath(a, b, fs) {
168
+ if (path.resolve(a) === path.resolve(b))
169
+ return true;
170
+ // A global root is routinely reached through a symlink (`/usr/local/lib` →
171
+ // `/opt/homebrew/lib`, nvm's per-version dirs), so compare realpaths too.
172
+ return realpathOr(a, fs) === realpathOr(b, fs);
173
+ }
174
+ /**
175
+ * The one command to print (SPEC §3, §6): what the reader should run instead.
176
+ *
177
+ * Returns the command and, where it helps, the parenthetical the spec's example
178
+ * carries (`(the global folder isn't writable)`). An install that CAN update
179
+ * itself gets `<tool> update`, which is the line the update notice prints.
180
+ */
181
+ export function updateCommand(info, input) {
182
+ const spec = input.target !== undefined ? `${input.packageName}@${input.target}` : input.packageName;
183
+ switch (info.method) {
184
+ case "docker": {
185
+ // A hint beats our guess: see detectInstall's note.
186
+ if (info.hint !== undefined)
187
+ return { command: info.hint };
188
+ const tag = input.target !== undefined ? `:${input.target}` : "";
189
+ return {
190
+ command: "docker compose pull && docker compose up -d",
191
+ ...(input.image !== undefined ? { note: `image ${input.image}${tag}` } : {}),
192
+ };
193
+ }
194
+ case "managed":
195
+ return { command: info.hint ?? "ask whoever manages this install" };
196
+ case "npx":
197
+ return {
198
+ command: `npx ${input.packageName}@latest`,
199
+ note: "npx already runs the latest version each time",
200
+ };
201
+ case "checkout":
202
+ return {
203
+ command: input.checkoutCommand ?? "git pull && npm install && npm run build",
204
+ note: "this is a checkout",
205
+ };
206
+ case "npm-global":
207
+ return {
208
+ command: `${input.sudo === true ? "sudo " : ""}npm install -g ${spec}`,
209
+ ...(input.sudo === true ? { note: "the global folder isn't writable" } : {}),
210
+ };
211
+ case "pnpm-global":
212
+ return { command: `pnpm add -g ${spec}` };
213
+ case "bun-global":
214
+ return { command: `bun add -g ${spec}` };
215
+ case "volta":
216
+ return { command: `volta install ${spec}` };
217
+ case "yarn-global":
218
+ return { command: `yarn global add ${spec}` };
219
+ case "dependency":
220
+ return {
221
+ command: `npm install ${spec}`,
222
+ note: `run it in the project that depends on ${input.packageName}${info.packageRoot !== undefined ? ` (${projectOf(info.packageRoot)})` : ""}`,
223
+ };
224
+ }
225
+ }
226
+ /** The project a dependency install belongs to: the directory above its `node_modules`. */
227
+ function projectOf(packageRoot) {
228
+ const parts = packageRoot.split(path.sep);
229
+ const i = parts.lastIndexOf("node_modules");
230
+ return i <= 0 ? packageRoot : parts.slice(0, i).join(path.sep);
231
+ }
232
+ /** One line a person can act on, for every row of the table. */
233
+ export function describeInstall(info, packageName) {
234
+ switch (info.method) {
235
+ case "docker":
236
+ return "running from a container image";
237
+ case "managed":
238
+ return "managed by something outside this machine's package manager";
239
+ case "npx":
240
+ return "running from an npx / dlx cache";
241
+ case "checkout":
242
+ return info.packageRoot !== undefined
243
+ ? `running from a checkout at ${info.packageRoot}`
244
+ : "running from a checkout";
245
+ case "npm-global":
246
+ return "installed globally with npm";
247
+ case "pnpm-global":
248
+ return "installed globally with pnpm";
249
+ case "bun-global":
250
+ return "installed globally with bun";
251
+ case "volta":
252
+ return "installed with volta";
253
+ case "yarn-global":
254
+ return "installed globally with yarn (classic)";
255
+ case "dependency":
256
+ return `installed as a dependency of another project (${packageName})`;
257
+ }
258
+ }
@@ -0,0 +1,40 @@
1
+ export declare const LOCK_FILE = "update.lock";
2
+ export declare function lockPath(home: string): string;
3
+ export interface LockBody {
4
+ pid: number;
5
+ startedAt: string;
6
+ /** What is holding it, for the message: `paddock update --to 0.81.0`. */
7
+ command?: string;
8
+ }
9
+ export interface Lock {
10
+ path: string;
11
+ /** Remove the file, but only if it is still ours. Safe to call twice. */
12
+ release(): void;
13
+ }
14
+ /** The lock is held by a live process. Carries its pid so the caller can say so. */
15
+ export declare class LockHeldError extends Error {
16
+ readonly holder: LockBody;
17
+ readonly path: string;
18
+ constructor(file: string, holder: LockBody);
19
+ }
20
+ export interface AcquireOptions {
21
+ home: string;
22
+ pid?: number;
23
+ command?: string;
24
+ /** Injected so a stale lock can be tested without spawning a process to kill. */
25
+ isAlive?: (pid: number) => boolean;
26
+ now?: () => Date;
27
+ }
28
+ /**
29
+ * Take the lock, or throw {@link LockHeldError}.
30
+ *
31
+ * The retry is deliberately ONCE. After removing a stale lock the create is
32
+ * attempted again, and a second `EEXIST` means another process won the same
33
+ * race in that microsecond — which is a genuine holder, not a stale file, so it
34
+ * refuses. Looping instead would be a way to spin forever against a lock being
35
+ * recreated as fast as it is deleted.
36
+ */
37
+ export declare function acquireLock(o: AcquireOptions): Lock;
38
+ export declare function readLock(file: string): LockBody | null;
39
+ /** Remove the lock only when it still names `pid`: never delete somebody else's. */
40
+ export declare function releaseLock(file: string, pid: number): void;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * `<home>/update.lock` — one update at a time (CLI contract SPEC §5 step 1).
3
+ *
4
+ * Two concurrent updates is not a theoretical race. The second one stops the
5
+ * service the first just started, or installs an older target over a newer one,
6
+ * and the loser's rollback then "restores" a version nobody asked for. It is
7
+ * also easy to cause: an Ansible run and a person at a terminal, or a scheduler
8
+ * firing twice.
9
+ *
10
+ * So: `O_EXCL` create, holding the pid. A lock whose pid is alive refuses with
11
+ * exit 1. A lock whose pid is gone — a crashed or killed update — is stale and
12
+ * is removed, because the alternative is an install that can never update
13
+ * itself again without someone finding out about a file they have never heard
14
+ * of.
15
+ *
16
+ * Generic: wave C4 lifts this into `reference/lock.ts`. Node built-ins only.
17
+ */
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ export const LOCK_FILE = "update.lock";
21
+ export function lockPath(home) {
22
+ return path.join(home, LOCK_FILE);
23
+ }
24
+ /** The lock is held by a live process. Carries its pid so the caller can say so. */
25
+ export class LockHeldError extends Error {
26
+ holder;
27
+ path;
28
+ constructor(file, holder) {
29
+ super(`another update is already running (pid ${holder.pid}, started ${holder.startedAt})\n` +
30
+ ` If that is wrong, remove ${file} and try again.`);
31
+ this.holder = holder;
32
+ this.path = file;
33
+ }
34
+ }
35
+ /**
36
+ * Take the lock, or throw {@link LockHeldError}.
37
+ *
38
+ * The retry is deliberately ONCE. After removing a stale lock the create is
39
+ * attempted again, and a second `EEXIST` means another process won the same
40
+ * race in that microsecond — which is a genuine holder, not a stale file, so it
41
+ * refuses. Looping instead would be a way to spin forever against a lock being
42
+ * recreated as fast as it is deleted.
43
+ */
44
+ export function acquireLock(o) {
45
+ const file = lockPath(o.home);
46
+ const pid = o.pid ?? process.pid;
47
+ const isAlive = o.isAlive ?? defaultIsAlive;
48
+ const body = {
49
+ pid,
50
+ startedAt: (o.now ?? (() => new Date()))().toISOString(),
51
+ ...(o.command !== undefined ? { command: o.command } : {}),
52
+ };
53
+ fs.mkdirSync(o.home, { recursive: true });
54
+ for (let attempt = 0; attempt < 2; attempt++) {
55
+ try {
56
+ // 'wx' is O_CREAT|O_EXCL|O_WRONLY: the create is the lock, in one
57
+ // syscall, so there is no window between "is it there?" and "make it".
58
+ fs.writeFileSync(file, `${JSON.stringify(body, null, 2)}\n`, { flag: "wx", mode: 0o600 });
59
+ return { path: file, release: () => releaseLock(file, pid) };
60
+ }
61
+ catch (err) {
62
+ if (err.code !== "EEXIST")
63
+ throw err;
64
+ const holder = readLock(file);
65
+ // An unreadable or pid-less lock file counts as stale: it is either
66
+ // half-written or from a version that wrote something else, and either
67
+ // way nobody can prove a process is holding it.
68
+ if (holder !== null && isAlive(holder.pid) && holder.pid !== pid) {
69
+ throw new LockHeldError(file, holder);
70
+ }
71
+ if (attempt === 1) {
72
+ throw new LockHeldError(file, holder ?? { pid: 0, startedAt: "unknown" });
73
+ }
74
+ fs.rmSync(file, { force: true });
75
+ }
76
+ }
77
+ // Unreachable: both branches of the loop above either return or throw.
78
+ throw new LockHeldError(file, { pid: 0, startedAt: "unknown" });
79
+ }
80
+ export function readLock(file) {
81
+ try {
82
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
83
+ if (Number.isInteger(raw.pid) && typeof raw.startedAt === "string")
84
+ return raw;
85
+ }
86
+ catch {
87
+ /* missing, half-written, or not ours */
88
+ }
89
+ return null;
90
+ }
91
+ /** Remove the lock only when it still names `pid`: never delete somebody else's. */
92
+ export function releaseLock(file, pid) {
93
+ const holder = readLock(file);
94
+ if (holder === null || holder.pid === pid)
95
+ fs.rmSync(file, { force: true });
96
+ }
97
+ function defaultIsAlive(pid) {
98
+ if (!Number.isInteger(pid) || pid <= 0)
99
+ return false;
100
+ try {
101
+ process.kill(pid, 0);
102
+ return true;
103
+ }
104
+ catch (err) {
105
+ // EPERM means the process exists and is not ours, which still counts.
106
+ return err.code === "EPERM";
107
+ }
108
+ }
@@ -0,0 +1,109 @@
1
+ import type { PackageManager } from "./install-method.js";
2
+ export interface RunResult {
3
+ status: number | null;
4
+ stdout: string;
5
+ stderr: string;
6
+ }
7
+ /** Run a command; never throws for a non-zero exit. Injected everywhere, for tests. */
8
+ export type Spawn = (cmd: string, args: string[], opts?: {
9
+ env?: NodeJS.ProcessEnv;
10
+ shell?: boolean;
11
+ timeoutMs?: number;
12
+ }) => RunResult;
13
+ export declare const spawnSyncRunner: Spawn;
14
+ /**
15
+ * The npm that belongs to the Node running us.
16
+ *
17
+ * `<dirname(execPath)>/npm` on POSIX; on Windows the sibling is `npm.cmd`.
18
+ * Falls back to the bare name — if the sibling is not there (a distro that
19
+ * splits node and npm into separate packages), PATH is the only answer left and
20
+ * is better than failing.
21
+ */
22
+ export declare function npmBeside(execPath?: string, platform?: NodeJS.Platform): string;
23
+ export interface NpmContext {
24
+ spawn?: Spawn;
25
+ env?: NodeJS.ProcessEnv;
26
+ execPath?: string;
27
+ platform?: NodeJS.Platform;
28
+ }
29
+ /** Run npm with no shell (except on Windows, where `npm.cmd` needs one). */
30
+ export declare function runNpm(args: string[], ctx?: NpmContext): RunResult;
31
+ /** `npm root -g`, or undefined when npm could not answer. */
32
+ export declare function npmGlobalRoot(ctx?: NpmContext): string | undefined;
33
+ /** `npm config get <key>`, or undefined. Used to resolve the registry (SPEC §5 step 3). */
34
+ export declare function npmConfigGet(key: string, ctx?: NpmContext): string | undefined;
35
+ /**
36
+ * Really writable? (SPEC §5 step 5.)
37
+ *
38
+ * `access(W_OK)` alone is not enough and the difference is the whole point of
39
+ * this check. It consults the mode bits, which say yes for root and say
40
+ * nothing about a read-only mount, an immutable flag, or an ACL — the usual
41
+ * shapes of "npm install -g needs sudo". So the directory is probed by
42
+ * CREATING something in it and removing it again, which is the same syscall
43
+ * npm is about to make.
44
+ *
45
+ * A missing directory walks up to its nearest existing parent: npm would create
46
+ * it, and whether it can is the parent's question.
47
+ */
48
+ export declare function isWritable(dir: string): boolean;
49
+ /**
50
+ * Where a package manager would put the package, so {@link isWritable} has a
51
+ * directory to ask about.
52
+ *
53
+ * Only npm can be asked properly (`npm root -g`). For the others the install
54
+ * command's own target is a good enough proxy — and if it is wrong, the install
55
+ * fails with that manager's own message and the rollback runs, which is a worse
56
+ * experience than the sudo hint but not a worse outcome.
57
+ */
58
+ export declare function globalRootFor(manager: PackageManager, ctx?: NpmContext): string | undefined;
59
+ export interface InstallInput {
60
+ manager: PackageManager;
61
+ packageName: string;
62
+ version: string;
63
+ spawn?: Spawn;
64
+ env?: NodeJS.ProcessEnv;
65
+ execPath?: string;
66
+ platform?: NodeJS.Platform;
67
+ }
68
+ /** The argv for a global install, per SPEC §6's last column. */
69
+ export declare function installArgs(manager: PackageManager, spec: string): {
70
+ cmd: string;
71
+ args: string[];
72
+ };
73
+ /**
74
+ * Install `<name>@<version>` with the manager this install came from.
75
+ *
76
+ * npm goes through {@link runNpm}, so it is the npm beside this Node. The other
77
+ * three are looked up on PATH, which is the only place they are: pnpm, bun and
78
+ * volta are not installed beside node.
79
+ */
80
+ export declare function installPackage(o: InstallInput): RunResult;
81
+ /**
82
+ * `npm cache add <name>@<version>` — fetch before stopping anything
83
+ * (SPEC §5 step 8).
84
+ *
85
+ * Both the target and the CURRENT version are cached, and caching the current
86
+ * one is the half that is easy to leave out and expensive to miss: it is what
87
+ * gives the rollback a package to reinstall when the reason the update failed
88
+ * was the network. Done while the old version is still serving, so a failure
89
+ * here costs nothing but a message.
90
+ *
91
+ * `npm cache add` is used whatever the manager is, because every one of these
92
+ * layouts has an npm available (it ships with Node) and the point is only to
93
+ * have the tarball on the machine. A failure is reported and not fatal — the
94
+ * install below may still succeed from the network.
95
+ */
96
+ export declare function cacheAdd(packageName: string, version: string, ctx?: NpmContext): RunResult;
97
+ /**
98
+ * Ask a CLI what version it is, by running it (`node <cli> --version`).
99
+ *
100
+ * This is how `update` proves that the install actually replaced the files
101
+ * before it hands over to the new CLI. The alternative — reading the
102
+ * `package.json` next to it — answers a different question: npm writes the
103
+ * manifest and the files in one operation, but a partially-updated global
104
+ * folder, a stale symlink, or a pnpm layout whose versioned realpath moved all
105
+ * show up here and not there.
106
+ */
107
+ export declare function cliVersion(cliPath: string, ctx?: NpmContext): string | undefined;
108
+ /** A throwaway directory, for callers that need one (the writability probe's sibling). */
109
+ export declare function tmpDir(prefix: string): string;