@overscore/cli 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +711 -0
  2. package/package.json +22 -0
package/dist/index.js ADDED
@@ -0,0 +1,711 @@
1
+ #!/usr/bin/env node
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { execSync } from "child_process";
5
+ import { createInterface } from "readline";
6
+ import yauzl from "yauzl";
7
+ // ── Shared helpers ──────────────────────────────────────────────────
8
+ function parseEnv(content) {
9
+ const env = {};
10
+ for (const line of content.split("\n")) {
11
+ const trimmed = line.trim();
12
+ if (!trimmed || trimmed.startsWith("#"))
13
+ continue;
14
+ const eqIndex = trimmed.indexOf("=");
15
+ if (eqIndex === -1)
16
+ continue;
17
+ const key = trimmed.slice(0, eqIndex);
18
+ const value = trimmed.slice(eqIndex + 1);
19
+ env[key] = value;
20
+ }
21
+ return env;
22
+ }
23
+ function loadEnv() {
24
+ const envPath = path.resolve(process.cwd(), ".env");
25
+ if (!fs.existsSync(envPath)) {
26
+ console.error(" Error: No .env file found. Run npx create-overscore first.");
27
+ process.exit(1);
28
+ }
29
+ const env = parseEnv(fs.readFileSync(envPath, "utf-8"));
30
+ const apiUrl = env.VITE_OVERSCORE_API_URL;
31
+ const apiKey = env.VITE_OVERSCORE_API_KEY;
32
+ const dashboardSlug = env.VITE_OVERSCORE_DASHBOARD_SLUG || path.basename(process.cwd());
33
+ if (!apiUrl || !apiKey) {
34
+ console.error(" Error: Missing VITE_OVERSCORE_API_URL or VITE_OVERSCORE_API_KEY in .env");
35
+ process.exit(1);
36
+ }
37
+ const projectSlug = env.VITE_OVERSCORE_PROJECT_SLUG || "";
38
+ return { apiUrl, apiKey, dashboardSlug, projectSlug };
39
+ }
40
+ async function apiRequest(method, urlPath, body) {
41
+ const { apiUrl, apiKey } = loadEnv();
42
+ const baseUrl = apiUrl.replace(/\/api$/, "");
43
+ const url = `${baseUrl}${urlPath}`;
44
+ const headers = {
45
+ Authorization: `Bearer ${apiKey}`,
46
+ };
47
+ if (body !== undefined) {
48
+ headers["Content-Type"] = "application/json";
49
+ }
50
+ const res = await fetch(url, {
51
+ method,
52
+ headers,
53
+ body: body !== undefined ? JSON.stringify(body) : undefined,
54
+ });
55
+ const data = await res.json();
56
+ if (!res.ok) {
57
+ console.error(`\n Error: ${data.error || `HTTP ${res.status}`}\n`);
58
+ process.exit(1);
59
+ }
60
+ return data;
61
+ }
62
+ function confirm(message) {
63
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
64
+ return new Promise((resolve) => {
65
+ rl.question(` ${message} (y/N) `, (answer) => {
66
+ rl.close();
67
+ resolve(answer.trim().toLowerCase() === "y");
68
+ });
69
+ });
70
+ }
71
+ function prompt(message) {
72
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
73
+ return new Promise((resolve) => {
74
+ rl.question(` ${message} `, (answer) => {
75
+ rl.close();
76
+ resolve(answer.trim());
77
+ });
78
+ });
79
+ }
80
+ const SOURCE_EXCLUDE = new Set([
81
+ "node_modules",
82
+ "dist",
83
+ ".git",
84
+ ".DS_Store",
85
+ ".aws",
86
+ ".ssh",
87
+ ".kube",
88
+ ".docker",
89
+ ".gnupg",
90
+ ".overscore",
91
+ ]);
92
+ const SECRET_FILE_EXTENSIONS = new Set([
93
+ ".pem",
94
+ ".key",
95
+ ".p12",
96
+ ".pfx",
97
+ ".jks",
98
+ ".keystore",
99
+ ]);
100
+ function collectSourceFiles(dir, prefix) {
101
+ const files = [];
102
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
103
+ if (SOURCE_EXCLUDE.has(entry.name))
104
+ continue;
105
+ if (entry.name.startsWith(".env"))
106
+ continue; // .env, .env.local, .env.production, etc.
107
+ if (entry.name.endsWith(".log"))
108
+ continue;
109
+ const ext = entry.name.substring(entry.name.lastIndexOf(".")).toLowerCase();
110
+ if (SECRET_FILE_EXTENSIONS.has(ext))
111
+ continue;
112
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
113
+ if (entry.isDirectory()) {
114
+ files.push(...collectSourceFiles(path.join(dir, entry.name), relativePath));
115
+ }
116
+ else {
117
+ files.push(relativePath);
118
+ }
119
+ }
120
+ return files;
121
+ }
122
+ function extractZip(zipBuffer, targetDir) {
123
+ return new Promise((resolve, reject) => {
124
+ yauzl.fromBuffer(zipBuffer, { lazyEntries: true }, (err, zipfile) => {
125
+ if (err || !zipfile)
126
+ return reject(err || new Error("Failed to open zip"));
127
+ zipfile.readEntry();
128
+ zipfile.on("entry", (entry) => {
129
+ const fullPath = path.resolve(targetDir, entry.fileName);
130
+ if (!fullPath.startsWith(path.resolve(targetDir) + path.sep) && fullPath !== path.resolve(targetDir)) {
131
+ return reject(new Error(`Zip entry escapes target directory: ${entry.fileName}`));
132
+ }
133
+ if (entry.fileName.endsWith("/")) {
134
+ // Directory
135
+ fs.mkdirSync(fullPath, { recursive: true });
136
+ zipfile.readEntry();
137
+ }
138
+ else {
139
+ // File
140
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
141
+ zipfile.openReadStream(entry, (err2, readStream) => {
142
+ if (err2 || !readStream)
143
+ return reject(err2 || new Error("Failed to read entry"));
144
+ const writeStream = fs.createWriteStream(fullPath);
145
+ readStream.pipe(writeStream);
146
+ writeStream.on("close", () => zipfile.readEntry());
147
+ writeStream.on("error", reject);
148
+ });
149
+ }
150
+ });
151
+ zipfile.on("end", resolve);
152
+ zipfile.on("error", reject);
153
+ });
154
+ });
155
+ }
156
+ // ── Commands ────────────────────────────────────────────────────────
157
+ async function deploy() {
158
+ const { apiUrl, apiKey, dashboardSlug } = loadEnv();
159
+ // Parse --message flag
160
+ const msgIndex = process.argv.indexOf("--message");
161
+ const commitMessage = msgIndex !== -1 && process.argv[msgIndex + 1]
162
+ ? process.argv[msgIndex + 1]
163
+ : null;
164
+ console.log(`\n Deploying dashboard: ${dashboardSlug}\n`);
165
+ // Build (strip API key from the bundle — it's for local dev only)
166
+ console.log(" Building...");
167
+ try {
168
+ execSync("npm run build", {
169
+ stdio: "inherit",
170
+ env: { ...process.env, VITE_OVERSCORE_API_KEY: "" },
171
+ });
172
+ }
173
+ catch {
174
+ console.error("\n Build failed. Fix errors and try again.");
175
+ process.exit(1);
176
+ }
177
+ // Collect dist files
178
+ const distDir = path.resolve(process.cwd(), "dist");
179
+ if (!fs.existsSync(distDir)) {
180
+ console.error(" Error: No dist/ directory found after build.");
181
+ process.exit(1);
182
+ }
183
+ const builtFiles = collectFiles(distDir, "");
184
+ console.log(` Found ${builtFiles.length} built files.`);
185
+ // Collect source files
186
+ const sourceFiles = collectSourceFiles(process.cwd(), "");
187
+ console.log(` Found ${sourceFiles.length} source files.`);
188
+ // Upload via deploy API
189
+ console.log(" Uploading...");
190
+ const formData = new FormData();
191
+ formData.append("dashboard_slug", dashboardSlug);
192
+ if (commitMessage) {
193
+ formData.append("commit_message", commitMessage);
194
+ }
195
+ // Built files
196
+ for (const file of builtFiles) {
197
+ const filePath = path.join(distDir, file);
198
+ const content = fs.readFileSync(filePath);
199
+ const blob = new Blob([content]);
200
+ formData.append(`file:${file}`, blob, file);
201
+ }
202
+ // Source files
203
+ for (const file of sourceFiles) {
204
+ const filePath = path.join(process.cwd(), file);
205
+ const content = fs.readFileSync(filePath);
206
+ const blob = new Blob([content]);
207
+ formData.append(`source:${file}`, blob, file);
208
+ }
209
+ const deployUrl = apiUrl.replace(/\/api$/, "") + "/api/deploy";
210
+ const res = await fetch(deployUrl, {
211
+ method: "POST",
212
+ headers: { Authorization: `Bearer ${apiKey}` },
213
+ body: formData,
214
+ });
215
+ const data = (await res.json());
216
+ if (!res.ok) {
217
+ console.error(`\n Deploy failed: ${data.error}`);
218
+ process.exit(1);
219
+ }
220
+ console.log(`
221
+ Deployed successfully!
222
+
223
+ ${data.file_count} built files uploaded${data.source_uploaded ? " + source code saved" : ""}
224
+ URL: ${data.url}
225
+ `);
226
+ // Sync context files to Hub
227
+ const { projectSlug } = loadEnv();
228
+ const baseUrl = apiUrl.replace(/\/api$/, "");
229
+ // Sync company context (with conflict detection)
230
+ const companyCtxPath = path.resolve(process.cwd(), ".claude/rules/company-context.md");
231
+ if (fs.existsSync(companyCtxPath)) {
232
+ const content = fs.readFileSync(companyCtxPath, "utf-8");
233
+ const hashPath = path.resolve(process.cwd(), ".claude/rules/.context-hash");
234
+ const storedHash = fs.existsSync(hashPath) ? fs.readFileSync(hashPath, "utf-8").trim() : null;
235
+ try {
236
+ // Check if Hub version has changed since we pulled
237
+ const checkRes = await fetch(`${baseUrl}/api/projects/${projectSlug}/context`, {
238
+ headers: { Authorization: `Bearer ${apiKey}` },
239
+ });
240
+ const checkData = checkRes.ok ? (await checkRes.json()) : null;
241
+ const hubHash = checkData?.context_md_hash || null;
242
+ if (storedHash && hubHash && storedHash !== hubHash) {
243
+ console.log(" ⚠ Company context was updated in the Hub since you pulled.");
244
+ console.log(` Review at: ${baseUrl}/projects/${projectSlug}/context`);
245
+ console.log(" Your local changes were NOT uploaded to avoid overwriting.");
246
+ }
247
+ else {
248
+ const putRes = await fetch(`${baseUrl}/api/projects/${projectSlug}/context`, {
249
+ method: "PUT",
250
+ headers: {
251
+ Authorization: `Bearer ${apiKey}`,
252
+ "Content-Type": "application/json",
253
+ },
254
+ body: JSON.stringify({ context_md: content }),
255
+ });
256
+ if (putRes.ok) {
257
+ const putData = (await putRes.json());
258
+ // Update stored hash
259
+ if (putData.context_md_hash) {
260
+ fs.mkdirSync(path.dirname(hashPath), { recursive: true });
261
+ fs.writeFileSync(hashPath, putData.context_md_hash);
262
+ }
263
+ console.log(" Company context synced.");
264
+ }
265
+ }
266
+ }
267
+ catch {
268
+ // Non-fatal
269
+ }
270
+ }
271
+ // Sync dashboard context (no conflict detection needed — per-dashboard)
272
+ const dashCtxPath = path.resolve(process.cwd(), ".claude/rules/dashboard-context.md");
273
+ if (fs.existsSync(dashCtxPath)) {
274
+ const content = fs.readFileSync(dashCtxPath, "utf-8");
275
+ try {
276
+ await fetch(`${baseUrl}/api/dashboards/${dashboardSlug}/context`, {
277
+ method: "PUT",
278
+ headers: {
279
+ Authorization: `Bearer ${apiKey}`,
280
+ "Content-Type": "application/json",
281
+ },
282
+ body: JSON.stringify({ context_md: content }),
283
+ });
284
+ console.log(" Dashboard context synced.");
285
+ }
286
+ catch {
287
+ // Non-fatal
288
+ }
289
+ }
290
+ }
291
+ async function queryList() {
292
+ const { dashboardSlug } = loadEnv();
293
+ const data = (await apiRequest("GET", `/api/dashboards/${dashboardSlug}/queries`));
294
+ console.log(`\n Dashboard: ${data.dashboard}\n`);
295
+ if (data.queries.length === 0) {
296
+ console.log(" No queries registered yet.\n");
297
+ console.log(" Add one with:");
298
+ console.log(' npx overscore query add <name> "SELECT ..."\n');
299
+ return;
300
+ }
301
+ for (const q of data.queries) {
302
+ const rowInfo = q.row_count !== null ? ` (~${q.row_count.toLocaleString()} rows)` : "";
303
+ console.log(` ${q.query_name}${rowInfo}`);
304
+ console.log(` useQuery("${q.query_name}")`);
305
+ if (q.sample_row) {
306
+ const cols = Object.keys(q.sample_row).join(", ");
307
+ console.log(` columns: ${cols}`);
308
+ }
309
+ console.log();
310
+ }
311
+ }
312
+ async function queryShow(name) {
313
+ if (!name) {
314
+ console.error("\n Usage: npx overscore query show <name>\n");
315
+ process.exit(1);
316
+ }
317
+ const { dashboardSlug } = loadEnv();
318
+ const data = (await apiRequest("GET", `/api/dashboards/${dashboardSlug}/queries`));
319
+ const query = data.queries.find((q) => q.query_name === name);
320
+ if (!query) {
321
+ console.error(`\n Error: Query "${name}" not found.\n`);
322
+ console.error(" Available queries:");
323
+ for (const q of data.queries) {
324
+ console.error(` - ${q.query_name}`);
325
+ }
326
+ console.error();
327
+ process.exit(1);
328
+ }
329
+ console.log(`\n ${query.query_name}`);
330
+ if (query.row_count !== null) {
331
+ console.log(` ~${query.row_count.toLocaleString()} rows\n`);
332
+ }
333
+ else {
334
+ console.log();
335
+ }
336
+ console.log(` SQL:`);
337
+ console.log(` ${query.sql}\n`);
338
+ if (query.sample_row) {
339
+ console.log(` Sample row:`);
340
+ console.log(` ${JSON.stringify(query.sample_row, null, 2).split("\n").join("\n ")}\n`);
341
+ }
342
+ }
343
+ function resolveSql(sqlOrNext) {
344
+ // Check for --file flag anywhere in argv
345
+ const fileIndex = process.argv.indexOf("--file");
346
+ if (fileIndex !== -1 && process.argv[fileIndex + 1]) {
347
+ const filePath = path.resolve(process.cwd(), process.argv[fileIndex + 1]);
348
+ if (!fs.existsSync(filePath)) {
349
+ console.error(`\n Error: File not found: ${filePath}\n`);
350
+ process.exit(1);
351
+ }
352
+ return fs.readFileSync(filePath, "utf-8").trim();
353
+ }
354
+ return sqlOrNext;
355
+ }
356
+ async function queryAdd(name, sql) {
357
+ const resolvedSql = resolveSql(sql);
358
+ if (!name || !resolvedSql) {
359
+ console.error('\n Usage: npx overscore query add <name> "<sql>"\n');
360
+ console.error(" Or with a file:");
361
+ console.error(" npx overscore query add <name> --file query.sql\n");
362
+ process.exit(1);
363
+ }
364
+ if (!/^[a-z][a-z0-9_]*$/.test(name) || name.length > 100) {
365
+ console.error("\n Error: Query name must start with a lowercase letter and contain only lowercase letters, numbers, and underscores.\n");
366
+ process.exit(1);
367
+ }
368
+ const { dashboardSlug } = loadEnv();
369
+ const data = (await apiRequest("POST", `/api/dashboards/${dashboardSlug}/queries`, { query_name: name, sql_text: resolvedSql }));
370
+ console.log(`\n Query "${data.query_name}" created.\n`);
371
+ console.log(` Use it in your dashboard:`);
372
+ console.log(` const { data } = useQuery("${data.query_name}");\n`);
373
+ }
374
+ async function queryUpdate(name, sql) {
375
+ const resolvedSql = resolveSql(sql);
376
+ if (!name || !resolvedSql) {
377
+ console.error('\n Usage: npx overscore query update <name> "<sql>"\n');
378
+ console.error(" Or with a file:");
379
+ console.error(" npx overscore query update <name> --file query.sql\n");
380
+ process.exit(1);
381
+ }
382
+ const { dashboardSlug } = loadEnv();
383
+ const data = (await apiRequest("PUT", `/api/dashboards/${dashboardSlug}/queries/${encodeURIComponent(name)}`, { sql_text: resolvedSql }));
384
+ console.log(`\n Query "${data.query_name}" updated.`);
385
+ console.log(" Cache has been invalidated — fresh data on next load.\n");
386
+ }
387
+ async function queryRemove(name) {
388
+ if (!name) {
389
+ console.error("\n Usage: npx overscore query remove <name>\n");
390
+ process.exit(1);
391
+ }
392
+ const yes = await confirm(`Delete query "${name}"? This cannot be undone.`);
393
+ if (!yes) {
394
+ console.log(" Cancelled.\n");
395
+ return;
396
+ }
397
+ const { dashboardSlug } = loadEnv();
398
+ await apiRequest("DELETE", `/api/dashboards/${dashboardSlug}/queries/${encodeURIComponent(name)}`);
399
+ console.log(`\n Query "${name}" deleted.\n`);
400
+ }
401
+ async function queryTest(sql) {
402
+ if (!sql) {
403
+ console.error('\n Usage: npx overscore query test "<sql>"\n');
404
+ console.error(" Example:");
405
+ console.error(' npx overscore query test "SELECT * FROM dataset.table LIMIT 10"\n');
406
+ process.exit(1);
407
+ }
408
+ const { dashboardSlug } = loadEnv();
409
+ const data = (await apiRequest("POST", `/api/dashboards/${dashboardSlug}/test-query`, { sql }));
410
+ console.log(`\n ${data.row_count} rows returned${data.truncated ? " (showing first 200)" : ""}\n`);
411
+ if (data.data.length === 0) {
412
+ console.log(" No rows.\n");
413
+ return;
414
+ }
415
+ // Print first 10 rows as a simple table
416
+ const rows = data.data.slice(0, 10);
417
+ const cols = Object.keys(rows[0]);
418
+ // Calculate column widths
419
+ const widths = cols.map((col) => {
420
+ const values = rows.map((r) => formatCell(r[col]));
421
+ return Math.max(col.length, ...values.map((v) => v.length));
422
+ });
423
+ // Header
424
+ const header = cols.map((col, i) => col.padEnd(widths[i])).join(" ");
425
+ const divider = cols.map((_, i) => "─".repeat(widths[i])).join("──");
426
+ console.log(` ${header}`);
427
+ console.log(` ${divider}`);
428
+ // Rows
429
+ for (const row of rows) {
430
+ const line = cols
431
+ .map((col, i) => formatCell(row[col]).padEnd(widths[i]))
432
+ .join(" ");
433
+ console.log(` ${line}`);
434
+ }
435
+ if (data.data.length > 10) {
436
+ console.log(`\n ... and ${data.data.length - 10} more rows`);
437
+ }
438
+ console.log();
439
+ }
440
+ function formatCell(value) {
441
+ if (value === null || value === undefined)
442
+ return "—";
443
+ if (typeof value === "object" && value !== null && "value" in value) {
444
+ return String(value.value);
445
+ }
446
+ if (typeof value === "number")
447
+ return value.toLocaleString();
448
+ return String(value);
449
+ }
450
+ async function pull(slug) {
451
+ if (!slug) {
452
+ console.error("\n Usage: npx overscore pull <dashboard-slug> [--version N]\n");
453
+ console.error(" Example:");
454
+ console.error(" npx overscore pull my-dashboard\n");
455
+ process.exit(1);
456
+ }
457
+ // Parse --version flag
458
+ const versionIndex = process.argv.indexOf("--version");
459
+ const versionParam = versionIndex !== -1 && process.argv[versionIndex + 1]
460
+ ? process.argv[versionIndex + 1]
461
+ : null;
462
+ // Interactive prompts for credentials
463
+ const apiKey = await prompt("API key (from the Hub):");
464
+ if (!apiKey || !apiKey.startsWith("os_")) {
465
+ console.error("\n Error: API key should start with os_\n");
466
+ process.exit(1);
467
+ }
468
+ const apiUrl = "https://overscore.dev/api";
469
+ const baseUrl = apiUrl.replace(/\/api$/, "");
470
+ const versionQuery = versionParam ? `?version=${versionParam}` : "";
471
+ const url = `${baseUrl}/api/dashboards/${slug}/source${versionQuery}`;
472
+ console.log(`\n Pulling source for: ${slug}${versionParam ? ` (v${versionParam})` : ""}...`);
473
+ const res = await fetch(url, {
474
+ headers: { Authorization: `Bearer ${apiKey}` },
475
+ });
476
+ if (!res.ok) {
477
+ let errorMsg = `HTTP ${res.status}`;
478
+ try {
479
+ const body = await res.json();
480
+ errorMsg = body.error || errorMsg;
481
+ }
482
+ catch {
483
+ // not JSON
484
+ }
485
+ console.error(`\n Error: ${errorMsg}\n`);
486
+ process.exit(1);
487
+ }
488
+ const projectSlug = res.headers.get("X-VD-Project-Slug") || "";
489
+ const dashboardSlug = res.headers.get("X-VD-Dashboard-Slug") || slug;
490
+ const version = res.headers.get("X-VD-Version") || "?";
491
+ const commitMsg = res.headers.get("X-VD-Commit-Message") || "";
492
+ // Download the zip
493
+ const zipBuffer = Buffer.from(await res.arrayBuffer());
494
+ // Extract to ./<slug>/
495
+ const targetDir = path.resolve(process.cwd(), slug);
496
+ if (fs.existsSync(targetDir)) {
497
+ const yes = await confirm(`Directory "${slug}" already exists. Overwrite source files?`);
498
+ if (!yes) {
499
+ console.log(" Cancelled.\n");
500
+ return;
501
+ }
502
+ }
503
+ // Clean target directory (preserve node_modules and .env)
504
+ if (fs.existsSync(targetDir)) {
505
+ for (const entry of fs.readdirSync(targetDir)) {
506
+ if (entry === "node_modules" || entry.startsWith(".env"))
507
+ continue;
508
+ const entryPath = path.join(targetDir, entry);
509
+ fs.rmSync(entryPath, { recursive: true, force: true });
510
+ }
511
+ }
512
+ else {
513
+ fs.mkdirSync(targetDir, { recursive: true });
514
+ }
515
+ await extractZip(zipBuffer, targetDir);
516
+ // Generate .env (only overscore config — user adds their own API keys)
517
+ const envContent = `# Overscore Configuration
518
+ VITE_OVERSCORE_PROJECT_SLUG=${projectSlug}
519
+ VITE_OVERSCORE_DASHBOARD_SLUG=${dashboardSlug}
520
+ VITE_OVERSCORE_API_KEY=${apiKey}
521
+ VITE_OVERSCORE_API_URL=https://overscore.dev/api
522
+ `;
523
+ fs.writeFileSync(path.join(targetDir, ".env"), envContent, { mode: 0o600 });
524
+ // Fetch latest project context from Hub (may be newer than what's in the source zip)
525
+ try {
526
+ const ctxRes = await fetch(`${baseUrl}/api/projects/${projectSlug}/context`, {
527
+ headers: { Authorization: `Bearer ${apiKey}` },
528
+ });
529
+ if (ctxRes.ok) {
530
+ const ctxData = (await ctxRes.json());
531
+ if (ctxData.context_md) {
532
+ const rulesDir = path.join(targetDir, ".claude", "rules");
533
+ fs.mkdirSync(rulesDir, { recursive: true });
534
+ fs.writeFileSync(path.join(rulesDir, "company-context.md"), ctxData.context_md);
535
+ // Store hash for conflict detection on next deploy
536
+ if (ctxData.context_md_hash) {
537
+ fs.writeFileSync(path.join(rulesDir, ".context-hash"), ctxData.context_md_hash);
538
+ }
539
+ console.log(" Updated company context from Hub.");
540
+ }
541
+ }
542
+ }
543
+ catch {
544
+ // Non-fatal — the version from the source zip is fine
545
+ }
546
+ console.log(`
547
+ Pulled v${version}${commitMsg ? ` — "${commitMsg}"` : ""}
548
+
549
+ Next steps:
550
+
551
+ cd ${slug}
552
+ npm install
553
+ code .
554
+ claude
555
+
556
+ Note: If this dashboard uses third-party APIs, add your
557
+ API keys to .env before running.
558
+
559
+ When ready to deploy:
560
+
561
+ npx overscore deploy --message "your changes"
562
+ `);
563
+ }
564
+ async function versions() {
565
+ const { dashboardSlug } = loadEnv();
566
+ const data = (await apiRequest("GET", `/api/dashboards/${dashboardSlug}/versions`));
567
+ console.log(`\n Dashboard: ${dashboardSlug}\n`);
568
+ if (data.versions.length === 0) {
569
+ console.log(" No deployments yet.\n");
570
+ return;
571
+ }
572
+ for (const v of data.versions) {
573
+ const date = new Date(v.deployed_at).toLocaleDateString("en-US", {
574
+ month: "short",
575
+ day: "numeric",
576
+ year: "numeric",
577
+ });
578
+ const size = v.bundle_size_bytes >= 1024 * 1024
579
+ ? `${(v.bundle_size_bytes / 1024 / 1024).toFixed(1)} MB`
580
+ : `${Math.round(v.bundle_size_bytes / 1024)} KB`;
581
+ const source = v.source_available ? "\u2713 source" : "\u2717 source";
582
+ const msg = v.commit_message ? ` "${v.commit_message}"` : "";
583
+ const by = v.deployed_by ? ` by ${v.deployed_by}` : "";
584
+ console.log(` v${v.version_number} ${date} ${v.file_count} files ${size} ${source}${msg}${by}`);
585
+ }
586
+ console.log(`\n Pull a version: npx overscore pull ${dashboardSlug} --version N\n`);
587
+ }
588
+ async function listDashboards() {
589
+ const { apiUrl, apiKey } = loadEnv();
590
+ const baseUrl = apiUrl.replace(/\/api$/, "");
591
+ const res = await fetch(`${baseUrl}/api/dashboards`, {
592
+ headers: { Authorization: `Bearer ${apiKey}` },
593
+ });
594
+ if (!res.ok) {
595
+ const body = await res.json().catch(() => ({}));
596
+ console.error(`\n Error: ${body.error || `HTTP ${res.status}`}\n`);
597
+ process.exit(1);
598
+ }
599
+ const data = (await res.json());
600
+ console.log(`\n ${data.project} (${data.project_slug})\n`);
601
+ if (data.dashboards.length === 0) {
602
+ console.log(" No dashboards yet.\n");
603
+ return;
604
+ }
605
+ for (const d of data.dashboards) {
606
+ const version = d.latest_version;
607
+ const deployed = version
608
+ ? `v${version.version_number} · ${new Date(version.deployed_at).toLocaleDateString("en-US", { month: "short", day: "numeric" })}`
609
+ : "not deployed";
610
+ const queries = d.query_count === 1 ? "1 query" : `${d.query_count} queries`;
611
+ console.log(` ${d.name}`);
612
+ console.log(` slug: ${d.slug} · ${queries} · ${deployed}`);
613
+ if (version?.commit_message) {
614
+ console.log(` "${version.commit_message}"`);
615
+ }
616
+ console.log();
617
+ }
618
+ }
619
+ function collectFiles(dir, prefix) {
620
+ const files = [];
621
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
622
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
623
+ if (entry.isDirectory()) {
624
+ files.push(...collectFiles(path.join(dir, entry.name), relativePath));
625
+ }
626
+ else {
627
+ files.push(relativePath);
628
+ }
629
+ }
630
+ return files;
631
+ }
632
+ // ── Routing ─────────────────────────────────────────────────────────
633
+ const command = process.argv[2];
634
+ const subcommand = process.argv[3];
635
+ if (command === "deploy") {
636
+ deploy();
637
+ }
638
+ else if (command === "pull") {
639
+ pull(process.argv[3]);
640
+ }
641
+ else if (command === "versions") {
642
+ versions();
643
+ }
644
+ else if (command === "list") {
645
+ listDashboards();
646
+ }
647
+ else if (command === "query") {
648
+ if (subcommand === "list") {
649
+ queryList();
650
+ }
651
+ else if (subcommand === "show") {
652
+ queryShow(process.argv[4]);
653
+ }
654
+ else if (subcommand === "add") {
655
+ queryAdd(process.argv[4], process.argv[5]);
656
+ }
657
+ else if (subcommand === "update") {
658
+ queryUpdate(process.argv[4], process.argv[5]);
659
+ }
660
+ else if (subcommand === "remove") {
661
+ queryRemove(process.argv[4]);
662
+ }
663
+ else if (subcommand === "test") {
664
+ queryTest(process.argv[4]);
665
+ }
666
+ else {
667
+ console.log(`
668
+ overscore query — Manage dashboard queries
669
+
670
+ Commands:
671
+ list List all queries
672
+ show <name> Show a query's SQL and sample data
673
+ add <name> "<sql>" Add a new query
674
+ update <name> "<sql>" Update a query's SQL
675
+ remove <name> Delete a query
676
+ test "<sql>" Test SQL without saving
677
+
678
+ Examples:
679
+ npx overscore query list
680
+ npx overscore query show monthly_revenue
681
+ npx overscore query add monthly_revenue --file queries/revenue.sql
682
+ npx overscore query add monthly_revenue "SELECT ..."
683
+ npx overscore query update monthly_revenue --file queries/revenue.sql
684
+ npx overscore query test "SELECT * FROM dataset.table LIMIT 10"
685
+ `);
686
+ }
687
+ }
688
+ else {
689
+ console.log(`
690
+ overscore — Overscore CLI
691
+
692
+ Commands:
693
+ list List all dashboards in the project
694
+ deploy Build and deploy the dashboard
695
+ pull <slug> Pull source code from the hub
696
+ versions List deploy history
697
+ query <subcommand> Manage dashboard queries
698
+
699
+ Usage:
700
+ npx overscore list
701
+ npx overscore deploy --message "description"
702
+ npx overscore pull <slug>
703
+ npx overscore pull <slug> --version 3
704
+ npx overscore versions
705
+ npx overscore query list
706
+ npx overscore query add <name> "<sql>"
707
+ npx overscore query update <name> "<sql>"
708
+ npx overscore query remove <name>
709
+ npx overscore query test "<sql>"
710
+ `);
711
+ }
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@overscore/cli",
3
+ "version": "0.6.0",
4
+ "description": "CLI for deploying Overscore dashboards",
5
+ "bin": {
6
+ "overscore": "dist/index.js"
7
+ },
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "scripts": {
12
+ "build": "tsc"
13
+ },
14
+ "devDependencies": {
15
+ "@types/node": "^20",
16
+ "typescript": "^5"
17
+ },
18
+ "dependencies": {
19
+ "@types/yauzl": "^2.10.3",
20
+ "yauzl": "^3.3.0"
21
+ }
22
+ }