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.
- package/docs/releases/index.md +2 -0
- package/docs/releases/v0.33.1.md +174 -0
- package/docs/releases/v0.33.2.md +124 -0
- package/package.json +1 -1
- package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
- package/ts/src/frontDoor/runtimeRoute.ts +185 -50
- package/ts/src/runtime/channel.ts +18 -0
- package/ts/src/runtime/git.ts +54 -9
- package/ts/src/runtime/installKind.ts +15 -0
- package/ts/src/runtime/releaseNotes.ts +230 -52
- package/ts/src/runtime/status.ts +24 -29
- package/ts/src/runtime/strategies/clone.ts +10 -3
- package/ts/src/runtime/strategies/package.ts +112 -23
- package/ts/src/util/tarEntries.ts +128 -0
- package/ts/src/welcome/card.ts +182 -60
- package/ts/src/welcome/statusLine.ts +12 -2
- package/ts/src/welcome/updateCache.ts +45 -27
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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,
|
|
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
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
59
|
-
const
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
+
}
|