thingd-cli 0.0.0-development → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1540 @@
1
+ import { spawn } from "node:child_process";
2
+ import * as crypto from "node:crypto";
3
+ import * as fs from "node:fs";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+ import readline from "node:readline";
7
+ import pc from "picocolors";
8
+ import { ThingD } from "thingd";
9
+ // ── Helpers ──────────────────────────────────────────────────────────
10
+ function highlightJson(val) {
11
+ const str = JSON.stringify(val, null, 2);
12
+ return str.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g, (match) => {
13
+ if (/^"/.test(match)) {
14
+ return /:$/.test(match) ? pc.cyan(match) : pc.green(match);
15
+ }
16
+ if (/true|false/.test(match))
17
+ return pc.magenta(match);
18
+ if (/null/.test(match))
19
+ return pc.dim(match);
20
+ return pc.yellow(match);
21
+ });
22
+ }
23
+ /** Strip ANSI escape codes to get the visible character count. */
24
+ function stripAnsi(s) {
25
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escapes require matching the ESC character
26
+ return s.replace(/\u001B\[[0-9;]*[a-zA-Z]/g, "");
27
+ }
28
+ /** Measure the visible width of a string accounting for wide characters (CJK, emoji). */
29
+ function visibleWidth(s) {
30
+ const clean = stripAnsi(s);
31
+ let w = 0;
32
+ for (const ch of clean) {
33
+ const cp = ch.codePointAt(0);
34
+ // Emoji (surrogate pairs / high codepoints) and CJK fullwidth ranges
35
+ if (cp > 0xffff ||
36
+ (cp >= 0x1100 && cp <= 0x115f) ||
37
+ (cp >= 0x2e80 && cp <= 0xa4cf) ||
38
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
39
+ (cp >= 0xf900 && cp <= 0xfaff) ||
40
+ (cp >= 0xfe10 && cp <= 0xfe6f) ||
41
+ (cp >= 0xff01 && cp <= 0xff60) ||
42
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
43
+ (cp >= 0x20000 && cp <= 0x2fffd) ||
44
+ (cp >= 0x30000 && cp <= 0x3fffd) ||
45
+ (cp >= 0xfe00 && cp <= 0xfe0f) ||
46
+ (cp >= 0x200d && cp <= 0x200d) ||
47
+ (cp >= 0xe0100 && cp <= 0xe01ef)) {
48
+ w += 2;
49
+ }
50
+ else {
51
+ w += 1;
52
+ }
53
+ }
54
+ return w;
55
+ }
56
+ // ── State ────────────────────────────────────────────────────────────// Connection State
57
+ let db;
58
+ let driver = "";
59
+ let dbPath = "";
60
+ let connected = false;
61
+ let authToken = "";
62
+ let collections = [];
63
+ let streams = [];
64
+ let queues = [];
65
+ let objectsByCollection = new Map();
66
+ const expandedSet = new Set(["cat:collections", "cat:streams", "cat:queues"]);
67
+ let cursorIndex = 0;
68
+ let scrollOffset = 0;
69
+ let startedAt = 0; // ms since epoch when we connected
70
+ let totalObjects = 0;
71
+ let totalEventsCount = 0;
72
+ let totalActiveJobsCount = 0;
73
+ let totalDeadJobsCount = 0;
74
+ let objectsHistory = [];
75
+ let eventsHistory = [];
76
+ let activeJobsHistory = [];
77
+ let deadJobsHistory = [];
78
+ let dbSizeHistory = [];
79
+ let objectWriteRateHistory = [];
80
+ let eventAppendRateHistory = [];
81
+ const colHistory = new Map();
82
+ const streamHistory = new Map();
83
+ const queueActiveHistory = new Map();
84
+ const queueDeadHistory = new Map();
85
+ let viewerLines = ["Select an item to view details."];
86
+ let viewerScroll = 0;
87
+ let loadedItemId = "";
88
+ let loadTimer = null;
89
+ let pollTimer = null;
90
+ let keypressHandler = null;
91
+ let formState = null;
92
+ function openForm(title, fields, onSubmit) {
93
+ formState = {
94
+ active: true,
95
+ title,
96
+ fields: fields.map((f) => ({
97
+ id: f.id,
98
+ label: f.label,
99
+ value: f.value || (f.options?.[0] ?? ""),
100
+ placeholder: f.placeholder,
101
+ isSecret: f.isSecret,
102
+ options: f.options,
103
+ allowCustom: f.allowCustom,
104
+ })),
105
+ activeIndex: 0,
106
+ onCancel: () => {
107
+ formState = null;
108
+ viewerLines = [];
109
+ loadedItemId = ""; // Force reload
110
+ draw();
111
+ const n = buildTree()[cursorIndex];
112
+ if (n)
113
+ scheduleLoad(n);
114
+ },
115
+ onSubmit: async (vals) => {
116
+ if (!formState)
117
+ return;
118
+ formState.isSubmitting = true;
119
+ formState.error = undefined;
120
+ draw();
121
+ try {
122
+ await onSubmit(vals);
123
+ formState = null;
124
+ viewerLines = [];
125
+ loadedItemId = ""; // Force reload
126
+ await fetchResources();
127
+ draw();
128
+ const n = buildTree()[cursorIndex];
129
+ if (n)
130
+ scheduleLoad(n);
131
+ }
132
+ catch (err) {
133
+ if (formState) {
134
+ formState.error = err?.message || String(err) || "Unknown error occurred";
135
+ formState.isSubmitting = false;
136
+ draw();
137
+ }
138
+ }
139
+ },
140
+ };
141
+ viewerScroll = 0;
142
+ draw();
143
+ }
144
+ // ── Data Fetching ────────────────────────────────────────────────────
145
+ const SPARK_WIDTH = 30;
146
+ function drawSparkline(data, baselineMax = 0, width = SPARK_WIDTH) {
147
+ const dataChars = ["\u2581", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
148
+ const track = "\u2581"; // Lower 1/8 block as baseline
149
+ if (data.length === 0)
150
+ return track.repeat(width);
151
+ const recent = data.slice(-width);
152
+ const padLen = width - recent.length;
153
+ const max = Math.max(baselineMax, ...recent);
154
+ // Left pad = no data yet
155
+ let result = track.repeat(padLen);
156
+ if (max === 0) {
157
+ result += track.repeat(recent.length);
158
+ return result;
159
+ }
160
+ result += recent
161
+ .map((v) => {
162
+ if (v === 0)
163
+ return track;
164
+ const ratio = v / max;
165
+ const idx = Math.max(0, Math.min(dataChars.length - 1, Math.floor(ratio * dataChars.length)));
166
+ return dataChars[idx];
167
+ })
168
+ .join("");
169
+ return result;
170
+ }
171
+ function formatUptime(ms) {
172
+ const s = Math.floor(ms / 1000);
173
+ if (s < 60)
174
+ return `${s}s`;
175
+ const m = Math.floor(s / 60);
176
+ if (m < 60)
177
+ return `${m}m ${s % 60}s`;
178
+ const h = Math.floor(m / 60);
179
+ return `${h}h ${m % 60}m`;
180
+ }
181
+ async function fetchResourcesFallback() {
182
+ try {
183
+ const nativeCollections = await db.listCollections();
184
+ const nativeStreams = await db.listStreams();
185
+ // Maintain default collections/streams for UI if none exist
186
+ const defaultCols = new Set(["decisions", "load-test"]);
187
+ const defaultStrs = new Set(["project:thingd", "load-events", "activity-log"]);
188
+ for (const c of nativeCollections)
189
+ defaultCols.add(c);
190
+ for (const s of nativeStreams)
191
+ defaultStrs.add(s);
192
+ collections = Array.from(defaultCols).sort();
193
+ streams = Array.from(defaultStrs).sort();
194
+ // Fallback totals
195
+ totalObjects = await db.countObjects();
196
+ totalEventsCount = await db.countEvents();
197
+ totalActiveJobsCount = await db.countActiveJobs();
198
+ totalDeadJobsCount = await db.countDeadJobs();
199
+ }
200
+ catch {
201
+ collections = [];
202
+ streams = [];
203
+ totalObjects = 0;
204
+ }
205
+ // Queues
206
+ try {
207
+ const store = db.store;
208
+ if (store?.queues) {
209
+ queues = Array.from(store.queues.keys()).sort();
210
+ }
211
+ else {
212
+ queues = ["embed", "load-queue", "worker-queue"];
213
+ }
214
+ }
215
+ catch {
216
+ queues = ["embed", "load-queue", "worker-queue"];
217
+ }
218
+ }
219
+ async function fetchResources() {
220
+ if (driver === "native" && dbPath) {
221
+ try {
222
+ // Override the tracked totals with the actual exact DB count!
223
+ const [objCount, evtCount, activeCount, deadCount, nativeCollections, nativeStreams, nativeQueues,] = await Promise.all([
224
+ db.countObjects(),
225
+ db.countEvents(),
226
+ db.countActiveJobs(),
227
+ db.countDeadJobs(),
228
+ db.listCollections(),
229
+ db.listStreams(),
230
+ db.listQueues?.() ?? Promise.resolve([]),
231
+ ]);
232
+ totalObjects = isNaN(objCount) || objCount === 0 ? totalObjects : objCount;
233
+ totalEventsCount = isNaN(evtCount) || evtCount === 0 ? totalEventsCount : evtCount;
234
+ totalActiveJobsCount =
235
+ isNaN(activeCount) || activeCount === 0 ? totalActiveJobsCount : activeCount;
236
+ totalDeadJobsCount = isNaN(deadCount) || deadCount === 0 ? totalDeadJobsCount : deadCount;
237
+ collections = nativeCollections.length > 0 ? nativeCollections : ["decisions", "load-test"];
238
+ streams =
239
+ nativeStreams.length > 0
240
+ ? nativeStreams
241
+ : ["project:thingd", "load-events", "activity-log"];
242
+ queues = nativeQueues.length > 0 ? nativeQueues : ["embed", "load-queue", "worker-queue"];
243
+ }
244
+ catch {
245
+ // Fallback if sqlite3 fails
246
+ await fetchResourcesFallback();
247
+ }
248
+ }
249
+ else {
250
+ await fetchResourcesFallback();
251
+ }
252
+ // Calculate Deltas for Operations Throughput Rates
253
+ const prevObjects = objectsHistory.length > 0 ? objectsHistory[objectsHistory.length - 1] : totalObjects;
254
+ const prevEvents = eventsHistory.length > 0 ? eventsHistory[eventsHistory.length - 1] : totalEventsCount;
255
+ // Polling is every 2000ms. Operations per second = delta / 2
256
+ const objectWriteRate = Math.max(0, Math.round((totalObjects - prevObjects) / 2));
257
+ const eventAppendRate = Math.max(0, Math.round((totalEventsCount - prevEvents) / 2));
258
+ // Push Histories with Initial Pre-population to prevent misleading growth wiggles
259
+ if (objectsHistory.length === 0) {
260
+ objectsHistory = new Array(60).fill(totalObjects);
261
+ }
262
+ else {
263
+ objectsHistory.push(totalObjects);
264
+ if (objectsHistory.length > 60)
265
+ objectsHistory.shift();
266
+ }
267
+ if (eventsHistory.length === 0) {
268
+ eventsHistory = new Array(60).fill(totalEventsCount);
269
+ }
270
+ else {
271
+ eventsHistory.push(totalEventsCount);
272
+ if (eventsHistory.length > 60)
273
+ eventsHistory.shift();
274
+ }
275
+ if (activeJobsHistory.length === 0) {
276
+ activeJobsHistory = new Array(60).fill(totalActiveJobsCount);
277
+ }
278
+ else {
279
+ activeJobsHistory.push(totalActiveJobsCount);
280
+ if (activeJobsHistory.length > 60)
281
+ activeJobsHistory.shift();
282
+ }
283
+ if (deadJobsHistory.length === 0) {
284
+ deadJobsHistory = new Array(60).fill(totalDeadJobsCount);
285
+ }
286
+ else {
287
+ deadJobsHistory.push(totalDeadJobsCount);
288
+ if (deadJobsHistory.length > 60)
289
+ deadJobsHistory.shift();
290
+ }
291
+ if (objectWriteRateHistory.length === 0) {
292
+ objectWriteRateHistory = new Array(60).fill(objectWriteRate);
293
+ }
294
+ else {
295
+ objectWriteRateHistory.push(objectWriteRate);
296
+ if (objectWriteRateHistory.length > 60)
297
+ objectWriteRateHistory.shift();
298
+ }
299
+ if (eventAppendRateHistory.length === 0) {
300
+ eventAppendRateHistory = new Array(60).fill(eventAppendRate);
301
+ }
302
+ else {
303
+ eventAppendRateHistory.push(eventAppendRate);
304
+ if (eventAppendRateHistory.length > 60)
305
+ eventAppendRateHistory.shift();
306
+ }
307
+ // Database Size (only if native)
308
+ let sizeKb = 0;
309
+ if (driver === "native" && dbPath) {
310
+ try {
311
+ sizeKb = Math.round(fs.statSync(dbPath).size / 1024);
312
+ }
313
+ catch { }
314
+ }
315
+ if (dbSizeHistory.length === 0) {
316
+ dbSizeHistory = new Array(60).fill(sizeKb);
317
+ }
318
+ else {
319
+ dbSizeHistory.push(sizeKb);
320
+ if (dbSizeHistory.length > 60)
321
+ dbSizeHistory.shift();
322
+ }
323
+ }
324
+ function buildTree() {
325
+ if (!connected) {
326
+ return [
327
+ {
328
+ id: "drv:memory",
329
+ type: "driver",
330
+ label: `${pc.dim("●")} ${pc.bold("Memory")} ${pc.dim("ephemeral")}`,
331
+ depth: 0,
332
+ expandable: false,
333
+ ref: { driver: "memory" },
334
+ },
335
+ {
336
+ id: "drv:native",
337
+ type: "driver",
338
+ label: `${pc.dim("●")} ${pc.bold("Native")} ${pc.dim("SQLite file")}`,
339
+ depth: 0,
340
+ expandable: false,
341
+ ref: { driver: "native" },
342
+ },
343
+ {
344
+ id: "drv:cloud",
345
+ type: "driver",
346
+ label: `${pc.dim("●")} ${pc.bold("Cloud")} ${pc.dim("remote")}`,
347
+ depth: 0,
348
+ expandable: false,
349
+ ref: { driver: "cloud" },
350
+ },
351
+ ];
352
+ }
353
+ const nodes = [];
354
+ // Collections
355
+ const colsOpen = expandedSet.has("cat:collections");
356
+ nodes.push({
357
+ id: "cat:collections",
358
+ type: "category",
359
+ label: `${colsOpen ? pc.yellow("▾") : pc.dim("▸")} ${pc.bold("Collections")}`,
360
+ depth: 0,
361
+ expandable: true,
362
+ });
363
+ if (colsOpen) {
364
+ if (collections.length === 0) {
365
+ nodes.push({
366
+ id: "empty:collections",
367
+ type: "status",
368
+ label: pc.dim("(empty)"),
369
+ depth: 1,
370
+ expandable: false,
371
+ });
372
+ }
373
+ for (const col of collections) {
374
+ const colId = `col:${col}`;
375
+ const colOpen = expandedSet.has(colId);
376
+ nodes.push({
377
+ id: colId,
378
+ parentId: "cat:collections",
379
+ type: "collection",
380
+ label: `${colOpen ? pc.yellow("▾") : pc.dim("▸")} ${pc.cyan(col)}`,
381
+ depth: 1,
382
+ expandable: true,
383
+ ref: { name: col },
384
+ });
385
+ if (colOpen) {
386
+ const objs = objectsByCollection.get(col) ?? [];
387
+ if (objs.length === 0) {
388
+ nodes.push({
389
+ id: `empty:${col}`,
390
+ parentId: colId,
391
+ type: "status",
392
+ label: pc.dim("(no objects)"),
393
+ depth: 2,
394
+ expandable: false,
395
+ });
396
+ }
397
+ for (const objId of objs) {
398
+ nodes.push({
399
+ id: `obj:${col}:${objId}`,
400
+ parentId: colId,
401
+ type: "object",
402
+ label: `${pc.dim("●")} ${objId}`,
403
+ depth: 2,
404
+ expandable: false,
405
+ ref: { collection: col, id: objId },
406
+ });
407
+ }
408
+ }
409
+ }
410
+ }
411
+ // Streams
412
+ const strsOpen = expandedSet.has("cat:streams");
413
+ nodes.push({
414
+ id: "cat:streams",
415
+ type: "category",
416
+ label: `${strsOpen ? pc.yellow("▾") : pc.dim("▸")} ${pc.bold("Streams")}`,
417
+ depth: 0,
418
+ expandable: true,
419
+ });
420
+ if (strsOpen) {
421
+ if (streams.length === 0) {
422
+ nodes.push({
423
+ id: "empty:streams",
424
+ type: "status",
425
+ label: pc.dim("(empty)"),
426
+ depth: 1,
427
+ expandable: false,
428
+ });
429
+ }
430
+ for (const stream of streams) {
431
+ nodes.push({
432
+ id: `stream:${stream}`,
433
+ parentId: "cat:streams",
434
+ type: "stream",
435
+ label: `${pc.dim("~")} ${pc.green(stream)}`,
436
+ depth: 1,
437
+ expandable: false,
438
+ ref: { name: stream },
439
+ });
440
+ }
441
+ }
442
+ // Queues
443
+ const qOpen = expandedSet.has("cat:queues");
444
+ nodes.push({
445
+ id: "cat:queues",
446
+ type: "category",
447
+ label: `${qOpen ? pc.yellow("▾") : pc.dim("▸")} ${pc.bold("Queues")}`,
448
+ depth: 0,
449
+ expandable: true,
450
+ });
451
+ if (qOpen) {
452
+ if (queues.length === 0) {
453
+ nodes.push({
454
+ id: "empty:queues",
455
+ type: "status",
456
+ label: pc.dim("(empty)"),
457
+ depth: 1,
458
+ expandable: false,
459
+ });
460
+ }
461
+ for (const q of queues) {
462
+ nodes.push({
463
+ id: `queue:${q}`,
464
+ parentId: "cat:queues",
465
+ type: "queue",
466
+ label: `${pc.dim("◆")} ${pc.magenta(q)}`,
467
+ depth: 1,
468
+ expandable: false,
469
+ ref: { name: q },
470
+ });
471
+ }
472
+ }
473
+ // Metrics
474
+ nodes.push({
475
+ id: "node:status",
476
+ type: "status",
477
+ label: `${pc.dim("○")} ${pc.dim("Metrics")}`,
478
+ depth: 0,
479
+ expandable: false,
480
+ });
481
+ return nodes;
482
+ }
483
+ // ── Content Loading ──────────────────────────────────────────────────
484
+ function scheduleLoad(node) {
485
+ if (!connected) {
486
+ // Show driver info in viewer
487
+ if (node.type === "driver" && node.ref) {
488
+ const d = node.ref.driver;
489
+ let info = "";
490
+ if (d === "memory") {
491
+ info = `${pc.bold("Memory Driver")}\n\n`;
492
+ info += ` Ephemeral in-memory database.\n`;
493
+ info += ` All data is destroyed on exit.\n\n`;
494
+ info += ` ${pc.dim("Best for: testing, prototyping")}\n\n`;
495
+ info += ` Press ${pc.bold("Enter")} to connect.`;
496
+ }
497
+ else if (d === "native") {
498
+ info = `${pc.bold("Native Driver")}\n\n`;
499
+ info += ` Persistent SQLite database.\n`;
500
+ info += ` Data is stored on disk.\n\n`;
501
+ info += ` ${pc.dim("Best for: local development, single-node")}\n\n`;
502
+ info += ` Press ${pc.bold("Enter")} to connect.`;
503
+ }
504
+ else if (d === "cloud") {
505
+ info = `${pc.bold("Cloud Driver")}\n\n`;
506
+ info += ` Connect to a remote thingd instance.\n`;
507
+ info += ` Requires a URL and optional auth token.\n\n`;
508
+ info += ` ${pc.dim("Best for: production, multi-node")}\n\n`;
509
+ info += ` Press ${pc.bold("Enter")} to connect.`;
510
+ }
511
+ viewerLines = info.split("\n");
512
+ loadedItemId = node.id;
513
+ }
514
+ return;
515
+ }
516
+ if (loadTimer)
517
+ clearTimeout(loadTimer);
518
+ if (loadedItemId === node.id)
519
+ return;
520
+ loadedItemId = node.id;
521
+ viewerLines = [pc.dim("Loading...")];
522
+ viewerScroll = 0;
523
+ draw();
524
+ loadTimer = setTimeout(async () => {
525
+ await loadContent(node);
526
+ }, 80);
527
+ }
528
+ async function loadContent(node) {
529
+ const snapId = node.id;
530
+ try {
531
+ let content = "";
532
+ if (node.type === "object" && node.ref) {
533
+ const data = await db.get(node.ref.collection, node.ref.id);
534
+ content = data ? highlightJson(data) : pc.yellow("Object not found.");
535
+ }
536
+ else if (node.type === "collection" && node.ref) {
537
+ const objs = objectsByCollection.get(node.ref.name) ?? [];
538
+ const hist = colHistory.get(node.ref.name) ?? [];
539
+ let res = `${pc.bold(node.ref.name)} ${pc.dim(`(${objs.length} objects)`)}\n\n`;
540
+ res += `${pc.bold("Performance")}\n`;
541
+ res += ` Volume ${pc.cyan(drawSparkline(hist))}\n\n`;
542
+ if (objs.length === 0) {
543
+ res += pc.dim(" No objects in this collection.");
544
+ }
545
+ else {
546
+ const lines = objs.map((id) => ` ${pc.dim("●")} ${id}`);
547
+ res += lines.join("\n");
548
+ }
549
+ content = res;
550
+ }
551
+ else if (node.type === "stream" && node.ref) {
552
+ const events = await db.events.list(node.ref.name);
553
+ const hist = streamHistory.get(node.ref.name) ?? [];
554
+ let res = `${pc.bold(node.ref.name)} ${pc.dim(`(${events.length} events)`)}\n\n`;
555
+ res += `${pc.bold("Performance")}\n`;
556
+ res += ` Volume ${pc.green(drawSparkline(hist))}\n\n`;
557
+ if (events.length === 0) {
558
+ res += pc.dim(" No events in this stream.");
559
+ }
560
+ else {
561
+ const lines = events.map((e) => {
562
+ const ts = e.createdAt ? pc.dim(String(e.createdAt)) : "";
563
+ const type = pc.magenta(e.type || "unknown");
564
+ return ` ${ts} ${type}`;
565
+ });
566
+ res += lines.join("\n");
567
+ }
568
+ content = res;
569
+ }
570
+ else if (node.type === "queue" && node.ref) {
571
+ const queue = db.queue(node.ref.name);
572
+ const [active, dead] = await Promise.all([queue.list(), queue.dead()]);
573
+ const aHist = queueActiveHistory.get(node.ref.name) ?? [];
574
+ const dHist = queueDeadHistory.get(node.ref.name) ?? [];
575
+ let res = `${pc.bold(node.ref.name)}\n\n`;
576
+ res += `${pc.bold("Performance")}\n`;
577
+ res += ` Active ${pc.cyan(drawSparkline(aHist))}\n`;
578
+ res += ` Dead ${pc.red(drawSparkline(dHist))}\n\n`;
579
+ res += `${pc.cyan("Active")} ${pc.dim(`(${active.length})`)}\n`;
580
+ if (active.length === 0) {
581
+ res += pc.dim(" No active jobs\n");
582
+ }
583
+ else {
584
+ for (const j of active) {
585
+ res += ` ${pc.dim("●")} ${j.id} ${pc.yellow(j.status)} ${pc.dim(`${j.attempts}/${j.maxAttempts}`)}\n`;
586
+ }
587
+ }
588
+ res += `\n${pc.red("Dead")} ${pc.dim(`(${dead.length})`)}\n`;
589
+ if (dead.length === 0) {
590
+ res += pc.dim(" No dead jobs\n");
591
+ }
592
+ else {
593
+ for (const j of dead) {
594
+ res += ` ${pc.dim("●")} ${j.id} ${pc.dim(`${j.attempts}/${j.maxAttempts}`)}\n`;
595
+ }
596
+ }
597
+ content = res;
598
+ }
599
+ else if (node.type === "status") {
600
+ const W = process.stdout.columns || 80;
601
+ const sideW = Math.min(40, Math.max(20, Math.floor(W * 0.35)));
602
+ const viewW = Math.max(20, W - sideW - 3);
603
+ const fullRule = pc.dim("─".repeat(Math.max(10, viewW - 2)));
604
+ const uptime = startedAt ? formatUptime(Date.now() - startedAt) : "--";
605
+ // ── Header
606
+ const titleStr = `${pc.bold("thingd")} ${pc.cyan("METRICS")}`;
607
+ const pathStr = pc.dim(dbPath || ":memory:");
608
+ const pathRaw = dbPath || ":memory:";
609
+ const gap = Math.max(2, viewW - 2 - 8 - "METRICS".length - pathRaw.length);
610
+ content = ` ${titleStr}${" ".repeat(gap)}${pathStr}\n`;
611
+ content += ` ${pc.dim("uptime")} ${pc.dim(uptime)}\n`;
612
+ content += ` ${fullRule}\n\n`;
613
+ // ── Physical Store & Driver Logic
614
+ let sizeKb = 0;
615
+ if (driver === "native" && dbPath) {
616
+ try {
617
+ sizeKb = Math.round(fs.statSync(dbPath).size / 1024);
618
+ }
619
+ catch { }
620
+ }
621
+ const dbSizeStr = driver === "native" ? `${sizeKb} KB` : "--";
622
+ let driverName = "Unknown";
623
+ if (driver === "memory")
624
+ driverName = "SQLite (Memory)";
625
+ else if (driver === "native")
626
+ driverName = "SQLite (Native)";
627
+ else if (driver === "cloud")
628
+ driverName = "Cloud (Remote)";
629
+ const objVal = String(totalObjects).padEnd(8);
630
+ const evtVal = String(totalEventsCount).padEnd(8);
631
+ const actVal = String(totalActiveJobsCount).padEnd(8);
632
+ const ddtVal = String(totalDeadJobsCount).padEnd(8);
633
+ // ── Metrics Layout
634
+ content += ` ${pc.bold("CAPACITY & STORAGE METRICS")}\n`;
635
+ content += ` ${fullRule}\n`;
636
+ content += ` ${pc.dim("Objects").padEnd(20)} ${pc.cyan(objVal)} ${pc.dim("total objects stored")}\n`;
637
+ content += ` ${pc.dim("Events").padEnd(20)} ${pc.green(evtVal)} ${pc.dim("total events in streams")}\n`;
638
+ content += ` ${pc.dim("Active Jobs").padEnd(20)} ${pc.yellow(actVal)} ${pc.dim("jobs currently processing")}\n`;
639
+ content += ` ${pc.dim("Dead Jobs").padEnd(20)} ${pc.red(ddtVal)} ${pc.dim("failed/dead jobs")}\n\n`;
640
+ content += ` ${pc.bold("PHYSICAL STORE & CONNECTION")}\n`;
641
+ content += ` ${fullRule}\n`;
642
+ content += ` ${pc.dim("Database Size").padEnd(20)} ${pc.blue(dbSizeStr)}\n`;
643
+ content += ` ${pc.dim("Driver Type").padEnd(20)} ${driverName}\n`;
644
+ content += ` ${pc.dim("Storage Path").padEnd(20)} ${dbPath || ":memory:"}\n`;
645
+ content += ` ${pc.dim("CLI Shortcuts").padEnd(20)} ${pc.bold("[c]")} Create ${pc.bold("[r]")} Refresh\n\n`;
646
+ // ── Throughput & Activity Metrics
647
+ const currentWrite = objectWriteRateHistory[objectWriteRateHistory.length - 1] ?? 0;
648
+ const currentAppend = eventAppendRateHistory[eventAppendRateHistory.length - 1] ?? 0;
649
+ const peakWrite = Math.max(5, ...objectWriteRateHistory);
650
+ const peakAppend = Math.max(5, ...eventAppendRateHistory);
651
+ // Adjust sparkline width to prevent terminal wrapping. Total fixed chars ~55.
652
+ const sparkW = Math.max(10, viewW - 55);
653
+ const wLine = drawSparkline(objectWriteRateHistory, 5, sparkW);
654
+ const apLine = drawSparkline(eventAppendRateHistory, 5, sparkW);
655
+ content += ` ${pc.bold("THROUGHPUT & ACTIVITY METRICS")}\n`;
656
+ content += ` ${fullRule}\n`;
657
+ content += ` ${pc.dim("Object Writes".padEnd(16))} ${pc.cyan(wLine)} ${pc.cyan(String(currentWrite).padEnd(4))} ${pc.dim(`writes/s (Peak: ${peakWrite}/s)`)}\n\n`;
658
+ content += ` ${pc.dim("Event Appends".padEnd(16))} ${pc.green(apLine)} ${pc.green(String(currentAppend).padEnd(4))} ${pc.dim(`appends/s (Peak: ${peakAppend}/s)`)}\n\n`;
659
+ }
660
+ else if (node.type === "category") {
661
+ content = pc.dim("Expand to browse items.");
662
+ }
663
+ else {
664
+ content = "";
665
+ }
666
+ if (loadedItemId === snapId) {
667
+ viewerLines = content.split("\n");
668
+ draw();
669
+ }
670
+ }
671
+ catch (err) {
672
+ if (loadedItemId === snapId) {
673
+ viewerLines = [pc.red(`Error: ${err.message}`)];
674
+ draw();
675
+ }
676
+ }
677
+ }
678
+ // ── Rendering ────────────────────────────────────────────────────────
679
+ function draw() {
680
+ const W = process.stdout.columns || 80;
681
+ const H = process.stdout.rows || 24;
682
+ const sideW = Math.min(40, Math.max(20, Math.floor(W * 0.35)));
683
+ const viewW = Math.max(1, W - sideW - 3); // 3 = " | "
684
+ const bodyH = Math.max(1, H - 4); // header(1) + separator(1) + separator(1) + footer(1)
685
+ const tree = buildTree();
686
+ // Clamp cursor
687
+ if (tree.length === 0) {
688
+ cursorIndex = 0;
689
+ }
690
+ else if (cursorIndex >= tree.length) {
691
+ cursorIndex = tree.length - 1;
692
+ }
693
+ if (cursorIndex < 0)
694
+ cursorIndex = 0;
695
+ // Scroll sidebar
696
+ if (cursorIndex >= scrollOffset + bodyH) {
697
+ scrollOffset = cursorIndex - bodyH + 1;
698
+ }
699
+ else if (cursorIndex < scrollOffset) {
700
+ scrollOffset = cursorIndex;
701
+ }
702
+ scrollOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, tree.length - bodyH)));
703
+ let buf = "\u001B[H"; // Move to top-left
704
+ // Header
705
+ let titleStr;
706
+ if (!connected) {
707
+ titleStr = ` thingd ${pc.dim("|")} Select Environment `;
708
+ }
709
+ else if (formState?.active) {
710
+ titleStr = ` thingd ${pc.dim("|")} ${driver.toUpperCase()} ${pc.dim("|")} Input Mode `;
711
+ }
712
+ else {
713
+ titleStr = ` thingd ${pc.dim("|")} ${driver.toUpperCase()} ${pc.dim("|")} ${dbPath} `;
714
+ }
715
+ buf += pc.inverse(padToWidth(titleStr, W)) + "\n";
716
+ // Separator
717
+ buf += pc.dim("─".repeat(sideW) + "─┬─" + "─".repeat(viewW)) + "\n";
718
+ // Build Form Lines if active
719
+ if (formState?.active) {
720
+ viewerLines = [`${pc.bgCyan(pc.black(` ${formState.title} `))}`, ""];
721
+ for (let i = 0; i < formState.fields.length; i++) {
722
+ const f = formState.fields[i];
723
+ if (!f)
724
+ continue;
725
+ const isSel = i === formState.activeIndex;
726
+ let displayLabel = f.label;
727
+ if (f.options && f.allowCustom && f.value && !f.options.includes(f.value)) {
728
+ displayLabel += pc.green(" (New)");
729
+ }
730
+ viewerLines.push(`${isSel ? pc.yellow("▶") : " "} ${pc.bold(displayLabel)}`);
731
+ let displayVal = f.value;
732
+ if (f.isSecret)
733
+ displayVal = "*".repeat(displayVal.length);
734
+ if (displayVal === "" && f.placeholder) {
735
+ displayVal = pc.dim(f.placeholder);
736
+ }
737
+ if (isSel && !formState.isSubmitting) {
738
+ if (f.options && !f.allowCustom) {
739
+ viewerLines.push(` ${pc.cyan("◀ ")}${pc.inverse(displayVal || " ")}${pc.cyan(" ▶")}`);
740
+ }
741
+ else if (f.options && f.allowCustom) {
742
+ const inOptions = f.options.includes(f.value);
743
+ if (inOptions) {
744
+ viewerLines.push(` ${pc.cyan("◀ ")}${displayVal}${pc.inverse(" ")}${pc.cyan(" ▶")}`);
745
+ }
746
+ else {
747
+ viewerLines.push(` ${displayVal}${pc.inverse(" ")}`);
748
+ }
749
+ }
750
+ else {
751
+ viewerLines.push(` ${displayVal}${pc.inverse(" ")}`); // cursor block
752
+ }
753
+ }
754
+ else {
755
+ viewerLines.push(` ${displayVal}`);
756
+ }
757
+ viewerLines.push("");
758
+ }
759
+ if (formState.error) {
760
+ viewerLines.push(pc.red(formState.error));
761
+ }
762
+ if (formState.isSubmitting) {
763
+ viewerLines.push(pc.cyan("Processing..."));
764
+ }
765
+ viewerLines.push("");
766
+ viewerLines.push(pc.dim(" [Enter] Next/Submit [Esc] Cancel"));
767
+ }
768
+ // Body rows
769
+ for (let r = 0; r < bodyH; r++) {
770
+ // Sidebar
771
+ const treeIdx = r + scrollOffset;
772
+ const node = tree[treeIdx];
773
+ const isActive = treeIdx === cursorIndex;
774
+ let left;
775
+ if (!node) {
776
+ left = " ".repeat(sideW);
777
+ }
778
+ else {
779
+ const indent = " ".repeat(node.depth);
780
+ const raw = indent + node.label;
781
+ left = fitToWidth(raw, sideW, isActive);
782
+ }
783
+ // Viewer
784
+ const vLine = viewerLines[r + viewerScroll] ?? "";
785
+ const right = fitToWidth(vLine, viewW, false);
786
+ buf += left + pc.dim(" │ ") + right + "\n";
787
+ }
788
+ // Separator
789
+ buf += pc.dim("─".repeat(sideW) + "─┴─" + "─".repeat(viewW)) + "\n";
790
+ // Footer
791
+ let help;
792
+ if (formState?.active) {
793
+ const hasOptions = formState.fields[formState.activeIndex]?.options;
794
+ help = ` ${pc.dim("↑↓")} focus ${hasOptions ? pc.dim("←→") + " select " : ""}${pc.dim("enter")} submit ${pc.dim("ctrl+e")} editor ${pc.dim("esc")} cancel `;
795
+ }
796
+ else if (!connected) {
797
+ help = ` ${pc.dim("↑↓")} nav ${pc.dim("enter")} connect ${pc.dim("q")} quit `;
798
+ }
799
+ else {
800
+ help = ` ${pc.dim("↑↓")} nav ${pc.dim("←→")} toggle ${pc.dim("c")} create ${pc.dim("e")} edit ${pc.dim("d")} delete ${pc.dim("/")} search ${pc.dim("i")} info ${pc.dim("r")} refresh ${pc.dim("s")} switch ${pc.dim("q")} quit `;
801
+ }
802
+ buf += padToWidth(help, W);
803
+ // Clear to end
804
+ buf += "\u001B[J";
805
+ process.stdout.write(buf);
806
+ }
807
+ /** Pad/truncate `text` to exactly `width` visible characters. */
808
+ function fitToWidth(text, width, highlight) {
809
+ const vw = visibleWidth(text);
810
+ let result;
811
+ if (vw > width) {
812
+ // Truncate (crude but safe: just truncate the clean text approach)
813
+ result = truncateToWidth(text, width - 1) + pc.dim("…");
814
+ }
815
+ else {
816
+ result = text + " ".repeat(Math.max(0, width - vw));
817
+ }
818
+ return highlight ? pc.inverse(result) : result;
819
+ }
820
+ /** Truncate a string (potentially with ANSI codes) to a target visible width. */
821
+ function truncateToWidth(text, targetW) {
822
+ let w = 0;
823
+ let i = 0;
824
+ const chars = [...text];
825
+ let result = "";
826
+ while (i < chars.length && w < targetW) {
827
+ const ch = chars[i];
828
+ if (ch === undefined)
829
+ break;
830
+ if (ch === "\u001B") {
831
+ // Consume ANSI sequence
832
+ let seq = ch;
833
+ i++;
834
+ while (i < chars.length) {
835
+ const next = chars[i];
836
+ if (next === undefined || /[a-zA-Z]/.test(next))
837
+ break;
838
+ seq += next;
839
+ i++;
840
+ }
841
+ if (i < chars.length && chars[i] !== undefined) {
842
+ seq += chars[i];
843
+ i++;
844
+ }
845
+ result += seq;
846
+ continue;
847
+ }
848
+ const cp = ch.codePointAt(0);
849
+ if (cp === undefined)
850
+ break;
851
+ const cw = cp > 0xffff ? 2 : 1;
852
+ if (w + cw > targetW)
853
+ break;
854
+ result += ch;
855
+ w += cw;
856
+ i++;
857
+ }
858
+ return result;
859
+ }
860
+ /** Simple pad with visible width awareness. */
861
+ function padToWidth(text, width) {
862
+ const vw = visibleWidth(text);
863
+ if (vw >= width)
864
+ return text;
865
+ return text + " ".repeat(width - vw);
866
+ }
867
+ // ── Utils ────────────────────────────────────────────────────────────
868
+ async function launchEditor(f) {
869
+ if (process.stdin.isTTY)
870
+ process.stdin.setRawMode(false);
871
+ process.stdin.removeListener("keypress", keypressHandler);
872
+ console.clear();
873
+ const tmpFile = path.join(os.tmpdir(), `thingd-edit-${Date.now()}.json`);
874
+ let initialContent = "";
875
+ if (f.value && f.value !== "") {
876
+ try {
877
+ initialContent = JSON.stringify(JSON.parse(f.value), null, 2);
878
+ }
879
+ catch {
880
+ initialContent = f.value;
881
+ }
882
+ }
883
+ else {
884
+ initialContent = "{\n \n}\n";
885
+ }
886
+ fs.writeFileSync(tmpFile, initialContent);
887
+ const editor = process.env.EDITOR || "vim";
888
+ await new Promise((resolve) => {
889
+ const child = spawn(editor, [tmpFile], { stdio: "inherit" });
890
+ child.on("exit", () => resolve());
891
+ child.on("error", (err) => {
892
+ console.error("Failed to start editor:", err);
893
+ setTimeout(() => resolve(), 2000);
894
+ });
895
+ });
896
+ try {
897
+ const newContent = fs.readFileSync(tmpFile, "utf-8");
898
+ f.value = newContent.trim();
899
+ }
900
+ catch (e) { }
901
+ if (process.stdin.isTTY)
902
+ process.stdin.setRawMode(true);
903
+ process.stdin.on("keypress", keypressHandler);
904
+ draw();
905
+ }
906
+ function parsePayload(str) {
907
+ str = str.trim();
908
+ if (!str)
909
+ return {};
910
+ if (str.startsWith("{") || str.startsWith("[")) {
911
+ return JSON.parse(str);
912
+ }
913
+ const obj = {};
914
+ const parts = str.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
915
+ for (const part of parts) {
916
+ const eqIdx = part.indexOf("=");
917
+ if (eqIdx === -1) {
918
+ obj[part] = true;
919
+ continue;
920
+ }
921
+ const k = part.substring(0, eqIdx);
922
+ let v = part.substring(eqIdx + 1);
923
+ if (v.startsWith('"') && v.endsWith('"')) {
924
+ v = v.substring(1, v.length - 1);
925
+ }
926
+ else {
927
+ if (v === "true")
928
+ v = true;
929
+ else if (v === "false")
930
+ v = false;
931
+ else if (!isNaN(Number(v)))
932
+ v = Number(v);
933
+ }
934
+ obj[k] = v;
935
+ }
936
+ return obj;
937
+ }
938
+ // ── Mutation Handlers ────────────────────────────────────────────────
939
+ async function handleCreate(selected) {
940
+ let defaultCol = "";
941
+ let defaultStream = "";
942
+ let defaultQueue = "";
943
+ if (selected) {
944
+ if (selected.type === "collection") {
945
+ defaultCol = selected.ref?.name ?? "";
946
+ }
947
+ else if (selected.type === "object") {
948
+ defaultCol = selected.ref?.collection ?? "";
949
+ }
950
+ else if (selected.type === "stream") {
951
+ defaultStream = selected.ref?.name ?? "";
952
+ }
953
+ else if (selected.type === "queue") {
954
+ defaultQueue = selected.ref?.name ?? "";
955
+ }
956
+ }
957
+ openForm("Create Resource", [
958
+ {
959
+ id: "kind",
960
+ label: "Kind (object, event, queue)",
961
+ value: defaultStream ? "event" : defaultQueue ? "queue" : "object",
962
+ options: ["object", "event", "queue"],
963
+ },
964
+ {
965
+ id: "target",
966
+ label: "Target (Collection, Stream, or Queue Name)",
967
+ value: defaultCol || defaultStream || defaultQueue,
968
+ options: Array.from(new Set([...collections, ...streams, ...queues])).sort(),
969
+ allowCustom: true,
970
+ },
971
+ {
972
+ id: "objId",
973
+ label: "Object ID (only for objects, auto if blank)",
974
+ placeholder: "Leave blank to auto-generate",
975
+ },
976
+ { id: "payload", label: "Data (JSON or key=value)", placeholder: 'e.g. name="John" age=30' },
977
+ ], async (vals) => {
978
+ const kind = (vals.kind || "").toLowerCase();
979
+ const target = (vals.target || "").trim();
980
+ if (!target)
981
+ throw new Error("Target is required.");
982
+ if (kind === "object") {
983
+ let id = vals.objId?.trim();
984
+ if (!id) {
985
+ try {
986
+ id = crypto.randomUUID();
987
+ }
988
+ catch (e) {
989
+ id = "obj_" + Date.now().toString(36) + Math.random().toString(36).substring(2);
990
+ }
991
+ }
992
+ const data = parsePayload(vals.payload || "");
993
+ await db.put(target, { id, ...data });
994
+ expandedSet.add("cat:collections");
995
+ expandedSet.add(`col:${target}`);
996
+ }
997
+ else if (kind === "event") {
998
+ if (!vals.payload?.trim())
999
+ throw new Error("Event Type is required (in Data field for events).");
1000
+ await db.events.append(target, { type: vals.payload.trim() });
1001
+ expandedSet.add("cat:streams");
1002
+ }
1003
+ else if (kind === "queue") {
1004
+ if (!vals.payload?.trim())
1005
+ throw new Error("Payload is required.");
1006
+ const data = parsePayload(vals.payload);
1007
+ await db.queue(target).push(data);
1008
+ expandedSet.add("cat:queues");
1009
+ }
1010
+ else {
1011
+ throw new Error("Kind must be 'object', 'event', or 'queue'.");
1012
+ }
1013
+ });
1014
+ }
1015
+ async function handleEdit(selected) {
1016
+ if (!selected)
1017
+ return;
1018
+ if (selected.type === "object" && selected.ref) {
1019
+ const ref = selected.ref;
1020
+ const current = await db.get(ref.collection, ref.id);
1021
+ const clean = current ? { ...current } : {};
1022
+ for (const k of ["id", "collection", "createdAt", "updatedAt", "version"]) {
1023
+ delete clean[k];
1024
+ }
1025
+ openForm(`Edit Object: ${ref.id}`, [{ id: "payload", label: "Data (JSON or key=value)", value: JSON.stringify(clean) }], async (vals) => {
1026
+ const data = parsePayload(vals.payload || "");
1027
+ await db.put(ref.collection, { id: ref.id, ...data });
1028
+ });
1029
+ }
1030
+ else if (selected.type === "queue" && selected.ref) {
1031
+ const ref = selected.ref;
1032
+ const queue = db.queue(ref.name);
1033
+ openForm(`Manage Queue: ${ref.name}`, [
1034
+ { id: "action", label: "Action (claim, push)", value: "claim", options: ["claim", "push"] },
1035
+ {
1036
+ id: "payload",
1037
+ label: "Job Data (JSON or key=value, only for push)",
1038
+ placeholder: 'task="email"',
1039
+ },
1040
+ ], async (vals) => {
1041
+ const action = vals.action || "";
1042
+ if (action === "claim") {
1043
+ const job = await queue.claim();
1044
+ if (job) {
1045
+ throw new Error(`Claimed job: ${job.id}`);
1046
+ }
1047
+ else {
1048
+ throw new Error("No ready jobs.");
1049
+ }
1050
+ }
1051
+ else if (action === "push") {
1052
+ const data = parsePayload(vals.payload || "");
1053
+ await queue.push(data);
1054
+ }
1055
+ else {
1056
+ throw new Error("Action must be 'claim' or 'push'.");
1057
+ }
1058
+ });
1059
+ }
1060
+ else {
1061
+ openForm("Edit Not Supported", [{ id: "msg", label: "Error", value: "Editing is only available for Objects and Queues." }], async () => { });
1062
+ }
1063
+ }
1064
+ async function handleDelete(selected) {
1065
+ if (!selected)
1066
+ return;
1067
+ if (selected.type === "object" && selected.ref) {
1068
+ const ref = selected.ref;
1069
+ openForm(`Delete Object: ${ref.id}`, [{ id: "confirm", label: 'Type "yes" to confirm deletion', placeholder: "yes" }], async (vals) => {
1070
+ if ((vals.confirm || "").toLowerCase() !== "yes")
1071
+ throw new Error("Canceled");
1072
+ await db.delete(ref.collection, ref.id);
1073
+ });
1074
+ }
1075
+ else if (selected.type === "queue" && selected.ref) {
1076
+ const ref = selected.ref;
1077
+ openForm(`Resolve Queue Job`, [
1078
+ { id: "jobId", label: "Leased Job ID", placeholder: "job-id" },
1079
+ { id: "action", label: "Action (ack, nack)", value: "ack" },
1080
+ ], async (vals) => {
1081
+ const jobId = (vals.jobId || "").trim();
1082
+ const action = vals.action || "";
1083
+ if (!jobId)
1084
+ throw new Error("Job ID required.");
1085
+ if (action === "ack") {
1086
+ await db.queue(ref.name).ack(jobId);
1087
+ }
1088
+ else if (action === "nack") {
1089
+ await db.queue(ref.name).nack(jobId, { error: "Rejected via CLI" });
1090
+ }
1091
+ else {
1092
+ throw new Error("Action must be 'ack' or 'nack'.");
1093
+ }
1094
+ });
1095
+ }
1096
+ else {
1097
+ openForm("Delete Not Supported", [{ id: "msg", label: "Error", value: "Deletion is only available for Objects and Queues." }], async () => { });
1098
+ }
1099
+ }
1100
+ async function handleSearch() {
1101
+ openForm("Global Search", [
1102
+ { id: "query", label: "Search Query", placeholder: "text to search" },
1103
+ { id: "limit", label: "Limit (optional)", placeholder: "100" },
1104
+ ], async (vals) => {
1105
+ const query = (vals.query || "").trim();
1106
+ if (!query)
1107
+ throw new Error("Search query required.");
1108
+ const limitStr = vals.limit || "";
1109
+ const options = {};
1110
+ if (limitStr) {
1111
+ const limit = parseInt(limitStr, 10);
1112
+ if (!isNaN(limit))
1113
+ options.limit = limit;
1114
+ }
1115
+ const results = await db.search(query, options);
1116
+ // Display results in the viewer
1117
+ viewerLines = [
1118
+ ` ${pc.bold("Search Results:")} ${pc.cyan(query)}`,
1119
+ "",
1120
+ ...(results.length === 0 ? [" No results found."] : []),
1121
+ ...results.map((r) => {
1122
+ const id = pc.green(r.id);
1123
+ const col = pc.cyan(r.kind === "object" ? r.collection : r.stream);
1124
+ const textStr = r.value?.text ? pc.dim(r.value.text.substring(0, 100)) : "";
1125
+ return ` ${col} / ${id} ${textStr}`;
1126
+ }),
1127
+ ];
1128
+ loadedItemId = "search_results";
1129
+ });
1130
+ }
1131
+ async function handleInfo() {
1132
+ const lines = [
1133
+ ` ${pc.bold("Connection Status")}`,
1134
+ "",
1135
+ ` Driver: ${pc.cyan(driver)}`,
1136
+ ` Path: ${pc.cyan(dbPath)}`,
1137
+ ];
1138
+ if (driver === "cloud") {
1139
+ try {
1140
+ const baseUrl = dbPath.startsWith("thingd://")
1141
+ ? `http://${dbPath.slice("thingd://".length)}`
1142
+ : dbPath;
1143
+ const urlObj = new URL(baseUrl);
1144
+ if (urlObj.pathname === "/mcp")
1145
+ urlObj.pathname = "/";
1146
+ const fetchJson = async (p) => {
1147
+ const u = new URL(p, urlObj.toString());
1148
+ const headers = {};
1149
+ if (authToken)
1150
+ headers["Authorization"] = `Bearer ${authToken}`;
1151
+ const res = await fetch(u, { headers });
1152
+ if (!res.ok)
1153
+ throw new Error(`HTTP ${res.status}`);
1154
+ return res.json();
1155
+ };
1156
+ const health = await fetchJson("/healthz");
1157
+ const cluster = await fetchJson("/cluster/status");
1158
+ lines.push("");
1159
+ lines.push(` ${pc.bold("Cloud Health")}`);
1160
+ lines.push(...JSON.stringify(health, null, 2)
1161
+ .split("\n")
1162
+ .map((l) => ` ${pc.dim(l)}`));
1163
+ lines.push("");
1164
+ lines.push(` ${pc.bold("Cloud Cluster")}`);
1165
+ lines.push(...JSON.stringify(cluster, null, 2)
1166
+ .split("\n")
1167
+ .map((l) => ` ${pc.dim(l)}`));
1168
+ }
1169
+ catch (err) {
1170
+ lines.push("", ` ${pc.red("Cloud Query Failed:")} ${err.message}`);
1171
+ }
1172
+ }
1173
+ viewerLines = lines;
1174
+ loadedItemId = "info_status";
1175
+ }
1176
+ // ── Keyboard Listener ────────────────────────────────────────────────
1177
+ function setupKeypress() {
1178
+ process.stdin.removeAllListeners("keypress");
1179
+ readline.emitKeypressEvents(process.stdin);
1180
+ if (process.stdin.isTTY) {
1181
+ process.stdin.setRawMode(true);
1182
+ }
1183
+ process.stdin.resume();
1184
+ keypressHandler = async (str, key) => {
1185
+ if (!key)
1186
+ return;
1187
+ // Quit
1188
+ if ((key.ctrl && key.name === "c") || key.name === "q") {
1189
+ if (formState?.active && key.name !== "q") {
1190
+ formState.onCancel();
1191
+ return;
1192
+ }
1193
+ else if (!formState?.active) {
1194
+ cleanup();
1195
+ return;
1196
+ }
1197
+ }
1198
+ if (formState?.active && !formState.isSubmitting) {
1199
+ if (key.ctrl && key.name === "e") {
1200
+ const f = formState.fields[formState.activeIndex];
1201
+ if (f) {
1202
+ await launchEditor(f);
1203
+ }
1204
+ return;
1205
+ }
1206
+ else if (key.name === "escape") {
1207
+ formState.onCancel();
1208
+ }
1209
+ else if (key.name === "up" || (key.name === "tab" && key.shift)) {
1210
+ if (formState.activeIndex > 0)
1211
+ formState.activeIndex--;
1212
+ formState.error = undefined;
1213
+ draw();
1214
+ }
1215
+ else if (key.name === "down" || key.name === "tab") {
1216
+ if (formState.activeIndex < formState.fields.length - 1)
1217
+ formState.activeIndex++;
1218
+ formState.error = undefined;
1219
+ draw();
1220
+ }
1221
+ else if (key.name === "return") {
1222
+ if (formState.activeIndex < formState.fields.length - 1) {
1223
+ formState.activeIndex++;
1224
+ draw();
1225
+ }
1226
+ else {
1227
+ const vals = {};
1228
+ for (const f of formState.fields)
1229
+ vals[f.id] = f.value;
1230
+ formState.onSubmit(vals);
1231
+ }
1232
+ }
1233
+ else if (key.name === "left" || key.name === "right") {
1234
+ const f = formState.fields[formState.activeIndex];
1235
+ if (f && f.options && f.options.length > 0) {
1236
+ const currentIndex = f.options.indexOf(f.value);
1237
+ let nextIndex = key.name === "right" ? currentIndex + 1 : currentIndex - 1;
1238
+ if (nextIndex < 0)
1239
+ nextIndex = f.options.length - 1;
1240
+ if (nextIndex >= f.options.length)
1241
+ nextIndex = 0;
1242
+ f.value = f.options[nextIndex] ?? "";
1243
+ formState.error = undefined;
1244
+ draw();
1245
+ }
1246
+ }
1247
+ else if (key.name === "backspace") {
1248
+ const f = formState.fields[formState.activeIndex];
1249
+ if (f && (!f.options || f.allowCustom) && f.value.length > 0) {
1250
+ f.value = f.value.slice(0, -1);
1251
+ formState.error = undefined;
1252
+ draw();
1253
+ }
1254
+ }
1255
+ else if (str) {
1256
+ const f = formState.fields[formState.activeIndex];
1257
+ if (f && (!f.options || f.allowCustom)) {
1258
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: we need to filter control characters
1259
+ const clean = str.replace(/[\x00-\x1F\x7F]/g, "");
1260
+ if (clean) {
1261
+ f.value += clean;
1262
+ formState.error = undefined;
1263
+ draw();
1264
+ }
1265
+ }
1266
+ }
1267
+ return;
1268
+ }
1269
+ const tree = buildTree();
1270
+ // Navigation (works in both connected and disconnected states)
1271
+ if (key.name === "up" || str === "k") {
1272
+ if (cursorIndex > 0) {
1273
+ cursorIndex--;
1274
+ draw();
1275
+ const n = tree[cursorIndex];
1276
+ if (n)
1277
+ scheduleLoad(n);
1278
+ }
1279
+ }
1280
+ else if (key.name === "down" || str === "j") {
1281
+ if (cursorIndex < tree.length - 1) {
1282
+ cursorIndex++;
1283
+ draw();
1284
+ const n = tree[cursorIndex];
1285
+ if (n)
1286
+ scheduleLoad(n);
1287
+ }
1288
+ }
1289
+ else if (!connected) {
1290
+ // Driver selection mode — only Enter works
1291
+ if (key.name === "return") {
1292
+ const node = tree[cursorIndex];
1293
+ if (node)
1294
+ await handleConnect(node);
1295
+ }
1296
+ }
1297
+ else {
1298
+ // Connected mode — full set of shortcuts
1299
+ if (key.name === "right" || str === "l") {
1300
+ const node = tree[cursorIndex];
1301
+ if (node?.expandable) {
1302
+ if (!expandedSet.has(node.id)) {
1303
+ expandedSet.add(node.id);
1304
+ if (node.type === "collection")
1305
+ await fetchResources();
1306
+ draw();
1307
+ }
1308
+ else {
1309
+ const newTree = buildTree();
1310
+ if (cursorIndex + 1 < newTree.length) {
1311
+ cursorIndex++;
1312
+ draw();
1313
+ const n = newTree[cursorIndex];
1314
+ if (n)
1315
+ scheduleLoad(n);
1316
+ }
1317
+ }
1318
+ }
1319
+ }
1320
+ else if (key.name === "left" || str === "h") {
1321
+ const node = tree[cursorIndex];
1322
+ if (node) {
1323
+ if (node.expandable && expandedSet.has(node.id)) {
1324
+ expandedSet.delete(node.id);
1325
+ draw();
1326
+ }
1327
+ else if (node.parentId) {
1328
+ const parentIdx = tree.findIndex((n) => n.id === node.parentId);
1329
+ if (parentIdx !== -1) {
1330
+ cursorIndex = parentIdx;
1331
+ draw();
1332
+ const n = tree[cursorIndex];
1333
+ if (n)
1334
+ scheduleLoad(n);
1335
+ }
1336
+ }
1337
+ }
1338
+ }
1339
+ else if (key.name === "return") {
1340
+ const node = tree[cursorIndex];
1341
+ if (node?.expandable) {
1342
+ if (expandedSet.has(node.id)) {
1343
+ expandedSet.delete(node.id);
1344
+ }
1345
+ else {
1346
+ expandedSet.add(node.id);
1347
+ if (node.type === "collection")
1348
+ await fetchResources();
1349
+ }
1350
+ draw();
1351
+ }
1352
+ }
1353
+ else if (str === "r" || str === "R") {
1354
+ loadedItemId = "";
1355
+ await fetchResources();
1356
+ draw();
1357
+ const n = tree[cursorIndex];
1358
+ if (n)
1359
+ scheduleLoad(n);
1360
+ }
1361
+ else if (str === "s" || str === "S") {
1362
+ await handleSwitch();
1363
+ }
1364
+ else if (str === "c" || str === "C") {
1365
+ await handleCreate(tree[cursorIndex]);
1366
+ }
1367
+ else if (str === "e" || str === "E") {
1368
+ await handleEdit(tree[cursorIndex]);
1369
+ }
1370
+ else if (str === "d" || str === "D") {
1371
+ await handleDelete(tree[cursorIndex]);
1372
+ }
1373
+ else if (str === "/" || str === "f" || str === "F") {
1374
+ await handleSearch();
1375
+ }
1376
+ else if (str === "i" || str === "I") {
1377
+ await handleInfo();
1378
+ }
1379
+ }
1380
+ };
1381
+ process.stdin.on("keypress", keypressHandler);
1382
+ if (process.stdout.isTTY) {
1383
+ process.stdout.on("resize", () => {
1384
+ draw();
1385
+ });
1386
+ }
1387
+ }
1388
+ function cleanup() {
1389
+ if (pollTimer)
1390
+ clearInterval(pollTimer);
1391
+ if (process.stdin.isTTY) {
1392
+ process.stdin.setRawMode(false);
1393
+ }
1394
+ process.stdout.write("\u001B[?1049l\u001B[?25h");
1395
+ console.clear();
1396
+ const finish = () => {
1397
+ process.exit(0);
1398
+ };
1399
+ if (connected && db) {
1400
+ db.close().then(finish);
1401
+ }
1402
+ else {
1403
+ finish();
1404
+ }
1405
+ }
1406
+ async function handleConnect(node) {
1407
+ if (node.type !== "driver" || !node.ref)
1408
+ return;
1409
+ const selectedDriver = node.ref.driver;
1410
+ if (selectedDriver === "native" || selectedDriver === "cloud") {
1411
+ openForm(`Connect to ${selectedDriver}`, [
1412
+ ...(selectedDriver === "cloud"
1413
+ ? [
1414
+ { id: "url", label: "Cloud URL", value: "http://localhost:3000" },
1415
+ { id: "token", label: "Bearer Token (optional)", isSecret: true },
1416
+ ]
1417
+ : [
1418
+ {
1419
+ id: "path",
1420
+ label: "Database Path",
1421
+ value: path.join(os.homedir(), "Downloads", "data.db"),
1422
+ },
1423
+ ]),
1424
+ ], async (vals) => {
1425
+ const resolvedPath = selectedDriver === "cloud" ? vals.url || "" : vals.path || "";
1426
+ // Allow the underlying SDK/SQLite driver to automatically create the file
1427
+ // if it does not exist, rather than throwing an error here.
1428
+ db = await ThingD.open({
1429
+ path: resolvedPath,
1430
+ url: selectedDriver === "cloud" ? resolvedPath : undefined,
1431
+ driver: selectedDriver,
1432
+ authToken: vals.token,
1433
+ });
1434
+ driver = selectedDriver;
1435
+ dbPath = resolvedPath;
1436
+ // Update global authToken safely
1437
+ if (typeof vals.token === "string") {
1438
+ authToken = vals.token;
1439
+ }
1440
+ else {
1441
+ authToken = "";
1442
+ }
1443
+ connected = true;
1444
+ startedAt = Date.now();
1445
+ cursorIndex = 0;
1446
+ scrollOffset = 0;
1447
+ loadedItemId = "";
1448
+ await fetchResources();
1449
+ draw();
1450
+ const tree = buildTree();
1451
+ const first = tree[cursorIndex];
1452
+ if (first)
1453
+ scheduleLoad(first);
1454
+ });
1455
+ }
1456
+ else {
1457
+ // Memory — connect directly without suspending
1458
+ driver = selectedDriver;
1459
+ dbPath = ":memory:";
1460
+ viewerLines = [pc.dim("Connecting...")];
1461
+ draw();
1462
+ try {
1463
+ db = await ThingD.open({
1464
+ path: ":memory:",
1465
+ driver: "memory",
1466
+ });
1467
+ connected = true;
1468
+ startedAt = Date.now();
1469
+ cursorIndex = 0;
1470
+ scrollOffset = 0;
1471
+ loadedItemId = "";
1472
+ await fetchResources();
1473
+ draw();
1474
+ const tree = buildTree();
1475
+ const first = tree[cursorIndex];
1476
+ if (first)
1477
+ scheduleLoad(first);
1478
+ }
1479
+ catch (error) {
1480
+ viewerLines = [pc.red(`Failed to connect: ${error.message}`)];
1481
+ draw();
1482
+ }
1483
+ }
1484
+ }
1485
+ async function handleSwitch() {
1486
+ if (!connected)
1487
+ return;
1488
+ // Close current connection
1489
+ try {
1490
+ await db.close();
1491
+ }
1492
+ catch {
1493
+ // ignore close errors
1494
+ }
1495
+ // Reset state
1496
+ connected = false;
1497
+ driver = "";
1498
+ dbPath = "";
1499
+ collections = [];
1500
+ streams = [];
1501
+ queues = [];
1502
+ objectsByCollection = new Map();
1503
+ cursorIndex = 0;
1504
+ scrollOffset = 0;
1505
+ loadedItemId = "";
1506
+ viewerLines = ["Select an environment to connect."];
1507
+ draw();
1508
+ const tree = buildTree();
1509
+ const first = tree[cursorIndex];
1510
+ if (first)
1511
+ scheduleLoad(first);
1512
+ }
1513
+ // ── Entry Point ──────────────────────────────────────────────────────
1514
+ export async function runInteractiveCli() {
1515
+ // Go straight into the TUI — no pre-prompts
1516
+ console.clear();
1517
+ process.stdout.write("\u001B[?1049h\u001B[H\u001B[?25l");
1518
+ // Show the driver selection screen
1519
+ viewerLines = ["Select an environment to connect."];
1520
+ draw();
1521
+ const tree = buildTree();
1522
+ const first = tree[cursorIndex];
1523
+ if (first)
1524
+ scheduleLoad(first);
1525
+ setupKeypress();
1526
+ // Background polling loop for real-time updates
1527
+ pollTimer = setInterval(async () => {
1528
+ if (connected && !formState?.active) {
1529
+ const snapItemId = loadedItemId;
1530
+ await fetchResources();
1531
+ draw();
1532
+ const tree = buildTree();
1533
+ const n = tree[cursorIndex];
1534
+ // Silently reload content if the same node is still actively viewed
1535
+ if (n && snapItemId === n.id && n.type !== "category") {
1536
+ await loadContent(n).catch(() => { });
1537
+ }
1538
+ }
1539
+ }, 2000);
1540
+ }