mirror-mind 0.33.0 → 0.33.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.
@@ -1,20 +1,36 @@
1
1
  // Release-note reading for the runtime surface (CV22.DS7.TS3, plateau 2).
2
2
  // Port of the release-note half of `src/memory/cli/runtime.py`.
3
3
  //
4
- // Two sources, one document shape. The working tree answers "what does this
5
- // checkout contain"; a git ref answers "what does the channel contain that I
6
- // have not merged", which is the question `release-notes pending` exists for
7
- // and the reason this path shells out to `ls-tree`/`show` instead of reading
8
- // files. Ordering is by semantic version everywhere: `v0.10.0` follows
9
- // `v0.9.0`, which a lexical sort gets backwards.
4
+ // Three sources, one document shape. A tree answers "what does this tree
5
+ // contain": `docs/releases` under the root the caller names, read as files
6
+ // with no git call -- the caller passes the running tree, which for a package
7
+ // may sit inside a git work tree it does not own, such as Homebrew's prefix
8
+ // (CR133). The other two answer `release-notes pending`, "what does my channel
9
+ // hold that I do not", for the install that is running and never for the cwd
10
+ // (CR134): a clone's git ref, read in the clone's own repository -- the only
11
+ // path here that shells out to git -- and a package's channel version,
12
+ // resolved by the registry and read from that version's tarball in memory.
13
+ // Ordering is by semantic version everywhere: `v0.10.0` follows `v0.9.0`,
14
+ // which a lexical sort gets backwards.
10
15
  //
11
- // `fetch` is the one command here that writes anything — remote-tracking refs,
12
- // never the worktree — and only when `release-notes pending` is allowed to
13
- // refresh. Everything else reads.
16
+ // When no source answers, the bundle says so -- `unknown`, with a reason --
17
+ // rather than `none`, which means a source answered and held nothing newer.
18
+ //
19
+ // `fetch` writes remote-tracking refs in the clone, never its worktree, and
20
+ // `npm pack` one tarball into a private directory removed after the read.
21
+ // Everything else reads.
14
22
 
15
- import { existsSync, readdirSync, readFileSync } from "node:fs";
23
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
24
+ import { tmpdir } from "node:os";
16
25
  import { basename, join, resolve } from "node:path";
26
+ import {
27
+ channelDirection,
28
+ type NpmRunner,
29
+ type PackageInstall,
30
+ packTarball,
31
+ } from "#runtime/strategies/package.ts";
17
32
  import { compareSemver } from "#util/semver.ts";
33
+ import { MAX_INFLATED_BYTES, readTarGzEntries } from "#util/tarEntries.ts";
18
34
  import { GIT_NETWORK_TIMEOUT_MS, runGit } from "./git.ts";
19
35
 
20
36
  export interface ReleaseNote {
@@ -29,6 +45,8 @@ export interface ReleaseNotesBundle {
29
45
  notes: ReleaseNote[];
30
46
  current_version: string | null;
31
47
  source: string | null;
48
+ /** Why no source answered; null when one did, with notes or with none (CR134). */
49
+ unknown_reason: string | null;
32
50
  }
33
51
 
34
52
  const NOTE_NAME = /^v\d+\.\d+\.\d+\.md$/;
@@ -99,15 +117,13 @@ export function releaseNoteFromText(path: string, text: string): ReleaseNote {
99
117
  };
100
118
  }
101
119
 
102
- function repositoryFor(start: string): string {
103
- const startPath = resolve(start);
104
- const top = runGit(["rev-parse", "--show-toplevel"], startPath);
105
- return top.code === 0 && top.stdout ? top.stdout : startPath;
106
- }
107
-
108
- /** Notes in the working tree, ordered by semantic version. */
109
- export function readReleaseNotes(start: string): ReleaseNote[] {
110
- const dir = join(repositoryFor(start), "docs", "releases");
120
+ /**
121
+ * Notes under `<root>/docs/releases`, ordered by semantic version. The root is
122
+ * read as given: asking git for its repository would climb out of a package
123
+ * installed under a prefix that is itself a work tree (CR133).
124
+ */
125
+ export function readReleaseNotes(root: string): ReleaseNote[] {
126
+ const dir = join(resolve(root), "docs", "releases");
111
127
  if (!existsSync(dir)) return [];
112
128
  return readdirSync(dir)
113
129
  .filter((name) => NOTE_NAME.test(name))
@@ -118,58 +134,104 @@ export function readReleaseNotes(start: string): ReleaseNote[] {
118
134
  });
119
135
  }
