@overscore/cli 0.13.4 → 0.13.5

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