@thingd/cli 0.77.0 → 0.77.3

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 (68) hide show
  1. package/dist/commands/cloud.d.ts +3 -0
  2. package/dist/commands/cloud.d.ts.map +1 -0
  3. package/dist/commands/cloud.js +669 -0
  4. package/dist/commands/mcp-connect.d.ts +3 -0
  5. package/dist/commands/mcp-connect.d.ts.map +1 -0
  6. package/dist/commands/mcp-connect.js +154 -0
  7. package/dist/commands/sync.d.ts +3 -0
  8. package/dist/commands/sync.d.ts.map +1 -0
  9. package/dist/commands/sync.js +197 -0
  10. package/dist/dashboard/public/assets/favicon-CgGFvG_0.svg +4 -0
  11. package/dist/dashboard/public/assets/index-C6PkDB7y.css +1 -0
  12. package/dist/dashboard/public/assets/index-DrpfyClj.js +4 -0
  13. package/dist/dashboard/public/index.html +19 -0
  14. package/dist/dashboard/server.d.ts +6 -0
  15. package/dist/dashboard/server.d.ts.map +1 -0
  16. package/dist/dashboard/server.js +684 -0
  17. package/dist/data-movement.d.ts +6 -0
  18. package/dist/data-movement.d.ts.map +1 -0
  19. package/dist/data-movement.js +475 -0
  20. package/dist/doctor.d.ts +3 -0
  21. package/dist/doctor.d.ts.map +1 -0
  22. package/dist/doctor.js +108 -0
  23. package/dist/index.d.ts +48 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +1446 -0
  26. package/dist/install.d.ts +3 -0
  27. package/dist/install.d.ts.map +1 -0
  28. package/dist/install.js +229 -0
  29. package/dist/interactive.d.ts +2 -0
  30. package/dist/interactive.d.ts.map +1 -0
  31. package/dist/interactive.js +3039 -0
  32. package/dist/lib/cloud-api.d.ts +143 -0
  33. package/dist/lib/cloud-api.d.ts.map +1 -0
  34. package/dist/lib/cloud-api.js +207 -0
  35. package/dist/lib/cloud-config.d.ts +33 -0
  36. package/dist/lib/cloud-config.d.ts.map +1 -0
  37. package/dist/lib/cloud-config.js +42 -0
  38. package/dist/lib/mcp-config-writer.d.ts +26 -0
  39. package/dist/lib/mcp-config-writer.d.ts.map +1 -0
  40. package/dist/lib/mcp-config-writer.js +86 -0
  41. package/dist/lib/sync-config.d.ts +22 -0
  42. package/dist/lib/sync-config.d.ts.map +1 -0
  43. package/dist/lib/sync-config.js +25 -0
  44. package/dist/logo.d.ts +3 -0
  45. package/dist/logo.d.ts.map +1 -0
  46. package/dist/logo.js +8 -0
  47. package/dist/mcp/cluster.d.ts +69 -0
  48. package/dist/mcp/cluster.d.ts.map +1 -0
  49. package/dist/mcp/cluster.js +304 -0
  50. package/dist/mcp/config.d.ts +14 -0
  51. package/dist/mcp/config.d.ts.map +1 -0
  52. package/dist/mcp/config.js +67 -0
  53. package/dist/mcp/http.d.ts +35 -0
  54. package/dist/mcp/http.d.ts.map +1 -0
  55. package/dist/mcp/http.js +957 -0
  56. package/dist/mcp/index.d.ts +5 -0
  57. package/dist/mcp/index.d.ts.map +1 -0
  58. package/dist/mcp/index.js +3 -0
  59. package/dist/mcp-http.d.ts +3 -0
  60. package/dist/mcp-http.d.ts.map +1 -0
  61. package/dist/mcp-http.js +42 -0
  62. package/dist/mcp.d.ts +3 -0
  63. package/dist/mcp.d.ts.map +1 -0
  64. package/dist/mcp.js +28 -0
  65. package/dist/paths.d.ts +4 -0
  66. package/dist/paths.d.ts.map +1 -0
  67. package/dist/paths.js +14 -0
  68. package/package.json +2 -2
