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