@mnmal-ai/substrate-tui 0.0.0-stage → 1.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +41 -2
- 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 -3
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { t as colorDepthEnv } from "../terminal-XfKFiD7r.js";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
import { DEFAULT_CAPABILITIES, makeTokenProvider } from "@mnmal-ai/substrate/client";
|
|
5
|
+
|
|
6
|
+
//#region src/config.ts
|
|
7
|
+
const message = (err) => err instanceof Error ? err.message : String(err);
|
|
8
|
+
function readConfig(env, cwd) {
|
|
9
|
+
const endpoint = (env.SUBSTRATE_URL ?? "").replace(/\/+$/, "");
|
|
10
|
+
if (endpoint === "") return { error: "SUBSTRATE_URL is required (e.g. https://substrate.example.com:3100)" };
|
|
11
|
+
const namespace = env.SUBSTRATE_NAMESPACE ?? "";
|
|
12
|
+
if (namespace === "") return { error: "SUBSTRATE_NAMESPACE is required (the namespace to watch)" };
|
|
13
|
+
const keyPath = env.SUBSTRATE_AGENT_KEY_PATH;
|
|
14
|
+
const kid = env.SUBSTRATE_AGENT_KEY_ID;
|
|
15
|
+
let token = null;
|
|
16
|
+
if (keyPath !== void 0 && keyPath !== "" && kid !== void 0 && kid !== "") {
|
|
17
|
+
const raw = env.SUBSTRATE_AGENT_CAPABILITIES?.split(",").map((c) => c.trim()).filter((c) => c !== "");
|
|
18
|
+
try {
|
|
19
|
+
token = makeTokenProvider({
|
|
20
|
+
keyPath,
|
|
21
|
+
kid
|
|
22
|
+
}, { capabilities: raw !== void 0 && raw.length > 0 ? raw : DEFAULT_CAPABILITIES });
|
|
23
|
+
} catch (err) {
|
|
24
|
+
return { error: `agent identity: ${message(err)}` };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
endpoint,
|
|
29
|
+
namespace,
|
|
30
|
+
project: basename(cwd),
|
|
31
|
+
token
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
//#endregion
|
|
36
|
+
//#region src/bin/tui.tsx
|
|
37
|
+
const USAGE = "usage: substrate-tui (env: SUBSTRATE_URL, optional SUBSTRATE_NAMESPACE, SUBSTRATE_AGENT_KEY_ID + SUBSTRATE_AGENT_KEY_PATH;\n SUBSTRATE_TUI_BACKGROUND=#rrggbb|none, SUBSTRATE_TUI_CLEAR=0 to keep the screen,\n SUBSTRATE_TUI_COLOR=256 for terminals without truecolor;\n admin: SUBSTRATE_ADMIN_TOKEN, or SUBSTRATE_ADMIN_KEY_PATH [+ SUBSTRATE_ADMIN_KEY_ID], SUBSTRATE_TUI_ADMIN_IDLE_MINUTES)\n";
|
|
38
|
+
async function main() {
|
|
39
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
40
|
+
process.stderr.write(`substrate-tui needs an interactive terminal.\n${USAGE}`);
|
|
41
|
+
return 2;
|
|
42
|
+
}
|
|
43
|
+
const config = readConfig(process.env, process.cwd());
|
|
44
|
+
if ("error" in config) {
|
|
45
|
+
process.stderr.write(`substrate-tui: ${config.error}\n`);
|
|
46
|
+
return 2;
|
|
47
|
+
}
|
|
48
|
+
Object.assign(process.env, colorDepthEnv(process.env));
|
|
49
|
+
const [{ createTui }, { makeDeps }, { prepareTerminal }, { terminalOptions }] = await Promise.all([
|
|
50
|
+
import("../app-BLOyJcBh.js"),
|
|
51
|
+
import("../deps-WzqsHHiC.js"),
|
|
52
|
+
import("@mnmal-ai/surface-core/pure"),
|
|
53
|
+
import("../terminal-CVX2CKWu.js")
|
|
54
|
+
]);
|
|
55
|
+
let deps;
|
|
56
|
+
try {
|
|
57
|
+
deps = makeDeps(config);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
process.stderr.write(`substrate-tui: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
60
|
+
return 2;
|
|
61
|
+
}
|
|
62
|
+
const restore = prepareTerminal(process.stdout, terminalOptions(process.env));
|
|
63
|
+
try {
|
|
64
|
+
await createTui(deps).run();
|
|
65
|
+
} finally {
|
|
66
|
+
restore();
|
|
67
|
+
}
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
process.exitCode = await main();
|
|
71
|
+
|
|
72
|
+
//#endregion
|
|
73
|
+
export { };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { n as adminIdleMs, r as createAdminSession } from "./session-C1D5Ermr.js";
|
|
3
|
+
import { SubstrateClient, noRedirectFetch, tokenAudience } from "@mnmal-ai/substrate/client";
|
|
4
|
+
|
|
5
|
+
//#region src/deps.ts
|
|
6
|
+
/** An HTTP failure from `getJson`: `status` is what `NetworkError` and `AuthError` also carry. */
|
|
7
|
+
var HttpStatusError = class extends Error {
|
|
8
|
+
status;
|
|
9
|
+
constructor(status, path) {
|
|
10
|
+
super(`${status} from ${path}`);
|
|
11
|
+
this.name = "HttpStatusError";
|
|
12
|
+
this.status = status;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
const sig = (signal) => signal !== void 0 ? { signal } : {};
|
|
16
|
+
function makeDeps(config, fetchImpl = fetch, env = process.env) {
|
|
17
|
+
const token = config.token;
|
|
18
|
+
if (token !== null || [
|
|
19
|
+
"SUBSTRATE_ADMIN_TOKEN",
|
|
20
|
+
"SUBSTRATE_ADMIN_KEY_PATH",
|
|
21
|
+
"SUBSTRATE_ADMIN_KEY_ID"
|
|
22
|
+
].some((k) => (env[k] ?? "") !== "")) tokenAudience(config.endpoint);
|
|
23
|
+
fetchImpl = noRedirectFetch(fetchImpl);
|
|
24
|
+
const adminFetch = (input, init) => {
|
|
25
|
+
tokenAudience(config.endpoint);
|
|
26
|
+
return fetchImpl(input, init);
|
|
27
|
+
};
|
|
28
|
+
const client = new SubstrateClient({
|
|
29
|
+
endpoint: config.endpoint,
|
|
30
|
+
...config.token !== null ? { getToken: config.token } : {},
|
|
31
|
+
fetch: fetchImpl,
|
|
32
|
+
onDiagnostics: () => {}
|
|
33
|
+
});
|
|
34
|
+
const admin = createAdminSession({
|
|
35
|
+
endpoint: config.endpoint,
|
|
36
|
+
idleMs: adminIdleMs(env),
|
|
37
|
+
fetch: adminFetch
|
|
38
|
+
});
|
|
39
|
+
return {
|
|
40
|
+
endpoint: config.endpoint,
|
|
41
|
+
project: config.project,
|
|
42
|
+
namespace: config.namespace,
|
|
43
|
+
nl: client.nl,
|
|
44
|
+
federatedSchema: () => client.federatedSchema(),
|
|
45
|
+
metrics: (signal) => client.metrics(sig(signal)),
|
|
46
|
+
errors: (signal) => admin.unlocked() ? admin.run(({ client: c }) => c.errors(sig(signal)), { touch: false }) : client.errors(sig(signal)),
|
|
47
|
+
admin,
|
|
48
|
+
adminEnv: {
|
|
49
|
+
...env.SUBSTRATE_ADMIN_TOKEN !== void 0 ? { SUBSTRATE_ADMIN_TOKEN: env.SUBSTRATE_ADMIN_TOKEN } : {},
|
|
50
|
+
...env.SUBSTRATE_ADMIN_KEY_PATH !== void 0 ? { SUBSTRATE_ADMIN_KEY_PATH: env.SUBSTRATE_ADMIN_KEY_PATH } : {},
|
|
51
|
+
...env.SUBSTRATE_ADMIN_KEY_ID !== void 0 ? { SUBSTRATE_ADMIN_KEY_ID: env.SUBSTRATE_ADMIN_KEY_ID } : {},
|
|
52
|
+
...env.SUBSTRATE_ADMIN_CAPABILITY !== void 0 ? { SUBSTRATE_ADMIN_CAPABILITY: env.SUBSTRATE_ADMIN_CAPABILITY } : {},
|
|
53
|
+
...env.HOME !== void 0 ? { HOME: env.HOME } : {}
|
|
54
|
+
},
|
|
55
|
+
agentAuthorization: token !== null ? () => `Bearer ${token()}` : null,
|
|
56
|
+
keys: (signal) => client.admin.keys(sig(signal)),
|
|
57
|
+
async getJson(path, signal) {
|
|
58
|
+
const headers = { accept: "application/json" };
|
|
59
|
+
if (config.token !== null) headers.authorization = `Bearer ${config.token()}`;
|
|
60
|
+
const res = await fetchImpl(`${config.endpoint}${path}`, {
|
|
61
|
+
headers,
|
|
62
|
+
...sig(signal)
|
|
63
|
+
});
|
|
64
|
+
if (!res.ok) throw new HttpStatusError(res.status, path);
|
|
65
|
+
return res.json();
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
//#endregion
|
|
71
|
+
export { HttpStatusError, makeDeps };
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { AdminBackfillError, AuthError, SubstrateClient } from "@mnmal-ai/substrate/client";
|
|
3
|
+
|
|
4
|
+
//#region src/admin/session.ts
|
|
5
|
+
/**
|
|
6
|
+
* The admin identity while unlocked. Holds ONLY the token provider and the kid; every
|
|
7
|
+
* admin operation builds its own SubstrateClient and closes it after, so no admin connection, cache
|
|
8
|
+
* or socket outlives one. Lock, idle expiry and a rejected key all drop the provider; an
|
|
9
|
+
* operation already running finishes on the client it holds.
|
|
10
|
+
*/
|
|
11
|
+
var AdminLockedError = class extends Error {
|
|
12
|
+
name = "AdminLockedError";
|
|
13
|
+
constructor() {
|
|
14
|
+
super("admin is locked — /admin unlock");
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const ADMIN_IDLE_DEFAULT_MIN = 15;
|
|
18
|
+
/** setTimeout's ceiling: a longer delay overflows and fires at once, which would lock on unlock. */
|
|
19
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
20
|
+
function adminIdleMs(env) {
|
|
21
|
+
const n = Number(env.SUBSTRATE_TUI_ADMIN_IDLE_MINUTES);
|
|
22
|
+
return Math.min(MAX_TIMER_MS, (Number.isInteger(n) && n > 0 ? n : 15) * 6e4);
|
|
23
|
+
}
|
|
24
|
+
/** `SUBSTRATE_ADMIN_CAPABILITY` as the CLI reads it: unset or empty means diagnose's default. */
|
|
25
|
+
function adminCapabilityOf(env) {
|
|
26
|
+
const c = env.SUBSTRATE_ADMIN_CAPABILITY;
|
|
27
|
+
return c !== void 0 && c.length > 0 ? { adminCapability: c } : {};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The admin key itself was refused: a 401, or the auth gate's 403 (no code, or `forbidden`). A
|
|
31
|
+
* route's own coded 403 (`directory_denied`, `destructive_ops_disabled`) refuses the request, not
|
|
32
|
+
* the key. A chunked backfill wraps every failure, so the cause is what is judged.
|
|
33
|
+
*/
|
|
34
|
+
function isKeyRejection(err) {
|
|
35
|
+
const e = err instanceof AdminBackfillError ? err.cause : err;
|
|
36
|
+
return e instanceof AuthError && (e.status === 401 || e.code === void 0 || e.code === "forbidden");
|
|
37
|
+
}
|
|
38
|
+
function createAdminSession(opts) {
|
|
39
|
+
const now = opts.now ?? Date.now;
|
|
40
|
+
const make = opts.makeClient ?? ((getToken) => new SubstrateClient({
|
|
41
|
+
endpoint: opts.endpoint,
|
|
42
|
+
getToken,
|
|
43
|
+
...opts.fetch !== void 0 ? { fetch: opts.fetch } : {},
|
|
44
|
+
onDiagnostics: () => {}
|
|
45
|
+
}));
|
|
46
|
+
let held = null;
|
|
47
|
+
let state = {
|
|
48
|
+
kind: "locked",
|
|
49
|
+
reason: null
|
|
50
|
+
};
|
|
51
|
+
let timer;
|
|
52
|
+
let disposed = false;
|
|
53
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
54
|
+
const set = (s) => {
|
|
55
|
+
state = s;
|
|
56
|
+
for (const fn of listeners) fn(s);
|
|
57
|
+
};
|
|
58
|
+
const lock = (reason) => {
|
|
59
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
60
|
+
timer = void 0;
|
|
61
|
+
held = null;
|
|
62
|
+
set({
|
|
63
|
+
kind: "locked",
|
|
64
|
+
reason
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
const touch = () => {
|
|
68
|
+
if (held === null) return;
|
|
69
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
70
|
+
timer = setTimeout(() => lock(`idle — no admin action for ${Math.round(opts.idleMs / 6e4)}m`), opts.idleMs);
|
|
71
|
+
timer.unref?.();
|
|
72
|
+
set({
|
|
73
|
+
kind: "unlocked",
|
|
74
|
+
kid: held.kid,
|
|
75
|
+
idleUntil: now() + opts.idleMs
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
return {
|
|
79
|
+
state: () => state,
|
|
80
|
+
unlocked: () => held !== null,
|
|
81
|
+
unlock(kid, getToken) {
|
|
82
|
+
if (disposed) return;
|
|
83
|
+
held = {
|
|
84
|
+
kid,
|
|
85
|
+
getToken
|
|
86
|
+
};
|
|
87
|
+
touch();
|
|
88
|
+
},
|
|
89
|
+
lock,
|
|
90
|
+
async run(op, runOpts) {
|
|
91
|
+
if (held === null) throw new AdminLockedError();
|
|
92
|
+
const { getToken } = held;
|
|
93
|
+
if (runOpts?.touch !== false) touch();
|
|
94
|
+
const client = make(getToken);
|
|
95
|
+
try {
|
|
96
|
+
return await op({
|
|
97
|
+
client,
|
|
98
|
+
authorization: () => `Bearer ${getToken()}`
|
|
99
|
+
});
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (isKeyRejection(err) && held !== null) {
|
|
102
|
+
const e = err instanceof AdminBackfillError ? err.cause : err;
|
|
103
|
+
lock(`admin locked: ${e.status} — the admin key was rejected`);
|
|
104
|
+
}
|
|
105
|
+
throw err;
|
|
106
|
+
} finally {
|
|
107
|
+
await client.close();
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
onChange(fn) {
|
|
111
|
+
listeners.add(fn);
|
|
112
|
+
return () => listeners.delete(fn);
|
|
113
|
+
},
|
|
114
|
+
dispose() {
|
|
115
|
+
disposed = true;
|
|
116
|
+
lock("session ended");
|
|
117
|
+
listeners.clear();
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
//#endregion
|
|
123
|
+
export { isKeyRejection as i, adminIdleMs as n, createAdminSession as r, adminCapabilityOf as t };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//#region src/terminal.ts
|
|
3
|
+
/**
|
|
4
|
+
* How substrate-tui prepares the terminal: a black ground and a fresh, bottom-anchored pane.
|
|
5
|
+
* `SUBSTRATE_TUI_BACKGROUND` takes `#rrggbb` or `none`; `SUBSTRATE_TUI_CLEAR=0` keeps what was on screen.
|
|
6
|
+
*/
|
|
7
|
+
function terminalOptions(env) {
|
|
8
|
+
return {
|
|
9
|
+
...env.SUBSTRATE_TUI_BACKGROUND !== void 0 ? { ground: env.SUBSTRATE_TUI_BACKGROUND } : {},
|
|
10
|
+
clear: (env.SUBSTRATE_TUI_CLEAR ?? "").trim() !== "0"
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The environment chalk must see for the theme's hex colours to reach the terminal as hex
|
|
15
|
+
*. chalk picks its depth once, at import, and reports 256 colours for any TERM
|
|
16
|
+
* ending `-256color`, rounding every hex to the nearest palette entry; COLORTERM=truecolor is the
|
|
17
|
+
* one check it makes first. So: truecolor by default, a COLORTERM the terminal already set is left
|
|
18
|
+
* alone, and `SUBSTRATE_TUI_COLOR=256` opts out for a terminal that cannot render 24-bit colour.
|
|
19
|
+
* Must be applied before Ink (and so chalk) is imported.
|
|
20
|
+
*/
|
|
21
|
+
function colorDepthEnv(env) {
|
|
22
|
+
if ((env.SUBSTRATE_TUI_COLOR ?? "").trim() === "256") return {};
|
|
23
|
+
if (env.COLORTERM !== void 0 && env.COLORTERM !== "") return {};
|
|
24
|
+
return { COLORTERM: "truecolor" };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
//#endregion
|
|
28
|
+
export { terminalOptions as n, colorDepthEnv as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mnmal-ai/substrate-tui",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "1.0.0-alpha.0",
|
|
4
|
+
"description": "Terminal UI for Substrate: ask in natural language, browse the schema, see the service status.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/mnmal-ai/substrate.git",
|
|
9
|
+
"directory": "apps/substrate-tui"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": {
|
|
13
|
+
"substrate-tui": "dist/bin/tui.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"registry": "https://registry.npmjs.org/",
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@mnmal-ai/substrate": "1.0.0-alpha.0",
|
|
24
|
+
"@mnmal-ai/substrate-nl": "1.0.0-alpha.0",
|
|
25
|
+
"@mnmal-ai/surface-core": "0.14.5",
|
|
26
|
+
"@mnmal-ai/surface-kit": "0.14.5",
|
|
27
|
+
"@mnmal-ai/surface-theme": "0.14.5",
|
|
28
|
+
"ink": "^7.1.1",
|
|
29
|
+
"react": "^19.3.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@biomejs/biome": "^2.5.15",
|
|
33
|
+
"@mnmal-ai/substrate-config": "1.0.0-alpha.0",
|
|
34
|
+
"@types/node": "^26.6.4",
|
|
35
|
+
"@types/react": "^19.2.0",
|
|
36
|
+
"ink-testing-library": "^4.0.0",
|
|
37
|
+
"tsdown": "^0.23.0",
|
|
38
|
+
"tsx": "^4.23.15",
|
|
39
|
+
"typescript": "^7.0.2",
|
|
40
|
+
"vitest": "^5.0.3"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=26.2.0"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsdown",
|
|
47
|
+
"dev": "tsx src/bin/tui.tsx",
|
|
48
|
+
"test": "vitest run",
|
|
49
|
+
"check-types": "tsc -p tsconfig.json --noEmit",
|
|
50
|
+
"lint": "biome check ."
|
|
51
|
+
}
|
|
6
52
|
}
|