package/dist/index.js ADDED
@@ -0,0 +1,1446 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync, } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { setTimeout as sleep } from "node:timers/promises";
5
+ import { pathToFileURL } from "node:url";
6
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
7
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
8
+ import { NativeThingStore, parseSchema, SDK_VERSION, ThingD, } from "@thingd/sdk";
9
+ import pc from "picocolors";
10
+ import { runInteractiveCli } from "./interactive.js";
11
+ import { deriveRestUrl } from "./lib/cloud-api.js";
12
+ import { readCloudConfig, resolveCloudUrl } from "./lib/cloud-config.js";
13
+ import { logoLine } from "./logo.js";
14
+ import { runMcp } from "./mcp.js";
15
+ import { defaultThingdDbPath, ensureThingdDir } from "./paths.js";
16
+ // ── Opencode-style log output ──────────────────────────────────────
17
+ function writeLog(target, data, header) {
18
+ const W = 60;
19
+ if (header) {
20
+ target.write(` ${pc.bold(header)}\n`);
21
+ target.write(` ${pc.dim("─".repeat(W))}\n`);
22
+ }
23
+ for (const { label, value } of data) {
24
+ target.write(` ${pc.cyan("●")} ${pc.dim(label.padEnd(14))} ${value}\n`);
25
+ }
26
+ target.write("\n");
27
+ }
28
+ function writeLogBullets(target, items, header) {
29
+ const W = 60;
30
+ if (header) {
31
+ target.write(` ${pc.bold(header)}\n`);
32
+ target.write(` ${pc.dim("─".repeat(W))}\n`);
33
+ }
34
+ for (const item of items) {
35
+ const icon = item.icon ?? pc.cyan("○");
36
+ const indent = " ".repeat(item.indent ?? 1);
37
+ target.write(`${indent}${icon} ${item.text}\n`);
38
+ }
39
+ target.write("\n");
40
+ }
41
+ const HELP_TEXT = `${logoLine()}Admin and operator CLI for thingd.
42
+
43
+ Usage:
44
+ thingd status [--url <url>]
45
+ thingd tools --url <url>
46
+ thingd install [--raw] [--claude] [--cursor] [--antigravity]
47
+ thingd doctor
48
+ thingd mcp [--path <path>] [--driver <driver>]
49
+ thingd mcp connect
50
+ thingd mcp-http [--path <path>] [--driver <driver>] [--host <host>] [--port <port>] [--auth-token <tok>] [--allow-unauthenticated]
51
+ thingd search <query> [--collection <name>] [--limit <n>] [--filter <json>]
52
+ thingd objects list <collection> [--limit <n>] [--offset <n>] [--sort-by <field>] [--sort-dir <asc|desc>] [--filter <json>]
53
+ thingd objects get <collection> <id>
54
+ thingd objects put <collection> <id> --text <text>
55
+ thingd objects put <collection> <id> --data '{"field":"value"}'
56
+ thingd objects put-batch <collection> --file <path>
57
+ thingd objects delete <collection> <id>
58
+ thingd objects delete-batch <collection> <id1> [id2] ...
59
+ thingd events streams
60
+ thingd events append <stream> <type> [--text <text>] [--data '{"field":"value"}']
61
+ thingd events list [stream] [--limit <n>]
62
+ thingd collections list
63
+ thingd streams list
64
+ thingd queues list-all
65
+ thingd queues stats <queue>
66
+ thingd queues push <queue> --payload '{"key":"value"}'
67
+ thingd queues claim <queue> [--lease-ms <ms>]
68
+ thingd queues ack <queue> <jobId>
69
+ thingd queues nack <queue> <jobId> [--error <message>] [--delay-ms <ms>]
70
+ thingd queues list <queue> [--limit <n>]
71
+ thingd queues dead <queue> [--limit <n>]
72
+ thingd links create <fromRef> <linkType> <toRef> [--weight <n>] [--metadata <json>]
73
+ thingd links get <id>
74
+ thingd links delete <id>
75
+ thingd links neighbors <reference> [--direction Outgoing|Incoming|Both] [--type <linkType>] [--limit <n>]
76
+ thingd links count
77
+ thingd schema [--collection <name>]
78
+ thingd schema check [file]
79
+ thingd migrate create <name> [--schema <file>]
80
+ thingd migrate plan [file]
81
+ thingd migrate apply [file]
82
+ thingd migrate status
83
+ thingd nlq <question> [--collection <name>]
84
+ thingd aggregate <function> <collection> [--field <name>] [--group-by <name>] [--filter <json>]
85
+ thingd timeseries <function> <collection> <bucket> [--field <name>] [--filter <json>] [--from <iso>] [--to <iso>]
86
+ thingd bench rust --smoke
87
+ thingd bench rust --count <n>
88
+ thingd metrics
89
+ thingd dashboard [--port <port>] [--path <path>] [--driver <driver>]
90
+ thingd export --collection <name> --out <path> [--redact [keys]]
91
+ thingd export --events [--stream <name>] --out <path> [--redact [keys]]
92
+ thingd import --collection <name> --in <path>
93
+ thingd import <connection-string> --collection <name> [--tables <names> | --query <sql>] [--sidecar <url>] [--dry-run] [--list-tables] [--batch-size <n>]
94
+ thingd snapshot create --out <path>
95
+ thingd snapshot restore --in <path>
96
+ thingd backup --out <path>
97
+ thingd backup --in <path>
98
+ thingd sync configure --local-url <url> --remote-url <url> --role <source|replica> [--provider <self-hosted|thingd.cloud>] [--project-id <id>] [--instance-slug <slug>] [--allow-cloud-target --confirm-target]
99
+ thingd sync status
100
+ thingd sync push|pull|bootstrap|pause|resume|reset [--replace]
101
+ thingd cloud login [--code <code> --token <token>]
102
+ thingd cloud logout
103
+ thingd cloud status
104
+ thingd cloud project list
105
+ thingd cloud instance list <project>
106
+ thingd cloud api-key create <project> <name>
107
+ thingd db checkpoint [--path <path>]
108
+ thingd db integrity [--path <path>]
109
+ thingd db reencrypt --source <path> --destination <path> [--allow-plaintext-output]
110
+
111
+ Options:
112
+ --url <url> remote thingd URL. Defaults to THINGD_URL
113
+ --auth-token <tok> remote bearer token. Defaults to THINGD_AUTH_TOKEN
114
+ --path <path> local database path. Defaults to THINGD_PATH or ~/.thingd/data.db
115
+ --driver <driver> memory, native, or cloud
116
+ Encryption: set THINGD_ENCRYPTION_KEY to a 64-character hex key for native storage
117
+ --pretty opencode-style log output (human-readable)
118
+ --limit <n> result limit for search and list commands
119
+ --filter <json> metadata key-value filter (e.g. '{"status":"active"}')
120
+ -h, --help show help
121
+ `;
122
+ const BOOLEAN_FLAGS = new Set([
123
+ "h",
124
+ "help",
125
+ "json",
126
+ "pretty",
127
+ "allow-unauthenticated",
128
+ "raw",
129
+ "claude",
130
+ "cursor",
131
+ "antigravity",
132
+ "smoke",
133
+ "events",
134
+ "allow-cloud-target",
135
+ "confirm-target",
136
+ "replace",
137
+ ]);
138
+ export async function runCli(args = process.argv.slice(2), options = {}) {
139
+ // Auto-detect native binary for global/local dev execution
140
+ if (!process.env.THINGD_NATIVE_PATH) {
141
+ try {
142
+ const { existsSync } = await import("node:fs");
143
+ const { join } = await import("node:path");
144
+ const cliDir = join(resolveCliPath(), "..", "..");
145
+ const platform = process.platform;
146
+ const arch = process.arch;
147
+ const candidates = [
148
+ // installed via pnpm/npm as transitive dependency of thingd
149
+ join(cliDir, "node_modules", "@thingd/native", "dist", "thingd_native.node"),
150
+ join(cliDir, "node_modules", "@thingd/native", "prebuilds", `${platform}-${arch}`, "thingd_native.node"),
151
+ // workspace sibling (local dev)
152
+ join(cliDir, "..", "thingd-native", "dist", "thingd_native.node"),
153
+ join(cliDir, "..", "thingd-native", "prebuilds", `${platform}-${arch}`, "thingd_native.node"),
154
+ ];
155
+ for (const candidate of candidates) {
156
+ if (existsSync(candidate)) {
157
+ process.env.THINGD_NATIVE_PATH = candidate;
158
+ break;
159
+ }
160
+ }
161
+ }
162
+ catch {
163
+ // Ignore detection errors
164
+ }
165
+ }
166
+ const parsed = parseArgs(args);
167
+ const context = {
168
+ parsed,
169
+ env: options.env ?? process.env,
170
+ stdout: options.stdout ?? process.stdout,
171
+ stderr: options.stderr ?? process.stderr,
172
+ stdin: options.stdin ?? process.stdin,
173
+ pretty: hasFlag(parsed, "pretty"),
174
+ };
175
+ try {
176
+ if (hasFlag(parsed, "help") || hasFlag(parsed, "h")) {
177
+ writeText(context.stdout, HELP_TEXT);
178
+ return 0;
179
+ }
180
+ if (parsed.tokens.length === 0) {
181
+ process.stdout.write(logoLine());
182
+ await sleep(300);
183
+ await runInteractiveCli();
184
+ return 0;
185
+ }
186
+ await runCommand(context);
187
+ return 0;
188
+ }
189
+ catch (error) {
190
+ writeJson(context.stderr, {
191
+ error: error instanceof Error ? error.message : String(error),
192
+ }, context.pretty);
193
+ return 1;
194
+ }
195
+ }
196
+ async function runCommand(context) {
197
+ const command = requiredToken(context.parsed, 0, "command");
198
+ if (command === "status") {
199
+ await runStatus(context);
200
+ return;
201
+ }
202
+ if (command === "tools") {
203
+ await runTools(context);
204
+ return;
205
+ }
206
+ if (command === "search") {
207
+ await runSearch(context);
208
+ return;
209
+ }
210
+ if (command === "mcp") {
211
+ await runMcp(context);
212
+ return;
213
+ }
214
+ if (command === "mcp-http") {
215
+ const { runMcpHttp } = await import("./mcp-http.js");
216
+ await runMcpHttp(context);
217
+ return;
218
+ }
219
+ if (command === "install") {
220
+ const { runInstall } = await import("./install.js");
221
+ await runInstall(context);
222
+ return;
223
+ }
224
+ if (command === "doctor") {
225
+ const { runDoctor } = await import("./doctor.js");
226
+ await runDoctor(context);
227
+ return;
228
+ }
229
+ if (command === "bench") {
230
+ await runBench(context);
231
+ return;
232
+ }
233
+ if (command === "cloud") {
234
+ const { runCloud } = await import("./commands/cloud.js");
235
+ await runCloud(context);
236
+ return;
237
+ }
238
+ if (command === "sync") {
239
+ const { runSyncCommand } = await import("./commands/sync.js");
240
+ await runSyncCommand(context);
241
+ return;
242
+ }
243
+ if (command === "objects") {
244
+ await runObjects(context);
245
+ return;
246
+ }
247
+ if (command === "events") {
248
+ await runEvents(context);
249
+ return;
250
+ }
251
+ if (command === "collections") {
252
+ await runCollections(context);
253
+ return;
254
+ }
255
+ if (command === "streams") {
256
+ await runStreams(context);
257
+ return;
258
+ }
259
+ if (command === "queues") {
260
+ await runQueues(context);
261
+ return;
262
+ }
263
+ if (command === "links") {
264
+ await runLinks(context);
265
+ return;
266
+ }
267
+ if (command === "schema") {
268
+ await runSchema(context);
269
+ return;
270
+ }
271
+ if (command === "migrate") {
272
+ await runMigrate(context);
273
+ return;
274
+ }
275
+ if (command === "nlq") {
276
+ await runNlq(context);
277
+ return;
278
+ }
279
+ if (command === "aggregate") {
280
+ await runAggregate(context);
281
+ return;
282
+ }
283
+ if (command === "timeseries") {
284
+ await runTimeseries(context);
285
+ return;
286
+ }
287
+ if (command === "metrics") {
288
+ await runMetrics(context);
289
+ return;
290
+ }
291
+ if (command === "dashboard") {
292
+ await runDashboard(context);
293
+ return;
294
+ }
295
+ if (command === "export") {
296
+ const { runExport } = await import("./data-movement.js");
297
+ await runExport(context);
298
+ return;
299
+ }
300
+ if (command === "import") {
301
+ const { runImport } = await import("./data-movement.js");
302
+ await runImport(context);
303
+ return;
304
+ }
305
+ if (command === "snapshot") {
306
+ const { runSnapshot } = await import("./data-movement.js");
307
+ await runSnapshot(context);
308
+ return;
309
+ }
310
+ if (command === "backup") {
311
+ const { runBackup } = await import("./data-movement.js");
312
+ await runBackup(context);
313
+ return;
314
+ }
315
+ if (command === "completions") {
316
+ await runCompletions(context);
317
+ return;
318
+ }
319
+ if (command === "db") {
320
+ const sub = context.parsed.tokens[1];
321
+ if (!sub) {
322
+ throw new Error("Expected db subcommand: checkpoint, integrity");
323
+ }
324
+ if (sub === "checkpoint") {
325
+ await runDbCheckpoint(context);
326
+ return;
327
+ }
328
+ if (sub === "integrity") {
329
+ await runDbIntegrity(context);
330
+ return;
331
+ }
332
+ if (sub === "reencrypt") {
333
+ await runDbReencrypt(context);
334
+ return;
335
+ }
336
+ throw new Error(`Unknown db subcommand: ${sub}. Expected: checkpoint, integrity, reencrypt`);
337
+ }
338
+ throw new Error(`Unknown command: ${command}`);
339
+ }
340
+ async function runDbReencrypt(context) {
341
+ const source = stringFlag(context.parsed, "source");
342
+ const destination = stringFlag(context.parsed, "destination");
343
+ if (!source || !destination) {
344
+ throw new Error("db reencrypt requires --source <path> and --destination <path>");
345
+ }
346
+ if (source === destination) {
347
+ throw new Error("db reencrypt requires different source and destination paths");
348
+ }
349
+ const destinationKey = context.env.THINGD_ENCRYPTION_DESTINATION_KEY;
350
+ if (!destinationKey && !hasFlag(context.parsed, "allow-plaintext-output")) {
351
+ throw new Error("Refusing unencrypted output. Set THINGD_ENCRYPTION_DESTINATION_KEY or pass --allow-plaintext-output explicitly.");
352
+ }
353
+ await NativeThingStore.reencrypt(source, destination, context.env.THINGD_ENCRYPTION_SOURCE_KEY ?? context.env.THINGD_ENCRYPTION_KEY, destinationKey, hasFlag(context.parsed, "allow-plaintext-output"));
354
+ writeJson(context.stdout, { source, destination, encrypted: Boolean(destinationKey) }, context.pretty);
355
+ }
356
+ async function runBench(context) {
357
+ const target = requiredToken(context.parsed, 1, "benchmark target (rust)");
358
+ if (target !== "rust") {
359
+ throw new Error(`Unsupported benchmark target: ${target}`);
360
+ }
361
+ const isSmoke = hasFlag(context.parsed, "smoke");
362
+ const countStr = stringFlag(context.parsed, "count");
363
+ const count = countStr ? Number.parseInt(countStr, 10) : isSmoke ? 100 : undefined;
364
+ if (count === undefined) {
365
+ throw new Error("bench rust requires --smoke or --count <n>");
366
+ }
367
+ if (Number.isNaN(count) || count <= 0) {
368
+ throw new Error("--count must be a positive integer");
369
+ }
370
+ try {
371
+ const { execSync } = await import("node:child_process");
372
+ try {
373
+ execSync("cargo --version", { stdio: "ignore" });
374
+ }
375
+ catch {
376
+ throw new Error("Rust toolchain (cargo) is not installed or not in the PATH. Cannot run Rust benchmarks.");
377
+ }
378
+ context.stderr.write(`\n${pc.bold("Running Rust storage benchmark")} (Count: ${pc.cyan(count)})...\n\n`);
379
+ const { spawn } = await import("node:child_process");
380
+ const child = spawn("cargo", [
381
+ "run",
382
+ "--release",
383
+ "-p",
384
+ "thingd",
385
+ "--example",
386
+ "storage_bench",
387
+ "--features",
388
+ "persistent,search",
389
+ "--",
390
+ String(count),
391
+ ], {
392
+ stdio: "inherit",
393
+ cwd: resolve(resolveCliPath(), "../../../.."),
394
+ });
395
+ return new Promise((resolvePromise, rejectPromise) => {
396
+ child.on("close", (code) => {
397
+ if (code === 0) {
398
+ resolvePromise();
399
+ }
400
+ else {
401
+ rejectPromise(new Error(`Benchmark failed with exit code: ${code}`));
402
+ }
403
+ });
404
+ child.on("error", (error) => {
405
+ rejectPromise(error);
406
+ });
407
+ });
408
+ }
409
+ catch (err) {
410
+ throw new Error(`Failed to run benchmark: ${err instanceof Error ? err.message : String(err)}`);
411
+ }
412
+ }
413
+ async function runDbCheckpoint(context) {
414
+ await withDb(context, async (db) => {
415
+ const result = db.walCheckpoint();
416
+ writeJson(context.stdout, result, context.pretty);
417
+ });
418
+ }
419
+ async function runDbIntegrity(context) {
420
+ await withDb(context, async (db) => {
421
+ try {
422
+ await db.countObjects();
423
+ writeJson(context.stdout, { ok: true, message: "Database is accessible" }, context.pretty);
424
+ }
425
+ catch (err) {
426
+ writeJson(context.stdout, { ok: false, message: err instanceof Error ? err.message : String(err) }, context.pretty);
427
+ }
428
+ });
429
+ }
430
+ async function runCompletions(context) {
431
+ const shell = optionalToken(context.parsed, 1) ?? "bash";
432
+ const cmds = [
433
+ "status",
434
+ "search",
435
+ "mcp",
436
+ "cloud",
437
+ "objects",
438
+ "events",
439
+ "collections",
440
+ "streams",
441
+ "queues",
442
+ "links",
443
+ "schema",
444
+ "nlq",
445
+ "aggregate",
446
+ "timeseries",
447
+ "metrics",
448
+ "dashboard",
449
+ "export",
450
+ "import",
451
+ "snapshot",
452
+ "backup",
453
+ "db",
454
+ "completions",
455
+ ];
456
+ const script = shell === "zsh"
457
+ ? `#compdef thingd\ncompdef _thingd thingd\n_thingd() {\n _describe 'thingd commands' ${JSON.stringify(cmds.map((c) => `${c}:thingd ${c}`))}\n}\n`
458
+ : shell === "fish"
459
+ ? `complete -c thingd -f\n${cmds.map((c) => `complete -c thingd -n '__fish_use_subcommand' -a ${c}`).join("\n")}\n`
460
+ : `_thingd() {\n local cur=\${COMP_WORDS[COMP_CWORD]}\n if [ $COMP_CWORD -eq 1 ]; then\n COMPREPLY=($(compgen -W "${cmds.join(" ")}" -- "$cur"))\n fi\n}\ncomplete -F _thingd thingd\n`;
461
+ writeText(context.stdout, script);
462
+ }
463
+ async function runStatus(context) {
464
+ const connection = resolveConnection(context);
465
+ if (!connection.cloud) {
466
+ if (context.pretty) {
467
+ writeLog(context.stdout, [
468
+ { label: "Driver", value: pc.cyan(connection.driver ?? "memory") },
469
+ { label: "Path", value: pc.dim(connection.path) },
470
+ ], "thingd status");
471
+ return;
472
+ }
473
+ writeJson(context.stdout, {
474
+ mode: "local",
475
+ driver: connection.driver ?? "memory",
476
+ path: connection.path,
477
+ }, context.pretty);
478
+ return;
479
+ }
480
+ const baseUrl = resolveCloudBaseUrl(connection.path);
481
+ const [health, cluster] = await Promise.all([
482
+ fetchJson(new URL("/healthz", baseUrl), connection.authToken),
483
+ fetchJson(new URL("/cluster/status", baseUrl), connection.authToken),
484
+ ]);
485
+ const castCluster = cluster;
486
+ const replication = castCluster?.replication;
487
+ const lastReplicatedSequence = replication && typeof replication === "object" && "lastReplicatedSequence" in replication
488
+ ? replication.lastReplicatedSequence
489
+ : undefined;
490
+ const replicationLag = replication && typeof replication === "object" && "lag" in replication
491
+ ? replication.lag
492
+ : undefined;
493
+ if (context.pretty) {
494
+ const items = [
495
+ { label: "Mode", value: pc.cyan("cloud") },
496
+ { label: "URL", value: pc.dim(resolveCloudMcpUrl(connection.path)) },
497
+ ];
498
+ if (lastReplicatedSequence !== undefined) {
499
+ items.push({ label: "Last Seq", value: String(lastReplicatedSequence) });
500
+ }
501
+ if (replicationLag !== undefined) {
502
+ items.push({ label: "Repl Lag", value: `${replicationLag}ms` });
503
+ }
504
+ writeLog(context.stdout, items, "thingd status");
505
+ return;
506
+ }
507
+ writeJson(context.stdout, {
508
+ mode: "cloud",
509
+ url: resolveCloudMcpUrl(connection.path),
510
+ health,
511
+ cluster,
512
+ leaderUrl: castCluster?.leaderUrl,
513
+ lastReplicatedSequence,
514
+ replicationLag,
515
+ }, context.pretty);
516
+ }
517
+ async function runTools(context) {
518
+ const connection = resolveConnection(context);
519
+ if (!connection.cloud) {
520
+ throw new Error("tools requires --url or THINGD_URL because tools are exposed by the MCP runtime");
521
+ }
522
+ const client = new Client({
523
+ name: "thingd-cli",
524
+ version: SDK_VERSION,
525
+ });
526
+ const transport = new StreamableHTTPClientTransport(new URL(resolveCloudMcpUrl(connection.path)), {
527
+ requestInit: connection.authToken
528
+ ? {
529
+ headers: {
530
+ Authorization: `Bearer ${connection.authToken}`,
531
+ },
532
+ }
533
+ : undefined,
534
+ });
535
+ try {
536
+ await client.connect(transport);
537
+ const result = await client.listTools();
538
+ writeJson(context.stdout, {
539
+ tools: result.tools.map((tool) => ({
540
+ name: tool.name,
541
+ description: tool.description,
542
+ })),
543
+ }, context.pretty);
544
+ }
545
+ finally {
546
+ await client.close();
547
+ }
548
+ }
549
+ async function runSearch(context) {
550
+ const query = context.parsed.tokens.slice(1).join(" ").trim();
551
+ if (!query) {
552
+ throw new Error("search requires a query");
553
+ }
554
+ await withDb(context, async (db) => {
555
+ const filterStr = stringFlag(context.parsed, "filter");
556
+ const options = {
557
+ collections: stringFlags(context.parsed, "collection"),
558
+ limit: optionalInt(context.parsed, "limit"),
559
+ filter: filterStr ? JSON.parse(filterStr) : undefined,
560
+ };
561
+ const results = await db.search(query, compactOptions(options));
562
+ if (context.pretty) {
563
+ const bullets = results.map((r) => {
564
+ const res = r;
565
+ const col = res.kind === "object" ? (res.collection ?? "") : (res.stream ?? "");
566
+ return `${pc.green(res.id)} ${pc.dim(col)}`;
567
+ });
568
+ writeLogBullets(context.stdout, bullets.map((t) => ({ text: t })), `thingd search ${query}`);
569
+ return;
570
+ }
571
+ writeJson(context.stdout, results, context.pretty);
572
+ });
573
+ }
574
+ async function runObjects(context) {
575
+ const action = requiredToken(context.parsed, 1, "objects action");
576
+ const collection = requiredToken(context.parsed, 2, "collection");
577
+ await withDb(context, async (db) => {
578
+ if (action === "list") {
579
+ const sortByField = stringFlag(context.parsed, "sort-by");
580
+ const sortByDir = stringFlag(context.parsed, "sort-dir");
581
+ const sortBy = sortByField
582
+ ? { field: sortByField, direction: sortByDir ?? "asc" }
583
+ : undefined;
584
+ const filterStr = stringFlag(context.parsed, "filter");
585
+ const options = {
586
+ filter: filterStr ? JSON.parse(filterStr) : undefined,
587
+ sortBy,
588
+ limit: optionalInt(context.parsed, "limit"),
589
+ offset: optionalInt(context.parsed, "offset"),
590
+ };
591
+ const objects = await db.listObjects(collection, compactOptions(options));
592
+ if (context.pretty) {
593
+ const bullets = objects.map((obj) => {
594
+ const { id, version, createdAt } = obj;
595
+ const meta = `${pc.dim(`v${version}`)} ${createdAt ? pc.dim(new Date(createdAt).toLocaleString()) : ""}`;
596
+ return `${pc.green(id)} ${meta}`;
597
+ });
598
+ writeLogBullets(context.stdout, bullets.map((t) => ({ text: t })), `thingd objects list ${collection}`);
599
+ }
600
+ else {
601
+ writeJson(context.stdout, objects, false);
602
+ }
603
+ return;
604
+ }
605
+ if (action === "put-batch") {
606
+ const filePath = stringFlag(context.parsed, "file");
607
+ if (!filePath) {
608
+ throw new Error("put-batch requires --file <path>");
609
+ }
610
+ const { existsSync, readFileSync } = await import("node:fs");
611
+ const { resolve } = await import("node:path");
612
+ const resolved = resolve(filePath);
613
+ if (!existsSync(resolved)) {
614
+ throw new Error(`File not found: ${resolved}`);
615
+ }
616
+ const raw = readFileSync(resolved, "utf8");
617
+ const parsed = JSON.parse(raw);
618
+ const objects = Array.isArray(parsed) ? parsed : parsed.objects;
619
+ if (!Array.isArray(objects)) {
620
+ throw new Error("File must contain a JSON array or { objects: [...] }");
621
+ }
622
+ const result = await db.putBatch(collection, objects);
623
+ if (context.pretty) {
624
+ writeLog(context.stdout, [{ label: "Created", value: `${result.length} object(s)` }], `thingd objects put-batch ${collection}`);
625
+ }
626
+ else {
627
+ writeJson(context.stdout, result, false);
628
+ }
629
+ return;
630
+ }
631
+ if (action === "delete-batch") {
632
+ const ids = context.parsed.tokens.slice(3);
633
+ if (ids.length === 0) {
634
+ throw new Error("delete-batch requires at least one object id");
635
+ }
636
+ const count = await db.deleteBatch(collection, ids);
637
+ if (context.pretty) {
638
+ writeLog(context.stdout, [{ label: "Deleted", value: `${count} object(s)` }], `thingd objects delete-batch ${collection}`);
639
+ }
640
+ else {
641
+ writeJson(context.stdout, { deleted: count }, false);
642
+ }
643
+ return;
644
+ }
645
+ const id = requiredToken(context.parsed, 3, "object id");
646
+ if (action === "get") {
647
+ writeJson(context.stdout, await db.get(collection, id), context.pretty);
648
+ return;
649
+ }
650
+ if (action === "put") {
651
+ const object = buildMemoryObject(context.parsed, id);
652
+ writeJson(context.stdout, await db.put(collection, object), context.pretty);
653
+ return;
654
+ }
655
+ if (action === "delete") {
656
+ writeJson(context.stdout, await db.delete(collection, id), context.pretty);
657
+ return;
658
+ }
659
+ throw new Error(`Unknown objects action: ${action}`);
660
+ });
661
+ }
662
+ async function runEvents(context) {
663
+ const action = requiredToken(context.parsed, 1, "events action");
664
+ await withDb(context, async (db) => {
665
+ if (action === "streams") {
666
+ const streams = await db.listStreams();
667
+ if (context.pretty) {
668
+ writeLogBullets(context.stdout, streams.map((s) => ({ text: pc.green(s), icon: pc.green("●") })), "thingd events streams");
669
+ }
670
+ else {
671
+ writeJson(context.stdout, streams, false);
672
+ }
673
+ return;
674
+ }
675
+ if (action === "list") {
676
+ const stream = optionalToken(context.parsed, 2);
677
+ const events = limitItems(await db.events.list(stream), optionalInt(context.parsed, "limit"));
678
+ if (context.pretty) {
679
+ const bullets = events.map((ev) => {
680
+ const { id, type, createdAt, ...data } = ev;
681
+ const ts = createdAt ? pc.dim(new Date(createdAt).toLocaleString()) : "";
682
+ const dataStr = Object.keys(data).length > 0 ? ` ${pc.dim(JSON.stringify(data))}` : "";
683
+ return `${pc.green(id)} ${pc.magenta(type)} ${ts}${dataStr}`;
684
+ });
685
+ writeLogBullets(context.stdout, bullets.map((t) => ({ text: t, icon: pc.green("●") })), stream ? `thingd events list ${stream}` : "thingd events list");
686
+ }
687
+ else {
688
+ writeJson(context.stdout, events, false);
689
+ }
690
+ return;
691
+ }
692
+ if (action === "append") {
693
+ const stream = requiredToken(context.parsed, 2, "stream");
694
+ const type = requiredToken(context.parsed, 3, "event type");
695
+ const event = buildMemoryEvent(context.parsed, type);
696
+ writeJson(context.stdout, await db.events.append(stream, event), context.pretty);
697
+ return;
698
+ }
699
+ throw new Error(`Unknown events action: ${action}`);
700
+ });
701
+ }
702
+ async function runCollections(context) {
703
+ const action = requiredToken(context.parsed, 1, "collections action");
704
+ await withDb(context, async (db) => {
705
+ if (action === "list") {
706
+ const collections = await db.listCollections();
707
+ if (context.pretty) {
708
+ writeLogBullets(context.stdout, collections.map((c) => ({ text: pc.cyan(c) })), "thingd collections list");
709
+ }
710
+ else {
711
+ writeJson(context.stdout, collections, false);
712
+ }
713
+ return;
714
+ }
715
+ throw new Error(`Unknown collections action: ${action}`);
716
+ });
717
+ }
718
+ async function runStreams(context) {
719
+ const action = requiredToken(context.parsed, 1, "streams action");
720
+ await withDb(context, async (db) => {
721
+ if (action === "list") {
722
+ const streams = await db.listStreams();
723
+ if (context.pretty) {
724
+ writeLogBullets(context.stdout, streams.map((s) => ({ text: pc.green(s), icon: pc.green("●") })), "thingd streams list");
725
+ }
726
+ else {
727
+ writeJson(context.stdout, streams, false);
728
+ }
729
+ return;
730
+ }
731
+ throw new Error(`Unknown streams action: ${action}`);
732
+ });
733
+ }
734
+ async function runMetrics(context) {
735
+ await withDb(context, async (db) => {
736
+ const [objects, events, activeJobs, deadJobs] = await Promise.all([
737
+ db.countObjects(),
738
+ db.countEvents(),
739
+ db.countActiveJobs(),
740
+ db.countDeadJobs(),
741
+ ]);
742
+ if (context.pretty) {
743
+ writeLog(context.stdout, [
744
+ { label: "Objects", value: pc.cyan(String(objects)) },
745
+ { label: "Events", value: pc.green(String(events)) },
746
+ { label: "Active Jobs", value: pc.yellow(String(activeJobs)) },
747
+ { label: "Dead Jobs", value: pc.red(String(deadJobs)) },
748
+ ], "thingd metrics");
749
+ return;
750
+ }
751
+ writeJson(context.stdout, {
752
+ objects,
753
+ events,
754
+ activeJobs,
755
+ deadJobs,
756
+ }, context.pretty);
757
+ });
758
+ }
759
+ async function runQueues(context) {
760
+ const action = requiredToken(context.parsed, 1, "queues action");
761
+ await withDb(context, async (db) => {
762
+ if (action === "list-all") {
763
+ const queues = await db.listQueues();
764
+ if (context.pretty) {
765
+ writeLogBullets(context.stdout, queues.map((q) => ({ text: pc.magenta(q), icon: pc.magenta("◇") })), "thingd queues list-all");
766
+ }
767
+ else {
768
+ writeJson(context.stdout, queues, false);
769
+ }
770
+ return;
771
+ }
772
+ const queueName = requiredToken(context.parsed, 2, "queue");
773
+ const queue = db.queue(queueName);
774
+ if (action === "stats") {
775
+ const [activeJobs, deadJobs] = await Promise.all([queue.list(), queue.dead()]);
776
+ const totalActive = activeJobs.length;
777
+ const totalDead = deadJobs.length;
778
+ const leasedJobs = activeJobs.filter((job) => job.status === "leased");
779
+ const readyJobs = activeJobs.filter((job) => job.status === "ready");
780
+ if (context.pretty) {
781
+ writeLog(context.stdout, [
782
+ { label: "Queue", value: pc.magenta(queueName) },
783
+ { label: "Ready", value: pc.cyan(String(readyJobs.length)) },
784
+ { label: "Leased", value: pc.yellow(String(leasedJobs.length)) },
785
+ { label: "Dead", value: pc.red(String(totalDead)) },
786
+ { label: "Total Active", value: String(totalActive) },
787
+ ], "thingd queues stats");
788
+ return;
789
+ }
790
+ writeJson(context.stdout, {
791
+ queue: queueName,
792
+ totalActive,
793
+ ready: readyJobs.length,
794
+ leased: leasedJobs.length,
795
+ dead: totalDead,
796
+ }, false);
797
+ return;
798
+ }
799
+ if (action === "push") {
800
+ const payload = parseJsonRecord(requiredFlag(context.parsed, "payload"));
801
+ const options = {
802
+ idempotencyKey: stringFlag(context.parsed, "idempotency-key"),
803
+ maxAttempts: optionalInt(context.parsed, "max-attempts"),
804
+ delayMs: optionalInt(context.parsed, "delay-ms"),
805
+ };
806
+ writeJson(context.stdout, await queue.push(payload, compactOptions(options)), context.pretty);
807
+ return;
808
+ }
809
+ if (action === "claim") {
810
+ const options = {
811
+ leaseMs: optionalInt(context.parsed, "lease-ms"),
812
+ };
813
+ writeJson(context.stdout, await queue.claim(compactOptions(options)), context.pretty);
814
+ return;
815
+ }
816
+ if (action === "ack") {
817
+ writeJson(context.stdout, await queue.ack(requiredToken(context.parsed, 3, "job id")), context.pretty);
818
+ return;
819
+ }
820
+ if (action === "nack") {
821
+ const options = {
822
+ delayMs: optionalInt(context.parsed, "delay-ms"),
823
+ error: stringFlag(context.parsed, "error"),
824
+ };
825
+ writeJson(context.stdout, await queue.nack(requiredToken(context.parsed, 3, "job id"), compactOptions(options)), context.pretty);
826
+ return;
827
+ }
828
+ if (action === "list") {
829
+ const jobs = limitItems(await queue.list(), optionalInt(context.parsed, "limit"));
830
+ if (context.pretty) {
831
+ const bullets = jobs.map((job) => {
832
+ const statusColor = job.status === "leased" ? pc.yellow : pc.cyan;
833
+ return `${pc.dim(job.id)} ${statusColor(job.status)} ${pc.dim(`${job.attempts}/${job.maxAttempts}`)}`;
834
+ });
835
+ writeLogBullets(context.stdout, bullets.map((t) => ({ text: t, icon: pc.cyan("●") })), `thingd queues list ${queueName}`);
836
+ }
837
+ else {
838
+ writeJson(context.stdout, jobs, false);
839
+ }
840
+ return;
841
+ }
842
+ if (action === "dead") {
843
+ const jobs = limitItems(await queue.dead(), optionalInt(context.parsed, "limit"));
844
+ if (context.pretty) {
845
+ const bullets = jobs.map((job) => {
846
+ const err = job.lastError ? ` ${pc.dim(job.lastError)}` : "";
847
+ return `${pc.dim(job.id)} ${pc.dim(`${job.attempts}/${job.maxAttempts}`)}${err}`;
848
+ });
849
+ writeLogBullets(context.stdout, bullets.map((t) => ({ text: t, icon: pc.red("○") })), `thingd queues dead ${queueName}`);
850
+ }
851
+ else {
852
+ writeJson(context.stdout, jobs, false);
853
+ }
854
+ return;
855
+ }
856
+ throw new Error(`Unknown queues action: ${action}`);
857
+ });
858
+ }
859
+ async function runLinks(context) {
860
+ const action = requiredToken(context.parsed, 1, "links action");
861
+ await withDb(context, async (db) => {
862
+ if (action === "count") {
863
+ const count = await db.countLinks();
864
+ if (context.pretty) {
865
+ writeLog(context.stdout, [{ label: "Links", value: String(count) }], "thingd links count");
866
+ }
867
+ else {
868
+ writeJson(context.stdout, { count }, false);
869
+ }
870
+ return;
871
+ }
872
+ if (action === "create") {
873
+ const fromRef = requiredToken(context.parsed, 2, "from reference");
874
+ const linkType = requiredToken(context.parsed, 3, "link type");
875
+ const toRef = requiredToken(context.parsed, 4, "to reference");
876
+ const weight = optionalInt(context.parsed, "weight");
877
+ const metadata = stringFlag(context.parsed, "metadata");
878
+ writeJson(context.stdout, await db.links.create(fromRef, linkType, toRef, weight ?? undefined, metadata), context.pretty);
879
+ return;
880
+ }
881
+ if (action === "get") {
882
+ const id = requiredToken(context.parsed, 2, "link id");
883
+ writeJson(context.stdout, await db.links.get(id), context.pretty);
884
+ return;
885
+ }
886
+ if (action === "delete") {
887
+ const id = requiredToken(context.parsed, 2, "link id");
888
+ writeJson(context.stdout, { deleted: await db.links.delete(id) }, context.pretty);
889
+ return;
890
+ }
891
+ if (action === "neighbors") {
892
+ const reference = requiredToken(context.parsed, 2, "reference");
893
+ const direction = (stringFlag(context.parsed, "direction") ?? "Both");
894
+ const linkType = stringFlag(context.parsed, "type");
895
+ const limit = optionalInt(context.parsed, "limit");
896
+ const neighbors = await db.links.neighbors(reference, direction, {
897
+ linkType: linkType ?? undefined,
898
+ limit: limit ?? undefined,
899
+ });
900
+ if (context.pretty) {
901
+ const bullets = neighbors.map((l) => `${pc.dim(l.id)} ${pc.green(l.fromRef)} ${pc.magenta(l.linkType)} ${pc.green(l.toRef)}`);
902
+ writeLogBullets(context.stdout, bullets.map((t) => ({ text: t })), `thingd links neighbors ${reference}`);
903
+ }
904
+ else {
905
+ writeJson(context.stdout, neighbors, false);
906
+ }
907
+ return;
908
+ }
909
+ throw new Error(`Unknown links action: ${action}`);
910
+ });
911
+ }
912
+ async function runSchema(context) {
913
+ const action = context.parsed.tokens[1];
914
+ if (action === "check") {
915
+ const file = resolve(context.parsed.tokens[2] ?? "schema.thingd");
916
+ const source = readFileSync(file, "utf8");
917
+ const result = await parseSchema(source);
918
+ writeJson(context.stdout, { file, ...result }, context.pretty);
919
+ return;
920
+ }
921
+ const collection = stringFlag(context.parsed, "collection");
922
+ await withDb(context, async (db) => {
923
+ const schemas = await db.schema(collection ?? undefined);
924
+ writeJson(context.stdout, schemas, context.pretty);
925
+ });
926
+ }
927
+ async function runMigrate(context) {
928
+ const action = context.parsed.tokens[1] ?? "status";
929
+ const migrationDirectory = resolve("thingd", "migrations");
930
+ if (action === "status") {
931
+ const files = existsSync(migrationDirectory)
932
+ ? readdirSync(migrationDirectory, { withFileTypes: true })
933
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".thingd"))
934
+ .map((entry) => entry.name)
935
+ .sort()
936
+ : [];
937
+ await withDb(context, async (db) => {
938
+ const applied = await db.listMigrations();
939
+ writeJson(context.stdout, { directory: migrationDirectory, migrations: files, applied }, context.pretty);
940
+ });
941
+ return;
942
+ }
943
+ if (action === "create") {
944
+ const name = requiredToken(context.parsed, 2, "migration name");
945
+ const sourceFile = resolve(stringFlag(context.parsed, "schema") ?? "schema.thingd");
946
+ const source = readFileSync(sourceFile, "utf8");
947
+ await parseSchema(source);
948
+ mkdirSync(migrationDirectory, { recursive: true });
949
+ const existing = readdirSync(migrationDirectory)
950
+ .filter((file) => /^\d{4}_.+\.thingd$/.test(file))
951
+ .sort();
952
+ const sequence = String(existing.reduce((max, file) => Math.max(max, Number(file.slice(0, 4))), 0) + 1).padStart(4, "0");
953
+ const safeName = name
954
+ .toLowerCase()
955
+ .replace(/[^a-z0-9]+/g, "_")
956
+ .replace(/^_|_$/g, "");
957
+ if (!safeName) {
958
+ throw new Error("migration name must contain letters or numbers");
959
+ }
960
+ const destination = resolve(migrationDirectory, `${sequence}_${safeName}.thingd`);
961
+ writeFileSync(destination, source, "utf8");
962
+ writeJson(context.stdout, { created: destination, source: sourceFile }, context.pretty);
963
+ return;
964
+ }
965
+ if (action === "plan") {
966
+ const sourceFile = resolve(context.parsed.tokens[2] ?? "schema.thingd");
967
+ const source = readFileSync(sourceFile, "utf8");
968
+ const parsed = (await parseSchema(source));
969
+ const changes = [];
970
+ await withDb(context, async (db) => {
971
+ const current = await db.schema();
972
+ const currentByName = new Map(current.map((collection) => [collection.name, collection]));
973
+ const declaredNames = new Set(parsed.schema.collections.map((collection) => collection.name));
974
+ for (const collection of parsed.schema.collections) {
975
+ const existing = currentByName.get(collection.name);
976
+ if (!existing) {
977
+ changes.push({
978
+ kind: "collection_added",
979
+ collection: collection.name,
980
+ destructive: false,
981
+ });
982
+ continue;
983
+ }
984
+ const fields = new Set(existing.fields.map((field) => field.name));
985
+ for (const field of collection.fields) {
986
+ if (!fields.has(field.name)) {
987
+ changes.push({
988
+ kind: "field_added",
989
+ collection: collection.name,
990
+ field: field.name,
991
+ destructive: false,
992
+ });
993
+ }
994
+ else {
995
+ const currentField = existing.fields.find((candidate) => candidate.name === field.name);
996
+ const from = currentField?.type?.toLowerCase();
997
+ const to = field.field_type?.kind?.toLowerCase();
998
+ if (from && to && from !== to) {
999
+ changes.push({
1000
+ kind: "field_type_changed",
1001
+ collection: collection.name,
1002
+ field: field.name,
1003
+ from,
1004
+ to,
1005
+ destructive: true,
1006
+ });
1007
+ }
1008
+ }
1009
+ }
1010
+ const declaredFields = new Set(collection.fields.map((field) => field.name));
1011
+ for (const field of existing.fields) {
1012
+ if (!declaredFields.has(field.name)) {
1013
+ changes.push({
1014
+ kind: "field_missing",
1015
+ collection: collection.name,
1016
+ field: field.name,
1017
+ destructive: true,
1018
+ });
1019
+ }
1020
+ }
1021
+ }
1022
+ for (const collection of current) {
1023
+ if (!declaredNames.has(collection.name)) {
1024
+ changes.push({
1025
+ kind: "collection_missing",
1026
+ collection: collection.name,
1027
+ destructive: true,
1028
+ });
1029
+ }
1030
+ }
1031
+ });
1032
+ writeJson(context.stdout, {
1033
+ file: sourceFile,
1034
+ hash: parsed.hash,
1035
+ changes,
1036
+ destructive: changes.some((change) => change.destructive),
1037
+ }, context.pretty);
1038
+ return;
1039
+ }
1040
+ if (action === "apply") {
1041
+ const sourceFile = resolve(context.parsed.tokens[2] ?? "schema.thingd");
1042
+ const source = readFileSync(sourceFile, "utf8");
1043
+ const parsed = (await parseSchema(source));
1044
+ const migrationId = sourceFile.split("/").pop() ?? sourceFile;
1045
+ await withDb(context, async (db) => {
1046
+ const applied = await db.listMigrations();
1047
+ if (applied.some((migration) => migration.id === migrationId)) {
1048
+ writeJson(context.stdout, { alreadyApplied: true, migrationId }, context.pretty);
1049
+ return;
1050
+ }
1051
+ const current = await db.schema();
1052
+ if (hasDestructiveSchemaChanges(parsed.schema, current)) {
1053
+ throw new Error("Destructive schema changes are not supported by migrate apply yet; review migrate plan and keep the migration unapplied");
1054
+ }
1055
+ const indexes = [];
1056
+ for (const collection of parsed.schema.collections) {
1057
+ for (const field of collection.fields) {
1058
+ if (field.annotations?.some((annotation) => ["index", "unique"].includes(annotation.name))) {
1059
+ if (field.annotations?.some((annotation) => annotation.name === "unique")) {
1060
+ await db.createUniqueIndex(collection.name, field.name);
1061
+ }
1062
+ else {
1063
+ await db.createIndex(collection.name, field.name);
1064
+ }
1065
+ indexes.push(`${collection.name}.${field.name}`);
1066
+ }
1067
+ }
1068
+ }
1069
+ const appliedAt = new Date().toISOString();
1070
+ await db.putSchemaDocument({
1071
+ schemaJson: JSON.stringify(parsed.schema),
1072
+ hash: parsed.hash,
1073
+ updatedAt: appliedAt,
1074
+ });
1075
+ await db.recordMigration({ id: migrationId, hash: parsed.hash, appliedAt });
1076
+ writeJson(context.stdout, { applied: migrationId, hash: parsed.hash, indexes, destructiveChangesApplied: false }, context.pretty);
1077
+ });
1078
+ return;
1079
+ }
1080
+ throw new Error(`Unknown migrate action: ${action}. Use create, plan, or status.`);
1081
+ }
1082
+ function hasDestructiveSchemaChanges(declared, current) {
1083
+ const currentByName = new Map(current.map((collection) => [collection.name, collection]));
1084
+ const declaredNames = new Set(declared.collections.map((collection) => collection.name));
1085
+ for (const collection of declared.collections) {
1086
+ const existing = currentByName.get(collection.name);
1087
+ if (!existing) {
1088
+ continue;
1089
+ }
1090
+ const declaredFields = new Map(collection.fields.map((field) => [field.name, field.field_type?.kind?.toLowerCase()]));
1091
+ for (const field of existing.fields) {
1092
+ const desired = declaredFields.get(field.name);
1093
+ if (!desired || (desired !== field.type.toLowerCase() && field.type !== "unknown")) {
1094
+ return true;
1095
+ }
1096
+ }
1097
+ }
1098
+ return current.some((collection) => !declaredNames.has(collection.name));
1099
+ }
1100
+ async function runNlq(context) {
1101
+ const question = context.parsed.tokens.slice(1).join(" ").trim();
1102
+ if (!question) {
1103
+ throw new Error("nlq requires a question. Usage: thingd nlq <question>");
1104
+ }
1105
+ const collection = stringFlag(context.parsed, "collection");
1106
+ await withDb(context, async (db) => {
1107
+ const result = await db.nlq.query(question, { collection: collection ?? undefined });
1108
+ writeJson(context.stdout, result, context.pretty);
1109
+ });
1110
+ }
1111
+ async function runAggregate(context) {
1112
+ const function_ = requiredToken(context.parsed, 1, "aggregate function (count|sum|avg|min|max)");
1113
+ const collection = requiredToken(context.parsed, 2, "collection name");
1114
+ const field = stringFlag(context.parsed, "field");
1115
+ const groupBy = stringFlag(context.parsed, "group-by");
1116
+ const filterRaw = stringFlag(context.parsed, "filter");
1117
+ const filter = filterRaw ? JSON.parse(filterRaw) : undefined;
1118
+ await withDb(context, async (db) => {
1119
+ let result;
1120
+ switch (function_) {
1121
+ case "sum":
1122
+ result = await db.aggregate.sum(collection, field ?? "", { groupBy, filter });
1123
+ break;
1124
+ case "avg":
1125
+ result = await db.aggregate.avg(collection, field ?? "", { groupBy, filter });
1126
+ break;
1127
+ case "min":
1128
+ result = await db.aggregate.min(collection, field ?? "", { groupBy, filter });
1129
+ break;
1130
+ case "max":
1131
+ result = await db.aggregate.max(collection, field ?? "", { groupBy, filter });
1132
+ break;
1133
+ default:
1134
+ result = await db.aggregate.count(collection, { groupBy, filter });
1135
+ break;
1136
+ }
1137
+ writeJson(context.stdout, result, context.pretty);
1138
+ });
1139
+ }
1140
+ async function runTimeseries(context) {
1141
+ const function_ = requiredToken(context.parsed, 1, "aggregate function (count|sum|avg|min|max)");
1142
+ const collection = requiredToken(context.parsed, 2, "collection name");
1143
+ const bucket = requiredToken(context.parsed, 3, "time bucket (hour|day|week|month)");
1144
+ const field = stringFlag(context.parsed, "field");
1145
+ const filterRaw = stringFlag(context.parsed, "filter");
1146
+ const filter = filterRaw ? JSON.parse(filterRaw) : undefined;
1147
+ const from = stringFlag(context.parsed, "from");
1148
+ const to = stringFlag(context.parsed, "to");
1149
+ await withDb(context, async (db) => {
1150
+ const result = await db.timeseries(collection, {
1151
+ function: function_,
1152
+ bucket: bucket,
1153
+ field,
1154
+ filter,
1155
+ from,
1156
+ to,
1157
+ });
1158
+ writeJson(context.stdout, result, context.pretty);
1159
+ });
1160
+ }
1161
+ export async function withDb(context, callback) {
1162
+ const connection = resolveConnection(context);
1163
+ const db = await ThingD.open({
1164
+ path: connection.path,
1165
+ url: connection.cloud ? connection.path : undefined,
1166
+ driver: connection.driver,
1167
+ authToken: connection.authToken,
1168
+ instanceSlug: connection.instanceSlug,
1169
+ encryption: connection.encryptionKey ? { key: connection.encryptionKey } : undefined,
1170
+ });
1171
+ try {
1172
+ await callback(db);
1173
+ }
1174
+ finally {
1175
+ await db.close();
1176
+ }
1177
+ }
1178
+ function buildMemoryObject(parsed, id) {
1179
+ const data = stringFlag(parsed, "data");
1180
+ const text = stringFlag(parsed, "text");
1181
+ if (data === undefined && text === undefined) {
1182
+ throw new Error("objects put requires --text or --data");
1183
+ }
1184
+ return {
1185
+ ...(data === undefined ? {} : parseJsonRecord(data)),
1186
+ id,
1187
+ ...(text === undefined ? {} : { text }),
1188
+ };
1189
+ }
1190
+ function buildMemoryEvent(parsed, type) {
1191
+ const data = stringFlag(parsed, "data");
1192
+ const text = stringFlag(parsed, "text");
1193
+ return {
1194
+ ...(data === undefined ? {} : parseJsonRecord(data)),
1195
+ type,
1196
+ ...(text === undefined ? {} : { text }),
1197
+ };
1198
+ }
1199
+ export function resolveConnection(context) {
1200
+ const url = stringFlag(context.parsed, "url") ?? context.env.THINGD_URL;
1201
+ const path = url ?? stringFlag(context.parsed, "path") ?? context.env.THINGD_PATH ?? defaultThingdDbPath();
1202
+ let cloud = isCloudPath(path);
1203
+ let driver = parseDriver(stringFlag(context.parsed, "driver") ?? context.env.THINGD_DRIVER);
1204
+ if (!driver) {
1205
+ if (cloud) {
1206
+ driver = "cloud";
1207
+ }
1208
+ else if (path !== ":memory:") {
1209
+ driver = "native";
1210
+ }
1211
+ else {
1212
+ driver = "memory";
1213
+ }
1214
+ }
1215
+ if (driver === "cloud") {
1216
+ cloud = true;
1217
+ }
1218
+ if (!cloud && path === defaultThingdDbPath()) {
1219
+ ensureThingdDir();
1220
+ }
1221
+ // Fall back to saved cloud config when no explicit URL/token is provided
1222
+ const cloudCfg = cloud && !url ? readCloudConfig() : null;
1223
+ if (driver === "cloud" && !url && !cloudCfg) {
1224
+ context.stderr.write(`${pc.red("Not logged in.")} Run ${pc.cyan("thingd cloud login")} first, or use ${pc.cyan("--url")} and ${pc.cyan("--auth-token")} to connect directly.\n`);
1225
+ throw new Error("not_logged_in");
1226
+ }
1227
+ const resolvedAuthToken = stringFlag(context.parsed, "auth-token") ??
1228
+ context.env.THINGD_AUTH_TOKEN ??
1229
+ cloudCfg?.userToken ??
1230
+ cloudCfg?.apiKey ??
1231
+ cloudCfg?.token;
1232
+ // Prefer instanceUrl (resolved MCP endpoint) over raw cloudCfg.url (API base)
1233
+ const resolvedCloudUrl = cloudCfg ? resolveCloudUrl(cloudCfg) : undefined;
1234
+ const effectiveCloudUrl = resolvedCloudUrl && driver === "cloud" ? deriveRestUrl(resolvedCloudUrl) : resolvedCloudUrl;
1235
+ return {
1236
+ path: effectiveCloudUrl && !url ? effectiveCloudUrl : path,
1237
+ driver,
1238
+ authToken: resolvedAuthToken,
1239
+ cloud,
1240
+ instanceSlug: cloudCfg?.instanceSlug,
1241
+ encryptionKey: context.env.THINGD_ENCRYPTION_KEY,
1242
+ };
1243
+ }
1244
+ function parseArgs(args) {
1245
+ const tokens = [];
1246
+ const flags = new Map();
1247
+ const booleans = new Set();
1248
+ for (let index = 0; index < args.length; index += 1) {
1249
+ const arg = args[index];
1250
+ if (arg === undefined) {
1251
+ continue;
1252
+ }
1253
+ if (!arg.startsWith("-")) {
1254
+ tokens.push(arg);
1255
+ continue;
1256
+ }
1257
+ const name = arg.replace(/^-+/, "");
1258
+ const next = args[index + 1];
1259
+ if (BOOLEAN_FLAGS.has(name)) {
1260
+ booleans.add(name);
1261
+ continue;
1262
+ }
1263
+ if (next === undefined || next.startsWith("-")) {
1264
+ booleans.add(name);
1265
+ continue;
1266
+ }
1267
+ addFlagValue(flags, name, next);
1268
+ index += 1;
1269
+ }
1270
+ return {
1271
+ tokens,
1272
+ flags,
1273
+ booleans,
1274
+ };
1275
+ }
1276
+ function addFlagValue(flags, name, value) {
1277
+ const values = flags.get(name) ?? [];
1278
+ values.push(value);
1279
+ flags.set(name, values);
1280
+ }
1281
+ export function hasFlag(parsed, name) {
1282
+ return parsed.booleans.has(name) || parsed.flags.has(name);
1283
+ }
1284
+ export function stringFlag(parsed, name) {
1285
+ return parsed.flags.get(name)?.at(-1);
1286
+ }
1287
+ export function stringFlags(parsed, name) {
1288
+ const values = parsed.flags.get(name);
1289
+ return values && values.length > 0 ? values : undefined;
1290
+ }
1291
+ export function requiredFlag(parsed, name) {
1292
+ const value = stringFlag(parsed, name);
1293
+ if (value === undefined) {
1294
+ throw new Error(`Missing required flag: --${name}`);
1295
+ }
1296
+ return value;
1297
+ }
1298
+ export function optionalToken(parsed, index) {
1299
+ return parsed.tokens[index];
1300
+ }
1301
+ export function requiredToken(parsed, index, name) {
1302
+ const value = optionalToken(parsed, index);
1303
+ if (!value) {
1304
+ throw new Error(`Missing required argument: ${name}`);
1305
+ }
1306
+ return value;
1307
+ }
1308
+ function optionalInt(parsed, name) {
1309
+ const value = stringFlag(parsed, name);
1310
+ if (value === undefined) {
1311
+ return undefined;
1312
+ }
1313
+ const parsedValue = Number.parseInt(value, 10);
1314
+ if (!Number.isSafeInteger(parsedValue) || parsedValue < 0) {
1315
+ throw new Error(`--${name} must be a non-negative integer`);
1316
+ }
1317
+ return parsedValue;
1318
+ }
1319
+ function parseDriver(value) {
1320
+ if (value === undefined) {
1321
+ return undefined;
1322
+ }
1323
+ if (value === "memory" || value === "native" || value === "cloud") {
1324
+ return value;
1325
+ }
1326
+ throw new Error(`Unsupported driver: ${value}`);
1327
+ }
1328
+ function parseJsonRecord(value) {
1329
+ const parsed = JSON.parse(value);
1330
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1331
+ throw new Error("Expected a JSON object");
1332
+ }
1333
+ return parsed;
1334
+ }
1335
+ function compactOptions(options) {
1336
+ return Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined));
1337
+ }
1338
+ function limitItems(items, limit) {
1339
+ return limit === undefined ? items : items.slice(0, limit);
1340
+ }
1341
+ function isCloudPath(path) {
1342
+ return path.startsWith("http://") || path.startsWith("https://") || path.startsWith("thingd://");
1343
+ }
1344
+ function resolveCloudMcpUrl(value) {
1345
+ const url = new URL(normalizeCloudUrl(value));
1346
+ if (url.pathname === "" || url.pathname === "/") {
1347
+ url.pathname = "/mcp";
1348
+ }
1349
+ return url.toString();
1350
+ }
1351
+ function resolveCloudBaseUrl(value) {
1352
+ const url = new URL(normalizeCloudUrl(value));
1353
+ if (url.pathname === "/mcp") {
1354
+ url.pathname = "/";
1355
+ }
1356
+ return url.toString();
1357
+ }
1358
+ function normalizeCloudUrl(value) {
1359
+ return value.startsWith("thingd://") ? `http://${value.slice("thingd://".length)}` : value;
1360
+ }
1361
+ async function fetchJson(url, authToken) {
1362
+ const response = await fetch(url, {
1363
+ headers: authToken
1364
+ ? {
1365
+ Authorization: `Bearer ${authToken}`,
1366
+ }
1367
+ : undefined,
1368
+ });
1369
+ if (!response.ok) {
1370
+ throw new Error(`HTTP ${response.status} from ${url.toString()}`);
1371
+ }
1372
+ return response.json();
1373
+ }
1374
+ export function writeJson(target, data, pretty) {
1375
+ target.write(`${JSON.stringify(data, null, pretty ? 2 : 0)}\n`);
1376
+ }
1377
+ export function writeText(target, text) {
1378
+ target.write(text.endsWith("\n") ? text : `${text}\n`);
1379
+ }
1380
+ function resolveCliPath() {
1381
+ const scriptPath = process.argv[1];
1382
+ if (!scriptPath) {
1383
+ throw new Error("Could not detect thingd CLI path from process.argv[1].");
1384
+ }
1385
+ try {
1386
+ return realpathSync(resolve(scriptPath));
1387
+ }
1388
+ catch {
1389
+ return resolve(scriptPath);
1390
+ }
1391
+ }
1392
+ async function runDashboard(context) {
1393
+ const portStr = stringFlag(context.parsed, "port");
1394
+ const port = portStr ? Number.parseInt(portStr, 10) : 8758;
1395
+ if (Number.isNaN(port) || port <= 0) {
1396
+ throw new Error("--port must be a positive integer");
1397
+ }
1398
+ const connection = resolveConnection(context);
1399
+ context.stderr.write(`\n${pc.bold(pc.blue("thingd Inspector Dashboard"))}\n` +
1400
+ `Starting local REST server on ${pc.cyan(`http://localhost:${port}`)}...\n` +
1401
+ `Database path: ${pc.green(connection.path)}\n` +
1402
+ `Storage engine: ${pc.cyan(connection.driver || "memory")}\n\n`);
1403
+ const { startDashboardServer } = await import("./dashboard/server.js");
1404
+ const { server: _server, close } = await startDashboardServer(connection, port);
1405
+ context.stderr.write(`${pc.green("✔ Dashboard successfully loaded.")}\n` +
1406
+ `Opening browser... (Press ${pc.yellow("Ctrl+C")} to stop the server)\n\n`);
1407
+ await openBrowser(`http://localhost:${port}`);
1408
+ return new Promise((_resolve) => {
1409
+ process.on("SIGINT", async () => {
1410
+ await close();
1411
+ process.exit(0);
1412
+ });
1413
+ process.on("SIGTERM", async () => {
1414
+ await close();
1415
+ process.exit(0);
1416
+ });
1417
+ });
1418
+ }
1419
+ async function openBrowser(url) {
1420
+ const { exec } = await import("node:child_process");
1421
+ const startCommand = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
1422
+ exec(`${startCommand} ${url}`, () => {
1423
+ // Ignore browser spawn errors silently
1424
+ });
1425
+ }
1426
+ let isMain = false;
1427
+ if (process.argv[1]) {
1428
+ try {
1429
+ isMain = import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
1430
+ }
1431
+ catch {
1432
+ // Ignore realpath resolution errors.
1433
+ }
1434
+ }
1435
+ if (isMain) {
1436
+ runCli()
1437
+ .then((code) => {
1438
+ if (code !== 0) {
1439
+ process.exit(code);
1440
+ }
1441
+ })
1442
+ .catch((error) => {
1443
+ console.error(error);
1444
+ process.exit(1);
1445
+ });
1446
+ }