@mnmal-ai/substrate-tui 0.0.0-stage → 1.0.0-alpha.0

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.
@@ -0,0 +1,3690 @@
1
+ #!/usr/bin/env node
2
+ import { i as isKeyRejection, t as adminCapabilityOf } from "./session-C1D5Ermr.js";
3
+ import { resolve } from "node:path";
4
+ import { AdminBackfillError, AuthError, CURRENT_MARK, NetworkError, STEP_MARKS, adminClaimCapabilities, capabilitiesFor, checkRevocable, checkUpdatable, currentStep, decodeList, describeGrant, diagnose, encodeGrant, flattenGrant, grantEntriesConfer, isActiveAdminKeyRow, isEncryptedKey, makeTokenProvider, parseGrantEntries, planOnboarding, resolveAdminCredential } from "@mnmal-ai/substrate/client";
5
+ import { createApp, defineModule, sanitise } from "@mnmal-ai/surface-core";
6
+ import { marathon } from "@mnmal-ai/surface-theme";
7
+ import { complete } from "@mnmal-ai/substrate-nl/complete";
8
+ import { Panel, cellWidth, padCells, sparkline, truncateCells } from "@mnmal-ai/surface-kit";
9
+ import { Box, Text } from "ink";
10
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
11
+ import { readFile } from "node:fs/promises";
12
+ import { createHash, createPublicKey } from "node:crypto";
13
+ import { homedir } from "node:os";
14
+ import { readFileSync } from "node:fs";
15
+
16
+ //#region src/admin/module.ts
17
+ /**
18
+ * `/admin unlock|lock|status`. Unlock resolves the admin credential the way
19
+ * `substrate-admin` does, asks for a passphrase on a masked panel when the key is encrypted, and
20
+ * verifies the identity with `diagnose` before the session holds anything: a key that is not an
21
+ * admin fails here, not at the first write.
22
+ */
23
+ const STATUS_ID = "admin";
24
+ function badge(deps, now) {
25
+ const s = deps.admin.state();
26
+ if (s.kind !== "unlocked") return null;
27
+ return `admin ${s.kid} · ${Math.max(0, Math.ceil((s.idleUntil - now) / 6e4))}m`;
28
+ }
29
+ /** The first check that answered no, as one printable line with its fix. */
30
+ function refusal$1(r) {
31
+ const no = r.checks.find((c) => c.status === "no");
32
+ if (no === void 0) return "admin not confirmed: the admin check did not run";
33
+ return `${no.name}: ${no.detail}${no.fix !== void 0 ? ` — fix: ${no.fix}` : ""}`;
34
+ }
35
+ function adminModule(deps, io = {}) {
36
+ const diagnose$2 = io.diagnose ?? diagnose;
37
+ /**
38
+ * Bumped by exactly three things: Esc on the passphrase panel, `/admin lock` (whether or not
39
+ * admin is held) and teardown. An attempt whose captured value is stale grants nothing.
40
+ */
41
+ let generation = 0;
42
+ /** Verifications awaiting diagnose, so `/admin lock` can say it cancelled one. */
43
+ let pending = 0;
44
+ /** Verify, then hold. Resolves to null on success, undefined when abandoned, else the refusal line. */
45
+ const verifyAndUnlock = async (getAuthorization, label, fallbackKid, getToken) => {
46
+ const mine = generation;
47
+ pending += 1;
48
+ try {
49
+ return await verify(mine, getAuthorization, label, fallbackKid, getToken);
50
+ } finally {
51
+ pending -= 1;
52
+ }
53
+ };
54
+ const verify = async (mine, getAuthorization, label, fallbackKid, getToken) => {
55
+ let r;
56
+ try {
57
+ r = await diagnose$2({
58
+ baseUrl: deps.endpoint,
59
+ credential: {
60
+ authorization: getAuthorization,
61
+ label
62
+ },
63
+ ...adminCapabilityOf(deps.adminEnv)
64
+ });
65
+ } catch (err) {
66
+ return mine === generation ? `could not verify: ${err instanceof Error ? err.message : String(err)}` : void 0;
67
+ }
68
+ if (mine !== generation) return void 0;
69
+ if (r.checks.find((c) => c.name === "admin-capability")?.status !== "ok") return refusal$1(r);
70
+ deps.admin.unlock(r.identity ?? fallbackKid ?? "admin", getToken);
71
+ return null;
72
+ };
73
+ const unlockWithKey = async (keyPath, kid, passphrase) => {
74
+ const getToken = makeTokenProvider({
75
+ keyPath,
76
+ kid
77
+ }, {
78
+ capabilities: adminClaimCapabilities(deps.adminEnv),
79
+ ...passphrase !== void 0 ? { passphrase } : {}
80
+ });
81
+ return verifyAndUnlock(() => `Bearer ${getToken()}`, keyPath, kid, getToken);
82
+ };
83
+ return defineModule("substrate.admin", {
84
+ commands: [{
85
+ name: "admin",
86
+ summary: "unlock or lock the admin identity",
87
+ args: [{
88
+ name: "unlock|lock|status",
89
+ optional: true
90
+ }],
91
+ async run([action], ctx) {
92
+ if (action === "lock") {
93
+ generation += 1;
94
+ if (!deps.admin.unlocked()) return ctx.print(pending > 0 ? "admin unlock cancelled" : "admin is already locked", "dim");
95
+ deps.admin.lock("locked by /admin lock");
96
+ return;
97
+ }
98
+ if (action === void 0 || action === "status") return ctx.print(badge(deps, Date.now()) ?? "admin is locked — /admin unlock");
99
+ if (action !== "unlock") return ctx.print(`unknown /admin action '${sanitise(action)}'`, "warn");
100
+ if (deps.admin.unlocked()) return ctx.print("admin is already unlocked", "dim");
101
+ const cred = resolveAdminCredential(deps.adminEnv);
102
+ if (cred.kind === "error") return ctx.print(sanitise(cred.error), "error");
103
+ if (cred.kind === "token") {
104
+ const authorization = cred.authorization;
105
+ const bare = authorization.replace(/^Bearer /, "");
106
+ const why = await verifyAndUnlock(() => authorization, "SUBSTRATE_ADMIN_TOKEN", null, () => bare);
107
+ if (why !== null && why !== void 0) ctx.print(sanitise(why), "error");
108
+ return;
109
+ }
110
+ let encrypted;
111
+ try {
112
+ encrypted = isEncryptedKey(cred.keyPath);
113
+ } catch (err) {
114
+ return ctx.print(`cannot read ${sanitise(cred.keyPath)}: ${sanitise(err instanceof Error ? err.message : String(err))}`, "error");
115
+ }
116
+ if (encrypted) {
117
+ ctx.open("passphrase", {
118
+ keyPath: cred.keyPath,
119
+ kid: cred.kid,
120
+ error: null,
121
+ busy: false
122
+ });
123
+ return;
124
+ }
125
+ let why;
126
+ try {
127
+ why = await unlockWithKey(cred.keyPath, cred.kid, void 0);
128
+ } catch (err) {
129
+ why = `could not sign with ${cred.keyPath}: ${err instanceof Error ? err.message : String(err)}`;
130
+ }
131
+ if (why !== null && why !== void 0) ctx.print(sanitise(why), "error");
132
+ }
133
+ }],
134
+ panels: { passphrase: {
135
+ mask: true,
136
+ view: ({ state, theme }) => [
137
+ `${theme.glyph.title} admin unlock · ${sanitise(state.kid)}`,
138
+ `passphrase for ${sanitise(state.keyPath)}${state.busy ? " · checking…" : ""}`,
139
+ ...state.error !== null ? [sanitise(state.error)] : [],
140
+ " type it and press enter · esc cancels"
141
+ ].join("\n"),
142
+ escape: () => {
143
+ generation += 1;
144
+ },
145
+ submit: (state, line, ctx) => {
146
+ if (state.busy) return void 0;
147
+ let getToken;
148
+ try {
149
+ getToken = makeTokenProvider({
150
+ keyPath: state.keyPath,
151
+ kid: state.kid
152
+ }, {
153
+ capabilities: adminClaimCapabilities(deps.adminEnv),
154
+ passphrase: line
155
+ });
156
+ } catch {
157
+ return {
158
+ ...state,
159
+ error: "wrong passphrase, or the key is unreadable"
160
+ };
161
+ }
162
+ verifyAndUnlock(() => `Bearer ${getToken()}`, state.keyPath, state.kid, getToken).then((why) => {
163
+ if (why === void 0) return;
164
+ if (why === null) ctx.close();
165
+ else ctx.update("passphrase", (s) => ({
166
+ ...s,
167
+ busy: false,
168
+ error: why
169
+ }));
170
+ });
171
+ return {
172
+ ...state,
173
+ busy: true,
174
+ error: null
175
+ };
176
+ }
177
+ } },
178
+ init(ctx) {
179
+ const show = () => ctx.status(STATUS_ID, badge(deps, Date.now()), "warn");
180
+ const off = deps.admin.onChange((s) => {
181
+ show();
182
+ if (s.kind === "locked" && s.reason !== null) ctx.print(sanitise(s.reason.startsWith("admin locked") ? s.reason : `admin locked: ${s.reason}`), "warn");
183
+ });
184
+ const tickTimer = setInterval(show, 3e4);
185
+ return () => {
186
+ generation += 1;
187
+ off();
188
+ clearInterval(tickTimer);
189
+ deps.admin.dispose();
190
+ };
191
+ }
192
+ });
193
+ }
194
+
195
+ //#endregion
196
+ //#region src/ask/adapt.ts
197
+ /** End of the whitespace-delimited token the cursor sits in. */
198
+ function tokenEnd(draft, cursor) {
199
+ let i = Math.max(0, Math.min(cursor, draft.length));
200
+ while (i < draft.length && !/\s/.test(draft[i])) i++;
201
+ return i;
202
+ }
203
+ /**
204
+ * A substrate-nl suggestion computed from the text before the cursor, as a core suggestion over the
205
+ * whole draft: its span keeps its start and runs to the end of the token under the cursor
206
+ * so `status:op|p` completes to `status:open`, not `status:openp`.
207
+ */
208
+ function adapt(draft, cursor, s) {
209
+ return {
210
+ label: s.label,
211
+ insert: s.insert,
212
+ replace: [s.replace[0], tokenEnd(draft, cursor)],
213
+ score: s.score,
214
+ ...s.detail !== void 0 ? { detail: s.detail } : {}
215
+ };
216
+ }
217
+
218
+ //#endregion
219
+ //#region src/ask/render.tsx
220
+ const KIND = {
221
+ line: "substrate.line",
222
+ rows: "substrate.rows",
223
+ recall: "substrate.recall",
224
+ clarify: "substrate.clarify",
225
+ refuse: "substrate.refuse",
226
+ error: "substrate.error"
227
+ };
228
+ const str = (v) => v === void 0 || v === null ? "" : sanitise(String(v));
229
+ const frameKey = (o) => sanitise(Object.keys(o.frame)[0] ?? "?");
230
+ const errorLines = (errors) => (errors ?? []).map((e) => {
231
+ const x = e;
232
+ return sanitise(`${x.code ?? "error"}${x.message !== void 0 ? `: ${x.message}` : ""}`);
233
+ });
234
+ function outcomeChips(o) {
235
+ if (o.kind !== "rows") return [];
236
+ const rejected = !Array.isArray(o.data);
237
+ return [
238
+ {
239
+ label: `▸ ${frameKey(o)}`,
240
+ tone: "query"
241
+ },
242
+ {
243
+ label: o.handoff !== void 0 ? `${o.source} (${sanitise(o.handoff)})` : o.source,
244
+ tone: o.source === "handoff" ? "warn" : "dim"
245
+ },
246
+ {
247
+ label: rejected ? "rejected" : `${o.data.length} rows`,
248
+ tone: rejected ? "error" : "dim"
249
+ }
250
+ ];
251
+ }
252
+ const COLS = {
253
+ id: 6,
254
+ status: 10,
255
+ project: 14,
256
+ gaps: 6,
257
+ minTitle: 12
258
+ };
259
+ const titleWidth = (columns) => Math.max(COLS.minTitle, columns - (COLS.id + COLS.status + COLS.project + COLS.gaps));
260
+ const rows = ({ data, theme, size }) => {
261
+ const titleCols = titleWidth(size.columns);
262
+ const o = data;
263
+ if (!Array.isArray(o.data)) return /* @__PURE__ */ jsxs(Box, {
264
+ flexDirection: "column",
265
+ children: [/* @__PURE__ */ jsxs(Text, {
266
+ color: theme.color.error,
267
+ children: [theme.glyph.fail, " rejected"]
268
+ }), errorLines(o.errors).map((l, i) => /* @__PURE__ */ jsx(Text, {
269
+ color: theme.color.error,
270
+ children: ` ${l}`
271
+ }, i))]
272
+ });
273
+ const list = o.data;
274
+ if (list.length === 0) return /* @__PURE__ */ jsx(Text, {
275
+ color: theme.color.dim,
276
+ children: "no rows"
277
+ });
278
+ return /* @__PURE__ */ jsx(Box, {
279
+ flexDirection: "column",
280
+ children: list.map((r, i) => /* @__PURE__ */ jsxs(Text, { children: [
281
+ /* @__PURE__ */ jsx(Text, {
282
+ color: theme.color.dim,
283
+ children: padCells(str(r.shortId ?? r.id), COLS.id, true)
284
+ }),
285
+ " ",
286
+ /* @__PURE__ */ jsx(Text, {
287
+ color: theme.color.text,
288
+ children: padCells(str(r.title ?? r.synopsis ?? r.name ?? r.topic ?? r.subject), titleCols)
289
+ }),
290
+ " ",
291
+ /* @__PURE__ */ jsx(Text, {
292
+ color: theme.color.info,
293
+ children: padCells(str(r.status), COLS.status)
294
+ }),
295
+ " ",
296
+ /* @__PURE__ */ jsx(Text, {
297
+ color: theme.color.dimMid,
298
+ children: padCells(str(r.project), COLS.project)
299
+ })
300
+ ] }, i))
301
+ });
302
+ };
303
+ const recall = ({ data, theme }) => {
304
+ const o = data;
305
+ if (o.hits === void 0) return /* @__PURE__ */ jsxs(Box, {
306
+ flexDirection: "column",
307
+ children: [/* @__PURE__ */ jsxs(Text, {
308
+ color: theme.color.error,
309
+ children: [theme.glyph.fail, " recall rejected"]
310
+ }), errorLines(o.errors).map((l, i) => /* @__PURE__ */ jsx(Text, {
311
+ color: theme.color.error,
312
+ children: ` ${l}`
313
+ }, i))]
314
+ });
315
+ return /* @__PURE__ */ jsxs(Box, {
316
+ flexDirection: "column",
317
+ children: [/* @__PURE__ */ jsxs(Text, {
318
+ color: theme.color.query,
319
+ children: [
320
+ theme.glyph.query,
321
+ " recall · ",
322
+ o.hits.length,
323
+ " hits"
324
+ ]
325
+ }), o.hits.map((h, i) => /* @__PURE__ */ jsxs(Text, { children: [
326
+ " ",
327
+ padCells(str(h.type), 14),
328
+ " ",
329
+ padCells(str(h.id), 38),
330
+ " ",
331
+ Number(h.score ?? 0).toFixed(2)
332
+ ] }, i))]
333
+ });
334
+ };
335
+ const clarify = ({ data, theme }) => {
336
+ const o = data;
337
+ return /* @__PURE__ */ jsxs(Box, {
338
+ flexDirection: "column",
339
+ children: [
340
+ /* @__PURE__ */ jsxs(Text, {
341
+ color: theme.color.warn,
342
+ children: ["? ", str(o.reason)]
343
+ }),
344
+ o.options.map((opt, i) => /* @__PURE__ */ jsxs(Text, { children: [
345
+ " ",
346
+ i + 1,
347
+ ". ",
348
+ str(opt.label)
349
+ ] }, i)),
350
+ o.options.length > 0 ? /* @__PURE__ */ jsxs(Text, {
351
+ color: theme.color.dim,
352
+ children: [
353
+ " ",
354
+ "press 1–",
355
+ Math.min(9, o.options.length),
356
+ " to choose"
357
+ ]
358
+ }) : null
359
+ ]
360
+ });
361
+ };
362
+ const refuse = ({ data, theme }) => /* @__PURE__ */ jsxs(Text, {
363
+ color: theme.color.warn,
364
+ children: [
365
+ theme.glyph.fail,
366
+ " ",
367
+ str(data.reason)
368
+ ]
369
+ });
370
+ const line$2 = ({ data, theme }) => /* @__PURE__ */ jsx(Text, {
371
+ color: theme.color.dim,
372
+ children: str(data)
373
+ });
374
+ const error = ({ data, theme }) => /* @__PURE__ */ jsxs(Text, {
375
+ color: theme.color.error,
376
+ children: [
377
+ theme.glyph.fail,
378
+ " ",
379
+ str(data)
380
+ ]
381
+ });
382
+ const renderers = {
383
+ [KIND.line]: line$2,
384
+ [KIND.rows]: rows,
385
+ [KIND.recall]: recall,
386
+ [KIND.clarify]: clarify,
387
+ [KIND.refuse]: refuse,
388
+ [KIND.error]: error
389
+ };
390
+
391
+ //#endregion
392
+ //#region src/ask/module.ts
393
+ const DIGITS = [
394
+ "1",
395
+ "2",
396
+ "3",
397
+ "4",
398
+ "5",
399
+ "6",
400
+ "7",
401
+ "8",
402
+ "9"
403
+ ];
404
+ function explain(err, endpoint) {
405
+ const e = err;
406
+ if (e.status === 404 && e.code !== "nl_unknown_namespace") return `substrate-nl is not installed on ${endpoint}`;
407
+ if (e.serverMessage !== void 0) return e.serverMessage;
408
+ if (e.status === 401 || e.status === 403) return `not authorised (${e.status}) — check SUBSTRATE_AGENT_KEY_ID / SUBSTRATE_AGENT_KEY_PATH`;
409
+ return e.message ?? String(err);
410
+ }
411
+ function askModule(deps) {
412
+ let namespace = deps.namespace;
413
+ let pack = null;
414
+ let forceType;
415
+ let last = { lines: ["no question asked yet"] };
416
+ let lastPackNote = "";
417
+ const loadPack = (ctx) => {
418
+ pack = null;
419
+ const ns = namespace;
420
+ deps.nl.pack(ns, { signal: ctx.signal }).then((p) => {
421
+ if (ns === namespace) pack = p;
422
+ }, (err) => {
423
+ if (ns !== namespace) return;
424
+ const note = `completion unavailable for ${ns}: ${explain(err, deps.endpoint)}`;
425
+ if (note === lastPackNote) return;
426
+ lastPackNote = note;
427
+ ctx.print(note, "dim");
428
+ });
429
+ };
430
+ const pick = (i) => (s, ctx) => {
431
+ const opt = s.options[i];
432
+ if (opt === void 0) return void 0;
433
+ forceType = opt.type;
434
+ ctx.close();
435
+ ctx.submit(opt.question);
436
+ };
437
+ return defineModule("substrate.ask", {
438
+ init(ctx) {
439
+ loadPack(ctx);
440
+ ctx.emit("substrate/ns", { namespace });
441
+ },
442
+ commands: [{
443
+ name: "ns",
444
+ summary: "switch the namespace questions go to",
445
+ args: [{ name: "namespace" }],
446
+ run([ns], ctx) {
447
+ if (ns === void 0 || ns === "") return ctx.print(`namespace: ${namespace}`);
448
+ namespace = ns;
449
+ loadPack(ctx);
450
+ ctx.emit("substrate/ns", { namespace });
451
+ ctx.print(`namespace → ${namespace}`);
452
+ }
453
+ }, {
454
+ name: "frame",
455
+ summary: "show the last answer's query frame, evidence and judge trail",
456
+ args: [],
457
+ run: (_a, ctx) => ctx.open("frame", last)
458
+ }],
459
+ panels: {
460
+ clarify: {
461
+ view: ({ state }) => [
462
+ `? ${sanitise(state.reason)}`,
463
+ ...state.options.slice(0, 9).map((o, i) => ` ${i + 1}. ${sanitise(o.label)}`),
464
+ " 1–9 choose · esc dismiss"
465
+ ].join("\n"),
466
+ keymap: DIGITS.map((d, i) => ({
467
+ key: d,
468
+ act: `pick${i}`
469
+ })),
470
+ on: Object.fromEntries(DIGITS.map((_d, i) => [`pick${i}`, pick(i)]))
471
+ },
472
+ frame: { view: ({ state }) => sanitise(state.lines.join("\n")) }
473
+ },
474
+ completers: [{
475
+ id: "nl",
476
+ complete: (draft, cursor) => {
477
+ if (pack === null) return [];
478
+ return complete(draft.slice(0, cursor), cursor, pack).map((s) => adapt(draft, cursor, s));
479
+ }
480
+ }],
481
+ turns: [{
482
+ id: "ask",
483
+ claims: (text) => text.trim() === "" ? false : 1,
484
+ async *answer(text, ctx, signal) {
485
+ const type = forceType;
486
+ forceType = void 0;
487
+ const lines = [];
488
+ try {
489
+ for await (const ev of deps.nl.ask({
490
+ namespace,
491
+ question: text,
492
+ project: deps.project,
493
+ ...type !== void 0 ? { type } : {}
494
+ }, { signal })) switch (ev.event) {
495
+ case "parsed": {
496
+ const key = ev.data.frame !== void 0 ? Object.keys(ev.data.frame)[0] : void 0;
497
+ lines.push(`parse: ${ev.data.status}${ev.data.reason !== void 0 ? ` — ${ev.data.reason}` : ""}`);
498
+ if (ev.data.frame !== void 0) lines.push(JSON.stringify(ev.data.frame, null, 2));
499
+ for (const e of ev.data.evidence) lines.push(` ${e.span} → ${e.role} (${e.detail})`);
500
+ yield {
501
+ phase: "partial",
502
+ reply: {
503
+ kind: KIND.line,
504
+ data: `▸ ${key ?? namespace} · parsing…`
505
+ }
506
+ };
507
+ break;
508
+ }
509
+ case "judging":
510
+ lines.push(`judging: ${ev.data.reason}`);
511
+ yield {
512
+ phase: "partial",
513
+ reply: {
514
+ kind: KIND.line,
515
+ data: "judging…"
516
+ }
517
+ };
518
+ break;
519
+ case "judged":
520
+ lines.push(`judged: ${ev.data.status}`);
521
+ for (const step of ev.data.trail) lines.push(` ${JSON.stringify(step)}`);
522
+ break;
523
+ case "rows":
524
+ case "recall":
525
+ case "clarify":
526
+ case "refuse":
527
+ last = { lines };
528
+ if (ev.event === "clarify" && ev.data.options.length > 0) ctx.open("clarify", ev.data);
529
+ else ctx.close();
530
+ yield {
531
+ phase: "final",
532
+ reply: {
533
+ kind: `substrate.${ev.event}`,
534
+ data: ev.data,
535
+ chips: outcomeChips(ev.data)
536
+ }
537
+ };
538
+ return;
539
+ case "done": return;
540
+ }
541
+ } catch (err) {
542
+ if (signal.aborted) return;
543
+ yield {
544
+ phase: "failed",
545
+ reply: {
546
+ kind: KIND.error,
547
+ data: explain(err, deps.endpoint)
548
+ }
549
+ };
550
+ }
551
+ }
552
+ }],
553
+ renderers
554
+ });
555
+ }
556
+
557
+ //#endregion
558
+ //#region src/text.ts
559
+ /** Server text on one line: no escape or control bytes, and line breaks collapsed to a space. */
560
+ const clean = (s) => sanitise(s.replace(/[\r\n]+/g, " "));
561
+
562
+ //#endregion
563
+ //#region src/dash/derive.ts
564
+ /** Polls kept per sparkline. */
565
+ const HISTORY = 30;
566
+ /** Metrics samples closer together than this add no history point: the baseline holds until a
567
+ * later sample spans a meaningful interval (an `r` right after a tick would spike the rate). */
568
+ const MIN_RATE_INTERVAL_MS = 1e3;
569
+ /** Per-key p99 histories kept, most recently seen first. */
570
+ const MAX_KEYS = 50;
571
+ /** The engine event counted as a policy denial. */
572
+ const DENIAL_EVENT = "engine_policy_denied";
573
+ const SORTS = [
574
+ "p99",
575
+ "calls",
576
+ "errors"
577
+ ];
578
+ function initialDash(sort = "p99") {
579
+ return {
580
+ health: null,
581
+ metrics: null,
582
+ embed: null,
583
+ errors: null,
584
+ hist: {
585
+ queryRate: [],
586
+ mutateRate: [],
587
+ queryErr: [],
588
+ mutateErr: [],
589
+ denials: [],
590
+ p99: /* @__PURE__ */ new Map()
591
+ },
592
+ eventDelta: {},
593
+ keyErrRose: /* @__PURE__ */ new Set(),
594
+ sort
595
+ };
596
+ }
597
+ /** `hist` plus `v`, keeping the last HISTORY values. */
598
+ function push(hist, v) {
599
+ const next = [...hist, v];
600
+ return next.length > 30 ? next.slice(next.length - 30) : next;
601
+ }
602
+ /** Growth of a counter; a counter that went down (a restart) clamps to 0. */
603
+ function delta(prev, next) {
604
+ return prev === void 0 ? 0 : Math.max(0, next - prev);
605
+ }
606
+ /** Latency bands: below 20 ms ok, below 100 ms warn, else error. */
607
+ function latencyTone(ms) {
608
+ return ms < 20 ? "ok" : ms < 100 ? "warn" : "error";
609
+ }
610
+ /** Largest first by `by`, ties by key name. */
611
+ function sortKeys(r, by) {
612
+ const metric = (s) => by === "p99" ? s.p99Ms : by === "calls" ? s.calls : s.errors;
613
+ return Object.entries(r).sort(([ka, a], [kb, b]) => metric(b) - metric(a) || (ka < kb ? -1 : ka > kb ? 1 : 0));
614
+ }
615
+ function nextSort(by) {
616
+ return SORTS[(SORTS.indexOf(by) + 1) % SORTS.length] ?? "p99";
617
+ }
618
+ /** Maps a failed fetch to why, from the HTTP status the error carries. */
619
+ function classify(err) {
620
+ const status = err?.status;
621
+ if (status === 401) return "auth";
622
+ if (status === 403) return "admin";
623
+ if (status === 404) return "absent";
624
+ return "down";
625
+ }
626
+ const num$2 = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
627
+ const round2 = (v) => Math.round(v * 100) / 100;
628
+ function totalsOf(m, at) {
629
+ const events = Object.fromEntries(Object.entries(m.events ?? {}).map(([k, v]) => [k, num$2(v)]));
630
+ return {
631
+ at,
632
+ query: num$2(m.requests?.query?.total),
633
+ mutate: num$2(m.requests?.mutate?.total),
634
+ queryErr: num$2(m.requests?.query?.error),
635
+ mutateErr: num$2(m.requests?.mutate?.error),
636
+ events,
637
+ keyErrors: Object.fromEntries(Object.entries(m.resolvers ?? {}).map(([k, st]) => [k, num$2(st?.errors)]))
638
+ };
639
+ }
640
+ function foldP99(prev, resolvers) {
641
+ const seen = Object.entries(resolvers).sort(([ka, a], [kb, b]) => (a.lastAt < b.lastAt ? 1 : a.lastAt > b.lastAt ? -1 : 0) || (ka < kb ? -1 : 1));
642
+ const next = /* @__PURE__ */ new Map();
643
+ for (const [key, stat] of seen) {
644
+ if (next.size >= 50) break;
645
+ next.set(key, push(prev.get(key) ?? [], num$2(stat.p99Ms)));
646
+ }
647
+ for (const [key, hist] of prev) {
648
+ if (next.size >= 50) break;
649
+ if (!next.has(key)) next.set(key, hist);
650
+ }
651
+ return next;
652
+ }
653
+ /** Folds one poll into the state. Histories grow only once a previous metrics poll gives a baseline. */
654
+ function step(prev, sample, now) {
655
+ const next = {
656
+ ...prev,
657
+ health: sample.health,
658
+ metrics: sample.metrics,
659
+ embed: sample.embed,
660
+ errors: sample.errors,
661
+ lastAt: now
662
+ };
663
+ if (!sample.metrics.ok) return next;
664
+ const m = sample.metrics.value;
665
+ const at = sample.metricsAt ?? now;
666
+ const cur = totalsOf(m, at);
667
+ const was = prev.prevTotals;
668
+ if (was === void 0) {
669
+ const p99 = foldP99(prev.hist.p99, m.resolvers ?? {});
670
+ return {
671
+ ...next,
672
+ prevTotals: cur,
673
+ eventDelta: {},
674
+ hist: {
675
+ ...prev.hist,
676
+ p99
677
+ }
678
+ };
679
+ }
680
+ if (at - was.at < 1e3) return next;
681
+ const p99 = foldP99(prev.hist.p99, m.resolvers ?? {});
682
+ const secs = (at - was.at) / 1e3;
683
+ const eventDelta = Object.fromEntries(Object.entries(cur.events).map(([k, v]) => [k, delta(Object.hasOwn(was.events, k) ? was.events[k] : 0, v)]));
684
+ const keyErrRose = new Set(Object.entries(cur.keyErrors).filter(([k, v]) => delta(Object.hasOwn(was.keyErrors, k) ? was.keyErrors[k] : 0, v) > 0).map(([k]) => k));
685
+ const h = prev.hist;
686
+ return {
687
+ ...next,
688
+ prevTotals: cur,
689
+ eventDelta,
690
+ keyErrRose,
691
+ hist: {
692
+ queryRate: push(h.queryRate, round2(delta(was.query, cur.query) / secs)),
693
+ mutateRate: push(h.mutateRate, round2(delta(was.mutate, cur.mutate) / secs)),
694
+ queryErr: push(h.queryErr, delta(was.queryErr, cur.queryErr)),
695
+ mutateErr: push(h.mutateErr, delta(was.mutateErr, cur.mutateErr)),
696
+ denials: push(h.denials, eventDelta["engine_policy_denied"] ?? 0),
697
+ p99
698
+ }
699
+ };
700
+ }
701
+
702
+ //#endregion
703
+ //#region src/dash/module.tsx
704
+ const DASH_INTERVAL_MS = 5e3;
705
+ /** Per source: one hung source must not hold up the others past this. */
706
+ const DASH_TIMEOUT_MS = 4e3;
707
+ /** TRAFFIC inner width from which a per-key row also shows calls and p50. */
708
+ const WIDE_KEY_ROW = 40;
709
+ const num$1 = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
710
+ function healthOf(raw) {
711
+ const h = raw;
712
+ return {
713
+ ...typeof h?.version === "string" ? { version: h.version } : {},
714
+ ...typeof h?.uptimeSeconds === "number" ? { uptimeSeconds: h.uptimeSeconds } : {}
715
+ };
716
+ }
717
+ function embedOf(raw) {
718
+ const e = raw;
719
+ return {
720
+ ...typeof e?.enabled === "boolean" ? { enabled: e.enabled } : {},
721
+ writes: num$1(e?.writes),
722
+ failures: num$1(e?.failures),
723
+ expansionFailures: num$1(e?.expansionFailures)
724
+ };
725
+ }
726
+ const seg = (t, tone = "text") => ({
727
+ t,
728
+ tone
729
+ });
730
+ /** Segments cut to `max` cells in total, the cut marked with `…`. */
731
+ function fit(line, max) {
732
+ const out = [];
733
+ let used = 0;
734
+ if (line.reduce((n, s) => n + cellWidth(s.t), 0) <= max) return [...line];
735
+ for (const s of line) {
736
+ const room = max - used;
737
+ if (room <= 0) break;
738
+ const w = cellWidth(s.t);
739
+ if (w < room) {
740
+ out.push(s);
741
+ used += w;
742
+ } else {
743
+ out.push({
744
+ ...s,
745
+ t: truncateCells(`${s.t}…`, room)
746
+ });
747
+ break;
748
+ }
749
+ }
750
+ return out;
751
+ }
752
+ const na = (label, why) => [seg(`${label} `, "dim"), seg(`n/a (${why})`, why === "down" ? "warn" : "dim")];
753
+ const ms = (v) => v >= 100 ? `${Math.round(v)}ms` : `${Math.round(v * 10) / 10}ms`;
754
+ const rate = (v) => v >= 100 ? `${Math.round(v)}/s` : `${Math.round(v * 10) / 10}/s`;
755
+ function uptime(s) {
756
+ const d = Math.floor(s / 86400);
757
+ const h = Math.floor(s % 86400 / 3600);
758
+ const m = Math.floor(s % 3600 / 60);
759
+ if (d > 0) return `${d}d ${h}h`;
760
+ if (h > 0) return `${h}h ${m}m`;
761
+ if (m > 0) return `${m}m`;
762
+ return `${Math.floor(s)}s`;
763
+ }
764
+ const last = (h) => h[h.length - 1] ?? 0;
765
+ function serviceLines(s, theme) {
766
+ const out = [];
767
+ if (s.health === null) out.push([seg("…", "dim")]);
768
+ else if (s.health.ok) {
769
+ const h = s.health.value;
770
+ out.push([
771
+ seg(`${theme.glyph.live} live`, "ok"),
772
+ ...h.version !== void 0 ? [seg(` v${clean(h.version)}`, "text")] : [],
773
+ ...h.uptimeSeconds !== void 0 ? [seg(` up ${uptime(h.uptimeSeconds)}`, "dim")] : []
774
+ ]);
775
+ } else out.push([seg(`${theme.glyph.fail} offline`, "error"), seg(` (${s.health.why})`, "dim")]);
776
+ const m = s.metrics;
777
+ if (m === null) out.push([seg("metrics …", "dim")]);
778
+ else if (!m.ok) out.push(na("metrics", m.why));
779
+ else {
780
+ const sub = m.value.subscriptions;
781
+ out.push([
782
+ seg("subs ", "dim"),
783
+ seg(String(num$1(sub?.active))),
784
+ ...sub?.maxQueueDepth !== void 0 ? [seg(" queue max ", "dim"), seg(String(num$1(sub.maxQueueDepth)))] : []
785
+ ]);
786
+ }
787
+ const e = s.embed;
788
+ if (e === null) out.push([seg("embed …", "dim")]);
789
+ else if (!e.ok) out.push(na("embed", e.why));
790
+ else if (e.value.enabled === false) out.push([seg("embed ", "dim"), seg("off", "dim")]);
791
+ else {
792
+ const failures = num$1(e.value.failures);
793
+ out.push([
794
+ seg("embed ", "dim"),
795
+ seg("on ", "ok"),
796
+ seg(`${theme.glyph.fail}${failures}`, failures > 0 ? "error" : "dim"),
797
+ seg(` · ${num$1(e.value.writes)} writes`, "dim")
798
+ ]);
799
+ }
800
+ const p = m?.ok === true ? m.value.process : void 0;
801
+ if (p !== void 0) {
802
+ out.push([
803
+ seg("rss ", "dim"),
804
+ seg(`${Math.round(num$1(p.rssMB))}MB`),
805
+ seg(" heap ", "dim"),
806
+ seg(`${Math.round(num$1(p.heapUsedMB))}MB`)
807
+ ]);
808
+ const lag = num$1(p.eventLoopLagP99Ms);
809
+ out.push([seg("loop p99 ", "dim"), seg(ms(lag), latencyTone(lag))]);
810
+ const inFlight = num$1(p.inFlight);
811
+ out.push([seg("in flight ", "dim"), seg(String(inFlight), inFlight > 0 ? "info" : "text")]);
812
+ }
813
+ return out;
814
+ }
815
+ function opLines(label, glyph, tone, op, rates, errs, theme, compact) {
816
+ const total = num$1(op?.total);
817
+ const mean = total > 0 ? num$1(op?.durationMsSum) / total : 0;
818
+ const max = num$1(op?.durationMsMax);
819
+ const newErr = last(errs);
820
+ if (compact) return [[
821
+ seg(`${glyph} ${label} `, tone),
822
+ seg(rate(last(rates))),
823
+ seg(` ${theme.glyph.fail}${newErr}`, newErr > 0 ? "error" : "dim"),
824
+ seg(" avg(all) ", "dim"),
825
+ seg(ms(mean), latencyTone(mean)),
826
+ seg(" ", "dim"),
827
+ seg(sparkline(rates, theme.glyph.spark), "spark")
828
+ ]];
829
+ return [[
830
+ seg(`${glyph} ${label} `, tone),
831
+ seg(rate(last(rates))),
832
+ seg(" ", "dim"),
833
+ seg(sparkline(rates, theme.glyph.spark), "spark")
834
+ ], [
835
+ seg(` ${theme.glyph.fail}${newErr}`, newErr > 0 ? "error" : "dim"),
836
+ seg(" avg(all) ", "dim"),
837
+ seg(ms(mean), latencyTone(mean)),
838
+ seg(" max ", "dim"),
839
+ seg(ms(max), latencyTone(max))
840
+ ]];
841
+ }
842
+ /**
843
+ * Exported for tests: tones are not visible in a rendered frame. `compact` gives each op one line
844
+ * (no max) and drops the KEYS header, so a short pane still reaches the per-key rows.
845
+ */
846
+ function trafficLines(s, theme, width, compact = false) {
847
+ const m = s.metrics;
848
+ if (m === null) return [[seg("…", "dim")]];
849
+ if (!m.ok) return [na("metrics", m.why)];
850
+ const v = m.value;
851
+ const out = [...opLines("query", theme.glyph.query, "query", v.requests?.query, s.hist.queryRate, s.hist.queryErr, theme, compact), ...opLines("mutate", theme.glyph.mutation, "mutation", v.requests?.mutate, s.hist.mutateRate, s.hist.mutateErr, theme, compact)];
852
+ if (!compact) out.push([seg(`KEYS · by ${s.sort}`, "dimMid")]);
853
+ const empty = compact ? "keys " : " ";
854
+ const keys = sortKeys(v.resolvers ?? {}, s.sort);
855
+ if (v.resolvers === void 0) out.push([seg(`${empty}n/a (server)`, "dim")]);
856
+ else if (keys.length === 0) out.push([seg(`${empty}none yet`, "dim")]);
857
+ const wide = width >= 40;
858
+ const tails = keys.map(([key, st]) => {
859
+ const p99 = num$1(st.p99Ms);
860
+ return [
861
+ ...wide ? [seg(` ${num$1(st.calls)}`, "dim"), seg(` ${ms(num$1(st.p50Ms))}/`, "dim")] : [],
862
+ seg(wide ? ms(p99) : ` ${ms(p99)}`, latencyTone(p99)),
863
+ seg(` ${theme.glyph.fail}${num$1(st.errors)} `, s.keyErrRose.has(key) ? "error" : "dim"),
864
+ seg(sparkline((s.hist.p99.get(key) ?? []).slice(wide ? -8 : -6), theme.glyph.spark), "spark")
865
+ ];
866
+ });
867
+ const tailW = Math.max(0, ...tails.map((t) => t.reduce((n, sg) => n + cellWidth(sg.t), 0)));
868
+ const longest = Math.max(0, ...keys.map(([key]) => cellWidth(clean(key))), ...(v.slowest ?? []).slice(0, 5).map((r) => cellWidth(clean(r.key))));
869
+ const keyW = Math.max(4, Math.min(width - 2 - tailW, longest));
870
+ keys.forEach(([key], i) => {
871
+ out.push([
872
+ seg(`${theme.glyph.query} `, "dim"),
873
+ seg(padCells(clean(key), keyW)),
874
+ ...tails[i] ?? []
875
+ ]);
876
+ });
877
+ const slow = v.slowest ?? [];
878
+ if (slow.length > 0) {
879
+ out.push([seg("SLOWEST", "dimMid")]);
880
+ for (const r of slow.slice(0, 5)) {
881
+ const d = num$1(r.durationMs);
882
+ out.push([seg(` ${padCells(clean(r.key), keyW)} `), seg(ms(d), latencyTone(d))]);
883
+ }
884
+ }
885
+ return out;
886
+ }
887
+ /** `compact` folds denials and errors onto one line, so RECENT ERRORS survives a short pane. */
888
+ function engineLines(s, theme, compact = false) {
889
+ const out = [];
890
+ const m = s.metrics;
891
+ if (m === null) out.push([seg("…", "dim")]);
892
+ else if (!m.ok) out.push(na("events", m.why));
893
+ else {
894
+ const den = last(s.hist.denials);
895
+ const errHist = s.hist.queryErr.map((q, i) => q + (s.hist.mutateErr[i] ?? 0));
896
+ const err = last(errHist);
897
+ if (compact) out.push([
898
+ seg("denials ", "dim"),
899
+ seg(`+${den}`, den > 0 ? "error" : "text"),
900
+ seg(" · errors ", "dim"),
901
+ seg(`+${err}`, err > 0 ? "error" : "text")
902
+ ]);
903
+ else {
904
+ out.push([
905
+ seg("denials ", "dim"),
906
+ seg(`+${den} `, den > 0 ? "error" : "text"),
907
+ seg(sparkline(s.hist.denials, theme.glyph.spark), "spark")
908
+ ]);
909
+ out.push([
910
+ seg("errors ", "dim"),
911
+ seg(`+${err} `, err > 0 ? "error" : "text"),
912
+ seg(sparkline(errHist, theme.glyph.spark), "spark")
913
+ ]);
914
+ }
915
+ }
916
+ const e = s.errors;
917
+ out.push([seg("RECENT ERRORS", "dimMid")]);
918
+ if (e === null) out.push([seg(" …", "dim")]);
919
+ else if (!e.ok) out.push(na(" ", e.why));
920
+ else if ((e.value.errors ?? []).length === 0) out.push([seg(" none", "dim")]);
921
+ else for (const r of e.value.errors.slice(0, 5)) {
922
+ const at = typeof r.at === "string" ? clean(r.at).slice(11, 19) : "";
923
+ out.push([
924
+ seg(` ${at} `, "dim"),
925
+ seg(`${clean(String(r.code))} `, "error"),
926
+ seg(`${clean(String(r.key))} `),
927
+ ...typeof r.caller === "string" ? [seg(`${clean(r.caller)} `, "dimMid")] : [],
928
+ seg(clean(String(r.message)), "dim"),
929
+ ...num$1(r.count) > 1 ? [seg(` ×${num$1(r.count)}`, "dim")] : []
930
+ ]);
931
+ }
932
+ if (m?.ok === true) {
933
+ const events = Object.entries(m.value.events ?? {}).filter(([k]) => k !== DENIAL_EVENT).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
934
+ for (const [name, total] of events) {
935
+ const d = Object.hasOwn(s.eventDelta, name) ? s.eventDelta[name] ?? 0 : 0;
936
+ out.push([
937
+ seg(`${clean(name)} `, "dim"),
938
+ seg(String(num$1(total))),
939
+ ...d > 0 ? [seg(` +${d}`, "warn")] : []
940
+ ]);
941
+ }
942
+ }
943
+ return out;
944
+ }
945
+ function Lines({ lines, width, theme }) {
946
+ return /* @__PURE__ */ jsx(Fragment, { children: lines.map((line, i) => /* @__PURE__ */ jsx(Text, {
947
+ wrap: "truncate",
948
+ children: fit(line, width).map((sg, j) => /* @__PURE__ */ jsx(Text, {
949
+ color: theme.color[sg.tone],
950
+ children: sg.t
951
+ }, j))
952
+ }, i)) });
953
+ }
954
+ /** SERVICE is short and steady: it never takes more than this many body lines. */
955
+ const SERVICE_CAP = 4;
956
+ /** ENGINE's tallies are the tail of a long list: past this, LIVE TRAFFIC gets the rows. */
957
+ const ENGINE_CAP = 10;
958
+ /** Compact: SERVICE keeps its first three lines (liveness, subscriptions, embed). */
959
+ const COMPACT_SERVICE_CAP = 3;
960
+ /** `total` lines dealt round-robin to sections wanting `want[i]` each, none past `caps[i]`. */
961
+ function share(want, total, caps = []) {
962
+ const got = want.map(() => 0);
963
+ let left = Math.max(0, total);
964
+ let grew = true;
965
+ while (left > 0 && grew) {
966
+ grew = false;
967
+ for (let i = 0; i < want.length && left > 0; i++) {
968
+ const cap = Math.min(want[i] ?? 0, caps[i] ?? Number.POSITIVE_INFINITY);
969
+ if ((got[i] ?? 0) < cap) {
970
+ got[i] = (got[i] ?? 0) + 1;
971
+ left--;
972
+ grew = true;
973
+ }
974
+ }
975
+ }
976
+ return got;
977
+ }
978
+ const TITLES = [
979
+ "service",
980
+ "engine",
981
+ "live traffic"
982
+ ];
983
+ const TONES = [
984
+ "accentAlt",
985
+ "error",
986
+ "accent"
987
+ ];
988
+ /**
989
+ * The dash panel, never taller than `size.rows`: SERVICE, ENGINE and LIVE TRAFFIC stacked, each
990
+ * the full width. SERVICE and ENGINE are capped and LIVE TRAFFIC takes the rows left over; when
991
+ * the rows cannot give each bordered panel MIN_BODY lines, one compact list with a title line
992
+ * per section.
993
+ */
994
+ function DashView({ state, theme, size }) {
995
+ const footer = size.rows >= 2 ? 1 : 0;
996
+ const room = size.rows - footer;
997
+ const inner = Math.max(1, size.columns - 4);
998
+ const stackRoom = room - 9;
999
+ const bordered = stackRoom >= 9;
1000
+ const lines = [
1001
+ serviceLines(state, theme),
1002
+ engineLines(state, theme, !bordered),
1003
+ trafficLines(state, theme, bordered ? inner : size.columns, !bordered)
1004
+ ];
1005
+ const foot = footer === 1 ? /* @__PURE__ */ jsx(Text, {
1006
+ color: theme.color.dim,
1007
+ wrap: "truncate",
1008
+ children: truncateCells(` r refresh · s sort (${state.sort}) · esc close`, size.columns)
1009
+ }) : null;
1010
+ if (!bordered) {
1011
+ const got = share(lines.map((l) => Math.max(1, l.length)), room, [COMPACT_SERVICE_CAP]);
1012
+ const flat = [];
1013
+ lines.forEach((l, i) => {
1014
+ const n = got[i] ?? 0;
1015
+ if (n === 0) return;
1016
+ const title = seg(`${theme.glyph.title} ${TITLES[i]?.toUpperCase()} `, TONES[i] ?? "accent");
1017
+ flat.push([title, ...l[0] ?? []]);
1018
+ flat.push(...l.slice(1, n));
1019
+ });
1020
+ return /* @__PURE__ */ jsxs(Box, {
1021
+ flexDirection: "column",
1022
+ width: size.columns,
1023
+ children: [/* @__PURE__ */ jsx(Lines, {
1024
+ lines: flat,
1025
+ width: size.columns,
1026
+ theme
1027
+ }), foot]
1028
+ });
1029
+ }
1030
+ const bodies = share(lines.map((l) => l.length), stackRoom, [SERVICE_CAP, ENGINE_CAP]);
1031
+ return /* @__PURE__ */ jsxs(Box, {
1032
+ flexDirection: "column",
1033
+ width: size.columns,
1034
+ children: [lines.map((l, i) => /* @__PURE__ */ jsx(Panel, {
1035
+ title: TITLES[i] ?? "",
1036
+ theme,
1037
+ tone: TONES[i] ?? "accent",
1038
+ width: size.columns,
1039
+ children: /* @__PURE__ */ jsx(Lines, {
1040
+ lines: l.slice(0, Math.max(1, bodies[i] ?? 1)),
1041
+ width: inner,
1042
+ theme
1043
+ })
1044
+ }, TITLES[i])), foot]
1045
+ });
1046
+ }
1047
+ function dashModule(deps, opts = {}) {
1048
+ const clock = opts.clock ?? (() => performance.now());
1049
+ const every = opts.intervalMs ?? 5e3;
1050
+ const limit = opts.timeoutMs ?? 4e3;
1051
+ let run = null;
1052
+ let disposed = false;
1053
+ const stop = () => {
1054
+ if (run === null) return;
1055
+ clearInterval(run.timer);
1056
+ run.abort.abort();
1057
+ run = null;
1058
+ };
1059
+ /** `f` with its own deadline: it settles by `limit` whether or not it honours the signal. */
1060
+ const within = (f, parents) => {
1061
+ const deadline = new AbortController();
1062
+ let timer;
1063
+ const timeout = new Promise((_res, rej) => {
1064
+ timer = setTimeout(() => {
1065
+ deadline.abort();
1066
+ rej(/* @__PURE__ */ new Error("timed out"));
1067
+ }, limit);
1068
+ });
1069
+ const signal = AbortSignal.any([...parents, deadline.signal]);
1070
+ return Promise.race([Promise.resolve().then(() => f(signal)), timeout]).then((value) => ({
1071
+ ok: true,
1072
+ value
1073
+ }), (err) => ({
1074
+ ok: false,
1075
+ why: classify(err)
1076
+ })).finally(() => clearTimeout(timer));
1077
+ };
1078
+ const sampleOf = async (parents) => {
1079
+ let metricsAt;
1080
+ const metricsP = within(async (sg) => {
1081
+ const m = await deps.metrics(sg);
1082
+ metricsAt = clock();
1083
+ return m;
1084
+ }, parents);
1085
+ const [health, metrics, embed, errors] = await Promise.all([
1086
+ within(async (sg) => healthOf(await deps.getJson("/health", sg)), parents),
1087
+ metricsP,
1088
+ within(async (sg) => embedOf(await deps.getJson("/substrate/v1/embed-stats", sg)), parents),
1089
+ within((sg) => deps.errors(sg), parents)
1090
+ ]);
1091
+ return {
1092
+ health,
1093
+ metrics,
1094
+ embed,
1095
+ errors,
1096
+ ...metricsAt !== void 0 ? { metricsAt } : {}
1097
+ };
1098
+ };
1099
+ /**
1100
+ * Whether the dash panel is the open one: ctx.update only calls back for an open panel. Core
1101
+ * has no close hook, so this probe is what lets a tick stop BEFORE fetching once the panel has
1102
+ * gone. It costs one re-render per tick (core rebuilds the overlay even for an unchanged
1103
+ * state); folding it into the post-fetch update would leave one fetch after every close.
1104
+ */
1105
+ const isOpen = (ctx) => {
1106
+ let open = false;
1107
+ ctx.update("dash", (s) => {
1108
+ open = true;
1109
+ return s;
1110
+ });
1111
+ return open;
1112
+ };
1113
+ const tick = async (ctx, queue = false) => {
1114
+ const cur = run;
1115
+ if (cur === null || disposed) return;
1116
+ if (!isOpen(ctx)) return stop();
1117
+ if (cur.busy) {
1118
+ if (queue) cur.again = true;
1119
+ return;
1120
+ }
1121
+ cur.busy = true;
1122
+ try {
1123
+ const sample = await sampleOf([ctx.signal, cur.abort.signal]);
1124
+ if (run !== cur || disposed) return;
1125
+ let applied = false;
1126
+ ctx.update("dash", (s) => {
1127
+ applied = true;
1128
+ return step(s, sample, Date.now());
1129
+ });
1130
+ if (!applied) stop();
1131
+ } finally {
1132
+ cur.busy = false;
1133
+ }
1134
+ if (cur.again && run === cur && !disposed) {
1135
+ cur.again = false;
1136
+ tick(ctx);
1137
+ }
1138
+ };
1139
+ return defineModule("substrate.dash", {
1140
+ commands: [{
1141
+ name: "dash",
1142
+ summary: "service, traffic and engine metrics, live while open",
1143
+ args: [],
1144
+ run(_args, ctx) {
1145
+ if (disposed) return;
1146
+ stop();
1147
+ ctx.open("dash", initialDash());
1148
+ run = {
1149
+ timer: setInterval(() => void tick(ctx), every),
1150
+ abort: new AbortController(),
1151
+ busy: false,
1152
+ again: false
1153
+ };
1154
+ tick(ctx);
1155
+ }
1156
+ }],
1157
+ panels: { dash: {
1158
+ view: ({ state, theme, size }) => /* @__PURE__ */ jsx(DashView, {
1159
+ state,
1160
+ theme,
1161
+ size
1162
+ }),
1163
+ keymap: [{
1164
+ key: "r",
1165
+ act: "refresh"
1166
+ }, {
1167
+ key: "s",
1168
+ act: "sort"
1169
+ }],
1170
+ on: {
1171
+ refresh: (_s, ctx) => {
1172
+ tick(ctx, true);
1173
+ },
1174
+ sort: (s) => ({
1175
+ ...s,
1176
+ sort: nextSort(s.sort)
1177
+ })
1178
+ }
1179
+ } },
1180
+ init() {
1181
+ disposed = false;
1182
+ return () => {
1183
+ disposed = true;
1184
+ stop();
1185
+ };
1186
+ }
1187
+ });
1188
+ }
1189
+
1190
+ //#endregion
1191
+ //#region src/doctor/watch.ts
1192
+ const PENDING = {
1193
+ text: "doctor …",
1194
+ tone: "dim"
1195
+ };
1196
+ const oneLine = (s) => s.replace(/\s*[\r\n]+\s*/g, " ").trim();
1197
+ function verdictOf$1(steps) {
1198
+ if (steps.find((s) => s.name === "reachable")?.status === "todo") return {
1199
+ text: "doctor ✗ unreachable",
1200
+ tone: "error"
1201
+ };
1202
+ const todo = steps.filter((s) => s.status === "todo" && !s.optional);
1203
+ const first = todo[0];
1204
+ if (first !== void 0) {
1205
+ const more = todo.length > 1 ? ` +${todo.length - 1}` : "";
1206
+ return {
1207
+ text: `doctor ⚠ ${first.name}${more}`,
1208
+ tone: "warn"
1209
+ };
1210
+ }
1211
+ const counted = steps.filter((s) => s.status !== "blocked");
1212
+ return {
1213
+ text: `doctor ✓ ${counted.filter((s) => s.status === "done").length}/${counted.length}`,
1214
+ tone: "dim"
1215
+ };
1216
+ }
1217
+ /** The baseline after `steps`: a blocked step keeps the status `prev` last measured for it. */
1218
+ function statusMap(steps, prev = /* @__PURE__ */ new Map()) {
1219
+ return new Map(steps.map((s) => [s.name, s.status === "blocked" ? prev.get(s.name) ?? "blocked" : s.status]));
1220
+ }
1221
+ function noticesFor(prev, steps) {
1222
+ const out = [];
1223
+ for (const s of steps) {
1224
+ const before = prev.get(s.name);
1225
+ if (before === "done" && s.status === "todo") out.push({
1226
+ text: `⚠ doctor: ${s.name} went NO — ${oneLine(s.detail)} · /doctor`,
1227
+ tone: "warn"
1228
+ });
1229
+ else if (before === "todo" && s.status === "done") out.push({
1230
+ text: `✓ doctor: ${s.name} recovered`,
1231
+ tone: "dim"
1232
+ });
1233
+ }
1234
+ return out;
1235
+ }
1236
+ /** A run that missed its deadline, shaped so the planner reads it as an unreachable server. */
1237
+ function timedOutResult(deadlineMs) {
1238
+ return {
1239
+ checks: [{
1240
+ name: "reachable",
1241
+ status: "no",
1242
+ detail: `no answer within ${Math.round(deadlineMs / 1e3)}s`,
1243
+ fix: "Check the service (`systemctl status substrate-binary`) and the network path."
1244
+ }],
1245
+ exitCode: 1,
1246
+ identity: null,
1247
+ facts: {}
1248
+ };
1249
+ }
1250
+
1251
+ //#endregion
1252
+ //#region src/doctor/module.ts
1253
+ /**
1254
+ * `substrate.doctor` — the command centre. diagnose runs in the background for the TUI's
1255
+ * lifetime: once at start, then every interval, one run at a time with a deadline. The
1256
+ * verdict is a `doctor` status cell, and a step flipping between done and todo prints one
1257
+ * line. `/doctor` shows the latest result live. Locked, it diagnoses the agent identity
1258
+ * without expecting admin; unlocked, the admin identity, through `run(…, { touch: false })`
1259
+ * so that watching never holds the admin session open. It reports and never acts — fixing
1260
+ * belongs to `substrate-admin onboard`.
1261
+ */
1262
+ const DOCTOR_INTERVAL_MS = 6e4;
1263
+ const DOCTOR_DEADLINE_MS = 2e4;
1264
+ /** diagnose's default advice names the admin env, which is wrong for the agent identity. */
1265
+ const AGENT_CREDENTIAL_FIX = "Set SUBSTRATE_AGENT_KEY_ID and SUBSTRATE_AGENT_KEY_PATH in this repo's .claude/settings.local.json env, or /admin unlock to diagnose as the admin identity.";
1266
+ function doctorModule(deps, io = {}) {
1267
+ const diagnose$1 = io.diagnose ?? diagnose;
1268
+ const intervalMs = io.intervalMs ?? 6e4;
1269
+ const deadlineMs = io.deadlineMs ?? 2e4;
1270
+ const now = io.now ?? Date.now;
1271
+ const whoNow = () => deps.admin.unlocked() ? "admin" : "agent";
1272
+ let latest;
1273
+ let baseline;
1274
+ let running;
1275
+ let again;
1276
+ /** Set only across the doctor's own 401 lock, so the listener leaves its explanation up. */
1277
+ let selfLocking = false;
1278
+ /** Bumped on every lock: an admin run that saw one, even if since re-unlocked, is discarded. */
1279
+ let locks = 0;
1280
+ const publish = (ctx, patch) => {
1281
+ latest = {
1282
+ ...latest,
1283
+ ...patch
1284
+ };
1285
+ const snapshot = latest;
1286
+ ctx.update("doctor", () => snapshot);
1287
+ };
1288
+ /** One diagnose under `who`, raced against the deadline. Null means the deadline won. */
1289
+ const attempt = async (ctx, who) => {
1290
+ const deadline = new AbortController();
1291
+ const timer = setTimeout(() => deadline.abort(), deadlineMs);
1292
+ const signal = AbortSignal.any([ctx.signal, deadline.signal]);
1293
+ const fetchWith = ((url, init) => fetch(url, {
1294
+ ...init,
1295
+ signal
1296
+ }));
1297
+ const timedOut = new Promise((resolve) => signal.addEventListener("abort", () => resolve(null), { once: true }));
1298
+ const run = who === "admin" ? deps.admin.run(({ authorization }) => diagnose$1({
1299
+ baseUrl: deps.endpoint,
1300
+ credential: {
1301
+ authorization,
1302
+ label: "the unlocked admin identity"
1303
+ },
1304
+ ...adminCapabilityOf(deps.adminEnv),
1305
+ fetch: fetchWith
1306
+ }), { touch: false }) : diagnose$1({
1307
+ baseUrl: deps.endpoint,
1308
+ credential: deps.agentAuthorization !== null ? {
1309
+ authorization: deps.agentAuthorization,
1310
+ label: "the agent identity"
1311
+ } : {
1312
+ error: "no agent identity configured (anonymous)",
1313
+ fix: AGENT_CREDENTIAL_FIX
1314
+ },
1315
+ expectAdmin: false,
1316
+ fetch: fetchWith
1317
+ });
1318
+ try {
1319
+ return await Promise.race([run, timedOut]);
1320
+ } finally {
1321
+ clearTimeout(timer);
1322
+ deadline.abort();
1323
+ }
1324
+ };
1325
+ const runOnce = async (ctx) => {
1326
+ if (ctx.signal.aborted) return;
1327
+ if (running) {
1328
+ again = true;
1329
+ return;
1330
+ }
1331
+ running = true;
1332
+ const who = whoNow();
1333
+ const epoch = locks;
1334
+ const stale = () => ctx.signal.aborted || who === "admin" && (locks !== epoch || !deps.admin.unlocked());
1335
+ publish(ctx, {
1336
+ running: true,
1337
+ who
1338
+ });
1339
+ try {
1340
+ const result = await attempt(ctx, who) ?? timedOutResult(deadlineMs);
1341
+ if (stale()) return;
1342
+ const identity = result.checks.find((c) => c.name === "identity");
1343
+ if (who === "admin" && identity?.status === "no" && identity.httpStatus === 401) {
1344
+ selfLocking = true;
1345
+ try {
1346
+ deps.admin.lock("admin locked: 401 — the admin key was rejected");
1347
+ } finally {
1348
+ selfLocking = false;
1349
+ }
1350
+ }
1351
+ const steps = planOnboarding(result, {
1352
+ canBootstrap: false,
1353
+ skipped: /* @__PURE__ */ new Set(),
1354
+ explainNoBootstrap: false
1355
+ });
1356
+ const verdict = verdictOf$1(steps);
1357
+ ctx.status("doctor", verdict.text, verdict.tone);
1358
+ if (baseline !== null && baseline.who === who) for (const n of noticesFor(baseline.map, steps)) ctx.print(sanitise(n.text), n.tone);
1359
+ const carried = baseline !== null && baseline.who === who ? baseline.map : void 0;
1360
+ baseline = {
1361
+ who,
1362
+ map: statusMap(steps, carried)
1363
+ };
1364
+ publish(ctx, {
1365
+ who,
1366
+ steps,
1367
+ error: null,
1368
+ checkedAt: now(),
1369
+ nextAt: now() + intervalMs,
1370
+ running: false
1371
+ });
1372
+ } catch (err) {
1373
+ if (stale()) return;
1374
+ ctx.status("doctor", "doctor ✗ could not run", "error");
1375
+ publish(ctx, {
1376
+ who,
1377
+ error: err instanceof Error ? err.message : String(err),
1378
+ running: false
1379
+ });
1380
+ } finally {
1381
+ running = false;
1382
+ if (latest.running) publish(ctx, { running: false });
1383
+ if (again && !ctx.signal.aborted) {
1384
+ again = false;
1385
+ runOnce(ctx);
1386
+ }
1387
+ }
1388
+ };
1389
+ return defineModule("substrate.doctor", {
1390
+ commands: [{
1391
+ name: "doctor",
1392
+ summary: "live checks for this deployment",
1393
+ args: [],
1394
+ run: (_, ctx) => {
1395
+ ctx.open("doctor", latest);
1396
+ runOnce(ctx);
1397
+ }
1398
+ }],
1399
+ panels: { doctor: {
1400
+ view: ({ state, theme, size }) => {
1401
+ const w = Math.max(1, size.columns - 2);
1402
+ const clock = (t) => new Date(t).toTimeString().slice(0, 8);
1403
+ const when = state.running || state.checkedAt === null ? " · checking…" : ` · checked ${clock(state.checkedAt)}` + (state.nextAt !== null ? ` · next ${clock(state.nextAt)}` : "");
1404
+ const head = truncateCells(`${theme.glyph.title} doctor · ${state.who} identity${when}`, w);
1405
+ if (state.error !== null) return [head, truncateCells(sanitise(`could not run: ${oneLine(state.error)}`), w)].join("\n");
1406
+ if (state.steps === null) return [head, " checking…"].join("\n");
1407
+ const current = currentStep(state.steps);
1408
+ const nameW = Math.max(...state.steps.map((s) => s.name.length));
1409
+ return [
1410
+ head,
1411
+ ...state.steps.flatMap((s) => {
1412
+ const mark = s === current ? CURRENT_MARK : STEP_MARKS[s.status];
1413
+ const tag = s.optional ? " (optional)" : "";
1414
+ return [truncateCells(sanitise(`${mark} ${s.name.padEnd(nameW)} ${oneLine(s.detail)}${tag}`), w), ...s.status === "todo" && s.fix !== void 0 ? [truncateCells(sanitise(` fix: ${oneLine(s.fix)}`), w)] : []];
1415
+ }),
1416
+ `${verdictOf$1(state.steps).text} · r re-check · esc close`
1417
+ ].join("\n");
1418
+ },
1419
+ keymap: [{
1420
+ key: "r",
1421
+ act: "rerun"
1422
+ }],
1423
+ on: { rerun: (_s, ctx) => {
1424
+ runOnce(ctx);
1425
+ } }
1426
+ } },
1427
+ init(ctx) {
1428
+ latest = {
1429
+ who: whoNow(),
1430
+ steps: null,
1431
+ error: null,
1432
+ checkedAt: null,
1433
+ nextAt: null,
1434
+ running: false
1435
+ };
1436
+ baseline = null;
1437
+ running = false;
1438
+ again = false;
1439
+ ctx.status("doctor", PENDING.text, PENDING.tone);
1440
+ runOnce(ctx);
1441
+ const timer = setInterval(() => {
1442
+ if (!running) runOnce(ctx);
1443
+ }, intervalMs);
1444
+ let lastKind = deps.admin.unlocked() ? "unlocked" : "locked";
1445
+ const off = deps.admin.onChange((st) => {
1446
+ if (st.kind === lastKind) return;
1447
+ lastKind = st.kind;
1448
+ baseline = null;
1449
+ if (st.kind === "locked") {
1450
+ locks += 1;
1451
+ if (!selfLocking) {
1452
+ let adminOpen = false;
1453
+ ctx.update("doctor", (s) => {
1454
+ adminOpen = s.who === "admin";
1455
+ return s;
1456
+ });
1457
+ if (adminOpen) {
1458
+ ctx.close();
1459
+ ctx.print("doctor closed — admin locked", "warn");
1460
+ }
1461
+ }
1462
+ }
1463
+ if (!selfLocking) runOnce(ctx);
1464
+ });
1465
+ return () => {
1466
+ clearInterval(timer);
1467
+ off();
1468
+ };
1469
+ }
1470
+ });
1471
+ }
1472
+
1473
+ //#endregion
1474
+ //#region src/admin/refusal.ts
1475
+ /** A server refusal as the server said it: `code: message`, sanitised, never retried. A chunked backfill wraps its failure; the cause is the refusal. */
1476
+ function refusalOf(err) {
1477
+ const e = err instanceof AdminBackfillError ? err.cause : err;
1478
+ const known = e instanceof NetworkError || e instanceof AuthError;
1479
+ const code = known ? e.code ?? String(e.status ?? "error") : "error";
1480
+ const message = (known ? e.serverMessage : void 0) ?? (e instanceof Error ? e.message : String(e));
1481
+ return {
1482
+ code: sanitise(code),
1483
+ message: sanitise(message),
1484
+ ...known && e.body !== void 0 ? { body: e.body } : {}
1485
+ };
1486
+ }
1487
+
1488
+ //#endregion
1489
+ //#region src/lines.tsx
1490
+ /** Lines as a column of `Text`, each in its tone: a joined string view has no tone to give them. */
1491
+ function linesView(lines, theme) {
1492
+ return /* @__PURE__ */ jsx(Box, {
1493
+ flexDirection: "column",
1494
+ children: lines.map((l, i) => /* @__PURE__ */ jsx(Text, {
1495
+ ...l.tone !== void 0 ? { color: theme.color[l.tone] } : {},
1496
+ children: l.text
1497
+ }, i))
1498
+ });
1499
+ }
1500
+
1501
+ //#endregion
1502
+ //#region src/keys/grant.ts
1503
+ const SHORT = {
1504
+ read: "r",
1505
+ mutate: "m",
1506
+ admin: "a"
1507
+ };
1508
+ /** The legend for {@link compactGrant}'s short forms, one per entry of `SHORT`. */
1509
+ const GRANT_LEGEND = Object.entries(SHORT).map(([cap, s]) => `${s}=${cap}`).join(" · ");
1510
+ const CANONICAL = Object.keys(SHORT);
1511
+ /** read, mutate, admin, then any other capability alphabetically: one order wherever a grant is shown or sent. */
1512
+ function canonicalCaps(caps) {
1513
+ const rank = (c) => {
1514
+ const i = CANONICAL.indexOf(c);
1515
+ return i < 0 ? CANONICAL.length : i;
1516
+ };
1517
+ return [...caps].sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0));
1518
+ }
1519
+ /**
1520
+ * One cell for the key table: `journal:r+m,*:r+m+a`. A capability without a short form stays
1521
+ * spelled out. Like `describeGrant` it is total: an unparseable grant renders as its description.
1522
+ */
1523
+ function compactGrant(namespaces, capabilities) {
1524
+ let grant;
1525
+ try {
1526
+ grant = parseGrantEntries(decodeList(namespaces), decodeList(capabilities));
1527
+ } catch {
1528
+ return describeGrant(namespaces, capabilities).join(" ");
1529
+ }
1530
+ if (grant.size === 0) return "(none)";
1531
+ return [...grant].map(([ns, caps]) => `${ns}:${canonicalCaps(caps).map((c) => SHORT[c] ?? c).join("+")}`).join(",");
1532
+ }
1533
+ /** A stored row's grant, or null when it does not parse (the table still renders it). */
1534
+ function rowGrant(row) {
1535
+ try {
1536
+ return parseGrantEntries(decodeList(row.namespaces), decodeList(row.capabilities));
1537
+ } catch {
1538
+ return null;
1539
+ }
1540
+ }
1541
+ /** The request form: the capability union as the default, so a bare entry reads back as it. */
1542
+ function toWire(grant) {
1543
+ const capabilities = canonicalCaps(flattenGrant(grant).capabilities);
1544
+ return {
1545
+ namespaces: encodeGrant(grant, capabilities),
1546
+ capabilities
1547
+ };
1548
+ }
1549
+ /**
1550
+ * The namespaces a `GET /substrate/v1/federated-schema` response hosts. The route adds one key that is
1551
+ * not a namespace, `_enumVerdicts`, for a caller who can read `_system`; the server judges
1552
+ * unhosted names against the schema without it, so this does too.
1553
+ */
1554
+ function hostedNamespaces(schema) {
1555
+ return Object.keys(schema).filter((k) => k !== "_enumVerdicts");
1556
+ }
1557
+
1558
+ //#endregion
1559
+ //#region src/keys/matrix.ts
1560
+ /** The grant editor's model: namespaces by capability, edited a cell at a time. */
1561
+ const CAPS = [
1562
+ "read",
1563
+ "mutate",
1564
+ "admin"
1565
+ ];
1566
+ const clamp = (n, max) => Math.max(0, Math.min(max, n));
1567
+ function openMatrix(before, hosted) {
1568
+ const granted = [...before.keys()];
1569
+ const star = granted.includes("*") ? ["*"] : [];
1570
+ const rest = granted.filter((ns) => ns !== "*");
1571
+ const extra = [...new Set(hosted)].filter((ns) => !before.has(ns)).sort();
1572
+ return {
1573
+ before,
1574
+ grant: before,
1575
+ rows: [
1576
+ ...star,
1577
+ ...rest,
1578
+ ...extra
1579
+ ],
1580
+ hosted: new Set(hosted),
1581
+ row: 0,
1582
+ col: 0
1583
+ };
1584
+ }
1585
+ function move(m, dRow, dCol) {
1586
+ return {
1587
+ ...m,
1588
+ row: clamp(m.row + dRow, m.rows.length - 1),
1589
+ col: clamp(m.col + dCol, CAPS.length - 1)
1590
+ };
1591
+ }
1592
+ function toggle(m) {
1593
+ const ns = m.rows[m.row];
1594
+ const cap = CAPS[m.col];
1595
+ if (ns === void 0 || cap === void 0) return m;
1596
+ const caps = new Set(m.grant.get(ns) ?? []);
1597
+ if (caps.has(cap)) caps.delete(cap);
1598
+ else caps.add(cap);
1599
+ const grant = new Map(m.grant);
1600
+ grant.set(ns, caps);
1601
+ return {
1602
+ ...m,
1603
+ grant
1604
+ };
1605
+ }
1606
+ function addNamespace(m, ns) {
1607
+ const at = m.rows.indexOf(ns);
1608
+ if (at >= 0) return {
1609
+ ...m,
1610
+ row: at
1611
+ };
1612
+ const rows = ns === "*" ? ["*", ...m.rows] : [...m.rows, ns];
1613
+ return {
1614
+ ...m,
1615
+ rows,
1616
+ row: ns === "*" ? 0 : rows.length - 1
1617
+ };
1618
+ }
1619
+ const explicit = (g, ns) => {
1620
+ const caps = g.get(ns);
1621
+ return caps !== void 0 && caps.size > 0 ? caps : void 0;
1622
+ };
1623
+ function cell(m, ns, cap) {
1624
+ const now = explicit(m.grant, ns);
1625
+ const was = explicit(m.before, ns);
1626
+ const on = now?.has(cap) === true;
1627
+ const diff = on === (was?.has(cap) === true) ? "" : on ? "+" : "-";
1628
+ if (on) return {
1629
+ held: "on",
1630
+ diff
1631
+ };
1632
+ return {
1633
+ held: ns !== "*" && now === void 0 && capabilitiesFor(result(m), ns).has(cap) ? "inherited" : "off",
1634
+ diff
1635
+ };
1636
+ }
1637
+ function result(m) {
1638
+ const out = /* @__PURE__ */ new Map();
1639
+ for (const ns of m.rows) {
1640
+ const caps = m.grant.get(ns);
1641
+ if (caps !== void 0 && caps.size > 0) out.set(ns, new Set(caps));
1642
+ }
1643
+ return out;
1644
+ }
1645
+ function changed(m) {
1646
+ const a = result(m);
1647
+ const b = result({
1648
+ ...m,
1649
+ grant: m.before
1650
+ });
1651
+ if (a.size !== b.size) return true;
1652
+ for (const [ns, caps] of a) {
1653
+ const other = b.get(ns);
1654
+ if (other === void 0 || other.size !== caps.size) return true;
1655
+ for (const c of caps) if (!other.has(c)) return true;
1656
+ }
1657
+ return false;
1658
+ }
1659
+
1660
+ //#endregion
1661
+ //#region src/keys/pubkey.ts
1662
+ /** A public key's algorithm and fingerprint, read locally before register sends it. */
1663
+ /** PEM armour of a private key, any algorithm prefix (none, RSA, EC, ENCRYPTED, OPENSSH…). */
1664
+ const PRIVATE_KEY_ARMOUR = /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/;
1665
+ function readPublicKey(pem) {
1666
+ if (PRIVATE_KEY_ARMOUR.test(pem)) return {
1667
+ ok: false,
1668
+ reason: "that file is a PRIVATE key — register takes the public half (.pub)"
1669
+ };
1670
+ let algorithm;
1671
+ try {
1672
+ const key = createPublicKey(pem);
1673
+ const type = key.asymmetricKeyType;
1674
+ const curve = key.asymmetricKeyDetails?.namedCurve;
1675
+ if (type === "ed25519") algorithm = "EdDSA";
1676
+ else if (type === "rsa") algorithm = "RS256";
1677
+ else if (type === "ec" && curve === "prime256v1") algorithm = "ES256";
1678
+ else {
1679
+ const detail = curve !== void 0 ? ` (${curve})` : "";
1680
+ return {
1681
+ ok: false,
1682
+ reason: `a ${type ?? "unknown"}${detail} key — the server takes Ed25519, RSA or P-256`
1683
+ };
1684
+ }
1685
+ } catch {
1686
+ return {
1687
+ ok: false,
1688
+ reason: "not a readable public-key PEM"
1689
+ };
1690
+ }
1691
+ const digest = createHash("sha256").update(pem).digest("base64url");
1692
+ return {
1693
+ ok: true,
1694
+ algorithm,
1695
+ fingerprint: `sha256:${digest.slice(0, 8)}`
1696
+ };
1697
+ }
1698
+ /** Expands `~` and `~/…` to the home directory. `~user/…` names someone else's home, which this
1699
+ * does not resolve: it is refused rather than rewritten as the current user's. */
1700
+ function expandHome(text, home = homedir()) {
1701
+ if (text === "~" || text.startsWith("~/")) return { path: home + text.slice(1) };
1702
+ if (text.startsWith("~")) return { error: "only ~ and ~/… are expanded — give the full path instead of ~user" };
1703
+ return { path: text };
1704
+ }
1705
+
1706
+ //#endregion
1707
+ //#region src/keys/verdict.ts
1708
+ const EMPTY = {
1709
+ kind: "refused",
1710
+ code: "empty_grant",
1711
+ message: "a key needs at least one namespace — revoke removes all access"
1712
+ };
1713
+ const adminKids = (table, cap) => table.filter((r) => isActiveAdminKeyRow(r, cap)).map((r) => r.kid);
1714
+ const unhosted = (next, hosted) => flattenGrant(next).namespaces.filter((ns) => ns !== "*" && !hosted.includes(ns));
1715
+ const starOf = (g) => [...g.get("*") ?? []].sort().join("+");
1716
+ const rowGrantOrEmpty = (row) => rowGrant(row) ?? /* @__PURE__ */ new Map();
1717
+ function updateVerdict(i) {
1718
+ if (i.next.size === 0) return EMPTY;
1719
+ const wire = toWire(i.next);
1720
+ const facts = {
1721
+ kid: i.row.kid,
1722
+ exists: true,
1723
+ existingRevokedAt: null,
1724
+ prevNamespaces: decodeList(i.row.namespaces),
1725
+ prevCapabilities: decodeList(i.row.capabilities),
1726
+ nextNamespaces: wire.namespaces,
1727
+ nextCapabilities: wire.capabilities,
1728
+ activeAdminKids: adminKids(i.table, i.adminCapability),
1729
+ adminCapability: i.adminCapability
1730
+ };
1731
+ const flags = [];
1732
+ const reasons = [];
1733
+ let widen = false;
1734
+ let allowAdmin = false;
1735
+ for (;;) {
1736
+ const r = checkUpdatable({
1737
+ ...facts,
1738
+ widen,
1739
+ allowAdmin
1740
+ });
1741
+ if (r === null) break;
1742
+ if (r.code === "requires_widen" && !widen) {
1743
+ widen = true;
1744
+ flags.push("widen");
1745
+ reasons.push(r.message);
1746
+ } else if (r.code === "requires_allow_admin" && !allowAdmin) {
1747
+ allowAdmin = true;
1748
+ flags.push("allowAdmin");
1749
+ reasons.push(r.message);
1750
+ } else return {
1751
+ kind: "refused",
1752
+ code: r.code,
1753
+ message: r.message
1754
+ };
1755
+ }
1756
+ const unknown = unhosted(i.next, i.hosted);
1757
+ if (unknown.length > 0) {
1758
+ flags.push("allowUnknownNs");
1759
+ reasons.push(`names namespace(s) this deployment does not host: ${unknown.join(", ")}`);
1760
+ }
1761
+ if (starOf(rowGrantOrEmpty(i.row)) !== starOf(i.next)) reasons.push("changes the * row, which reaches every namespace");
1762
+ if (!allowAdmin && grantEntriesConfer(wire.namespaces, wire.capabilities, i.adminCapability)) reasons.push(`carries '${i.adminCapability}'`);
1763
+ return {
1764
+ kind: "ok",
1765
+ tier: reasons.length > 0 ? 2 : 1,
1766
+ flags,
1767
+ reasons
1768
+ };
1769
+ }
1770
+ function revokeVerdict(i) {
1771
+ const r = checkRevocable({
1772
+ kid: i.kid,
1773
+ exists: true,
1774
+ existingRevokedAt: null,
1775
+ activeAdminKids: adminKids(i.table, i.adminCapability)
1776
+ });
1777
+ return r === null ? {
1778
+ kind: "ok",
1779
+ tier: 1,
1780
+ flags: [],
1781
+ reasons: []
1782
+ } : {
1783
+ kind: "refused",
1784
+ code: r.code,
1785
+ message: r.message
1786
+ };
1787
+ }
1788
+ function newKeyVerdict(i) {
1789
+ if (i.next.size === 0) return EMPTY;
1790
+ const wire = toWire(i.next);
1791
+ const flags = [];
1792
+ const reasons = i.kind === "register" ? ["registers a new identity"] : [];
1793
+ if (grantEntriesConfer(wire.namespaces, wire.capabilities, i.adminCapability)) {
1794
+ if (i.kind === "register") flags.push("allowAdmin");
1795
+ reasons.push(`carries '${i.adminCapability}'`);
1796
+ }
1797
+ const unknown = unhosted(i.next, i.hosted);
1798
+ if (unknown.length > 0) {
1799
+ if (i.kind === "register") flags.push("allowUnknownNs");
1800
+ reasons.push(`names namespace(s) this deployment does not host: ${unknown.join(", ")}`);
1801
+ }
1802
+ if (i.next.has("*")) reasons.push("grants the * row, which reaches every namespace");
1803
+ return {
1804
+ kind: "ok",
1805
+ tier: reasons.length > 0 ? 2 : 1,
1806
+ flags,
1807
+ reasons
1808
+ };
1809
+ }
1810
+
1811
+ //#endregion
1812
+ //#region src/keys/editor.tsx
1813
+ /**
1814
+ * The `/keys` grant editor: the matrix (`edit`), a text prompt (`prompt`) and the
1815
+ * review (`review`). Each stage that takes typed text is its own panel, because a panel's keymap
1816
+ * wins over the draft (surface-core keys.ts).
1817
+ */
1818
+ /** Parses `30m`, `12h`, `7d`; '' is the server default (undefined). Null when unreadable. */
1819
+ function parseTtl(text) {
1820
+ const t = text.trim();
1821
+ if (t === "") return void 0;
1822
+ const m = /^(\d+)\s*([mhd])$/.exec(t);
1823
+ if (m === null) return null;
1824
+ const n = Number(m[1]);
1825
+ return n > 0 ? n * {
1826
+ m: 6e4,
1827
+ h: 36e5,
1828
+ d: 864e5
1829
+ }[m[2]] : null;
1830
+ }
1831
+ /** The invites route's ceiling (substrate-compose `_system/routes/shared.ts` MAX_TTL_MS): 30 days. */
1832
+ const MAX_INVITE_TTL_MS = 2592e6;
1833
+ /** The invite-ttl prompt's reading: a ttl (absent for the server default), or why not. */
1834
+ function readInviteTtl(text) {
1835
+ const ttlMs = parseTtl(text);
1836
+ if (ttlMs === null) return { error: "a duration like 30m, 12h or 7d" };
1837
+ if (ttlMs === void 0) return {};
1838
+ return ttlMs > MAX_INVITE_TTL_MS ? { error: "at most 30 days" } : { ttlMs };
1839
+ }
1840
+ const NS_NAME = /^(\*|[A-Za-z0-9_.-]+)$/;
1841
+ const refusal = (text) => ({ error: text });
1842
+ /** As the shared toned lines: a refusal in the error tone, anything else untoned. */
1843
+ const toned = (lines) => lines.map((l) => typeof l === "string" ? { text: l } : {
1844
+ text: l.error,
1845
+ tone: "error"
1846
+ });
1847
+ function promptView(s, glyph) {
1848
+ return [
1849
+ `${glyph.title} keys › ${sanitise(s.label)}`,
1850
+ ...s.kid !== void 0 ? [` ${sanitise(s.kid)}`] : [],
1851
+ ...s.algorithm !== void 0 && s.fingerprint !== void 0 ? [` ${s.algorithm} · ${s.fingerprint}`] : [],
1852
+ ...s.error !== null ? [refusal(` ${sanitise(s.error)}`)] : [],
1853
+ " enter accept · esc back"
1854
+ ];
1855
+ }
1856
+ /** What the tier-2 gesture asks to be typed: the kid, or the word `invite`. Never pre-filled. */
1857
+ function targetOf$1(flow) {
1858
+ switch (flow.kind) {
1859
+ case "update":
1860
+ case "revoke": return flow.row.kid;
1861
+ case "register": return flow.kid;
1862
+ case "invite": return "invite";
1863
+ }
1864
+ }
1865
+ const EMPTY_NOTE = "a key needs at least one namespace — revoke removes all access";
1866
+ /** The request's verdict, judged on exactly what apply will send. */
1867
+ function verdictOf(deps, s, next) {
1868
+ const hosted = [...s.matrix.hosted];
1869
+ const adminCapability = adminCapabilityOf(deps.adminEnv).adminCapability ?? "admin";
1870
+ return s.flow.kind === "update" ? updateVerdict({
1871
+ row: s.flow.row,
1872
+ next,
1873
+ table: s.table,
1874
+ hosted,
1875
+ adminCapability
1876
+ }) : newKeyVerdict({
1877
+ kind: s.flow.kind,
1878
+ next,
1879
+ hosted,
1880
+ adminCapability
1881
+ });
1882
+ }
1883
+ function reviewOf(s, next, verdict, ttlMs) {
1884
+ return {
1885
+ flow: s.flow,
1886
+ edit: s,
1887
+ next,
1888
+ verdict,
1889
+ ...ttlMs !== void 0 ? { ttlMs } : {},
1890
+ stage: "review",
1891
+ error: null,
1892
+ mismatch: false,
1893
+ table: s.table
1894
+ };
1895
+ }
1896
+ function ttlText(ms) {
1897
+ for (const [unit, n] of [
1898
+ ["d", 864e5],
1899
+ ["h", 36e5],
1900
+ ["m", 6e4]
1901
+ ]) if (ms % n === 0) return `${ms / n}${unit}`;
1902
+ return `${ms}ms`;
1903
+ }
1904
+ const entry = (ns, caps) => compactGrant(ns, [...caps].join(","));
1905
+ /** One line per namespace whose entry changed: removed (−), added (+), or both for a change. */
1906
+ function diffLines(before, after) {
1907
+ const out = [];
1908
+ for (const ns of /* @__PURE__ */ new Set([...before.keys(), ...after.keys()])) {
1909
+ const a = before.get(ns);
1910
+ const b = after.get(ns);
1911
+ if (a !== void 0 && b !== void 0 && a.size === b.size && [...a].every((c) => b.has(c))) continue;
1912
+ if (a !== void 0 && a.size > 0) out.push(` − ${entry(ns, a)}`);
1913
+ if (b !== void 0 && b.size > 0) out.push(` + ${entry(ns, b)}`);
1914
+ }
1915
+ return out;
1916
+ }
1917
+ /** ` code <chunk>` then ` | <chunk>` lines, each at most `width` cells; nothing is dropped. */
1918
+ function wrapCode(code, width) {
1919
+ const first = " code ";
1920
+ const next = " | ";
1921
+ const out = [];
1922
+ let rest = Array.from(code);
1923
+ let prefix = first;
1924
+ do {
1925
+ const room = Math.max(1, width - prefix.length);
1926
+ out.push(prefix + rest.slice(0, room).join(""));
1927
+ rest = rest.slice(room);
1928
+ prefix = next;
1929
+ } while (rest.length > 0);
1930
+ return out;
1931
+ }
1932
+ function reviewView(s, glyph, armed, size) {
1933
+ const w = Math.max(1, size.columns - 2);
1934
+ const fit = (line) => truncateCells(sanitise(line), w);
1935
+ const f = s.flow;
1936
+ const target = targetOf$1(f);
1937
+ const next = s.next ?? /* @__PURE__ */ new Map();
1938
+ const body = [];
1939
+ switch (f.kind) {
1940
+ case "update":
1941
+ body.push(` before ${compactGrant(f.row.namespaces, f.row.capabilities)}`, ` after ${grantLine(next)}`, ...diffLines(rowGrant(f.row) ?? /* @__PURE__ */ new Map(), next));
1942
+ break;
1943
+ case "register":
1944
+ body.push(` register ${f.kid} · ${f.algorithm} · ${f.fingerprint}`, ` grant ${grantLine(next)}`);
1945
+ break;
1946
+ case "invite":
1947
+ body.push(` invite for ${grantLine(next)}`, ` expires in ${s.ttlMs !== void 0 ? ttlText(s.ttlMs) : "the server default"}`);
1948
+ break;
1949
+ case "revoke": body.push(` ${f.row.kid} loses access immediately`);
1950
+ }
1951
+ const v = s.verdict;
1952
+ const verdict = v.kind === "refused" ? [refusal(` refused: ${v.code}: ${v.message}`)] : v.reasons.map((r) => ` · ${r}`);
1953
+ let tail;
1954
+ switch (s.stage) {
1955
+ case "review":
1956
+ tail = v.kind === "refused" ? [" esc back"] : v.tier === 1 ? [armed !== void 0 ? ` ${armed.key} — ${armed.label} · enter confirms · esc back` : " y then enter applies · esc back"] : [` type ${target} then enter to apply · esc back`, ...s.mismatch ? [" does not match"] : []];
1957
+ break;
1958
+ case "applying":
1959
+ tail = [" applying…"];
1960
+ break;
1961
+ case "refused":
1962
+ tail = [
1963
+ refusal(` ${s.error?.code ?? "error"}: ${s.error?.message ?? ""}`),
1964
+ ...s.refreshNote !== void 0 ? [refusal(` ${s.refreshNote}`)] : [],
1965
+ " esc back"
1966
+ ];
1967
+ break;
1968
+ case "done": tail = s.inviteCode !== void 0 ? [
1969
+ ...wrapCode(sanitise(s.inviteCode), w),
1970
+ " give it to the invitee; it is not shown again",
1971
+ " esc close"
1972
+ ] : [" esc close"];
1973
+ }
1974
+ const head = fit(`${glyph.title} keys › ${target} · review`);
1975
+ const fitLine = (l) => typeof l === "string" ? fit(l) : refusal(fit(l.error));
1976
+ return [
1977
+ head,
1978
+ ...body.map(fit),
1979
+ ...verdict.map(fitLine),
1980
+ ...tail.map((l) => typeof l === "string" && (l.startsWith(" code ") || l.startsWith(" | ")) ? l : fitLine(l))
1981
+ ];
1982
+ }
1983
+ /** The 422 refusals a verdict can predict and a stale table can get wrong (verdict.ts). */
1984
+ const CONFIRMABLE = /* @__PURE__ */ new Set([
1985
+ "requires_widen",
1986
+ "requires_allow_admin",
1987
+ "unknown_namespaces"
1988
+ ]);
1989
+ /** Every panel it opens is an admin panel, so it opens nothing once the session is locked. */
1990
+ function switchTo(ctx, deps, panel, state) {
1991
+ if (deps.admin.unlocked()) ctx.open(panel, state);
1992
+ }
1993
+ /** Opens the matrix for a flow; reads the hosted namespaces first. */
1994
+ async function openEditor(ctx, deps, flow, table, from = "keys") {
1995
+ const subject = flow.kind === "invite" ? "new invite" : flow.kind === "update" ? flow.row.kid : flow.kid;
1996
+ let hosted;
1997
+ try {
1998
+ hosted = hostedNamespaces(await deps.federatedSchema());
1999
+ } catch (err) {
2000
+ ctx.print(`cannot edit ${subject}: could not read the hosted namespaces: ${err instanceof Error ? err.message : String(err)}`, "error");
2001
+ return;
2002
+ }
2003
+ let stillThere = false;
2004
+ ctx.update(from, (cur) => {
2005
+ stillThere = true;
2006
+ return cur;
2007
+ });
2008
+ if (!stillThere || !deps.admin.unlocked()) return;
2009
+ let before = /* @__PURE__ */ new Map();
2010
+ if (flow.kind === "update") {
2011
+ const parsed = rowGrant(flow.row);
2012
+ if (parsed === null) {
2013
+ ctx.print(`cannot edit ${flow.row.kid}: its stored grant does not parse`, "error");
2014
+ return;
2015
+ }
2016
+ before = parsed;
2017
+ }
2018
+ ctx.setDraft("");
2019
+ ctx.open("edit", {
2020
+ flow,
2021
+ matrix: openMatrix(before, hosted),
2022
+ table,
2023
+ discardArmed: false,
2024
+ note: null
2025
+ });
2026
+ }
2027
+ const GLYPH = {
2028
+ on: "■",
2029
+ inherited: "◆",
2030
+ off: "□"
2031
+ };
2032
+ const DIFF = {
2033
+ "+": "+",
2034
+ "-": "−",
2035
+ "": " "
2036
+ };
2037
+ function grantLine(g) {
2038
+ const wire = toWire(g);
2039
+ return wire.namespaces.length === 0 ? "(none)" : compactGrant(wire.namespaces.join(","), wire.capabilities.join(","));
2040
+ }
2041
+ function editView(s, glyph, size) {
2042
+ const w = Math.max(1, size.columns - 2);
2043
+ const fit = (line) => truncateCells(sanitise(line), w);
2044
+ const m = s.matrix;
2045
+ const f = s.flow;
2046
+ const subject = f.kind === "invite" ? "new invite" : f.kind === "update" ? f.row.kid : f.kid;
2047
+ const label = (ns) => ns === "*" ? "* every namespace" : ns;
2048
+ const nsW = Math.min(24, Math.max(9, ...m.rows.map((ns) => label(ns).length)) + 2);
2049
+ const granted = result(m);
2050
+ const visible = Math.max(1, size.rows - 6);
2051
+ const start = Math.max(0, Math.min(m.row - visible + 1, m.rows.length - visible));
2052
+ const grid = m.rows.slice(start, start + visible).map((ns, k) => {
2053
+ const at = start + k === m.row;
2054
+ const cells = CAPS.map((cap, c) => {
2055
+ const x = cell(m, ns, cap);
2056
+ const text = `${GLYPH[x.held]}${DIFF[x.diff]}`;
2057
+ return at && c === m.col ? `[${text}]` : ` ${text} `;
2058
+ }).join("");
2059
+ const tail = ns !== "*" && !m.hosted.has(ns) ? " unhosted" : m.hosted.has(ns) && !granted.has(ns) ? " (hosted, not granted)" : "";
2060
+ const name = truncateCells(label(ns), nsW - 2).padEnd(nsW);
2061
+ return fit(`${at ? glyph.cursor : " "} ${name}${cells}${tail}`.trimEnd());
2062
+ });
2063
+ const before = f.kind === "update" ? compactGrant(f.row.namespaces, f.row.capabilities) : "(none)";
2064
+ const foot = s.note !== null ? [s.note === EMPTY_NOTE ? refusal(fit(` ${s.note}`)) : fit(` ${s.note}`)] : s.discardArmed ? [" discard changes? esc again"] : [];
2065
+ return [
2066
+ fit(`${glyph.title} keys › ${subject} · editing`),
2067
+ fit(` ${"namespace".padEnd(nsW)} r m a`),
2068
+ ...grid,
2069
+ fit(` before ${before}`),
2070
+ fit(` after ${grantLine(granted)}`),
2071
+ ...foot,
2072
+ fit(" ↑↓←→ move · space toggle · + add namespace · enter review · esc discard")
2073
+ ];
2074
+ }
2075
+ function editorPanels(deps) {
2076
+ const act = (fn) => (s, ctx) => {
2077
+ ctx.setDraft("");
2078
+ return fn({
2079
+ ...s,
2080
+ discardArmed: false,
2081
+ note: null
2082
+ }, ctx);
2083
+ };
2084
+ const step = (dRow, dCol) => act((s) => ({
2085
+ ...s,
2086
+ matrix: move(s.matrix, dRow, dCol)
2087
+ }));
2088
+ return {
2089
+ edit: {
2090
+ view: ({ state, theme, size }) => linesView(toned(editView(state, theme.glyph, size)), theme),
2091
+ keymap: [
2092
+ {
2093
+ key: "up",
2094
+ act: "up"
2095
+ },
2096
+ {
2097
+ key: "down",
2098
+ act: "down"
2099
+ },
2100
+ {
2101
+ key: "left",
2102
+ act: "left"
2103
+ },
2104
+ {
2105
+ key: "right",
2106
+ act: "right"
2107
+ },
2108
+ {
2109
+ key: " ",
2110
+ act: "toggle"
2111
+ },
2112
+ {
2113
+ key: "+",
2114
+ act: "add"
2115
+ },
2116
+ {
2117
+ key: "enter",
2118
+ act: "review"
2119
+ }
2120
+ ],
2121
+ on: {
2122
+ up: step(-1, 0),
2123
+ down: step(1, 0),
2124
+ left: step(0, -1),
2125
+ right: step(0, 1),
2126
+ toggle: act((s) => ({
2127
+ ...s,
2128
+ matrix: toggle(s.matrix)
2129
+ })),
2130
+ add: act((s, ctx) => {
2131
+ switchTo(ctx, deps, "prompt", {
2132
+ purpose: "add-ns",
2133
+ label: "namespace to add",
2134
+ error: null,
2135
+ back: s,
2136
+ table: s.table
2137
+ });
2138
+ }),
2139
+ review: act((s, ctx) => {
2140
+ if (s.flow.kind === "update" && !changed(s.matrix)) return {
2141
+ ...s,
2142
+ note: "no changes to review"
2143
+ };
2144
+ const next = result(s.matrix);
2145
+ if (next.size === 0) return {
2146
+ ...s,
2147
+ note: EMPTY_NOTE
2148
+ };
2149
+ if (s.flow.kind === "invite") {
2150
+ switchTo(ctx, deps, "prompt", {
2151
+ purpose: "invite-ttl",
2152
+ label: "expires in (e.g. 30m, 12h, 7d; enter for the server default)",
2153
+ error: null,
2154
+ back: s,
2155
+ table: s.table
2156
+ });
2157
+ return;
2158
+ }
2159
+ switchTo(ctx, deps, "review", reviewOf(s, next, verdictOf(deps, s, next)));
2160
+ })
2161
+ },
2162
+ submit: () => void 0,
2163
+ escape: (s) => changed(s.matrix) && !s.discardArmed ? {
2164
+ ...s,
2165
+ discardArmed: true
2166
+ } : void 0
2167
+ },
2168
+ prompt: {
2169
+ view: ({ state, theme }) => linesView(toned(promptView(state, theme.glyph)), theme),
2170
+ submit: (s, line, ctx) => {
2171
+ const text = line.trim();
2172
+ switch (s.purpose) {
2173
+ case "add-ns":
2174
+ if (s.back === null) return void 0;
2175
+ if (!NS_NAME.test(text)) return {
2176
+ ...s,
2177
+ error: "a namespace is letters, digits, _ . - or *"
2178
+ };
2179
+ switchTo(ctx, deps, "edit", {
2180
+ ...s.back,
2181
+ matrix: addNamespace(s.back.matrix, text),
2182
+ discardArmed: false,
2183
+ note: null
2184
+ });
2185
+ return;
2186
+ case "reg-kid":
2187
+ if (text === "") return {
2188
+ ...s,
2189
+ error: "a kid is required"
2190
+ };
2191
+ if (s.table.some((r) => r.kid === text)) return {
2192
+ ...s,
2193
+ error: `${text} is already live — revoke it first`
2194
+ };
2195
+ return {
2196
+ ...s,
2197
+ purpose: "reg-pubkey",
2198
+ label: "public key path",
2199
+ kid: text,
2200
+ error: null
2201
+ };
2202
+ case "reg-pubkey": {
2203
+ const expanded = expandHome(text);
2204
+ if ("error" in expanded) return {
2205
+ ...s,
2206
+ error: expanded.error
2207
+ };
2208
+ const path = expanded.path;
2209
+ const kid = s.kid;
2210
+ readFile(path, "utf8").then((pem) => {
2211
+ const read = readPublicKey(pem);
2212
+ ctx.update("prompt", (cur) => cur.purpose !== "reg-pubkey" || cur.kid !== kid ? cur : read.ok ? {
2213
+ ...cur,
2214
+ purpose: "reg-name",
2215
+ label: "name (enter for none)",
2216
+ publicKey: pem,
2217
+ algorithm: read.algorithm,
2218
+ fingerprint: read.fingerprint,
2219
+ error: null
2220
+ } : {
2221
+ ...cur,
2222
+ error: read.reason
2223
+ });
2224
+ }, (err) => {
2225
+ const code = err.code;
2226
+ const why = typeof code === "string" ? code : "unreadable";
2227
+ ctx.update("prompt", (cur) => cur.purpose !== "reg-pubkey" || cur.kid !== kid ? cur : {
2228
+ ...cur,
2229
+ error: `cannot read ${path}: ${why}`
2230
+ });
2231
+ });
2232
+ return {
2233
+ ...s,
2234
+ error: null
2235
+ };
2236
+ }
2237
+ case "reg-name":
2238
+ if (s.kid === void 0 || s.publicKey === void 0 || s.algorithm === void 0 || s.fingerprint === void 0) return;
2239
+ openEditor(ctx, deps, {
2240
+ kind: "register",
2241
+ kid: s.kid,
2242
+ publicKey: s.publicKey,
2243
+ algorithm: s.algorithm,
2244
+ fingerprint: s.fingerprint,
2245
+ ...text !== "" ? { name: text } : {}
2246
+ }, s.table, "prompt");
2247
+ return;
2248
+ case "invite-ttl": {
2249
+ if (s.back === null) return void 0;
2250
+ const ttl = readInviteTtl(line);
2251
+ if ("error" in ttl) return {
2252
+ ...s,
2253
+ error: ttl.error
2254
+ };
2255
+ const next = result(s.back.matrix);
2256
+ switchTo(ctx, deps, "review", reviewOf(s.back, next, verdictOf(deps, s.back, next), ttl.ttlMs));
2257
+ return;
2258
+ }
2259
+ }
2260
+ },
2261
+ escape: (s, ctx) => {
2262
+ if (s.back !== null) switchTo(ctx, deps, "edit", s.back);
2263
+ else loadKeys(ctx, deps);
2264
+ }
2265
+ },
2266
+ review: {
2267
+ view: ({ state, theme, armed, size }) => linesView(toned(reviewView(state, theme.glyph, armed, size)), theme),
2268
+ confirm: [{
2269
+ key: "y",
2270
+ act: "apply",
2271
+ label: "applies this change"
2272
+ }],
2273
+ on: { apply: (s, ctx) => s.stage === "review" && s.verdict.kind === "ok" && s.verdict.tier === 1 ? apply(s, ctx) : void 0 },
2274
+ submit: (s, line, ctx) => {
2275
+ if (!(s.stage === "review" && s.verdict.kind === "ok" && s.verdict.tier === 2)) return;
2276
+ return line === targetOf$1(s.flow) ? apply(s, ctx) : {
2277
+ ...s,
2278
+ mismatch: true
2279
+ };
2280
+ },
2281
+ escape: (s, ctx) => {
2282
+ if (s.stage === "applying") return s;
2283
+ if (s.stage !== "done" && s.edit !== null) switchTo(ctx, deps, "edit", s.edit);
2284
+ else loadKeys(ctx, deps);
2285
+ }
2286
+ }
2287
+ };
2288
+ /** After a confirmable 422 the verdict was judged on state the server no longer holds: the key's
2289
+ * row, the key table or the hosted set. Re-reads the table and the hosted namespaces, swaps the
2290
+ * fresh row into the flow and the editor it returns to, and recomputes the verdict, so the next
2291
+ * review (Esc, Enter) shows the real tier and flags. The refusal stays visible; nothing is sent. */
2292
+ async function refreshReview(ctx, at) {
2293
+ let rows;
2294
+ let hosted;
2295
+ try {
2296
+ rows = await deps.admin.run(({ client }) => client.admin.keys({ signal: ctx.signal }));
2297
+ hosted = hostedNamespaces(await deps.federatedSchema());
2298
+ } catch {
2299
+ ctx.update("review", (x) => x.flow === at.flow && x.stage === "refused" ? {
2300
+ ...x,
2301
+ refreshNote: "could not refresh the key table or hosted namespaces — esc back, then reopen it"
2302
+ } : x);
2303
+ return;
2304
+ }
2305
+ if (!deps.admin.unlocked()) return;
2306
+ ctx.update("review", (x) => {
2307
+ if (x.flow !== at.flow || x.stage !== "refused") return x;
2308
+ const f = x.flow;
2309
+ const gone = (kid) => ({
2310
+ ...x,
2311
+ edit: null,
2312
+ table: rows,
2313
+ mismatch: false,
2314
+ refreshNote: `${kid} is no longer in the key table — esc back and reopen it`,
2315
+ verdict: {
2316
+ kind: "refused",
2317
+ code: "not_found",
2318
+ message: `${kid} is no longer in the key table`
2319
+ }
2320
+ });
2321
+ if (f.kind === "revoke") {
2322
+ const row = rows.find((r) => r.kid === f.row.kid);
2323
+ return row === void 0 ? gone(f.row.kid) : {
2324
+ ...x,
2325
+ flow: {
2326
+ kind: "revoke",
2327
+ row
2328
+ },
2329
+ table: rows,
2330
+ verdict: revokeVerdict({
2331
+ kid: row.kid,
2332
+ table: rows,
2333
+ adminCapability: adminCapabilityOf(deps.adminEnv).adminCapability ?? "admin"
2334
+ })
2335
+ };
2336
+ }
2337
+ if (x.edit === null || x.next === null) return x;
2338
+ let flow = x.edit.flow;
2339
+ let matrix = {
2340
+ ...x.edit.matrix,
2341
+ hosted: new Set(hosted)
2342
+ };
2343
+ if (flow.kind === "update") {
2344
+ const row = rows.find((r) => r.kid === flow.row.kid);
2345
+ if (row === void 0) return gone(flow.row.kid);
2346
+ const before = rowGrant(row);
2347
+ if (before === null) return {
2348
+ ...x,
2349
+ edit: null,
2350
+ table: rows,
2351
+ refreshNote: `${row.kid}: its stored grant does not parse — esc back and reopen it`,
2352
+ verdict: {
2353
+ kind: "refused",
2354
+ code: "unparseable_grant",
2355
+ message: "stored grant does not parse"
2356
+ }
2357
+ };
2358
+ flow = {
2359
+ kind: "update",
2360
+ row
2361
+ };
2362
+ const extra = [...before.keys()].filter((ns) => !matrix.rows.includes(ns));
2363
+ matrix = {
2364
+ ...matrix,
2365
+ before,
2366
+ rows: [...matrix.rows, ...extra]
2367
+ };
2368
+ }
2369
+ const edit = {
2370
+ ...x.edit,
2371
+ flow,
2372
+ matrix,
2373
+ table: rows
2374
+ };
2375
+ return {
2376
+ ...x,
2377
+ flow,
2378
+ edit,
2379
+ table: rows,
2380
+ mismatch: false,
2381
+ verdict: verdictOf(deps, edit, x.next)
2382
+ };
2383
+ });
2384
+ }
2385
+ /** Sends exactly what the verdict judged, with only the flags it named; the result lands through `ctx.update`. */
2386
+ function apply(s, ctx) {
2387
+ const v = s.verdict;
2388
+ if (v.kind !== "ok") return s;
2389
+ const flag = (f) => v.flags.includes(f) ? { [f]: true } : {};
2390
+ const f = s.flow;
2391
+ const st = deps.admin.state();
2392
+ const who = st.kind === "unlocked" ? st.kid : "unknown";
2393
+ deps.admin.run(async ({ client }) => {
2394
+ if (f.kind === "revoke") {
2395
+ await client.admin.revoke(f.row.kid);
2396
+ return { line: `✓ revoked ${f.row.kid}` };
2397
+ }
2398
+ const w = toWire(s.next ?? /* @__PURE__ */ new Map());
2399
+ const shown = compactGrant(w.namespaces.join(","), w.capabilities.join(","));
2400
+ if (f.kind === "update") {
2401
+ await client.admin.update({
2402
+ kid: f.row.kid,
2403
+ ...w,
2404
+ ...flag("widen"),
2405
+ ...flag("allowAdmin"),
2406
+ ...flag("allowUnknownNs")
2407
+ });
2408
+ return {
2409
+ line: `✓ granted ${f.row.kid} ${shown}`,
2410
+ select: f.row.kid
2411
+ };
2412
+ }
2413
+ if (f.kind === "register") {
2414
+ const out = await client.admin.register({
2415
+ kid: f.kid,
2416
+ publicKey: f.publicKey,
2417
+ algorithm: f.algorithm,
2418
+ ...f.name !== void 0 ? { name: f.name } : {},
2419
+ ...w,
2420
+ ...flag("allowAdmin"),
2421
+ ...flag("allowUnknownNs")
2422
+ });
2423
+ return {
2424
+ line: `✓ registered ${f.kid} ${shown} ${out.fingerprint}`,
2425
+ select: f.kid
2426
+ };
2427
+ }
2428
+ const out = await client.admin.invite({
2429
+ ...w,
2430
+ ...s.ttlMs !== void 0 ? { ttlMs: s.ttlMs } : {}
2431
+ });
2432
+ return {
2433
+ line: `✓ invite minted for ${shown}, expires ${out.expiresAt}`,
2434
+ code: out.code
2435
+ };
2436
+ }).then((r) => {
2437
+ ctx.print(sanitise(`${r.line} (admin: ${who})`), "ok");
2438
+ let open = false;
2439
+ ctx.update("review", (x) => {
2440
+ open = true;
2441
+ return r.code !== void 0 ? {
2442
+ ...x,
2443
+ stage: "done",
2444
+ inviteCode: r.code
2445
+ } : {
2446
+ ...x,
2447
+ stage: "done"
2448
+ };
2449
+ });
2450
+ if (open && r.code === void 0 && deps.admin.unlocked()) loadKeys(ctx, deps, r.select);
2451
+ }, (err) => {
2452
+ const error = refusalOf(err);
2453
+ let open = false;
2454
+ ctx.update("review", (x) => {
2455
+ open = true;
2456
+ return {
2457
+ ...x,
2458
+ stage: "refused",
2459
+ error
2460
+ };
2461
+ });
2462
+ if (!open) ctx.print(`${error.code}: ${error.message}`, "error");
2463
+ else if (CONFIRMABLE.has(error.code)) refreshReview(ctx, s);
2464
+ });
2465
+ return {
2466
+ ...s,
2467
+ stage: "applying"
2468
+ };
2469
+ }
2470
+ }
2471
+
2472
+ //#endregion
2473
+ //#region src/keys/module.ts
2474
+ /**
2475
+ * `/keys`: the active key table. Unlocked it reads as the admin identity: Enter edits a
2476
+ * row's grant (editor.tsx), `x` revokes it, `i` mints an invite and `n` registers a new key, each
2477
+ * through a review. Locked, it reads as the agent, which a 403 turns into "admin required", and
2478
+ * Enter shows a row's grant read-only. Locking closes the table and every editor panel.
2479
+ */
2480
+ /** Opens the table and reads it; `select` is a kid whose row takes the cursor once the read lands. */
2481
+ async function loadKeys(ctx, deps, select) {
2482
+ const source = deps.admin.unlocked() ? "admin" : "agent";
2483
+ ctx.open("keys", {
2484
+ source,
2485
+ rows: null,
2486
+ note: null,
2487
+ cursor: 0,
2488
+ detail: false
2489
+ });
2490
+ try {
2491
+ const rows = source === "admin" ? await deps.admin.run(({ client }) => client.admin.keys({ signal: ctx.signal })) : await deps.keys(ctx.signal);
2492
+ const at = select === void 0 ? -1 : rows.findIndex((r) => r.kid === select);
2493
+ const cursor = Math.max(0, at);
2494
+ ctx.update("keys", (s) => s.source === source ? {
2495
+ ...s,
2496
+ rows,
2497
+ cursor
2498
+ } : s);
2499
+ } catch (err) {
2500
+ const note = source === "admin" && err instanceof AuthError && err.code !== void 0 && err.code !== "forbidden" ? `could not read keys: ${err.code}: ${err.serverMessage ?? err.message}` : err instanceof AuthError ? "admin required — /admin unlock" : `could not read keys: ${err instanceof Error ? err.message : String(err)}`;
2501
+ ctx.update("keys", (s) => s.source === source ? {
2502
+ ...s,
2503
+ rows: null,
2504
+ note
2505
+ } : s);
2506
+ }
2507
+ }
2508
+ function keysModule(deps) {
2509
+ const move = (d) => (s) => s.rows === null || s.detail ? void 0 : {
2510
+ ...s,
2511
+ cursor: Math.max(0, Math.min(s.rows.length - 1, s.cursor + d))
2512
+ };
2513
+ return defineModule("substrate.keys", {
2514
+ commands: [{
2515
+ name: "keys",
2516
+ summary: "the active key table",
2517
+ args: [],
2518
+ run: (_, ctx) => loadKeys(ctx, deps)
2519
+ }],
2520
+ panels: {
2521
+ keys: {
2522
+ view: ({ state, theme, size }) => {
2523
+ const w = Math.max(1, size.columns - 2);
2524
+ const count = state.rows === null ? "" : ` · ${state.rows.length} active`;
2525
+ const head = `${theme.glyph.title} keys${count} · ${state.source}`;
2526
+ if (state.note !== null) return [head, ` ${sanitise(state.note)}`].join("\n");
2527
+ if (state.rows === null) return [head, " reading…"].join("\n");
2528
+ const pick = state.rows[state.cursor];
2529
+ if (state.detail && pick !== void 0) return [
2530
+ head,
2531
+ truncateCells(sanitise(` ${pick.kid} ${pick.name} ${pick.algorithm}`), w),
2532
+ ...describeGrant(pick.namespaces, pick.capabilities).map((g) => truncateCells(sanitise(` ${g}`), w)),
2533
+ " backspace back · esc close"
2534
+ ].join("\n");
2535
+ const kidW = Math.min(40, Math.max(4, ...state.rows.map((r) => r.kid.length)));
2536
+ const nameW = Math.min(24, Math.max(4, ...state.rows.map((r) => r.name.length)));
2537
+ const max = Math.max(1, size.rows - 3);
2538
+ const start = Math.max(0, Math.min(state.cursor - max + 1, state.rows.length - max));
2539
+ return [
2540
+ head,
2541
+ ...state.rows.slice(start, start + max).map((r, k) => {
2542
+ const mark = start + k === state.cursor ? theme.glyph.cursor : " ";
2543
+ const grant = compactGrant(r.namespaces, r.capabilities);
2544
+ return truncateCells(sanitise(`${mark} ${r.kid.padEnd(kidW)} ${r.name.padEnd(nameW)} ${grant}`), w);
2545
+ }),
2546
+ truncateCells(` ${GRANT_LEGEND}`, w),
2547
+ state.source === "admin" && deps.admin.unlocked() ? " ↑↓/jk move · enter edit · x revoke · i invite · n register · esc close" : " ↑↓/jk move · enter grant · esc close"
2548
+ ].join("\n");
2549
+ },
2550
+ keymap: [
2551
+ {
2552
+ key: "up",
2553
+ act: "up"
2554
+ },
2555
+ {
2556
+ key: "k",
2557
+ act: "up"
2558
+ },
2559
+ {
2560
+ key: "down",
2561
+ act: "down"
2562
+ },
2563
+ {
2564
+ key: "j",
2565
+ act: "down"
2566
+ },
2567
+ {
2568
+ key: "enter",
2569
+ act: "in"
2570
+ },
2571
+ {
2572
+ key: "backspace",
2573
+ act: "out"
2574
+ },
2575
+ {
2576
+ key: "n",
2577
+ act: "register"
2578
+ },
2579
+ {
2580
+ key: "i",
2581
+ act: "invite"
2582
+ },
2583
+ {
2584
+ key: "x",
2585
+ act: "revoke"
2586
+ }
2587
+ ],
2588
+ on: {
2589
+ up: move(-1),
2590
+ down: move(1),
2591
+ in: (s, ctx) => {
2592
+ if (s.rows === null || s.rows.length === 0) return void 0;
2593
+ if (deps.admin.unlocked() && s.source === "admin") {
2594
+ openEditor(ctx, deps, {
2595
+ kind: "update",
2596
+ row: s.rows[s.cursor]
2597
+ }, s.rows);
2598
+ return;
2599
+ }
2600
+ return {
2601
+ ...s,
2602
+ detail: true
2603
+ };
2604
+ },
2605
+ out: (s) => s.detail ? {
2606
+ ...s,
2607
+ detail: false
2608
+ } : void 0,
2609
+ register: (s, ctx) => {
2610
+ if (!(deps.admin.unlocked() && s.source === "admin" && s.rows !== null && !s.detail)) return;
2611
+ ctx.setDraft("");
2612
+ switchTo(ctx, deps, "prompt", {
2613
+ purpose: "reg-kid",
2614
+ label: "kid to register",
2615
+ error: null,
2616
+ back: null,
2617
+ table: s.rows
2618
+ });
2619
+ },
2620
+ invite: (s, ctx) => {
2621
+ if (!(deps.admin.unlocked() && s.source === "admin" && s.rows !== null && !s.detail)) return;
2622
+ openEditor(ctx, deps, { kind: "invite" }, s.rows);
2623
+ },
2624
+ revoke: (s, ctx) => {
2625
+ if (!(deps.admin.unlocked() && s.source === "admin" && s.rows !== null && !s.detail)) return;
2626
+ const row = s.rows[s.cursor];
2627
+ if (row === void 0) return void 0;
2628
+ ctx.setDraft("");
2629
+ switchTo(ctx, deps, "review", {
2630
+ flow: {
2631
+ kind: "revoke",
2632
+ row
2633
+ },
2634
+ edit: null,
2635
+ next: null,
2636
+ verdict: revokeVerdict({
2637
+ kid: row.kid,
2638
+ table: s.rows,
2639
+ adminCapability: adminCapabilityOf(deps.adminEnv).adminCapability ?? "admin"
2640
+ }),
2641
+ stage: "review",
2642
+ error: null,
2643
+ mismatch: false,
2644
+ table: s.rows
2645
+ });
2646
+ }
2647
+ }
2648
+ },
2649
+ ...editorPanels(deps)
2650
+ },
2651
+ init(ctx) {
2652
+ const off = deps.admin.onChange((st) => {
2653
+ if (st.kind !== "locked") return;
2654
+ let adminOpen = false;
2655
+ ctx.update("keys", (s) => {
2656
+ adminOpen = s.source === "admin";
2657
+ return s;
2658
+ });
2659
+ for (const p of [
2660
+ "edit",
2661
+ "prompt",
2662
+ "review"
2663
+ ]) ctx.update(p, (x) => {
2664
+ if (!(p === "review" && "inviteCode" in x && x.inviteCode !== void 0)) adminOpen = true;
2665
+ return x;
2666
+ });
2667
+ if (adminOpen) {
2668
+ ctx.close();
2669
+ ctx.print("keys closed — admin locked", "warn");
2670
+ }
2671
+ });
2672
+ return () => {
2673
+ off();
2674
+ };
2675
+ }
2676
+ });
2677
+ }
2678
+
2679
+ //#endregion
2680
+ //#region src/schema/module.tsx
2681
+ /** `rec[key]` only when `key` is the record's own property: names come from the server and the user. */
2682
+ const own = (rec, key) => rec !== void 0 && key !== void 0 && Object.hasOwn(rec, key) ? rec[key] : void 0;
2683
+ const typesOf = (s) => own(s.schema, s.ns)?.types ?? {};
2684
+ const items = (s) => {
2685
+ if (s.level === "ns") return Object.keys(s.schema).sort();
2686
+ const types = typesOf(s);
2687
+ if (s.level === "type") return Object.keys(types).sort();
2688
+ const fields = own(types, s.type)?.fields;
2689
+ return Object.keys(fields ?? {}).sort();
2690
+ };
2691
+ function fieldLine(s, name) {
2692
+ const def = own(own(typesOf(s), s.type)?.fields, name);
2693
+ const pf = own(own(s.pack?.types, s.type)?.fields, name);
2694
+ const parts = [name, `${String(def?.kind ?? "?")}${def?.type !== void 0 ? `/${String(def.type)}` : ""}`];
2695
+ if (def?.indexed === true) parts.push("indexed");
2696
+ const en = def?.enum ?? pf?.enum;
2697
+ if (en !== void 0 && en.length > 0) parts.push(`enum: ${en.join(", ")}`);
2698
+ if (pf?.aliases !== void 0 && Object.keys(pf.aliases).length > 0) parts.push(`aliases: ${Object.entries(pf.aliases).map(([a, c]) => `${a}→${c}`).join(" ")}`);
2699
+ if (pf !== void 0 && pf.operators.length > 0) parts.push(`ops: ${pf.operators.join(" ")}`);
2700
+ return parts.join(" ");
2701
+ }
2702
+ function schemaModule(deps) {
2703
+ const loadPack = (ns, ctx) => {
2704
+ deps.nl.pack(ns, { signal: ctx.signal }).then((pack) => ctx.update("schema", (s) => s.ns === ns ? {
2705
+ ...s,
2706
+ pack
2707
+ } : s), () => void 0);
2708
+ };
2709
+ const move = (d) => (s) => ({
2710
+ ...s,
2711
+ cursor: Math.max(0, Math.min(items(s).length - 1, s.cursor + d))
2712
+ });
2713
+ const into = (s, ctx) => {
2714
+ const pick = items(s)[s.cursor];
2715
+ if (pick === void 0) return void 0;
2716
+ if (s.level === "ns") {
2717
+ loadPack(pick, ctx);
2718
+ const { pack: _drop, ...rest } = s;
2719
+ return {
2720
+ ...rest,
2721
+ level: "type",
2722
+ ns: pick,
2723
+ cursor: 0
2724
+ };
2725
+ }
2726
+ if (s.level === "type") return {
2727
+ ...s,
2728
+ level: "field",
2729
+ type: pick,
2730
+ cursor: 0
2731
+ };
2732
+ };
2733
+ const out = (s) => {
2734
+ if (s.level === "field") {
2735
+ const back = {
2736
+ ...s,
2737
+ level: "type"
2738
+ };
2739
+ return {
2740
+ ...back,
2741
+ cursor: Math.max(0, items(back).indexOf(s.type ?? ""))
2742
+ };
2743
+ }
2744
+ if (s.level === "type") {
2745
+ const { ns: _ns, pack: _p, type: _t, ...rest } = s;
2746
+ const back = {
2747
+ ...rest,
2748
+ level: "ns",
2749
+ cursor: 0
2750
+ };
2751
+ return {
2752
+ ...back,
2753
+ cursor: Math.max(0, items(back).indexOf(s.ns ?? ""))
2754
+ };
2755
+ }
2756
+ };
2757
+ return defineModule("substrate.schema", {
2758
+ commands: [{
2759
+ name: "schema",
2760
+ summary: "browse namespaces, types and fields",
2761
+ args: [{
2762
+ name: "namespace",
2763
+ optional: true
2764
+ }, {
2765
+ name: "type",
2766
+ optional: true
2767
+ }],
2768
+ async run([ns, type], ctx) {
2769
+ let schema;
2770
+ try {
2771
+ schema = await deps.federatedSchema();
2772
+ } catch (err) {
2773
+ ctx.print(`schema unavailable: ${sanitise(err instanceof Error ? err.message : String(err))}`, "error");
2774
+ return;
2775
+ }
2776
+ if (ns !== void 0 && !Object.hasOwn(schema, ns)) return ctx.print(`unknown namespace '${sanitise(ns)}'`, "warn");
2777
+ if (ns !== void 0 && type !== void 0 && !Object.hasOwn(schema[ns]?.types ?? {}, type)) return ctx.print(`unknown type '${sanitise(type)}' in ${sanitise(ns)}`, "warn");
2778
+ const state = ns === void 0 ? {
2779
+ level: "ns",
2780
+ schema,
2781
+ cursor: 0
2782
+ } : type === void 0 ? {
2783
+ level: "type",
2784
+ schema,
2785
+ ns,
2786
+ cursor: 0
2787
+ } : {
2788
+ level: "field",
2789
+ schema,
2790
+ ns,
2791
+ type,
2792
+ cursor: 0
2793
+ };
2794
+ ctx.open("schema", state);
2795
+ if (ns !== void 0) loadPack(ns, ctx);
2796
+ }
2797
+ }],
2798
+ panels: { schema: {
2799
+ view: ({ state, theme, size }) => {
2800
+ const head = state.level === "ns" ? "namespaces" : state.level === "type" ? `${state.ns} · types` : `${state.ns}/${state.type} · fields`;
2801
+ const all = items(state);
2802
+ const max = Math.max(1, size.rows - 2);
2803
+ const start = Math.max(0, Math.min(state.cursor - max + 1, all.length - max));
2804
+ const lines = all.slice(start, start + max).map((name, k) => {
2805
+ const mark = start + k === state.cursor ? theme.glyph.cursor : " ";
2806
+ const body = state.level === "ns" ? `${name} ${Object.keys(state.schema[name]?.types ?? {}).length} types` : state.level === "type" ? `${name} ${Object.keys(typesOf(state)[name]?.fields ?? {}).length} fields` : fieldLine(state, name);
2807
+ return truncateCells(sanitise(`${mark} ${body}`), Math.max(1, size.columns - 2));
2808
+ });
2809
+ return [
2810
+ truncateCells(sanitise(`${theme.glyph.title} ${head}`), size.columns),
2811
+ ...lines,
2812
+ truncateCells(" ↑↓/jk move · enter open · backspace up · esc close", size.columns)
2813
+ ].join("\n");
2814
+ },
2815
+ keymap: [
2816
+ {
2817
+ key: "up",
2818
+ act: "up"
2819
+ },
2820
+ {
2821
+ key: "k",
2822
+ act: "up"
2823
+ },
2824
+ {
2825
+ key: "down",
2826
+ act: "down"
2827
+ },
2828
+ {
2829
+ key: "j",
2830
+ act: "down"
2831
+ },
2832
+ {
2833
+ key: "enter",
2834
+ act: "in"
2835
+ },
2836
+ {
2837
+ key: "backspace",
2838
+ act: "out"
2839
+ }
2840
+ ],
2841
+ on: {
2842
+ up: move(-1),
2843
+ down: move(1),
2844
+ in: into,
2845
+ out: (s) => out(s)
2846
+ }
2847
+ } }
2848
+ });
2849
+ }
2850
+
2851
+ //#endregion
2852
+ //#region src/status/module.ts
2853
+ const HEALTH_INTERVAL_MS = 1e4;
2854
+ const HEALTH_TIMEOUT_MS = 5e3;
2855
+ function statusModule(deps, opts = {}) {
2856
+ let conn = "connecting";
2857
+ let namespace = deps.namespace;
2858
+ let inFlight = 0;
2859
+ const presenceText = () => {
2860
+ const parts = [conn === "live" ? "● live" : conn === "offline" ? "○ offline" : conn === "reconnecting" ? "◌ reconnecting" : "◌ …", clean(namespace)];
2861
+ if (conn === "live" && inFlight > 0) parts.push(`${inFlight} in flight`);
2862
+ return parts.join(" · ");
2863
+ };
2864
+ return defineModule("substrate.status", {
2865
+ presence: {
2866
+ name: "s u b s t r a t e",
2867
+ view: presenceText
2868
+ },
2869
+ init(ctx) {
2870
+ conn = "connecting";
2871
+ namespace = deps.namespace;
2872
+ inFlight = 0;
2873
+ const repaint = () => ctx.status("presence-tick", null);
2874
+ let everLive = false;
2875
+ let stopped = false;
2876
+ let polling = false;
2877
+ let needWho = false;
2878
+ ctx.status("version", "…", "dim");
2879
+ ctx.status("who", "…", "dim");
2880
+ const fetchWho = () => deps.getJson("/substrate/v1/whoami", ctx.signal).then((w) => {
2881
+ if (stopped) return;
2882
+ const id = w?.identity?.id;
2883
+ ctx.status("who", typeof id === "string" ? clean(id) : "anon", "dim");
2884
+ }, () => {
2885
+ if (!stopped) ctx.status("who", "anon?", "dim");
2886
+ });
2887
+ let metricsPending = false;
2888
+ const fetchInFlight = async () => {
2889
+ if (metricsPending) return;
2890
+ metricsPending = true;
2891
+ const timeout = new AbortController();
2892
+ const timer = setTimeout(() => timeout.abort(), HEALTH_TIMEOUT_MS);
2893
+ try {
2894
+ const n = (await deps.getJson("/substrate/v1/metrics", AbortSignal.any([ctx.signal, timeout.signal])))?.process?.inFlight;
2895
+ if (!stopped && conn === "live") inFlight = typeof n === "number" && Number.isFinite(n) && n > 0 ? n : 0;
2896
+ } catch {
2897
+ inFlight = 0;
2898
+ } finally {
2899
+ clearTimeout(timer);
2900
+ metricsPending = false;
2901
+ if (!stopped) repaint();
2902
+ }
2903
+ };
2904
+ const poll = async () => {
2905
+ if (polling) return;
2906
+ polling = true;
2907
+ const timeout = new AbortController();
2908
+ const timer = setTimeout(() => timeout.abort(), HEALTH_TIMEOUT_MS);
2909
+ try {
2910
+ const h = await deps.getJson("/health", AbortSignal.any([ctx.signal, timeout.signal]));
2911
+ if (stopped) return;
2912
+ everLive = true;
2913
+ conn = "live";
2914
+ repaint();
2915
+ fetchInFlight();
2916
+ if (typeof h.version === "string") ctx.status("version", clean(`v${h.version}`), "dim");
2917
+ if (needWho) {
2918
+ needWho = false;
2919
+ fetchWho();
2920
+ }
2921
+ } catch {
2922
+ if (stopped) return;
2923
+ needWho = true;
2924
+ conn = everLive ? "reconnecting" : "offline";
2925
+ inFlight = 0;
2926
+ repaint();
2927
+ ctx.status("version", "…", "dim");
2928
+ } finally {
2929
+ clearTimeout(timer);
2930
+ polling = false;
2931
+ }
2932
+ };
2933
+ poll();
2934
+ fetchWho();
2935
+ const off = ctx.on("substrate/ns", (e) => {
2936
+ namespace = e.namespace;
2937
+ repaint();
2938
+ });
2939
+ const timer = setInterval(() => void poll(), opts.intervalMs ?? 1e4);
2940
+ return () => {
2941
+ stopped = true;
2942
+ clearInterval(timer);
2943
+ off();
2944
+ };
2945
+ }
2946
+ });
2947
+ }
2948
+
2949
+ //#endregion
2950
+ //#region src/surgery/args.ts
2951
+ const NS = /^[A-Za-z_][\w-]*$/;
2952
+ const TYPE = /^([A-Za-z_][\w-]*)\/([A-Za-z_]\w*)$/;
2953
+ const FIELD = /^([A-Za-z_][\w-]*)\/([A-Za-z_]\w*)\.([A-Za-z_]\w*)$/;
2954
+ const USAGE = {
2955
+ "drop-namespace": "usage: /drop-namespace <ns> [--cascade]",
2956
+ "drop-type": "usage: /drop-type <ns>/<Type> [--cascade]",
2957
+ backfill: "usage: /backfill <ns>/<Type>.<field> --values <file.json> [--overwrite]",
2958
+ normalise: "usage: /normalise <ns>/<Type>.<field> [--reject a,b]",
2959
+ reindex: "usage: /reindex [<ns>/<Type> …]"
2960
+ };
2961
+ /** The deployment's own namespace. The server refuses to drop it too; this fails earlier and says why. */
2962
+ const SUBSTRATE = "_system";
2963
+ const OWN = "_system is the deployment's own namespace and cannot be dropped";
2964
+ /** Flags each command takes; `true` means the flag carries a value. */
2965
+ const FLAGS = {
2966
+ "drop-namespace": { "--cascade": false },
2967
+ "drop-type": { "--cascade": false },
2968
+ backfill: {
2969
+ "--values": true,
2970
+ "--overwrite": false
2971
+ },
2972
+ normalise: { "--reject": true },
2973
+ reindex: {}
2974
+ };
2975
+ function parseOp(command, args) {
2976
+ const usage = { error: USAGE[command] };
2977
+ const allowed = FLAGS[command];
2978
+ const positional = [];
2979
+ const flags = /* @__PURE__ */ new Map();
2980
+ for (let i = 0; i < args.length; i++) {
2981
+ const a = args[i];
2982
+ if (!a.startsWith("--")) {
2983
+ positional.push(a);
2984
+ continue;
2985
+ }
2986
+ if (!(a in allowed)) return { error: `unknown flag '${a}' for /${command}` };
2987
+ if (flags.has(a)) return { error: `flag '${a}' given twice for /${command}` };
2988
+ if (allowed[a]) {
2989
+ const v = args[i + 1];
2990
+ if (v === void 0 || v.startsWith("--")) return usage;
2991
+ flags.set(a, v);
2992
+ i++;
2993
+ } else flags.set(a, true);
2994
+ }
2995
+ switch (command) {
2996
+ case "drop-namespace": {
2997
+ const [ns, ...rest] = positional;
2998
+ if (ns === void 0 || rest.length > 0 || !NS.test(ns)) return usage;
2999
+ if (ns === SUBSTRATE) return { error: OWN };
3000
+ return {
3001
+ kind: "drop-namespace",
3002
+ namespace: ns,
3003
+ cascade: flags.has("--cascade")
3004
+ };
3005
+ }
3006
+ case "drop-type": {
3007
+ const m = positional.length === 1 ? TYPE.exec(positional[0]) : null;
3008
+ if (m === null) return usage;
3009
+ if (m[1] === SUBSTRATE) return { error: OWN };
3010
+ return {
3011
+ kind: "drop-type",
3012
+ namespace: m[1],
3013
+ type: m[2],
3014
+ cascade: flags.has("--cascade")
3015
+ };
3016
+ }
3017
+ case "backfill": {
3018
+ const m = positional.length === 1 ? FIELD.exec(positional[0]) : null;
3019
+ const values = flags.get("--values");
3020
+ if (m === null || typeof values !== "string") return usage;
3021
+ return {
3022
+ kind: "backfill",
3023
+ namespace: m[1],
3024
+ type: m[2],
3025
+ field: m[3],
3026
+ valuesPath: values,
3027
+ overwrite: flags.has("--overwrite")
3028
+ };
3029
+ }
3030
+ case "normalise": {
3031
+ const m = positional.length === 1 ? FIELD.exec(positional[0]) : null;
3032
+ if (m === null) return usage;
3033
+ const csv = flags.get("--reject");
3034
+ return {
3035
+ kind: "normalise",
3036
+ namespace: m[1],
3037
+ type: m[2],
3038
+ field: m[3],
3039
+ reject: typeof csv === "string" ? csv.split(",").map((s) => s.trim()).filter((s) => s !== "") : []
3040
+ };
3041
+ }
3042
+ case "reindex": {
3043
+ const targets = [];
3044
+ for (const p of positional) {
3045
+ const m = TYPE.exec(p);
3046
+ if (m === null) return usage;
3047
+ if (targets.some((t) => t.namespace === m[1] && t.type === m[2])) return { error: `${p} named twice` };
3048
+ targets.push({
3049
+ namespace: m[1],
3050
+ type: m[2]
3051
+ });
3052
+ }
3053
+ return {
3054
+ kind: "reindex",
3055
+ targets
3056
+ };
3057
+ }
3058
+ }
3059
+ }
3060
+ /** What the tier-2 gesture asks to be typed. Never pre-filled. */
3061
+ function targetOf(op) {
3062
+ switch (op.kind) {
3063
+ case "drop-namespace": return op.namespace;
3064
+ case "drop-type": return `${op.namespace}/${op.type}`;
3065
+ case "backfill":
3066
+ case "normalise": return `${op.namespace}/${op.type}.${op.field}`;
3067
+ case "reindex": return "reindex";
3068
+ }
3069
+ }
3070
+
3071
+ //#endregion
3072
+ //#region src/surgery/preview.ts
3073
+ /** Previews: what each surgery op would do, computed against the admin client before the operator types the target. */
3074
+ const line$1 = (text, tone) => ({
3075
+ text: sanitise(text),
3076
+ ...tone !== void 0 ? { tone } : {}
3077
+ });
3078
+ const ready = (lines, apply) => ({
3079
+ kind: "ready",
3080
+ lines,
3081
+ apply
3082
+ });
3083
+ function refused(refusal, extra = []) {
3084
+ return {
3085
+ kind: "refused",
3086
+ refusal,
3087
+ lines: [line$1(`${refusal.code}: ${refusal.message}`, "error"), ...extra]
3088
+ };
3089
+ }
3090
+ const local = (code, message) => refused({
3091
+ code,
3092
+ message: sanitise(message)
3093
+ });
3094
+ const sig = (env) => env.signal !== void 0 ? { signal: env.signal } : {};
3095
+ const num = (v) => typeof v === "number" ? v : Number(v ?? 0);
3096
+ async function previewOf(op, admin, env) {
3097
+ try {
3098
+ switch (op.kind) {
3099
+ case "drop-namespace":
3100
+ case "drop-type": return await dropPreview(op, admin, env);
3101
+ case "backfill": return await backfillPreview(op, admin, env);
3102
+ case "normalise": return await normalisePreview(op, admin, env);
3103
+ case "reindex": return await reindexPreview(op, admin, env);
3104
+ }
3105
+ } catch (err) {
3106
+ if (isKeyRejection(err)) throw err;
3107
+ return refusedFrom(err, op);
3108
+ }
3109
+ }
3110
+ function refusedFrom(err, op) {
3111
+ const refusal = refusalOf(err);
3112
+ return refused(refusal, refusalDetail(refusal, op));
3113
+ }
3114
+ /** The route-specific detail of a server refusal, sanitised: the preview and the apply both show it. */
3115
+ function refusalDetail(refusal, op) {
3116
+ const extra = [];
3117
+ const body = refusal.body;
3118
+ if (refusal.code === "dangling_edges" && body !== void 0) {
3119
+ extra.push(line$1(`${num(body.edgeCount)} dangling edges — pass --cascade to delete them`, "warn"));
3120
+ const samples = Array.isArray(body.sampleEdges) ? body.sampleEdges.slice(0, 5) : [];
3121
+ for (const s of samples) {
3122
+ const id = s?.id;
3123
+ if (id !== void 0) extra.push(line$1(` ${String(id)}`, "dim"));
3124
+ }
3125
+ } else if (refusal.code === "destructive_ops_disabled") extra.push(line$1("this deployment refuses destructive operations (SUBSTRATE_HARD_DELETE=refuse)", "warn"));
3126
+ else if (op.kind === "backfill" && body !== void 0 && Array.isArray(body.invalid)) for (const i of body.invalid.slice(0, 8)) {
3127
+ const e = i;
3128
+ extra.push(line$1(`${String(e.id)}: ${String(e.value)} (${String(e.reason)})`, "dim"));
3129
+ }
3130
+ else if (op.kind === "normalise" && body !== void 0 && Array.isArray(body.unmapped)) {
3131
+ for (const u of body.unmapped.slice(0, 8)) {
3132
+ const e = u;
3133
+ extra.push(line$1(`${String(e.value)} · ${num(e.count)}`, "warn"));
3134
+ }
3135
+ if (body.unmapped.length > 8) extra.push(line$1(`… ${body.unmapped.length - 8} more values`, "dim"));
3136
+ }
3137
+ return extra;
3138
+ }
3139
+ async function dropPreview(op, admin, env) {
3140
+ const base = {
3141
+ confirm: targetOf(op),
3142
+ cascade: op.cascade ? "delete" : "refuse",
3143
+ dryRun: true
3144
+ };
3145
+ const r = op.kind === "drop-namespace" ? await admin.dropNamespace({
3146
+ namespace: op.namespace,
3147
+ ...base
3148
+ }, sig(env)) : await admin.dropType({
3149
+ namespace: op.namespace,
3150
+ type: op.type,
3151
+ ...base
3152
+ }, sig(env));
3153
+ const lines = [line$1(`would delete ${r.rowsDeleted} rows · ${r.edgesDeleted} edges · ${r.shortIdsDeleted} shortIds`), line$1(`cascade: ${base.cascade}`, "dim")];
3154
+ if (op.kind === "drop-type") lines.push(line$1("drops data only — a type still declared reappears on the next boot", "warn"));
3155
+ return ready(lines, { kind: op.kind });
3156
+ }
3157
+ async function backfillPreview(op, admin, env) {
3158
+ const p = expandHome(op.valuesPath, env.home);
3159
+ if ("error" in p) return local("bad_path", p.error);
3160
+ let raw;
3161
+ try {
3162
+ raw = env.readFile(p.path);
3163
+ } catch (err) {
3164
+ return local("unreadable", `cannot read ${p.path}: ${err instanceof Error ? err.message : String(err)}`);
3165
+ }
3166
+ let parsed;
3167
+ try {
3168
+ parsed = JSON.parse(raw);
3169
+ } catch {
3170
+ return local("not_json", `${p.path} is not valid JSON`);
3171
+ }
3172
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return local("not_an_object", `${p.path} must hold a JSON object of row id → value`);
3173
+ const values = parsed;
3174
+ const r = await admin.backfill({
3175
+ namespace: op.namespace,
3176
+ type: op.type,
3177
+ field: op.field,
3178
+ values,
3179
+ overwrite: op.overwrite
3180
+ }, sig(env));
3181
+ const lines = [line$1(`values: ${resolve(p.path)} · ${Object.keys(values).length} entries`), line$1(`${r.requested} requested · would write ${r.write}`)];
3182
+ const counts = Object.entries(r.counts).sort((a, b) => b[1] - a[1]);
3183
+ for (const [value, n] of counts.slice(0, 8)) lines.push(line$1(` ${value} · ${n}`, "dim"));
3184
+ if (counts.length > 8) lines.push(line$1(` … ${counts.length - 8} more values`, "dim"));
3185
+ if (r.kept > 0 && !op.overwrite) lines.push(line$1(`kept ${r.kept} existing (pass --overwrite to replace)`));
3186
+ if (r.unchanged > 0) lines.push(line$1(`unchanged ${r.unchanged}`));
3187
+ if (r.missing.length > 0) lines.push(line$1(`missing ${r.missing.length} id(s)`, "warn"));
3188
+ if (r.chunks > 1) lines.push(line$1(`${r.chunks} requests of up to 5000 rows — apply is NOT atomic: a refusal part-way leaves earlier chunks written`, "warn"));
3189
+ return ready(lines, {
3190
+ kind: "backfill",
3191
+ values
3192
+ });
3193
+ }
3194
+ async function normalisePreview(op, admin, env) {
3195
+ const r = await admin.normalise({
3196
+ namespace: op.namespace,
3197
+ type: op.type,
3198
+ field: op.field,
3199
+ reject: op.reject
3200
+ }, sig(env));
3201
+ const lines = [line$1(`scanned ${r.scanned}`)];
3202
+ if (r.truncated) lines.push(line$1("scan stopped at its row cap — counts are partial", "warn"));
3203
+ for (const p of r.plan) lines.push(line$1(`${p.from} → ${p.to} · ${p.count}`));
3204
+ for (const u of r.unmapped) lines.push(line$1(`${u.value} · ${u.count}`, "warn"));
3205
+ const unlisted = r.unmapped.filter((u) => !op.reject.includes(String(u.value)));
3206
+ if (r.strict && unlisted.length > 0) return refused({
3207
+ code: "unmapped_values",
3208
+ message: "strict field: values no alias explains must be listed in --reject"
3209
+ }, [...lines, line$1(`not in --reject: ${unlisted.map((u) => u.value).join(", ")}`, "warn")]);
3210
+ if (r.plan.length === 0 && r.unmapped.length === 0) lines.push(line$1("nothing to normalise"));
3211
+ return ready(lines, { kind: "normalise" });
3212
+ }
3213
+ async function reindexPreview(op, admin, env) {
3214
+ const s = await admin.embedStats(sig(env));
3215
+ if (s.enabled !== true) return local("embedder_disabled", "the server has no embedder enabled");
3216
+ if (s.embeddedTypes === void 0) return local("no_embedded_types", "the server did not report its embedded types");
3217
+ const embedded = s.embeddedTypes;
3218
+ const name = (t) => `${t.namespace}/${t.type}`;
3219
+ const has = (t) => embedded.some((e) => e.namespace === t.namespace && e.type === t.type);
3220
+ const missing = op.targets.filter((t) => !has(t));
3221
+ if (missing.length > 0) return refused({
3222
+ code: "not_embedded",
3223
+ message: `not embedded: ${missing.map(name).join(", ")}`
3224
+ }, [line$1(`embedded types: ${embedded.map(name).join(", ") || "(none)"}`, "dim")]);
3225
+ const types = op.targets.length === 0 ? embedded : op.targets;
3226
+ return ready([
3227
+ line$1(`will re-embed ${types.length} type(s): ${types.map(name).join(", ")}`),
3228
+ line$1("row counts are not known until it runs — this re-embeds for real", "warn"),
3229
+ line$1("esc stops between pages", "warn")
3230
+ ], {
3231
+ kind: "reindex",
3232
+ types
3233
+ });
3234
+ }
3235
+
3236
+ //#endregion
3237
+ //#region src/surgery/reindex.ts
3238
+ const PAGE_LIMIT = 200;
3239
+ async function runReindex(types, page, hooks) {
3240
+ const progress = types.map((ref) => ({
3241
+ ref,
3242
+ scanned: 0,
3243
+ embedded: 0,
3244
+ unindexable: 0,
3245
+ failed: 0,
3246
+ pages: 0,
3247
+ done: false
3248
+ }));
3249
+ for (const p of progress) {
3250
+ let cursor;
3251
+ for (;;) {
3252
+ let r;
3253
+ try {
3254
+ r = await page({
3255
+ namespace: p.ref.namespace,
3256
+ type: p.ref.type,
3257
+ limit: PAGE_LIMIT,
3258
+ ...cursor !== void 0 ? { cursor } : {}
3259
+ });
3260
+ if (r.hasMore && typeof r.nextCursor !== "string") throw new Error("server reported hasMore with no nextCursor");
3261
+ } catch (error) {
3262
+ return {
3263
+ kind: "failed",
3264
+ progress,
3265
+ at: p.ref,
3266
+ error
3267
+ };
3268
+ }
3269
+ p.scanned += r.scanned;
3270
+ p.embedded += r.embedded;
3271
+ p.unindexable += r.unindexable;
3272
+ p.failed += r.failed;
3273
+ p.pages += 1;
3274
+ if (!r.hasMore) {
3275
+ p.done = true;
3276
+ hooks.onProgress(progress);
3277
+ const next = progress[progress.indexOf(p) + 1];
3278
+ if (next !== void 0 && hooks.stopRequested()) return {
3279
+ kind: "stopped",
3280
+ progress,
3281
+ at: next.ref,
3282
+ cursor: void 0
3283
+ };
3284
+ break;
3285
+ }
3286
+ cursor = r.nextCursor;
3287
+ hooks.onProgress(progress);
3288
+ if (hooks.stopRequested()) return {
3289
+ kind: "stopped",
3290
+ progress,
3291
+ at: p.ref,
3292
+ cursor
3293
+ };
3294
+ }
3295
+ }
3296
+ return {
3297
+ kind: "done",
3298
+ progress
3299
+ };
3300
+ }
3301
+
3302
+ //#endregion
3303
+ //#region src/surgery/module.tsx
3304
+ /**
3305
+ * The surgery commands: `/drop-namespace`, `/drop-type`, `/backfill`, `/normalise` and
3306
+ * `/reindex` each open the one `surgery` panel, which previews, then applies only once the exact
3307
+ * target is typed (tier 2). Stages: previewing → preview → applying → done | refused | stopped.
3308
+ * Every update after an await goes through `ours`, which writes only into the panel this op opened:
3309
+ * a closed panel, or a later command's, is never written by an earlier request.
3310
+ *
3311
+ * One surgery write at a time: while an apply is in flight every surgery command is unavailable,
3312
+ * until that apply settles, even if a lock closed its panel. Opening any other panel stops a
3313
+ * running reindex between pages (its loop reads "is my panel still open?" as its stop check), and
3314
+ * the transcript says where: `■ reindex stopped at <ns>/<Type> (cursor …)`.
3315
+ * An apply request that never returns keeps that flag set: a lock does not cancel a request
3316
+ * already in flight (the admin session's contract), so only restarting the TUI clears it.
3317
+ */
3318
+ const line = (text, tone) => ({
3319
+ text: sanitise(text),
3320
+ ...tone !== void 0 ? { tone } : {}
3321
+ });
3322
+ const SUMMARY = {
3323
+ "drop-namespace": "delete every row of a namespace (admin; previews first, then type the target)",
3324
+ "drop-type": "delete every row of a type (admin; previews first, then type the target)",
3325
+ backfill: "write a field from a JSON file of row id → value (admin)",
3326
+ normalise: "migrate a field's aliases to their canonical values (admin)",
3327
+ reindex: "re-embed every row of the embedded types, page by page (admin)"
3328
+ };
3329
+ const name = (t) => `${t.namespace}/${t.type}`;
3330
+ function progressLines(progress) {
3331
+ const w = Math.max(...progress.map((p) => name(p.ref).length));
3332
+ return progress.map((p) => line(` ${name(p.ref).padEnd(w)} scanned ${p.scanned} · embedded ${p.embedded} · unindexable ${p.unindexable} · failed ${p.failed}${p.done ? " ✓" : ""}`, p.failed > 0 ? "warn" : void 0));
3333
+ }
3334
+ const zero = (ref) => ({
3335
+ ref,
3336
+ scanned: 0,
3337
+ embedded: 0,
3338
+ unindexable: 0,
3339
+ failed: 0,
3340
+ pages: 0,
3341
+ done: false
3342
+ });
3343
+ /** A 4xx guarantees the failing backfill chunk wrote nothing (substrate-client AdminBackfillError). */
3344
+ function is4xx(err) {
3345
+ const s = err instanceof NetworkError || err instanceof AuthError ? err.status : void 0;
3346
+ return s !== void 0 && s >= 400 && s < 500;
3347
+ }
3348
+ function surgeryView(s, theme, size) {
3349
+ const w = Math.max(1, size.columns - 2);
3350
+ const fit = (l) => ({
3351
+ ...l,
3352
+ text: truncateCells(l.text, w)
3353
+ });
3354
+ const target = sanitise(targetOf(s.op));
3355
+ const head = line(`${theme.glyph.title} surgery › ${target} · ${s.stage}`);
3356
+ const body = [...s.preview?.lines ?? []];
3357
+ const foot = [];
3358
+ switch (s.stage) {
3359
+ case "previewing":
3360
+ body.push(line("previewing…", "dim"));
3361
+ foot.push(line("esc closes", "dim"));
3362
+ break;
3363
+ case "preview":
3364
+ if (s.preview?.kind === "ready") {
3365
+ foot.push(line(`type ${target} then enter to apply · esc closes`));
3366
+ if (s.mismatch) foot.push(line("doesn't match", "error"));
3367
+ } else foot.push(line("apply is unavailable — esc closes"));
3368
+ break;
3369
+ case "applying":
3370
+ if (s.op.kind === "reindex") {
3371
+ foot.push(...s.result);
3372
+ foot.push(line(s.stopping ? " stopping after this page…" : " esc stops after this page", "dim"));
3373
+ } else foot.push(...s.result, line("applying…", "dim"));
3374
+ break;
3375
+ case "done":
3376
+ case "refused":
3377
+ case "stopped": foot.push(...s.result, line("esc closes", "dim"));
3378
+ }
3379
+ const room = Math.max(0, size.rows - 1 - foot.length);
3380
+ const shown = body.length <= room ? body : [...body.slice(0, Math.max(0, room - 1)), line(`… ${body.length - room + 1} more`, "dim")];
3381
+ return linesView([
3382
+ head,
3383
+ ...shown,
3384
+ ...foot
3385
+ ].map(fit), theme);
3386
+ }
3387
+ function surgeryModule(deps, io = {}) {
3388
+ const readFile = io.readFile ?? ((p) => readFileSync(p, "utf8"));
3389
+ let generation = 0;
3390
+ let writing = false;
3391
+ const settled = () => {
3392
+ writing = false;
3393
+ };
3394
+ /** Runs `fn` on the panel only if it is still the one `op` opened; says whether it was. */
3395
+ const ours = (ctx, op, fn) => {
3396
+ let open = false;
3397
+ ctx.update("surgery", (s) => {
3398
+ if (s.op !== op) return s;
3399
+ open = true;
3400
+ return fn(s);
3401
+ });
3402
+ return open;
3403
+ };
3404
+ async function open(command, args, ctx) {
3405
+ const op = parseOp(command, args);
3406
+ if ("error" in op) {
3407
+ ctx.print(op.error, "error");
3408
+ return;
3409
+ }
3410
+ ctx.open("surgery", {
3411
+ op,
3412
+ stage: "previewing",
3413
+ preview: null,
3414
+ result: [],
3415
+ mismatch: false,
3416
+ stopping: false
3417
+ });
3418
+ const env = {
3419
+ readFile,
3420
+ ...io.home !== void 0 ? { home: io.home } : {},
3421
+ signal: ctx.signal
3422
+ };
3423
+ try {
3424
+ const preview = await deps.admin.run(({ client }) => previewOf(op, client.admin, env));
3425
+ ours(ctx, op, (s) => s.stage === "previewing" ? {
3426
+ ...s,
3427
+ stage: "preview",
3428
+ preview
3429
+ } : s);
3430
+ } catch (err) {
3431
+ const r = refusalOf(err);
3432
+ ours(ctx, op, (s) => s.stage === "previewing" ? {
3433
+ ...s,
3434
+ stage: "refused",
3435
+ result: [line(`${r.code}: ${r.message}`, "error")]
3436
+ } : s);
3437
+ }
3438
+ }
3439
+ /** Starts the apply the preview planned; the panel moves through `ours`. */
3440
+ function apply(s, typed, ctx) {
3441
+ const preview = s.preview;
3442
+ if (preview?.kind !== "ready") return s;
3443
+ const op = s.op;
3444
+ const plan = preview.apply;
3445
+ const st = deps.admin.state();
3446
+ const by = `(admin: ${st.kind === "unlocked" ? st.kid : "unknown"})`;
3447
+ /** A refusal: into the panel, or to the transcript when the panel has gone. */
3448
+ const refuse = (lines) => {
3449
+ if (!ours(ctx, op, (x) => ({
3450
+ ...x,
3451
+ stage: "refused",
3452
+ result: [...x.result, ...lines]
3453
+ }))) for (const l of lines) ctx.print(l.text, "error");
3454
+ };
3455
+ /** A write that landed: always to the transcript, and into the panel while it is open. */
3456
+ const done = (text) => {
3457
+ const l = line(text, "ok");
3458
+ ctx.print(l.text, "ok");
3459
+ ours(ctx, op, (x) => ({
3460
+ ...x,
3461
+ stage: "done",
3462
+ result: [...x.result, l]
3463
+ }));
3464
+ };
3465
+ const refusalLines = (err) => {
3466
+ const r = refusalOf(err);
3467
+ return [line(`${r.code}: ${r.message}`, "error"), ...refusalDetail(r, op)];
3468
+ };
3469
+ /** The planner and the op disagree: unreachable, so it throws to surface-core's guard rather than hide. */
3470
+ const mismatched = () => {
3471
+ throw new Error(`surgery: a ${plan.kind} plan for a ${op.kind} apply`);
3472
+ };
3473
+ switch (op.kind) {
3474
+ case "drop-namespace":
3475
+ case "drop-type": {
3476
+ const cascade = op.cascade ? "delete" : "refuse";
3477
+ writing = true;
3478
+ deps.admin.run(({ client }) => op.kind === "drop-namespace" ? client.admin.dropNamespace({
3479
+ namespace: op.namespace,
3480
+ confirm: typed,
3481
+ cascade,
3482
+ dryRun: false
3483
+ }) : client.admin.dropType({
3484
+ namespace: op.namespace,
3485
+ type: op.type,
3486
+ confirm: typed,
3487
+ cascade,
3488
+ dryRun: false
3489
+ })).then((r) => done(`✓ dropped ${op.kind === "drop-namespace" ? "namespace" : "type"} ${targetOf(op)}: ${r.rowsDeleted} rows, ${r.edgesDeleted} edges ${by}`), (err) => refuse(refusalLines(err))).finally(settled);
3490
+ break;
3491
+ }
3492
+ case "backfill":
3493
+ if (plan.kind !== "backfill") return mismatched();
3494
+ writing = true;
3495
+ deps.admin.run(({ client }) => client.admin.backfill({
3496
+ namespace: op.namespace,
3497
+ type: op.type,
3498
+ field: op.field,
3499
+ values: plan.values,
3500
+ overwrite: op.overwrite,
3501
+ apply: true
3502
+ }, { onChunk: (c) => {
3503
+ ours(ctx, op, (x) => ({
3504
+ ...x,
3505
+ result: [line(`chunk ${c.index + 1}/${c.chunks} written`, "dim")]
3506
+ }));
3507
+ } })).then((r) => done(`✓ backfilled ${targetOf(op)}: wrote ${r.write} of ${r.requested} ${by}`), (err) => {
3508
+ const lines = refusalLines(err);
3509
+ if (err instanceof AdminBackfillError) {
3510
+ if (err.chunksDone > 0) lines.push(line(` ${err.chunksDone} of ${err.chunks} chunk(s) were written before the refusal ${by}`, "warn"));
3511
+ if (!is4xx(err.cause)) lines.push(line("the failing chunk's own state is unknown — re-running is safe", "warn"));
3512
+ }
3513
+ for (const l of lines) ctx.print(l.text, "error");
3514
+ ours(ctx, op, (x) => ({
3515
+ ...x,
3516
+ stage: "refused",
3517
+ result: [...x.result, ...lines]
3518
+ }));
3519
+ }).finally(settled);
3520
+ break;
3521
+ case "normalise":
3522
+ writing = true;
3523
+ deps.admin.run(({ client }) => client.admin.normalise({
3524
+ namespace: op.namespace,
3525
+ type: op.type,
3526
+ field: op.field,
3527
+ reject: op.reject,
3528
+ apply: true
3529
+ })).then((r) => {
3530
+ const sum = (xs) => (xs ?? []).reduce((a, x) => a + x.count, 0);
3531
+ done(`✓ normalised ${targetOf(op)}: migrated ${sum(r.migrated)}, stranded ${sum(r.stranded)} ${by}`);
3532
+ }, (err) => {
3533
+ const lines = refusalLines(err);
3534
+ if (is4xx(err)) return refuse(lines);
3535
+ lines.push(line("the server-side state is unknown — re-run the preview to see what changed", "warn"));
3536
+ for (const l of lines) ctx.print(l.text, "error");
3537
+ ours(ctx, op, (x) => ({
3538
+ ...x,
3539
+ stage: "refused",
3540
+ result: [...x.result, ...lines]
3541
+ }));
3542
+ }).finally(settled);
3543
+ break;
3544
+ case "reindex": {
3545
+ if (plan.kind !== "reindex") return mismatched();
3546
+ writing = true;
3547
+ const gen = generation;
3548
+ runReindex(plan.types, (input) => deps.admin.run(({ client }) => client.admin.reindexPage(input)), {
3549
+ stopRequested: () => {
3550
+ if (gen !== generation) return true;
3551
+ let stop = true;
3552
+ ours(ctx, op, (x) => {
3553
+ stop = x.stopping;
3554
+ return x;
3555
+ });
3556
+ return stop;
3557
+ },
3558
+ onProgress: (p) => {
3559
+ ours(ctx, op, (x) => ({
3560
+ ...x,
3561
+ result: progressLines(p)
3562
+ }));
3563
+ }
3564
+ }).then((o) => {
3565
+ const embedded = o.progress.reduce((a, p) => a + p.embedded, 0);
3566
+ const failed = o.progress.reduce((a, p) => a + p.failed, 0);
3567
+ const settle = (stage, l) => {
3568
+ ctx.print(l.text, l.tone);
3569
+ ours(ctx, op, (x) => ({
3570
+ ...x,
3571
+ stage,
3572
+ result: [...progressLines(o.progress), l]
3573
+ }));
3574
+ };
3575
+ switch (o.kind) {
3576
+ case "done":
3577
+ settle("done", line(`✓ reindexed ${o.progress.length} type(s): embedded ${embedded}, failed ${failed} ${by}`, "ok"));
3578
+ break;
3579
+ case "stopped":
3580
+ settle("stopped", line(o.cursor !== void 0 ? `■ reindex stopped at ${name(o.at)} (cursor ${o.cursor}) — embedded ${embedded} so far ${by}` : `■ reindex stopped before ${name(o.at)} — embedded ${embedded} so far ${by}`, "warn"));
3581
+ break;
3582
+ case "failed": {
3583
+ const r = refusalOf(o.error);
3584
+ settle("refused", line(`reindex failed at ${name(o.at)}: ${r.code}: ${r.message} — embedded ${embedded} so far ${by}`, "error"));
3585
+ break;
3586
+ }
3587
+ }
3588
+ }).finally(settled);
3589
+ return {
3590
+ ...s,
3591
+ stage: "applying",
3592
+ mismatch: false,
3593
+ result: progressLines(plan.types.map(zero))
3594
+ };
3595
+ }
3596
+ }
3597
+ return {
3598
+ ...s,
3599
+ stage: "applying",
3600
+ mismatch: false
3601
+ };
3602
+ }
3603
+ const command = (c) => ({
3604
+ name: c,
3605
+ summary: SUMMARY[c],
3606
+ args: [{
3607
+ name: "target",
3608
+ optional: c === "reindex"
3609
+ }],
3610
+ unavailable: () => !deps.admin.unlocked() ? "admin is locked — /admin unlock" : writing ? "a surgery write is in progress — wait for it to report" : null,
3611
+ run: (args, ctx) => open(c, args, ctx)
3612
+ });
3613
+ return defineModule("substrate.surgery", {
3614
+ commands: [
3615
+ "drop-namespace",
3616
+ "drop-type",
3617
+ "backfill",
3618
+ "normalise",
3619
+ "reindex"
3620
+ ].map(command),
3621
+ panels: { surgery: {
3622
+ view: ({ state, theme, size }) => surgeryView(state, theme, size),
3623
+ submit: (s, typed, ctx) => {
3624
+ if (!(s.stage === "preview" && s.preview?.kind === "ready")) return void 0;
3625
+ return typed === targetOf(s.op) ? apply(s, typed, ctx) : {
3626
+ ...s,
3627
+ mismatch: true
3628
+ };
3629
+ },
3630
+ escape: (s) => {
3631
+ if (s.stage !== "applying") return void 0;
3632
+ return s.op.kind === "reindex" ? {
3633
+ ...s,
3634
+ stopping: true
3635
+ } : s;
3636
+ }
3637
+ } },
3638
+ init(ctx) {
3639
+ const off = deps.admin.onChange((st) => {
3640
+ if (st.kind !== "locked") return;
3641
+ generation++;
3642
+ let open = false;
3643
+ let inFlight = null;
3644
+ ctx.update("surgery", (s) => {
3645
+ open = true;
3646
+ inFlight = s.stage === "applying" ? s.op : null;
3647
+ return s;
3648
+ });
3649
+ if (!open) return;
3650
+ ctx.close();
3651
+ const op = inFlight;
3652
+ ctx.print(op === null ? "surgery closed — admin locked" : op.kind === "reindex" ? "surgery closed — admin locked; the reindex requests no further page" : `surgery closed — admin locked; the ${sanitise(targetOf(op))} ${op.kind} in flight reports its outcome below`, "warn");
3653
+ });
3654
+ return () => {
3655
+ off();
3656
+ };
3657
+ }
3658
+ });
3659
+ }
3660
+
3661
+ //#endregion
3662
+ //#region src/app.ts
3663
+ function buildModules(deps, opts = {}) {
3664
+ return [
3665
+ statusModule(deps, opts),
3666
+ adminModule(deps),
3667
+ doctorModule(deps, opts.doctorIntervalMs !== void 0 ? { intervalMs: opts.doctorIntervalMs } : {}),
3668
+ keysModule(deps),
3669
+ surgeryModule(deps),
3670
+ askModule(deps),
3671
+ schemaModule(deps),
3672
+ dashModule(deps, opts.dashIntervalMs !== void 0 ? { intervalMs: opts.dashIntervalMs } : {})
3673
+ ];
3674
+ }
3675
+ function substrateAppOptions(deps, opts = {}) {
3676
+ return {
3677
+ theme: marathon,
3678
+ modules: buildModules(deps, opts),
3679
+ defaultTurn: "substrate.ask:ask",
3680
+ band: true,
3681
+ gutter: 2,
3682
+ terminal: false
3683
+ };
3684
+ }
3685
+ function createTui(deps, opts = {}) {
3686
+ return createApp(substrateAppOptions(deps, opts));
3687
+ }
3688
+
3689
+ //#endregion
3690
+ export { buildModules, createTui, substrateAppOptions };