@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.
- package/dist/index.js +711 -0
- 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
|
+
}
|