@alilis/k-hat 0.1.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 +21 -0
- package/README.md +229 -0
- package/dist/admin.js +213 -0
- package/dist/cli.js +700 -0
- package/dist/config.js +84 -0
- package/dist/doctor.js +123 -0
- package/dist/dpapi.js +34 -0
- package/dist/key-protector.js +94 -0
- package/dist/logger.js +67 -0
- package/dist/portable-vault.js +146 -0
- package/dist/router.js +10 -0
- package/dist/selector.js +23 -0
- package/dist/server.js +228 -0
- package/dist/store.js +240 -0
- package/dist/supervisor.js +98 -0
- package/dist/tui-client.js +87 -0
- package/dist/tui-main.js +8 -0
- package/dist/tui-types.js +1 -0
- package/dist/tui.js +286 -0
- package/dist/types.js +1 -0
- package/dist/vault.js +96 -0
- package/dist/web-ui.js +323 -0
- package/package.json +52 -0
package/dist/tui.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { Box, Text, useApp, useInput } from 'ink';
|
|
4
|
+
const MAIN_DASHBOARD_INDEX = 0;
|
|
5
|
+
function n(value) { return new Intl.NumberFormat('en-US').format(value); }
|
|
6
|
+
function mask(value) { return value ? '*'.repeat(Math.min(value.length, 24)) : ''; }
|
|
7
|
+
function providerOf(status, id) { return status?.providers.find((item) => item.id === id); }
|
|
8
|
+
function Menu({ title, items, selected }) { return _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "cyan", children: title }), items.map((item, index) => _jsxs(Text, { color: index === selected ? 'yellow' : undefined, children: [index === selected ? '› ' : ' ', item] }, `${item}-${index}`))] }); }
|
|
9
|
+
function FormView({ form }) { return _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "yellow", children: [form.action === 'edit' ? 'Edit' : 'Add', " ", form.kind] }), form.fields.map((field, index) => _jsxs(Text, { color: index === form.cursor ? 'cyan' : undefined, children: [index === form.cursor ? '› ' : ' ', field, ": ", form.secret?.[index] ? mask(form.values[index]) : form.values[index]] }, field)), _jsx(Text, { dimColor: true, children: "Tab/\u2191\u2193 next field \u00B7 Enter submit \u00B7 Esc cancel" })] }); }
|
|
10
|
+
function Details({ log }) { return _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "cyan", children: "Log details" }), Object.entries(log).map(([key, value]) => _jsxs(Text, { children: [key, ": ", String(value)] }, key))] }); }
|
|
11
|
+
export function Tui({ client }) {
|
|
12
|
+
const { exit } = useApp();
|
|
13
|
+
const [view, setView] = useState({ kind: 'main' });
|
|
14
|
+
const [selected, setSelected] = useState(MAIN_DASHBOARD_INDEX);
|
|
15
|
+
const [snapshot, setSnapshot] = useState({ status: null, logs: [], error: null, refreshedAt: null });
|
|
16
|
+
const [loading, setLoading] = useState(false);
|
|
17
|
+
const loadingRef = useRef(false);
|
|
18
|
+
const [notice, setNotice] = useState(null);
|
|
19
|
+
const [confirm, setConfirm] = useState(null);
|
|
20
|
+
const [form, setForm] = useState(null);
|
|
21
|
+
const [filters, setFilters] = useState({ model: '', key: '', status: '' });
|
|
22
|
+
const refresh = useCallback(async () => {
|
|
23
|
+
if (loadingRef.current)
|
|
24
|
+
return;
|
|
25
|
+
loadingRef.current = true;
|
|
26
|
+
setLoading(true);
|
|
27
|
+
try {
|
|
28
|
+
const [status, logs] = await Promise.all([client.status(), client.logs(50, filters)]);
|
|
29
|
+
setSnapshot({ status, logs, error: null, refreshedAt: new Date() });
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
setSnapshot((current) => ({ ...current, error: error?.message ?? String(error) }));
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
loadingRef.current = false;
|
|
36
|
+
setLoading(false);
|
|
37
|
+
}
|
|
38
|
+
}, [client, filters]);
|
|
39
|
+
useEffect(() => { void refresh(); }, [refresh]);
|
|
40
|
+
useEffect(() => { const timer = setInterval(() => void refresh(), 5_000); return () => clearInterval(timer); }, [refresh]);
|
|
41
|
+
const providers = snapshot.status?.providers ?? [];
|
|
42
|
+
const routes = snapshot.status?.routes ?? [];
|
|
43
|
+
const currentProvider = view.kind === 'provider-menu' || view.kind === 'keys' || view.kind === 'key-menu' ? providerOf(snapshot.status, view.providerId) : undefined;
|
|
44
|
+
const currentKey = view.kind === 'key-menu' ? currentProvider?.keys.find((item) => item.id === view.keyId) : undefined;
|
|
45
|
+
const mutation = async (operation, message) => { setConfirm(null); setForm(null); setNotice('Working…'); try {
|
|
46
|
+
await operation();
|
|
47
|
+
setNotice(message);
|
|
48
|
+
await refresh();
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
setNotice(error?.message ?? String(error));
|
|
52
|
+
} };
|
|
53
|
+
const submit = async (current) => {
|
|
54
|
+
const values = current.values.map((value) => value.trim());
|
|
55
|
+
const required = current.kind === 'key' && current.action === 'edit' ? values.slice(0, 3) : values;
|
|
56
|
+
if (required.some((value) => !value)) {
|
|
57
|
+
setNotice('Required fields are missing.');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (current.kind === 'provider') {
|
|
61
|
+
if (!['openai', 'anthropic'].includes(values[2])) {
|
|
62
|
+
setNotice('Protocol must be openai or anthropic.');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const input = { name: values[1], protocol: values[2], baseUrl: values[3].replace(/\/+$/, '') };
|
|
66
|
+
await mutation(() => current.action === 'edit' && current.target?.providerId ? client.updateProvider(current.target.providerId, input) : client.createProvider({ id: values[0], ...input }), `${current.action === 'edit' ? 'Updated' : 'Added'} provider ${values[0]}`);
|
|
67
|
+
}
|
|
68
|
+
else if (current.kind === 'key') {
|
|
69
|
+
const weight = Number(values[2]);
|
|
70
|
+
if (!Number.isInteger(weight) || weight < 1) {
|
|
71
|
+
setNotice('Weight must be a positive integer.');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const providerId = current.target?.providerId;
|
|
75
|
+
const keyId = current.target?.keyId;
|
|
76
|
+
if (current.action === 'edit' && providerId && keyId)
|
|
77
|
+
await mutation(() => client.updateKey(providerId, keyId, { weight, ...(values[3] ? { value: values[3] } : {}) }), `Updated key ${values[1]}`);
|
|
78
|
+
else
|
|
79
|
+
await mutation(() => client.createKey(values[0], { id: values[1], value: values[3], weight }), `Added key ${values[0]}/${values[1]}`);
|
|
80
|
+
}
|
|
81
|
+
else if (current.kind === 'route')
|
|
82
|
+
await mutation(() => current.action === 'edit' && current.target?.model ? client.updateRoute(current.target.model, values[1]) : client.createRoute(values[0], values[1]), `${current.action === 'edit' ? 'Updated' : 'Added'} route ${values[0]}`);
|
|
83
|
+
else {
|
|
84
|
+
setFilters({ model: values[0], key: values[1], status: values[2] });
|
|
85
|
+
setForm(null);
|
|
86
|
+
setNotice('Log filters updated');
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const items = view.kind === 'main' ? ['Dashboard', 'Providers', 'Routes', 'Logs', 'Diagnostics', 'Quit']
|
|
90
|
+
: view.kind === 'providers' ? ['Add provider', ...providers.map((item) => `${item.id} (${item.protocol})`), 'Back']
|
|
91
|
+
: view.kind === 'provider-menu' ? ['View details', 'Keys', 'Edit provider', 'Delete provider', 'Back']
|
|
92
|
+
: view.kind === 'keys' ? ['Add key', ...(currentProvider?.keys ?? []).map((item) => `${item.id} (${item.status})`), 'Back']
|
|
93
|
+
: view.kind === 'key-menu' ? ['View details', 'Edit key', 'Enable key', 'Probe key', 'Delete key', 'Back']
|
|
94
|
+
: view.kind === 'routes' ? ['Add route', ...routes.map((item) => `${item.model} → ${item.provider}`), 'Back']
|
|
95
|
+
: view.kind === 'route-menu' ? ['Edit route', 'Delete route', 'Back']
|
|
96
|
+
: view.kind === 'logs' ? ['Browse recent logs', 'Set filters', 'Clear filters', 'Back']
|
|
97
|
+
: view.kind === 'diagnostics' ? ['Health', 'Unavailable keys', 'Rotate access token', 'Back'] : [];
|
|
98
|
+
useInput((input, key) => {
|
|
99
|
+
if (form) {
|
|
100
|
+
if (key.escape)
|
|
101
|
+
setForm(null);
|
|
102
|
+
else if (key.return)
|
|
103
|
+
void submit(form);
|
|
104
|
+
else if (key.tab || key.downArrow || key.rightArrow)
|
|
105
|
+
setForm((current) => current && ({ ...current, cursor: (current.cursor + 1) % current.fields.length }));
|
|
106
|
+
else if (key.upArrow || key.leftArrow)
|
|
107
|
+
setForm((current) => current && ({ ...current, cursor: (current.cursor + current.fields.length - 1) % current.fields.length }));
|
|
108
|
+
else if (key.backspace || key.delete)
|
|
109
|
+
setForm((current) => current && ({ ...current, values: current.values.map((value, index) => index === current.cursor ? value.slice(0, -1) : value) }));
|
|
110
|
+
else if (input && !key.ctrl && !key.meta)
|
|
111
|
+
setForm((current) => current && ({ ...current, values: current.values.map((value, index) => index === current.cursor ? value + input : value) }));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (confirm) {
|
|
115
|
+
if (input === 'y' || key.return)
|
|
116
|
+
void mutation(confirm.run, `${confirm.label} completed`);
|
|
117
|
+
else if (input === 'n' || key.escape)
|
|
118
|
+
setConfirm(null);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (input === 'q' || (key.ctrl && input === 'c'))
|
|
122
|
+
return exit();
|
|
123
|
+
if (key.escape) {
|
|
124
|
+
setView((current) => current.kind === 'main' ? current : { kind: 'main' });
|
|
125
|
+
setSelected(MAIN_DASHBOARD_INDEX);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (key.upArrow) {
|
|
129
|
+
setSelected((value) => Math.max(0, value - 1));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (key.downArrow) {
|
|
133
|
+
setSelected((value) => Math.min(Math.max(0, items.length - 1), value + 1));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (input === 'r') {
|
|
137
|
+
void refresh();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (input === 'd') {
|
|
141
|
+
setView({ kind: 'dashboard' });
|
|
142
|
+
setSelected(0);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (input === 'p') {
|
|
146
|
+
setView({ kind: 'providers' });
|
|
147
|
+
setSelected(0);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (input === 'g') {
|
|
151
|
+
setView({ kind: 'routes' });
|
|
152
|
+
setSelected(0);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (input === 'l') {
|
|
156
|
+
setView({ kind: 'logs' });
|
|
157
|
+
setSelected(0);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (input === 'i') {
|
|
161
|
+
setView({ kind: 'diagnostics' });
|
|
162
|
+
setSelected(0);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (input !== '\r' && input !== '\n' && key.return)
|
|
166
|
+
return;
|
|
167
|
+
if (!key.return)
|
|
168
|
+
return;
|
|
169
|
+
if (view.kind === 'main') {
|
|
170
|
+
const target = items[selected];
|
|
171
|
+
setView({ kind: target === 'Dashboard' ? 'dashboard' : target === 'Providers' ? 'providers' : target === 'Routes' ? 'routes' : target === 'Logs' ? 'logs' : target === 'Diagnostics' ? 'diagnostics' : 'main' });
|
|
172
|
+
setSelected(0);
|
|
173
|
+
if (target === 'Quit')
|
|
174
|
+
exit();
|
|
175
|
+
}
|
|
176
|
+
else if (view.kind === 'providers') {
|
|
177
|
+
if (selected === 0)
|
|
178
|
+
setForm({ kind: 'provider', action: 'create', fields: ['id', 'name', 'protocol', 'base URL'], values: ['', '', 'openai', ''], cursor: 0 });
|
|
179
|
+
else if (selected <= providers.length)
|
|
180
|
+
setView({ kind: 'provider-menu', providerId: providers[selected - 1].id });
|
|
181
|
+
else
|
|
182
|
+
setView({ kind: 'main' });
|
|
183
|
+
setSelected(0);
|
|
184
|
+
}
|
|
185
|
+
else if (view.kind === 'provider-menu' && currentProvider) {
|
|
186
|
+
if (selected === 0)
|
|
187
|
+
setNotice(`${currentProvider.id}: ${currentProvider.baseUrl}`);
|
|
188
|
+
else if (selected === 1)
|
|
189
|
+
setView({ kind: 'keys', providerId: currentProvider.id });
|
|
190
|
+
else if (selected === 2)
|
|
191
|
+
setForm({ kind: 'provider', action: 'edit', target: { providerId: currentProvider.id }, fields: ['id', 'name', 'protocol', 'base URL'], values: [currentProvider.id, currentProvider.name, currentProvider.protocol, currentProvider.baseUrl], cursor: 1 });
|
|
192
|
+
else if (selected === 3)
|
|
193
|
+
setConfirm({ label: `delete provider ${currentProvider.id} and its keys/routes`, run: () => client.deleteProvider(currentProvider.id) });
|
|
194
|
+
else
|
|
195
|
+
setView({ kind: 'providers' });
|
|
196
|
+
setSelected(0);
|
|
197
|
+
}
|
|
198
|
+
else if (view.kind === 'keys' && currentProvider) {
|
|
199
|
+
if (selected === 0)
|
|
200
|
+
setForm({ kind: 'key', action: 'create', fields: ['provider', 'id', 'weight', 'secret'], values: [currentProvider.id, '', '1', ''], cursor: 1, secret: [false, false, false, true] });
|
|
201
|
+
else if (selected <= currentProvider.keys.length)
|
|
202
|
+
setView({ kind: 'key-menu', providerId: currentProvider.id, keyId: currentProvider.keys[selected - 1].id });
|
|
203
|
+
else
|
|
204
|
+
setView({ kind: 'provider-menu', providerId: currentProvider.id });
|
|
205
|
+
setSelected(0);
|
|
206
|
+
}
|
|
207
|
+
else if (view.kind === 'key-menu' && currentProvider && currentKey) {
|
|
208
|
+
if (selected === 0)
|
|
209
|
+
setNotice(`${currentKey.id}: ${currentKey.secret ?? '(missing)'}`);
|
|
210
|
+
else if (selected === 1)
|
|
211
|
+
setForm({ kind: 'key', action: 'edit', target: { providerId: currentProvider.id, keyId: currentKey.id }, fields: ['provider', 'id', 'weight', 'new secret'], values: [currentProvider.id, currentKey.id, String(currentKey.weight), ''], cursor: 2, secret: [false, false, false, true] });
|
|
212
|
+
else if (selected === 2)
|
|
213
|
+
setConfirm(currentKey.status === 'unavailable' ? { label: `enable ${currentProvider.id}/${currentKey.id}`, run: () => client.enableKey(currentProvider.id, currentKey.id) } : { label: 'Key is already available', run: async () => undefined });
|
|
214
|
+
else if (selected === 3)
|
|
215
|
+
setConfirm({ label: `probe ${currentProvider.id}/${currentKey.id}`, run: async () => { const result = await client.probeKey(currentProvider.id, currentKey.id); if (!result.ok)
|
|
216
|
+
throw new Error(`probe returned HTTP ${result.status}`); } });
|
|
217
|
+
else if (selected === 4)
|
|
218
|
+
setConfirm({ label: `delete key ${currentProvider.id}/${currentKey.id}`, run: () => client.deleteKey(currentProvider.id, currentKey.id) });
|
|
219
|
+
else
|
|
220
|
+
setView({ kind: 'keys', providerId: currentProvider.id });
|
|
221
|
+
setSelected(0);
|
|
222
|
+
}
|
|
223
|
+
else if (view.kind === 'routes') {
|
|
224
|
+
if (selected === 0)
|
|
225
|
+
setForm({ kind: 'route', action: 'create', fields: ['model', 'provider'], values: ['', providers[0]?.id ?? ''], cursor: 0 });
|
|
226
|
+
else if (selected <= routes.length)
|
|
227
|
+
setView({ kind: 'route-menu', model: routes[selected - 1].model });
|
|
228
|
+
else
|
|
229
|
+
setView({ kind: 'main' });
|
|
230
|
+
setSelected(0);
|
|
231
|
+
}
|
|
232
|
+
else if (view.kind === 'route-menu') {
|
|
233
|
+
const route = routes.find((item) => item.model === view.model);
|
|
234
|
+
if (!route)
|
|
235
|
+
return;
|
|
236
|
+
if (selected === 0)
|
|
237
|
+
setForm({ kind: 'route', action: 'edit', target: { model: route.model }, fields: ['model', 'provider'], values: [route.model, route.provider], cursor: 1 });
|
|
238
|
+
else if (selected === 1)
|
|
239
|
+
setConfirm({ label: `delete route ${route.model}`, run: () => client.deleteRoute(route.model) });
|
|
240
|
+
else
|
|
241
|
+
setView({ kind: 'routes' });
|
|
242
|
+
setSelected(0);
|
|
243
|
+
}
|
|
244
|
+
else if (view.kind === 'logs') {
|
|
245
|
+
if (selected === 0)
|
|
246
|
+
setView({ kind: 'log-details', index: snapshot.logs.length - 1 });
|
|
247
|
+
else if (selected === 1)
|
|
248
|
+
setForm({ kind: 'logs', fields: ['model', 'key', 'status'], values: [filters.model, filters.key, filters.status], cursor: 0 });
|
|
249
|
+
else if (selected === 2) {
|
|
250
|
+
setFilters({ model: '', key: '', status: '' });
|
|
251
|
+
void refresh();
|
|
252
|
+
}
|
|
253
|
+
else
|
|
254
|
+
setView({ kind: 'main' });
|
|
255
|
+
setSelected(0);
|
|
256
|
+
}
|
|
257
|
+
else if (view.kind === 'diagnostics') {
|
|
258
|
+
if (selected === 2)
|
|
259
|
+
setConfirm({ label: 'rotate access token', run: async () => { const result = await client.rotateToken(); setNotice(`New token: ${result.token}`); } });
|
|
260
|
+
else if (selected === 3)
|
|
261
|
+
setView({ kind: 'main' });
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
let content;
|
|
265
|
+
if (form)
|
|
266
|
+
content = _jsx(FormView, { form: form });
|
|
267
|
+
else if (confirm)
|
|
268
|
+
content = _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "yellow", bold: true, children: ["Confirm: ", confirm.label, "?"] }), _jsx(Text, { children: "Press y/Enter to confirm, n/Esc to cancel." })] });
|
|
269
|
+
else if (view.kind === 'dashboard') {
|
|
270
|
+
const keys = providers.flatMap((item) => item.keys);
|
|
271
|
+
const requests = keys.reduce((sum, item) => sum + item.counters.requests, 0);
|
|
272
|
+
const failed = keys.reduce((sum, item) => sum + item.counters.failed, 0);
|
|
273
|
+
content = _jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Dashboard" }), _jsxs(Text, { children: ["daemon: ", snapshot.error ? 'offline' : 'online', " \u00B7 ", snapshot.status?.listen ?? '—'] }), _jsxs(Text, { children: ["providers: ", providers.length, " \u00B7 keys: ", keys.length, " \u00B7 unavailable: ", keys.filter((item) => item.status === 'unavailable').length] }), _jsxs(Text, { children: ["requests: ", n(requests), " \u00B7 failed: ", n(failed), " \u00B7 success: ", requests ? `${(((requests - failed) / requests) * 100).toFixed(1)}%` : '—'] }), _jsxs(Text, { children: ["tokens: ", n(keys.reduce((sum, item) => sum + item.counters.tokensIn, 0)), " in / ", n(keys.reduce((sum, item) => sum + item.counters.tokensOut, 0)), " out"] })] });
|
|
274
|
+
}
|
|
275
|
+
else if (view.kind === 'log-details')
|
|
276
|
+
content = snapshot.logs[view.index] ? _jsx(Details, { log: snapshot.logs[view.index] }) : _jsx(Text, { children: "No log selected." });
|
|
277
|
+
else if (view.kind === 'diagnostics')
|
|
278
|
+
content = _jsxs(Box, { flexDirection: "column", children: [_jsx(Menu, { title: "Diagnostics", items: items, selected: selected }), _jsxs(Text, { dimColor: true, children: ["health: ", snapshot.error ? 'unreachable' : 'ok', " \u00B7 refreshed: ", snapshot.refreshedAt?.toLocaleTimeString() ?? '—'] })] });
|
|
279
|
+
else if (view.kind === 'logs')
|
|
280
|
+
content = _jsxs(Box, { flexDirection: "column", children: [_jsx(Menu, { title: "Logs", items: items, selected: selected }), _jsxs(Text, { dimColor: true, children: ["filters: ", filters.model || '*', " / ", filters.key || '*', " / ", filters.status || '*'] }), snapshot.logs.length > 0 && _jsxs(Text, { children: [snapshot.logs.length, " log(s) loaded"] })] });
|
|
281
|
+
else if (view.kind === 'main' || view.kind === 'providers' || view.kind === 'provider-menu' || view.kind === 'keys' || view.kind === 'key-menu' || view.kind === 'routes' || view.kind === 'route-menu')
|
|
282
|
+
content = _jsx(Menu, { title: view.kind === 'main' ? 'khat manager' : view.kind, items: items, selected: selected });
|
|
283
|
+
else
|
|
284
|
+
content = _jsx(Text, { children: "Loading\u2026" });
|
|
285
|
+
return _jsxs(Box, { flexDirection: "column", minHeight: 14, children: [_jsxs(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 1, justifyContent: "space-between", children: [_jsx(Text, { bold: true, children: "khat manager" }), _jsx(Text, { children: loading ? 'refreshing' : snapshot.error ? 'offline' : 'online' })] }), _jsxs(Box, { flexDirection: "column", paddingX: 1, flexGrow: 1, children: [content, snapshot.error && !form && !confirm && _jsx(Text, { color: "red", children: snapshot.error }), notice && _jsx(Text, { color: "green", children: notice })] }), _jsx(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, children: _jsx(Text, { dimColor: true, children: "\u2191\u2193 select \u00B7 Enter open \u00B7 Esc back \u00B7 r refresh \u00B7 q quit" }) })] });
|
|
286
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/vault.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { saveJsonAtomic } from './config.js';
|
|
4
|
+
const VAULT_VERSION = 1;
|
|
5
|
+
const AAD = 'khat-vault-v1';
|
|
6
|
+
// Releases before the kay->khat rename wrote their payload under this AAD; files saved that way stay readable and are rewritten under the current AAD on their next save().
|
|
7
|
+
const LEGACY_AAD = 'kay-vault-v1';
|
|
8
|
+
/** Vault namespace key holding the proxy access token (not a provider key). */
|
|
9
|
+
export const ACCESS_TOKEN_REF = 'accessToken';
|
|
10
|
+
function encrypt(master, plain) {
|
|
11
|
+
const nonce = randomBytes(12);
|
|
12
|
+
const cipher = createCipheriv('aes-256-gcm', master, nonce, { authTagLength: 16 });
|
|
13
|
+
cipher.setAAD(Buffer.from(AAD, 'utf8'));
|
|
14
|
+
const ciphertext = Buffer.concat([cipher.update(plain), cipher.final()]);
|
|
15
|
+
return { nonce: nonce.toString('base64'), ciphertext: ciphertext.toString('base64'), tag: cipher.getAuthTag().toString('base64') };
|
|
16
|
+
}
|
|
17
|
+
function decryptWithAad(master, payload, aad) {
|
|
18
|
+
const decipher = createDecipheriv('aes-256-gcm', master, Buffer.from(payload.nonce, 'base64'), { authTagLength: 16 });
|
|
19
|
+
decipher.setAAD(Buffer.from(aad, 'utf8'));
|
|
20
|
+
decipher.setAuthTag(Buffer.from(payload.tag, 'base64'));
|
|
21
|
+
return Buffer.concat([decipher.update(Buffer.from(payload.ciphertext, 'base64')), decipher.final()]);
|
|
22
|
+
}
|
|
23
|
+
function decrypt(master, payload) {
|
|
24
|
+
for (const aad of [AAD, LEGACY_AAD]) {
|
|
25
|
+
try {
|
|
26
|
+
return decryptWithAad(master, payload, aad);
|
|
27
|
+
}
|
|
28
|
+
catch { /* payload not sealed under this AAD; try the next one */ }
|
|
29
|
+
}
|
|
30
|
+
throw new Error('vault payload failed to decrypt');
|
|
31
|
+
}
|
|
32
|
+
export class Vault {
|
|
33
|
+
path;
|
|
34
|
+
master;
|
|
35
|
+
protector;
|
|
36
|
+
secrets;
|
|
37
|
+
constructor(path, master, protector, secrets) {
|
|
38
|
+
this.path = path;
|
|
39
|
+
this.master = master;
|
|
40
|
+
this.protector = protector;
|
|
41
|
+
this.secrets = secrets;
|
|
42
|
+
}
|
|
43
|
+
static async create(path, protector) {
|
|
44
|
+
const vault = new Vault(path, randomBytes(32), protector, {});
|
|
45
|
+
await vault.save();
|
|
46
|
+
return vault;
|
|
47
|
+
}
|
|
48
|
+
static async open(path, protector) {
|
|
49
|
+
let file;
|
|
50
|
+
try {
|
|
51
|
+
file = JSON.parse(await readFile(path, 'utf8'));
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error?.code === 'ENOENT')
|
|
55
|
+
throw new Error(`vault not found at ${path}; run khat init first`);
|
|
56
|
+
throw new Error(`vault file is unreadable: ${error.message}`);
|
|
57
|
+
}
|
|
58
|
+
if (file?.version !== VAULT_VERSION || !file.wrappedMaster || !file.payload?.nonce) {
|
|
59
|
+
throw new Error('vault file has an unsupported format');
|
|
60
|
+
}
|
|
61
|
+
const master = await protector.unprotect(Buffer.from(file.wrappedMaster, 'base64'));
|
|
62
|
+
if (master.length !== 32)
|
|
63
|
+
throw new Error('vault master key has an unexpected length');
|
|
64
|
+
let secrets;
|
|
65
|
+
try {
|
|
66
|
+
secrets = JSON.parse(decrypt(master, file.payload).toString('utf8'));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
throw new Error('vault payload failed to decrypt (corrupted file, or copied from another machine)');
|
|
70
|
+
}
|
|
71
|
+
return new Vault(path, master, protector, secrets);
|
|
72
|
+
}
|
|
73
|
+
static async openOrCreate(path, protector) {
|
|
74
|
+
try {
|
|
75
|
+
await readFile(path, 'utf8');
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (error?.code === 'ENOENT')
|
|
79
|
+
return Vault.create(path, protector);
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
return Vault.open(path, protector);
|
|
83
|
+
}
|
|
84
|
+
get(ref) { return this.secrets[ref]; }
|
|
85
|
+
set(ref, value) { this.secrets[ref] = value; }
|
|
86
|
+
delete(ref) { delete this.secrets[ref]; }
|
|
87
|
+
async save() {
|
|
88
|
+
const payload = encrypt(this.master, Buffer.from(JSON.stringify(this.secrets), 'utf8'));
|
|
89
|
+
const file = { version: VAULT_VERSION, wrappedMaster: (await this.protector.protect(this.master)).toString('base64'), payload };
|
|
90
|
+
await saveJsonAtomic(this.path, file);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** 256-bit random access token, printed once by `khat init` and stored only in the vault. */
|
|
94
|
+
export function generateAccessToken() {
|
|
95
|
+
return `khat_${randomBytes(32).toString('base64url')}`;
|
|
96
|
+
}
|