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.
- package/docs/releases/index.md +1 -0
- package/docs/releases/v0.33.1.md +174 -0
- package/package.json +1 -1
- package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
- package/ts/src/frontDoor/runtimeRoute.ts +142 -24
- package/ts/src/runtime/git.ts +8 -2
- package/ts/src/runtime/releaseNotes.ts +230 -52
- 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
|
@@ -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
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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,
|
|
122
|
-
const notes = readReleaseNotes(
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
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
|
|
147
|
-
if (
|
|
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
|
-
|
|
218
|
+
repository: string,
|
|
157
219
|
): ReleaseNote | null {
|
|
158
|
-
const
|
|
220
|
+
const root = resolve(repository);
|
|
159
221
|
let selected: string;
|
|
160
222
|
if (version === "latest") {
|
|
161
|
-
const
|
|
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
|
|
171
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
300
|
+
const repository = resolve(options.repository);
|
|
199
301
|
const current = normalizeVersion(options.currentVersion);
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
)
|
|
204
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
: {
|