@overscore/cli 0.13.1 → 0.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +175 -10
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -31,6 +31,28 @@ function parseEnv(content) {
31
31
  }
32
32
  return env;
33
33
  }
34
+ /**
35
+ * Only let credentialed requests (API key + deploy/source bundle) target
36
+ * overscore.dev or localhost. Prevents a poisoned project .env
37
+ * (VITE_OVERSCORE_API_URL) from redirecting the key + uploaded bundle to an
38
+ * attacker host (audit L6). Falls back to the canonical Hub on anything else.
39
+ */
40
+ function safeApiUrl(apiUrl) {
41
+ try {
42
+ const h = new URL(apiUrl).hostname;
43
+ if (h === "overscore.dev" ||
44
+ h.endsWith(".overscore.dev") ||
45
+ h === "localhost" ||
46
+ h === "127.0.0.1") {
47
+ return apiUrl;
48
+ }
49
+ }
50
+ catch {
51
+ /* invalid URL */
52
+ }
53
+ console.warn(` Ignoring untrusted API URL "${apiUrl}"; using ${HUB_URL}/api`);
54
+ return `${HUB_URL}/api`;
55
+ }
34
56
  async function loadEnv() {
35
57
  // (1) Local .env (inside a dashboard project)
36
58
  const envPath = path.resolve(process.cwd(), ".env");
@@ -41,7 +63,7 @@ async function loadEnv() {
41
63
  const dashboardSlug = env.VITE_OVERSCORE_DASHBOARD_SLUG || path.basename(process.cwd());
42
64
  const projectSlug = env.VITE_OVERSCORE_PROJECT_SLUG || "";
43
65
  if (apiUrl && apiKey) {
44
- return { apiUrl, apiKey, dashboardSlug, projectSlug };
66
+ return { apiUrl: safeApiUrl(apiUrl), apiKey, dashboardSlug, projectSlug };
45
67
  }
46
68
  // .env has project context but no API key — generate one via device token
47
69
  if (projectSlug) {
@@ -58,7 +80,7 @@ async function loadEnv() {
58
80
  : envContent + `VITE_OVERSCORE_API_KEY=${newKey}\n`;
59
81
  fs.writeFileSync(envPath, updated);
60
82
  return {
61
- apiUrl: apiUrl || `${HUB_URL}/api`,
83
+ apiUrl: safeApiUrl(apiUrl || `${HUB_URL}/api`),
62
84
  apiKey: newKey,
63
85
  dashboardSlug,
64
86
  projectSlug,
@@ -73,7 +95,7 @@ async function loadEnv() {
73
95
  const cfg = parseEnv(fs.readFileSync(configPath, "utf-8"));
74
96
  if (cfg.OVERSCORE_API_KEY) {
75
97
  return {
76
- apiUrl: cfg.OVERSCORE_API_URL || `${HUB_URL}/api`,
98
+ apiUrl: safeApiUrl(cfg.OVERSCORE_API_URL || `${HUB_URL}/api`),
77
99
  apiKey: cfg.OVERSCORE_API_KEY,
78
100
  dashboardSlug: path.basename(process.cwd()),
79
101
  projectSlug: cfg.OVERSCORE_PROJECT_SLUG || "",
@@ -84,7 +106,7 @@ async function loadEnv() {
84
106
  const newKey = await refreshApiKey(cfg.OVERSCORE_PROJECT_SLUG);
85
107
  if (newKey) {
86
108
  return {
87
- apiUrl: cfg.OVERSCORE_API_URL || `${HUB_URL}/api`,
109
+ apiUrl: safeApiUrl(cfg.OVERSCORE_API_URL || `${HUB_URL}/api`),
88
110
  apiKey: newKey,
89
111
  dashboardSlug: path.basename(process.cwd()),
90
112
  projectSlug: cfg.OVERSCORE_PROJECT_SLUG,
@@ -100,7 +122,7 @@ async function loadEnv() {
100
122
  const cfg2 = parseEnv(fs.readFileSync(configPath2, "utf-8"));
101
123
  if (cfg2.OVERSCORE_API_KEY) {
102
124
  return {
103
- apiUrl: cfg2.OVERSCORE_API_URL || `${HUB_URL}/api`,
125
+ apiUrl: safeApiUrl(cfg2.OVERSCORE_API_URL || `${HUB_URL}/api`),
104
126
  apiKey: cfg2.OVERSCORE_API_KEY,
105
127
  dashboardSlug: path.basename(process.cwd()),
106
128
  projectSlug: cfg2.OVERSCORE_PROJECT_SLUG || "",
@@ -284,6 +306,23 @@ function isSecretFileName(name) {
284
306
  return true;
285
307
  return false;
286
308
  }
309
+ /**
310
+ * Resolve `relPath` under `baseDir`, returning the absolute path only if it
311
+ * stays inside `baseDir`. Knowledge-file paths come from the Hub (tenant-
312
+ * controlled) and are written to disk on pull/scaffold, so they MUST be
313
+ * contained — otherwise `../rules/data-fetching.md` could overwrite an
314
+ * auto-loaded Claude Code rule on a teammate's machine (audit H6).
315
+ */
316
+ function safeJoinWithin(baseDir, relPath) {
317
+ if (typeof relPath !== "string" || relPath.length === 0 || relPath.includes("\0")) {
318
+ return null;
319
+ }
320
+ const base = path.resolve(baseDir);
321
+ const resolved = path.resolve(base, relPath);
322
+ if (resolved !== base && !resolved.startsWith(base + path.sep))
323
+ return null;
324
+ return resolved;
325
+ }
287
326
  /**
288
327
  * Walk a directory collecting deployable source files. Excludes machine-state
289
328
  * and secrets, NEVER follows symlinks (a symlink could point at ~/.ssh/id_rsa
@@ -1068,7 +1107,7 @@ async function loadAnalysisConfig() {
1068
1107
  const apiKey = env.VITE_OVERSCORE_API_KEY;
1069
1108
  const projectSlug = env.VITE_OVERSCORE_PROJECT_SLUG;
1070
1109
  if (apiUrl && apiKey && projectSlug) {
1071
- return { apiUrl, apiKey, projectSlug };
1110
+ return { apiUrl: safeApiUrl(apiUrl), apiKey, projectSlug };
1072
1111
  }
1073
1112
  }
1074
1113
  // (2) ~/.overscore/config
@@ -1086,7 +1125,7 @@ async function loadAnalysisConfig() {
1086
1125
  });
1087
1126
  if (checkRes.ok) {
1088
1127
  return {
1089
- apiUrl: cfg.OVERSCORE_API_URL || `${HUB_URL}/api`,
1128
+ apiUrl: safeApiUrl(cfg.OVERSCORE_API_URL || `${HUB_URL}/api`),
1090
1129
  apiKey: cfg.OVERSCORE_API_KEY,
1091
1130
  projectSlug: cfg.OVERSCORE_PROJECT_SLUG,
1092
1131
  };
@@ -1096,7 +1135,7 @@ async function loadAnalysisConfig() {
1096
1135
  catch {
1097
1136
  // Can't reach the hub — use saved config and hope for the best
1098
1137
  return {
1099
- apiUrl: cfg.OVERSCORE_API_URL || `${HUB_URL}/api`,
1138
+ apiUrl: safeApiUrl(cfg.OVERSCORE_API_URL || `${HUB_URL}/api`),
1100
1139
  apiKey: cfg.OVERSCORE_API_KEY,
1101
1140
  projectSlug: cfg.OVERSCORE_PROJECT_SLUG,
1102
1141
  };
@@ -1264,9 +1303,15 @@ async function syncKnowledgeBase(targetDir, baseUrl, projectSlug, apiKey) {
1264
1303
  if (!data.files || data.files.length === 0)
1265
1304
  return false;
1266
1305
  fs.mkdirSync(knowledgeDir, { recursive: true });
1267
- // Write each knowledge file
1306
+ // Write each knowledge file — contained within knowledgeDir (audit H6).
1268
1307
  for (const file of data.files) {
1269
- fs.writeFileSync(path.join(knowledgeDir, file.path), file.content);
1308
+ const dest = safeJoinWithin(knowledgeDir, file.path);
1309
+ if (!dest) {
1310
+ console.warn(` Skipped knowledge file with unsafe path: ${file.path}`);
1311
+ continue;
1312
+ }
1313
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
1314
+ fs.writeFileSync(dest, file.content);
1270
1315
  }
1271
1316
  // Generate INDEX.md
1272
1317
  const indexLines = [
@@ -1777,8 +1822,16 @@ function collectAnalysisFiles(dir, prefix, patterns) {
1777
1822
  const userPatterns = patterns ?? loadOverscoreIgnore(dir);
1778
1823
  const files = [];
1779
1824
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1825
+ // Never follow symlinks or ship secrets — same guards as the deploy path's
1826
+ // collectSourceFiles (audit H5: this analysis-publish collector had neither,
1827
+ // so a poisoned agent could upload service-account.json / id_rsa, or a
1828
+ // file-target symlink to ~/.ssh/id_rsa).
1829
+ if (entry.isSymbolicLink())
1830
+ continue;
1780
1831
  if (isBaselineExcluded(entry.name))
1781
1832
  continue;
1833
+ if (isSecretFileName(entry.name))
1834
+ continue;
1782
1835
  const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
1783
1836
  if (isIgnored(relativePath, entry.isDirectory(), userPatterns))
1784
1837
  continue;
@@ -2332,6 +2385,17 @@ async function dev() {
2332
2385
  await cleanup();
2333
2386
  process.exit(0);
2334
2387
  });
2388
+ // Also clean up on terminal close (SIGHUP) and on a crash, so the short-lived
2389
+ // dev key isn't stranded live when the tab closes or the process throws (M12).
2390
+ process.on("SIGHUP", async () => {
2391
+ await cleanup();
2392
+ process.exit(0);
2393
+ });
2394
+ process.on("uncaughtException", async (err) => {
2395
+ await cleanup();
2396
+ console.error(err);
2397
+ process.exit(1);
2398
+ });
2335
2399
  }
2336
2400
  // ── auth ────────────────────────────────────────────────────────────
2337
2401
  async function authStatus() {
@@ -2520,6 +2584,90 @@ ${installed.map((s) => ` - ${s}`).join("\n")}
2520
2584
  Re-run this command anytime to update.
2521
2585
  `);
2522
2586
  }
2587
+ // ── template pull ──────────────────────────────────────────────────
2588
+ async function templatePull(slug) {
2589
+ if (!slug) {
2590
+ console.error("\n Usage: npx @overscore/cli template pull <template-slug> [dir]\n");
2591
+ console.error(" Example:");
2592
+ console.error(" npx @overscore/cli template pull mrr-tracker\n");
2593
+ process.exit(1);
2594
+ }
2595
+ const targetDir = path.resolve(process.cwd(), process.argv[4] || slug);
2596
+ const url = `https://overscore.dev/api/templates/${slug}/source`;
2597
+ console.log(`\n Pulling template: ${slug}...`);
2598
+ let res;
2599
+ try {
2600
+ res = await fetch(url, {
2601
+ // Optional: attach device token if available, for attribution in install counting
2602
+ headers: (() => {
2603
+ const configPath = path.join(process.env.HOME || "", ".overscore", "config");
2604
+ if (fs.existsSync(configPath)) {
2605
+ const cfg = parseEnv(fs.readFileSync(configPath, "utf-8"));
2606
+ if (cfg.OVERSCORE_DEVICE_TOKEN) {
2607
+ return { Authorization: `Bearer ${cfg.OVERSCORE_DEVICE_TOKEN}` };
2608
+ }
2609
+ }
2610
+ return {};
2611
+ })(),
2612
+ });
2613
+ }
2614
+ catch {
2615
+ console.error(`\n Error: Could not reach overscore.dev\n`);
2616
+ process.exit(1);
2617
+ }
2618
+ if (!res.ok) {
2619
+ let errorMsg = `HTTP ${res.status}`;
2620
+ try {
2621
+ const body = await res.json();
2622
+ errorMsg = body.error || errorMsg;
2623
+ }
2624
+ catch {
2625
+ //
2626
+ }
2627
+ console.error(`\n Error: ${errorMsg}\n`);
2628
+ process.exit(1);
2629
+ }
2630
+ const templateSlug = res.headers.get("X-OS-Template-Slug") || slug;
2631
+ const templateVersion = res.headers.get("X-OS-Template-Version") || "0";
2632
+ const zipBuffer = Buffer.from(await res.arrayBuffer());
2633
+ // Confirm overwrite if dir exists
2634
+ if (fs.existsSync(targetDir)) {
2635
+ const yes = await confirm(`Directory "${path.basename(targetDir)}" already exists. Overwrite?`);
2636
+ if (!yes) {
2637
+ console.log(" Cancelled.\n");
2638
+ return;
2639
+ }
2640
+ for (const entry of fs.readdirSync(targetDir)) {
2641
+ if (entry === "node_modules")
2642
+ continue;
2643
+ fs.rmSync(path.join(targetDir, entry), { recursive: true, force: true });
2644
+ }
2645
+ }
2646
+ else {
2647
+ fs.mkdirSync(targetDir, { recursive: true });
2648
+ }
2649
+ // Extract zip
2650
+ await extractZip(zipBuffer, targetDir);
2651
+ // Write provenance: templates don't have .env (unbound to a project)
2652
+ // Instead, .overscore/template.json records the pull metadata
2653
+ const oscoreDir = path.join(targetDir, ".overscore");
2654
+ fs.mkdirSync(oscoreDir, { recursive: true });
2655
+ fs.writeFileSync(path.join(oscoreDir, "template.json"), JSON.stringify({
2656
+ template_slug: templateSlug,
2657
+ template_version: templateVersion,
2658
+ pulled_at: new Date().toISOString(),
2659
+ }, null, 2));
2660
+ console.log(`
2661
+ ✓ Template extracted to: ${path.relative(process.cwd(), targetDir)}
2662
+
2663
+ Next steps:
2664
+ cd ${path.relative(process.cwd(), targetDir)}
2665
+ npm install
2666
+ npx @overscore/cli dev
2667
+
2668
+ This template runs on mock data — no warehouse connection or API key needed.
2669
+ `);
2670
+ }
2523
2671
  // ── Routing ─────────────────────────────────────────────────────────
2524
2672
  const command = process.argv[2];
2525
2673
  const subcommand = process.argv[3];
@@ -2552,6 +2700,23 @@ else if (command === "auth") {
2552
2700
  authStatus();
2553
2701
  }
2554
2702
  }
2703
+ else if (command === "template") {
2704
+ if (subcommand === "pull") {
2705
+ templatePull(process.argv[4]);
2706
+ }
2707
+ else {
2708
+ console.log(`
2709
+ overscore template — Manage marketplace templates
2710
+
2711
+ Commands:
2712
+ pull <slug> [dir] Download and extract a template
2713
+
2714
+ Examples:
2715
+ npx @overscore/cli template pull mrr-tracker
2716
+ npx @overscore/cli template pull mrr-tracker my-mrr-dash
2717
+ `);
2718
+ }
2719
+ }
2555
2720
  else if (command === "analysis") {
2556
2721
  if (subcommand === "new") {
2557
2722
  analysisNew(process.argv[4]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@overscore/cli",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
4
4
  "description": "CLI for deploying Overscore dashboards and publishing analyses",
5
5
  "bin": {
6
6
  "overscore": "dist/index.js"