@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.
- package/dist/index.js +257 -19
- 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:
|
|
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
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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:
|
|
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
|
-
|
|
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]);
|