@overscore/cli 0.9.14 → 0.10.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
CHANGED
|
@@ -47,7 +47,29 @@ function loadEnv() {
|
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
-
|
|
50
|
+
// No creds. Tailor the message based on what kind of folder we're in so the
|
|
51
|
+
// user gets pointed at the right setup command.
|
|
52
|
+
const inAnalysisFolder = fs.existsSync(path.resolve(process.cwd(), ".overscore", "analysis.json"));
|
|
53
|
+
if (inAnalysisFolder) {
|
|
54
|
+
let analysisSlug = null;
|
|
55
|
+
try {
|
|
56
|
+
const meta = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), ".overscore", "analysis.json"), "utf-8"));
|
|
57
|
+
analysisSlug = typeof meta.slug === "string" ? meta.slug : null;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Fall through to the generic message.
|
|
61
|
+
}
|
|
62
|
+
console.error("\n Error: No credentials found in this analysis folder.");
|
|
63
|
+
console.error(" This usually means the analysis hasn't been linked to your account yet.");
|
|
64
|
+
console.error(" Run:");
|
|
65
|
+
console.error(` npx @overscore/cli analysis pull ${analysisSlug || "<slug>"}`);
|
|
66
|
+
console.error(" to refresh credentials and link this folder, or:");
|
|
67
|
+
console.error(" npx @overscore/cli auth login");
|
|
68
|
+
console.error(" if you've never set up the CLI on this machine.\n");
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
console.error("\n Error: No credentials found. Run 'npx @overscore/cli auth login' to set up.\n");
|
|
72
|
+
}
|
|
51
73
|
process.exit(1);
|
|
52
74
|
}
|
|
53
75
|
async function apiRequest(method, urlPath, body) {
|
|
@@ -423,6 +445,14 @@ function saveQueryResult(sql, data) {
|
|
|
423
445
|
.slice(0, 40);
|
|
424
446
|
const filename = `${hash}_${snippet}.json`;
|
|
425
447
|
const dataDir = path.resolve(process.cwd(), "data");
|
|
448
|
+
const fullPath = path.join(dataDir, filename);
|
|
449
|
+
const relPath = `data/${filename}`;
|
|
450
|
+
// Hash is content-derived from the SQL — if a file with this hash already
|
|
451
|
+
// exists, the exact same query was run before. Skip the rewrite so the
|
|
452
|
+
// data/ folder doesn't accumulate duplicates of identical queries.
|
|
453
|
+
if (fs.existsSync(fullPath)) {
|
|
454
|
+
return { path: relPath, cached: true };
|
|
455
|
+
}
|
|
426
456
|
if (!fs.existsSync(dataDir)) {
|
|
427
457
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
428
458
|
}
|
|
@@ -434,27 +464,53 @@ function saveQueryResult(sql, data) {
|
|
|
434
464
|
columns: data.data.length > 0 ? Object.keys(data.data[0]) : [],
|
|
435
465
|
data: data.data,
|
|
436
466
|
};
|
|
437
|
-
fs.writeFileSync(
|
|
438
|
-
return
|
|
467
|
+
fs.writeFileSync(fullPath, JSON.stringify(payload, null, 2));
|
|
468
|
+
return { path: relPath, cached: false };
|
|
439
469
|
}
|
|
440
470
|
catch {
|
|
441
471
|
return null;
|
|
442
472
|
}
|
|
443
473
|
}
|
|
444
474
|
async function queryTest(sql) {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
console.error(
|
|
448
|
-
console.error(
|
|
475
|
+
const resolvedSql = resolveSql(sql);
|
|
476
|
+
if (!resolvedSql) {
|
|
477
|
+
console.error('\n Usage: npx @overscore/cli query run "<sql>"\n');
|
|
478
|
+
console.error(" Or with a file:");
|
|
479
|
+
console.error(" npx @overscore/cli query run --file queries/foo.sql\n");
|
|
449
480
|
process.exit(1);
|
|
450
481
|
}
|
|
451
482
|
const { projectSlug } = loadEnv();
|
|
452
|
-
|
|
453
|
-
|
|
483
|
+
if (!projectSlug) {
|
|
484
|
+
const analysisMetaPath = path.resolve(process.cwd(), ".overscore", "analysis.json");
|
|
485
|
+
if (fs.existsSync(analysisMetaPath)) {
|
|
486
|
+
let slug = "<slug>";
|
|
487
|
+
try {
|
|
488
|
+
const meta = JSON.parse(fs.readFileSync(analysisMetaPath, "utf-8"));
|
|
489
|
+
if (typeof meta.slug === "string")
|
|
490
|
+
slug = meta.slug;
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
// ignore
|
|
494
|
+
}
|
|
495
|
+
console.error("\n Error: This analysis folder isn't linked to a project yet.");
|
|
496
|
+
console.error(` Run: npx @overscore/cli analysis pull ${slug}\n`);
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
console.error("\n Error: No project slug configured. Run 'npx @overscore/cli auth login' to set one up.\n");
|
|
500
|
+
}
|
|
501
|
+
process.exit(1);
|
|
502
|
+
}
|
|
503
|
+
const data = (await apiRequest("POST", `/api/projects/${projectSlug}/test-query`, { sql: resolvedSql }));
|
|
504
|
+
const saved = saveQueryResult(resolvedSql, data);
|
|
505
|
+
const savedLine = saved
|
|
506
|
+
? saved.cached
|
|
507
|
+
? ` Cached (already saved earlier): ${saved.path}`
|
|
508
|
+
: ` Saved to ${saved.path}`
|
|
509
|
+
: null;
|
|
454
510
|
console.log(`\n ${data.row_count} rows returned${data.truncated ? " (showing first 200)" : ""}\n`);
|
|
455
511
|
if (data.data.length === 0) {
|
|
456
|
-
if (
|
|
457
|
-
console.log(
|
|
512
|
+
if (savedLine)
|
|
513
|
+
console.log(`${savedLine}\n`);
|
|
458
514
|
console.log(" No rows.\n");
|
|
459
515
|
return;
|
|
460
516
|
}
|
|
@@ -481,8 +537,8 @@ async function queryTest(sql) {
|
|
|
481
537
|
if (data.data.length > 10) {
|
|
482
538
|
console.log(`\n ... and ${data.data.length - 10} more rows`);
|
|
483
539
|
}
|
|
484
|
-
if (
|
|
485
|
-
console.log(`\n
|
|
540
|
+
if (savedLine)
|
|
541
|
+
console.log(`\n${savedLine}`);
|
|
486
542
|
console.log();
|
|
487
543
|
}
|
|
488
544
|
function formatCell(value) {
|
|
@@ -913,18 +969,22 @@ function scaffoldFromTemplate(templateDir, targetDir, substitutions) {
|
|
|
913
969
|
}
|
|
914
970
|
return out;
|
|
915
971
|
}
|
|
972
|
+
const TEMPLATE_RENAMES = {
|
|
973
|
+
_gitignore: ".gitignore",
|
|
974
|
+
_overscoreignore: ".overscoreignore",
|
|
975
|
+
};
|
|
916
976
|
function walk(srcDir, destDir) {
|
|
917
977
|
fs.mkdirSync(destDir, { recursive: true });
|
|
918
978
|
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
|
|
919
979
|
const srcPath = path.join(srcDir, entry.name);
|
|
920
|
-
const destName = entry.name
|
|
980
|
+
const destName = TEMPLATE_RENAMES[entry.name] || entry.name;
|
|
921
981
|
const destPath = path.join(destDir, destName);
|
|
922
982
|
if (entry.isDirectory()) {
|
|
923
983
|
walk(srcPath, destPath);
|
|
924
984
|
}
|
|
925
985
|
else if (entry.isFile()) {
|
|
926
986
|
const ext = path.extname(entry.name).toLowerCase();
|
|
927
|
-
if (TEXT_EXTENSIONS.has(ext) || entry.name
|
|
987
|
+
if (TEXT_EXTENSIONS.has(ext) || TEMPLATE_RENAMES[entry.name]) {
|
|
928
988
|
const content = fs.readFileSync(srcPath, "utf8");
|
|
929
989
|
fs.writeFileSync(destPath, substitute(content));
|
|
930
990
|
}
|
|
@@ -1011,23 +1071,77 @@ function loadAnalysisMetadata() {
|
|
|
1011
1071
|
}
|
|
1012
1072
|
return JSON.parse(fs.readFileSync(metaPath, "utf-8"));
|
|
1013
1073
|
}
|
|
1014
|
-
|
|
1074
|
+
// Always-excluded names — these can never be published regardless of
|
|
1075
|
+
// .overscoreignore, so secrets and machine-state can't accidentally ship.
|
|
1076
|
+
const ANALYSIS_PUBLISH_BASELINE_EXCLUDE = new Set([
|
|
1015
1077
|
"node_modules",
|
|
1016
1078
|
".git",
|
|
1017
1079
|
".DS_Store",
|
|
1018
|
-
".
|
|
1019
|
-
"data",
|
|
1080
|
+
".overscore",
|
|
1020
1081
|
]);
|
|
1021
|
-
function
|
|
1082
|
+
function isBaselineExcluded(name) {
|
|
1083
|
+
if (ANALYSIS_PUBLISH_BASELINE_EXCLUDE.has(name))
|
|
1084
|
+
return true;
|
|
1085
|
+
if (name.startsWith(".env"))
|
|
1086
|
+
return true;
|
|
1087
|
+
return false;
|
|
1088
|
+
}
|
|
1089
|
+
function compileIgnorePattern(raw) {
|
|
1090
|
+
let pattern = raw.trim();
|
|
1091
|
+
if (!pattern || pattern.startsWith("#"))
|
|
1092
|
+
return null;
|
|
1093
|
+
const dirOnly = pattern.endsWith("/");
|
|
1094
|
+
if (dirOnly)
|
|
1095
|
+
pattern = pattern.slice(0, -1);
|
|
1096
|
+
// Escape regex specials except for `*`, then turn `*` into `[^/]*`.
|
|
1097
|
+
const escaped = pattern
|
|
1098
|
+
.split("*")
|
|
1099
|
+
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
|
1100
|
+
.join("[^/]*");
|
|
1101
|
+
// Anchor to either a full path match or a path that has the pattern as a
|
|
1102
|
+
// path segment somewhere (so `data/` matches `data/foo.json` and `*.email.html`
|
|
1103
|
+
// matches `emails/x.email.html`).
|
|
1104
|
+
const regex = new RegExp(`(^|/)${escaped}(/|$)`);
|
|
1105
|
+
return { raw, regex, dirOnly };
|
|
1106
|
+
}
|
|
1107
|
+
function loadOverscoreIgnore(dir) {
|
|
1108
|
+
const ignorePath = path.join(dir, ".overscoreignore");
|
|
1109
|
+
if (!fs.existsSync(ignorePath))
|
|
1110
|
+
return [];
|
|
1111
|
+
const content = fs.readFileSync(ignorePath, "utf-8");
|
|
1112
|
+
const patterns = [];
|
|
1113
|
+
for (const line of content.split("\n")) {
|
|
1114
|
+
const compiled = compileIgnorePattern(line);
|
|
1115
|
+
if (compiled)
|
|
1116
|
+
patterns.push(compiled);
|
|
1117
|
+
}
|
|
1118
|
+
return patterns;
|
|
1119
|
+
}
|
|
1120
|
+
function isIgnored(relativePath, isDirectory, patterns) {
|
|
1121
|
+
for (const p of patterns) {
|
|
1122
|
+
if (p.dirOnly && !isDirectory) {
|
|
1123
|
+
// Directory-only pattern can still match a file *inside* the directory.
|
|
1124
|
+
// The regex handles that case for paths like `data/foo.json`.
|
|
1125
|
+
if (p.regex.test(relativePath))
|
|
1126
|
+
return true;
|
|
1127
|
+
}
|
|
1128
|
+
else if (p.regex.test(relativePath)) {
|
|
1129
|
+
return true;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
return false;
|
|
1133
|
+
}
|
|
1134
|
+
function collectAnalysisFiles(dir, prefix, patterns) {
|
|
1135
|
+
const userPatterns = patterns ?? loadOverscoreIgnore(dir);
|
|
1022
1136
|
const files = [];
|
|
1023
1137
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
1024
|
-
if (
|
|
1025
|
-
continue;
|
|
1026
|
-
if (entry.name.startsWith(".env"))
|
|
1138
|
+
if (isBaselineExcluded(entry.name))
|
|
1027
1139
|
continue;
|
|
1028
1140
|
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
1141
|
+
if (isIgnored(relativePath, entry.isDirectory(), userPatterns))
|
|
1142
|
+
continue;
|
|
1029
1143
|
if (entry.isDirectory()) {
|
|
1030
|
-
files.push(...collectAnalysisFiles(path.join(dir, entry.name), relativePath));
|
|
1144
|
+
files.push(...collectAnalysisFiles(path.join(dir, entry.name), relativePath, userPatterns));
|
|
1031
1145
|
}
|
|
1032
1146
|
else {
|
|
1033
1147
|
files.push(relativePath);
|
|
@@ -1035,7 +1149,61 @@ function collectAnalysisFiles(dir, prefix) {
|
|
|
1035
1149
|
}
|
|
1036
1150
|
return files;
|
|
1037
1151
|
}
|
|
1152
|
+
function formatBytes(bytes) {
|
|
1153
|
+
if (bytes < 1024)
|
|
1154
|
+
return `${bytes} B`;
|
|
1155
|
+
if (bytes < 1024 * 1024)
|
|
1156
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
1157
|
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
1158
|
+
}
|
|
1159
|
+
function summarizeFiles(files, baseDir) {
|
|
1160
|
+
const groupMap = new Map();
|
|
1161
|
+
let totalBytes = 0;
|
|
1162
|
+
for (const rel of files) {
|
|
1163
|
+
const fullPath = path.join(baseDir, rel);
|
|
1164
|
+
let size = 0;
|
|
1165
|
+
try {
|
|
1166
|
+
size = fs.statSync(fullPath).size;
|
|
1167
|
+
}
|
|
1168
|
+
catch {
|
|
1169
|
+
// skip stat failures, count as 0
|
|
1170
|
+
}
|
|
1171
|
+
totalBytes += size;
|
|
1172
|
+
const segments = rel.split("/");
|
|
1173
|
+
const group = segments.length > 1 ? segments[0] + "/" : "(root)";
|
|
1174
|
+
let entry = groupMap.get(group);
|
|
1175
|
+
if (!entry) {
|
|
1176
|
+
entry = { group, files: [], totalBytes: 0 };
|
|
1177
|
+
groupMap.set(group, entry);
|
|
1178
|
+
}
|
|
1179
|
+
entry.files.push({ path: rel, size });
|
|
1180
|
+
entry.totalBytes += size;
|
|
1181
|
+
}
|
|
1182
|
+
// Sort groups: (root) first, then alphabetical
|
|
1183
|
+
const groups = Array.from(groupMap.values()).sort((a, b) => {
|
|
1184
|
+
if (a.group === "(root)")
|
|
1185
|
+
return -1;
|
|
1186
|
+
if (b.group === "(root)")
|
|
1187
|
+
return 1;
|
|
1188
|
+
return a.group.localeCompare(b.group);
|
|
1189
|
+
});
|
|
1190
|
+
return { groups, totalFiles: files.length, totalBytes };
|
|
1191
|
+
}
|
|
1192
|
+
function printBundleSummary(files, baseDir, opts = {}) {
|
|
1193
|
+
const { groups, totalFiles, totalBytes } = summarizeFiles(files, baseDir);
|
|
1194
|
+
for (const group of groups) {
|
|
1195
|
+
const fileWord = group.files.length === 1 ? "file" : "files";
|
|
1196
|
+
console.log(` ${group.group} ${group.files.length} ${fileWord}, ${formatBytes(group.totalBytes)}`);
|
|
1197
|
+
if (opts.verbose) {
|
|
1198
|
+
for (const f of group.files) {
|
|
1199
|
+
console.log(` ${f.path} (${formatBytes(f.size)})`);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
console.log(` ${"─".repeat(20)}\n Total: ${totalFiles} files, ${formatBytes(totalBytes)}`);
|
|
1204
|
+
}
|
|
1038
1205
|
async function analysisPublish() {
|
|
1206
|
+
const dryRun = process.argv.includes("--dry-run") || process.argv.includes("--preview");
|
|
1039
1207
|
const meta = loadAnalysisMetadata();
|
|
1040
1208
|
if (!meta.project_slug) {
|
|
1041
1209
|
console.error("\n Error: analysis.json has no project_slug. Check .overscore/analysis.json.\n");
|
|
@@ -1045,6 +1213,27 @@ async function analysisPublish() {
|
|
|
1045
1213
|
console.error("\n Error: analysis.json has no slug. Check .overscore/analysis.json.\n");
|
|
1046
1214
|
process.exit(1);
|
|
1047
1215
|
}
|
|
1216
|
+
const reportPath = path.resolve(process.cwd(), "report.html");
|
|
1217
|
+
if (!fs.existsSync(reportPath)) {
|
|
1218
|
+
console.error("\n Error: report.html not found in this folder. Generate it before publishing.\n");
|
|
1219
|
+
process.exit(1);
|
|
1220
|
+
}
|
|
1221
|
+
const reportContent = fs.readFileSync(reportPath, "utf-8");
|
|
1222
|
+
if (reportContent.includes("This analysis is empty")) {
|
|
1223
|
+
console.error("\n Error: report.html still has placeholder content. Generate the report before publishing.\n");
|
|
1224
|
+
process.exit(1);
|
|
1225
|
+
}
|
|
1226
|
+
// Dry-run: print what would be uploaded, then exit. No network, no auth.
|
|
1227
|
+
if (dryRun) {
|
|
1228
|
+
const files = collectAnalysisFiles(process.cwd(), "");
|
|
1229
|
+
console.log(`\n Dry run — these files would be uploaded for "${meta.title}":\n`);
|
|
1230
|
+
printBundleSummary(files, process.cwd(), { verbose: true });
|
|
1231
|
+
console.log(`
|
|
1232
|
+
Excluded by .overscoreignore + baseline (.git, node_modules, .env*, .overscore/, .DS_Store).
|
|
1233
|
+
No upload made. Re-run without --dry-run to publish.
|
|
1234
|
+
`);
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1048
1237
|
const cfg = await loadAnalysisConfig();
|
|
1049
1238
|
// Resolve missing id from the Hub before giving up
|
|
1050
1239
|
if (!meta.id) {
|
|
@@ -1058,16 +1247,6 @@ async function analysisPublish() {
|
|
|
1058
1247
|
}
|
|
1059
1248
|
meta.id = refreshed.id;
|
|
1060
1249
|
}
|
|
1061
|
-
const reportPath = path.resolve(process.cwd(), "report.html");
|
|
1062
|
-
if (!fs.existsSync(reportPath)) {
|
|
1063
|
-
console.error("\n Error: report.html not found in this folder. Generate it before publishing.\n");
|
|
1064
|
-
process.exit(1);
|
|
1065
|
-
}
|
|
1066
|
-
const reportContent = fs.readFileSync(reportPath, "utf-8");
|
|
1067
|
-
if (reportContent.includes("This analysis is empty")) {
|
|
1068
|
-
console.error("\n Error: report.html still has placeholder content. Generate the report before publishing.\n");
|
|
1069
|
-
process.exit(1);
|
|
1070
|
-
}
|
|
1071
1250
|
console.log(`\n Publishing analysis: ${meta.title}\n`);
|
|
1072
1251
|
const files = collectAnalysisFiles(process.cwd(), "");
|
|
1073
1252
|
console.log(` Bundling ${files.length} files...`);
|
|
@@ -1091,12 +1270,9 @@ async function analysisPublish() {
|
|
|
1091
1270
|
console.error(`\n Publish failed: ${data.error}\n`);
|
|
1092
1271
|
process.exit(1);
|
|
1093
1272
|
}
|
|
1094
|
-
console.log(`
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
${data.file_count} files uploaded
|
|
1098
|
-
URL: ${data.url}
|
|
1099
|
-
`);
|
|
1273
|
+
console.log(`\n Published successfully!\n`);
|
|
1274
|
+
printBundleSummary(files, process.cwd());
|
|
1275
|
+
console.log(`\n URL: ${data.url}\n`);
|
|
1100
1276
|
}
|
|
1101
1277
|
/**
|
|
1102
1278
|
* Ensure analysis.json has a valid id by fetching from the Hub.
|
|
@@ -1350,8 +1526,30 @@ async function analysisPull(slug) {
|
|
|
1350
1526
|
await ensureAnalysisId(targetDir, cfg, analysisSlug);
|
|
1351
1527
|
}
|
|
1352
1528
|
if (!inPlace) {
|
|
1529
|
+
// Summarize what landed on disk so the user knows what they got.
|
|
1530
|
+
let pulledFileCount = 0;
|
|
1531
|
+
let pulledBytes = 0;
|
|
1532
|
+
try {
|
|
1533
|
+
const pulledFiles = collectAnalysisFiles(targetDir, "");
|
|
1534
|
+
pulledFileCount = pulledFiles.length;
|
|
1535
|
+
for (const f of pulledFiles) {
|
|
1536
|
+
try {
|
|
1537
|
+
pulledBytes += fs.statSync(path.join(targetDir, f)).size;
|
|
1538
|
+
}
|
|
1539
|
+
catch {
|
|
1540
|
+
// ignore
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
catch {
|
|
1545
|
+
// collection failed — fall back to no summary
|
|
1546
|
+
}
|
|
1547
|
+
const sizeSummary = pulledFileCount > 0
|
|
1548
|
+
? ` Pulled into ./${analysisSlug} (${pulledFileCount} files, ${formatBytes(pulledBytes)})`
|
|
1549
|
+
: ` Pulled into ./${analysisSlug}`;
|
|
1353
1550
|
console.log(`
|
|
1354
1551
|
Pulled "${analysisTitle}"
|
|
1552
|
+
${sizeSummary}
|
|
1355
1553
|
|
|
1356
1554
|
Next steps:
|
|
1357
1555
|
|
|
@@ -1367,6 +1565,28 @@ async function analysisPull(slug) {
|
|
|
1367
1565
|
`);
|
|
1368
1566
|
}
|
|
1369
1567
|
}
|
|
1568
|
+
function analysisPreview() {
|
|
1569
|
+
const reportPath = path.resolve(process.cwd(), "report.html");
|
|
1570
|
+
if (!fs.existsSync(reportPath)) {
|
|
1571
|
+
console.error("\n Error: report.html not found in this folder. Generate it before previewing.\n");
|
|
1572
|
+
process.exit(1);
|
|
1573
|
+
}
|
|
1574
|
+
// Windows `start` needs an empty quoted title before the path arg
|
|
1575
|
+
// (`start "" "C:\path\report.html"`), which is why we prefix it here.
|
|
1576
|
+
const opener = process.platform === "darwin"
|
|
1577
|
+
? "open"
|
|
1578
|
+
: process.platform === "win32"
|
|
1579
|
+
? 'start ""'
|
|
1580
|
+
: "xdg-open";
|
|
1581
|
+
try {
|
|
1582
|
+
execSync(`${opener} "${reportPath}"`, { stdio: "inherit" });
|
|
1583
|
+
console.log(`\n Opened ${path.basename(reportPath)} in your default browser.\n`);
|
|
1584
|
+
}
|
|
1585
|
+
catch {
|
|
1586
|
+
console.error(`\n Error: couldn't run "${opener}". Open ${reportPath} manually.\n`);
|
|
1587
|
+
process.exit(1);
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1370
1590
|
// ── auth ────────────────────────────────────────────────────────────
|
|
1371
1591
|
async function authStatus() {
|
|
1372
1592
|
const configDir = path.join(process.env.HOME || "", ".overscore");
|
|
@@ -1575,19 +1795,26 @@ else if (command === "analysis") {
|
|
|
1575
1795
|
else if (subcommand === "pull") {
|
|
1576
1796
|
analysisPull(process.argv[4]);
|
|
1577
1797
|
}
|
|
1798
|
+
else if (subcommand === "preview") {
|
|
1799
|
+
analysisPreview();
|
|
1800
|
+
}
|
|
1578
1801
|
else {
|
|
1579
1802
|
console.log(`
|
|
1580
1803
|
overscore analysis — Manage Overscore Analyses (frozen investigations)
|
|
1581
1804
|
|
|
1582
1805
|
Commands:
|
|
1583
|
-
new "<title>"
|
|
1584
|
-
publish
|
|
1585
|
-
|
|
1806
|
+
new "<title>" Create a new analysis in the current directory
|
|
1807
|
+
publish Upload report.html + bundle from the current analysis folder
|
|
1808
|
+
publish --dry-run Print what would be uploaded without sending it
|
|
1809
|
+
pull <slug> Re-download an existing analysis bundle to continue working
|
|
1810
|
+
preview Open report.html in your default browser
|
|
1586
1811
|
|
|
1587
1812
|
Examples:
|
|
1588
1813
|
npx @overscore/cli analysis new "Why did Q1 churn spike"
|
|
1589
1814
|
npx @overscore/cli analysis publish
|
|
1815
|
+
npx @overscore/cli analysis publish --dry-run
|
|
1590
1816
|
npx @overscore/cli analysis pull why-did-q1-churn-spike
|
|
1817
|
+
npx @overscore/cli analysis preview
|
|
1591
1818
|
`);
|
|
1592
1819
|
}
|
|
1593
1820
|
}
|
|
@@ -1615,21 +1842,22 @@ else if (command === "query") {
|
|
|
1615
1842
|
overscore query — Manage dashboard queries
|
|
1616
1843
|
|
|
1617
1844
|
Commands:
|
|
1618
|
-
list
|
|
1619
|
-
show <name>
|
|
1620
|
-
add <name> "<sql>"
|
|
1621
|
-
update <name> "<sql>"
|
|
1622
|
-
remove <name>
|
|
1623
|
-
run "<sql>"
|
|
1624
|
-
|
|
1845
|
+
list List all queries
|
|
1846
|
+
show <name> Show a query's SQL and sample data
|
|
1847
|
+
add <name> "<sql>" Add a new query
|
|
1848
|
+
update <name> "<sql>" Update a query's SQL
|
|
1849
|
+
remove <name> Delete a query
|
|
1850
|
+
run "<sql>" Run SQL against BigQuery
|
|
1851
|
+
run --file <path> Run SQL from a file (e.g. queries/foo.sql)
|
|
1852
|
+
test Alias for run
|
|
1625
1853
|
|
|
1626
1854
|
Examples:
|
|
1627
1855
|
npx @overscore/cli query list
|
|
1628
1856
|
npx @overscore/cli query show monthly_revenue
|
|
1629
1857
|
npx @overscore/cli query add monthly_revenue --file queries/revenue.sql
|
|
1630
|
-
npx @overscore/cli query add monthly_revenue "SELECT ..."
|
|
1631
1858
|
npx @overscore/cli query update monthly_revenue --file queries/revenue.sql
|
|
1632
1859
|
npx @overscore/cli query run "SELECT * FROM dataset.table LIMIT 10"
|
|
1860
|
+
npx @overscore/cli query run --file queries/q3-cohort-halo.sql
|
|
1633
1861
|
`);
|
|
1634
1862
|
}
|
|
1635
1863
|
}
|
|
@@ -1660,9 +1888,12 @@ else {
|
|
|
1660
1888
|
npx @overscore/cli query update <name> "<sql>"
|
|
1661
1889
|
npx @overscore/cli query remove <name>
|
|
1662
1890
|
npx @overscore/cli query run "<sql>"
|
|
1891
|
+
npx @overscore/cli query run --file queries/foo.sql
|
|
1663
1892
|
npx @overscore/cli analysis new "<title>"
|
|
1664
1893
|
npx @overscore/cli analysis publish
|
|
1894
|
+
npx @overscore/cli analysis publish --dry-run
|
|
1665
1895
|
npx @overscore/cli analysis pull <slug>
|
|
1896
|
+
npx @overscore/cli analysis preview
|
|
1666
1897
|
npx @overscore/cli install-skills
|
|
1667
1898
|
`);
|
|
1668
1899
|
}
|
package/package.json
CHANGED
|
@@ -31,6 +31,12 @@ Use the Overscore CLI to run SQL against the project's BigQuery connection:
|
|
|
31
31
|
npx @overscore/cli query run "SELECT col1, COUNT(*) FROM \`project.dataset.table\` WHERE date >= '2024-01-01' GROUP BY 1 LIMIT 100"
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
+
For longer queries, write the SQL to a file in `queries/` and pass `--file`:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npx @overscore/cli query run --file queries/q3-cohort-halo.sql
|
|
38
|
+
```
|
|
39
|
+
|
|
34
40
|
The CLI authenticates via `~/.overscore/config` (set up on first use). Credentials are stored in the Hub — never hardcode them locally.
|
|
35
41
|
|
|
36
42
|
**Rules for exploratory queries:**
|
|
@@ -39,7 +45,11 @@ The CLI authenticates via `~/.overscore/config` (set up on first use). Credentia
|
|
|
39
45
|
- Pre-aggregate before pulling wide result sets
|
|
40
46
|
- Do NOT use `query list`, `query add`, or `query show` — those are for dashboard registered queries, not analysis exploration
|
|
41
47
|
|
|
42
|
-
**Previous query results are saved automatically.** Every `query run` saves the full result set to `data/` as a JSON file. Before re-running a query, check `data/` — the results may already be there from a previous session. Read them with standard file tools instead of re-querying BigQuery.
|
|
48
|
+
**Previous query results are saved automatically.** Every `query run` saves the full result set to `data/` as a JSON file, keyed by a hash of the SQL. Re-running an identical query is a no-op — the CLI prints `Cached (already saved earlier): data/...` instead of writing a duplicate. Before re-running a query, check `data/` — the results may already be there from a previous session. Read them with standard file tools instead of re-querying BigQuery.
|
|
49
|
+
|
|
50
|
+
## Saving final queries
|
|
51
|
+
|
|
52
|
+
The `queries/` directory is for SQL files you want to keep around — the canonical version of each Q-numbered query in `analysis-log.md`, queries the user might want to re-run later, or anything you've iterated on enough to deserve a name. File names like `q3-cohort-halo.sql` cross-reference the Query Log. Exploratory one-offs don't need to live here; they're already saved as JSON in `data/`.
|
|
43
53
|
|
|
44
54
|
## Where data context lives
|
|
45
55
|
|
|
@@ -50,13 +60,20 @@ The CLI authenticates via `~/.overscore/config` (set up on first use). Credentia
|
|
|
50
60
|
To preview the report at any point, open it in the browser:
|
|
51
61
|
|
|
52
62
|
```bash
|
|
53
|
-
|
|
54
|
-
start report.html # Windows
|
|
55
|
-
xdg-open report.html # Linux
|
|
63
|
+
npx @overscore/cli analysis preview
|
|
56
64
|
```
|
|
57
65
|
|
|
66
|
+
(Or `open report.html` / `start report.html` / `xdg-open report.html` if the CLI isn't handy.)
|
|
67
|
+
|
|
58
68
|
Preview early and often — don't write the entire report before checking the layout. Build section by section, previewing after each major addition.
|
|
59
69
|
|
|
70
|
+
## If the user wants a share email or Slack message
|
|
71
|
+
|
|
72
|
+
If the user asks for a copy-paste email or Slack summary of the analysis, write it to a file like `update-YYYY-MM-DD.email.html` in the analysis root. Two things to know:
|
|
73
|
+
|
|
74
|
+
- **Email-safe HTML only.** Gmail and most clients strip `<style>` blocks — use inline styles on every element. No `<script>` (most clients block it). Keep layouts simple; complex things (multi-column) want `<table>` for cross-client compatibility.
|
|
75
|
+
- **The `.email.html` suffix keeps it out of the published bundle.** It's matched by `.overscoreignore`, so iterating on email drafts won't pollute the public report.
|
|
76
|
+
|
|
60
77
|
## Publishing
|
|
61
78
|
|
|
62
79
|
When the analysis is ready, **tell the user to run** (do not run it yourself without asking):
|
|
@@ -77,4 +77,8 @@ When starting `report.html`, read only the **Key Findings** section of `analysis
|
|
|
77
77
|
|
|
78
78
|
## Cached query results
|
|
79
79
|
|
|
80
|
-
Every `query run` command saves its result to `data/{hash}_{snippet}.json`. At the start of a session, check `data/` for existing results before re-running queries — especially after a context reset or `analysis pull`. The JSON files contain the SQL, timestamp, columns, and full row data. Read them directly instead of re-querying BigQuery.
|
|
80
|
+
Every `query run` command saves its result to `data/{hash}_{snippet}.json`. The hash is derived from the SQL string, so re-running an identical query is a no-op — the CLI prints `Cached (already saved earlier): data/...` instead of writing a duplicate. At the start of a session, check `data/` for existing results before re-running queries — especially after a context reset or `analysis pull`. The JSON files contain the SQL, timestamp, columns, and full row data. Read them directly instead of re-querying BigQuery.
|
|
81
|
+
|
|
82
|
+
## Saving queries to disk
|
|
83
|
+
|
|
84
|
+
For any query worth referencing later (each Q-numbered query in this log, queries the user might re-run, longer queries you've iterated on), save the SQL to `queries/qN-short-name.sql` and run it via `npx @overscore/cli query run --file queries/qN-short-name.sql`. The Query Log entry then just needs the file reference instead of a full SQL paste — it keeps the log readable and gives you a single source of truth to edit. Throwaway one-shot exploratory queries don't need a file; their JSON in `data/` is enough.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Files matching these patterns are excluded from `analysis publish`.
|
|
2
|
+
# Syntax: one pattern per line. `*` is a glob within a single path segment.
|
|
3
|
+
# Trailing `/` matches a directory and everything inside it.
|
|
4
|
+
# Lines starting with `#` are comments.
|
|
5
|
+
#
|
|
6
|
+
# A baseline set is always excluded regardless of this file:
|
|
7
|
+
# .git/, node_modules/, .DS_Store, .env*, .overscore/
|
|
8
|
+
|
|
9
|
+
data/
|
|
10
|
+
*.draft.html
|
|
11
|
+
*.email.html
|
|
File without changes
|