@overscore/cli 0.12.0 → 0.13.1

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 +199 -28
  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")) {
@@ -187,7 +195,7 @@ async function refreshApiKey(projectSlug) {
187
195
  const keyRes = await fetch(`${HUB_URL}/api/projects/${projectSlug}/api-keys`, {
188
196
  method: "POST",
189
197
  headers: { Authorization: `Bearer ${deviceToken}`, "Content-Type": "application/json" },
190
- body: JSON.stringify({ name: `cli-${Date.now()}` }),
198
+ body: JSON.stringify({ name: cliKeyName() }),
191
199
  });
192
200
  if (keyRes.ok) {
193
201
  const data = (await keyRes.json());
@@ -239,27 +247,85 @@ const SECRET_FILE_EXTENSIONS = new Set([
239
247
  ".pfx",
240
248
  ".jks",
241
249
  ".keystore",
250
+ ".p8",
251
+ ".ppk",
252
+ ".gpg",
253
+ ".asc",
254
+ ".tfstate", // Terraform state — often contains plaintext secrets
255
+ ".tfvars",
256
+ ]);
257
+ // Exact filenames that are secrets regardless of (or lacking) an extension.
258
+ const SECRET_FILE_NAMES = new Set([
259
+ "service-account.json",
260
+ "service_account.json",
261
+ "serviceaccount.json",
262
+ "gcp-credentials.json",
263
+ "credentials.json",
264
+ "id_rsa",
265
+ "id_dsa",
266
+ "id_ecdsa",
267
+ "id_ed25519",
268
+ ".netrc",
269
+ ".pgpass",
270
+ ".htpasswd",
271
+ "secrets.json",
272
+ "secrets.yaml",
273
+ "secrets.yml",
242
274
  ]);
243
- function collectSourceFiles(dir, prefix) {
275
+ function isSecretFileName(name) {
276
+ const lower = name.toLowerCase();
277
+ if (SECRET_FILE_NAMES.has(lower))
278
+ return true;
279
+ const ext = lower.substring(lower.lastIndexOf("."));
280
+ if (SECRET_FILE_EXTENSIONS.has(ext))
281
+ return true;
282
+ // Terraform state backups: terraform.tfstate.backup, *.tfstate.*
283
+ if (lower.includes(".tfstate"))
284
+ return true;
285
+ return false;
286
+ }
287
+ /**
288
+ * Walk a directory collecting deployable source files. Excludes machine-state
289
+ * and secrets, NEVER follows symlinks (a symlink could point at ~/.ssh/id_rsa
290
+ * or outside the project), and honors .overscoreignore at the project root.
291
+ */
292
+ function collectSourceFiles(dir, prefix, ctx) {
293
+ // Initialize ignore context on the first (root) call.
294
+ const c = ctx ?? { patterns: loadOverscoreIgnore(dir), skipped: [] };
244
295
  const files = [];
245
296
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
297
+ // Never traverse or upload symlinks — they can escape the project tree
298
+ // or point at credentials. Skip silently-but-noted.
299
+ if (entry.isSymbolicLink()) {
300
+ c.skipped.push((prefix ? `${prefix}/` : "") + entry.name + " (symlink)");
301
+ continue;
302
+ }
246
303
  if (SOURCE_EXCLUDE.has(entry.name))
247
304
  continue;
248
305
  if (entry.name.startsWith(".env"))
249
306
  continue; // .env, .env.local, .env.production, etc.
250
307
  if (entry.name.endsWith(".log"))
251
308
  continue;
252
- const ext = entry.name.substring(entry.name.lastIndexOf(".")).toLowerCase();
253
- if (SECRET_FILE_EXTENSIONS.has(ext))
309
+ if (isSecretFileName(entry.name)) {
310
+ c.skipped.push((prefix ? `${prefix}/` : "") + entry.name + " (secret)");
254
311
  continue;
312
+ }
255
313
  const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
314
+ if (isIgnored(relativePath, entry.isDirectory(), c.patterns))
315
+ continue;
256
316
  if (entry.isDirectory()) {
257
- files.push(...collectSourceFiles(path.join(dir, entry.name), relativePath));
317
+ files.push(...collectSourceFiles(path.join(dir, entry.name), relativePath, c));
258
318
  }
259
319
  else {
260
320
  files.push(relativePath);
261
321
  }
262
322
  }
323
+ // Surface what was withheld so a deploy is never silently lossy on secrets.
324
+ if (!ctx && c.skipped.length > 0) {
325
+ console.log(` Skipped ${c.skipped.length} sensitive/symlinked file(s): ${c.skipped
326
+ .slice(0, 10)
327
+ .join(", ")}${c.skipped.length > 10 ? ", …" : ""}`);
328
+ }
263
329
  return files;
264
330
  }
265
331
  function extractZip(zipBuffer, targetDir) {
@@ -325,9 +391,16 @@ async function deploy() {
325
391
  }
326
392
  const builtFiles = collectFiles(distDir, "");
327
393
  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.`);
394
+ // Collect source files (skipped entirely with --no-source). Source is saved
395
+ // for versioning/restore; symlinks and secrets are always excluded.
396
+ const noSource = process.argv.includes("--no-source");
397
+ const sourceFiles = noSource ? [] : collectSourceFiles(process.cwd(), "");
398
+ if (noSource) {
399
+ console.log(" Skipping source upload (--no-source).");
400
+ }
401
+ else {
402
+ console.log(` Found ${sourceFiles.length} source files.`);
403
+ }
331
404
  // Upload via deploy API
332
405
  console.log(" Uploading...");
333
406
  const formData = new FormData();
@@ -1063,7 +1136,7 @@ async function loadAnalysisConfig() {
1063
1136
  Authorization: `Bearer ${deviceToken}`,
1064
1137
  "Content-Type": "application/json",
1065
1138
  },
1066
- body: JSON.stringify({ name: `cli-${Date.now()}` }),
1139
+ body: JSON.stringify({ name: cliKeyName() }),
1067
1140
  });
1068
1141
  if (keyGenRes.ok) {
1069
1142
  const data = (await keyGenRes.json());
@@ -1300,11 +1373,52 @@ function syncClaudeMdImports(targetDir) {
1300
1373
  fs.writeFileSync(claudeMdPath, updated);
1301
1374
  return true;
1302
1375
  }
1376
+ function platformMetaPath(targetDir) {
1377
+ return path.join(targetDir, ".claude", ".platform-meta.json");
1378
+ }
1379
+ function readPlatformMeta(targetDir) {
1380
+ const p = platformMetaPath(targetDir);
1381
+ if (!fs.existsSync(p))
1382
+ return { version: 1, last_synced: {} };
1383
+ try {
1384
+ const parsed = JSON.parse(fs.readFileSync(p, "utf-8"));
1385
+ if (parsed?.version === 1 && parsed.last_synced)
1386
+ return parsed;
1387
+ }
1388
+ catch {
1389
+ // fall through
1390
+ }
1391
+ return { version: 1, last_synced: {} };
1392
+ }
1393
+ function writePlatformMeta(targetDir, meta) {
1394
+ const p = platformMetaPath(targetDir);
1395
+ fs.mkdirSync(path.dirname(p), { recursive: true });
1396
+ fs.writeFileSync(p, JSON.stringify(meta, null, 2) + "\n");
1397
+ }
1398
+ function md5(s) {
1399
+ return createHash("md5").update(s).digest("hex");
1400
+ }
1401
+ function decidePlatformWrite(fullPath, serverContent, serverHash, lastSyncedHash, force) {
1402
+ if (force)
1403
+ return { action: "write", reason: "force" };
1404
+ if (!fs.existsSync(fullPath))
1405
+ return { action: "write", reason: "new" };
1406
+ const localContent = fs.readFileSync(fullPath, "utf-8");
1407
+ if (localContent === serverContent)
1408
+ return { action: "skip", reason: "unchanged" };
1409
+ const localHash = md5(localContent);
1410
+ if (lastSyncedHash && localHash === lastSyncedHash) {
1411
+ return { action: "write", reason: "clean-update" };
1412
+ }
1413
+ return { action: "skip", reason: "user-modified" };
1414
+ }
1303
1415
  /**
1304
1416
  * Fetch the latest platform-owned Claude Code rules from the Hub and write
1305
1417
  * them into the project's .claude/ directory. Called from `deploy` (before
1306
1418
  * uploading), `pull` (after extracting), and `syncHubManagedRules` (analysis
1307
1419
  * paths). User-owned files (knowledge/, this-dashboard.md) are NOT touched.
1420
+ *
1421
+ * Preserves user edits via .claude/.platform-meta.json hash tracking.
1308
1422
  */
1309
1423
  async function syncPlatformRules(targetDir, baseUrl, apiKey) {
1310
1424
  try {
@@ -1316,17 +1430,39 @@ async function syncPlatformRules(targetDir, baseUrl, apiKey) {
1316
1430
  const data = (await res.json());
1317
1431
  if (!data.files)
1318
1432
  return;
1319
- let count = 0;
1433
+ const meta = readPlatformMeta(targetDir);
1434
+ const written = [];
1435
+ const preserved = [];
1320
1436
  for (const [relPath, content] of Object.entries(data.files)) {
1321
1437
  const fullPath = path.join(targetDir, ".claude", relPath);
1322
- fs.mkdirSync(path.dirname(fullPath), { recursive: true });
1323
- fs.writeFileSync(fullPath, content);
1324
- count++;
1438
+ const serverHash = data.hashes?.[relPath] ?? md5(content);
1439
+ const lastSyncedHash = meta.last_synced[relPath] ?? null;
1440
+ const decision = decidePlatformWrite(fullPath, content, serverHash, lastSyncedHash, false);
1441
+ if (decision.action === "write") {
1442
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
1443
+ fs.writeFileSync(fullPath, content);
1444
+ meta.last_synced[relPath] = serverHash;
1445
+ written.push(relPath);
1446
+ }
1447
+ else if (decision.reason === "unchanged") {
1448
+ // Bootstrap: record hash even if we didn't write, so future
1449
+ // user-edits are detected against the right baseline.
1450
+ meta.last_synced[relPath] = serverHash;
1451
+ }
1452
+ else {
1453
+ preserved.push(relPath);
1454
+ }
1325
1455
  }
1456
+ writePlatformMeta(targetDir, meta);
1326
1457
  const claudeUpdated = syncClaudeMdImports(targetDir);
1327
- const total = count + (claudeUpdated ? 1 : 0);
1328
- if (total > 0) {
1329
- console.log(` Platform rules synced (${count} files${claudeUpdated ? " + CLAUDE.md updated" : ""}).`);
1458
+ if (written.length > 0 || claudeUpdated) {
1459
+ console.log(` Platform rules synced (${written.length} file${written.length === 1 ? "" : "s"}${claudeUpdated ? " + CLAUDE.md updated" : ""}).`);
1460
+ }
1461
+ if (preserved.length > 0) {
1462
+ console.log(` Preserved ${preserved.length} locally-modified file${preserved.length === 1 ? "" : "s"} (Hub updates skipped):`);
1463
+ for (const f of preserved)
1464
+ console.log(` ${f}`);
1465
+ console.log(` To accept Hub updates and discard your local edits, run: npx @overscore/cli platform update --force`);
1330
1466
  }
1331
1467
  }
1332
1468
  catch {
@@ -1338,13 +1474,16 @@ async function syncPlatformRules(targetDir, baseUrl, apiKey) {
1338
1474
  * can tell the user what's new. Called by `npx @overscore/cli platform update`.
1339
1475
  *
1340
1476
  * With `--check`: read-only mode. Reports what *would* change without writing.
1477
+ * With `--force`: discard local edits and accept the Hub version verbatim.
1341
1478
  */
1342
- async function platformUpdate(checkOnly = false) {
1479
+ async function platformUpdate(checkOnly = false, force = false) {
1343
1480
  const { apiUrl, apiKey } = await loadEnv();
1344
1481
  const baseUrl = apiUrl.replace(/\/api$/, "");
1345
1482
  console.log(checkOnly
1346
1483
  ? "\n Checking platform rules (no changes will be written)...\n"
1347
- : "\n Syncing platform rules from Hub...\n");
1484
+ : force
1485
+ ? "\n Syncing platform rules from Hub (--force: local edits will be overwritten)...\n"
1486
+ : "\n Syncing platform rules from Hub...\n");
1348
1487
  const capPath = path.resolve(process.cwd(), ".claude", "platform-capabilities.md");
1349
1488
  const prevContent = fs.existsSync(capPath) ? fs.readFileSync(capPath, "utf-8") : null;
1350
1489
  const prevVersion = prevContent?.match(/^version:\s*(.+)$/m)?.[1]?.trim() ?? null;
@@ -1360,18 +1499,34 @@ async function platformUpdate(checkOnly = false) {
1360
1499
  console.error("\n Error: No files in platform-rules response.\n");
1361
1500
  process.exit(1);
1362
1501
  }
1502
+ const meta = readPlatformMeta(process.cwd());
1363
1503
  const updated = [];
1504
+ const preserved = [];
1364
1505
  for (const [relPath, content] of Object.entries(data.files)) {
1365
1506
  const fullPath = path.join(process.cwd(), ".claude", relPath);
1366
- const existing = fs.existsSync(fullPath) ? fs.readFileSync(fullPath, "utf-8") : null;
1367
- if (existing !== content) {
1507
+ const serverHash = data.hashes?.[relPath] ?? md5(content);
1508
+ const lastSyncedHash = meta.last_synced[relPath] ?? null;
1509
+ const decision = decidePlatformWrite(fullPath, content, serverHash, lastSyncedHash, force);
1510
+ if (decision.action === "write") {
1368
1511
  updated.push(relPath);
1369
1512
  if (!checkOnly) {
1370
1513
  fs.mkdirSync(path.dirname(fullPath), { recursive: true });
1371
1514
  fs.writeFileSync(fullPath, content);
1515
+ meta.last_synced[relPath] = serverHash;
1372
1516
  }
1373
1517
  }
1518
+ else if (decision.reason === "unchanged") {
1519
+ // Bootstrap: in non-check mode, record hash so future user-edits
1520
+ // are detected against the right baseline.
1521
+ if (!checkOnly)
1522
+ meta.last_synced[relPath] = serverHash;
1523
+ }
1524
+ else {
1525
+ preserved.push(relPath);
1526
+ }
1374
1527
  }
1528
+ if (!checkOnly)
1529
+ writePlatformMeta(process.cwd(), meta);
1375
1530
  // syncClaudeMdImports only makes sense if we're actually writing files.
1376
1531
  // In check mode, compute whether imports would be added by reading current state.
1377
1532
  let claudeWouldChange = false;
@@ -1393,14 +1548,26 @@ async function platformUpdate(checkOnly = false) {
1393
1548
  if (claudeUpdated)
1394
1549
  updated.push(".claude/CLAUDE.md (@imports updated)");
1395
1550
  }
1396
- if (updated.length === 0) {
1551
+ if (updated.length === 0 && preserved.length === 0) {
1397
1552
  console.log(" Already up to date — no changes.\n");
1398
1553
  return;
1399
1554
  }
1400
- const verb = checkOnly ? "Would update" : "Updated";
1401
- console.log(` ${verb} ${updated.length} file${updated.length === 1 ? "" : "s"}:\n`);
1402
- for (const f of updated)
1403
- console.log(` ${f}`);
1555
+ if (updated.length > 0) {
1556
+ const verb = checkOnly ? "Would update" : "Updated";
1557
+ console.log(` ${verb} ${updated.length} file${updated.length === 1 ? "" : "s"}:\n`);
1558
+ for (const f of updated)
1559
+ console.log(` ${f}`);
1560
+ }
1561
+ if (preserved.length > 0) {
1562
+ if (updated.length > 0)
1563
+ console.log();
1564
+ const verb = checkOnly ? "Would preserve" : "Preserved";
1565
+ console.log(` ${verb} ${preserved.length} locally-modified file${preserved.length === 1 ? "" : "s"} (Hub updates skipped):\n`);
1566
+ for (const f of preserved)
1567
+ console.log(` ${f}`);
1568
+ console.log(`\n To accept the Hub version and discard your local edits, re-run with --force:`);
1569
+ console.log(` npx @overscore/cli platform update --force`);
1570
+ }
1404
1571
  if (updated.includes("platform-capabilities.md")) {
1405
1572
  const newContent = data.files["platform-capabilities.md"];
1406
1573
  const newVersion = newContent.match(/^version:\s*(.+)$/m)?.[1]?.trim() ?? "unknown";
@@ -2421,19 +2588,23 @@ else if (command === "analysis") {
2421
2588
  else if (command === "platform") {
2422
2589
  if (subcommand === "update") {
2423
2590
  const checkOnly = process.argv.includes("--check");
2424
- platformUpdate(checkOnly);
2591
+ const force = process.argv.includes("--force");
2592
+ platformUpdate(checkOnly, force);
2425
2593
  }
2426
2594
  else {
2427
2595
  console.log(`
2428
2596
  overscore platform — Manage platform rules and capabilities
2429
2597
 
2430
2598
  Commands:
2431
- update Sync latest platform rules, skills, and capabilities from Hub
2432
- update --check Show what would change without writing anything
2599
+ update Sync latest platform rules, skills, and capabilities from Hub
2600
+ (preserves locally-modified rule files via hash tracking)
2601
+ update --check Show what would change without writing anything
2602
+ update --force Overwrite locally-modified rule files with the Hub version
2433
2603
 
2434
2604
  Examples:
2435
2605
  npx @overscore/cli platform update
2436
2606
  npx @overscore/cli platform update --check
2607
+ npx @overscore/cli platform update --force
2437
2608
  `);
2438
2609
  }
2439
2610
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@overscore/cli",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "description": "CLI for deploying Overscore dashboards and publishing analyses",
5
5
  "bin": {
6
6
  "overscore": "dist/index.js"