@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,108 +1,74 @@
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";
1
+ // From edspencer/cli-contract reference/lock.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.lock`: one update at a time (SPEC §5 step 1). Two at once is easy to cause (an Ansible run and a
3
+ // person at a terminal) and bad: the second stops the service the first just started, or installs an older target
4
+ // over a newer one, and the loser's rollback "restores" a version nobody asked for.
5
+ //
6
+ // So the lock is an `O_EXCL` create holding the pid. A live holder refuses with exit 1; a dead one (a crashed or
7
+ // killed update) is stale and is removed, or the install could never update itself again. Node built-ins only.
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { isPidAlive } from '../../runtime-file.js';
11
+ export const LOCK_FILE = 'update.lock';
21
12
  export function lockPath(home) {
22
13
  return path.join(home, LOCK_FILE);
23
14
  }
24
- /** The lock is held by a live process. Carries its pid so the caller can say so. */
15
+ /** Another live process holds the lock. */
25
16
  export class LockHeldError extends Error {
17
+ name = 'LockHeldError';
26
18
  holder;
27
19
  path;
28
20
  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.`);
21
+ super(`another update is already running (pid ${holder.pid}, started ${holder.startedAt}). If that's wrong, remove ${file} and try again.`);
31
22
  this.holder = holder;
32
23
  this.path = file;
33
24
  }
34
25
  }
35
26
  /**
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.
27
+ * Takes the lock, or throws `LockHeldError`. After removing a stale lock it tries once more, and only once: a second
28
+ * `EEXIST` means another process won the same race just now, which is a real holder.
43
29
  */
