@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
- console.error(" Error: No credentials found. Run 'npx @overscore/cli auth login' to set up.");
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(path.join(dataDir, filename), JSON.stringify(payload, null, 2));
438
- return `data/${filename}`;
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
- if (!sql) {
446
- console.error('\n Usage: npx @overscore/cli query test "<sql>"\n');
447
- console.error(" Example:");
448
- console.error(' npx @overscore/cli query test "SELECT * FROM dataset.table LIMIT 10"\n');
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
- const data = (await apiRequest("POST", `/api/projects/${projectSlug}/test-query`, { sql }));
453
- const savedPath = saveQueryResult(sql, data);
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 (savedPath)
457
- console.log(` Saved to ${savedPath}\n`);
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 (savedPath)
485
- console.log(`\n Saved to ${savedPath}`);
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 === "_gitignore" ? ".gitignore" : 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 === "_gitignore") {
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
- const ANALYSIS_PUBLISH_EXCLUDE = new Set([
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
- ".env",
1019
- "data",
1080
+ ".overscore",
1020
1081
  ]);
1021
- function collectAnalysisFiles(dir, prefix) {
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 (ANALYSIS_PUBLISH_EXCLUDE.has(entry.name))
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
- Published successfully!
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
- console.log(`\n Pulling analysis: ${slug}...`);
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 and scaffold
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 zipBuffer = Buffer.from(await res.arrayBuffer());
1265
- const targetDir = path.resolve(process.cwd(), analysisSlug);
1266
- if (fs.existsSync(targetDir)) {
1267
- const yes = await confirm(`Directory "${analysisSlug}" already exists. Overwrite?`);
1268
- if (!yes) {
1269
- console.log(" Cancelled.\n");
1270
- return;
1271
- }
1272
- fs.rmSync(targetDir, { recursive: true, force: true });
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
- fs.mkdirSync(targetDir, { recursive: true });
1275
- await extractZip(zipBuffer, targetDir);
1276
- // Make sure analysis.json reflects the current project (in case the user
1277
- // pulled into a different machine where their default project is different)
1278
- const metaPath = path.join(targetDir, ".overscore", "analysis.json");
1279
- if (fs.existsSync(metaPath)) {
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 meta = JSON.parse(fs.readFileSync(metaPath, "utf-8"));
1282
- meta.project_slug = projectSlug;
1283
- meta.slug = analysisSlug;
1284
- fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
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
- // Non-fatal
1545
+ // collection failed — fall back to no summary
1288
1546
  }
1289
- }
1290
- // Refresh hub-managed rule files after restoring from the bundle. The
1291
- // bundle contains whatever was set at publish time; this overwrites them
1292
- // with the latest hub state so company context and SQL style stay in
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>" Create a new analysis in the current directory
1526
- publish Upload report.html + bundle from the current analysis folder
1527
- pull <slug> Re-download an existing analysis bundle to continue working
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 List all queries
1561
- show <name> Show a query's SQL and sample data
1562
- add <name> "<sql>" Add a new query
1563
- update <name> "<sql>" Update a query's SQL
1564
- remove <name> Delete a query
1565
- run "<sql>" Run SQL against BigQuery
1566
- test "<sql>" Alias for run
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@overscore/cli",
3
- "version": "0.9.13",
3
+ "version": "0.10.0",
4
4
  "description": "CLI for deploying Overscore dashboards and publishing analyses",
5
5
  "bin": {
6
6
  "overscore": "dist/index.js"
@@ -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
- open report.html # macOS
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