@mnmal-ai/substrate-tui 0.0.0 → 1.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +40 -1
- package/dist/app-BLOyJcBh.js +3690 -0
- package/dist/bin/substrate-tui.js +73 -0
- package/dist/deps-WzqsHHiC.js +71 -0
- package/dist/session-C1D5Ermr.js +123 -0
- package/dist/terminal-CVX2CKWu.js +4 -0
- package/dist/terminal-XfKFiD7r.js +28 -0
- package/package.json +49 -4
|
@@ -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 };
|