120
136
 
121
- export function readReleaseNote(version: string, start: string): ReleaseNote | null {
122
- const notes = readReleaseNotes(start);
137
+ export function readReleaseNote(version: string, root: string): ReleaseNote | null {
138
+ const notes = readReleaseNotes(root);
123
139
  if (version === "latest")
124
140
  return notes.length > 0 ? (notes[notes.length - 1] as ReleaseNote) : null;
125
141
  const wanted = version.startsWith("v") ? version : `v${version}`;
126
142
  return notes.find((note) => note.version === wanted) ?? null;
127
143
  }
128
144
 
129
- function refNoteNames(ref: string, repository: string): string[] {
130
- const listed = runGit(["ls-tree", "-r", "--name-only", ref, "docs/releases"], repository);
131
- if (listed.code !== 0 || !listed.stdout) return [];
132
- return listed.stdout
145
+ /**
146
+ * A ref is user-supplied (`--ref`), and git takes an argument that begins with
147
+ * `-` as an option, `--upload-pack=<command>` among them (CR134). Such a ref is
148
+ * refused before git runs, and every verb below also puts `--end-of-options`
149
+ * before its operands, which covers the half of a ref after its first slash.
150
+ */
151
+ function refusedRef(ref: string): string | null {
152
+ if (ref === "") return "no ref was named";
153
+ if (ref.startsWith("-")) return `ref ${JSON.stringify(ref)} begins with '-' and is refused`;
154
+ return null;
155
+ }
156
+
157
+ function firstLine(text: string): string {
158
+ return (
159
+ text
160
+ .split("\n")
161
+ .find((line) => line.trim() !== "")
162
+ ?.trim() ?? ""
163
+ );
164
+ }
165
+
166
+ type RefNames = { ok: true; names: string[] } | { ok: false; detail: string };
167
+
168
+ /** The note paths at `ref`. A ref that does not resolve is said, not read as empty. */
169
+ function refNoteNames(ref: string, repository: string): RefNames {
170
+ const refused = refusedRef(ref);
171
+ if (refused !== null) return { ok: false, detail: refused };
172
+ const listed = runGit(
173
+ ["ls-tree", "-r", "--name-only", "--end-of-options", ref, "docs/releases"],
174
+ repository,
175
+ );
176
+ if (listed.code !== 0) return { ok: false, detail: `ref ${ref} does not resolve` };
177
+ const names = listed.stdout
133
178
  .split("\n")
134
179
  .map((line) => line.trim())
135
180
  .filter((line) => REF_NOTE_PATH.test(line));
181
+ return { ok: true, names };
182
+ }
183
+
184
+ function showAtRef(ref: string, path: string, repository: string): string | null {
185
+ const shown = runGit(["show", "--end-of-options", `${ref}:${path}`], repository);
186
+ return shown.code === 0 && shown.stdout ? shown.stdout : null;
136
187
  }
137
188
 
138
- /** Notes as the ref has them — what a channel contains, merged or not. */
139
- export function readReleaseNotesFromRef(ref: string, start: string): ReleaseNote[] {
140
- const repository = repositoryFor(start);
141
- const names = refNoteNames(ref, repository).sort((a, b) =>
189
+ type RefNotes = { ok: true; notes: ReleaseNote[] } | { ok: false; detail: string };
190
+
191
+ function readRefNotes(ref: string, repository: string): RefNotes {
192
+ const listed = refNoteNames(ref, repository);
193
+ if (!listed.ok) return listed;
194
+ const names = listed.names.sort((a, b) =>
142
195
  compareSemver(basename(a).replace(/\.md$/, ""), basename(b).replace(/\.md$/, "")),
143
196
  );
144
197
  const notes: ReleaseNote[] = [];
145
198
  for (const selected of names) {
146
- const shown = runGit(["show", `${ref}:${selected}`], repository);
147
- if (shown.code !== 0 || !shown.stdout) continue;
148
- notes.push(releaseNoteFromText(selected, shown.stdout));
199
+ const text = showAtRef(ref, selected, repository);
200
+ if (text !== null) notes.push(releaseNoteFromText(selected, text));
149
201
  }
150
- return notes;
202
+ return { ok: true, notes };
203
+ }
204
+
205
+ /**
206
+ * Notes as the ref has them — what a channel contains, merged or not — in the
207
+ * repository given, never one discovered from a cwd (CR134). A ref that does
208
+ * not resolve yields none here; `buildPendingReleaseNotes` says why.
209
+ */
210
+ export function readReleaseNotesFromRef(ref: string, repository: string): ReleaseNote[] {
211
+ const read = readRefNotes(ref, resolve(repository));
212
+ return read.ok ? read.notes : [];
151
213
  }
152
214
 
153
215
  export function readReleaseNoteFromRef(
154
216
  ref: string,
155
217
  version: string,
156
- start: string,
218
+ repository: string,
157
219
  ): ReleaseNote | null {
158
- const repository = repositoryFor(start);
220
+ const root = resolve(repository);
159
221
  let selected: string;
160
222
  if (version === "latest") {
161
- const names = refNoteNames(ref, repository);
162
- if (names.length === 0) return null;
163
- selected = names.sort((a, b) =>
223
+ const listed = refNoteNames(ref, root);
224
+ if (!listed.ok || listed.names.length === 0) return null;
225
+ selected = listed.names.sort((a, b) =>
164
226
  compareSemver(basename(b).replace(/\.md$/, ""), basename(a).replace(/\.md$/, "")),
165
227
  )[0] as string;
166
228
  } else {
229
+ if (refusedRef(ref) !== null) return null;
167
230
  const wanted = version.startsWith("v") ? version : `v${version}`;
168
231
  selected = `docs/releases/${wanted}.md`;
169
232
  }
170
- const shown = runGit(["show", `${ref}:${selected}`], repository);
171
- if (shown.code !== 0 || !shown.stdout) return null;
172
- return releaseNoteFromText(selected, shown.stdout);
233
+ const text = showAtRef(ref, selected, root);
234
+ return text === null ? null : releaseNoteFromText(selected, text);
173
235
  }
174
236
 
175
237
  /** Port of `_normalize_version`: drop a leading `v`, keep the rest. */
@@ -178,30 +240,141 @@ export function normalizeVersion(version: string): string {
178
240
  return trimmed.startsWith("v") ? trimmed.slice(1) : trimmed;
179
241
  }
180
242
 
181
- /** Refresh the channel ref before reading it. The only write in this module. */
182
- export function fetchReleaseNotesRef(ref: string, repository: string): void {
243
+ export type RefFetch =
244
+ | { kind: "skipped" }
245
+ | { kind: "fetched" }
246
+ | { kind: "failed"; detail: string };
247
+
248
+ /**
249
+ * Refresh the channel ref before reading it: `git fetch <remote> <branch>` for
250
+ * a ref shaped `<remote>/<branch>`, in the clone's own repository. A ref with
251
+ * no remote part, such as `HEAD`, has nothing to fetch, and a refused ref never
252
+ * reaches git.
253
+ */
254
+ export function fetchReleaseNotesRef(ref: string, repository: string): RefFetch {
255
+ if (refusedRef(ref) !== null) return { kind: "skipped" };
183
256
  const slash = ref.indexOf("/");
184
- if (slash <= 0 || slash === ref.length - 1) return;
257
+ if (slash <= 0 || slash === ref.length - 1) return { kind: "skipped" };
185
258
  const remote = ref.slice(0, slash);
186
259
  const branch = ref.slice(slash + 1);
187
- runGit(["fetch", remote, branch], repository, GIT_NETWORK_TIMEOUT_MS);
260
+ const fetched = runGit(
261
+ ["fetch", "--end-of-options", remote, branch],
262
+ repository,
263
+ GIT_NETWORK_TIMEOUT_MS,
264
+ );
265
+ if (fetched.code === 0) return { kind: "fetched" };
266
+ return {
267
+ kind: "failed",
268
+ detail: firstLine(fetched.stderr) || `git fetch exited ${fetched.code}`,
269
+ };
270
+ }
271
+
272
+ /** A bundle no source answered: `Pending releases: unknown`, and why. */
273
+ export function unknownReleaseNotes(
274
+ currentVersion: string,
275
+ source: string | null,
276
+ reason: string,
277
+ ): ReleaseNotesBundle {
278
+ return {
279
+ notes: [],
280
+ current_version: `v${normalizeVersion(currentVersion)}`,
281
+ source,
282
+ unknown_reason: reason,
283
+ };
188
284
  }
189
285
 
190
286
  export interface PendingOptions {
191
287
  currentVersion: string;
192
288
  ref: string;
193
- start: string;
289
+ /** The install's own checkout, used as given: never discovered from a cwd (CR134). */
290
+ repository: string;
194
291
  fetch: boolean;
195
292
  }
196
293
 
294
+ /**
295
+ * A clone's pending notes: the releases at `ref` newer than the installed
296
+ * version. A fetch that fails is `unknown`, not a quiet read of a stale ref;
297
+ * `--no-fetch` is how to ask for the ref as last fetched (CR134, decision 2).
298
+ */
197
299
  export function buildPendingReleaseNotes(options: PendingOptions): ReleaseNotesBundle {
198
- const repository = repositoryFor(options.start);
300
+ const repository = resolve(options.repository);
199
301
  const current = normalizeVersion(options.currentVersion);
200
- if (options.fetch) fetchReleaseNotesRef(options.ref, repository);
201
- const notes = readReleaseNotesFromRef(options.ref, repository).filter((note) =>
202
- isNewer(note.version, current),
203
- );
204
- return { notes, current_version: `v${current}`, source: options.ref };
302
+ const unknown = (reason: string) => unknownReleaseNotes(current, options.ref, reason);
303
+ const refused = refusedRef(options.ref);
304
+ if (refused !== null) return unknown(refused);
305
+ if (options.fetch) {
306
+ const fetched = fetchReleaseNotesRef(options.ref, repository);
307
+ if (fetched.kind === "failed") return unknown(`fetch failed: ${fetched.detail}`);
308
+ }
309
+ const read = readRefNotes(options.ref, repository);
310
+ if (!read.ok) return unknown(read.detail);
311
+ return {
312
+ notes: read.notes.filter((note) => isNewer(note.version, current)),
313
+ current_version: `v${current}`,
314
+ source: options.ref,
315
+ unknown_reason: null,
316
+ };
317
+ }
318
+
319
+ export interface RegistryPendingOptions {
320
+ currentVersion: string;
321
+ install: PackageInstall;
322
+ channel: string;
323
+ npm: NpmRunner;
324
+ }
325
+
326
+ const TARBALL_NOTE = /^package\/docs\/releases\/v\d+\.\d+\.\d+\.md$/;
327
+
328
+ /**
329
+ * A package's pending notes (CR134). The registry resolves the channel's
330
+ * dist-tag to a version, as `runtime update` does at plan. When that version is
331
+ * ahead, its exact tarball is fetched into a private directory and the notes
332
+ * are read in memory, within the reader's bound; when it is current or behind,
333
+ * nothing is downloaded. Every npm call runs in the install's root (CR135).
334
+ */
335
+ export function buildPendingReleaseNotesFromRegistry(
336
+ options: RegistryPendingOptions,
337
+ ): ReleaseNotesBundle {
338
+ const { install, channel, npm } = options;
339
+ const current = normalizeVersion(options.currentVersion);
340
+ const tag = `npm ${install.name}@${channel}`;
341
+ const direction = channelDirection({ ...install, version: current }, channel, npm);
342
+ if (direction.kind === "unresolved") return unknownReleaseNotes(current, tag, direction.detail);
343
+ const target = direction.version;
344
+ const source = `${tag} (${target})`;
345
+ if (direction.kind !== "ahead") {
346
+ return { notes: [], current_version: `v${current}`, source, unknown_reason: null };
347
+ }
348
+
349
+ const destination = mkdtempSync(join(tmpdir(), "mirror-release-notes-"));
350
+ try {
351
+ const packed = packTarball(install.name, target, install.root, destination, npm);
352
+ if (!packed.ok) return unknownReleaseNotes(current, source, packed.detail);
353
+ // The compressed file is read whole before it is inflated, so it is bounded too.
354
+ if (statSync(packed.path).size > MAX_INFLATED_BYTES) {
355
+ return unknownReleaseNotes(
356
+ current,
357
+ source,
358
+ `the tarball is larger than ${MAX_INFLATED_BYTES} bytes`,
359
+ );
360
+ }
361
+ const read = readTarGzEntries(readFileSync(packed.path), (name) => TARBALL_NOTE.test(name));
362
+ if (!read.ok) return unknownReleaseNotes(current, source, read.detail);
363
+ const notes = [...read.entries]
364
+ .sort(([a], [b]) =>
365
+ compareSemver(basename(a).replace(/\.md$/, ""), basename(b).replace(/\.md$/, "")),
366
+ )
367
+ .map(([name, bytes]) =>
368
+ releaseNoteFromText(
369
+ `${install.name}@${target}:${name.slice("package/".length)}`,
370
+ bytes.toString("utf8"),
371
+ ),
372
+ )
373
+ .filter((note) => isNewer(note.version, current));
374
+ return { notes, current_version: `v${current}`, source, unknown_reason: null };
375
+ } finally {
376
+ rmSync(destination, { recursive: true, force: true });
377
+ }
205
378
  }
206
379
 
207
380
  export function renderReleaseNote(note: ReleaseNote | null): string {
@@ -226,6 +399,11 @@ export function renderReleaseNotesBundle(bundle: ReleaseNotesBundle): string {
226
399
  const lines = ["Mirror runtime release notes", ""];
227
400
  if (bundle.current_version) lines.push(`Current version: ${bundle.current_version}`);
228
401
  if (bundle.source) lines.push(`Source: ${bundle.source}`);
402
+ if (bundle.unknown_reason !== null) {
403
+ lines.push("Pending releases: unknown");
404
+ lines.push(`Reason: ${bundle.unknown_reason}`);
405
+ return `${lines.join("\n")}\n`;
406
+ }
229
407
  if (bundle.notes.length === 0) {
230
408
  lines.push("Pending releases: none");
231
409
  return `${lines.join("\n")}\n`;
@@ -9,15 +9,18 @@
9
9
  // decisions can be driven in tests without a remote, and so this module can be
10
10
  // read as "what we ask git for" rather than "how we spawn it".
11
11
 
12
- import { runGit } from "#runtime/git.ts";
12
+ import { GIT_NETWORK_TIMEOUT_MS, runGit } from "#runtime/git.ts";
13
13
  import type { ApplyStrategy } from "#runtime/updatePipeline.ts";
14
14
 
15
+ /** Without a timeout, a command gets `runGit`'s local budget. */
15
16
  export type GitRunner = (
16
17
  args: readonly string[],
17
18
  cwd: string,
19
+ timeoutMs?: number,
18
20
  ) => { code: number; stdout: string; stderr: string };
19
21
 
20
- export const defaultGitRunner: GitRunner = (args, cwd) => runGit([...args], cwd);
22
+ export const defaultGitRunner: GitRunner = (args, cwd, timeoutMs) =>
23
+ runGit([...args], cwd, timeoutMs);
21
24
 
22
25
  /** `origin/main` -> `["origin", "main"]`; anything else is refused. */
23
26
  export function splitUpstream(upstream: string): [string, string] | null {
@@ -44,7 +47,11 @@ export function fetchUpstream(
44
47
  const split = splitUpstream(upstream);
45
48
  if (split === null) return { ok: false, detail: "unexpected upstream name" };
46
49
  const [remote, branch] = split;
47
- const result = git(["fetch", remote, branch], repository);
50
+ // The one command here that crosses the network, so it gets the network
51
+ // budget (CR132). On the 2-second local budget a fetch still in flight was
52
+ // killed and reported failed. The oracle's `_git_fetch` had this since
53
+ // 521177f8, and the port lost it. Both update lanes run this fetch.
54
+ const result = git(["fetch", remote, branch], repository, GIT_NETWORK_TIMEOUT_MS);
48
55
  return result.code === 0
49
56
  ? { ok: true, detail: `${remote} ${branch}` }
50
57
  : { ok: false, detail: result.stderr.trim() || "fetch failed" };
@@ -11,29 +11,53 @@
11
11
  // the pipeline's decisions are testable without a registry.
12
12
 
13
13
  import { execFileSync } from "node:child_process";
14
+ import { basename, join } from "node:path";
14
15
  import { type ApplyStrategy, KNOWN_CHANNELS } from "#runtime/updatePipeline.ts";
15
16
  import { compareSemver, isPlainSemver } from "#util/semver.ts";
16
17
 
17
- /** `cwd` matters to the one npm command that reads a tree, `publish`; the rest ignore it. */
18
+ /**
19
+ * Where and how long one npm command runs.
20
+ *
21
+ * `cwd` is a trust decision, not a convenience (CR135). npm reads the project
22
+ * config of the directory it runs in -- the `.npmrc` beside the nearest
23
+ * `package.json` -- so a command started in the person's project takes its
24
+ * `registry=` and `prefix=` from whatever that project ships. Every call the
25
+ * product makes names its directory: the install's own root for the package's
26
+ * commands, the release's repository for `publish`.
27
+ */
28
+ export interface NpmCall {
29
+ cwd?: string;
30
+ /** Kill npm after this long. Absent, npm runs until it answers. */
31
+ timeoutMs?: number;
32
+ }
33
+
18
34
  export type NpmRunner = (
19
35
  args: readonly string[],
20
- cwd?: string,
36
+ call?: NpmCall,
21
37
  ) => {
22
38
  code: number;
23
39
  stdout: string;
24
40
  stderr: string;
25
41
  };
26
42
 
27
- export const defaultNpmRunner: NpmRunner = (args, cwd) => {
43
+ export const defaultNpmRunner: NpmRunner = (args, call = {}) => {
28
44
  try {
29
45
  const stdout = execFileSync("npm", [...args], {
30
46
  encoding: "utf8",
31
47
  stdio: ["ignore", "pipe", "pipe"],
32
- ...(cwd === undefined ? {} : { cwd }),
48
+ ...(call.cwd === undefined ? {} : { cwd: call.cwd }),
49
+ ...(call.timeoutMs === undefined ? {} : { timeout: call.timeoutMs }),
33
50
  });
34
51
  return { code: 0, stdout, stderr: "" };
35
52
  } catch (error) {
36
- const shaped = error as { status?: number; stdout?: string; stderr?: string };
53
+ const shaped = error as { status?: number; stdout?: string; stderr?: string; code?: string };
54
+ if (shaped.code === "ETIMEDOUT") {
55
+ return {
56
+ code: -1,
57
+ stdout: shaped.stdout ?? "",
58
+ stderr: `npm ${args[0] ?? ""} timed out after ${call.timeoutMs} ms`,
59
+ };
60
+ }
37
61
  return {
38
62
  code: shaped.status ?? -1,
39
63
  stdout: shaped.stdout ?? "",
@@ -42,9 +66,14 @@ export const defaultNpmRunner: NpmRunner = (args, cwd) => {
42
66
  }
43
67
  };
44
68
 
45
- /** `npm root -g`, or null when npm is not usable. Resolved once per run. */
46
- export function npmRootGlobal(npm: NpmRunner = defaultNpmRunner): string | null {
47
- const result = npm(["root", "-g"]);
69
+ /**
70
+ * `npm root -g`, or null when npm is not usable. Resolved once per run.
71
+ *
72
+ * It runs before the install is known, so its directory is the caller's: the
73
+ * tree the front door runs from, which for a package is the install's root.
74
+ */
75
+ export function npmRootGlobal(cwd: string, npm: NpmRunner = defaultNpmRunner): string | null {
76
+ const result = npm(["root", "-g"], { cwd });
48
77
  const value = result.stdout.trim();
49
78
  return result.code === 0 && value ? value : null;
50
79
  }
@@ -54,9 +83,25 @@ export function npmRootGlobal(npm: NpmRunner = defaultNpmRunner): string | null
54
83
 
55
84
  export type DistTags = { ok: true; tags: Record<string, string> } | { ok: false; detail: string };
56
85
 
86
+ /**
87
+ * How to read the dist-tags. `cwd` is the install's root. The welcome card
88
+ * bounds the read and drops npm's retries, which cost over a minute when the
89
+ * registry cannot be reached (CR135); `runtime update` asks for neither.
90
+ */
91
+ export interface DistTagRead {
92
+ cwd: string;
93
+ timeoutMs?: number;
94
+ fetchRetries?: number;
95
+ }
96
+
57
97
  /** `npm view <name> dist-tags --json`, read once per run. */
58
- export function readDistTags(name: string, npm: NpmRunner = defaultNpmRunner): DistTags {
59
- const result = npm(["view", name, "dist-tags", "--json"]);
98
+ export function readDistTags(name: string, npm: NpmRunner, read: DistTagRead): DistTags {
99
+ const args = ["view", name, "dist-tags", "--json"];
100
+ if (read.fetchRetries !== undefined) args.push(`--fetch-retries=${read.fetchRetries}`);
101
+ const result = npm(
102
+ args,
103
+ read.timeoutMs === undefined ? { cwd: read.cwd } : { cwd: read.cwd, timeoutMs: read.timeoutMs },
104
+ );
60
105
  if (result.code !== 0)
61
106
  return { ok: false, detail: firstLine(result.stderr) || "npm view failed" };
62
107
  let parsed: unknown;
@@ -117,12 +162,24 @@ export type ChannelDirection =
117
162
  | { kind: "behind"; version: string; detail: string; carriedBy: string | null }
118
163
  | { kind: "unresolved"; detail: string };
119
164
 
165
+ /** What the package's npm commands need to know about the install. */
166
+ export interface PackageInstall {
167
+ name: string;
168
+ version: string;
169
+ /** Where the package lives; its npm commands run here. */
170
+ root: string;
171
+ }
172
+
173
+ /** The bound and retries of one dist-tag read; where it runs is the install's. */
174
+ export type DistTagLimits = Omit<DistTagRead, "cwd">;
175
+
120
176
  export function channelDirection(
121
- install: { name: string; version: string },
177
+ install: PackageInstall,
122
178
  channel: string,
123
179
  npm: NpmRunner = defaultNpmRunner,
180
+ limits: DistTagLimits = {},
124
181
  ): ChannelDirection {
125
- const read = readDistTags(install.name, npm);
182
+ const read = readDistTags(install.name, npm, { cwd: install.root, ...limits });
126
183
  if (!read.ok) return { kind: "unresolved", detail: read.detail };
127
184
  const resolved = resolveFrom(read.tags, install.name, channel);
128
185
  if (!resolved.ok) return { kind: "unresolved", detail: resolved.detail };
@@ -165,12 +222,13 @@ export interface PackageUpdateAvailability {
165
222
  }
166
223
 
167
224
  export function checkPackageUpdateAvailability(
168
- install: { name: string; version: string },
225
+ install: PackageInstall,
169
226
  channel: { value: string; note: string | null },
170
227
  npm: NpmRunner = defaultNpmRunner,
228
+ limits: DistTagLimits = {},
171
229
  ): PackageUpdateAvailability {
172
230
  const base = { name: install.name, installed: install.version, channel, carriedBy: null };
173
- const direction = channelDirection(install, channel.value, npm);
231
+ const direction = channelDirection(install, channel.value, npm, limits);
174
232
  switch (direction.kind) {
175
233
  case "current":
176
234
  return { ...base, resolved: direction.version, status: "up_to_date", note: null };
@@ -220,18 +278,53 @@ export function renderPackageUpdateAvailability(report: PackageUpdateAvailabilit
220
278
  return `${lines.join("\n")}\n`;
221
279
  }
222
280
 
223
- /** Install one exact version globally. Never a bare tag. */
281
+ /** Install one exact version globally, from the install's own root. Never a bare tag. */
224
282
  export function installGlobal(
225
283
  name: string,
226
284
  version: string,
285
+ cwd: string,
227
286
  npm: NpmRunner = defaultNpmRunner,
228
287
  ): { ok: boolean; detail: string } {
229
- const result = npm(["install", "-g", `${name}@${version}`]);
288
+ const result = npm(["install", "-g", `${name}@${version}`], { cwd });
230
289
  return result.code === 0
231
290
  ? { ok: true, detail: `${name}@${version}` }
232
291
  : { ok: false, detail: firstLine(result.stderr) || "npm install failed" };
233
292
  }
234
293
 
294
+ /**
295
+ * `npm pack <name>@<version>` into `destination`: the exact tarball `runtime
296
+ * update` would install, fetched -- never unpacked here -- so `release-notes
297
+ * pending` can read what that version brings (CR134). It runs in the install's
298
+ * own root, as every npm call does (CR135). `--ignore-scripts`: a registry
299
+ * tarball runs no scripts when packed, and at a trust boundary that is said
300
+ * rather than assumed.
301
+ */
302
+ export function packTarball(
303
+ name: string,
304
+ version: string,
305
+ cwd: string,
306
+ destination: string,
307
+ npm: NpmRunner = defaultNpmRunner,
308
+ ): { ok: true; path: string } | { ok: false; detail: string } {
309
+ const result = npm(
310
+ ["pack", `${name}@${version}`, "--pack-destination", destination, "--ignore-scripts", "--json"],
311
+ { cwd },
312
+ );
313
+ if (result.code !== 0)
314
+ return { ok: false, detail: firstLine(result.stderr) || "npm pack failed" };
315
+ let filename: unknown;
316
+ try {
317
+ filename = (JSON.parse(result.stdout) as Array<{ filename?: unknown }>)[0]?.filename;
318
+ } catch {
319
+ return { ok: false, detail: "npm pack returned unreadable JSON" };
320
+ }
321
+ // A bare file name, or nothing: the path is ours to build.
322
+ if (typeof filename !== "string" || filename === "" || basename(filename) !== filename) {
323
+ return { ok: false, detail: "npm pack named no tarball" };
324
+ }
325
+ return { ok: true, path: join(destination, filename) };
326
+ }
327
+
235
328
  /** The pasteable route back, with the captured version in it. */
236
329
  export function packageRecoveryCommand(name: string, previousVersion: string | null): string {
237
330
  return previousVersion === null
@@ -249,7 +342,7 @@ export function packageRecoveryCommand(name: string, previousVersion: string | n
249
342
  * asked once, at plan.
250
343
  */
251
344
  export function packageStrategy(
252
- install: { name: string; version: string },
345
+ install: PackageInstall,
253
346
  channel: string,
254
347
  npm: NpmRunner,
255
348
  ): ApplyStrategy {
@@ -268,11 +361,7 @@ export function packageStrategy(
268
361
  }),
269
362
 
270
363
  plan: (previousRef) => {
271
- const direction = channelDirection(
272
- { name: install.name, version: previousRef },
273
- channel,
274
- npm,
275
- );
364
+ const direction = channelDirection({ ...install, version: previousRef }, channel, npm);
276
365
  switch (direction.kind) {
277
366
  case "unresolved":
278
367
  return {
@@ -308,7 +397,7 @@ export function packageStrategy(
308
397
  },
309
398
 
310
399
  apply: (_previousRef, target) => {
311
- const applied = installGlobal(install.name, target, npm);
400
+ const applied = installGlobal(install.name, target, install.root, npm);
312
401
  return applied.ok
313
402
  ? { ok: true, newRef: target, detail: applied.detail, changes: [] }
314
403
  : {