@thingd/cli 0.77.0 → 0.77.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/cloud.d.ts +3 -0
- package/dist/commands/cloud.d.ts.map +1 -0
- package/dist/commands/cloud.js +669 -0
- package/dist/commands/mcp-connect.d.ts +3 -0
- package/dist/commands/mcp-connect.d.ts.map +1 -0
- package/dist/commands/mcp-connect.js +154 -0
- package/dist/commands/sync.d.ts +3 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/sync.js +197 -0
- package/dist/dashboard/public/assets/favicon-CgGFvG_0.svg +4 -0
- package/dist/dashboard/public/assets/index-C6PkDB7y.css +1 -0
- package/dist/dashboard/public/assets/index-DrpfyClj.js +4 -0
- package/dist/dashboard/public/index.html +19 -0
- package/dist/dashboard/server.d.ts +6 -0
- package/dist/dashboard/server.d.ts.map +1 -0
- package/dist/dashboard/server.js +684 -0
- package/dist/data-movement.d.ts +6 -0
- package/dist/data-movement.d.ts.map +1 -0
- package/dist/data-movement.js +475 -0
- package/dist/doctor.d.ts +3 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +108 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1446 -0
- package/dist/install.d.ts +3 -0
- package/dist/install.d.ts.map +1 -0
- package/dist/install.js +229 -0
- package/dist/interactive.d.ts +2 -0
- package/dist/interactive.d.ts.map +1 -0
- package/dist/interactive.js +3039 -0
- package/dist/lib/cloud-api.d.ts +143 -0
- package/dist/lib/cloud-api.d.ts.map +1 -0
- package/dist/lib/cloud-api.js +207 -0
- package/dist/lib/cloud-config.d.ts +33 -0
- package/dist/lib/cloud-config.d.ts.map +1 -0
- package/dist/lib/cloud-config.js +42 -0
- package/dist/lib/mcp-config-writer.d.ts +26 -0
- package/dist/lib/mcp-config-writer.d.ts.map +1 -0
- package/dist/lib/mcp-config-writer.js +86 -0
- package/dist/lib/sync-config.d.ts +22 -0
- package/dist/lib/sync-config.d.ts.map +1 -0
- package/dist/lib/sync-config.js +25 -0
- package/dist/logo.d.ts +3 -0
- package/dist/logo.d.ts.map +1 -0
- package/dist/logo.js +8 -0
- package/dist/mcp/cluster.d.ts +69 -0
- package/dist/mcp/cluster.d.ts.map +1 -0
- package/dist/mcp/cluster.js +304 -0
- package/dist/mcp/config.d.ts +14 -0
- package/dist/mcp/config.d.ts.map +1 -0
- package/dist/mcp/config.js +67 -0
- package/dist/mcp/http.d.ts +35 -0
- package/dist/mcp/http.d.ts.map +1 -0
- package/dist/mcp/http.js +957 -0
- package/dist/mcp/index.d.ts +5 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +3 -0
- package/dist/mcp-http.d.ts +3 -0
- package/dist/mcp-http.d.ts.map +1 -0
- package/dist/mcp-http.js +42 -0
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +28 -0
- package/dist/paths.d.ts +4 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +14 -0
- package/package.json +2 -2
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type CliContext } from "./index.js";
|
|
2
|
+
export declare function runExport(context: CliContext): Promise<void>;
|
|
3
|
+
export declare function runImport(context: CliContext): Promise<void>;
|
|
4
|
+
export declare function runSnapshot(context: CliContext): Promise<void>;
|
|
5
|
+
export declare function runBackup(context: CliContext): Promise<void>;
|
|
6
|
+
//# sourceMappingURL=data-movement.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"data-movement.d.ts","sourceRoot":"","sources":["../src/data-movement.ts"],"names":[],"mappings":"AAWA,OAAO,EAAE,KAAK,UAAU,EAAwD,MAAM,YAAY,CAAC;AAwEnG,wBAAsB,SAAS,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAyClE;AAED,wBAAsB,SAAS,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAsFlE;AAoND,wBAAsB,WAAW,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAmHpE;AAED,wBAAsB,SAAS,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAyClE"}
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { hasFlag, requiredFlag, stringFlag, withDb, writeJson } from "./index.js";
|
|
4
|
+
const SIDECAR_DEFAULT_URL = "http://localhost:8757";
|
|
5
|
+
const DEFAULT_REDACT_KEYS = [
|
|
6
|
+
"password",
|
|
7
|
+
"secret",
|
|
8
|
+
"token",
|
|
9
|
+
"key",
|
|
10
|
+
"auth",
|
|
11
|
+
"credential",
|
|
12
|
+
"email",
|
|
13
|
+
"phone",
|
|
14
|
+
"session",
|
|
15
|
+
"cookie",
|
|
16
|
+
"signature",
|
|
17
|
+
"private",
|
|
18
|
+
"cert",
|
|
19
|
+
"api",
|
|
20
|
+
];
|
|
21
|
+
function redactText(text) {
|
|
22
|
+
let res = text;
|
|
23
|
+
// Redact emails
|
|
24
|
+
res = res.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, "[REDACTED_EMAIL]");
|
|
25
|
+
// Redact API keys matching sk-...
|
|
26
|
+
res = res.replace(/sk-[a-zA-Z0-9]{20,}/g, "[REDACTED_KEY]");
|
|
27
|
+
// Redact Bearer tokens
|
|
28
|
+
res = res.replace(/bearer\s+[a-zA-Z0-9\-._~+/]+=*/gi, "Bearer [REDACTED]");
|
|
29
|
+
return res;
|
|
30
|
+
}
|
|
31
|
+
function redactValue(val, redactKeys) {
|
|
32
|
+
if (val === null || val === undefined) {
|
|
33
|
+
return val;
|
|
34
|
+
}
|
|
35
|
+
if (Array.isArray(val)) {
|
|
36
|
+
return val.map((item) => redactValue(item, redactKeys));
|
|
37
|
+
}
|
|
38
|
+
if (typeof val === "object") {
|
|
39
|
+
const obj = val;
|
|
40
|
+
const result = {};
|
|
41
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
42
|
+
const lowerKey = k.toLowerCase();
|
|
43
|
+
const shouldRedact = redactKeys.some((rKey) => lowerKey.includes(rKey.toLowerCase()));
|
|
44
|
+
if (shouldRedact) {
|
|
45
|
+
result[k] = "[REDACTED]";
|
|
46
|
+
}
|
|
47
|
+
else if (v && typeof v === "object") {
|
|
48
|
+
result[k] = redactValue(v, redactKeys);
|
|
49
|
+
}
|
|
50
|
+
else if (typeof v === "string" && k === "text") {
|
|
51
|
+
result[k] = redactText(v);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
result[k] = v;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
return val;
|
|
60
|
+
}
|
|
61
|
+
export async function runExport(context) {
|
|
62
|
+
const isEvents = hasFlag(context.parsed, "events");
|
|
63
|
+
const collection = stringFlag(context.parsed, "collection");
|
|
64
|
+
const outPath = requiredFlag(context.parsed, "out");
|
|
65
|
+
if (!isEvents && !collection) {
|
|
66
|
+
throw new Error("Must specify either --collection <name> or --events for export.");
|
|
67
|
+
}
|
|
68
|
+
if (isEvents && collection) {
|
|
69
|
+
throw new Error("Cannot specify both --collection <name> and --events.");
|
|
70
|
+
}
|
|
71
|
+
await withDb(context, async (db) => {
|
|
72
|
+
const redactFlag = stringFlag(context.parsed, "redact");
|
|
73
|
+
const isRedact = hasFlag(context.parsed, "redact");
|
|
74
|
+
const redactKeys = isRedact
|
|
75
|
+
? redactFlag
|
|
76
|
+
? redactFlag.split(",").map((k) => k.trim())
|
|
77
|
+
: DEFAULT_REDACT_KEYS
|
|
78
|
+
: null;
|
|
79
|
+
let lines = [];
|
|
80
|
+
if (collection) {
|
|
81
|
+
const objects = await db.listObjects(collection);
|
|
82
|
+
lines = objects.map((obj) => {
|
|
83
|
+
const finalObj = redactKeys ? redactValue(obj, redactKeys) : obj;
|
|
84
|
+
return JSON.stringify(finalObj);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
const stream = stringFlag(context.parsed, "stream");
|
|
89
|
+
const events = await db.events.list(stream);
|
|
90
|
+
lines = events.map((ev) => {
|
|
91
|
+
const finalEv = redactKeys ? redactValue(ev, redactKeys) : ev;
|
|
92
|
+
return JSON.stringify(finalEv);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
writeFileSync(resolve(outPath), `${lines.join("\n")}\n`, "utf8");
|
|
96
|
+
writeJson(context.stdout, { success: true, count: lines.length, out: outPath }, context.pretty);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
export async function runImport(context) {
|
|
100
|
+
const source = stringFlag(context.parsed, "source") ?? context.parsed.tokens[1] ?? "";
|
|
101
|
+
const type = stringFlag(context.parsed, "type");
|
|
102
|
+
const isDb = source.startsWith("postgresql://") ||
|
|
103
|
+
source.startsWith("postgres://") ||
|
|
104
|
+
source.startsWith("mysql://") ||
|
|
105
|
+
type === "postgres" ||
|
|
106
|
+
type === "mysql";
|
|
107
|
+
if (isDb) {
|
|
108
|
+
await runImportDb(context, source, type);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// --- existing file import logic (unchanged) ---
|
|
112
|
+
const collection = requiredFlag(context.parsed, "collection");
|
|
113
|
+
const inPath = requiredFlag(context.parsed, "in");
|
|
114
|
+
const resolvedPath = resolve(inPath);
|
|
115
|
+
if (!existsSync(resolvedPath)) {
|
|
116
|
+
throw new Error(`Input file not found: ${inPath}`);
|
|
117
|
+
}
|
|
118
|
+
const content = readFileSync(resolvedPath, "utf8");
|
|
119
|
+
const lines = content
|
|
120
|
+
.split("\n")
|
|
121
|
+
.map((l) => l.trim())
|
|
122
|
+
.filter(Boolean);
|
|
123
|
+
// Detect file type
|
|
124
|
+
const isCsv = inPath.endsWith(".csv");
|
|
125
|
+
await withDb(context, async (db) => {
|
|
126
|
+
let count = 0;
|
|
127
|
+
if (isCsv) {
|
|
128
|
+
// Parse CSV
|
|
129
|
+
const headers = lines[0]?.split(",").map((h) => h.trim()) ?? [];
|
|
130
|
+
for (let i = 1; i < lines.length; i++) {
|
|
131
|
+
const values = lines[i]?.split(",") ?? [];
|
|
132
|
+
const obj = {};
|
|
133
|
+
for (let j = 0; j < headers.length; j++) {
|
|
134
|
+
const key = headers[j];
|
|
135
|
+
if (!key) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const val = values[j]?.trim() ?? "";
|
|
139
|
+
// Try to infer types
|
|
140
|
+
if (val === "" || val === "null") {
|
|
141
|
+
obj[key] = null;
|
|
142
|
+
}
|
|
143
|
+
else if (val === "true") {
|
|
144
|
+
obj[key] = true;
|
|
145
|
+
}
|
|
146
|
+
else if (val === "false") {
|
|
147
|
+
obj[key] = false;
|
|
148
|
+
}
|
|
149
|
+
else if (!Number.isNaN(Number(val))) {
|
|
150
|
+
obj[key] = Number(val);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
obj[key] = val;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Add row index as ID if not present
|
|
157
|
+
if (!obj.id) {
|
|
158
|
+
obj.id = `row-${i}`;
|
|
159
|
+
}
|
|
160
|
+
await db.put(collection, obj);
|
|
161
|
+
count += 1;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
// Parse JSONL
|
|
166
|
+
for (const line of lines) {
|
|
167
|
+
const parsedObj = JSON.parse(line);
|
|
168
|
+
if (!parsedObj.id || typeof parsedObj.id !== "string") {
|
|
169
|
+
throw new Error("Imported object must contain a string 'id' field.");
|
|
170
|
+
}
|
|
171
|
+
await db.put(collection, parsedObj);
|
|
172
|
+
count += 1;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
writeJson(context.stdout, { success: true, count, collection, format: isCsv ? "csv" : "jsonl" }, context.pretty);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
// ── DB import helpers ──────────────────────────────────────────────────
|
|
179
|
+
function resolveSidecarUrl(context) {
|
|
180
|
+
const flag = stringFlag(context.parsed, "sidecar");
|
|
181
|
+
if (flag) {
|
|
182
|
+
return flag.replace(/\/$/, "");
|
|
183
|
+
}
|
|
184
|
+
const envUrl = context.env.THINGD_URL;
|
|
185
|
+
if (envUrl) {
|
|
186
|
+
const normalized = envUrl.replace(/\/$/, "");
|
|
187
|
+
if (normalized.startsWith("http://") || normalized.startsWith("https://")) {
|
|
188
|
+
return normalized;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return SIDECAR_DEFAULT_URL;
|
|
192
|
+
}
|
|
193
|
+
function authHeaders(context) {
|
|
194
|
+
const token = stringFlag(context.parsed, "auth-token") ?? context.env.THINGD_AUTH_TOKEN;
|
|
195
|
+
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
196
|
+
}
|
|
197
|
+
function detectConnectorType(source, explicitType) {
|
|
198
|
+
if (explicitType === "postgres" || explicitType === "mysql") {
|
|
199
|
+
return explicitType;
|
|
200
|
+
}
|
|
201
|
+
if (source.startsWith("postgresql://") || source.startsWith("postgres://")) {
|
|
202
|
+
return "postgres";
|
|
203
|
+
}
|
|
204
|
+
if (source.startsWith("mysql://")) {
|
|
205
|
+
return "mysql";
|
|
206
|
+
}
|
|
207
|
+
throw new Error(`Could not detect connector type from "${source.slice(0, 30)}...". Use --type postgres or --type mysql.`);
|
|
208
|
+
}
|
|
209
|
+
function parseConnectionString(source) {
|
|
210
|
+
const isUri = source.startsWith("postgresql://") ||
|
|
211
|
+
source.startsWith("postgres://") ||
|
|
212
|
+
source.startsWith("mysql://");
|
|
213
|
+
if (isUri) {
|
|
214
|
+
const url = new URL(source);
|
|
215
|
+
const defaultPort = source.startsWith("mysql://") ? 3306 : 5432;
|
|
216
|
+
return {
|
|
217
|
+
host: url.hostname,
|
|
218
|
+
port: url.port ? Number(url.port) : defaultPort,
|
|
219
|
+
database: decodeURIComponent(url.pathname.replace(/^\//, "")),
|
|
220
|
+
username: decodeURIComponent(url.username),
|
|
221
|
+
password: decodeURIComponent(url.password),
|
|
222
|
+
sslMode: url.searchParams.get("sslmode") ?? "prefer",
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
// Shorthand: host:port:database:username:password
|
|
226
|
+
const parts = source.split(":");
|
|
227
|
+
return {
|
|
228
|
+
host: parts[0] ?? "localhost",
|
|
229
|
+
port: Number(parts[1]) || 5432,
|
|
230
|
+
database: parts[2] ?? "",
|
|
231
|
+
username: parts[3] ?? "",
|
|
232
|
+
password: parts.slice(4).join(":"),
|
|
233
|
+
sslMode: "prefer",
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function parseMappings(context) {
|
|
237
|
+
const maps = stringFlag(context.parsed, "map");
|
|
238
|
+
if (!maps) {
|
|
239
|
+
return [];
|
|
240
|
+
}
|
|
241
|
+
return maps.split(",").map((m) => {
|
|
242
|
+
const parts = m.split(":");
|
|
243
|
+
return [parts[0] ?? "", parts[1] ?? parts[0] ?? ""];
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
function buildColumnMapping(pairs) {
|
|
247
|
+
if (pairs.length === 0) {
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
return Object.fromEntries(pairs);
|
|
251
|
+
}
|
|
252
|
+
async function callConnectorSchema(url, type, auth, query, headers) {
|
|
253
|
+
const res = await fetch(`${url}/v1/connectors/${type}/schema`, {
|
|
254
|
+
method: "POST",
|
|
255
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
256
|
+
body: JSON.stringify({ auth, query }),
|
|
257
|
+
});
|
|
258
|
+
if (!res.ok) {
|
|
259
|
+
const err = await res.json().catch(() => null);
|
|
260
|
+
throw new Error(err?.error?.detail ?? `Schema discovery failed (HTTP ${res.status})`);
|
|
261
|
+
}
|
|
262
|
+
const json = await res.json();
|
|
263
|
+
return json.data;
|
|
264
|
+
}
|
|
265
|
+
async function callConnectorPull(url, type, auth, opts, headers) {
|
|
266
|
+
const res = await fetch(`${url}/v1/connectors/${type}/pull`, {
|
|
267
|
+
method: "POST",
|
|
268
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
269
|
+
body: JSON.stringify({
|
|
270
|
+
auth,
|
|
271
|
+
collection: opts.collection,
|
|
272
|
+
query: opts.query,
|
|
273
|
+
batchSize: opts.batchSize,
|
|
274
|
+
columnMapping: opts.columnMapping,
|
|
275
|
+
}),
|
|
276
|
+
});
|
|
277
|
+
if (!res.ok) {
|
|
278
|
+
const err = await res.json().catch(() => null);
|
|
279
|
+
throw new Error(err?.error?.detail ?? `Import failed (HTTP ${res.status})`);
|
|
280
|
+
}
|
|
281
|
+
const json = await res.json();
|
|
282
|
+
return json.data;
|
|
283
|
+
}
|
|
284
|
+
async function runImportDb(context, source, explicitType) {
|
|
285
|
+
const sidecarUrl = resolveSidecarUrl(context);
|
|
286
|
+
const headers = authHeaders(context);
|
|
287
|
+
const connectorType = detectConnectorType(source, explicitType);
|
|
288
|
+
const auth = parseConnectionString(source);
|
|
289
|
+
const collection = requiredFlag(context.parsed, "collection");
|
|
290
|
+
const batchSize = Number(stringFlag(context.parsed, "batch-size") ?? "1000");
|
|
291
|
+
const columnMapping = buildColumnMapping(parseMappings(context));
|
|
292
|
+
// Verify sidecar is reachable
|
|
293
|
+
try {
|
|
294
|
+
const healthRes = await fetch(`${sidecarUrl}/v1/health`, { headers });
|
|
295
|
+
if (!healthRes.ok) {
|
|
296
|
+
throw new Error(`Sidecar returned HTTP ${healthRes.status}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
301
|
+
throw new Error(`Cannot reach sidecar at ${sidecarUrl}: ${message}. ` +
|
|
302
|
+
`Is thingd running? Try 'thingd mcp' first.`);
|
|
303
|
+
}
|
|
304
|
+
// --list-tables: show available tables
|
|
305
|
+
if (hasFlag(context.parsed, "list-tables")) {
|
|
306
|
+
const tables = await callConnectorSchema(sidecarUrl, connectorType, auth, "", headers);
|
|
307
|
+
writeJson(context.stdout, { tables }, context.pretty);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
// Resolve query from --tables or --query
|
|
311
|
+
const tablesFlag = stringFlag(context.parsed, "tables");
|
|
312
|
+
const queryFlag = stringFlag(context.parsed, "query");
|
|
313
|
+
if (!queryFlag && !tablesFlag) {
|
|
314
|
+
throw new Error("Must specify --tables (comma-separated) or --query for DB import.");
|
|
315
|
+
}
|
|
316
|
+
const queries = tablesFlag
|
|
317
|
+
? tablesFlag.split(",").map((t) => `SELECT * FROM ${t.trim()}`)
|
|
318
|
+
: [queryFlag];
|
|
319
|
+
// --dry-run: show schema without importing
|
|
320
|
+
if (hasFlag(context.parsed, "dry-run")) {
|
|
321
|
+
const schemas = [];
|
|
322
|
+
for (const q of queries) {
|
|
323
|
+
const schema = await callConnectorSchema(sidecarUrl, connectorType, auth, q, headers);
|
|
324
|
+
schemas.push(schema);
|
|
325
|
+
}
|
|
326
|
+
writeJson(context.stdout, { dryRun: true, schemas }, context.pretty);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
// Execute imports for each query/table
|
|
330
|
+
let totalImported = 0;
|
|
331
|
+
for (const q of queries) {
|
|
332
|
+
const result = await callConnectorPull(sidecarUrl, connectorType, auth, { collection, query: q, batchSize, columnMapping }, headers);
|
|
333
|
+
totalImported += result.imported;
|
|
334
|
+
}
|
|
335
|
+
writeJson(context.stdout, { success: true, imported: totalImported, collection }, context.pretty);
|
|
336
|
+
}
|
|
337
|
+
export async function runSnapshot(context) {
|
|
338
|
+
const subCommand = context.parsed.tokens[1];
|
|
339
|
+
if (subCommand === "create") {
|
|
340
|
+
const outPath = requiredFlag(context.parsed, "out");
|
|
341
|
+
await withDb(context, async (db) => {
|
|
342
|
+
const collectionsMap = {};
|
|
343
|
+
const cols = await db.listCollections();
|
|
344
|
+
for (const col of cols) {
|
|
345
|
+
collectionsMap[col] = await db.listObjects(col);
|
|
346
|
+
}
|
|
347
|
+
const eventsList = await db.events.list();
|
|
348
|
+
const queuesMap = {};
|
|
349
|
+
const queues = await db.listQueues();
|
|
350
|
+
for (const q of queues) {
|
|
351
|
+
const queue = db.queue(q);
|
|
352
|
+
const [active, dead] = await Promise.all([queue.list(), queue.dead()]);
|
|
353
|
+
queuesMap[q] = { active, dead };
|
|
354
|
+
}
|
|
355
|
+
const snapshot = {
|
|
356
|
+
version: "1.0.0",
|
|
357
|
+
timestamp: new Date().toISOString(),
|
|
358
|
+
collections: collectionsMap,
|
|
359
|
+
events: eventsList,
|
|
360
|
+
queues: queuesMap,
|
|
361
|
+
};
|
|
362
|
+
writeFileSync(resolve(outPath), JSON.stringify(snapshot, null, 2), "utf8");
|
|
363
|
+
writeJson(context.stdout, { success: true, out: outPath }, context.pretty);
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
else if (subCommand === "restore") {
|
|
367
|
+
const inPath = requiredFlag(context.parsed, "in");
|
|
368
|
+
const resolvedPath = resolve(inPath);
|
|
369
|
+
if (!existsSync(resolvedPath)) {
|
|
370
|
+
throw new Error(`Snapshot file not found: ${inPath}`);
|
|
371
|
+
}
|
|
372
|
+
const snapshot = JSON.parse(readFileSync(resolvedPath, "utf8"));
|
|
373
|
+
if (snapshot.version !== "1.0.0") {
|
|
374
|
+
throw new Error(`Unsupported snapshot version: ${snapshot.version}`);
|
|
375
|
+
}
|
|
376
|
+
context.stderr.write("Restoring snapshot... (consider 'thingd backup --out pre-restore.db' first)\n");
|
|
377
|
+
await withDb(context, async (db) => {
|
|
378
|
+
try {
|
|
379
|
+
// 1. Restore Collections (clear existing first for true restore)
|
|
380
|
+
if (snapshot.collections) {
|
|
381
|
+
for (const [colName, objects] of Object.entries(snapshot.collections)) {
|
|
382
|
+
const currentObjs = await db.listObjects(colName);
|
|
383
|
+
for (const obj of currentObjs) {
|
|
384
|
+
await db.delete(colName, obj.id);
|
|
385
|
+
}
|
|
386
|
+
for (const obj of objects) {
|
|
387
|
+
const cleanObj = { ...obj };
|
|
388
|
+
delete cleanObj.collection;
|
|
389
|
+
delete cleanObj.createdAt;
|
|
390
|
+
delete cleanObj.updatedAt;
|
|
391
|
+
delete cleanObj.version;
|
|
392
|
+
await db.put(colName, cleanObj);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// 2. Restore Events
|
|
397
|
+
if (snapshot.events) {
|
|
398
|
+
for (const ev of snapshot.events) {
|
|
399
|
+
const cleanEv = { ...ev };
|
|
400
|
+
delete cleanEv.id;
|
|
401
|
+
delete cleanEv.createdAt;
|
|
402
|
+
delete cleanEv.stream;
|
|
403
|
+
await db.events.append(ev.stream, cleanEv);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
// 3. Restore Queues
|
|
407
|
+
if (snapshot.queues) {
|
|
408
|
+
for (const [qName, jobsData] of Object.entries(snapshot.queues)) {
|
|
409
|
+
const queue = db.queue(qName);
|
|
410
|
+
const { active, dead } = jobsData;
|
|
411
|
+
for (const job of active) {
|
|
412
|
+
await queue.push(job.payload, {
|
|
413
|
+
idempotencyKey: job.id,
|
|
414
|
+
maxAttempts: job.maxAttempts,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
for (const job of dead) {
|
|
418
|
+
await queue.push(job.payload, {
|
|
419
|
+
idempotencyKey: job.id,
|
|
420
|
+
maxAttempts: job.maxAttempts,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
writeJson(context.stdout, { success: true, in: inPath }, context.pretty);
|
|
426
|
+
}
|
|
427
|
+
catch (err) {
|
|
428
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
429
|
+
// Restore failed — recommend using backup for atomic recovery
|
|
430
|
+
context.stderr.write(`Restore failed: ${message}. Data may be partially restored.\n` +
|
|
431
|
+
`For atomic restore, use 'thingd backup --out backup.db' before making changes\n` +
|
|
432
|
+
`and copy the backup file to restore.\n`);
|
|
433
|
+
throw err;
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
throw new Error(`Unknown snapshot command: ${subCommand}. Expected 'create' or 'restore'.`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
export async function runBackup(context) {
|
|
442
|
+
const inPath = stringFlag(context.parsed, "in");
|
|
443
|
+
const outPath = stringFlag(context.parsed, "out");
|
|
444
|
+
if (inPath) {
|
|
445
|
+
// Restore from backup file
|
|
446
|
+
const resolvedIn = resolve(inPath);
|
|
447
|
+
if (!existsSync(resolvedIn)) {
|
|
448
|
+
throw new Error(`Backup file not found: ${inPath}`);
|
|
449
|
+
}
|
|
450
|
+
await withDb(context, async (db) => {
|
|
451
|
+
if (db.path === ":memory:") {
|
|
452
|
+
throw new Error("Cannot restore an in-memory database. Use a file-based native database.");
|
|
453
|
+
}
|
|
454
|
+
// Close current DB, copy backup over, reopen happens after callback
|
|
455
|
+
await db.close();
|
|
456
|
+
const { copyFileSync } = await import("node:fs");
|
|
457
|
+
copyFileSync(resolvedIn, db.path);
|
|
458
|
+
context.stderr.write(`Restored from: ${resolvedIn}\n`);
|
|
459
|
+
});
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (!outPath) {
|
|
463
|
+
throw new Error("Expected --out <path> (backup) or --in <path> (restore).");
|
|
464
|
+
}
|
|
465
|
+
await withDb(context, async (db) => {
|
|
466
|
+
if (db.path === ":memory:") {
|
|
467
|
+
throw new Error("Cannot backup an in-memory database. Use a file-based native database.");
|
|
468
|
+
}
|
|
469
|
+
db.backupTo(outPath);
|
|
470
|
+
const { statSync } = await import("node:fs");
|
|
471
|
+
const stats = statSync(outPath);
|
|
472
|
+
const sizeMb = (stats.size / (1024 * 1024)).toFixed(2);
|
|
473
|
+
context.stderr.write(`Backup created: ${outPath} (${sizeMb} MB)\n`);
|
|
474
|
+
});
|
|
475
|
+
}
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../src/doctor.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,UAAU,EAAqB,MAAM,YAAY,CAAC;AAEhE,wBAAsB,SAAS,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAwIlE"}
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { NativeThingStore } from "@thingd/sdk";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { resolveConnection } from "./index.js";
|
|
4
|
+
export async function runDoctor(context) {
|
|
5
|
+
context.stderr.write(`\n${pc.bold("thingd doctor")}\n`);
|
|
6
|
+
context.stderr.write(`${pc.dim("Running system diagnostics and connectivity tests...")}\n\n`);
|
|
7
|
+
let healthy = true;
|
|
8
|
+
// 1. Check Node version
|
|
9
|
+
const nodeVersion = process.version;
|
|
10
|
+
const majorVersion = Number.parseInt(nodeVersion.slice(1).split(".")[0] ?? "0", 10);
|
|
11
|
+
if (majorVersion >= 20) {
|
|
12
|
+
context.stderr.write(` ${pc.green("✓")} Node version: ${pc.cyan(nodeVersion)} (OK)\n`);
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
healthy = false;
|
|
16
|
+
context.stderr.write(` ${pc.red("×")} Node version: ${pc.yellow(nodeVersion)} (Requires >= v20.x)\n`);
|
|
17
|
+
}
|
|
18
|
+
// 2. Resolve connection options
|
|
19
|
+
const connection = resolveConnection(context);
|
|
20
|
+
// 3. Native Driver Checks
|
|
21
|
+
if (connection.driver === "native") {
|
|
22
|
+
try {
|
|
23
|
+
const hasNative = await NativeThingStore.isAvailable();
|
|
24
|
+
if (hasNative) {
|
|
25
|
+
const loadedPath = (await NativeThingStore.getLoadedPath()) ?? "unknown";
|
|
26
|
+
const buildType = loadedPath.includes("prebuilds")
|
|
27
|
+
? pc.cyan("Loaded prebuilt driver")
|
|
28
|
+
: pc.cyan("Loaded local development build");
|
|
29
|
+
context.stderr.write(` ${pc.green("✓")} Native Addon: ${buildType} (${pc.dim(loadedPath)})\n`);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
healthy = false;
|
|
33
|
+
context.stderr.write(` ${pc.red("×")} Native Addon: ${pc.yellow('Not found. Run "pnpm --filter thingd-native build" or configure THINGD_NATIVE_PATH.')}\n`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
healthy = false;
|
|
38
|
+
context.stderr.write(` ${pc.red("×")} Native Addon: ${pc.yellow(`Failed to load native addon: ${error instanceof Error ? error.message : String(error)}`)}\n`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
context.stderr.write(` ${pc.dim("○")} Native Addon: Skipped (Using driver: "${connection.driver ?? "memory"}")\n`);
|
|
43
|
+
}
|
|
44
|
+
// 4. Remote Sidecar Reachability & Auth Checks
|
|
45
|
+
if (connection.cloud) {
|
|
46
|
+
const rawUrl = connection.path;
|
|
47
|
+
const isLocal = rawUrl.includes("localhost") || rawUrl.includes("127.0.0.1");
|
|
48
|
+
if (!connection.authToken && !isLocal) {
|
|
49
|
+
context.stderr.write(` ${pc.yellow("⚠")} Auth Token: ${pc.yellow("Missing THINGD_AUTH_TOKEN for remote server (might fail)")}\n`);
|
|
50
|
+
}
|
|
51
|
+
else if (connection.authToken) {
|
|
52
|
+
context.stderr.write(` ${pc.green("✓")} Auth Token: ${pc.cyan("Configured")}\n`);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
context.stderr.write(` ${pc.green("✓")} Auth Token: ${pc.dim("Not required for local sidecar")}\n`);
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const targetUrl = new URL(rawUrl.startsWith("thingd://") ? `http://${rawUrl.slice("thingd://".length)}` : rawUrl);
|
|
59
|
+
if (targetUrl.pathname === "/mcp" || targetUrl.pathname === "") {
|
|
60
|
+
targetUrl.pathname = "/healthz";
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
targetUrl.pathname = `${targetUrl.pathname.replace(/\/mcp$/, "")}/healthz`;
|
|
64
|
+
}
|
|
65
|
+
context.stderr.write(` ${pc.dim("○")} Connectivity: Checking reachability to ${pc.cyan(targetUrl.toString())}...\n`);
|
|
66
|
+
const controller = new AbortController();
|
|
67
|
+
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
|
68
|
+
const headers = {};
|
|
69
|
+
if (connection.authToken) {
|
|
70
|
+
headers.Authorization = `Bearer ${connection.authToken}`;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const response = await fetch(targetUrl, {
|
|
74
|
+
signal: controller.signal,
|
|
75
|
+
headers,
|
|
76
|
+
});
|
|
77
|
+
clearTimeout(timeoutId);
|
|
78
|
+
if (response.ok) {
|
|
79
|
+
context.stderr.write(` ${pc.green("✓")} Connectivity: ${pc.cyan("Connected successfully!")} (${pc.dim(`HTTP ${response.status}`)})\n`);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
healthy = false;
|
|
83
|
+
context.stderr.write(` ${pc.red("×")} Connectivity: ${pc.yellow(`Server responded with non-2xx status`)} (${pc.dim(`HTTP ${response.status}`)})\n`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch (fetchError) {
|
|
87
|
+
clearTimeout(timeoutId);
|
|
88
|
+
healthy = false;
|
|
89
|
+
context.stderr.write(` ${pc.red("×")} Connectivity: ${pc.yellow(`Failed to connect. Connection refused or timed out.`)} (${pc.dim(fetchError instanceof Error ? fetchError.message : String(fetchError))})\n`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch (urlError) {
|
|
93
|
+
healthy = false;
|
|
94
|
+
context.stderr.write(` ${pc.red("×")} Connectivity: ${pc.yellow(`Invalid URL structure: ${urlError instanceof Error ? urlError.message : String(urlError)}`)}\n`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
context.stderr.write(` ${pc.green("✓")} Connectivity: ${pc.cyan("Local persistent store")} (${pc.dim(connection.path)})\n`);
|
|
99
|
+
}
|
|
100
|
+
// Final Summary Report
|
|
101
|
+
context.stderr.write("\n");
|
|
102
|
+
if (healthy) {
|
|
103
|
+
context.stderr.write(` ${pc.bold(pc.green("Diagnosis: Everything looks healthy!"))}\n\n`);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
context.stderr.write(` ${pc.bold(pc.yellow("Diagnosis: Some items require attention (see errors above)."))}\n\n`);
|
|
107
|
+
}
|
|
108
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { ThingD, type ThingDDriver } from "@thingd/sdk";
|
|
3
|
+
type CliEnv = Record<string, string | undefined>;
|
|
4
|
+
type WritableLike = {
|
|
5
|
+
write(chunk: string): void;
|
|
6
|
+
};
|
|
7
|
+
type ReadableLike = NodeJS.ReadableStream;
|
|
8
|
+
export type RunCliOptions = {
|
|
9
|
+
env?: CliEnv;
|
|
10
|
+
stdout?: WritableLike;
|
|
11
|
+
stderr?: WritableLike;
|
|
12
|
+
stdin?: ReadableLike;
|
|
13
|
+
};
|
|
14
|
+
export type ParsedArgs = {
|
|
15
|
+
tokens: string[];
|
|
16
|
+
flags: Map<string, string[]>;
|
|
17
|
+
booleans: Set<string>;
|
|
18
|
+
};
|
|
19
|
+
export type CliContext = {
|
|
20
|
+
parsed: ParsedArgs;
|
|
21
|
+
env: CliEnv;
|
|
22
|
+
stdout: WritableLike;
|
|
23
|
+
stderr: WritableLike;
|
|
24
|
+
stdin: ReadableLike;
|
|
25
|
+
pretty: boolean;
|
|
26
|
+
};
|
|
27
|
+
export type ConnectionOptions = {
|
|
28
|
+
path: string;
|
|
29
|
+
driver?: ThingDDriver;
|
|
30
|
+
authToken?: string;
|
|
31
|
+
cloud: boolean;
|
|
32
|
+
/** Cloud instance slug for multi-instance routing. */
|
|
33
|
+
instanceSlug?: string;
|
|
34
|
+
encryptionKey?: string;
|
|
35
|
+
};
|
|
36
|
+
export declare function runCli(args?: string[], options?: RunCliOptions): Promise<number>;
|
|
37
|
+
export declare function withDb(context: CliContext, callback: (db: ThingD) => Promise<void>): Promise<void>;
|
|
38
|
+
export declare function resolveConnection(context: CliContext): ConnectionOptions;
|
|
39
|
+
export declare function hasFlag(parsed: ParsedArgs, name: string): boolean;
|
|
40
|
+
export declare function stringFlag(parsed: ParsedArgs, name: string): string | undefined;
|
|
41
|
+
export declare function stringFlags(parsed: ParsedArgs, name: string): string[] | undefined;
|
|
42
|
+
export declare function requiredFlag(parsed: ParsedArgs, name: string): string;
|
|
43
|
+
export declare function optionalToken(parsed: ParsedArgs, index: number): string | undefined;
|
|
44
|
+
export declare function requiredToken(parsed: ParsedArgs, index: number, name: string): string;
|
|
45
|
+
export declare function writeJson(target: WritableLike, data: unknown, pretty: boolean): void;
|
|
46
|
+
export declare function writeText(target: WritableLike, text: string): void;
|
|
47
|
+
export {};
|
|
48
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAeA,OAAO,EAYL,MAAM,EACN,KAAK,YAAY,EAClB,MAAM,aAAa,CAAC;AASrB,KAAK,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;AAEjD,KAAK,YAAY,GAAG;IAClB,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B,CAAC;AAEF,KAAK,YAAY,GAAG,MAAM,CAAC,cAAc,CAAC;AAE1C,MAAM,MAAM,aAAa,GAAG;IAC1B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,KAAK,CAAC,EAAE,YAAY,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG;IACvB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IAC7B,QAAQ,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG;IACvB,MAAM,EAAE,UAAU,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,YAAY,CAAC;IACrB,MAAM,EAAE,YAAY,CAAC;IACrB,KAAK,EAAE,YAAY,CAAC;IACpB,MAAM,EAAE,OAAO,CAAC;CACjB,CAAC;AAsCF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,OAAO,CAAC;IACf,sDAAsD;IACtD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;AAqGF,wBAAsB,MAAM,CAC1B,IAAI,WAAwB,EAC5B,OAAO,GAAE,aAAkB,GAC1B,OAAO,CAAC,MAAM,CAAC,CA8EjB;AAiuCD,wBAAsB,MAAM,CAC1B,OAAO,EAAE,UAAU,EACnB,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GACtC,OAAO,CAAC,IAAI,CAAC,CAgBf;AA4BD,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,UAAU,GAAG,iBAAiB,CAuDxE;AAiDD,wBAAgB,OAAO,CAAC,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAEjE;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAE/E;AAED,wBAAgB,WAAW,CAAC,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAGlF;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAMrE;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEnF;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAMrF;AAyFD,wBAAgB,SAAS,CAAC,MAAM,EAAE,YAAY,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,GAAG,IAAI,CAEpF;AAED,wBAAgB,SAAS,CAAC,MAAM,EAAE,YAAY,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAElE"}
|