@overscore/cli 0.9.13 → 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.
|
|
@@ -1123,13 +1299,38 @@ async function ensureAnalysisId(targetDir, cfg, slug) {
|
|
|
1123
1299
|
// Non-fatal
|
|
1124
1300
|
}
|
|
1125
1301
|
}
|
|
1302
|
+
/**
|
|
1303
|
+
* Detect if CWD is already an analysis folder matching the given slug.
|
|
1304
|
+
* Returns the in-place target dir if so, or a new sibling dir otherwise.
|
|
1305
|
+
*/
|
|
1306
|
+
function resolvePullTarget(slug) {
|
|
1307
|
+
const localMetaPath = path.resolve(process.cwd(), ".overscore", "analysis.json");
|
|
1308
|
+
if (fs.existsSync(localMetaPath)) {
|
|
1309
|
+
try {
|
|
1310
|
+
const localMeta = JSON.parse(fs.readFileSync(localMetaPath, "utf-8"));
|
|
1311
|
+
if (localMeta.slug === slug) {
|
|
1312
|
+
return { targetDir: process.cwd(), inPlace: true };
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
catch {
|
|
1316
|
+
// ignore parse errors
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
return { targetDir: path.resolve(process.cwd(), slug), inPlace: false };
|
|
1320
|
+
}
|
|
1126
1321
|
async function analysisPull(slug) {
|
|
1127
1322
|
if (!slug) {
|
|
1128
1323
|
console.error("\n Usage: npx @overscore/cli analysis pull <slug>\n");
|
|
1129
1324
|
process.exit(1);
|
|
1130
1325
|
}
|
|
1131
1326
|
const cfg = await loadAnalysisConfig();
|
|
1132
|
-
|
|
1327
|
+
const { targetDir: resolvedTarget, inPlace } = resolvePullTarget(slug);
|
|
1328
|
+
if (inPlace) {
|
|
1329
|
+
console.log(`\n Pulling analysis: ${slug} (refreshing in place)...`);
|
|
1330
|
+
}
|
|
1331
|
+
else {
|
|
1332
|
+
console.log(`\n Pulling analysis: ${slug}...`);
|
|
1333
|
+
}
|
|
1133
1334
|
const baseUrl = cfg.apiUrl.replace(/\/api$/, "");
|
|
1134
1335
|
const res = await fetch(`${baseUrl}/api/projects/${cfg.projectSlug}/analyses/${slug}/source`, {
|
|
1135
1336
|
headers: { Authorization: `Bearer ${cfg.apiKey}` },
|
|
@@ -1150,6 +1351,19 @@ async function analysisPull(slug) {
|
|
|
1150
1351
|
// If the analysis exists but hasn't been published yet, scaffold from
|
|
1151
1352
|
// the template instead of trying to download a bundle that doesn't exist.
|
|
1152
1353
|
if (isUnpublished) {
|
|
1354
|
+
const targetDir = resolvedTarget;
|
|
1355
|
+
if (inPlace) {
|
|
1356
|
+
// In-place refresh: only update metadata and hub-managed rules
|
|
1357
|
+
const baseUrl = cfg.apiUrl.replace(/\/api$/, "");
|
|
1358
|
+
await syncHubManagedRules(targetDir, baseUrl, cfg.projectSlug, cfg.apiKey);
|
|
1359
|
+
await ensureAnalysisId(targetDir, cfg, slug);
|
|
1360
|
+
console.log(` Refreshed in place (not yet published).
|
|
1361
|
+
|
|
1362
|
+
Updated: hub-managed rules, analysis metadata/id.
|
|
1363
|
+
Untouched: report.html, analysis-log.md, data/, and all other local files.
|
|
1364
|
+
`);
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1153
1367
|
console.log(" Not yet published — scaffolding from template...\n");
|
|
1154
1368
|
const distDir = path.dirname(new URL(import.meta.url).pathname);
|
|
1155
1369
|
const pkgRoot = path.resolve(distDir, "..");
|
|
@@ -1160,7 +1374,6 @@ async function analysisPull(slug) {
|
|
|
1160
1374
|
}
|
|
1161
1375
|
// Fetch analysis metadata from the Hub
|
|
1162
1376
|
const metaRes = await analysisApiRequest(cfg, "GET", `/api/projects/${cfg.projectSlug}/analyses/${slug}/meta`).catch(() => null);
|
|
1163
|
-
const targetDir = path.resolve(process.cwd(), slug);
|
|
1164
1377
|
if (fs.existsSync(targetDir)) {
|
|
1165
1378
|
const yes = await confirm(`Directory "${slug}" already exists. Overwrite?`);
|
|
1166
1379
|
if (!yes) {
|
|
@@ -1190,8 +1403,6 @@ async function analysisPull(slug) {
|
|
|
1190
1403
|
const baseUrl = cfg.apiUrl.replace(/\/api$/, "");
|
|
1191
1404
|
await syncHubManagedRules(targetDir, baseUrl, cfg.projectSlug, cfg.apiKey);
|
|
1192
1405
|
await ensureAnalysisId(targetDir, cfg, slug);
|
|
1193
|
-
// Don't sync dashboard platform rules into analysis folders —
|
|
1194
|
-
// analyses have their own rules in the template (sql-style, report-html, etc.)
|
|
1195
1406
|
console.log(` Scaffolded "${title}"
|
|
1196
1407
|
|
|
1197
1408
|
Next steps:
|
|
@@ -1212,7 +1423,19 @@ async function analysisPull(slug) {
|
|
|
1212
1423
|
// Verify we got a zip back (not an HTML redirect or JSON error)
|
|
1213
1424
|
const contentType = res.headers.get("Content-Type") || "";
|
|
1214
1425
|
if (!contentType.includes("zip") && !contentType.includes("octet-stream")) {
|
|
1215
|
-
// Probably an auth redirect or error page — treat as unpublished
|
|
1426
|
+
// Probably an auth redirect or error page — treat as unpublished
|
|
1427
|
+
const targetDir = resolvedTarget;
|
|
1428
|
+
if (inPlace) {
|
|
1429
|
+
const baseUrl = cfg.apiUrl.replace(/\/api$/, "");
|
|
1430
|
+
await syncHubManagedRules(targetDir, baseUrl, cfg.projectSlug, cfg.apiKey);
|
|
1431
|
+
await ensureAnalysisId(targetDir, cfg, slug);
|
|
1432
|
+
console.log(` Refreshed in place.
|
|
1433
|
+
|
|
1434
|
+
Updated: hub-managed rules, analysis metadata/id.
|
|
1435
|
+
Untouched: report.html, analysis-log.md, data/, and all other local files.
|
|
1436
|
+
`);
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1216
1439
|
console.log(" No published bundle found — scaffolding from template...\n");
|
|
1217
1440
|
const distDir = path.dirname(new URL(import.meta.url).pathname);
|
|
1218
1441
|
const pkgRoot = path.resolve(distDir, "..");
|
|
@@ -1221,7 +1444,6 @@ async function analysisPull(slug) {
|
|
|
1221
1444
|
console.error(`\n Error: Bundled analysis template not found.\n`);
|
|
1222
1445
|
process.exit(1);
|
|
1223
1446
|
}
|
|
1224
|
-
const targetDir = path.resolve(process.cwd(), slug);
|
|
1225
1447
|
if (fs.existsSync(targetDir)) {
|
|
1226
1448
|
const yes = await confirm(`Directory "${slug}" already exists. Overwrite?`);
|
|
1227
1449
|
if (!yes) {
|
|
@@ -1243,7 +1465,6 @@ async function analysisPull(slug) {
|
|
|
1243
1465
|
const baseUrl = cfg.apiUrl.replace(/\/api$/, "");
|
|
1244
1466
|
await syncHubManagedRules(targetDir, baseUrl, cfg.projectSlug, cfg.apiKey);
|
|
1245
1467
|
await ensureAnalysisId(targetDir, cfg, slug);
|
|
1246
|
-
// Don't sync dashboard platform rules into analysis folders
|
|
1247
1468
|
console.log(` Scaffolded "${slug}"
|
|
1248
1469
|
|
|
1249
1470
|
Next steps:
|
|
@@ -1261,40 +1482,74 @@ async function analysisPull(slug) {
|
|
|
1261
1482
|
const analysisSlug = res.headers.get("X-OS-Analysis-Slug") || slug;
|
|
1262
1483
|
const analysisTitle = res.headers.get("X-OS-Analysis-Title") || slug;
|
|
1263
1484
|
const projectSlug = res.headers.get("X-OS-Project-Slug") || cfg.projectSlug;
|
|
1264
|
-
const
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1485
|
+
const targetDir = resolvedTarget;
|
|
1486
|
+
if (inPlace) {
|
|
1487
|
+
// In-place refresh: only update metadata and hub-managed rules.
|
|
1488
|
+
// Never overwrite user content (report.html, analysis-log.md, data/, etc.)
|
|
1489
|
+
await syncHubManagedRules(targetDir, baseUrl, projectSlug, cfg.apiKey);
|
|
1490
|
+
await ensureAnalysisId(targetDir, cfg, analysisSlug);
|
|
1491
|
+
console.log(`
|
|
1492
|
+
Refreshed "${analysisTitle}" in place.
|
|
1493
|
+
|
|
1494
|
+
Updated: hub-managed rules, analysis metadata/id.
|
|
1495
|
+
Untouched: report.html, analysis-log.md, data/, and all other local files.
|
|
1496
|
+
`);
|
|
1273
1497
|
}
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1498
|
+
else {
|
|
1499
|
+
const zipBuffer = Buffer.from(await res.arrayBuffer());
|
|
1500
|
+
if (fs.existsSync(targetDir)) {
|
|
1501
|
+
const yes = await confirm(`Directory "${analysisSlug}" already exists. Overwrite?`);
|
|
1502
|
+
if (!yes) {
|
|
1503
|
+
console.log(" Cancelled.\n");
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
1507
|
+
}
|
|
1508
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
1509
|
+
await extractZip(zipBuffer, targetDir);
|
|
1510
|
+
// Make sure analysis.json reflects the current project (in case the user
|
|
1511
|
+
// pulled into a different machine where their default project is different)
|
|
1512
|
+
const metaPath = path.join(targetDir, ".overscore", "analysis.json");
|
|
1513
|
+
if (fs.existsSync(metaPath)) {
|
|
1514
|
+
try {
|
|
1515
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8"));
|
|
1516
|
+
meta.project_slug = projectSlug;
|
|
1517
|
+
meta.slug = analysisSlug;
|
|
1518
|
+
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
|
|
1519
|
+
}
|
|
1520
|
+
catch {
|
|
1521
|
+
// Non-fatal
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
// Refresh hub-managed rule files after restoring from the bundle.
|
|
1525
|
+
await syncHubManagedRules(targetDir, baseUrl, projectSlug, cfg.apiKey);
|
|
1526
|
+
await ensureAnalysisId(targetDir, cfg, analysisSlug);
|
|
1527
|
+
}
|
|
1528
|
+
if (!inPlace) {
|
|
1529
|
+
// Summarize what landed on disk so the user knows what they got.
|
|
1530
|
+
let pulledFileCount = 0;
|
|
1531
|
+
let pulledBytes = 0;
|
|
1280
1532
|
try {
|
|
1281
|
-
const
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
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
|
+
}
|
|
1285
1543
|
}
|
|
1286
1544
|
catch {
|
|
1287
|
-
//
|
|
1545
|
+
// collection failed — fall back to no summary
|
|
1288
1546
|
}
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
// sync across pulls. Best-effort and non-fatal.
|
|
1294
|
-
await syncHubManagedRules(targetDir, baseUrl, projectSlug, cfg.apiKey);
|
|
1295
|
-
await ensureAnalysisId(targetDir, cfg, analysisSlug);
|
|
1296
|
-
console.log(`
|
|
1547
|
+
const sizeSummary = pulledFileCount > 0
|
|
1548
|
+
? ` Pulled into ./${analysisSlug} (${pulledFileCount} files, ${formatBytes(pulledBytes)})`
|
|
1549
|
+
: ` Pulled into ./${analysisSlug}`;
|
|
1550
|
+
console.log(`
|
|
1297
1551
|
Pulled "${analysisTitle}"
|
|
1552
|
+
${sizeSummary}
|
|
1298
1553
|
|
|
1299
1554
|
Next steps:
|
|
1300
1555
|
|
|
@@ -1308,6 +1563,29 @@ async function analysisPull(slug) {
|
|
|
1308
1563
|
|
|
1309
1564
|
npx @overscore/cli analysis publish
|
|
1310
1565
|
`);
|
|
1566
|
+
}
|
|
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
|
+
}
|
|
1311
1589
|
}
|
|
1312
1590
|
// ── auth ────────────────────────────────────────────────────────────
|
|
1313
1591
|
async function authStatus() {
|
|
@@ -1517,19 +1795,26 @@ else if (command === "analysis") {
|
|
|
1517
1795
|
else if (subcommand === "pull") {
|
|
1518
1796
|
analysisPull(process.argv[4]);
|
|
1519
1797
|
}
|
|
1798
|
+
else if (subcommand === "preview") {
|
|
1799
|
+
analysisPreview();
|
|
1800
|
+
}
|
|
1520
1801
|
else {
|
|
1521
1802
|
console.log(`
|
|
1522
1803
|
overscore analysis — Manage Overscore Analyses (frozen investigations)
|
|
1523
1804
|
|
|
1524
1805
|
Commands:
|
|
1525
|
-
new "<title>"
|
|
1526
|
-
publish
|
|
1527
|
-
|
|
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
|
|
1528
1811
|
|
|
1529
1812
|
Examples:
|
|
1530
1813
|
npx @overscore/cli analysis new "Why did Q1 churn spike"
|
|
1531
1814
|
npx @overscore/cli analysis publish
|
|
1815
|
+
npx @overscore/cli analysis publish --dry-run
|
|
1532
1816
|
npx @overscore/cli analysis pull why-did-q1-churn-spike
|
|
1817
|
+
npx @overscore/cli analysis preview
|
|
1533
1818
|
`);
|
|
1534
1819
|
}
|
|
1535
1820
|
}
|
|
@@ -1557,21 +1842,22 @@ else if (command === "query") {
|
|
|
1557
1842
|
overscore query — Manage dashboard queries
|
|
1558
1843
|
|
|
1559
1844
|
Commands:
|
|
1560
|
-
list
|
|
1561
|
-
show <name>
|
|
1562
|
-
add <name> "<sql>"
|
|
1563
|
-
update <name> "<sql>"
|
|
1564
|
-
remove <name>
|
|
1565
|
-
run "<sql>"
|
|
1566
|
-
|
|
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
|
|
1567
1853
|
|
|
1568
1854
|
Examples:
|
|
1569
1855
|
npx @overscore/cli query list
|
|
1570
1856
|
npx @overscore/cli query show monthly_revenue
|
|
1571
1857
|
npx @overscore/cli query add monthly_revenue --file queries/revenue.sql
|
|
1572
|
-
npx @overscore/cli query add monthly_revenue "SELECT ..."
|
|
1573
1858
|
npx @overscore/cli query update monthly_revenue --file queries/revenue.sql
|
|
1574
1859
|
npx @overscore/cli query run "SELECT * FROM dataset.table LIMIT 10"
|
|
1860
|
+
npx @overscore/cli query run --file queries/q3-cohort-halo.sql
|
|
1575
1861
|
`);
|
|
1576
1862
|
}
|
|
1577
1863
|
}
|
|
@@ -1602,9 +1888,12 @@ else {
|
|
|
1602
1888
|
npx @overscore/cli query update <name> "<sql>"
|
|
1603
1889
|
npx @overscore/cli query remove <name>
|
|
1604
1890
|
npx @overscore/cli query run "<sql>"
|
|
1891
|
+
npx @overscore/cli query run --file queries/foo.sql
|
|
1605
1892
|
npx @overscore/cli analysis new "<title>"
|
|
1606
1893
|
npx @overscore/cli analysis publish
|
|
1894
|
+
npx @overscore/cli analysis publish --dry-run
|
|
1607
1895
|
npx @overscore/cli analysis pull <slug>
|
|
1896
|
+
npx @overscore/cli analysis preview
|
|
1608
1897
|
npx @overscore/cli install-skills
|
|
1609
1898
|
`);
|
|
1610
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
|