@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.
- package/dist/index.js +199 -28
- 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:
|
|
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
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
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
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
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
|
-
:
|
|
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
|
|
1367
|
-
|
|
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
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
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
|
-
|
|
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
|
|
2432
|
-
|
|
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
|
}
|