@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 +13 -2
- package/dist/template-push.js +310 -0
- package/package.json +1 -1
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
|
-
|
|
2602
|
-
|
|
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
|
+
}
|