44
30
  export function acquireLock(o) {
45
31
  const file = lockPath(o.home);
46
32
  const pid = o.pid ?? process.pid;
47
- const isAlive = o.isAlive ?? defaultIsAlive;
33
+ const isAlive = o.isAlive ?? isPidAlive;
48
34
  const body = {
49
35
  pid,
50
36
  startedAt: (o.now ?? (() => new Date()))().toISOString(),
51
37
  ...(o.command !== undefined ? { command: o.command } : {}),
52
38
  };
53
39
  fs.mkdirSync(o.home, { recursive: true });
54
- for (let attempt = 0; attempt < 2; attempt++) {
40
+ for (let attempt = 0;; attempt++) {
55
41
  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 });
42
+ // 'wx' is O_CREAT | O_EXCL: the create is the lock, in one call.
43
+ fs.writeFileSync(file, `${JSON.stringify(body, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
59
44
  return { path: file, release: () => releaseLock(file, pid) };
60
45
  }
61
46
  catch (err) {
62
- if (err.code !== "EEXIST")
47
+ if (err.code !== 'EEXIST')
63
48
  throw err;
64
49
  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" });
50
+ // A lock with no readable pid counts as stale: nobody can show a process holds it.
51
+ if ((holder !== null && holder.pid !== pid && isAlive(holder.pid)) || attempt > 0) {
52
+ throw new LockHeldError(file, holder ?? { pid: 0, startedAt: 'unknown' });
73
53
  }
74
54
  fs.rmSync(file, { force: true });
75
55
  }
76
56
  }
77
- // Unreachable: both branches of the loop above either return or throw.
78
- throw new LockHeldError(file, { pid: 0, startedAt: "unknown" });
79
57
  }
80
58
  export function readLock(file) {
81
59
  try {
82
- const raw = JSON.parse(fs.readFileSync(file, "utf8"));
83
- if (Number.isInteger(raw.pid) && typeof raw.startedAt === "string")
60
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
61
+ if (Number.isInteger(raw.pid) && typeof raw.startedAt === 'string')
84
62
  return raw;
85
63
  }
86
64
  catch {
87
- /* missing, half-written, or not ours */
65
+ // missing, half-written, or not a lock
88
66
  }
89
67
  return null;
90
68
  }
91
- /** Remove the lock only when it still names `pid`: never delete somebody else's. */
69
+ /** Removes the lock only if it names `pid`, or names nothing: never somebody else's. */
92
70
  export function releaseLock(file, pid) {
93
71
  const holder = readLock(file);
94
72
  if (holder === null || holder.pid === pid)
95
73
  fs.rmSync(file, { force: true });
96
74
  }
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
- }
@@ -1,109 +1,74 @@
1
- import type { PackageManager } from "./install-method.js";
1
+ import type { PackageManager } from './install-method.js';
2
2
  export interface RunResult {
3
3
  status: number | null;
4
4
  stdout: string;
5
5
  stderr: string;
6
6
  }
7
- /** Run a command; never throws for a non-zero exit. Injected everywhere, for tests. */
7
+ /** Runs a command to completion; never throws for a non-zero exit. Injected everywhere, for tests. */
8
8
  export type Spawn = (cmd: string, args: string[], opts?: {
9
9
  env?: NodeJS.ProcessEnv;
10
10
  shell?: boolean;
11
11
  timeoutMs?: number;
12
12
  }) => RunResult;
13
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;
14
+ /** How long an install may take. */
15
+ export declare const INSTALL_TIMEOUT_MS: number;
23
16
  export interface NpmContext {
24
17
  spawn?: Spawn;
25
18
  env?: NodeJS.ProcessEnv;
26
19
  execPath?: string;
27
20
  platform?: NodeJS.Platform;
21
+ /** Injected for tests: does this file exist? */
22
+ exists?: (p: string) => boolean;
23
+ realpath?: (p: string) => string;
28
24
  }
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. */
25
+ /**
26
+ * How to run the npm that belongs to this Node: `[execPath, npm-cli.js]`, found beside it (`<dir>/npm` is a symlink
27
+ * to it on POSIX; on Windows it's `<dir>/node_modules/npm/bin/npm-cli.js`). Falls back to `npm` on the PATH, for a
28
+ * distribution that packages npm separately.
29
+ */
30
+ export declare function npmCommand(ctx?: NpmContext): {
31
+ cmd: string;
32
+ pre: string[];
33
+ shell: boolean;
34
+ };
35
+ /** Runs this Node's npm. */
36
+ export declare function runNpm(args: string[], ctx?: NpmContext, timeoutMs?: number): RunResult;
37
+ /** `npm root -g`, or undefined when npm can't answer. */
32
38
  export declare function npmGlobalRoot(ctx?: NpmContext): string | undefined;
33
- /** `npm config get <key>`, or undefined. Used to resolve the registry (SPEC §5 step 3). */
39
+ /** `npm config get <key>`, or undefined. */
34
40
  export declare function npmConfigGet(key: string, ctx?: NpmContext): string | undefined;
35
41
  /**
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.
42
+ * Whether `dir` is really writable (SPEC §5 step 5): a folder is created in it and removed again, the same call npm
43
+ * is about to make. `access(W_OK)` alone says yes to root on a read-only mount, and nothing about ACLs or immutable
44
+ * flags. A folder that doesn't exist yet asks its nearest existing parent, since npm would create it.
47
45
  */
48
46
  export declare function isWritable(dir: string): boolean;
49
47
  /**
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.
48
+ * Where a manager would install, for `isWritable` to ask about. Only npm can be asked (`npm root -g`). For the others
49
+ * there's no answer, and an install that can't write fails with that manager's own message, and rolls back.
57
50
  */
58
51
  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. */
52
+ /** The global install command, per SPEC §6's last column. */
69
53
  export declare function installArgs(manager: PackageManager, spec: string): {
70
54
  cmd: string;
71
55
  args: string[];
72
56
  };
57
+ /** Installs `<name>@<version>` with the manager this install came from. pnpm, bun and volta come from the PATH. */
58
+ export declare function installPackage(o: NpmContext & {
59
+ manager: PackageManager;
60
+ packageName: string;
61
+ version: string;
62
+ }): RunResult;
73
63
  /**
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.
64
+ * `npm cache add <name>@<version>` (SPEC §5 step 8), for the target and for the current version, while the old one
65
+ * still serves. Caching the current one is what lets a rollback reinstall it when the network is what failed. npm
66
+ * is used whatever the manager: it ships with Node, and the point is only to have the tarball on the machine.
95
67
  */
96
68
  export declare function cacheAdd(packageName: string, version: string, ctx?: NpmContext): RunResult;
97
69
  /**
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.
70
+ * A CLI's version, by running it: `node <cli> --version`. This is how `update` shows the install replaced the files
71
+ * before it hands over. A `package.json` beside it answers a different question: a stale symlink, or a pnpm layout
72
+ * whose versioned path moved, shows up here and not there.
106
73
  */
107
74
  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;
@@ -1,107 +1,95 @@
1
- /**
2
- * Running the package manager (CLI contract SPEC §5 steps 8 and 10, §6, §7).
3
- *
4
- * Three jobs, all of them small and all of them easy to get subtly wrong:
5
- *
6
- * - **Find npm.** SPEC §6 and §10 both say the `npm` next to
7
- * `process.execPath`, not whatever is on PATH. Under nvm, fnm or volta those
8
- * are routinely different npms belonging to different Nodes, and asking the
9
- * wrong one `npm root -g` gets a global folder this install is not in — which
10
- * would report a perfectly ordinary global install as a `dependency` and
11
- * refuse to update it.
12
- * - **No shell.** `spawnSync(npm, args)` with the default `shell: false`, so a
13
- * package name or a path with a space in it cannot become two arguments.
14
- * Windows is the exception and has to be: `npm` there is `npm.cmd`, which
15
- * Node refuses to spawn without a shell since its fix for CVE-2024-27980
16
- * (SPEC §7). That path quotes its arguments itself.
17
- * - **Can we write there?** SPEC §5 step 5. Asked before anything is stopped,
18
- * and answered by actually creating a file rather than by `access()` alone —
19
- * see {@link isWritable}.
20
- *
21
- * Generic: wave C4 lifts this into `reference/`. Node built-ins only.
22
- */
23
- import { spawnSync } from "node:child_process";
24
- import fs from "node:fs";
25
- import os from "node:os";
26
- import path from "node:path";
1
+ // From edspencer/cli-contract reference/package-manager.ts @ 0.2; local changes: `.ts` import specifiers → `.js`, and `exit-codes`, `output` and `runtime-file` imported from where Paddock keeps them (`../`, `../../`).
2
+ // Running the package manager (SPEC §5 steps 5, 8 and 10, §6, §7). Node built-ins only.
3
+ //
4
+ // - **npm is the one beside `process.execPath`**, run as `node <npm-cli.js>`, never whichever `npm` is first on PATH.
5
+ // Under nvm, fnm or volta those are different npms for different Nodes, and the wrong one's `npm root -g` is a
6
+ // global folder this install isn't in: an ordinary global install would read as a dependency and refuse to update.
7
+ // Running `npm-cli.js` with this Node also needs no shell on Windows, where `npm.cmd` would (CVE-2024-27980).
8
+ // - **No shell otherwise**, so a path or a name with a space in it can't become two arguments. pnpm, bun and volta on
9
+ // Windows are `.cmd` files and do need one; their arguments are quoted for `cmd.exe` here.
10
+ // - **Writable means a file can be created**, not just that `access()` says yes (`isWritable`).
11
+ import { spawnSync } from 'node:child_process';
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
27
14
  export const spawnSyncRunner = (cmd, args, opts = {}) => {
28
15
  const r = spawnSync(cmd, args, {
29
- encoding: "utf8",
16
+ encoding: 'utf8',
30
17
  ...(opts.env !== undefined ? { env: opts.env } : {}),
31
18
  ...(opts.shell === true ? { shell: true } : {}),
32
19
  ...(opts.timeoutMs !== undefined ? { timeout: opts.timeoutMs } : {}),
33
20
  maxBuffer: 32 * 1024 * 1024,
21
+ windowsHide: true,
34
22
  });
35
- return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
23
+ return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? (r.error ? String(r.error.message) : '') };
36
24
  };
25
+ /** How long an install may take. */
26
+ export const INSTALL_TIMEOUT_MS = 15 * 60 * 1000;
37
27
  /**
38
- * The npm that belongs to the Node running us.
39
- *
40
- * `<dirname(execPath)>/npm` on POSIX; on Windows the sibling is `npm.cmd`.
41
- * Falls back to the bare name — if the sibling is not there (a distro that
42
- * splits node and npm into separate packages), PATH is the only answer left and
43
- * is better than failing.
28
+ * How to run the npm that belongs to this Node: `[execPath, npm-cli.js]`, found beside it (`<dir>/npm` is a symlink
29
+ * to it on POSIX; on Windows it's `<dir>/node_modules/npm/bin/npm-cli.js`). Falls back to `npm` on the PATH, for a
30
+ * distribution that packages npm separately.
44
31
  */
45
- export function npmBeside(execPath = process.execPath, platform = process.platform) {
32
+ export function npmCommand(ctx = {}) {
33
+ const execPath = ctx.execPath ?? process.execPath;
34
+ const platform = ctx.platform ?? process.platform;
35
+ const exists = ctx.exists ?? ((p) => fs.existsSync(p));
36
+ const realpath = ctx.realpath ?? ((p) => fs.realpathSync(p));
46
37
  const dir = path.dirname(execPath);
47
- const candidate = path.join(dir, platform === "win32" ? "npm.cmd" : "npm");
48
- try {
49
- if (fs.existsSync(candidate))
50
- return candidate;
38
+ const candidates = platform === 'win32'
39
+ ? [path.join(dir, 'node_modules', 'npm', 'bin', 'npm-cli.js')]
40
+ : [path.join(dir, 'npm'), path.join(dir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')];
41
+ for (const c of candidates) {
42
+ if (!exists(c))
43
+ continue;
44
+ let real = c;
45
+ try {
46
+ real = realpath(c);
47
+ }
48
+ catch {
49
+ // keep the candidate
50
+ }
51
+ if (real.endsWith('.js'))
52
+ return { cmd: execPath, pre: [real], shell: false };
51
53
  }
52
- catch {
53
- /* fall through */
54
- }
55
- return platform === "win32" ? "npm.cmd" : "npm";
54
+ return platform === 'win32' ? { cmd: 'npm.cmd', pre: [], shell: true } : { cmd: 'npm', pre: [], shell: false };
56
55
  }
57
- /** Run npm with no shell (except on Windows, where `npm.cmd` needs one). */
58
- export function runNpm(args, ctx = {}) {
59
- const platform = ctx.platform ?? process.platform;
60
- const cmd = npmBeside(ctx.execPath ?? process.execPath, platform);
56
+ /** Quotes each word for `cmd.exe`, for the one case that needs a shell. */
57
+ function cmdLine(cmd, args) {
58
+ const q = (a) => (/^[\w@./:=+-]+$/.test(a) ? a : `"${a.replace(/"/g, '""')}"`);
59
+ return { cmd: [cmd, ...args].map(q).join(' '), args: [] };
60
+ }
61
+ function runWith(cmd, args, shell, ctx, timeoutMs) {
61
62
  const run = ctx.spawn ?? spawnSyncRunner;
62
- return run(cmd, args, {
63
- ...(ctx.env !== undefined ? { env: ctx.env } : {}),
64
- // The ONE place a shell is used, and only where Node leaves no choice. The
65
- // arguments passed through here are package specs and npm flags we build
66
- // ourselves, never user text.
67
- ...(platform === "win32" ? { shell: true } : {}),
68
- timeoutMs: 15 * 60 * 1000,
69
- });
63
+ const line = shell ? cmdLine(cmd, args) : { cmd, args };
64
+ return run(line.cmd, line.args, { ...(ctx.env !== undefined ? { env: ctx.env } : {}), ...(shell ? { shell: true } : {}), timeoutMs });
70
65
  }
