@overscore/cli 0.13.4 → 0.13.6

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 CHANGED
@@ -8,6 +8,7 @@ import { execSync } from "child_process";
8
8
  import { createInterface } from "readline";
9
9
  import { createHash, randomBytes } from "crypto";
10
10
  import yauzl from "yauzl";
11
+ import { templatePush } from "./template-push.js";
11
12
  // ── Shared helpers ──────────────────────────────────────────────────
12
13
  // Name CLI-minted API keys after the machine + date so stale keys are
13
14
  // recognizable and revocable in the Hub UI (mitigates key sprawl). Keys are
@@ -2598,8 +2599,14 @@ async function templatePull(slug) {
2598
2599
  console.error(" npx @overscore/cli template pull mrr-tracker\n");
2599
2600
  process.exit(1);
2600
2601
  }
2601
- const targetDir = path.resolve(process.cwd(), process.argv[4] || slug);
2602
- const url = `https://overscore.dev/api/templates/${slug}/source`;
2602
+ // Refs come in as handle/slug; the stored/API key is handle_slug, and the
2603
+ // extract directory is just the local slug.
2604
+ const apiSlug = slug.replace("/", "_");
2605
+ const localSlug = apiSlug.includes("_")
2606
+ ? apiSlug.slice(apiSlug.indexOf("_") + 1)
2607
+ : apiSlug;
2608
+ const targetDir = path.resolve(process.cwd(), process.argv[5] || localSlug);
2609
+ const url = `https://overscore.dev/api/templates/${apiSlug}/source`;
2603
2610
  console.log(`\n Pulling template: ${slug}...`);
2604
2611
  let res;
