mirror-mind 0.33.0 → 0.33.2

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.
@@ -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
  : {
@@ -0,0 +1,128 @@
1
+ // Named entries out of a gzipped tar archive, in memory. (CR134)
2
+ //
3
+ // A package's pending release notes are read from the channel version's
4
+ // tarball, which `npm pack` fetched across a trust boundary: the registry is
5
+ // whatever the npm config names, and a project's `.npmrc` can name it (CR135).
6
+ // So nothing here writes a file, and no entry name becomes a path or an
7
+ // argument -- the caller asks for names and gets bytes. That is also why `tar`
8
+ // is not spawned: it would take archive-chosen member names as arguments.
9
+ // Inflation stops at a bound, because a small gzip can inflate to gigabytes.
10
+ //
11
+ // What is read is what tar writers produce: ustar headers with the POSIX
12
+ // `prefix` field, pax extended headers (`x`, whose `path` names the next
13
+ // entry), and GNU long names (`L`). Every header's checksum is checked, and an
14
+ // archive that stops before its end-of-archive block is refused rather than
15
+ // half-read: a tarball cut between two entries would otherwise lose the later
16
+ // ones -- the newest note among them -- without a word.
17
+
18
+ import { gunzipSync } from "node:zlib";
19
+
20
+ const BLOCK = 512;
21
+ const USTAR_MAGIC = Buffer.from("ustar\0", "latin1");
22
+
23
+ /** The bound on inflated bytes. A release tarball inflates to a few MB. */
24
+ export const MAX_INFLATED_BYTES = 64 * 1024 * 1024;
25
+
26
+ export type TarEntries = { ok: true; entries: Map<string, Buffer> } | { ok: false; detail: string };
27
+
28
+ /** A NUL-terminated header field. */
29
+ function field(bytes: Buffer): string {
30
+ const end = bytes.indexOf(0);
31
+ return bytes.subarray(0, end === -1 ? bytes.length : end).toString("utf8");
32
+ }
33
+
34
+ /** An octal header number, or null when the field holds anything else. */
35
+ function octal(bytes: Buffer): number | null {
36
+ const value = field(bytes).trim();
37
+ return /^[0-7]+$/.test(value) ? Number.parseInt(value, 8) : null;
38
+ }
39
+
40
+ /** The checksum counts the header with its own field read as spaces. */
41
+ function checksumHolds(header: Buffer): boolean {
42
+ const recorded = octal(header.subarray(148, 156));
43
+ if (recorded === null) return false;
44
+ let sum = 0;
45
+ for (let index = 0; index < BLOCK; index += 1) {
46
+ sum += index >= 148 && index < 156 ? 0x20 : (header[index] as number);
47
+ }
48
+ return sum === recorded;
49
+ }
50
+
51
+ /** The `path` record of a pax extended header: records are `<len> <key>=<value>\n`. */
52
+ function paxPath(data: Buffer): string | null {
53
+ let path: string | null = null;
54
+ let offset = 0;
55
+ while (offset < data.length) {
56
+ const space = data.indexOf(0x20, offset);
57
+ if (space === -1) break;
58
+ const length = Number.parseInt(data.subarray(offset, space).toString("latin1"), 10);
59
+ if (!Number.isInteger(length) || length <= 0 || offset + length > data.length) break;
60
+ const record = data.subarray(space + 1, offset + length - 1).toString("utf8");
61
+ const equals = record.indexOf("=");
62
+ if (equals !== -1 && record.slice(0, equals) === "path") path = record.slice(equals + 1);
63
+ offset += length;
64
+ }
65
+ return path;
66
+ }
67
+
68
+ /**
69
+ * The regular-file entries of a gzipped tar whose names `wanted` accepts.
70
+ * Refuses, with a reason, an archive that inflates past the bound, is not
71
+ * gzip, fails a header checksum, or ends inside an entry.
72
+ */
73
+ export function readTarGzEntries(
74
+ gz: Buffer,
75
+ wanted: (name: string) => boolean,
76
+ maxInflatedBytes: number = MAX_INFLATED_BYTES,
77
+ ): TarEntries {
78
+ let tar: Buffer;
79
+ try {
80
+ tar = gunzipSync(gz, { maxOutputLength: maxInflatedBytes });
81
+ } catch (error) {
82
+ if ((error as { code?: string }).code === "ERR_BUFFER_TOO_LARGE") {
83
+ return { ok: false, detail: `the archive inflates to more than ${maxInflatedBytes} bytes` };
84
+ }
85
+ const message = error instanceof Error ? error.message : String(error);
86
+ return { ok: false, detail: `unreadable gzip: ${message}` };
87
+ }
88
+
89
+ const entries = new Map<string, Buffer>();
90
+ let nextName: string | null = null;
91
+ let offset = 0;
92
+ while (offset + BLOCK <= tar.length) {
93
+ const header = tar.subarray(offset, offset + BLOCK);
94
+ if (header.every((byte) => byte === 0)) return { ok: true, entries };
95
+ if (!checksumHolds(header)) {
96
+ return { ok: false, detail: `the tar header at byte ${offset} fails its checksum` };
97
+ }
98
+ const size = octal(header.subarray(124, 136));
99
+ if (size === null) {
100
+ return { ok: false, detail: `the tar header at byte ${offset} has an unreadable size` };
101
+ }
102
+ const start = offset + BLOCK;
103
+ const end = start + size;
104
+ if (end > tar.length) return { ok: false, detail: "the archive is truncated" };
105
+ const data = tar.subarray(start, end);
106
+ const type = String.fromCharCode(header[156] as number);
107
+
108
+ if (type === "x") {
109
+ nextName = paxPath(data) ?? nextName;
110
+ } else if (type === "L") {
111
+ nextName = field(data);
112
+ } else if (type !== "g") {
113
+ const prefix = header.subarray(257, 263).equals(USTAR_MAGIC)
114
+ ? field(header.subarray(345, 500))
115
+ : "";
116
+ const base = field(header.subarray(0, 100));
117
+ const name = nextName ?? (prefix ? `${prefix}/${base}` : base);
118
+ nextName = null;
119
+ if ((type === "0" || type === "\0") && wanted(name)) entries.set(name, Buffer.from(data));
120
+ }
121
+ offset = start + Math.ceil(size / BLOCK) * BLOCK;
122
+ }
123
+ // The archive stopped before its end-of-archive block: inside an entry's
124
+ // padding, or cleanly between two entries. Entries after the last whole one
125
+ // may be missing, so nothing read here is presented as the whole (CI at
126
+ // 565b7b97 met the first; the second is the general case).
127
+ return { ok: false, detail: "the archive is truncated" };
128
+ }