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
|
@@ -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`;
|
package/ts/src/runtime/status.ts
CHANGED
|
@@ -26,16 +26,21 @@ import { inspectMigrationFiles } from "#extensions/migrations.ts";
|
|
|
26
26
|
import { type DbPathEnv, dbNameForEnv, resolveMirrorHome } from "#frontDoor/dbPath.ts";
|
|
27
27
|
import { DEFAULT_EMBEDDING_MODEL, DEFAULT_EXTRACTION_MODEL } from "#providers/config.ts";
|
|
28
28
|
import { sortByCodePoint } from "#util/pythonText.ts";
|
|
29
|
-
import {
|
|
29
|
+
import { channelForReport } from "./channel.ts";
|
|
30
30
|
import type { MarkerValue } from "./git.ts";
|
|
31
31
|
import {
|
|
32
|
+
cloneRoleFor,
|
|
32
33
|
type GitStatus,
|
|
33
|
-
inspectCloneRole,
|
|
34
34
|
inspectGitFor,
|
|
35
|
-
|
|
35
|
+
renderCloneRoleLines,
|
|
36
36
|
renderRepositoryLines,
|
|
37
37
|
} from "./git.ts";
|
|
38
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
detectInstallKind,
|
|
40
|
+
type InstallKind,
|
|
41
|
+
inspectionRootFor,
|
|
42
|
+
renderInstallLines,
|
|
43
|
+
} from "./installKind.ts";
|
|
39
44
|
|
|
40
45
|
export interface CoreMigrationHealth {
|
|
41
46
|
ready: boolean;
|
|
@@ -339,6 +344,11 @@ export function detectNodeVersion(): string | null {
|
|
|
339
344
|
}
|
|
340
345
|
|
|
341
346
|
export interface BuildStatusOptions {
|
|
347
|
+
/**
|
|
348
|
+
* An explicit repository to inspect. Absent, the install's own: a clone's
|
|
349
|
+
* checkout, and for anything else none (CR138). It used to default to the
|
|
350
|
+
* cwd, which for a skill is the person's project.
|
|
351
|
+
*/
|
|
342
352
|
start?: string;
|
|
343
353
|
mirrorHome?: string | null;
|
|
344
354
|
channel?: string | null;
|
|
@@ -354,21 +364,6 @@ export interface BuildStatusOptions {
|
|
|
354
364
|
install?: InstallKind;
|
|
355
365
|
}
|
|
356
366
|
|
|
357
|
-
/**
|
|
358
|
-
* The channel the report shows. A clone is inspected at `start`, as the
|
|
359
|
-
* oracle did and `version --start PATH` still means; a package has no tree at
|
|
360
|
-
* `start` and keeps its channel in the user's config file (US3 D9).
|
|
361
|
-
*/
|
|
362
|
-
function channelForStatus(
|
|
363
|
-
install: InstallKind,
|
|
364
|
-
startPath: string,
|
|
365
|
-
override: string | null,
|
|
366
|
-
env: NodeJS.ProcessEnv,
|
|
367
|
-
): MarkerValue {
|
|
368
|
-
if (install.kind === "package") return channelFor(install, env, undefined, override);
|
|
369
|
-
return inspectUpdateChannel(startPath, override);
|
|
370
|
-
}
|
|
371
|
-
|
|
372
367
|
/**
|
|
373
368
|
* Python's `resolve_mirror_home` raises `ValueError`, and
|
|
374
369
|
* `build_runtime_status` prints `str(exc)` as `Mirror home note:`. The front
|
|
@@ -394,16 +389,17 @@ function mirrorHomeForStatus(env: DbPathEnv): { home: string | null; error: stri
|
|
|
394
389
|
|
|
395
390
|
/** Port of `build_runtime_status`. */
|
|
396
391
|
export function buildRuntimeStatus(options: BuildStatusOptions = {}): RuntimeStatusReport {
|
|
397
|
-
const startPath = resolve(options.start ?? process.cwd());
|
|
398
392
|
const env = options.env ?? process.env;
|
|
399
393
|
const install =
|
|
400
394
|
options.install ??
|
|
401
395
|
detectInstallKind({ frontDoorPath: new URL("../frontDoor/cli.ts", import.meta.url).pathname });
|
|
402
|
-
// The
|
|
403
|
-
// cwd
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
|
|
396
|
+
// The repository rows are the install's own, or the one `start` names; never
|
|
397
|
+
// the cwd's (CR138). The git facts are a clone's: a package has no
|
|
398
|
+
// repository, and grading the cwd as one read "not a git repository" as a git
|
|
399
|
+
// error, said `attention needed`, and so refused every package update at the
|
|
400
|
+
// gate (CV22.DS10.US3 plateau 4, found by the package lane of the update smoke).
|
|
401
|
+
const root = inspectionRootFor(install, options.start);
|
|
402
|
+
const git = inspectGitFor(install, root);
|
|
407
403
|
|
|
408
404
|
let mirrorHome: string | null;
|
|
409
405
|
let mirrorHomeError: string | null;
|
|
@@ -430,9 +426,9 @@ export function buildRuntimeStatus(options: BuildStatusOptions = {}): RuntimeSta
|
|
|
430
426
|
core_migrations: inspectCoreMigrations(dbPath, dbExists),
|
|
431
427
|
extensions: listInstalledExtensions(mirrorHome),
|
|
432
428
|
extension_health: inspectExtensionHealth(mirrorHome, dbPath, dbExists),
|
|
433
|
-
clone_role:
|
|
429
|
+
clone_role: cloneRoleFor(install, root),
|
|
434
430
|
memory_env: memoryEnv,
|
|
435
|
-
update_channel:
|
|
431
|
+
update_channel: channelForReport(install, root, env, undefined, options.channel ?? null),
|
|
436
432
|
node_version: options.nodeVersion === undefined ? detectNodeVersion() : options.nodeVersion,
|
|
437
433
|
};
|
|
438
434
|
}
|
|
@@ -533,8 +529,7 @@ export function renderRuntimeStatus(
|
|
|
533
529
|
lines.push("Installed extensions: 0");
|
|
534
530
|
}
|
|
535
531
|
lines.push(...renderExtensionHealth(report.extension_health));
|
|
536
|
-
lines.push(
|
|
537
|
-
if (report.clone_role.note) lines.push(`Clone role note: ${report.clone_role.note}`);
|
|
532
|
+
lines.push(...renderCloneRoleLines(report.install, report.clone_role));
|
|
538
533
|
lines.push(`Update channel: ${report.update_channel.value}`);
|
|
539
534
|
if (report.update_channel.note) lines.push(`Update channel note: ${report.update_channel.note}`);
|
|
540
535
|
lines.push(`Node: ${report.node_version || "not found (TS front door requires Node >= 24)"}`);
|
|
@@ -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" };
|