@bpmnkit/cli 0.0.9
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/README.md +113 -0
- package/dist/args.js +101 -0
- package/dist/client.js +19 -0
- package/dist/color.js +50 -0
- package/dist/commands/admin-shared.js +180 -0
- package/dist/commands/bpmn.js +143 -0
- package/dist/commands/completion.js +52 -0
- package/dist/commands/connector.js +172 -0
- package/dist/commands/index.js +47 -0
- package/dist/commands/profile.js +325 -0
- package/dist/commands/relations.js +28 -0
- package/dist/commands/settings.js +138 -0
- package/dist/commands/shared.js +196 -0
- package/dist/commands/worker.js +135 -0
- package/dist/completion.js +89 -0
- package/dist/generated/admin-commands.js +407 -0
- package/dist/generated/commands.js +2101 -0
- package/dist/help.js +117 -0
- package/dist/index.js +4 -0
- package/dist/output.js +262 -0
- package/dist/profile-tui.js +229 -0
- package/dist/profile.js +103 -0
- package/dist/run.js +210 -0
- package/dist/settings-tui.js +195 -0
- package/dist/tui.js +2544 -0
- package/dist/types.js +2 -0
- package/package.json +32 -0
package/dist/tui.js
ADDED
|
@@ -0,0 +1,2544 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { renderBpmnAscii } from "@bpmnkit/ascii";
|
|
3
|
+
import { appendAuditEntry, getAuditLog, getSettings, saveSettings } from "@bpmnkit/profiles";
|
|
4
|
+
// ─── ANSI helpers ─────────────────────────────────────────────────────────────
|
|
5
|
+
const CSI = "\x1b[";
|
|
6
|
+
const HIDE = `${CSI}?25l`;
|
|
7
|
+
const SHOW = `${CSI}?25h`;
|
|
8
|
+
const ALT_ON = `${CSI}?1049h`;
|
|
9
|
+
const ALT_OFF = `${CSI}?1049l`;
|
|
10
|
+
const CLEAR = `${CSI}2J${CSI}H`;
|
|
11
|
+
const inv = (s) => `${CSI}7m${s}${CSI}m`;
|
|
12
|
+
const bold = (s) => `${CSI}1m${s}${CSI}m`;
|
|
13
|
+
const dim = (s) => `${CSI}2m${s}${CSI}m`;
|
|
14
|
+
const green = (s) => `${CSI}32m${s}${CSI}m`;
|
|
15
|
+
const red = (s) => `${CSI}31m${s}${CSI}m`;
|
|
16
|
+
const cyan = (s) => `${CSI}36m${s}${CSI}m`;
|
|
17
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: needed for ANSI stripping
|
|
18
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
19
|
+
function vlen(s) {
|
|
20
|
+
return s.replace(ANSI_RE, "").length;
|
|
21
|
+
}
|
|
22
|
+
function padEnd(s, n) {
|
|
23
|
+
const v = vlen(s);
|
|
24
|
+
return v < n ? s + " ".repeat(n - v) : s;
|
|
25
|
+
}
|
|
26
|
+
function fit(s, n) {
|
|
27
|
+
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
|
28
|
+
}
|
|
29
|
+
// ─── Capturing output writer ──────────────────────────────────────────────────
|
|
30
|
+
function makeCapturingWriter() {
|
|
31
|
+
let captured = { type: "messages", lines: [] };
|
|
32
|
+
function extractItems(data) {
|
|
33
|
+
if (typeof data === "object" && data !== null && "items" in data) {
|
|
34
|
+
return data.items;
|
|
35
|
+
}
|
|
36
|
+
if (Array.isArray(data))
|
|
37
|
+
return data;
|
|
38
|
+
return [data];
|
|
39
|
+
}
|
|
40
|
+
function extractTotal(data, items) {
|
|
41
|
+
if (typeof data === "object" && data !== null) {
|
|
42
|
+
const page = data.page;
|
|
43
|
+
if (typeof page === "object" && page !== null) {
|
|
44
|
+
const t = page.totalItems;
|
|
45
|
+
if (typeof t === "number")
|
|
46
|
+
return t;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return items.length;
|
|
50
|
+
}
|
|
51
|
+
const writer = {
|
|
52
|
+
format: "table",
|
|
53
|
+
isInteractive: true,
|
|
54
|
+
printList(data, columns) {
|
|
55
|
+
const items = extractItems(data);
|
|
56
|
+
captured = { type: "list", items, columns, total: extractTotal(data, items) };
|
|
57
|
+
},
|
|
58
|
+
printItem(data) {
|
|
59
|
+
captured = { type: "item", data };
|
|
60
|
+
},
|
|
61
|
+
print(data) {
|
|
62
|
+
if (captured.type === "messages") {
|
|
63
|
+
const str = typeof data === "string" ? data : JSON.stringify(data, null, 2);
|
|
64
|
+
for (const line of str.split("\n")) {
|
|
65
|
+
captured.lines.push(line);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
ok(msg) {
|
|
70
|
+
if (captured.type === "messages")
|
|
71
|
+
captured.lines.push(`${green("✓")} ${msg}`);
|
|
72
|
+
},
|
|
73
|
+
info(msg) {
|
|
74
|
+
if (captured.type === "messages")
|
|
75
|
+
captured.lines.push(msg);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
return { writer, get: () => captured };
|
|
79
|
+
}
|
|
80
|
+
// ─── Field helpers ────────────────────────────────────────────────────────────
|
|
81
|
+
const SKIP_FLAGS = new Set(["output", "profile", "help", "no-color", "debug"]);
|
|
82
|
+
function buildFields(cmd) {
|
|
83
|
+
const fields = [];
|
|
84
|
+
for (const arg of cmd.args ?? []) {
|
|
85
|
+
fields.push({
|
|
86
|
+
kind: "arg",
|
|
87
|
+
label: arg.name,
|
|
88
|
+
hint: arg.required ? "required" : "optional",
|
|
89
|
+
value: "",
|
|
90
|
+
cursor: 0,
|
|
91
|
+
required: arg.required ?? false,
|
|
92
|
+
argSpec: arg,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
for (const flag of cmd.flags ?? []) {
|
|
96
|
+
if (SKIP_FLAGS.has(flag.name))
|
|
97
|
+
continue;
|
|
98
|
+
const hintParts = [
|
|
99
|
+
flag.type,
|
|
100
|
+
flag.placeholder ? `<${flag.placeholder}>` : "",
|
|
101
|
+
flag.required ? "required" : "optional",
|
|
102
|
+
];
|
|
103
|
+
fields.push({
|
|
104
|
+
kind: "flag",
|
|
105
|
+
label: `--${flag.name}`,
|
|
106
|
+
hint: hintParts.filter(Boolean).join(" "),
|
|
107
|
+
value: flag.default !== undefined ? String(flag.default) : "",
|
|
108
|
+
cursor: 0,
|
|
109
|
+
required: flag.required ?? false,
|
|
110
|
+
flagSpec: flag,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
fields.push({ kind: "run", label: "[ Run ]", hint: "", value: "", cursor: 0, required: false });
|
|
114
|
+
return fields;
|
|
115
|
+
}
|
|
116
|
+
function buildContext(cmd, fields, writer, getClient, getAdminClient) {
|
|
117
|
+
const positional = [];
|
|
118
|
+
const flags = {};
|
|
119
|
+
for (const f of fields) {
|
|
120
|
+
if (f.kind === "arg" && f.value)
|
|
121
|
+
positional.push(f.value);
|
|
122
|
+
if (f.kind === "flag" && f.value && f.flagSpec) {
|
|
123
|
+
const { name, type } = f.flagSpec;
|
|
124
|
+
if (type === "boolean") {
|
|
125
|
+
flags[name] = f.value === "true" || f.value === "yes" || f.value === "1";
|
|
126
|
+
}
|
|
127
|
+
else if (type === "number") {
|
|
128
|
+
const n = Number(f.value);
|
|
129
|
+
if (!Number.isNaN(n))
|
|
130
|
+
flags[name] = n;
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
flags[name] = f.value;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return { positional, flags, output: writer, getClient, getAdminClient };
|
|
138
|
+
}
|
|
139
|
+
// ─── Field type helpers ────────────────────────────────────────────────────────
|
|
140
|
+
function getEnum(field) {
|
|
141
|
+
if (field.kind === "flag")
|
|
142
|
+
return field.flagSpec?.enum;
|
|
143
|
+
if (field.kind === "arg")
|
|
144
|
+
return field.argSpec?.enum;
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
function isJsonField(field) {
|
|
148
|
+
if (field.kind === "flag")
|
|
149
|
+
return field.flagSpec?.json === true;
|
|
150
|
+
if (field.kind === "arg")
|
|
151
|
+
return field.argSpec?.json === true;
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
function getPresets(field) {
|
|
155
|
+
if (field.kind === "flag")
|
|
156
|
+
return field.flagSpec?.presets;
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
function parseJsonToEntries(json) {
|
|
160
|
+
if (!json.trim())
|
|
161
|
+
return [];
|
|
162
|
+
try {
|
|
163
|
+
const obj = JSON.parse(json);
|
|
164
|
+
return Object.entries(obj).map(([key, val]) => ({
|
|
165
|
+
key,
|
|
166
|
+
keyCursor: key.length,
|
|
167
|
+
val: typeof val === "string" ? val : JSON.stringify(val),
|
|
168
|
+
valCursor: 0,
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function entriesToJson(entries) {
|
|
176
|
+
const obj = {};
|
|
177
|
+
for (const e of entries) {
|
|
178
|
+
if (!e.key)
|
|
179
|
+
continue;
|
|
180
|
+
try {
|
|
181
|
+
obj[e.key] = JSON.parse(e.val);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
obj[e.key] = e.val;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return Object.keys(obj).length === 0 ? "" : JSON.stringify(obj);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Build initial entries for the JSON editor.
|
|
191
|
+
* If fieldSpecs are provided, pre-populate with all known fields (in spec order),
|
|
192
|
+
* merging in any existing values. Extra keys from existing JSON are appended at the end.
|
|
193
|
+
*/
|
|
194
|
+
function buildInitialEntries(existingJson, fieldSpecs) {
|
|
195
|
+
const existing = {};
|
|
196
|
+
if (existingJson.trim()) {
|
|
197
|
+
try {
|
|
198
|
+
const obj = JSON.parse(existingJson);
|
|
199
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
200
|
+
existing[k] = typeof v === "string" ? v : JSON.stringify(v);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// ignore malformed JSON
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (!fieldSpecs || fieldSpecs.length === 0)
|
|
208
|
+
return parseJsonToEntries(existingJson);
|
|
209
|
+
const seenKeys = new Set();
|
|
210
|
+
const entries = [];
|
|
211
|
+
for (const spec of fieldSpecs) {
|
|
212
|
+
seenKeys.add(spec.name);
|
|
213
|
+
const val = existing[spec.name] ?? "";
|
|
214
|
+
entries.push({ key: spec.name, keyCursor: spec.name.length, val, valCursor: val.length });
|
|
215
|
+
}
|
|
216
|
+
// Append extra keys not in the spec
|
|
217
|
+
for (const [k, v] of Object.entries(existing)) {
|
|
218
|
+
if (!seenKeys.has(k)) {
|
|
219
|
+
entries.push({ key: k, keyCursor: k.length, val: v, valCursor: v.length });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return entries;
|
|
223
|
+
}
|
|
224
|
+
/** Look up a JsonFieldSpec by key name. */
|
|
225
|
+
function getFieldSpec(key, fieldSpecs) {
|
|
226
|
+
return fieldSpecs?.find((s) => s.name === key);
|
|
227
|
+
}
|
|
228
|
+
// ─── Table helpers ────────────────────────────────────────────────────────────
|
|
229
|
+
function getCellStr(item, col) {
|
|
230
|
+
let val = item;
|
|
231
|
+
for (const part of col.key.split(".")) {
|
|
232
|
+
val = val?.[part];
|
|
233
|
+
}
|
|
234
|
+
if (col.transform)
|
|
235
|
+
return col.transform(val);
|
|
236
|
+
if (val === null || val === undefined)
|
|
237
|
+
return "—";
|
|
238
|
+
if (typeof val === "object")
|
|
239
|
+
return JSON.stringify(val);
|
|
240
|
+
return String(val);
|
|
241
|
+
}
|
|
242
|
+
function calcColWidths(items, columns, available) {
|
|
243
|
+
const natural = columns.map((col) => {
|
|
244
|
+
let max = col.header.length;
|
|
245
|
+
for (const item of items) {
|
|
246
|
+
const len = Math.min(getCellStr(item, col).length, col.maxWidth ?? 999);
|
|
247
|
+
if (len > max)
|
|
248
|
+
max = len;
|
|
249
|
+
}
|
|
250
|
+
return max;
|
|
251
|
+
});
|
|
252
|
+
const gap = (columns.length - 1) * 2;
|
|
253
|
+
const total = natural.reduce((s, w) => s + w, 0) + gap;
|
|
254
|
+
if (total <= available)
|
|
255
|
+
return natural;
|
|
256
|
+
const scale = (available - gap) / natural.reduce((s, w) => s + w, 0);
|
|
257
|
+
return natural.map((w) => Math.max(5, Math.round(w * scale)));
|
|
258
|
+
}
|
|
259
|
+
/** Sentinel for arrays of objects — displayed as "[N items]", Space to drill down. */
|
|
260
|
+
class ArrayValue {
|
|
261
|
+
items;
|
|
262
|
+
constructor(items) {
|
|
263
|
+
this.items = items;
|
|
264
|
+
}
|
|
265
|
+
toString() {
|
|
266
|
+
return `[${this.items.length} item${this.items.length !== 1 ? "s" : ""}]`;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function flattenObj(obj, prefix = "") {
|
|
270
|
+
// Top-level array (from drill-down): expand each element with [i] prefix
|
|
271
|
+
if (Array.isArray(obj)) {
|
|
272
|
+
const result = {};
|
|
273
|
+
for (let i = 0; i < obj.length; i++) {
|
|
274
|
+
const elem = obj[i];
|
|
275
|
+
if (typeof elem === "object" && elem !== null) {
|
|
276
|
+
Object.assign(result, flattenObj(elem, `[${i}]`));
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
result[`[${i}]`] = elem;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return result;
|
|
283
|
+
}
|
|
284
|
+
if (typeof obj !== "object" || obj === null)
|
|
285
|
+
return { value: obj };
|
|
286
|
+
const result = {};
|
|
287
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
288
|
+
const key = prefix ? `${prefix}.${k}` : k;
|
|
289
|
+
if (Array.isArray(v)) {
|
|
290
|
+
if (v.length === 0) {
|
|
291
|
+
result[key] = "[]";
|
|
292
|
+
}
|
|
293
|
+
else if (v.every((item) => typeof item !== "object" || item === null)) {
|
|
294
|
+
result[key] = v.join(", ");
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
// Arrays of objects: show as collapsed summary; Space to expand
|
|
298
|
+
result[key] = new ArrayValue(v);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
else if (typeof v === "object" && v !== null) {
|
|
302
|
+
Object.assign(result, flattenObj(v, key));
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
result[key] = v;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return result;
|
|
309
|
+
}
|
|
310
|
+
// ─── Render helpers ───────────────────────────────────────────────────────────
|
|
311
|
+
function termSize() {
|
|
312
|
+
return { cols: process.stdout.columns ?? 80, rows: process.stdout.rows ?? 24 };
|
|
313
|
+
}
|
|
314
|
+
function renderHeader(crumbs, cols, profile) {
|
|
315
|
+
const title = crumbs.map((c, i) => (i === 0 ? bold(c) : cyan(c))).join(dim(" › "));
|
|
316
|
+
const profileStr = profile ? dim(` profile: ${profile}`) : "";
|
|
317
|
+
const titleLen = crumbs.join(" › ").length + 2;
|
|
318
|
+
const profileLen = profile ? ` profile: ${profile}`.length : 0;
|
|
319
|
+
const pad = Math.max(0, cols - 4 - titleLen - profileLen);
|
|
320
|
+
const header = profileStr ? ` ${title}${" ".repeat(pad)}${profileStr}` : ` ${title}`;
|
|
321
|
+
return `\n${header}\n ${dim("─".repeat(cols - 4))}`;
|
|
322
|
+
}
|
|
323
|
+
function renderText(value, cursorPos, width, isEditing) {
|
|
324
|
+
if (!isEditing)
|
|
325
|
+
return value ? fit(value, width) : dim("─");
|
|
326
|
+
const v = value;
|
|
327
|
+
const c = cursorPos;
|
|
328
|
+
const start = c >= width ? c - width + 1 : 0;
|
|
329
|
+
const segment = v.slice(start, start + width + 1);
|
|
330
|
+
const rel = c - start;
|
|
331
|
+
const before = segment.slice(0, rel);
|
|
332
|
+
const ch = segment[rel];
|
|
333
|
+
const after = segment.slice(rel + (ch ? 1 : 0), rel + width);
|
|
334
|
+
return `${before}${ch ? inv(ch) : inv(" ")}${after}`;
|
|
335
|
+
}
|
|
336
|
+
function renderFieldValue(field, width, isEditing) {
|
|
337
|
+
return renderText(field.value, field.cursor, width, isEditing);
|
|
338
|
+
}
|
|
339
|
+
/** Pop the stack all the way back to the main menu (first screen). */
|
|
340
|
+
function popToMain(state) {
|
|
341
|
+
if (state.stack.length > 1)
|
|
342
|
+
state.stack.splice(1);
|
|
343
|
+
}
|
|
344
|
+
// ─── Screen renderers ─────────────────────────────────────────────────────────
|
|
345
|
+
function filterGroups(groups, search) {
|
|
346
|
+
if (!search)
|
|
347
|
+
return groups;
|
|
348
|
+
const q = search.toLowerCase();
|
|
349
|
+
return groups.filter((g) => g.name.toLowerCase().includes(q) || g.description.toLowerCase().includes(q));
|
|
350
|
+
}
|
|
351
|
+
function filterCommands(cmds, search) {
|
|
352
|
+
if (!search)
|
|
353
|
+
return cmds;
|
|
354
|
+
const q = search.toLowerCase();
|
|
355
|
+
return cmds.filter((c) => c.name.toLowerCase().includes(q) || c.description.toLowerCase().includes(q));
|
|
356
|
+
}
|
|
357
|
+
function renderMain(state, screen) {
|
|
358
|
+
const { cols, rows } = termSize();
|
|
359
|
+
const searching = screen.search.length > 0;
|
|
360
|
+
const viewH = Math.max(3, rows - (searching ? 9 : 7));
|
|
361
|
+
const groups = filterGroups(state.groups, screen.search);
|
|
362
|
+
const nameW = state.groups.reduce((m, g) => Math.max(m, g.name.length), 0);
|
|
363
|
+
const lines = [renderHeader(["casen"], cols, state.profile), ""];
|
|
364
|
+
if (searching) {
|
|
365
|
+
lines.push(` ${dim("/")} ${screen.search}█\n`);
|
|
366
|
+
}
|
|
367
|
+
const visible = groups.slice(screen.scroll, screen.scroll + viewH);
|
|
368
|
+
for (let vi = 0; vi < visible.length; vi++) {
|
|
369
|
+
const g = visible[vi];
|
|
370
|
+
if (!g)
|
|
371
|
+
continue;
|
|
372
|
+
const i = screen.scroll + vi;
|
|
373
|
+
const isCursor = i === screen.cursor;
|
|
374
|
+
const name = padEnd(isCursor ? cyan(g.name) : g.name, nameW + 2);
|
|
375
|
+
const desc = dim(fit(g.description, cols - nameW - 8));
|
|
376
|
+
const line = ` ${name} ${desc}`;
|
|
377
|
+
lines.push(isCursor ? inv(line.padEnd(cols - 1)) : line);
|
|
378
|
+
}
|
|
379
|
+
if (groups.length > viewH) {
|
|
380
|
+
const hi = Math.min(screen.scroll + viewH, groups.length);
|
|
381
|
+
lines.push(`\n ${dim(`${screen.scroll + 1}–${hi} of ${groups.length}`)}`);
|
|
382
|
+
}
|
|
383
|
+
const hint = searching
|
|
384
|
+
? ` ${dim("↑↓")} navigate ${cyan("enter")} open ${cyan("esc")} clear ${dim("bksp")} delete ${cyan("^C")} quit`
|
|
385
|
+
: ` ${dim("↑↓")} navigate ${cyan("enter")} open ${dim("type")} search ${cyan("^C")} quit`;
|
|
386
|
+
lines.push(`\n${hint}`);
|
|
387
|
+
return lines;
|
|
388
|
+
}
|
|
389
|
+
function renderCommands(state, screen) {
|
|
390
|
+
const { cols, rows } = termSize();
|
|
391
|
+
const searching = screen.search.length > 0;
|
|
392
|
+
const viewH = Math.max(3, rows - (searching ? 11 : 9));
|
|
393
|
+
const cmds = filterCommands(screen.group.commands, screen.search);
|
|
394
|
+
const nameW = screen.group.commands.reduce((m, c) => Math.max(m, c.name.length), 0);
|
|
395
|
+
const hasMain = state.stack[0]?.kind === "main";
|
|
396
|
+
const lines = [
|
|
397
|
+
renderHeader([screen.group.name], cols, state.profile),
|
|
398
|
+
`\n ${dim(screen.group.description)}\n`,
|
|
399
|
+
];
|
|
400
|
+
if (searching) {
|
|
401
|
+
lines.push(` ${dim("/")} ${screen.search}█\n`);
|
|
402
|
+
}
|
|
403
|
+
for (let i = 0; i < cmds.length; i++) {
|
|
404
|
+
const cmd = cmds[i];
|
|
405
|
+
if (!cmd)
|
|
406
|
+
continue;
|
|
407
|
+
const isCursor = i === screen.cursor;
|
|
408
|
+
const name = padEnd(isCursor ? cyan(cmd.name) : cmd.name, nameW + 2);
|
|
409
|
+
const desc = dim(fit(cmd.description, cols - nameW - 8));
|
|
410
|
+
const line = ` ${name} ${desc}`;
|
|
411
|
+
lines.push(isCursor ? inv(line.padEnd(cols - 1)) : line);
|
|
412
|
+
}
|
|
413
|
+
if (searching) {
|
|
414
|
+
lines.push(`\n ${dim("↑↓")} navigate ${cyan("enter")} select ${cyan("esc")} clear ${dim("bksp")} delete`);
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
const mHint = hasMain ? ` ${cyan("m")} main menu` : "";
|
|
418
|
+
lines.push(`\n ${dim("↑↓")} navigate ${cyan("enter")} select ${cyan("esc")} back${mHint} ${dim("type")} search ${cyan("^C")} quit`);
|
|
419
|
+
}
|
|
420
|
+
return lines;
|
|
421
|
+
}
|
|
422
|
+
function renderInput(state, screen) {
|
|
423
|
+
const { cols, rows } = termSize();
|
|
424
|
+
const viewH = Math.max(3, rows - 9);
|
|
425
|
+
const labelW = screen.fields.reduce((m, f) => (f.kind !== "run" ? Math.max(m, f.label.length) : m), 0);
|
|
426
|
+
const hintW = 26;
|
|
427
|
+
const valueW = Math.max(20, cols - labelW - hintW - 10);
|
|
428
|
+
const lines = [
|
|
429
|
+
renderHeader([screen.group.name, screen.cmd.name], cols, state.profile),
|
|
430
|
+
`\n ${dim(screen.cmd.description)}\n`,
|
|
431
|
+
];
|
|
432
|
+
const visible = screen.fields.slice(screen.scroll, screen.scroll + viewH);
|
|
433
|
+
for (let vi = 0; vi < visible.length; vi++) {
|
|
434
|
+
const field = visible[vi];
|
|
435
|
+
if (!field)
|
|
436
|
+
continue;
|
|
437
|
+
const fi = screen.scroll + vi;
|
|
438
|
+
const isCursor = fi === screen.cursor;
|
|
439
|
+
if (field.kind === "run") {
|
|
440
|
+
const btn = isCursor ? inv(" Run ") : dim("[ Run ]");
|
|
441
|
+
lines.push(` ${isCursor ? cyan("▶") : " "} ${btn}`);
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
const marker = isCursor ? cyan("▶") : " ";
|
|
445
|
+
const label = padEnd(isCursor ? cyan(field.label) : dim(field.label), labelW + 2);
|
|
446
|
+
const enumVals = getEnum(field);
|
|
447
|
+
const isJson = isJsonField(field);
|
|
448
|
+
const presets = getPresets(field);
|
|
449
|
+
// In edit mode for enum fields, ↑↓ cycle rather than typing
|
|
450
|
+
const isEnumEditing = screen.editing && isCursor && enumVals !== undefined;
|
|
451
|
+
const value = isEnumEditing
|
|
452
|
+
? enumVals.includes(field.value)
|
|
453
|
+
? cyan(padEnd(field.value, valueW))
|
|
454
|
+
: padEnd(field.value, valueW)
|
|
455
|
+
: renderFieldValue(field, valueW, screen.editing && isCursor && !isJson);
|
|
456
|
+
const req = field.required ? red("*") : " ";
|
|
457
|
+
// Show contextual hint for the focused field
|
|
458
|
+
let hintText = field.hint;
|
|
459
|
+
if (isCursor) {
|
|
460
|
+
if (enumVals) {
|
|
461
|
+
const idx = enumVals.indexOf(field.value);
|
|
462
|
+
const pos = idx >= 0 ? `${idx + 1}/${enumVals.length}` : `?/${enumVals.length}`;
|
|
463
|
+
hintText = `↑↓ pick ${pos}`;
|
|
464
|
+
}
|
|
465
|
+
else if (isJson) {
|
|
466
|
+
hintText = "→ json editor";
|
|
467
|
+
}
|
|
468
|
+
else if (presets) {
|
|
469
|
+
hintText = "↑↓ preset";
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const hint = isCursor ? cyan(fit(hintText, hintW)) : dim(fit(hintText, hintW));
|
|
473
|
+
lines.push(` ${marker} ${label} ${padEnd(value, valueW)} ${req} ${hint}`);
|
|
474
|
+
}
|
|
475
|
+
lines.push("");
|
|
476
|
+
if (screen.error) {
|
|
477
|
+
lines.push(` ${red("error:")} ${screen.error}`);
|
|
478
|
+
}
|
|
479
|
+
else if (screen.running) {
|
|
480
|
+
lines.push(` ${dim("running…")}`);
|
|
481
|
+
}
|
|
482
|
+
else if (screen.editing) {
|
|
483
|
+
const curField = screen.fields[screen.cursor];
|
|
484
|
+
const curEnum = curField ? getEnum(curField) : undefined;
|
|
485
|
+
const curPresets = curField ? getPresets(curField) : undefined;
|
|
486
|
+
if (curEnum) {
|
|
487
|
+
lines.push(` ${dim("↑↓")} cycle ${cyan("enter")} confirm ${cyan("esc")} cancel ${dim(curEnum.join(" | "))}`);
|
|
488
|
+
}
|
|
489
|
+
else if (curPresets) {
|
|
490
|
+
lines.push(` ${dim("←→")} cursor ${cyan("↑↓")} presets ${cyan("enter")} confirm ${cyan("esc")} cancel`);
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
lines.push(` ${dim("←→")} cursor ${cyan("ctrl+a/e")} home/end ${cyan("ctrl+k/u")} clear ${cyan("enter")} confirm ${cyan("esc")} cancel`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
lines.push(` ${dim("↑↓")} navigate ${cyan("enter")} edit/run ${cyan("m")} main menu ${cyan("esc")} back ${cyan("q")} quit`);
|
|
498
|
+
}
|
|
499
|
+
return lines;
|
|
500
|
+
}
|
|
501
|
+
function renderRawView(screen, cols) {
|
|
502
|
+
const raw = screen.raw;
|
|
503
|
+
if (!raw)
|
|
504
|
+
return [` ${dim("(no raw response captured)")}`];
|
|
505
|
+
const lines = [];
|
|
506
|
+
const statusCol = raw.status >= 200 && raw.status < 300 ? green : red;
|
|
507
|
+
lines.push(` ${bold("Status:")} ${statusCol(`HTTP ${raw.status}`)}`);
|
|
508
|
+
lines.push("");
|
|
509
|
+
lines.push(` ${bold("Headers:")}`);
|
|
510
|
+
for (const [k, v] of Object.entries(raw.headers)) {
|
|
511
|
+
const kStr = padEnd(cyan(k), 36);
|
|
512
|
+
lines.push(` ${kStr} ${fit(v, cols - 42)}`);
|
|
513
|
+
}
|
|
514
|
+
lines.push("");
|
|
515
|
+
lines.push(` ${bold("Body:")}`);
|
|
516
|
+
let bodyLines;
|
|
517
|
+
try {
|
|
518
|
+
const parsed = JSON.parse(raw.body);
|
|
519
|
+
bodyLines = JSON.stringify(parsed, null, 2).split("\n");
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
bodyLines = raw.body.split("\n");
|
|
523
|
+
}
|
|
524
|
+
for (const l of bodyLines) {
|
|
525
|
+
lines.push(` ${fit(l, cols - 4)}`);
|
|
526
|
+
}
|
|
527
|
+
return lines;
|
|
528
|
+
}
|
|
529
|
+
// ─── Curl view ────────────────────────────────────────────────────────────────
|
|
530
|
+
const CURL_TOKEN_VAR = "CAMUNDA_TOKEN";
|
|
531
|
+
function buildCurlCmd(raw) {
|
|
532
|
+
const parts = [`curl -X ${raw.method}`];
|
|
533
|
+
let token = null;
|
|
534
|
+
for (const [k, v] of Object.entries(raw.requestHeaders)) {
|
|
535
|
+
let val = v;
|
|
536
|
+
if (k.toLowerCase() === "authorization") {
|
|
537
|
+
const m = /^Bearer (.+)$/.exec(v);
|
|
538
|
+
if (m) {
|
|
539
|
+
token = m[1] ?? null;
|
|
540
|
+
val = `Bearer $${CURL_TOKEN_VAR}`;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
parts.push(` -H '${k}: ${val}'`);
|
|
544
|
+
}
|
|
545
|
+
if (raw.requestBody) {
|
|
546
|
+
parts.push(` -d '${raw.requestBody.replace(/'/g, "'\\''")}'`);
|
|
547
|
+
}
|
|
548
|
+
parts.push(` '${raw.url}'`);
|
|
549
|
+
return { cmd: parts.join(" \\\n"), token };
|
|
550
|
+
}
|
|
551
|
+
function copyToClipboard(text) {
|
|
552
|
+
const cmds = process.platform === "darwin"
|
|
553
|
+
? [["pbcopy"]]
|
|
554
|
+
: process.platform === "win32"
|
|
555
|
+
? [["clip"]]
|
|
556
|
+
: [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]];
|
|
557
|
+
const tryNext = (i) => {
|
|
558
|
+
const entry = cmds[i];
|
|
559
|
+
if (!entry)
|
|
560
|
+
return;
|
|
561
|
+
const [bin, ...args] = entry;
|
|
562
|
+
if (!bin) {
|
|
563
|
+
tryNext(i + 1);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
try {
|
|
567
|
+
const proc = spawn(bin, args, { stdio: ["pipe", "ignore", "ignore"] });
|
|
568
|
+
proc.on("error", () => tryNext(i + 1));
|
|
569
|
+
proc.stdin.write(text);
|
|
570
|
+
proc.stdin.end();
|
|
571
|
+
proc.unref();
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
tryNext(i + 1);
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
tryNext(0);
|
|
578
|
+
}
|
|
579
|
+
function renderCurlView(screen, cols) {
|
|
580
|
+
const raw = screen.raw;
|
|
581
|
+
if (!raw)
|
|
582
|
+
return [` ${dim("(no raw response captured)")}`];
|
|
583
|
+
const { cmd, token } = buildCurlCmd(raw);
|
|
584
|
+
const lines = [];
|
|
585
|
+
lines.push(` ${bold("curl command:")}`);
|
|
586
|
+
lines.push("");
|
|
587
|
+
for (const l of cmd.split("\n")) {
|
|
588
|
+
lines.push(` ${fit(l, cols - 4)}`);
|
|
589
|
+
}
|
|
590
|
+
if (token) {
|
|
591
|
+
lines.push("");
|
|
592
|
+
lines.push(` ${dim(`Token obfuscated as $${CURL_TOKEN_VAR}. Press ${bold("e")} to copy export statement.`)}`);
|
|
593
|
+
}
|
|
594
|
+
lines.push("");
|
|
595
|
+
lines.push(` ${dim(`Press ${bold("y")} to copy curl command to clipboard.`)}`);
|
|
596
|
+
return lines;
|
|
597
|
+
}
|
|
598
|
+
function renderResults(state, screen) {
|
|
599
|
+
const { cols, rows } = termSize();
|
|
600
|
+
const viewH = Math.max(3, rows - 10);
|
|
601
|
+
const out = screen.output;
|
|
602
|
+
const lines = [];
|
|
603
|
+
const statusBadge = screen.raw
|
|
604
|
+
? (() => {
|
|
605
|
+
const fn = screen.raw.status >= 200 && screen.raw.status < 300 ? green : red;
|
|
606
|
+
return ` ${dim(fn(`HTTP ${screen.raw.status}`))}`;
|
|
607
|
+
})()
|
|
608
|
+
: "";
|
|
609
|
+
const rawToggle = ` ${screen.rawView ? cyan("r") : dim("r")} ${dim("raw")}`;
|
|
610
|
+
const curlToggle = screen.raw ? ` ${screen.curlView ? cyan("u") : dim("u")} ${dim("curl")}` : "";
|
|
611
|
+
if (screen.curlView) {
|
|
612
|
+
lines.push(renderHeader([screen.group.name, screen.cmd.name], cols, state.profile));
|
|
613
|
+
lines.push("");
|
|
614
|
+
const curlLines = renderCurlView(screen, cols);
|
|
615
|
+
const visible = curlLines.slice(screen.scroll, screen.scroll + rows - 8);
|
|
616
|
+
for (const l of visible)
|
|
617
|
+
lines.push(l);
|
|
618
|
+
lines.push("");
|
|
619
|
+
if (curlLines.length > rows - 8) {
|
|
620
|
+
const hi = Math.min(screen.scroll + rows - 8, curlLines.length);
|
|
621
|
+
lines.push(` ${dim(`${screen.scroll + 1}–${hi} of ${curlLines.length}`)}`);
|
|
622
|
+
}
|
|
623
|
+
lines.push(` ${dim("↑↓")} scroll${rawToggle}${curlToggle} ${cyan("y")} copy curl ${cyan("e")} copy export ${cyan("m")} main menu ${cyan("esc")} back ${cyan("q")} quit`);
|
|
624
|
+
return lines;
|
|
625
|
+
}
|
|
626
|
+
if (screen.rawView) {
|
|
627
|
+
lines.push(renderHeader([screen.group.name, screen.cmd.name], cols, state.profile));
|
|
628
|
+
lines.push("");
|
|
629
|
+
const rawLines = renderRawView(screen, cols);
|
|
630
|
+
const visible = rawLines.slice(screen.scroll, screen.scroll + rows - 8);
|
|
631
|
+
for (const l of visible)
|
|
632
|
+
lines.push(l);
|
|
633
|
+
lines.push("");
|
|
634
|
+
if (rawLines.length > rows - 8) {
|
|
635
|
+
const hi = Math.min(screen.scroll + rows - 8, rawLines.length);
|
|
636
|
+
lines.push(` ${dim(`${screen.scroll + 1}–${hi} of ${rawLines.length}`)}`);
|
|
637
|
+
}
|
|
638
|
+
lines.push(` ${dim("↑↓")} scroll${rawToggle}${curlToggle} ${cyan("m")} main menu ${cyan("esc")} back ${cyan("q")} quit`);
|
|
639
|
+
return lines;
|
|
640
|
+
}
|
|
641
|
+
if (out.type === "list") {
|
|
642
|
+
lines.push(renderHeader([screen.group.name, screen.cmd.name], cols, state.profile));
|
|
643
|
+
lines.push(`\n ${dim(`${out.total} item${out.total !== 1 ? "s" : ""}`)}${statusBadge}\n`);
|
|
644
|
+
const available = cols - 4;
|
|
645
|
+
const widths = calcColWidths(out.items, out.columns, available);
|
|
646
|
+
const header = out.columns
|
|
647
|
+
.map((col, i) => padEnd(dim(col.header), widths[i] ?? col.header.length))
|
|
648
|
+
.join(" ");
|
|
649
|
+
lines.push(` ${header}`);
|
|
650
|
+
lines.push(` ${dim("─".repeat(available))}`);
|
|
651
|
+
const visible = out.items.slice(screen.scroll, screen.scroll + viewH);
|
|
652
|
+
for (let vi = 0; vi < visible.length; vi++) {
|
|
653
|
+
const item = visible[vi];
|
|
654
|
+
if (!item)
|
|
655
|
+
continue;
|
|
656
|
+
const isCursor = screen.scroll + vi === screen.cursor;
|
|
657
|
+
const cells = out.columns.map((col, ci) => {
|
|
658
|
+
const w = widths[ci] ?? 10;
|
|
659
|
+
return padEnd(fit(getCellStr(item, col), w), w);
|
|
660
|
+
});
|
|
661
|
+
const line = ` ${cells.join(" ")}`;
|
|
662
|
+
lines.push(isCursor ? inv(line.padEnd(cols - 1)) : line);
|
|
663
|
+
}
|
|
664
|
+
lines.push("");
|
|
665
|
+
if (out.total > viewH) {
|
|
666
|
+
const hi = Math.min(screen.scroll + viewH, out.total);
|
|
667
|
+
lines.push(` ${dim(`${screen.scroll + 1}–${hi} of ${out.total}`)}`);
|
|
668
|
+
}
|
|
669
|
+
const followupHint = screen.cmd.relations ? ` ${cyan("f")} follow-up` : "";
|
|
670
|
+
lines.push(` ${dim("↑↓")} navigate ${cyan("enter")} detail${followupHint} ${dim("pgup/pgdn")} page${rawToggle}${curlToggle} ${cyan("m")} main menu ${cyan("esc")} back ${cyan("q")} quit`);
|
|
671
|
+
}
|
|
672
|
+
else if (out.type === "item") {
|
|
673
|
+
lines.push(renderHeader([screen.group.name, screen.cmd.name], cols, state.profile));
|
|
674
|
+
lines.push(statusBadge ? `\n${statusBadge}` : "");
|
|
675
|
+
const flat = flattenObj(out.data);
|
|
676
|
+
const entries = Object.entries(flat);
|
|
677
|
+
const kw = entries.reduce((m, [k]) => Math.max(m, k.length), 0);
|
|
678
|
+
for (let i = 0; i < entries.length; i++) {
|
|
679
|
+
const entry = entries[i];
|
|
680
|
+
if (!entry)
|
|
681
|
+
continue;
|
|
682
|
+
const [k, v] = entry;
|
|
683
|
+
const isCursor = i === screen.cursor;
|
|
684
|
+
const isArr = v instanceof ArrayValue;
|
|
685
|
+
const valueStr = v === null || v === undefined ? dim("—") : String(v);
|
|
686
|
+
const hint = isArr && isCursor ? ` ${dim("[space] expand")}` : "";
|
|
687
|
+
const line = ` ${padEnd(cyan(k), kw + 2)} ${valueStr}${hint}`;
|
|
688
|
+
lines.push(isCursor ? inv(line.padEnd(cols - 1)) : line);
|
|
689
|
+
}
|
|
690
|
+
const spaceHint = entries.some(([, v]) => v instanceof ArrayValue)
|
|
691
|
+
? ` ${cyan("space")} expand array`
|
|
692
|
+
: "";
|
|
693
|
+
lines.push(`\n${rawToggle}${curlToggle}${spaceHint} ${cyan("m")} main menu ${cyan("esc")} back ${cyan("q")} quit`);
|
|
694
|
+
}
|
|
695
|
+
else {
|
|
696
|
+
lines.push(renderHeader([screen.group.name, screen.cmd.name], cols, state.profile));
|
|
697
|
+
lines.push(statusBadge ? `\n${statusBadge}` : "");
|
|
698
|
+
const displayLines = screen.altView && out.altLines ? out.altLines : out.lines;
|
|
699
|
+
const altToggle = out.altLines
|
|
700
|
+
? ` ${screen.altView ? cyan("x") : dim("x")} ${dim("ascii")}`
|
|
701
|
+
: "";
|
|
702
|
+
if (displayLines.length === 0) {
|
|
703
|
+
lines.push(` ${dim("(no output)")}`);
|
|
704
|
+
}
|
|
705
|
+
else {
|
|
706
|
+
const hOff = screen.altView ? 0 : screen.cursor;
|
|
707
|
+
const visible = displayLines.slice(screen.scroll, screen.scroll + viewH);
|
|
708
|
+
for (const line of visible) {
|
|
709
|
+
lines.push(` ${fit(line.slice(hOff), cols - 4)}`);
|
|
710
|
+
}
|
|
711
|
+
if (displayLines.length > viewH || hOff > 0) {
|
|
712
|
+
const hi = Math.min(screen.scroll + viewH, displayLines.length);
|
|
713
|
+
const panHint = hOff > 0 ? ` col +${hOff}` : "";
|
|
714
|
+
lines.push(` ${dim(`${screen.scroll + 1}–${hi} of ${displayLines.length}${panHint}`)}`);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
lines.push(`\n ${dim("↑↓←→")} scroll/pan${rawToggle}${curlToggle}${altToggle} ${cyan("m")} main menu ${cyan("esc")} back ${cyan("q")} quit`);
|
|
718
|
+
}
|
|
719
|
+
return lines;
|
|
720
|
+
}
|
|
721
|
+
function renderDetail(state, screen) {
|
|
722
|
+
const { cols, rows } = termSize();
|
|
723
|
+
const viewH = Math.max(3, rows - 7);
|
|
724
|
+
const flat = flattenObj(screen.item);
|
|
725
|
+
const entries = Object.entries(flat);
|
|
726
|
+
const kw = entries.reduce((m, [k]) => Math.max(m, k.length), 0);
|
|
727
|
+
const lines = [renderHeader([screen.group.name, screen.label], cols, state.profile), ""];
|
|
728
|
+
const visible = entries.slice(screen.scroll, screen.scroll + viewH);
|
|
729
|
+
for (let vi = 0; vi < visible.length; vi++) {
|
|
730
|
+
const entry = visible[vi];
|
|
731
|
+
if (!entry)
|
|
732
|
+
continue;
|
|
733
|
+
const [k, v] = entry;
|
|
734
|
+
const isCursor = screen.scroll + vi === screen.cursor;
|
|
735
|
+
const isArr = v instanceof ArrayValue;
|
|
736
|
+
const valueStr = v === null || v === undefined ? dim("—") : String(v);
|
|
737
|
+
const hint = isArr && isCursor ? ` ${dim("[space] expand")}` : "";
|
|
738
|
+
const line = ` ${padEnd(cyan(k), kw + 2)} ${valueStr}${hint}`;
|
|
739
|
+
lines.push(isCursor ? inv(line.padEnd(cols - 1)) : line);
|
|
740
|
+
}
|
|
741
|
+
lines.push("");
|
|
742
|
+
if (entries.length > viewH) {
|
|
743
|
+
const hi = Math.min(screen.scroll + viewH, entries.length);
|
|
744
|
+
lines.push(` ${dim(`${screen.scroll + 1}–${hi} of ${entries.length} fields`)}`);
|
|
745
|
+
}
|
|
746
|
+
const spaceHint = entries.some(([, v]) => v instanceof ArrayValue)
|
|
747
|
+
? ` ${cyan("space/→")} expand ${cyan("←")} back`
|
|
748
|
+
: ` ${cyan("←")} back`;
|
|
749
|
+
lines.push(` ${dim("↑↓")} navigate${spaceHint} ${cyan("m")} main menu ${cyan("esc")} back ${cyan("q")} quit`);
|
|
750
|
+
return lines;
|
|
751
|
+
}
|
|
752
|
+
function renderFollowup(state, screen) {
|
|
753
|
+
const { cols, rows } = termSize();
|
|
754
|
+
const viewH = Math.max(3, rows - 8);
|
|
755
|
+
const lines = [
|
|
756
|
+
renderHeader([screen.sourceGroup.name, "follow-up actions"], cols, state.profile),
|
|
757
|
+
"",
|
|
758
|
+
];
|
|
759
|
+
// Show the fields that will be pre-filled
|
|
760
|
+
const usedFields = new Set();
|
|
761
|
+
for (const rel of screen.relations) {
|
|
762
|
+
for (const p of rel.params)
|
|
763
|
+
usedFields.add(p.field);
|
|
764
|
+
}
|
|
765
|
+
const summary = [...usedFields]
|
|
766
|
+
.map((f) => `${dim(f)}: ${String(screen.item[f] ?? "")}`)
|
|
767
|
+
.join(" ");
|
|
768
|
+
if (summary) {
|
|
769
|
+
lines.push(` ${summary}`);
|
|
770
|
+
lines.push("");
|
|
771
|
+
}
|
|
772
|
+
const visible = screen.relations.slice(screen.scroll, screen.scroll + viewH);
|
|
773
|
+
for (let vi = 0; vi < visible.length; vi++) {
|
|
774
|
+
const rel = visible[vi];
|
|
775
|
+
if (!rel)
|
|
776
|
+
continue;
|
|
777
|
+
const isCursor = screen.scroll + vi === screen.cursor;
|
|
778
|
+
const gName = padEnd(cyan(rel.group.name), 22);
|
|
779
|
+
const cName = rel.cmd.name.padEnd(26);
|
|
780
|
+
const line = ` ${gName} ${cName} ${dim(fit(rel.cmd.description, cols - 56))}`;
|
|
781
|
+
lines.push(isCursor ? inv(line.padEnd(cols - 1)) : line);
|
|
782
|
+
}
|
|
783
|
+
lines.push("");
|
|
784
|
+
if (screen.relations.length > viewH) {
|
|
785
|
+
const hi = Math.min(screen.scroll + viewH, screen.relations.length);
|
|
786
|
+
lines.push(` ${dim(`${screen.scroll + 1}–${hi} of ${screen.relations.length}`)}`);
|
|
787
|
+
}
|
|
788
|
+
lines.push(` ${dim("↑↓")} navigate ${cyan("enter")} run pre-filled ${cyan("esc")} back ${cyan("q")} quit`);
|
|
789
|
+
return lines;
|
|
790
|
+
}
|
|
791
|
+
function renderProfile(state, screen) {
|
|
792
|
+
const { cols, rows } = termSize();
|
|
793
|
+
const viewH = Math.max(3, rows - 7);
|
|
794
|
+
const kw = state.profileInfo.reduce((m, { key }) => Math.max(m, key.length), 0);
|
|
795
|
+
const lines = [renderHeader(["profile"], cols, state.profile), ""];
|
|
796
|
+
const visible = state.profileInfo.slice(screen.scroll, screen.scroll + viewH);
|
|
797
|
+
for (const { key, value } of visible) {
|
|
798
|
+
lines.push(` ${padEnd(cyan(key), kw + 2)} ${value}`);
|
|
799
|
+
}
|
|
800
|
+
lines.push("");
|
|
801
|
+
if (state.profileInfo.length > viewH) {
|
|
802
|
+
const hi = Math.min(screen.scroll + viewH, state.profileInfo.length);
|
|
803
|
+
lines.push(` ${dim(`${screen.scroll + 1}–${hi} of ${state.profileInfo.length}`)}`);
|
|
804
|
+
}
|
|
805
|
+
lines.push(` ${dim("↑↓")} scroll ${cyan("esc/p")} close ${cyan("q")} quit`);
|
|
806
|
+
return lines;
|
|
807
|
+
}
|
|
808
|
+
const SETTINGS_ROWS = [
|
|
809
|
+
{
|
|
810
|
+
kind: "number",
|
|
811
|
+
key: "auditLogSize",
|
|
812
|
+
label: "Audit Log Size",
|
|
813
|
+
description: "Number of actions to keep per profile (0 = disabled)",
|
|
814
|
+
min: 0,
|
|
815
|
+
max: 1000,
|
|
816
|
+
},
|
|
817
|
+
{
|
|
818
|
+
kind: "action",
|
|
819
|
+
id: "show-audit-log",
|
|
820
|
+
label: "Show Audit Log",
|
|
821
|
+
description: "View recent commands executed for the active profile",
|
|
822
|
+
},
|
|
823
|
+
];
|
|
824
|
+
function renderSettings(state, screen) {
|
|
825
|
+
const { cols } = termSize();
|
|
826
|
+
const settings = getSettings();
|
|
827
|
+
const labelW = SETTINGS_ROWS.reduce((m, r) => Math.max(m, r.label.length), 0) + 2;
|
|
828
|
+
const lines = [renderHeader(["settings"], cols, state.profile), ""];
|
|
829
|
+
for (let i = 0; i < SETTINGS_ROWS.length; i++) {
|
|
830
|
+
const row = SETTINGS_ROWS[i];
|
|
831
|
+
if (!row)
|
|
832
|
+
continue;
|
|
833
|
+
const isCursor = i === screen.cursor;
|
|
834
|
+
let valueStr;
|
|
835
|
+
if (row.kind === "number") {
|
|
836
|
+
valueStr =
|
|
837
|
+
screen.editing && isCursor
|
|
838
|
+
? cyan(`[${screen.editValue}_]`)
|
|
839
|
+
: bold(String(settings[row.key]));
|
|
840
|
+
}
|
|
841
|
+
else {
|
|
842
|
+
valueStr = dim("→");
|
|
843
|
+
}
|
|
844
|
+
const labelPart = padEnd(isCursor ? cyan(row.label) : row.label, labelW);
|
|
845
|
+
const content = ` ${labelPart} ${valueStr}`;
|
|
846
|
+
lines.push(isCursor ? inv(content.padEnd(cols - 1)) : content);
|
|
847
|
+
}
|
|
848
|
+
const row = SETTINGS_ROWS[screen.cursor];
|
|
849
|
+
if (row) {
|
|
850
|
+
lines.push("");
|
|
851
|
+
lines.push(` ${dim(row.description)}`);
|
|
852
|
+
}
|
|
853
|
+
lines.push("");
|
|
854
|
+
if (screen.message) {
|
|
855
|
+
lines.push(` ${screen.message}`);
|
|
856
|
+
}
|
|
857
|
+
else if (screen.editing) {
|
|
858
|
+
lines.push(` ${dim("type")} new value ${cyan("enter")} save ${cyan("esc")} cancel ${dim("bksp")} delete`);
|
|
859
|
+
}
|
|
860
|
+
else {
|
|
861
|
+
lines.push(` ${dim("↑↓")} navigate ${cyan("enter")} select ${cyan("esc")} back ${cyan("q")} quit`);
|
|
862
|
+
}
|
|
863
|
+
return lines;
|
|
864
|
+
}
|
|
865
|
+
// ─── Audit log screen ─────────────────────────────────────────────────────────
|
|
866
|
+
function renderAuditLog(state, screen) {
|
|
867
|
+
const { cols, rows } = termSize();
|
|
868
|
+
const viewH = Math.max(3, rows - 8);
|
|
869
|
+
const entries = getAuditLog(state.profile || undefined);
|
|
870
|
+
const lines = [renderHeader(["settings", "audit log"], cols, state.profile), ""];
|
|
871
|
+
if (entries.length === 0) {
|
|
872
|
+
lines.push(` ${dim(`No audit log entries for profile "${state.profile || "default"}".`)}`);
|
|
873
|
+
}
|
|
874
|
+
else {
|
|
875
|
+
const tsW = 19;
|
|
876
|
+
const stW = 5;
|
|
877
|
+
const cmdW = Math.max(20, cols - tsW - stW - 8);
|
|
878
|
+
lines.push(` ${dim(padEnd("TIME", tsW + 2))} ${dim(padEnd("COMMAND", cmdW))} ${dim("STATUS")}`);
|
|
879
|
+
lines.push(` ${dim("─".repeat(cols - 4))}`);
|
|
880
|
+
const visible = entries.slice(screen.scroll, screen.scroll + viewH);
|
|
881
|
+
for (const e of visible) {
|
|
882
|
+
const ts = dim(e.timestamp.replace("T", " ").slice(0, tsW));
|
|
883
|
+
const cmd = fit(`${e.group} ${e.command}${e.positional.length ? ` ${e.positional.join(" ")}` : ""}`, cmdW);
|
|
884
|
+
const status = e.status === "ok" ? green("ok") : red("err");
|
|
885
|
+
lines.push(` ${ts} ${padEnd(cmd, cmdW)} ${status}`);
|
|
886
|
+
}
|
|
887
|
+
if (entries.length > viewH) {
|
|
888
|
+
const hi = Math.min(screen.scroll + viewH, entries.length);
|
|
889
|
+
lines.push(`\n ${dim(`${screen.scroll + 1}–${hi} of ${entries.length}`)}`);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
lines.push(`\n ${dim("↑↓")} scroll ${cyan("esc")} back ${cyan("q")} quit`);
|
|
893
|
+
return lines;
|
|
894
|
+
}
|
|
895
|
+
function renderWorker(state, screen) {
|
|
896
|
+
const { cols, rows } = termSize();
|
|
897
|
+
const viewH = Math.max(3, rows - 13);
|
|
898
|
+
const statusStr = screen.status === "running"
|
|
899
|
+
? green("● RUNNING")
|
|
900
|
+
: screen.status === "starting"
|
|
901
|
+
? dim("◌ STARTING")
|
|
902
|
+
: screen.status === "stopping"
|
|
903
|
+
? cyan("◌ STOPPING")
|
|
904
|
+
: dim("■ STOPPED");
|
|
905
|
+
const lines = [renderHeader(["job", "worker"], cols, state.profile), ""];
|
|
906
|
+
lines.push(` ${padEnd(dim("type"), 8)} ${cyan(screen.jobType)} ${dim("status")} ${statusStr}`);
|
|
907
|
+
lines.push(` ${dim("─".repeat(cols - 4))}`);
|
|
908
|
+
const { activated, completed, failed } = screen.stats;
|
|
909
|
+
lines.push(` ${dim("activated")} ${bold(String(activated))} ${dim("completed")} ${completed > 0 ? green(String(completed)) : dim("0")} ${dim("failed")} ${failed > 0 ? red(String(failed)) : dim("0")}`);
|
|
910
|
+
lines.push(` ${dim("─".repeat(cols - 4))}`);
|
|
911
|
+
lines.push("");
|
|
912
|
+
if (screen.log.length === 0) {
|
|
913
|
+
lines.push(` ${dim("Waiting for jobs…")}`);
|
|
914
|
+
}
|
|
915
|
+
else {
|
|
916
|
+
const logLines = screen.log.map((e) => {
|
|
917
|
+
const ts = dim(e.ts);
|
|
918
|
+
const msg = e.level === "ok"
|
|
919
|
+
? `${green("✓")} ${e.text}`
|
|
920
|
+
: e.level === "err"
|
|
921
|
+
? `${red("✗")} ${e.text}`
|
|
922
|
+
: ` ${dim(e.text)}`;
|
|
923
|
+
return ` ${ts} ${msg}`;
|
|
924
|
+
});
|
|
925
|
+
const visible = logLines.slice(screen.scroll, screen.scroll + viewH);
|
|
926
|
+
for (const l of visible)
|
|
927
|
+
lines.push(fit(l, cols - 2));
|
|
928
|
+
if (screen.log.length > viewH) {
|
|
929
|
+
const hi = Math.min(screen.scroll + viewH, screen.log.length);
|
|
930
|
+
lines.push(` ${dim(`${screen.scroll + 1}–${hi} of ${screen.log.length}`)}`);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
const isRunning = screen.status === "running" || screen.status === "starting";
|
|
934
|
+
const autoHint = ` ${screen.autoScroll ? cyan("a") : dim("a")} ${dim("auto")}`;
|
|
935
|
+
const navHint = isRunning
|
|
936
|
+
? ` ${cyan("s")} ${dim("stop")}`
|
|
937
|
+
: ` ${cyan("esc")} back ${cyan("m")} main menu`;
|
|
938
|
+
lines.push(`\n ${dim("↑↓")} scroll${autoHint}${navHint} ${cyan("q")} quit`);
|
|
939
|
+
return lines;
|
|
940
|
+
}
|
|
941
|
+
function renderJsonEditor(state, screen) {
|
|
942
|
+
const { cols, rows } = termSize();
|
|
943
|
+
// Reserve extra row for field hint line
|
|
944
|
+
const viewH = Math.max(3, rows - 10);
|
|
945
|
+
const keyW = Math.max(14, Math.min(28, Math.floor((cols - 12) * 0.38)));
|
|
946
|
+
const valW = Math.max(16, cols - keyW - 12);
|
|
947
|
+
const lines = [
|
|
948
|
+
renderHeader([screen.group.name, screen.cmd.name, `--${screen.fieldLabel}`], cols, state.profile),
|
|
949
|
+
"",
|
|
950
|
+
];
|
|
951
|
+
lines.push(` ${padEnd(dim("KEY"), keyW + 4)}${dim("VALUE")}`);
|
|
952
|
+
lines.push(` ${dim("─".repeat(cols - 4))}`);
|
|
953
|
+
// entries + add-row
|
|
954
|
+
const rowCount = screen.entries.length + 1;
|
|
955
|
+
const visible = Math.min(viewH, rowCount);
|
|
956
|
+
for (let vi = 0; vi < visible; vi++) {
|
|
957
|
+
const absIdx = screen.scroll + vi;
|
|
958
|
+
const isCursor = absIdx === screen.cursor;
|
|
959
|
+
const marker = isCursor ? cyan("▶") : " ";
|
|
960
|
+
if (absIdx === screen.entries.length) {
|
|
961
|
+
// "add" row
|
|
962
|
+
const label = `${isCursor ? cyan("+ add field") : dim("+ add field")}`;
|
|
963
|
+
lines.push(isCursor ? inv(` ${marker} ${label}`.padEnd(cols - 1)) : ` ${marker} ${label}`);
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
const entry = screen.entries[absIdx];
|
|
967
|
+
if (!entry)
|
|
968
|
+
continue;
|
|
969
|
+
const spec = getFieldSpec(entry.key, screen.fieldSpecs);
|
|
970
|
+
const isKnown = spec !== undefined;
|
|
971
|
+
const hasEnum = spec?.enum && spec.enum.length > 0;
|
|
972
|
+
const editKey = isCursor && screen.col === "key" && screen.editing;
|
|
973
|
+
const editVal = isCursor && screen.col === "val" && screen.editing;
|
|
974
|
+
const keyStr = renderText(entry.key, entry.keyCursor, keyW - (isKnown ? 0 : 0), editKey);
|
|
975
|
+
// For known enum fields in nav mode, show cycling hint instead of raw value
|
|
976
|
+
const valDisplay = !editVal && hasEnum && entry.val
|
|
977
|
+
? `${entry.val} ${dim("↑↓")}`
|
|
978
|
+
: !editVal && hasEnum && !entry.val
|
|
979
|
+
? dim("<pick ↑↓>")
|
|
980
|
+
: renderText(entry.val, entry.valCursor, valW - 4, editVal);
|
|
981
|
+
const valStr = valDisplay;
|
|
982
|
+
const keyPart = isCursor && screen.col === "key" && !editKey
|
|
983
|
+
? cyan(padEnd(keyStr, keyW))
|
|
984
|
+
: isKnown
|
|
985
|
+
? padEnd(keyStr, keyW)
|
|
986
|
+
: dim(padEnd(keyStr, keyW));
|
|
987
|
+
const valPart = isCursor && screen.col === "val" && !editVal
|
|
988
|
+
? cyan(padEnd(valStr, valW))
|
|
989
|
+
: padEnd(valStr, valW);
|
|
990
|
+
const line = ` ${marker} ${keyPart} ${valPart}`;
|
|
991
|
+
lines.push(isCursor && !screen.editing ? inv(line.padEnd(cols - 1)) : line);
|
|
992
|
+
}
|
|
993
|
+
lines.push("");
|
|
994
|
+
// Field hint line: show description/type for the row under the cursor
|
|
995
|
+
const cursorEntry = screen.entries[screen.cursor];
|
|
996
|
+
const cursorSpec = cursorEntry ? getFieldSpec(cursorEntry.key, screen.fieldSpecs) : undefined;
|
|
997
|
+
if (cursorSpec) {
|
|
998
|
+
const req = cursorSpec.required ? red("required") : dim("optional");
|
|
999
|
+
const typeStr = cursorSpec.enum
|
|
1000
|
+
? `enum(${cursorSpec.enum.slice(0, 3).join("|")}${cursorSpec.enum.length > 3 ? "…" : ""})`
|
|
1001
|
+
: cursorSpec.type;
|
|
1002
|
+
const desc = cursorSpec.description ? ` — ${fit(cursorSpec.description, 40)}` : "";
|
|
1003
|
+
lines.push(` ${dim(typeStr)} ${req}${desc}`);
|
|
1004
|
+
}
|
|
1005
|
+
else {
|
|
1006
|
+
lines.push("");
|
|
1007
|
+
}
|
|
1008
|
+
if (rowCount > viewH) {
|
|
1009
|
+
const hi = Math.min(screen.scroll + viewH, rowCount);
|
|
1010
|
+
lines.push(` ${dim(`${screen.scroll + 1}–${hi} of ${rowCount}`)}`);
|
|
1011
|
+
}
|
|
1012
|
+
if (screen.error) {
|
|
1013
|
+
lines.push(` ${red("error:")} ${screen.error}`);
|
|
1014
|
+
}
|
|
1015
|
+
else if (screen.editing) {
|
|
1016
|
+
const cursorEntryEditing = screen.entries[screen.cursor];
|
|
1017
|
+
const editSpec = screen.col === "val" && cursorEntryEditing
|
|
1018
|
+
? getFieldSpec(cursorEntryEditing.key, screen.fieldSpecs)
|
|
1019
|
+
: undefined;
|
|
1020
|
+
const editHint = editSpec?.enum
|
|
1021
|
+
? `${dim("↑↓")} pick value ${cyan("enter")} confirm ${cyan("esc")} cancel`
|
|
1022
|
+
: `${dim("←→")} cursor ${cyan("tab")} switch col ${cyan("enter")} confirm ${cyan("esc")} cancel`;
|
|
1023
|
+
lines.push(` ${editHint}`);
|
|
1024
|
+
}
|
|
1025
|
+
else {
|
|
1026
|
+
lines.push(` ${dim("↑↓")} navigate ${cyan("tab")} switch col ${cyan("enter")} edit ${cyan("a")} add ${cyan("d")} del ${cyan("esc")} save ${cyan("q")} quit`);
|
|
1027
|
+
}
|
|
1028
|
+
return lines;
|
|
1029
|
+
}
|
|
1030
|
+
// ─── Worker view helpers ──────────────────────────────────────────────────────
|
|
1031
|
+
function workerLogH() {
|
|
1032
|
+
return Math.max(3, (process.stdout.rows ?? 24) - 13);
|
|
1033
|
+
}
|
|
1034
|
+
function addWorkerLog(ws, level, text) {
|
|
1035
|
+
const now = new Date();
|
|
1036
|
+
const ts = [now.getHours(), now.getMinutes(), now.getSeconds()]
|
|
1037
|
+
.map((n) => String(n).padStart(2, "0"))
|
|
1038
|
+
.join(":");
|
|
1039
|
+
ws.log.push({ ts, level, text });
|
|
1040
|
+
if (ws.log.length > 500)
|
|
1041
|
+
ws.log.shift();
|
|
1042
|
+
if (ws.autoScroll)
|
|
1043
|
+
ws.scroll = Math.max(0, ws.log.length - workerLogH());
|
|
1044
|
+
}
|
|
1045
|
+
function stopWorkerScreen(ws) {
|
|
1046
|
+
if (ws._timer !== null) {
|
|
1047
|
+
clearInterval(ws._timer);
|
|
1048
|
+
ws._timer = null;
|
|
1049
|
+
}
|
|
1050
|
+
if (ws._stop) {
|
|
1051
|
+
ws._stop();
|
|
1052
|
+
ws._stop = null;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
async function runWorkerLoop(ws, state, variables, jobTimeout, maxJobs) {
|
|
1056
|
+
let client;
|
|
1057
|
+
try {
|
|
1058
|
+
client = await state.getClient();
|
|
1059
|
+
}
|
|
1060
|
+
catch (err) {
|
|
1061
|
+
addWorkerLog(ws, "err", `Client error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1062
|
+
ws.status = "stopped";
|
|
1063
|
+
stopWorkerScreen(ws);
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
let running = true;
|
|
1067
|
+
ws._stop = () => {
|
|
1068
|
+
running = false;
|
|
1069
|
+
ws.status = "stopping";
|
|
1070
|
+
ws._stop = null;
|
|
1071
|
+
};
|
|
1072
|
+
ws.status = "running";
|
|
1073
|
+
addWorkerLog(ws, "info", `Worker started for type "${ws.jobType}"`);
|
|
1074
|
+
addWorkerLog(ws, "info", `Returning: ${JSON.stringify(variables)}`);
|
|
1075
|
+
while (running) {
|
|
1076
|
+
let result;
|
|
1077
|
+
try {
|
|
1078
|
+
result = (await client.job.activateJobs({
|
|
1079
|
+
type: ws.jobType,
|
|
1080
|
+
worker: "casen-worker",
|
|
1081
|
+
timeout: jobTimeout,
|
|
1082
|
+
maxJobsToActivate: maxJobs,
|
|
1083
|
+
requestTimeout: 20000,
|
|
1084
|
+
}));
|
|
1085
|
+
}
|
|
1086
|
+
catch (err) {
|
|
1087
|
+
if (!running)
|
|
1088
|
+
break;
|
|
1089
|
+
addWorkerLog(ws, "err", `Poll error: ${err instanceof Error ? err.message : String(err)} — retry in 5s`);
|
|
1090
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
1091
|
+
continue;
|
|
1092
|
+
}
|
|
1093
|
+
const jobs = result?.jobs ?? [];
|
|
1094
|
+
for (const job of jobs) {
|
|
1095
|
+
if (!running)
|
|
1096
|
+
break;
|
|
1097
|
+
ws.stats.activated++;
|
|
1098
|
+
addWorkerLog(ws, "info", `Activated ${job.jobKey} process=${job.processDefinitionId} element=${job.elementId}`);
|
|
1099
|
+
try {
|
|
1100
|
+
await client.job.completeJob(job.jobKey, { variables });
|
|
1101
|
+
ws.stats.completed++;
|
|
1102
|
+
addWorkerLog(ws, "ok", `Completed ${job.jobKey}`);
|
|
1103
|
+
}
|
|
1104
|
+
catch (err) {
|
|
1105
|
+
ws.stats.failed++;
|
|
1106
|
+
addWorkerLog(ws, "err", `Failed to complete ${job.jobKey}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
if (jobs.length > 0)
|
|
1110
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1111
|
+
}
|
|
1112
|
+
ws.status = "stopped";
|
|
1113
|
+
addWorkerLog(ws, "info", `Stopped. Activated: ${ws.stats.activated} Completed: ${ws.stats.completed} Failed: ${ws.stats.failed}`);
|
|
1114
|
+
stopWorkerScreen(ws);
|
|
1115
|
+
}
|
|
1116
|
+
function launchWorkerView(inputScreen, state) {
|
|
1117
|
+
const typeArg = inputScreen.fields.find((f) => f.kind === "arg");
|
|
1118
|
+
const jobType = typeArg?.value?.trim() ?? "";
|
|
1119
|
+
if (!jobType) {
|
|
1120
|
+
inputScreen.error = '"type" is required';
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
let variables = { result: "sample-value" };
|
|
1124
|
+
const varField = inputScreen.fields.find((f) => f.label === "--variables");
|
|
1125
|
+
if (varField?.value) {
|
|
1126
|
+
try {
|
|
1127
|
+
const parsed = JSON.parse(varField.value);
|
|
1128
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1129
|
+
variables = parsed;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
catch {
|
|
1133
|
+
// use default
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
const timeoutField = inputScreen.fields.find((f) => f.label === "--timeout");
|
|
1137
|
+
const workerTimeout = timeoutField?.value ? Number(timeoutField.value) : 30000;
|
|
1138
|
+
const maxJobsField = inputScreen.fields.find((f) => f.label === "--max-jobs");
|
|
1139
|
+
const maxJobs = maxJobsField?.value ? Number(maxJobsField.value) : 32;
|
|
1140
|
+
const ws = {
|
|
1141
|
+
kind: "worker",
|
|
1142
|
+
group: inputScreen.group,
|
|
1143
|
+
cmd: inputScreen.cmd,
|
|
1144
|
+
jobType,
|
|
1145
|
+
status: "starting",
|
|
1146
|
+
stats: { activated: 0, completed: 0, failed: 0 },
|
|
1147
|
+
log: [],
|
|
1148
|
+
scroll: 0,
|
|
1149
|
+
autoScroll: true,
|
|
1150
|
+
_stop: null,
|
|
1151
|
+
_timer: null,
|
|
1152
|
+
};
|
|
1153
|
+
state.stack.push(ws);
|
|
1154
|
+
render(state);
|
|
1155
|
+
// Re-render at 200 ms so live updates appear without waiting for key presses
|
|
1156
|
+
ws._timer = setInterval(() => {
|
|
1157
|
+
if (!state.quitting && state.stack.includes(ws)) {
|
|
1158
|
+
render(state);
|
|
1159
|
+
}
|
|
1160
|
+
else {
|
|
1161
|
+
if (ws._timer !== null) {
|
|
1162
|
+
clearInterval(ws._timer);
|
|
1163
|
+
ws._timer = null;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}, 200);
|
|
1167
|
+
// Fire-and-forget: the loop writes to ws state; the timer re-renders
|
|
1168
|
+
runWorkerLoop(ws, state, variables, workerTimeout, maxJobs).catch((err) => {
|
|
1169
|
+
addWorkerLog(ws, "err", `Unexpected: ${err instanceof Error ? err.message : String(err)}`);
|
|
1170
|
+
ws.status = "stopped";
|
|
1171
|
+
stopWorkerScreen(ws);
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
function render(state) {
|
|
1175
|
+
if (state.quitting)
|
|
1176
|
+
return;
|
|
1177
|
+
const screen = state.stack[state.stack.length - 1];
|
|
1178
|
+
if (!screen)
|
|
1179
|
+
return;
|
|
1180
|
+
let lines;
|
|
1181
|
+
switch (screen.kind) {
|
|
1182
|
+
case "main":
|
|
1183
|
+
lines = renderMain(state, screen);
|
|
1184
|
+
break;
|
|
1185
|
+
case "commands":
|
|
1186
|
+
lines = renderCommands(state, screen);
|
|
1187
|
+
break;
|
|
1188
|
+
case "input":
|
|
1189
|
+
lines = renderInput(state, screen);
|
|
1190
|
+
break;
|
|
1191
|
+
case "results":
|
|
1192
|
+
lines = renderResults(state, screen);
|
|
1193
|
+
break;
|
|
1194
|
+
case "detail":
|
|
1195
|
+
lines = renderDetail(state, screen);
|
|
1196
|
+
break;
|
|
1197
|
+
case "followup":
|
|
1198
|
+
lines = renderFollowup(state, screen);
|
|
1199
|
+
break;
|
|
1200
|
+
case "profile":
|
|
1201
|
+
lines = renderProfile(state, screen);
|
|
1202
|
+
break;
|
|
1203
|
+
case "settings":
|
|
1204
|
+
lines = renderSettings(state, screen);
|
|
1205
|
+
break;
|
|
1206
|
+
case "audit-log":
|
|
1207
|
+
lines = renderAuditLog(state, screen);
|
|
1208
|
+
break;
|
|
1209
|
+
case "worker":
|
|
1210
|
+
lines = renderWorker(state, screen);
|
|
1211
|
+
break;
|
|
1212
|
+
case "json-editor":
|
|
1213
|
+
lines = renderJsonEditor(state, screen);
|
|
1214
|
+
break;
|
|
1215
|
+
}
|
|
1216
|
+
process.stdout.write(`${CLEAR}${lines.join("\n")}\n`);
|
|
1217
|
+
}
|
|
1218
|
+
// ─── Key handlers ─────────────────────────────────────────────────────────────
|
|
1219
|
+
function handleMainKey(key, screen, state, done) {
|
|
1220
|
+
const { rows } = termSize();
|
|
1221
|
+
const searching = screen.search.length > 0;
|
|
1222
|
+
const viewH = Math.max(3, rows - (searching ? 9 : 7));
|
|
1223
|
+
const groups = filterGroups(state.groups, screen.search);
|
|
1224
|
+
// Backspace removes last search char
|
|
1225
|
+
if (key === "\x7f" || key === "\x08") {
|
|
1226
|
+
screen.search = screen.search.slice(0, -1);
|
|
1227
|
+
screen.cursor = 0;
|
|
1228
|
+
screen.scroll = 0;
|
|
1229
|
+
render(state);
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
// Ctrl+C quits unconditionally
|
|
1233
|
+
if (key === "\x03") {
|
|
1234
|
+
done();
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
// ESC clears search; quits only when search is already empty
|
|
1238
|
+
if (key === "\x1b") {
|
|
1239
|
+
if (searching) {
|
|
1240
|
+
screen.search = "";
|
|
1241
|
+
screen.cursor = 0;
|
|
1242
|
+
screen.scroll = 0;
|
|
1243
|
+
}
|
|
1244
|
+
else {
|
|
1245
|
+
done();
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
render(state);
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
switch (key) {
|
|
1252
|
+
case "\x1b[A": // up
|
|
1253
|
+
if (screen.cursor > 0) {
|
|
1254
|
+
screen.cursor--;
|
|
1255
|
+
if (screen.cursor < screen.scroll)
|
|
1256
|
+
screen.scroll--;
|
|
1257
|
+
}
|
|
1258
|
+
break;
|
|
1259
|
+
case "\x1b[B": // down
|
|
1260
|
+
if (screen.cursor < groups.length - 1) {
|
|
1261
|
+
screen.cursor++;
|
|
1262
|
+
if (screen.cursor >= screen.scroll + viewH)
|
|
1263
|
+
screen.scroll++;
|
|
1264
|
+
}
|
|
1265
|
+
break;
|
|
1266
|
+
case "\x1b[5~": // page up
|
|
1267
|
+
screen.cursor = Math.max(0, screen.cursor - viewH);
|
|
1268
|
+
screen.scroll = Math.max(0, screen.scroll - viewH);
|
|
1269
|
+
break;
|
|
1270
|
+
case "\x1b[6~": // page down
|
|
1271
|
+
screen.cursor = Math.min(groups.length - 1, screen.cursor + viewH);
|
|
1272
|
+
screen.scroll = Math.min(Math.max(0, groups.length - viewH), screen.scroll + viewH);
|
|
1273
|
+
break;
|
|
1274
|
+
case "\r":
|
|
1275
|
+
case "\n": {
|
|
1276
|
+
const group = groups[screen.cursor];
|
|
1277
|
+
if (group?.name === "settings") {
|
|
1278
|
+
state.stack.push({
|
|
1279
|
+
kind: "settings",
|
|
1280
|
+
cursor: 0,
|
|
1281
|
+
editing: false,
|
|
1282
|
+
editValue: "",
|
|
1283
|
+
message: "",
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
else if (group) {
|
|
1287
|
+
state.stack.push({ kind: "commands", group, cursor: 0, search: "" });
|
|
1288
|
+
}
|
|
1289
|
+
break;
|
|
1290
|
+
}
|
|
1291
|
+
default:
|
|
1292
|
+
// Any printable char starts/extends search
|
|
1293
|
+
if (key.length === 1 && key >= " ") {
|
|
1294
|
+
screen.search += key;
|
|
1295
|
+
screen.cursor = 0;
|
|
1296
|
+
screen.scroll = 0;
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
render(state);
|
|
1300
|
+
}
|
|
1301
|
+
function handleCommandsKey(key, screen, state, done) {
|
|
1302
|
+
const searching = screen.search.length > 0;
|
|
1303
|
+
const cmds = filterCommands(screen.group.commands, screen.search);
|
|
1304
|
+
// Backspace removes last search char
|
|
1305
|
+
if (key === "\x7f" || key === "\x08") {
|
|
1306
|
+
screen.search = screen.search.slice(0, -1);
|
|
1307
|
+
screen.cursor = 0;
|
|
1308
|
+
render(state);
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
// Ctrl+C quits unconditionally
|
|
1312
|
+
if (key === "\x03") {
|
|
1313
|
+
done();
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
// ESC clears search; goes back when search already empty
|
|
1317
|
+
if (key === "\x1b") {
|
|
1318
|
+
if (searching) {
|
|
1319
|
+
screen.search = "";
|
|
1320
|
+
screen.cursor = 0;
|
|
1321
|
+
}
|
|
1322
|
+
else {
|
|
1323
|
+
state.stack.pop();
|
|
1324
|
+
}
|
|
1325
|
+
render(state);
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
switch (key) {
|
|
1329
|
+
case "\x1b[A":
|
|
1330
|
+
if (screen.cursor > 0)
|
|
1331
|
+
screen.cursor--;
|
|
1332
|
+
break;
|
|
1333
|
+
case "\x1b[B":
|
|
1334
|
+
if (screen.cursor < cmds.length - 1)
|
|
1335
|
+
screen.cursor++;
|
|
1336
|
+
break;
|
|
1337
|
+
case "\r":
|
|
1338
|
+
case "\n": {
|
|
1339
|
+
const cmd = cmds[screen.cursor];
|
|
1340
|
+
if (cmd) {
|
|
1341
|
+
state.stack.push({
|
|
1342
|
+
kind: "input",
|
|
1343
|
+
group: screen.group,
|
|
1344
|
+
cmd,
|
|
1345
|
+
fields: buildFields(cmd),
|
|
1346
|
+
cursor: 0,
|
|
1347
|
+
scroll: 0,
|
|
1348
|
+
editing: false,
|
|
1349
|
+
error: "",
|
|
1350
|
+
running: false,
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
break;
|
|
1354
|
+
}
|
|
1355
|
+
default:
|
|
1356
|
+
if (key === "m" || key === "M") {
|
|
1357
|
+
if (!searching) {
|
|
1358
|
+
popToMain(state);
|
|
1359
|
+
break;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
// Any printable char starts/extends search
|
|
1363
|
+
if (key.length === 1 && key >= " ") {
|
|
1364
|
+
screen.search += key;
|
|
1365
|
+
screen.cursor = 0;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
render(state);
|
|
1369
|
+
}
|
|
1370
|
+
async function executeCommand(screen, state) {
|
|
1371
|
+
for (const f of screen.fields) {
|
|
1372
|
+
if (f.required && !f.value) {
|
|
1373
|
+
screen.error = `"${f.label}" is required`;
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
screen.running = true;
|
|
1378
|
+
screen.error = "";
|
|
1379
|
+
render(state);
|
|
1380
|
+
const { writer, get } = makeCapturingWriter();
|
|
1381
|
+
// Wrap client factories to capture the last raw HTTP response
|
|
1382
|
+
let rawCapture = null;
|
|
1383
|
+
const getClient = async () => {
|
|
1384
|
+
const client = await state.getClient();
|
|
1385
|
+
client.on("rawResponse", (evt) => {
|
|
1386
|
+
rawCapture = evt;
|
|
1387
|
+
});
|
|
1388
|
+
return client;
|
|
1389
|
+
};
|
|
1390
|
+
const getAdminClient = async () => {
|
|
1391
|
+
const client = await state.getAdminClient();
|
|
1392
|
+
client.on("rawResponse", (evt) => {
|
|
1393
|
+
rawCapture = evt;
|
|
1394
|
+
});
|
|
1395
|
+
return client;
|
|
1396
|
+
};
|
|
1397
|
+
const ctx = buildContext(screen.cmd, screen.fields, writer, getClient, getAdminClient);
|
|
1398
|
+
const SECRET_FLAG_RE = /secret|password|token/i;
|
|
1399
|
+
const auditFlags = {};
|
|
1400
|
+
for (const [k, v] of Object.entries(ctx.flags)) {
|
|
1401
|
+
auditFlags[k] = SECRET_FLAG_RE.test(k) ? "***" : v;
|
|
1402
|
+
}
|
|
1403
|
+
try {
|
|
1404
|
+
await screen.cmd.run(ctx);
|
|
1405
|
+
appendAuditEntry(state.profile || "default", {
|
|
1406
|
+
group: screen.group.name,
|
|
1407
|
+
command: screen.cmd.name,
|
|
1408
|
+
positional: ctx.positional,
|
|
1409
|
+
flags: auditFlags,
|
|
1410
|
+
status: "ok",
|
|
1411
|
+
});
|
|
1412
|
+
const output = get();
|
|
1413
|
+
// Try to generate ASCII art for BPMN XML output
|
|
1414
|
+
if (output.type === "messages" && output.lines.length > 0) {
|
|
1415
|
+
const xml = output.lines.join("\n");
|
|
1416
|
+
if (xml.includes("<?xml") && (xml.includes("bpmn:") || xml.includes("definitions"))) {
|
|
1417
|
+
try {
|
|
1418
|
+
const ascii = renderBpmnAscii(xml);
|
|
1419
|
+
output.altLines = ascii.split("\n");
|
|
1420
|
+
}
|
|
1421
|
+
catch {
|
|
1422
|
+
// ASCII rendering failed — no alt view
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
state.stack.push({
|
|
1427
|
+
kind: "results",
|
|
1428
|
+
group: screen.group,
|
|
1429
|
+
cmd: screen.cmd,
|
|
1430
|
+
output,
|
|
1431
|
+
raw: rawCapture,
|
|
1432
|
+
rawView: false,
|
|
1433
|
+
curlView: false,
|
|
1434
|
+
altView: false,
|
|
1435
|
+
cursor: 0,
|
|
1436
|
+
scroll: 0,
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
catch (err) {
|
|
1440
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1441
|
+
appendAuditEntry(state.profile || "default", {
|
|
1442
|
+
group: screen.group.name,
|
|
1443
|
+
command: screen.cmd.name,
|
|
1444
|
+
positional: ctx.positional,
|
|
1445
|
+
flags: auditFlags,
|
|
1446
|
+
status: "error",
|
|
1447
|
+
error: msg,
|
|
1448
|
+
});
|
|
1449
|
+
screen.error = msg;
|
|
1450
|
+
}
|
|
1451
|
+
finally {
|
|
1452
|
+
screen.running = false;
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
async function handleInputKey(key, screen, state, done) {
|
|
1456
|
+
const { rows } = termSize();
|
|
1457
|
+
const viewH = Math.max(3, rows - 9);
|
|
1458
|
+
const field = screen.fields[screen.cursor];
|
|
1459
|
+
// ── Edit mode ────────────────────────────────────────────────────────────
|
|
1460
|
+
if (screen.editing && field && field.kind !== "run") {
|
|
1461
|
+
const enumVals = getEnum(field);
|
|
1462
|
+
const presets = getPresets(field);
|
|
1463
|
+
// ── Enum field: only cycle, no free text ──────────────────────────────
|
|
1464
|
+
if (enumVals) {
|
|
1465
|
+
const idx = enumVals.indexOf(field.value);
|
|
1466
|
+
switch (key) {
|
|
1467
|
+
case "\x1b[A": // up — cycle backward
|
|
1468
|
+
field.value = enumVals[idx <= 0 ? enumVals.length - 1 : idx - 1] ?? field.value;
|
|
1469
|
+
break;
|
|
1470
|
+
case "\x1b[B": // down — cycle forward
|
|
1471
|
+
field.value = enumVals[idx < 0 || idx >= enumVals.length - 1 ? 0 : idx + 1] ?? field.value;
|
|
1472
|
+
break;
|
|
1473
|
+
case "\r":
|
|
1474
|
+
case "\n":
|
|
1475
|
+
screen.editing = false;
|
|
1476
|
+
if (screen.cursor < screen.fields.length - 1) {
|
|
1477
|
+
screen.cursor++;
|
|
1478
|
+
if (screen.cursor >= screen.scroll + viewH)
|
|
1479
|
+
screen.scroll++;
|
|
1480
|
+
}
|
|
1481
|
+
break;
|
|
1482
|
+
case "\x1b":
|
|
1483
|
+
screen.editing = false;
|
|
1484
|
+
break;
|
|
1485
|
+
}
|
|
1486
|
+
render(state);
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
// ── Preset number field: ↑↓ cycle presets, free text still works ─────
|
|
1490
|
+
if (presets) {
|
|
1491
|
+
if (key === "\x1b[A") {
|
|
1492
|
+
// up — previous preset (smaller)
|
|
1493
|
+
const cur = Number(field.value);
|
|
1494
|
+
const prev = [...presets].reverse().find((p) => p < cur) ?? presets[presets.length - 1];
|
|
1495
|
+
if (prev !== undefined) {
|
|
1496
|
+
field.value = String(prev);
|
|
1497
|
+
field.cursor = field.value.length;
|
|
1498
|
+
}
|
|
1499
|
+
render(state);
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
if (key === "\x1b[B") {
|
|
1503
|
+
// down — next preset (larger)
|
|
1504
|
+
const cur = Number(field.value);
|
|
1505
|
+
const next = presets.find((p) => p > cur) ?? presets[0];
|
|
1506
|
+
if (next !== undefined) {
|
|
1507
|
+
field.value = String(next);
|
|
1508
|
+
field.cursor = field.value.length;
|
|
1509
|
+
}
|
|
1510
|
+
render(state);
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
// Fall through to normal text editing for all other keys
|
|
1514
|
+
}
|
|
1515
|
+
// ── Normal text editing ───────────────────────────────────────────────
|
|
1516
|
+
switch (key) {
|
|
1517
|
+
case "\r":
|
|
1518
|
+
case "\n":
|
|
1519
|
+
screen.editing = false;
|
|
1520
|
+
if (screen.cursor < screen.fields.length - 1) {
|
|
1521
|
+
screen.cursor++;
|
|
1522
|
+
if (screen.cursor >= screen.scroll + viewH)
|
|
1523
|
+
screen.scroll++;
|
|
1524
|
+
}
|
|
1525
|
+
break;
|
|
1526
|
+
case "\x1b":
|
|
1527
|
+
screen.editing = false;
|
|
1528
|
+
break;
|
|
1529
|
+
case "\x1b[D": // left
|
|
1530
|
+
if (field.cursor > 0)
|
|
1531
|
+
field.cursor--;
|
|
1532
|
+
break;
|
|
1533
|
+
case "\x1b[C": // right
|
|
1534
|
+
if (field.cursor < field.value.length)
|
|
1535
|
+
field.cursor++;
|
|
1536
|
+
break;
|
|
1537
|
+
case "\x7f": // backspace
|
|
1538
|
+
if (field.cursor > 0) {
|
|
1539
|
+
field.value = field.value.slice(0, field.cursor - 1) + field.value.slice(field.cursor);
|
|
1540
|
+
field.cursor--;
|
|
1541
|
+
}
|
|
1542
|
+
break;
|
|
1543
|
+
case "\x1b[3~": // delete
|
|
1544
|
+
field.value = field.value.slice(0, field.cursor) + field.value.slice(field.cursor + 1);
|
|
1545
|
+
break;
|
|
1546
|
+
case "\x01": // Ctrl+A / Home
|
|
1547
|
+
case "\x1b[H":
|
|
1548
|
+
field.cursor = 0;
|
|
1549
|
+
break;
|
|
1550
|
+
case "\x05": // Ctrl+E / End
|
|
1551
|
+
case "\x1b[F":
|
|
1552
|
+
field.cursor = field.value.length;
|
|
1553
|
+
break;
|
|
1554
|
+
case "\x0b": // Ctrl+K — clear to end
|
|
1555
|
+
field.value = field.value.slice(0, field.cursor);
|
|
1556
|
+
break;
|
|
1557
|
+
case "\x15": // Ctrl+U — clear line
|
|
1558
|
+
field.value = "";
|
|
1559
|
+
field.cursor = 0;
|
|
1560
|
+
break;
|
|
1561
|
+
default: {
|
|
1562
|
+
// Filter printable chars to support both single keypresses and pasted text
|
|
1563
|
+
const printable = [...key].filter((ch) => ch >= " ").join("");
|
|
1564
|
+
if (printable) {
|
|
1565
|
+
field.value =
|
|
1566
|
+
field.value.slice(0, field.cursor) + printable + field.value.slice(field.cursor);
|
|
1567
|
+
field.cursor += printable.length;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
render(state);
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
// ── Navigation mode ───────────────────────────────────────────────────────
|
|
1575
|
+
switch (key) {
|
|
1576
|
+
case "\x1b[A": // up
|
|
1577
|
+
if (screen.cursor > 0) {
|
|
1578
|
+
screen.cursor--;
|
|
1579
|
+
if (screen.cursor < screen.scroll)
|
|
1580
|
+
screen.scroll--;
|
|
1581
|
+
}
|
|
1582
|
+
break;
|
|
1583
|
+
case "\x1b[B": // down
|
|
1584
|
+
if (screen.cursor < screen.fields.length - 1) {
|
|
1585
|
+
screen.cursor++;
|
|
1586
|
+
if (screen.cursor >= screen.scroll + viewH)
|
|
1587
|
+
screen.scroll++;
|
|
1588
|
+
}
|
|
1589
|
+
break;
|
|
1590
|
+
case "\r":
|
|
1591
|
+
case "\n":
|
|
1592
|
+
if (!field)
|
|
1593
|
+
break;
|
|
1594
|
+
if (field.kind === "run") {
|
|
1595
|
+
if (screen.cmd.name === "worker") {
|
|
1596
|
+
launchWorkerView(screen, state);
|
|
1597
|
+
}
|
|
1598
|
+
else {
|
|
1599
|
+
await executeCommand(screen, state);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
else if (isJsonField(field)) {
|
|
1603
|
+
// Open the key-value JSON editor
|
|
1604
|
+
const jsonFieldSpecs = field.kind === "flag" ? field.flagSpec?.fields : undefined;
|
|
1605
|
+
state.stack.push({
|
|
1606
|
+
kind: "json-editor",
|
|
1607
|
+
group: screen.group,
|
|
1608
|
+
cmd: screen.cmd,
|
|
1609
|
+
fieldIndex: screen.cursor,
|
|
1610
|
+
fieldLabel: field.label,
|
|
1611
|
+
entries: buildInitialEntries(field.value, jsonFieldSpecs),
|
|
1612
|
+
fieldSpecs: jsonFieldSpecs,
|
|
1613
|
+
cursor: 0,
|
|
1614
|
+
col: "val",
|
|
1615
|
+
editing: false,
|
|
1616
|
+
scroll: 0,
|
|
1617
|
+
error: "",
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
else {
|
|
1621
|
+
screen.editing = true;
|
|
1622
|
+
// For enum fields, set to first value if currently empty
|
|
1623
|
+
const enumVals = getEnum(field);
|
|
1624
|
+
if (enumVals && !field.value)
|
|
1625
|
+
field.value = enumVals[0] ?? "";
|
|
1626
|
+
field.cursor = field.value.length;
|
|
1627
|
+
}
|
|
1628
|
+
break;
|
|
1629
|
+
case "\x1b[C": // right arrow also opens JSON editor
|
|
1630
|
+
if (field && isJsonField(field)) {
|
|
1631
|
+
const jsonFieldSpecs2 = field.kind === "flag" ? field.flagSpec?.fields : undefined;
|
|
1632
|
+
state.stack.push({
|
|
1633
|
+
kind: "json-editor",
|
|
1634
|
+
group: screen.group,
|
|
1635
|
+
cmd: screen.cmd,
|
|
1636
|
+
fieldIndex: screen.cursor,
|
|
1637
|
+
fieldLabel: field.label,
|
|
1638
|
+
entries: buildInitialEntries(field.value, jsonFieldSpecs2),
|
|
1639
|
+
fieldSpecs: jsonFieldSpecs2,
|
|
1640
|
+
cursor: 0,
|
|
1641
|
+
col: "val",
|
|
1642
|
+
editing: false,
|
|
1643
|
+
scroll: 0,
|
|
1644
|
+
error: "",
|
|
1645
|
+
});
|
|
1646
|
+
}
|
|
1647
|
+
break;
|
|
1648
|
+
case "m":
|
|
1649
|
+
case "M":
|
|
1650
|
+
popToMain(state);
|
|
1651
|
+
break;
|
|
1652
|
+
case "\x1b":
|
|
1653
|
+
state.stack.pop();
|
|
1654
|
+
break;
|
|
1655
|
+
case "q":
|
|
1656
|
+
case "Q":
|
|
1657
|
+
done();
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
render(state);
|
|
1661
|
+
}
|
|
1662
|
+
function handleResultsKey(key, screen, state, done) {
|
|
1663
|
+
const { rows } = termSize();
|
|
1664
|
+
const viewH = Math.max(3, rows - 10);
|
|
1665
|
+
// r/R toggles raw view; u/U toggles curl view — mutually exclusive
|
|
1666
|
+
if (key === "r" || key === "R") {
|
|
1667
|
+
screen.rawView = !screen.rawView;
|
|
1668
|
+
if (screen.rawView)
|
|
1669
|
+
screen.curlView = false;
|
|
1670
|
+
screen.scroll = 0;
|
|
1671
|
+
render(state);
|
|
1672
|
+
return;
|
|
1673
|
+
}
|
|
1674
|
+
if (key === "u" || key === "U") {
|
|
1675
|
+
screen.curlView = !screen.curlView;
|
|
1676
|
+
if (screen.curlView)
|
|
1677
|
+
screen.rawView = false;
|
|
1678
|
+
screen.scroll = 0;
|
|
1679
|
+
render(state);
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
if (screen.curlView) {
|
|
1683
|
+
if (key === "y" || key === "Y") {
|
|
1684
|
+
if (screen.raw) {
|
|
1685
|
+
const { cmd } = buildCurlCmd(screen.raw);
|
|
1686
|
+
copyToClipboard(cmd);
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
else if (key === "e" || key === "E") {
|
|
1690
|
+
if (screen.raw) {
|
|
1691
|
+
const { token } = buildCurlCmd(screen.raw);
|
|
1692
|
+
if (token)
|
|
1693
|
+
copyToClipboard(`export ${CURL_TOKEN_VAR}="${token}"`);
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
else if (key === "\x1b[A") {
|
|
1697
|
+
if (screen.scroll > 0)
|
|
1698
|
+
screen.scroll--;
|
|
1699
|
+
}
|
|
1700
|
+
else if (key === "\x1b[B") {
|
|
1701
|
+
screen.scroll++;
|
|
1702
|
+
}
|
|
1703
|
+
else if (key === "m" || key === "M") {
|
|
1704
|
+
popToMain(state);
|
|
1705
|
+
}
|
|
1706
|
+
else if (key === "\x1b") {
|
|
1707
|
+
state.stack.pop();
|
|
1708
|
+
}
|
|
1709
|
+
else if (key === "q" || key === "Q") {
|
|
1710
|
+
done();
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
render(state);
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
if (screen.rawView) {
|
|
1717
|
+
switch (key) {
|
|
1718
|
+
case "\x1b[A":
|
|
1719
|
+
if (screen.scroll > 0)
|
|
1720
|
+
screen.scroll--;
|
|
1721
|
+
break;
|
|
1722
|
+
case "\x1b[B":
|
|
1723
|
+
screen.scroll++;
|
|
1724
|
+
break;
|
|
1725
|
+
case "m":
|
|
1726
|
+
case "M":
|
|
1727
|
+
popToMain(state);
|
|
1728
|
+
break;
|
|
1729
|
+
case "\x1b":
|
|
1730
|
+
state.stack.pop();
|
|
1731
|
+
break;
|
|
1732
|
+
case "q":
|
|
1733
|
+
case "Q":
|
|
1734
|
+
done();
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
render(state);
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
if (screen.output.type !== "list") {
|
|
1741
|
+
if (screen.output.type === "item") {
|
|
1742
|
+
const entries = Object.entries(flattenObj(screen.output.data));
|
|
1743
|
+
const expandArray = () => {
|
|
1744
|
+
const entry = entries[screen.cursor];
|
|
1745
|
+
if (entry) {
|
|
1746
|
+
const [fieldKey, v] = entry;
|
|
1747
|
+
if (v instanceof ArrayValue) {
|
|
1748
|
+
state.stack.push({
|
|
1749
|
+
kind: "detail",
|
|
1750
|
+
group: screen.group,
|
|
1751
|
+
item: v.items,
|
|
1752
|
+
label: `${fieldKey} (${v.items.length})`,
|
|
1753
|
+
cursor: 0,
|
|
1754
|
+
scroll: 0,
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
};
|
|
1759
|
+
if (key === "\x1b[A" && screen.cursor > 0)
|
|
1760
|
+
screen.cursor--;
|
|
1761
|
+
else if (key === "\x1b[B" && screen.cursor < entries.length - 1)
|
|
1762
|
+
screen.cursor++;
|
|
1763
|
+
else if (key === " " || key === "\x1b[C")
|
|
1764
|
+
expandArray();
|
|
1765
|
+
else if (key === "\x1b[D")
|
|
1766
|
+
state.stack.pop();
|
|
1767
|
+
else if (key === "\x1b")
|
|
1768
|
+
state.stack.pop();
|
|
1769
|
+
else if (key === "m" || key === "M")
|
|
1770
|
+
popToMain(state);
|
|
1771
|
+
else if (key === "q" || key === "Q") {
|
|
1772
|
+
done();
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
else {
|
|
1777
|
+
// messages type — scrollable + horizontal pan (screen.cursor = hOff)
|
|
1778
|
+
const msgLines = screen.altView && screen.output.altLines ? screen.output.altLines : screen.output.lines;
|
|
1779
|
+
switch (key) {
|
|
1780
|
+
case "x":
|
|
1781
|
+
case "X":
|
|
1782
|
+
if (screen.output.altLines) {
|
|
1783
|
+
screen.altView = !screen.altView;
|
|
1784
|
+
screen.scroll = 0;
|
|
1785
|
+
screen.cursor = 0;
|
|
1786
|
+
}
|
|
1787
|
+
break;
|
|
1788
|
+
case "\x1b[A":
|
|
1789
|
+
if (screen.scroll > 0)
|
|
1790
|
+
screen.scroll = Math.max(0, screen.scroll - 3);
|
|
1791
|
+
break;
|
|
1792
|
+
case "\x1b[B":
|
|
1793
|
+
screen.scroll = Math.min(Math.max(0, msgLines.length - viewH), screen.scroll + 3);
|
|
1794
|
+
break;
|
|
1795
|
+
case "\x1b[C":
|
|
1796
|
+
if (!screen.altView)
|
|
1797
|
+
screen.cursor += 3;
|
|
1798
|
+
break;
|
|
1799
|
+
case "\x1b[D":
|
|
1800
|
+
if (!screen.altView)
|
|
1801
|
+
screen.cursor = Math.max(0, screen.cursor - 3);
|
|
1802
|
+
break;
|
|
1803
|
+
case "\x1b[5~":
|
|
1804
|
+
screen.scroll = Math.max(0, screen.scroll - viewH);
|
|
1805
|
+
break;
|
|
1806
|
+
case "\x1b[6~":
|
|
1807
|
+
screen.scroll = Math.min(Math.max(0, msgLines.length - viewH), screen.scroll + viewH);
|
|
1808
|
+
break;
|
|
1809
|
+
default:
|
|
1810
|
+
if (key === "\x1b")
|
|
1811
|
+
state.stack.pop();
|
|
1812
|
+
else if (key === "m" || key === "M")
|
|
1813
|
+
popToMain(state);
|
|
1814
|
+
else if (key === "q" || key === "Q") {
|
|
1815
|
+
done();
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
render(state);
|
|
1821
|
+
return;
|
|
1822
|
+
}
|
|
1823
|
+
const len = screen.output.items.length;
|
|
1824
|
+
switch (key) {
|
|
1825
|
+
case "\x1b[A": // up
|
|
1826
|
+
if (screen.cursor > 0) {
|
|
1827
|
+
screen.cursor--;
|
|
1828
|
+
if (screen.cursor < screen.scroll)
|
|
1829
|
+
screen.scroll--;
|
|
1830
|
+
}
|
|
1831
|
+
break;
|
|
1832
|
+
case "\x1b[B": // down
|
|
1833
|
+
if (screen.cursor < len - 1) {
|
|
1834
|
+
screen.cursor++;
|
|
1835
|
+
if (screen.cursor >= screen.scroll + viewH)
|
|
1836
|
+
screen.scroll++;
|
|
1837
|
+
}
|
|
1838
|
+
break;
|
|
1839
|
+
case "\x1b[5~": // page up
|
|
1840
|
+
screen.cursor = Math.max(0, screen.cursor - viewH);
|
|
1841
|
+
screen.scroll = Math.max(0, screen.scroll - viewH);
|
|
1842
|
+
break;
|
|
1843
|
+
case "\x1b[6~": // page down
|
|
1844
|
+
screen.cursor = Math.min(len - 1, screen.cursor + viewH);
|
|
1845
|
+
screen.scroll = Math.min(Math.max(0, len - viewH), screen.scroll + viewH);
|
|
1846
|
+
break;
|
|
1847
|
+
case "\r":
|
|
1848
|
+
case "\n": {
|
|
1849
|
+
const item = screen.output.items[screen.cursor];
|
|
1850
|
+
if (item) {
|
|
1851
|
+
state.stack.push({
|
|
1852
|
+
kind: "detail",
|
|
1853
|
+
group: screen.group,
|
|
1854
|
+
item,
|
|
1855
|
+
label: "detail",
|
|
1856
|
+
cursor: 0,
|
|
1857
|
+
scroll: 0,
|
|
1858
|
+
});
|
|
1859
|
+
}
|
|
1860
|
+
break;
|
|
1861
|
+
}
|
|
1862
|
+
case "f":
|
|
1863
|
+
case "F": {
|
|
1864
|
+
const item = screen.output.items[screen.cursor];
|
|
1865
|
+
if (item && screen.cmd.relations) {
|
|
1866
|
+
const resolved = [];
|
|
1867
|
+
for (const rel of screen.cmd.relations) {
|
|
1868
|
+
const tGroup = state.groups.find((g) => g.name === rel.groupName);
|
|
1869
|
+
if (!tGroup)
|
|
1870
|
+
continue;
|
|
1871
|
+
const tCmd = tGroup.commands.find((c) => c.name === rel.commandName);
|
|
1872
|
+
if (!tCmd)
|
|
1873
|
+
continue;
|
|
1874
|
+
resolved.push({ group: tGroup, cmd: tCmd, params: rel.params });
|
|
1875
|
+
}
|
|
1876
|
+
if (resolved.length > 0) {
|
|
1877
|
+
state.stack.push({
|
|
1878
|
+
kind: "followup",
|
|
1879
|
+
sourceGroup: screen.group,
|
|
1880
|
+
item: item,
|
|
1881
|
+
relations: resolved,
|
|
1882
|
+
cursor: 0,
|
|
1883
|
+
scroll: 0,
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
break;
|
|
1888
|
+
}
|
|
1889
|
+
case "m":
|
|
1890
|
+
case "M":
|
|
1891
|
+
popToMain(state);
|
|
1892
|
+
break;
|
|
1893
|
+
case "\x1b":
|
|
1894
|
+
state.stack.pop();
|
|
1895
|
+
break;
|
|
1896
|
+
case "q":
|
|
1897
|
+
case "Q":
|
|
1898
|
+
done();
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
render(state);
|
|
1902
|
+
}
|
|
1903
|
+
function handleDetailKey(key, screen, state, done) {
|
|
1904
|
+
const { rows } = termSize();
|
|
1905
|
+
const viewH = Math.max(3, rows - 7);
|
|
1906
|
+
const entries = Object.entries(flattenObj(screen.item));
|
|
1907
|
+
const entryCount = entries.length;
|
|
1908
|
+
switch (key) {
|
|
1909
|
+
case "\x1b[A":
|
|
1910
|
+
if (screen.cursor > 0) {
|
|
1911
|
+
screen.cursor--;
|
|
1912
|
+
if (screen.cursor < screen.scroll)
|
|
1913
|
+
screen.scroll--;
|
|
1914
|
+
}
|
|
1915
|
+
break;
|
|
1916
|
+
case "\x1b[B":
|
|
1917
|
+
if (screen.cursor < entryCount - 1) {
|
|
1918
|
+
screen.cursor++;
|
|
1919
|
+
if (screen.cursor >= screen.scroll + viewH)
|
|
1920
|
+
screen.scroll++;
|
|
1921
|
+
}
|
|
1922
|
+
break;
|
|
1923
|
+
case "\x1b[5~":
|
|
1924
|
+
screen.cursor = Math.max(0, screen.cursor - viewH);
|
|
1925
|
+
screen.scroll = Math.max(0, screen.scroll - viewH);
|
|
1926
|
+
break;
|
|
1927
|
+
case "\x1b[6~":
|
|
1928
|
+
screen.cursor = Math.min(entryCount - 1, screen.cursor + viewH);
|
|
1929
|
+
screen.scroll = Math.min(Math.max(0, entryCount - viewH), screen.scroll + viewH);
|
|
1930
|
+
break;
|
|
1931
|
+
case " ":
|
|
1932
|
+
case "\x1b[C": {
|
|
1933
|
+
const entry = entries[screen.cursor];
|
|
1934
|
+
if (entry) {
|
|
1935
|
+
const [fieldKey, v] = entry;
|
|
1936
|
+
if (v instanceof ArrayValue) {
|
|
1937
|
+
state.stack.push({
|
|
1938
|
+
kind: "detail",
|
|
1939
|
+
group: screen.group,
|
|
1940
|
+
item: v.items,
|
|
1941
|
+
label: `${fieldKey} (${v.items.length})`,
|
|
1942
|
+
cursor: 0,
|
|
1943
|
+
scroll: 0,
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
break;
|
|
1948
|
+
}
|
|
1949
|
+
case "\x1b[D":
|
|
1950
|
+
state.stack.pop();
|
|
1951
|
+
break;
|
|
1952
|
+
case "m":
|
|
1953
|
+
case "M":
|
|
1954
|
+
popToMain(state);
|
|
1955
|
+
break;
|
|
1956
|
+
case "\x1b":
|
|
1957
|
+
state.stack.pop();
|
|
1958
|
+
break;
|
|
1959
|
+
case "q":
|
|
1960
|
+
case "Q":
|
|
1961
|
+
done();
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1964
|
+
render(state);
|
|
1965
|
+
}
|
|
1966
|
+
function handleFollowupKey(key, screen, state, done) {
|
|
1967
|
+
const { rows } = termSize();
|
|
1968
|
+
const viewH = Math.max(3, rows - 8);
|
|
1969
|
+
switch (key) {
|
|
1970
|
+
case "\x1b[A":
|
|
1971
|
+
if (screen.cursor > 0) {
|
|
1972
|
+
screen.cursor--;
|
|
1973
|
+
if (screen.cursor < screen.scroll)
|
|
1974
|
+
screen.scroll--;
|
|
1975
|
+
}
|
|
1976
|
+
break;
|
|
1977
|
+
case "\x1b[B":
|
|
1978
|
+
if (screen.cursor < screen.relations.length - 1) {
|
|
1979
|
+
screen.cursor++;
|
|
1980
|
+
if (screen.cursor >= screen.scroll + viewH)
|
|
1981
|
+
screen.scroll++;
|
|
1982
|
+
}
|
|
1983
|
+
break;
|
|
1984
|
+
case "\r":
|
|
1985
|
+
case "\n": {
|
|
1986
|
+
const rel = screen.relations[screen.cursor];
|
|
1987
|
+
if (rel) {
|
|
1988
|
+
const fields = buildFields(rel.cmd);
|
|
1989
|
+
// Pre-fill args from the source item
|
|
1990
|
+
for (const p of rel.params) {
|
|
1991
|
+
const val = String(screen.item[p.field] ?? "");
|
|
1992
|
+
const field = fields.find((f) => f.kind === "arg" && f.label === p.param);
|
|
1993
|
+
if (field) {
|
|
1994
|
+
field.value = val;
|
|
1995
|
+
field.cursor = val.length;
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
const allFilled = fields.filter((f) => f.required).every((f) => f.value);
|
|
1999
|
+
const runIdx = fields.findIndex((f) => f.kind === "run");
|
|
2000
|
+
state.stack.push({
|
|
2001
|
+
kind: "input",
|
|
2002
|
+
group: rel.group,
|
|
2003
|
+
cmd: rel.cmd,
|
|
2004
|
+
fields,
|
|
2005
|
+
cursor: allFilled && runIdx >= 0 ? runIdx : 0,
|
|
2006
|
+
scroll: 0,
|
|
2007
|
+
editing: false,
|
|
2008
|
+
error: "",
|
|
2009
|
+
running: false,
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
2012
|
+
break;
|
|
2013
|
+
}
|
|
2014
|
+
case "m":
|
|
2015
|
+
case "M":
|
|
2016
|
+
popToMain(state);
|
|
2017
|
+
break;
|
|
2018
|
+
case "\x1b":
|
|
2019
|
+
state.stack.pop();
|
|
2020
|
+
break;
|
|
2021
|
+
case "q":
|
|
2022
|
+
case "Q":
|
|
2023
|
+
done();
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
render(state);
|
|
2027
|
+
}
|
|
2028
|
+
function saveJsonEditorToField(screen, state) {
|
|
2029
|
+
const inputScreen = [...state.stack].reverse().find((s) => s.kind === "input");
|
|
2030
|
+
if (inputScreen?.kind === "input") {
|
|
2031
|
+
const field = inputScreen.fields[screen.fieldIndex];
|
|
2032
|
+
if (field) {
|
|
2033
|
+
field.value = entriesToJson(screen.entries);
|
|
2034
|
+
field.cursor = field.value.length;
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
function handleJsonEditorKey(key, screen, state, done) {
|
|
2039
|
+
const { rows } = termSize();
|
|
2040
|
+
const viewH = Math.max(3, rows - 9);
|
|
2041
|
+
const rowCount = screen.entries.length + 1;
|
|
2042
|
+
const isAddRow = screen.cursor === screen.entries.length;
|
|
2043
|
+
const entry = screen.entries[screen.cursor];
|
|
2044
|
+
// ── Edit mode ────────────────────────────────────────────────────────────
|
|
2045
|
+
if (screen.editing && entry) {
|
|
2046
|
+
const activeIsKey = screen.col === "key";
|
|
2047
|
+
const activeText = activeIsKey ? entry.key : entry.val;
|
|
2048
|
+
const activeCursor = activeIsKey ? entry.keyCursor : entry.valCursor;
|
|
2049
|
+
const setTextAndCursor = (text, cur) => {
|
|
2050
|
+
if (activeIsKey) {
|
|
2051
|
+
entry.key = text;
|
|
2052
|
+
entry.keyCursor = cur;
|
|
2053
|
+
}
|
|
2054
|
+
else {
|
|
2055
|
+
entry.val = text;
|
|
2056
|
+
entry.valCursor = cur;
|
|
2057
|
+
}
|
|
2058
|
+
};
|
|
2059
|
+
// Enum cycling for value column of a known enum field
|
|
2060
|
+
const valSpec = !activeIsKey ? getFieldSpec(entry.key, screen.fieldSpecs) : undefined;
|
|
2061
|
+
const enumVals = valSpec?.enum;
|
|
2062
|
+
if (enumVals && enumVals.length > 0 && !activeIsKey) {
|
|
2063
|
+
const curIdx = enumVals.indexOf(entry.val);
|
|
2064
|
+
switch (key) {
|
|
2065
|
+
case "\x1b[A": // up
|
|
2066
|
+
entry.val = enumVals[(curIdx - 1 + enumVals.length) % enumVals.length] ?? "";
|
|
2067
|
+
entry.valCursor = entry.val.length;
|
|
2068
|
+
render(state);
|
|
2069
|
+
return;
|
|
2070
|
+
case "\x1b[B": // down
|
|
2071
|
+
entry.val = enumVals[(curIdx + 1) % enumVals.length] ?? "";
|
|
2072
|
+
entry.valCursor = entry.val.length;
|
|
2073
|
+
render(state);
|
|
2074
|
+
return;
|
|
2075
|
+
case "\r":
|
|
2076
|
+
case "\n":
|
|
2077
|
+
// If no value selected yet, pick first
|
|
2078
|
+
if (!entry.val && enumVals[0]) {
|
|
2079
|
+
entry.val = enumVals[0];
|
|
2080
|
+
entry.valCursor = entry.val.length;
|
|
2081
|
+
}
|
|
2082
|
+
screen.editing = false;
|
|
2083
|
+
screen.col = "key";
|
|
2084
|
+
if (screen.cursor < rowCount - 1) {
|
|
2085
|
+
screen.cursor++;
|
|
2086
|
+
if (screen.cursor >= screen.scroll + viewH)
|
|
2087
|
+
screen.scroll++;
|
|
2088
|
+
}
|
|
2089
|
+
render(state);
|
|
2090
|
+
return;
|
|
2091
|
+
case "\x1b":
|
|
2092
|
+
screen.editing = false;
|
|
2093
|
+
render(state);
|
|
2094
|
+
return;
|
|
2095
|
+
case "\t":
|
|
2096
|
+
screen.col = "key";
|
|
2097
|
+
render(state);
|
|
2098
|
+
return;
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
switch (key) {
|
|
2102
|
+
case "\r":
|
|
2103
|
+
case "\n":
|
|
2104
|
+
screen.editing = false;
|
|
2105
|
+
// Advance: key → val, then val → next row key
|
|
2106
|
+
if (activeIsKey) {
|
|
2107
|
+
screen.col = "val";
|
|
2108
|
+
}
|
|
2109
|
+
else {
|
|
2110
|
+
screen.col = "key";
|
|
2111
|
+
if (screen.cursor < rowCount - 1) {
|
|
2112
|
+
screen.cursor++;
|
|
2113
|
+
if (screen.cursor >= screen.scroll + viewH)
|
|
2114
|
+
screen.scroll++;
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
break;
|
|
2118
|
+
case "\t": // Tab: switch key↔val
|
|
2119
|
+
screen.col = activeIsKey ? "val" : "key";
|
|
2120
|
+
break;
|
|
2121
|
+
case "\x1b":
|
|
2122
|
+
screen.editing = false;
|
|
2123
|
+
break;
|
|
2124
|
+
case "\x1b[D":
|
|
2125
|
+
if (activeCursor > 0)
|
|
2126
|
+
setTextAndCursor(activeText, activeCursor - 1);
|
|
2127
|
+
break;
|
|
2128
|
+
case "\x1b[C":
|
|
2129
|
+
if (activeCursor < activeText.length)
|
|
2130
|
+
setTextAndCursor(activeText, activeCursor + 1);
|
|
2131
|
+
break;
|
|
2132
|
+
case "\x7f":
|
|
2133
|
+
if (activeCursor > 0) {
|
|
2134
|
+
setTextAndCursor(activeText.slice(0, activeCursor - 1) + activeText.slice(activeCursor), activeCursor - 1);
|
|
2135
|
+
}
|
|
2136
|
+
break;
|
|
2137
|
+
case "\x01":
|
|
2138
|
+
case "\x1b[H":
|
|
2139
|
+
setTextAndCursor(activeText, 0);
|
|
2140
|
+
break;
|
|
2141
|
+
case "\x05":
|
|
2142
|
+
case "\x1b[F":
|
|
2143
|
+
setTextAndCursor(activeText, activeText.length);
|
|
2144
|
+
break;
|
|
2145
|
+
case "\x15":
|
|
2146
|
+
setTextAndCursor("", 0);
|
|
2147
|
+
break;
|
|
2148
|
+
default: {
|
|
2149
|
+
const printable = [...key].filter((ch) => ch >= " ").join("");
|
|
2150
|
+
if (printable) {
|
|
2151
|
+
setTextAndCursor(activeText.slice(0, activeCursor) + printable + activeText.slice(activeCursor), activeCursor + printable.length);
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
render(state);
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
// ── Navigation mode ───────────────────────────────────────────────────────
|
|
2159
|
+
switch (key) {
|
|
2160
|
+
case "\x1b[A": // up
|
|
2161
|
+
if (screen.cursor > 0) {
|
|
2162
|
+
screen.cursor--;
|
|
2163
|
+
if (screen.cursor < screen.scroll)
|
|
2164
|
+
screen.scroll--;
|
|
2165
|
+
}
|
|
2166
|
+
break;
|
|
2167
|
+
case "\x1b[B": // down
|
|
2168
|
+
if (screen.cursor < rowCount - 1) {
|
|
2169
|
+
screen.cursor++;
|
|
2170
|
+
if (screen.cursor >= screen.scroll + viewH)
|
|
2171
|
+
screen.scroll++;
|
|
2172
|
+
}
|
|
2173
|
+
break;
|
|
2174
|
+
case "\t": // Tab: switch key↔val (not on add-row)
|
|
2175
|
+
if (!isAddRow)
|
|
2176
|
+
screen.col = screen.col === "key" ? "val" : "key";
|
|
2177
|
+
break;
|
|
2178
|
+
case "\r":
|
|
2179
|
+
case "\n":
|
|
2180
|
+
if (isAddRow) {
|
|
2181
|
+
// Add a new entry and start editing its key
|
|
2182
|
+
screen.entries.push({ key: "", keyCursor: 0, val: "", valCursor: 0 });
|
|
2183
|
+
screen.cursor = screen.entries.length - 1;
|
|
2184
|
+
screen.col = "key";
|
|
2185
|
+
screen.editing = true;
|
|
2186
|
+
}
|
|
2187
|
+
else {
|
|
2188
|
+
// For known enum fields in val column, seed with first enum value
|
|
2189
|
+
if (screen.col === "val" && entry) {
|
|
2190
|
+
const spec = getFieldSpec(entry.key, screen.fieldSpecs);
|
|
2191
|
+
if (spec?.enum && spec.enum.length > 0 && !entry.val) {
|
|
2192
|
+
entry.val = spec.enum[0] ?? "";
|
|
2193
|
+
entry.valCursor = entry.val.length;
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
screen.editing = true;
|
|
2197
|
+
}
|
|
2198
|
+
break;
|
|
2199
|
+
case "a":
|
|
2200
|
+
case "A":
|
|
2201
|
+
// Insert new entry after cursor and start editing
|
|
2202
|
+
screen.entries.splice(screen.cursor + 1, 0, {
|
|
2203
|
+
key: "",
|
|
2204
|
+
keyCursor: 0,
|
|
2205
|
+
val: "",
|
|
2206
|
+
valCursor: 0,
|
|
2207
|
+
});
|
|
2208
|
+
screen.cursor = Math.min(screen.cursor + 1, screen.entries.length - 1);
|
|
2209
|
+
screen.col = "key";
|
|
2210
|
+
screen.editing = true;
|
|
2211
|
+
break;
|
|
2212
|
+
case "d":
|
|
2213
|
+
case "D":
|
|
2214
|
+
if (!isAddRow && screen.entries.length > 0) {
|
|
2215
|
+
screen.entries.splice(screen.cursor, 1);
|
|
2216
|
+
screen.cursor = Math.min(screen.cursor, screen.entries.length);
|
|
2217
|
+
}
|
|
2218
|
+
break;
|
|
2219
|
+
case "\x1b":
|
|
2220
|
+
saveJsonEditorToField(screen, state);
|
|
2221
|
+
state.stack.pop();
|
|
2222
|
+
break;
|
|
2223
|
+
case "q":
|
|
2224
|
+
case "Q":
|
|
2225
|
+
saveJsonEditorToField(screen, state);
|
|
2226
|
+
done();
|
|
2227
|
+
return;
|
|
2228
|
+
}
|
|
2229
|
+
render(state);
|
|
2230
|
+
}
|
|
2231
|
+
function handleProfileKey(key, screen, state, done) {
|
|
2232
|
+
const { rows } = termSize();
|
|
2233
|
+
const viewH = Math.max(3, rows - 7);
|
|
2234
|
+
switch (key) {
|
|
2235
|
+
case "\x1b[A":
|
|
2236
|
+
if (screen.scroll > 0)
|
|
2237
|
+
screen.scroll--;
|
|
2238
|
+
break;
|
|
2239
|
+
case "\x1b[B":
|
|
2240
|
+
if (screen.scroll < Math.max(0, state.profileInfo.length - viewH))
|
|
2241
|
+
screen.scroll++;
|
|
2242
|
+
break;
|
|
2243
|
+
case "p":
|
|
2244
|
+
case "P":
|
|
2245
|
+
case "\x1b":
|
|
2246
|
+
state.stack.pop();
|
|
2247
|
+
break;
|
|
2248
|
+
case "q":
|
|
2249
|
+
case "Q":
|
|
2250
|
+
done();
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
render(state);
|
|
2254
|
+
}
|
|
2255
|
+
function handleSettingsKey(key, screen, state, done) {
|
|
2256
|
+
screen.message = "";
|
|
2257
|
+
if (screen.editing) {
|
|
2258
|
+
if (key === "\r" || key === "\n") {
|
|
2259
|
+
const row = SETTINGS_ROWS[screen.cursor];
|
|
2260
|
+
if (row?.kind === "number") {
|
|
2261
|
+
const n = Number(screen.editValue);
|
|
2262
|
+
if (!Number.isNaN(n) && n >= row.min && n <= row.max) {
|
|
2263
|
+
saveSettings({ [row.key]: Math.floor(n) });
|
|
2264
|
+
screen.message = green(`✓ Saved: ${row.label} = ${Math.floor(n)}`);
|
|
2265
|
+
}
|
|
2266
|
+
else {
|
|
2267
|
+
screen.message = red(`Invalid — must be a number between ${row.min} and ${row.max}`);
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
screen.editing = false;
|
|
2271
|
+
screen.editValue = "";
|
|
2272
|
+
}
|
|
2273
|
+
else if (key === "\x1b") {
|
|
2274
|
+
screen.editing = false;
|
|
2275
|
+
screen.editValue = "";
|
|
2276
|
+
}
|
|
2277
|
+
else if (key === "\x7f" || key === "\x08") {
|
|
2278
|
+
screen.editValue = screen.editValue.slice(0, -1);
|
|
2279
|
+
}
|
|
2280
|
+
else if (key >= "0" && key <= "9") {
|
|
2281
|
+
screen.editValue += key;
|
|
2282
|
+
}
|
|
2283
|
+
render(state);
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
switch (key) {
|
|
2287
|
+
case "\x1b[A":
|
|
2288
|
+
if (screen.cursor > 0)
|
|
2289
|
+
screen.cursor--;
|
|
2290
|
+
break;
|
|
2291
|
+
case "\x1b[B":
|
|
2292
|
+
if (screen.cursor < SETTINGS_ROWS.length - 1)
|
|
2293
|
+
screen.cursor++;
|
|
2294
|
+
break;
|
|
2295
|
+
case "\r":
|
|
2296
|
+
case "\n": {
|
|
2297
|
+
const row = SETTINGS_ROWS[screen.cursor];
|
|
2298
|
+
if (row?.kind === "number") {
|
|
2299
|
+
screen.editing = true;
|
|
2300
|
+
screen.editValue = String(getSettings()[row.key]);
|
|
2301
|
+
}
|
|
2302
|
+
else if (row?.kind === "action" && row.id === "show-audit-log") {
|
|
2303
|
+
state.stack.push({ kind: "audit-log", scroll: 0 });
|
|
2304
|
+
}
|
|
2305
|
+
break;
|
|
2306
|
+
}
|
|
2307
|
+
case "\x1b":
|
|
2308
|
+
state.stack.pop();
|
|
2309
|
+
break;
|
|
2310
|
+
case "q":
|
|
2311
|
+
case "Q":
|
|
2312
|
+
done();
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
2315
|
+
render(state);
|
|
2316
|
+
}
|
|
2317
|
+
function handleAuditLogKey(key, screen, state, done) {
|
|
2318
|
+
const { rows } = termSize();
|
|
2319
|
+
const viewH = Math.max(3, rows - 8);
|
|
2320
|
+
const total = getAuditLog(state.profile || undefined).length;
|
|
2321
|
+
switch (key) {
|
|
2322
|
+
case "\x1b[A":
|
|
2323
|
+
if (screen.scroll > 0)
|
|
2324
|
+
screen.scroll--;
|
|
2325
|
+
break;
|
|
2326
|
+
case "\x1b[B":
|
|
2327
|
+
if (screen.scroll < Math.max(0, total - viewH))
|
|
2328
|
+
screen.scroll++;
|
|
2329
|
+
break;
|
|
2330
|
+
case "\x1b":
|
|
2331
|
+
state.stack.pop();
|
|
2332
|
+
break;
|
|
2333
|
+
case "q":
|
|
2334
|
+
case "Q":
|
|
2335
|
+
done();
|
|
2336
|
+
return;
|
|
2337
|
+
}
|
|
2338
|
+
render(state);
|
|
2339
|
+
}
|
|
2340
|
+
function handleWorkerKey(key, screen, state, done) {
|
|
2341
|
+
const { rows } = termSize();
|
|
2342
|
+
const viewH = Math.max(3, rows - 13);
|
|
2343
|
+
const isRunning = screen.status === "running" || screen.status === "starting";
|
|
2344
|
+
switch (key) {
|
|
2345
|
+
case "\x1b[A": // up
|
|
2346
|
+
screen.autoScroll = false;
|
|
2347
|
+
if (screen.scroll > 0)
|
|
2348
|
+
screen.scroll--;
|
|
2349
|
+
break;
|
|
2350
|
+
case "\x1b[B": // down
|
|
2351
|
+
if (screen.scroll < Math.max(0, screen.log.length - viewH)) {
|
|
2352
|
+
screen.scroll++;
|
|
2353
|
+
if (screen.scroll >= screen.log.length - viewH)
|
|
2354
|
+
screen.autoScroll = true;
|
|
2355
|
+
}
|
|
2356
|
+
break;
|
|
2357
|
+
case "\x1b[5~": // page up
|
|
2358
|
+
screen.autoScroll = false;
|
|
2359
|
+
screen.scroll = Math.max(0, screen.scroll - viewH);
|
|
2360
|
+
break;
|
|
2361
|
+
case "\x1b[6~": // page down
|
|
2362
|
+
screen.scroll = Math.min(Math.max(0, screen.log.length - viewH), screen.scroll + viewH);
|
|
2363
|
+
break;
|
|
2364
|
+
case "a":
|
|
2365
|
+
case "A":
|
|
2366
|
+
screen.autoScroll = !screen.autoScroll;
|
|
2367
|
+
if (screen.autoScroll)
|
|
2368
|
+
screen.scroll = Math.max(0, screen.log.length - viewH);
|
|
2369
|
+
break;
|
|
2370
|
+
case "s":
|
|
2371
|
+
case "S":
|
|
2372
|
+
if (isRunning && screen._stop)
|
|
2373
|
+
screen._stop();
|
|
2374
|
+
break;
|
|
2375
|
+
case "\x1b": // esc — only go back when stopped
|
|
2376
|
+
if (!isRunning) {
|
|
2377
|
+
stopWorkerScreen(screen);
|
|
2378
|
+
state.stack.pop();
|
|
2379
|
+
}
|
|
2380
|
+
break;
|
|
2381
|
+
case "m":
|
|
2382
|
+
case "M":
|
|
2383
|
+
if (!isRunning) {
|
|
2384
|
+
stopWorkerScreen(screen);
|
|
2385
|
+
popToMain(state);
|
|
2386
|
+
}
|
|
2387
|
+
break;
|
|
2388
|
+
case "q":
|
|
2389
|
+
case "Q":
|
|
2390
|
+
if (isRunning && screen._stop)
|
|
2391
|
+
screen._stop();
|
|
2392
|
+
stopWorkerScreen(screen);
|
|
2393
|
+
done();
|
|
2394
|
+
return;
|
|
2395
|
+
}
|
|
2396
|
+
render(state);
|
|
2397
|
+
}
|
|
2398
|
+
async function handleKey(key, state, done) {
|
|
2399
|
+
if (state.quitting)
|
|
2400
|
+
return;
|
|
2401
|
+
if (key === "\x03") {
|
|
2402
|
+
done();
|
|
2403
|
+
return;
|
|
2404
|
+
}
|
|
2405
|
+
const screen = state.stack[state.stack.length - 1];
|
|
2406
|
+
if (!screen)
|
|
2407
|
+
return;
|
|
2408
|
+
// Global p/P: open profile view (skip when actively editing text)
|
|
2409
|
+
if (key === "p" || key === "P") {
|
|
2410
|
+
const isEditing = (screen.kind === "input" && screen.editing) ||
|
|
2411
|
+
(screen.kind === "json-editor" && screen.editing) ||
|
|
2412
|
+
(screen.kind === "settings" && screen.editing);
|
|
2413
|
+
const isProfile = screen.kind === "profile";
|
|
2414
|
+
if (!isEditing && !isProfile) {
|
|
2415
|
+
state.stack.push({ kind: "profile", scroll: 0 });
|
|
2416
|
+
render(state);
|
|
2417
|
+
return;
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
switch (screen.kind) {
|
|
2421
|
+
case "main":
|
|
2422
|
+
handleMainKey(key, screen, state, done);
|
|
2423
|
+
break;
|
|
2424
|
+
case "commands":
|
|
2425
|
+
handleCommandsKey(key, screen, state, done);
|
|
2426
|
+
break;
|
|
2427
|
+
case "input":
|
|
2428
|
+
await handleInputKey(key, screen, state, done);
|
|
2429
|
+
break;
|
|
2430
|
+
case "results":
|
|
2431
|
+
handleResultsKey(key, screen, state, done);
|
|
2432
|
+
break;
|
|
2433
|
+
case "detail":
|
|
2434
|
+
handleDetailKey(key, screen, state, done);
|
|
2435
|
+
break;
|
|
2436
|
+
case "followup":
|
|
2437
|
+
handleFollowupKey(key, screen, state, done);
|
|
2438
|
+
break;
|
|
2439
|
+
case "profile":
|
|
2440
|
+
handleProfileKey(key, screen, state, done);
|
|
2441
|
+
break;
|
|
2442
|
+
case "settings":
|
|
2443
|
+
handleSettingsKey(key, screen, state, done);
|
|
2444
|
+
break;
|
|
2445
|
+
case "audit-log":
|
|
2446
|
+
handleAuditLogKey(key, screen, state, done);
|
|
2447
|
+
break;
|
|
2448
|
+
case "worker":
|
|
2449
|
+
handleWorkerKey(key, screen, state, done);
|
|
2450
|
+
break;
|
|
2451
|
+
case "json-editor":
|
|
2452
|
+
handleJsonEditorKey(key, screen, state, done);
|
|
2453
|
+
break;
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
// ─── Shared TUI runner ────────────────────────────────────────────────────────
|
|
2457
|
+
async function startTui(state) {
|
|
2458
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
2459
|
+
// Non-interactive fallback: print group list or group commands
|
|
2460
|
+
const first = state.stack[0];
|
|
2461
|
+
if (first?.kind === "commands") {
|
|
2462
|
+
process.stdout.write(`${first.group.name}: ${first.group.description}\n`);
|
|
2463
|
+
process.stdout.write(`Commands: ${first.group.commands.map((c) => c.name).join(", ")}\n`);
|
|
2464
|
+
}
|
|
2465
|
+
else {
|
|
2466
|
+
for (const g of state.groups) {
|
|
2467
|
+
process.stdout.write(`${g.name.padEnd(24)} ${g.description}\n`);
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
return;
|
|
2471
|
+
}
|
|
2472
|
+
process.stdout.write(`${ALT_ON}${HIDE}`);
|
|
2473
|
+
let cleaned = false;
|
|
2474
|
+
const cleanup = () => {
|
|
2475
|
+
if (cleaned)
|
|
2476
|
+
return;
|
|
2477
|
+
cleaned = true;
|
|
2478
|
+
process.stdout.write(`${ALT_OFF}${SHOW}`);
|
|
2479
|
+
if (process.stdin.isTTY)
|
|
2480
|
+
process.stdin.setRawMode(false);
|
|
2481
|
+
process.stdin.pause();
|
|
2482
|
+
};
|
|
2483
|
+
process.on("exit", cleanup);
|
|
2484
|
+
render(state);
|
|
2485
|
+
await new Promise((resolve) => {
|
|
2486
|
+
const done = () => {
|
|
2487
|
+
state.quitting = true;
|
|
2488
|
+
resolve();
|
|
2489
|
+
};
|
|
2490
|
+
process.stdin.setRawMode(true);
|
|
2491
|
+
process.stdin.resume();
|
|
2492
|
+
process.stdin.setEncoding("utf8");
|
|
2493
|
+
let handling = false;
|
|
2494
|
+
process.stdin.on("data", async (key) => {
|
|
2495
|
+
if (handling || state.quitting)
|
|
2496
|
+
return;
|
|
2497
|
+
handling = true;
|
|
2498
|
+
try {
|
|
2499
|
+
await handleKey(key, state, done);
|
|
2500
|
+
}
|
|
2501
|
+
finally {
|
|
2502
|
+
handling = false;
|
|
2503
|
+
}
|
|
2504
|
+
});
|
|
2505
|
+
});
|
|
2506
|
+
cleanup();
|
|
2507
|
+
process.removeListener("exit", cleanup);
|
|
2508
|
+
}
|
|
2509
|
+
/** Open the TUI at the top-level main menu listing all command groups. */
|
|
2510
|
+
export async function runMainTui(groups, getClient, getAdminClient, opts) {
|
|
2511
|
+
return startTui({
|
|
2512
|
+
groups,
|
|
2513
|
+
stack: [{ kind: "main", cursor: 0, scroll: 0, search: "" }],
|
|
2514
|
+
getClient,
|
|
2515
|
+
getAdminClient: getAdminClient ??
|
|
2516
|
+
opts?.getAdminClient ??
|
|
2517
|
+
(() => Promise.reject(new Error("No admin client"))),
|
|
2518
|
+
quitting: false,
|
|
2519
|
+
profile: opts?.profile ?? "",
|
|
2520
|
+
profileInfo: opts?.profileInfo ?? [],
|
|
2521
|
+
});
|
|
2522
|
+
}
|
|
2523
|
+
/**
|
|
2524
|
+
* Open the TUI directly at a specific group's command list.
|
|
2525
|
+
* The main menu is placed at the bottom of the stack so `m` always works.
|
|
2526
|
+
*/
|
|
2527
|
+
export async function runGroupTui(group, groups, getClient, getAdminClient, opts) {
|
|
2528
|
+
const cursor = groups.findIndex((g) => g.name === group.name);
|
|
2529
|
+
return startTui({
|
|
2530
|
+
groups,
|
|
2531
|
+
stack: [
|
|
2532
|
+
{ kind: "main", cursor: Math.max(0, cursor), scroll: 0, search: "" },
|
|
2533
|
+
{ kind: "commands", group, cursor: 0, search: "" },
|
|
2534
|
+
],
|
|
2535
|
+
getClient,
|
|
2536
|
+
getAdminClient: getAdminClient ??
|
|
2537
|
+
opts?.getAdminClient ??
|
|
2538
|
+
(() => Promise.reject(new Error("No admin client"))),
|
|
2539
|
+
quitting: false,
|
|
2540
|
+
profile: opts?.profile ?? "",
|
|
2541
|
+
profileInfo: opts?.profileInfo ?? [],
|
|
2542
|
+
});
|
|
2543
|
+
}
|
|
2544
|
+
//# sourceMappingURL=tui.js.map
|