2605
2612
  try {
@@ -2710,12 +2717,16 @@ else if (command === "template") {
2710
2717
  if (subcommand === "pull") {
2711
2718
  templatePull(process.argv[4]);
2712
2719
  }
2720
+ else if (subcommand === "push") {
2721
+ templatePush(process.argv[4]);
2722
+ }
2713
2723
  else {
2714
2724
  console.log(`
2715
2725
  overscore template — Manage marketplace templates
2716
2726
 
2717
2727
  Commands:
2718
2728
  pull <slug> [dir] Download and extract a template
2729
+ push [slug] Build the current directory and push it back to your template
2719
2730
 
2720
2731
  Examples:
2721
2732
  npx @overscore/cli template pull mrr-tracker
@@ -0,0 +1,310 @@
1
+ /**
2
+ * `overscore template push [slug]` — build the current directory and push it
3
+ * back to YOUR marketplace template (push update channel). The Hub replaces
4
+ * the template's frozen built files + pinned source.zip in place; no project,
5
+ * no dashboard, and no API key are involved — auth is the device token and
6
+ * ownership is checked server-side.
7
+ *
8
+ * Self-contained on purpose: index.ts is a single hand-rolled router and this
9
+ * module duplicates the few helpers it needs (parseEnv, file collectors)
10
+ * rather than entangling the deploy path.
11
+ *
12
+ * ── DEFERRED HOOK (staged — do NOT wire until the push backend ships) ──────
13
+ * packages/cli/src/index.ts needs exactly these 4 lines:
14
+ *
15
+ * 1. Import, with the other imports at the top of the file (after the
16
+ * `import yauzl from "yauzl";` line, index.ts ~11). The `.js` extension is
17
+ * required — tsc emits ESM (module: ESNext) and Node only resolves
18
+ * relative ESM imports with an extension:
19
+ *
20
+ * import { templatePush } from "./template-push.js";
21
+ *
22
+ * 2-3. Router case inside the `command === "template"` block, directly after
23
+ * the `if (subcommand === "pull") { templatePull(process.argv[4]); }`
24
+ * branch (index.ts ~3338-3339), before the help `else`:
25
+ *
26
+ * } else if (subcommand === "push") {
27
+ * templatePush(process.argv[4]);
28
+ *
29
+ * 4. Help line in the `overscore template` help text (index.ts ~3345, under
30
+ * the `pull <slug> [dir]` line):
31
+ *
32
+ * push [slug] Build the current directory and push it back to your template
33
+ */
34
+ import fs from "fs";
35
+ import path from "path";
36
+ import { execSync } from "child_process";
37
+ const HUB_URL = "https://overscore.dev";
38
+ // Mirrors the templates_slug_format CHECK in the marketplace migration.
39
+ // `_` is the handle namespace delimiter in the stored slug ({handle}_{local}).
40
+ const TEMPLATE_SLUG_RE = /^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$/;
41
+ function isValidTemplateSlug(slug) {
42
+ return (slug.length >= 1 &&
43
+ slug.length <= 100 &&
44
+ !slug.includes("--") &&
45
+ TEMPLATE_SLUG_RE.test(slug));
46
+ }
47
+ // Duplicated from index.ts — parses the KEY=VALUE lines of ~/.overscore/config.
48
+ function parseEnv(content) {
49
+ const env = {};
50
+ for (const line of content.split("\n")) {
51
+ const trimmed = line.trim();
52
+ if (!trimmed || trimmed.startsWith("#"))
53
+ continue;
54
+ const eqIndex = trimmed.indexOf("=");
55
+ if (eqIndex === -1)
56
+ continue;
57
+ env[trimmed.slice(0, eqIndex)] = trimmed.slice(eqIndex + 1);
58
+ }
59
+ return env;
60
+ }
61
+ function readDeviceToken() {
62
+ const configPath = path.join(process.env.HOME || "", ".overscore", "config");
63
+ if (!fs.existsSync(configPath))
64
+ return null;
65
+ const cfg = parseEnv(fs.readFileSync(configPath, "utf-8"));
66
+ return cfg.OVERSCORE_DEVICE_TOKEN || null;
67
+ }
68
+ /** Slug fallback: `template pull` records it in .overscore/template.json. */
69
+ function readPulledTemplateSlug() {
70
+ const metaPath = path.resolve(process.cwd(), ".overscore", "template.json");
71
+ if (!fs.existsSync(metaPath))
72
+ return null;
73
+ try {
74
+ const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8"));
75
+ return typeof meta.template_slug === "string" ? meta.template_slug : null;
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }
81
+ // Duplicated from index.ts — plain recursive walk of dist/ (built output is
82
+ // machine-generated; no secret filtering needed there).
83
+ function collectFiles(dir, prefix) {
84
+ const files = [];
85
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
86
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
87
+ if (entry.isDirectory()) {
88
+ files.push(...collectFiles(path.join(dir, entry.name), relativePath));
89
+ }
90
+ else {
91
+ files.push(relativePath);
92
+ }
93
+ }
94
+ return files;
95
+ }
96
+ // ── Safe source collector (duplicated guards from the deploy path) ──────────
97
+ // Excludes machine state, build output, and anything secret-shaped, and NEVER
98
+ // follows symlinks (a symlink could point at ~/.ssh/id_rsa or outside the
99
+ // project). v1 limitation, on purpose: `.overscoreignore` is NOT honored here
100
+ // — templates ship mock data only, so there should be nothing project-
101
+ // specific to hide beyond these baseline exclusions.
102
+ const SOURCE_EXCLUDE = new Set([
103
+ "node_modules",
104
+ "dist",
105
+ ".git",
106
+ ".DS_Store",
107
+ ".aws",
108
+ ".ssh",
109
+ ".kube",
110
+ ".docker",
111
+ ".gnupg",
112
+ ".overscore",
113
+ // Raw exploratory query results — never ship real warehouse rows.
114
+ "data",
115
+ ]);
116
+ const SECRET_FILE_EXTENSIONS = new Set([
117
+ ".pem",
118
+ ".key",
119
+ ".p12",
120
+ ".pfx",
121
+ ".jks",
122
+ ".keystore",
123
+ ".p8",
124
+ ".ppk",
125
+ ".gpg",
126
+ ".asc",
127
+ ".tfstate",
128
+ ".tfvars",
129
+ ]);
130
+ const SECRET_FILE_NAMES = new Set([
131
+ "service-account.json",
132
+ "service_account.json",
133
+ "serviceaccount.json",
134
+ "gcp-credentials.json",
135
+ "credentials.json",
136
+ "id_rsa",
137
+ "id_dsa",
138
+ "id_ecdsa",
139
+ "id_ed25519",
140
+ ".netrc",
141
+ ".pgpass",
142
+ ".htpasswd",
143
+ "secrets.json",
144
+ "secrets.yaml",
145
+ "secrets.yml",
146
+ ]);
147
+ function isSecretFileName(name) {
148
+ const lower = name.toLowerCase();
149
+ if (SECRET_FILE_NAMES.has(lower))
150
+ return true;
151
+ const ext = lower.substring(lower.lastIndexOf("."));
152
+ if (SECRET_FILE_EXTENSIONS.has(ext))
153
+ return true;
154
+ if (lower.includes(".tfstate"))
155
+ return true;
156
+ return false;
157
+ }
158
+ function collectTemplateSourceFiles(dir, prefix, skipped) {
159
+ const s = skipped ?? [];
160
+ const files = [];
161
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
162
+ if (entry.isSymbolicLink()) {
163
+ s.push((prefix ? `${prefix}/` : "") + entry.name + " (symlink)");
164
+ continue;
165
+ }
166
+ if (SOURCE_EXCLUDE.has(entry.name))
167
+ continue;
168
+ if (entry.name.startsWith(".env"))
169
+ continue; // .env, .env.local, …
170
+ if (entry.name.endsWith(".log"))
171
+ continue;
172
+ if (isSecretFileName(entry.name)) {
173
+ s.push((prefix ? `${prefix}/` : "") + entry.name + " (secret)");
174
+ continue;
175
+ }
176
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
177
+ if (entry.isDirectory()) {
178
+ files.push(...collectTemplateSourceFiles(path.join(dir, entry.name), relativePath, s)
179
+ .files);
180
+ }
181
+ else {
182
+ files.push(relativePath);
183
+ }
184
+ }
185
+ return { files, skipped: s };
186
+ }
187
+ // ── Command ──────────────────────────────────────────────────────────────────
188
+ export async function templatePush(slugArg) {
189
+ // Refs come in as handle/slug; the stored/API key is handle_slug. A pulled
190
+ // template's .overscore/template.json already stores the handle_slug form.
191
+ const rawSlug = slugArg || readPulledTemplateSlug();
192
+ const slug = rawSlug ? rawSlug.replace("/", "_") : null;
193
+ if (!slug) {
194
+ console.error(`
195
+ Usage: npx @overscore/cli template push [template-slug]
196
+
197
+ Run it from a pulled template directory (.overscore/template.json supplies
198
+ the slug automatically) or pass the slug explicitly.
199
+
200
+ Example:
201
+ npx @overscore/cli template push mrr-tracker
202
+ `);
203
+ process.exit(1);
204
+ }
205
+ if (!isValidTemplateSlug(slug)) {
206
+ console.error(`\n Error: "${slug}" is not a valid template slug.\n`);
207
+ process.exit(1);
208
+ }
209
+ const deviceToken = readDeviceToken();
210
+ if (!deviceToken) {
211
+ console.error(`
212
+ No Overscore credentials found. Sign in first:
213
+
214
+ npx @overscore/cli auth login
215
+ `);
216
+ process.exit(1);
217
+ }
218
+ console.log(`\n Pushing template: ${slug}\n`);
219
+ // Build — same guard as deploy: blank the API key env so a local dev key
220
+ // can never end up baked into the published bundle.
221
+ console.log(" Building...");
222
+ try {
223
+ execSync("npm run build", {
224
+ stdio: "inherit",
225
+ env: { ...process.env, VITE_OVERSCORE_API_KEY: "" },
226
+ });
227
+ }
228
+ catch {
229
+ console.error("\n Build failed. Fix errors and try again.");
230
+ process.exit(1);
231
+ }
232
+ const distDir = path.resolve(process.cwd(), "dist");
233
+ if (!fs.existsSync(distDir)) {
234
+ console.error(" Error: No dist/ directory found after build.");
235
+ process.exit(1);
236
+ }
237
+ const builtFiles = collectFiles(distDir, "");
238
+ console.log(` Found ${builtFiles.length} built files.`);
239
+ const { files: sourceFiles, skipped } = collectTemplateSourceFiles(process.cwd(), "");
240
+ console.log(` Found ${sourceFiles.length} source files.`);
241
+ if (skipped.length > 0) {
242
+ // Never silently lossy on secrets — same rule as deploy.
243
+ console.log(` Skipped ${skipped.length} sensitive/symlinked file(s): ${skipped
244
+ .slice(0, 10)
245
+ .join(", ")}${skipped.length > 10 ? ", …" : ""}`);
246
+ }
247
+ console.log(" Uploading...");
248
+ const formData = new FormData();
249
+ for (const file of builtFiles) {
250
+ const content = fs.readFileSync(path.join(distDir, file));
251
+ formData.append(`file:${file}`, new Blob([content]), file);
252
+ }
253
+ for (const file of sourceFiles) {
254
+ const content = fs.readFileSync(path.join(process.cwd(), file));
255
+ formData.append(`source:${file}`, new Blob([content]), file);
256
+ }
257
+ let res;
258
+ try {
259
+ res = await fetch(`${HUB_URL}/api/templates/${slug}/push`, {
260
+ method: "POST",
261
+ headers: { Authorization: `Bearer ${deviceToken}` },
262
+ body: formData,
263
+ });
264
+ }
265
+ catch {
266
+ console.error(`\n Error: Could not reach overscore.dev\n`);
267
+ process.exit(1);
268
+ }
269
+ const data = (await res.json().catch(() => ({})));
270
+ if (!res.ok) {
271
+ const serverMsg = data.error || data.message;
272
+ if (res.status === 401) {
273
+ console.error(`
274
+ Authentication failed${serverMsg ? `: ${serverMsg}` : ""}.
275
+
276
+ Your device token may have expired — sign in again:
277
+ npx @overscore/cli auth login
278
+ `);
279
+ }
280
+ else if (res.status === 403) {
281
+ console.error(`
282
+ You don't own the template "${slug}"${serverMsg ? ` — ${serverMsg}` : "."}
283
+
284
+ Push only works on templates you published. To build on someone else's
285
+ template, pull it and publish your own listing.
286
+ `);
287
+ }
288
+ else if (res.status === 422) {
289
+ // Channel/validation errors (e.g. a dashboard-channel template that
290
+ // updates via "Refresh to the latest deploy") — the server explains
291
+ // itself; pass it through verbatim.
292
+ console.error(`\n Error: ${serverMsg || "This template can't be pushed."}\n`);
293
+ }
294
+ else {
295
+ console.error(`\n Push failed: ${serverMsg || `HTTP ${res.status}`}\n`);
296
+ }
297
+ process.exit(1);
298
+ }
299
+ const builtCount = data.built_file_count ?? builtFiles.length;
300
+ const sourceCount = sourceFiles.length;
301
+ const listingUrl = data.detail_url
302
+ ? `${HUB_URL}${data.detail_url}`
303
+ : `${HUB_URL}/explore/${slug.replace("_", "/")}`;
304
+ console.log(`
305
+ Pushed successfully!
306
+
307
+ ${builtCount} built files + ${sourceCount} source files uploaded
308
+ Listing: ${listingUrl}${data.preview_url ? `\n Preview: ${data.preview_url}` : ""}
309
+ `);
310
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@overscore/cli",
3
- "version": "0.13.4",
3
+ "version": "0.13.6",
4
4
  "description": "CLI for deploying Overscore dashboards and publishing analyses",
5
5
  "bin": {
6
6
  "overscore": "dist/index.js"