71
- /** `npm root -g`, or undefined when npm could not answer. */
72
- export function npmGlobalRoot(ctx = {}) {
73
- const r = runNpm(["root", "-g"], ctx);
66
+ /** Runs this Node's npm. */
67
+ export function runNpm(args, ctx = {}, timeoutMs = INSTALL_TIMEOUT_MS) {
68
+ const { cmd, pre, shell } = npmCommand(ctx);
69
+ return runWith(cmd, [...pre, ...args], shell, ctx, timeoutMs);
70
+ }
71
+ const lastLine = (r) => {
74
72
  if (r.status !== 0)
75
73
  return undefined;
76
- const out = r.stdout.trim().split("\n").at(-1)?.trim();
77
- return out !== undefined && out !== "" ? out : undefined;
74
+ const out = r.stdout.trim().split(/\r?\n/).at(-1)?.trim();
75
+ return out !== undefined && out !== '' && out !== 'undefined' && out !== 'null' ? out : undefined;
76
+ };
77
+ /** `npm root -g`, or undefined when npm can't answer. */
78
+ export function npmGlobalRoot(ctx = {}) {
79
+ return lastLine(runNpm(['root', '-g'], ctx, 60_000));
78
80
  }
79
- /** `npm config get <key>`, or undefined. Used to resolve the registry (SPEC §5 step 3). */
81
+ /** `npm config get <key>`, or undefined. */
80
82
  export function npmConfigGet(key, ctx = {}) {
81
- const r = runNpm(["config", "get", key], ctx);
82
- if (r.status !== 0)
83
- return undefined;
84
- const out = r.stdout.trim().split("\n").at(-1)?.trim();
85
- return out !== undefined && out !== "" && out !== "undefined" && out !== "null" ? out : undefined;
83
+ return lastLine(runNpm(['config', 'get', key], ctx, 60_000));
86
84
  }
87
85
  /**
88
- * Really writable? (SPEC §5 step 5.)
89
- *
90
- * `access(W_OK)` alone is not enough and the difference is the whole point of
91
- * this check. It consults the mode bits, which say yes for root and say
92
- * nothing about a read-only mount, an immutable flag, or an ACL — the usual
93
- * shapes of "npm install -g needs sudo". So the directory is probed by
94
- * CREATING something in it and removing it again, which is the same syscall
95
- * npm is about to make.
96
- *
97
- * A missing directory walks up to its nearest existing parent: npm would create
98
- * it, and whether it can is the parent's question.
86
+ * Whether `dir` is really writable (SPEC §5 step 5): a folder is created in it and removed again, the same call npm
87
+ * is about to make. `access(W_OK)` alone says yes to root on a read-only mount, and nothing about ACLs or immutable
88
+ * flags. A folder that doesn't exist yet asks its nearest existing parent, since npm would create it.
99
89
  */
100
90
  export function isWritable(dir) {
101
91
  let probe = dir;
102
- for (;;) {
103
- if (fs.existsSync(probe))
104
- break;
92
+ while (!fs.existsSync(probe)) {
105
93
  const parent = path.dirname(probe);
106
94
  if (parent === probe)
107
95
  return false;
@@ -109,13 +97,7 @@ export function isWritable(dir) {
109
97
  }
110
98
  try {
111
99
  fs.accessSync(probe, fs.constants.W_OK);
112
- }
113
- catch {
114
- return false;
115
- }
116
- try {
117
- const tmp = fs.mkdtempSync(path.join(probe, ".update-probe-"));
118
- fs.rmSync(tmp, { recursive: true, force: true });
100
+ fs.rmSync(fs.mkdtempSync(path.join(probe, '.update-probe-')), { recursive: true, force: true });
119
101
  return true;
120
102
  }
121
103
  catch {
@@ -123,99 +105,52 @@ export function isWritable(dir) {
123
105
  }
124
106
  }
125
107
  /**
126
- * Where a package manager would put the package, so {@link isWritable} has a
127
- * directory to ask about.
128
- *
129
- * Only npm can be asked properly (`npm root -g`). For the others the install
130
- * command's own target is a good enough proxy — and if it is wrong, the install
131
- * fails with that manager's own message and the rollback runs, which is a worse
132
- * experience than the sudo hint but not a worse outcome.
108
+ * Where a manager would install, for `isWritable` to ask about. Only npm can be asked (`npm root -g`). For the others
109
+ * there's no answer, and an install that can't write fails with that manager's own message, and rolls back.
133
110
  */
134
111
  export function globalRootFor(manager, ctx = {}) {
135
- if (manager === "npm")
136
- return npmGlobalRoot(ctx);
137
- return undefined;
112
+ return manager === 'npm' ? npmGlobalRoot(ctx) : undefined;
138
113
  }
139
- /** The argv for a global install, per SPEC §6's last column. */
114
+ /** The global install command, per SPEC §6's last column. */
140
115
  export function installArgs(manager, spec) {
141
116
  switch (manager) {
142
- case "npm":
143
- // `--no-audit --no-fund` keeps the output to the point; `--global` is
144
- // spelled out rather than `-g` so a log of what ran reads plainly.
145
- return { cmd: "npm", args: ["install", "--global", "--no-audit", "--no-fund", spec] };
146
- case "pnpm":
147
- return { cmd: "pnpm", args: ["add", "-g", spec] };
148
- case "bun":
149
- return { cmd: "bun", args: ["add", "-g", spec] };
150
- case "volta":
151
- return { cmd: "volta", args: ["install", spec] };
117
+ case 'npm':
118
+ return { cmd: 'npm', args: ['install', '--global', '--no-audit', '--no-fund', spec] };
119
+ case 'pnpm':
120
+ return { cmd: 'pnpm', args: ['add', '-g', spec] };
121
+ case 'bun':
122
+ return { cmd: 'bun', args: ['add', '-g', spec] };
123
+ case 'volta':
124
+ return { cmd: 'volta', args: ['install', spec] };
152
125
  }
153
126
  }
154
- /**
155
- * Install `<name>@<version>` with the manager this install came from.
156
- *
157
- * npm goes through {@link runNpm}, so it is the npm beside this Node. The other
158
- * three are looked up on PATH, which is the only place they are: pnpm, bun and
159
- * volta are not installed beside node.
160
- */
127
+ /** Installs `<name>@<version>` with the manager this install came from. pnpm, bun and volta come from the PATH. */
161
128
  export function installPackage(o) {
162
- const spec = `${o.packageName}@${o.version}`;
163
- const { cmd, args } = installArgs(o.manager, spec);
164
- if (o.manager === "npm") {
165
- return runNpm(args, {
166
- ...(o.spawn !== undefined ? { spawn: o.spawn } : {}),
167
- ...(o.env !== undefined ? { env: o.env } : {}),
168
- ...(o.execPath !== undefined ? { execPath: o.execPath } : {}),
169
- ...(o.platform !== undefined ? { platform: o.platform } : {}),
170
- });
171
- }
172
- const run = o.spawn ?? spawnSyncRunner;
173
- return run(cmd, args, {
174
- ...(o.env !== undefined ? { env: o.env } : {}),
175
- ...((o.platform ?? process.platform) === "win32" ? { shell: true } : {}),
176
- timeoutMs: 15 * 60 * 1000,
177
- });
129
+ const { cmd, args } = installArgs(o.manager, `${o.packageName}@${o.version}`);
130
+ if (o.manager === 'npm')
131
+ return runNpm(args, o);
132
+ return runWith(cmd, args, (o.platform ?? process.platform) === 'win32', o, INSTALL_TIMEOUT_MS);
178
133
  }
179
134
  /**
180
- * `npm cache add <name>@<version>` — fetch before stopping anything
181
- * (SPEC §5 step 8).
182
- *
183
- * Both the target and the CURRENT version are cached, and caching the current
184
- * one is the half that is easy to leave out and expensive to miss: it is what
185
- * gives the rollback a package to reinstall when the reason the update failed
186
- * was the network. Done while the old version is still serving, so a failure
187
- * here costs nothing but a message.
188
- *
189
- * `npm cache add` is used whatever the manager is, because every one of these
190
- * layouts has an npm available (it ships with Node) and the point is only to
191
- * have the tarball on the machine. A failure is reported and not fatal — the
192
- * install below may still succeed from the network.
135
+ * `npm cache add <name>@<version>` (SPEC §5 step 8), for the target and for the current version, while the old one
136
+ * still serves. Caching the current one is what lets a rollback reinstall it when the network is what failed. npm
137
+ * is used whatever the manager: it ships with Node, and the point is only to have the tarball on the machine.
193
138
  */
194
139
  export function cacheAdd(packageName, version, ctx = {}) {
195
- return runNpm(["cache", "add", `${packageName}@${version}`], ctx);
140
+ return runNpm(['cache', 'add', `${packageName}@${version}`], ctx);
196
141
  }
197
142
  /**
198
- * Ask a CLI what version it is, by running it (`node <cli> --version`).
199
- *
200
- * This is how `update` proves that the install actually replaced the files
201
- * before it hands over to the new CLI. The alternative — reading the
202
- * `package.json` next to it — answers a different question: npm writes the
203
- * manifest and the files in one operation, but a partially-updated global
204
- * folder, a stale symlink, or a pnpm layout whose versioned realpath moved all
205
- * show up here and not there.
143
+ * A CLI's version, by running it: `node <cli> --version`. This is how `update` shows the install replaced the files
144
+ * before it hands over. A `package.json` beside it answers a different question: a stale symlink, or a pnpm layout
145
+ * whose versioned path moved, shows up here and not there.
206
146
  */
207
147
  export function cliVersion(cliPath, ctx = {}) {
208
- const run = ctx.spawn ?? spawnSyncRunner;
209
- const r = run(ctx.execPath ?? process.execPath, [cliPath, "--version"], {
148
+ const r = (ctx.spawn ?? spawnSyncRunner)(ctx.execPath ?? process.execPath, [cliPath, '--version'], {
210
149
  ...(ctx.env !== undefined ? { env: ctx.env } : {}),
211
150
  timeoutMs: 60_000,
212
151
  });
213
152
  if (r.status !== 0)
214
153
  return undefined;
215
- const line = r.stdout.trim().split("\n")[0]?.trim();
154
+ const line = r.stdout.trim().split(/\r?\n/)[0]?.trim();
216
155
  return line !== undefined && /^\d+\.\d+\.\d+/.test(line) ? line : undefined;
217
156
  }
218
- /** A throwaway directory, for callers that need one (the writability probe's sibling). */
219
- export function tmpDir(prefix) {
220
- return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
221
- }