@overscore/cli 0.13.0 → 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 +257 -19
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -3,11 +3,19 @@ import fs from "fs";
3
3
  import path from "path";
4
4
  import http from "http";
5
5
  import net from "net";
6
+ import os from "os";
6
7
  import { execSync } from "child_process";
7
8
  import { createInterface } from "readline";
8
9
  import { createHash, randomBytes } from "crypto";
9
10
  import yauzl from "yauzl";
10
11
  // ── Shared helpers ──────────────────────────────────────────────────
12
+ // Name CLI-minted API keys after the machine + date so stale keys are
13
+ // recognizable and revocable in the Hub UI (mitigates key sprawl). Keys are
14
+ // project-scoped; revoke unused ones under Project Settings → API Keys.
15
+ function cliKeyName() {
16
+ const host = (os.hostname() || "unknown").split(".")[0];
17
+ return `cli ${host} ${new Date().toISOString().slice(0, 10)}`;
18
+ }
11
19
  function parseEnv(content) {
12
20
  const env = {};
13
21
  for (const line of content.split("\n")) {
@@ -23,6 +31,28 @@ function parseEnv(content) {
23
31
  }
24
32
  return env;
25
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
+ }
26
56
  async function loadEnv() {
27
57
  // (1) Local .env (inside a dashboard project)
28
58
  const envPath = path.resolve(process.cwd(), ".env");
@@ -33,7 +63,7 @@ async function loadEnv() {
33
63
  const dashboardSlug = env.VITE_OVERSCORE_DASHBOARD_SLUG || path.basename(process.cwd());
34
64
  const projectSlug = env.VITE_OVERSCORE_PROJECT_SLUG || "";
35
65
  if (apiUrl && apiKey) {
36
- return { apiUrl, apiKey, dashboardSlug, projectSlug };
66
+ return { apiUrl: safeApiUrl(apiUrl), apiKey, dashboardSlug, projectSlug };
37
67
  }
38
68
  // .env has project context but no API key — generate one via device token
39
69
  if (projectSlug) {
@@ -50,7 +80,7 @@ async function loadEnv() {
50
80
  : envContent + `VITE_OVERSCORE_API_KEY=${newKey}\n`;
51
81
  fs.writeFileSync(envPath, updated);
52
82
  return {
53
- apiUrl: apiUrl || `${HUB_URL}/api`,
83
+ apiUrl: safeApiUrl(apiUrl || `${HUB_URL}/api`),
54
84
  apiKey: newKey,
55
85
  dashboardSlug,
56
86
  projectSlug,
@@ -65,7 +95,7 @@ async function loadEnv() {
65
95
  const cfg = parseEnv(fs.readFileSync(configPath, "utf-8"));
66
96
  if (cfg.OVERSCORE_API_KEY) {
67
97
  return {
68
- apiUrl: cfg.OVERSCORE_API_URL || `${HUB_URL}/api`,
98
+ apiUrl: safeApiUrl(cfg.OVERSCORE_API_URL || `${HUB_URL}/api`),
69
99
  apiKey: cfg.OVERSCORE_API_KEY,
70
100
  dashboardSlug: path.basename(process.cwd()),
71
101
  projectSlug: cfg.OVERSCORE_PROJECT_SLUG || "",
@@ -76,7 +106,7 @@ async function loadEnv() {
76
106
  const newKey = await refreshApiKey(cfg.OVERSCORE_PROJECT_SLUG);
77
107
  if (newKey) {
78
108
  return {
79
- apiUrl: cfg.OVERSCORE_API_URL || `${HUB_URL}/api`,
109
+ apiUrl: safeApiUrl(cfg.OVERSCORE_API_URL || `${HUB_URL}/api`),
80
110
  apiKey: newKey,
81
111
  dashboardSlug: path.basename(process.cwd()),
82
112
  projectSlug: cfg.OVERSCORE_PROJECT_SLUG,
@@ -92,7 +122,7 @@ async function loadEnv() {
92
122
  const cfg2 = parseEnv(fs.readFileSync(configPath2, "utf-8"));
93
123
  if (cfg2.OVERSCORE_API_KEY) {
94
124
  return {
95
- apiUrl: cfg2.OVERSCORE_API_URL || `${HUB_URL}/api`,
125
+ apiUrl: safeApiUrl(cfg2.OVERSCORE_API_URL || `${HUB_URL}/api`),
96
126
  apiKey: cfg2.OVERSCORE_API_KEY,
97
127
  dashboardSlug: path.basename(process.cwd()),
98
128
  projectSlug: cfg2.OVERSCORE_PROJECT_SLUG || "",
@@ -187,7 +217,7 @@ async function refreshApiKey(projectSlug) {
187
217
  const keyRes = await fetch(`${HUB_URL}/api/projects/${projectSlug}/api-keys`, {
188
218
  method: "POST",
189
219
  headers: { Authorization: `Bearer ${deviceToken}`, "Content-Type": "application/json" },
190
- body: JSON.stringify({ name: `cli-${Date.now()}` }),
220
+ body: JSON.stringify({ name: cliKeyName() }),
191
221
  });
192
222
  if (keyRes.ok) {
193
223
  const data = (await keyRes.json());
@@ -239,27 +269,102 @@ const SECRET_FILE_EXTENSIONS = new Set([
239
269
  ".pfx",
240
270
  ".jks",
241
271
  ".keystore",
272
+ ".p8",
273
+ ".ppk",
274
+ ".gpg",
275
+ ".asc",
276
+ ".tfstate", // Terraform state — often contains plaintext secrets
277
+ ".tfvars",
278
+ ]);
279
+ // Exact filenames that are secrets regardless of (or lacking) an extension.
280
+ const SECRET_FILE_NAMES = new Set([
281
+ "service-account.json",
282
+ "service_account.json",
283
+ "serviceaccount.json",
284
+ "gcp-credentials.json",
285
+ "credentials.json",
286
+ "id_rsa",
287
+ "id_dsa",
288
+ "id_ecdsa",
289
+ "id_ed25519",
290
+ ".netrc",
291
+ ".pgpass",
292
+ ".htpasswd",
293
+ "secrets.json",
294
+ "secrets.yaml",
295
+ "secrets.yml",
242
296
  ]);
243
- function collectSourceFiles(dir, prefix) {
297
+ function isSecretFileName(name) {
298
+ const lower = name.toLowerCase();
299
+ if (SECRET_FILE_NAMES.has(lower))
300
+ return true;
301
+ const ext = lower.substring(lower.lastIndexOf("."));
302
+ if (SECRET_FILE_EXTENSIONS.has(ext))
303
+ return true;
304
+ // Terraform state backups: terraform.tfstate.backup, *.tfstate.*
305
+ if (lower.includes(".tfstate"))
306
+ return true;
307
+ return false;
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
+ }
326
+ /**
327
+ * Walk a directory collecting deployable source files. Excludes machine-state
328
+ * and secrets, NEVER follows symlinks (a symlink could point at ~/.ssh/id_rsa
329
+ * or outside the project), and honors .overscoreignore at the project root.
330
+ */
331
+ function collectSourceFiles(dir, prefix, ctx) {
332
+ // Initialize ignore context on the first (root) call.
333
+ const c = ctx ?? { patterns: loadOverscoreIgnore(dir), skipped: [] };
244
334
  const files = [];
245
335
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
336
+ // Never traverse or upload symlinks — they can escape the project tree
337
+ // or point at credentials. Skip silently-but-noted.
338
+ if (entry.isSymbolicLink()) {
339
+ c.skipped.push((prefix ? `${prefix}/` : "") + entry.name + " (symlink)");
340
+ continue;
341
+ }
246
342
  if (SOURCE_EXCLUDE.has(entry.name))
247
343
  continue;
248
344
  if (entry.name.startsWith(".env"))
249
345
  continue; // .env, .env.local, .env.production, etc.
250
346
  if (entry.name.endsWith(".log"))
251
347
  continue;
252
- const ext = entry.name.substring(entry.name.lastIndexOf(".")).toLowerCase();
253
- if (SECRET_FILE_EXTENSIONS.has(ext))
348
+ if (isSecretFileName(entry.name)) {
349
+ c.skipped.push((prefix ? `${prefix}/` : "") + entry.name + " (secret)");
254
350
  continue;
351
+ }
255
352
  const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
353
+ if (isIgnored(relativePath, entry.isDirectory(), c.patterns))
354
+ continue;
256
355
  if (entry.isDirectory()) {
257
- files.push(...collectSourceFiles(path.join(dir, entry.name), relativePath));
356
+ files.push(...collectSourceFiles(path.join(dir, entry.name), relativePath, c));
258
357
  }
259
358
  else {
260
359
  files.push(relativePath);
261
360
  }
262
361
  }
362
+ // Surface what was withheld so a deploy is never silently lossy on secrets.
363
+ if (!ctx && c.skipped.length > 0) {
364
+ console.log(` Skipped ${c.skipped.length} sensitive/symlinked file(s): ${c.skipped
365
+ .slice(0, 10)
366
+ .join(", ")}${c.skipped.length > 10 ? ", …" : ""}`);
367
+ }
263
368
  return files;
264
369
  }
265
370
  function extractZip(zipBuffer, targetDir) {
@@ -325,9 +430,16 @@ async function deploy() {
325
430
  }
326
431
  const builtFiles = collectFiles(distDir, "");
327
432
  console.log(` Found ${builtFiles.length} built files.`);
328
- // Collect source files
329
- const sourceFiles = collectSourceFiles(process.cwd(), "");
330
- console.log(` Found ${sourceFiles.length} source files.`);
433
+ // Collect source files (skipped entirely with --no-source). Source is saved
434
+ // for versioning/restore; symlinks and secrets are always excluded.
435
+ const noSource = process.argv.includes("--no-source");
436
+ const sourceFiles = noSource ? [] : collectSourceFiles(process.cwd(), "");
437
+ if (noSource) {
438
+ console.log(" Skipping source upload (--no-source).");
439
+ }
440
+ else {
441
+ console.log(` Found ${sourceFiles.length} source files.`);
442
+ }
331
443
  // Upload via deploy API
332
444
  console.log(" Uploading...");
333
445
  const formData = new FormData();
@@ -995,7 +1107,7 @@ async function loadAnalysisConfig() {
995
1107
  const apiKey = env.VITE_OVERSCORE_API_KEY;
996
1108
  const projectSlug = env.VITE_OVERSCORE_PROJECT_SLUG;
997
1109
  if (apiUrl && apiKey && projectSlug) {
998
- return { apiUrl, apiKey, projectSlug };
1110
+ return { apiUrl: safeApiUrl(apiUrl), apiKey, projectSlug };
999
1111
  }
1000
1112
  }
1001
1113
  // (2) ~/.overscore/config
@@ -1013,7 +1125,7 @@ async function loadAnalysisConfig() {
1013
1125
  });
1014
1126
  if (checkRes.ok) {
1015
1127
  return {
1016
- apiUrl: cfg.OVERSCORE_API_URL || `${HUB_URL}/api`,
1128
+ apiUrl: safeApiUrl(cfg.OVERSCORE_API_URL || `${HUB_URL}/api`),
1017
1129
  apiKey: cfg.OVERSCORE_API_KEY,
1018
1130
  projectSlug: cfg.OVERSCORE_PROJECT_SLUG,
1019
1131
  };
@@ -1023,7 +1135,7 @@ async function loadAnalysisConfig() {
1023
1135
  catch {
1024
1136
  // Can't reach the hub — use saved config and hope for the best
1025
1137
  return {
1026
- apiUrl: cfg.OVERSCORE_API_URL || `${HUB_URL}/api`,
1138
+ apiUrl: safeApiUrl(cfg.OVERSCORE_API_URL || `${HUB_URL}/api`),
1027
1139
  apiKey: cfg.OVERSCORE_API_KEY,
1028
1140
  projectSlug: cfg.OVERSCORE_PROJECT_SLUG,
1029
1141
  };
@@ -1063,7 +1175,7 @@ async function loadAnalysisConfig() {
1063
1175
  Authorization: `Bearer ${deviceToken}`,
1064
1176
  "Content-Type": "application/json",
1065
1177
  },
1066
- body: JSON.stringify({ name: `cli-${Date.now()}` }),
1178
+ body: JSON.stringify({ name: cliKeyName() }),
1067
1179
  });
1068
1180
  if (keyGenRes.ok) {
1069
1181
  const data = (await keyGenRes.json());
@@ -1191,9 +1303,15 @@ async function syncKnowledgeBase(targetDir, baseUrl, projectSlug, apiKey) {
1191
1303
  if (!data.files || data.files.length === 0)
1192
1304
  return false;
1193
1305
  fs.mkdirSync(knowledgeDir, { recursive: true });
1194
- // Write each knowledge file
1306
+ // Write each knowledge file — contained within knowledgeDir (audit H6).
1195
1307
  for (const file of data.files) {
1196
- 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);
1197
1315
  }
1198
1316
  // Generate INDEX.md
1199
1317
  const indexLines = [
@@ -1704,8 +1822,16 @@ function collectAnalysisFiles(dir, prefix, patterns) {
1704
1822
  const userPatterns = patterns ?? loadOverscoreIgnore(dir);
1705
1823
  const files = [];
1706
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;
1707
1831
  if (isBaselineExcluded(entry.name))
1708
1832
  continue;
1833
+ if (isSecretFileName(entry.name))
1834
+ continue;
1709
1835
  const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
1710
1836
  if (isIgnored(relativePath, entry.isDirectory(), userPatterns))
1711
1837
  continue;
@@ -2259,6 +2385,17 @@ async function dev() {
2259
2385
  await cleanup();
2260
2386
  process.exit(0);
2261
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
+ });
2262
2399
  }
2263
2400
  // ── auth ────────────────────────────────────────────────────────────
2264
2401
  async function authStatus() {
@@ -2447,6 +2584,90 @@ ${installed.map((s) => ` - ${s}`).join("\n")}
2447
2584
  Re-run this command anytime to update.
2448
2585
  `);
2449
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
+ }
2450
2671
  // ── Routing ─────────────────────────────────────────────────────────
2451
2672
  const command = process.argv[2];
2452
2673
  const subcommand = process.argv[3];
@@ -2479,6 +2700,23 @@ else if (command === "auth") {
2479
2700
  authStatus();
2480
2701
  }
2481
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
+ }
2482
2720
  else if (command === "analysis") {
2483
2721
  if (subcommand === "new") {
2484
2722
  analysisNew(process.argv[4]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@overscore/cli",
3
- "version": "0.13.0",
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"