@officexapp/catalogs-cli 0.1.0 → 0.2.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/README.md +9 -5
- package/dist/index.js +816 -76
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @officexapp/catalogs-cli
|
|
2
2
|
|
|
3
|
-
CLI for Catalog
|
|
3
|
+
CLI for Catalog Kit — upload videos, push catalog schemas, manage assets.
|
|
4
4
|
|
|
5
5
|
## Setup
|
|
6
6
|
|
|
@@ -11,15 +11,19 @@ npm install -g @officexapp/catalogs-cli
|
|
|
11
11
|
Configure authentication:
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
export
|
|
15
|
-
export CATALOGS_TOKEN="cfk_your_api_key_here"
|
|
14
|
+
export CATALOG_KIT_TOKEN="cfk_your_api_key_here"
|
|
16
15
|
```
|
|
17
16
|
|
|
18
|
-
|
|
17
|
+
The API URL defaults to `https://api.catalogkit.cc`. To override (e.g. for staging):
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
export CATALOG_KIT_API_URL="https://api-staging.catalogkit.cc"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or create `~/.catalog-kit/config.json`:
|
|
19
24
|
|
|
20
25
|
```json
|
|
21
26
|
{
|
|
22
|
-
"api_url": "https://catalog-funnel-api-staging.cloud.zoomgtm.com",
|
|
23
27
|
"token": "cfk_your_api_key_here"
|
|
24
28
|
}
|
|
25
29
|
```
|
package/dist/index.js
CHANGED
|
@@ -2,72 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
-
|
|
6
|
-
// src/commands/video-upload.ts
|
|
7
|
-
import { readFileSync as readFileSync2, statSync } from "fs";
|
|
8
|
-
import { basename } from "path";
|
|
9
|
-
import ora from "ora";
|
|
5
|
+
import { createRequire } from "module";
|
|
10
6
|
|
|
11
7
|
// src/config.ts
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
8
|
+
var DEFAULT_API_URL = "https://api.catalogkit.cc";
|
|
9
|
+
var globalTokenOverride;
|
|
10
|
+
function setGlobalToken(token) {
|
|
11
|
+
globalTokenOverride = token;
|
|
12
|
+
}
|
|
17
13
|
function getConfig() {
|
|
18
|
-
const envUrl = process.env.
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
return { api_url: envUrl, token: envToken };
|
|
22
|
-
}
|
|
23
|
-
if (existsSync(CONFIG_FILE)) {
|
|
24
|
-
try {
|
|
25
|
-
const raw = readFileSync(CONFIG_FILE, "utf-8");
|
|
26
|
-
const parsed = JSON.parse(raw);
|
|
27
|
-
return {
|
|
28
|
-
api_url: envUrl || parsed.api_url || "",
|
|
29
|
-
token: envToken || parsed.token || ""
|
|
30
|
-
};
|
|
31
|
-
} catch {
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
const dotenv = join(process.cwd(), ".env");
|
|
35
|
-
if (existsSync(dotenv)) {
|
|
36
|
-
const lines = readFileSync(dotenv, "utf-8").split("\n");
|
|
37
|
-
const env = {};
|
|
38
|
-
for (const line of lines) {
|
|
39
|
-
const match = line.match(/^([A-Z_]+)=["']?(.+?)["']?\s*$/);
|
|
40
|
-
if (match) env[match[1]] = match[2];
|
|
41
|
-
}
|
|
42
|
-
if (env.CATALOGS_API_URL || env.CATALOGS_TOKEN) {
|
|
43
|
-
return {
|
|
44
|
-
api_url: envUrl || env.CATALOGS_API_URL || "",
|
|
45
|
-
token: envToken || env.CATALOGS_TOKEN || ""
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
return {
|
|
50
|
-
api_url: envUrl || "",
|
|
51
|
-
token: envToken || ""
|
|
52
|
-
};
|
|
14
|
+
const envUrl = process.env.CATALOG_KIT_API_URL;
|
|
15
|
+
const token = globalTokenOverride || process.env.CATALOG_KIT_TOKEN || "";
|
|
16
|
+
return { api_url: envUrl || DEFAULT_API_URL, token };
|
|
53
17
|
}
|
|
54
18
|
function requireConfig() {
|
|
55
19
|
const config = getConfig();
|
|
56
|
-
if (!config.api_url) {
|
|
57
|
-
console.error(
|
|
58
|
-
"Missing API URL. Set CATALOGS_API_URL env var or create ~/.catalogs-cli/config.json"
|
|
59
|
-
);
|
|
60
|
-
process.exit(1);
|
|
61
|
-
}
|
|
62
20
|
if (!config.token) {
|
|
63
21
|
console.error(
|
|
64
|
-
|
|
22
|
+
'\nMissing auth token.\n\n Set the CATALOG_KIT_TOKEN environment variable:\n export CATALOG_KIT_TOKEN="cfk_..."\n\n Or pass --token on the command line:\n catalogs --token cfk_... catalog push schema.json\n'
|
|
65
23
|
);
|
|
66
24
|
process.exit(1);
|
|
67
25
|
}
|
|
68
26
|
return config;
|
|
69
27
|
}
|
|
70
28
|
|
|
29
|
+
// src/commands/video-upload.ts
|
|
30
|
+
import { readFileSync, statSync } from "fs";
|
|
31
|
+
import { basename } from "path";
|
|
32
|
+
import ora from "ora";
|
|
33
|
+
|
|
71
34
|
// src/api.ts
|
|
72
35
|
var ApiClient = class {
|
|
73
36
|
constructor(config) {
|
|
@@ -116,6 +79,26 @@ var ApiClient = class {
|
|
|
116
79
|
}
|
|
117
80
|
};
|
|
118
81
|
|
|
82
|
+
// src/lib/identity.ts
|
|
83
|
+
async function printIdentity(api) {
|
|
84
|
+
try {
|
|
85
|
+
const res = await api.get("/api/v1/me");
|
|
86
|
+
if (res.ok && res.data) {
|
|
87
|
+
const d = res.data;
|
|
88
|
+
const parts = [`Authenticated as ${d.subdomain || d.user_id}`];
|
|
89
|
+
if (d.email) parts.push(`(${d.email})`);
|
|
90
|
+
console.log(`
|
|
91
|
+
${parts.join(" ")}`);
|
|
92
|
+
if (d.subdomain) {
|
|
93
|
+
console.log(` Subdomain: ${d.subdomain}.catalogkit.cc`);
|
|
94
|
+
}
|
|
95
|
+
console.log();
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
console.warn("\n Warning: Could not verify identity (API unreachable)\n");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
119
102
|
// src/commands/video-upload.ts
|
|
120
103
|
var MIME_MAP = {
|
|
121
104
|
".mp4": "video/mp4",
|
|
@@ -140,6 +123,7 @@ function formatBytes(bytes) {
|
|
|
140
123
|
async function videoUpload(file, opts) {
|
|
141
124
|
const config = requireConfig();
|
|
142
125
|
const api = new ApiClient(config);
|
|
126
|
+
await printIdentity(api);
|
|
143
127
|
const skipTranscode = opts.transcode === false;
|
|
144
128
|
let stat;
|
|
145
129
|
try {
|
|
@@ -175,7 +159,7 @@ File: ${filename} (${formatBytes(sizeBytes)})`);
|
|
|
175
159
|
}
|
|
176
160
|
const uploadSpinner = ora("Uploading to S3...").start();
|
|
177
161
|
try {
|
|
178
|
-
const fileBuffer =
|
|
162
|
+
const fileBuffer = readFileSync(file);
|
|
179
163
|
const res = await fetch(uploadUrl, {
|
|
180
164
|
method: "PUT",
|
|
181
165
|
headers: { "Content-Type": contentType },
|
|
@@ -247,7 +231,7 @@ Check status later: catalogs video status ${videoId}
|
|
|
247
231
|
`);
|
|
248
232
|
}
|
|
249
233
|
function sleep(ms) {
|
|
250
|
-
return new Promise((
|
|
234
|
+
return new Promise((resolve4) => setTimeout(resolve4, ms));
|
|
251
235
|
}
|
|
252
236
|
|
|
253
237
|
// src/commands/video-status.ts
|
|
@@ -280,24 +264,353 @@ async function videoStatus(videoId) {
|
|
|
280
264
|
|
|
281
265
|
// src/commands/catalog-push.ts
|
|
282
266
|
import { readFileSync as readFileSync3 } from "fs";
|
|
267
|
+
import { resolve as resolve2, extname as extname2, dirname } from "path";
|
|
268
|
+
import { pathToFileURL } from "url";
|
|
283
269
|
import ora3 from "ora";
|
|
270
|
+
|
|
271
|
+
// src/lib/serialize.ts
|
|
272
|
+
var MAX_CATALOG_SIZE_KB = 512;
|
|
273
|
+
function serializeCatalog(obj) {
|
|
274
|
+
if (obj === null || obj === void 0) return obj;
|
|
275
|
+
if (typeof obj === "function") {
|
|
276
|
+
return obj.toString();
|
|
277
|
+
}
|
|
278
|
+
if (Array.isArray(obj)) {
|
|
279
|
+
return obj.map((item) => serializeCatalog(item));
|
|
280
|
+
}
|
|
281
|
+
if (typeof obj === "object") {
|
|
282
|
+
const result = {};
|
|
283
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
284
|
+
result[key] = serializeCatalog(value);
|
|
285
|
+
}
|
|
286
|
+
return result;
|
|
287
|
+
}
|
|
288
|
+
return obj;
|
|
289
|
+
}
|
|
290
|
+
function validateCatalog(schema) {
|
|
291
|
+
const errors = [];
|
|
292
|
+
if (!schema.slug) {
|
|
293
|
+
errors.push("Schema must have a 'slug' field");
|
|
294
|
+
}
|
|
295
|
+
if (!schema.schema_version) {
|
|
296
|
+
errors.push("Schema must have a 'schema_version' field");
|
|
297
|
+
}
|
|
298
|
+
if (!schema.settings?.theme) {
|
|
299
|
+
errors.push("Schema must have 'settings.theme'");
|
|
300
|
+
}
|
|
301
|
+
if (!schema.pages || Object.keys(schema.pages).length === 0) {
|
|
302
|
+
errors.push("Schema must have at least one page in 'pages'");
|
|
303
|
+
}
|
|
304
|
+
if (!schema.routing) {
|
|
305
|
+
errors.push("Schema must have a 'routing' object");
|
|
306
|
+
}
|
|
307
|
+
const json = JSON.stringify(schema);
|
|
308
|
+
const sizeKB = Math.round(json.length / 1024);
|
|
309
|
+
if (sizeKB > MAX_CATALOG_SIZE_KB) {
|
|
310
|
+
errors.push(
|
|
311
|
+
`Catalog too large (${sizeKB}KB). Max: ${MAX_CATALOG_SIZE_KB}KB. Move heavy assets to settings.scripts[] CDN imports.`
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
return errors;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// src/lib/resolve-assets.ts
|
|
318
|
+
import { existsSync, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
|
|
319
|
+
import { resolve, join, extname, basename as basename2 } from "path";
|
|
320
|
+
var FILE_PROPS = /* @__PURE__ */ new Set(["src", "poster", "hls_url", "href", "link"]);
|
|
321
|
+
var IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".avif", ".ico"]);
|
|
322
|
+
var VIDEO_EXTS = /* @__PURE__ */ new Set([".mp4", ".mov", ".webm", ".avi", ".mkv", ".m4v", ".wmv", ".flv"]);
|
|
323
|
+
function isLocalRef(value) {
|
|
324
|
+
if (!value || typeof value !== "string") return false;
|
|
325
|
+
if (value.startsWith("http://") || value.startsWith("https://") || value.startsWith("//")) return false;
|
|
326
|
+
if (value.startsWith("data:")) return false;
|
|
327
|
+
if (value.startsWith("{{")) return false;
|
|
328
|
+
return value.startsWith("./") || value.startsWith("../") || !value.startsWith("/");
|
|
329
|
+
}
|
|
330
|
+
function rewriteHtmlLocalRefs(html, catalogDir, baseUrl) {
|
|
331
|
+
return html.replace(
|
|
332
|
+
/(\bsrc\s*=\s*)(["'])(\.\.?\/[^"']+)\2/gi,
|
|
333
|
+
(match, prefix, quote, path) => {
|
|
334
|
+
if (isLocalRef(path)) {
|
|
335
|
+
const fullPath = resolve(join(catalogDir, path));
|
|
336
|
+
if (existsSync(fullPath)) {
|
|
337
|
+
const relativePath = fullPath.slice(resolve(catalogDir).length + 1);
|
|
338
|
+
return `${prefix}${quote}${baseUrl}/${relativePath}${quote}`;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return match;
|
|
342
|
+
}
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
function resolveLocalAssets(schema, catalogDir, baseUrl) {
|
|
346
|
+
return walkAndRewrite(schema, catalogDir, (localPath) => {
|
|
347
|
+
const relativePath = localPath.slice(resolve(catalogDir).length + 1);
|
|
348
|
+
return `${baseUrl}/${relativePath}`;
|
|
349
|
+
}, baseUrl);
|
|
350
|
+
}
|
|
351
|
+
async function uploadLocalAssets(schema, catalogDir, api, onProgress) {
|
|
352
|
+
const localRefs = /* @__PURE__ */ new Map();
|
|
353
|
+
collectLocalRefs(schema, catalogDir, localRefs);
|
|
354
|
+
collectHtmlLocalRefs(schema, catalogDir, localRefs);
|
|
355
|
+
if (localRefs.size === 0) {
|
|
356
|
+
return { schema, uploaded: 0, errors: [] };
|
|
357
|
+
}
|
|
358
|
+
const uploadMap = /* @__PURE__ */ new Map();
|
|
359
|
+
const errors = [];
|
|
360
|
+
let uploaded = 0;
|
|
361
|
+
for (const localPath of localRefs.keys()) {
|
|
362
|
+
const ext = extname(localPath).toLowerCase();
|
|
363
|
+
const filename = basename2(localPath);
|
|
364
|
+
try {
|
|
365
|
+
if (IMAGE_EXTS.has(ext)) {
|
|
366
|
+
onProgress?.(`Uploading image: ${filename}`);
|
|
367
|
+
const url = await uploadImage(localPath, filename, api);
|
|
368
|
+
uploadMap.set(localPath, url);
|
|
369
|
+
uploaded++;
|
|
370
|
+
} else if (VIDEO_EXTS.has(ext)) {
|
|
371
|
+
onProgress?.(`Uploading video: ${filename}`);
|
|
372
|
+
const url = await uploadVideo(localPath, filename, api);
|
|
373
|
+
uploadMap.set(localPath, url);
|
|
374
|
+
uploaded++;
|
|
375
|
+
} else {
|
|
376
|
+
onProgress?.(`Uploading file: ${filename}`);
|
|
377
|
+
const url = await uploadFile(localPath, filename, api);
|
|
378
|
+
uploadMap.set(localPath, url);
|
|
379
|
+
uploaded++;
|
|
380
|
+
}
|
|
381
|
+
} catch (err) {
|
|
382
|
+
errors.push(`${filename}: ${err.message}`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const resolved = walkAndRewrite(schema, catalogDir, (localPath) => {
|
|
386
|
+
return uploadMap.get(localPath) || localPath;
|
|
387
|
+
});
|
|
388
|
+
return { schema: resolved, uploaded, errors };
|
|
389
|
+
}
|
|
390
|
+
function walkAndRewrite(obj, catalogDir, resolver, htmlBaseUrl) {
|
|
391
|
+
if (obj === null || obj === void 0) return obj;
|
|
392
|
+
if (typeof obj === "string") return obj;
|
|
393
|
+
if (Array.isArray(obj)) {
|
|
394
|
+
return obj.map((item) => walkAndRewrite(item, catalogDir, resolver, htmlBaseUrl));
|
|
395
|
+
}
|
|
396
|
+
if (typeof obj === "object") {
|
|
397
|
+
const result = {};
|
|
398
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
399
|
+
if (typeof value === "string" && FILE_PROPS.has(key) && isLocalRef(value)) {
|
|
400
|
+
const localPath = resolve(join(catalogDir, value));
|
|
401
|
+
if (existsSync(localPath)) {
|
|
402
|
+
result[key] = resolver(localPath);
|
|
403
|
+
} else {
|
|
404
|
+
result[key] = value;
|
|
405
|
+
}
|
|
406
|
+
} else if (key === "content" && typeof value === "string" && value.includes("<")) {
|
|
407
|
+
if (htmlBaseUrl) {
|
|
408
|
+
result[key] = rewriteHtmlLocalRefs(value, catalogDir, htmlBaseUrl);
|
|
409
|
+
} else {
|
|
410
|
+
result[key] = rewriteHtmlForUpload(value, catalogDir, resolver);
|
|
411
|
+
}
|
|
412
|
+
} else {
|
|
413
|
+
result[key] = walkAndRewrite(value, catalogDir, resolver, htmlBaseUrl);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return result;
|
|
417
|
+
}
|
|
418
|
+
return obj;
|
|
419
|
+
}
|
|
420
|
+
function rewriteHtmlForUpload(html, catalogDir, resolver) {
|
|
421
|
+
return html.replace(
|
|
422
|
+
/(\bsrc\s*=\s*)(["'])(\.\.?\/[^"']+)\2/gi,
|
|
423
|
+
(match, prefix, quote, path) => {
|
|
424
|
+
if (isLocalRef(path)) {
|
|
425
|
+
const fullPath = resolve(join(catalogDir, path));
|
|
426
|
+
if (existsSync(fullPath)) {
|
|
427
|
+
const resolved = resolver(fullPath);
|
|
428
|
+
return `${prefix}${quote}${resolved}${quote}`;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return match;
|
|
432
|
+
}
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
function collectLocalRefs(obj, catalogDir, refs) {
|
|
436
|
+
if (obj === null || obj === void 0 || typeof obj !== "object") return;
|
|
437
|
+
if (Array.isArray(obj)) {
|
|
438
|
+
for (const item of obj) collectLocalRefs(item, catalogDir, refs);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
442
|
+
if (typeof value === "string" && FILE_PROPS.has(key) && isLocalRef(value)) {
|
|
443
|
+
const localPath = resolve(join(catalogDir, value));
|
|
444
|
+
if (existsSync(localPath)) {
|
|
445
|
+
refs.set(localPath, "");
|
|
446
|
+
}
|
|
447
|
+
} else if (typeof value === "object") {
|
|
448
|
+
collectLocalRefs(value, catalogDir, refs);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
function collectHtmlLocalRefs(obj, catalogDir, refs) {
|
|
453
|
+
if (obj === null || obj === void 0 || typeof obj !== "object") return;
|
|
454
|
+
if (Array.isArray(obj)) {
|
|
455
|
+
for (const item of obj) collectHtmlLocalRefs(item, catalogDir, refs);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
459
|
+
if (key === "content" && typeof value === "string" && value.includes("<")) {
|
|
460
|
+
const matches = value.matchAll(/\bsrc\s*=\s*["'](\.\.?\/[^"']+)["']/gi);
|
|
461
|
+
for (const m of matches) {
|
|
462
|
+
const path = m[1];
|
|
463
|
+
if (isLocalRef(path)) {
|
|
464
|
+
const localPath = resolve(join(catalogDir, path));
|
|
465
|
+
if (existsSync(localPath)) {
|
|
466
|
+
refs.set(localPath, "");
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
} else if (typeof value === "object") {
|
|
471
|
+
collectHtmlLocalRefs(value, catalogDir, refs);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
async function uploadImage(localPath, filename, api) {
|
|
476
|
+
const ext = extname(filename).toLowerCase();
|
|
477
|
+
const contentType = {
|
|
478
|
+
".png": "image/png",
|
|
479
|
+
".jpg": "image/jpeg",
|
|
480
|
+
".jpeg": "image/jpeg",
|
|
481
|
+
".gif": "image/gif",
|
|
482
|
+
".svg": "image/svg+xml",
|
|
483
|
+
".webp": "image/webp",
|
|
484
|
+
".avif": "image/avif",
|
|
485
|
+
".ico": "image/x-icon"
|
|
486
|
+
}[ext] || "image/png";
|
|
487
|
+
const sizeBytes = statSync2(localPath).size;
|
|
488
|
+
const res = await api.post("/api/v1/images/upload", {
|
|
489
|
+
filename,
|
|
490
|
+
content_type: contentType,
|
|
491
|
+
size_bytes: sizeBytes
|
|
492
|
+
});
|
|
493
|
+
const uploadUrl = res.data.upload_url;
|
|
494
|
+
const compressedUrl = res.data.compressed_url;
|
|
495
|
+
const originalUrl = res.data.original_url;
|
|
496
|
+
const fileBuffer = readFileSync2(localPath);
|
|
497
|
+
const putRes = await fetch(uploadUrl, {
|
|
498
|
+
method: "PUT",
|
|
499
|
+
headers: { "Content-Type": contentType },
|
|
500
|
+
body: fileBuffer
|
|
501
|
+
});
|
|
502
|
+
if (!putRes.ok) throw new Error(`S3 upload failed: ${putRes.status}`);
|
|
503
|
+
return compressedUrl || originalUrl;
|
|
504
|
+
}
|
|
505
|
+
async function uploadVideo(localPath, filename, api) {
|
|
506
|
+
const ext = extname(filename).toLowerCase();
|
|
507
|
+
const contentType = {
|
|
508
|
+
".mp4": "video/mp4",
|
|
509
|
+
".mov": "video/quicktime",
|
|
510
|
+
".webm": "video/webm",
|
|
511
|
+
".avi": "video/x-msvideo",
|
|
512
|
+
".mkv": "video/x-matroska",
|
|
513
|
+
".m4v": "video/x-m4v",
|
|
514
|
+
".wmv": "video/x-ms-wmv",
|
|
515
|
+
".flv": "video/x-flv"
|
|
516
|
+
}[ext] || "video/mp4";
|
|
517
|
+
const sizeBytes = statSync2(localPath).size;
|
|
518
|
+
const res = await api.post("/api/v1/videos/upload", {
|
|
519
|
+
filename,
|
|
520
|
+
content_type: contentType,
|
|
521
|
+
size_bytes: sizeBytes
|
|
522
|
+
});
|
|
523
|
+
const uploadUrl = res.data.upload_url;
|
|
524
|
+
const videoId = res.data.video_id;
|
|
525
|
+
const fileBuffer = readFileSync2(localPath);
|
|
526
|
+
const putRes = await fetch(uploadUrl, {
|
|
527
|
+
method: "PUT",
|
|
528
|
+
headers: { "Content-Type": contentType },
|
|
529
|
+
body: fileBuffer
|
|
530
|
+
});
|
|
531
|
+
if (!putRes.ok) throw new Error(`S3 upload failed: ${putRes.status}`);
|
|
532
|
+
try {
|
|
533
|
+
const transcodeRes = await api.post(`/api/v1/videos/${videoId}/transcode`);
|
|
534
|
+
if (transcodeRes.data?.hls_url) {
|
|
535
|
+
return transcodeRes.data.hls_url;
|
|
536
|
+
}
|
|
537
|
+
} catch {
|
|
538
|
+
}
|
|
539
|
+
return res.data.video_url || res.data.original_url || `video:${videoId}`;
|
|
540
|
+
}
|
|
541
|
+
async function uploadFile(localPath, filename, api) {
|
|
542
|
+
const sizeBytes = statSync2(localPath).size;
|
|
543
|
+
const res = await api.post("/api/v1/files/upload", {
|
|
544
|
+
filename,
|
|
545
|
+
size_bytes: sizeBytes
|
|
546
|
+
});
|
|
547
|
+
const uploadUrl = res.data.upload_url;
|
|
548
|
+
const downloadUrl = res.data.download_url || res.data.url;
|
|
549
|
+
const fileBuffer = readFileSync2(localPath);
|
|
550
|
+
const putRes = await fetch(uploadUrl, {
|
|
551
|
+
method: "PUT",
|
|
552
|
+
body: fileBuffer
|
|
553
|
+
});
|
|
554
|
+
if (!putRes.ok) throw new Error(`S3 upload failed: ${putRes.status}`);
|
|
555
|
+
return downloadUrl;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// src/commands/catalog-push.ts
|
|
559
|
+
async function loadTsFile(file) {
|
|
560
|
+
const abs = resolve2(file);
|
|
561
|
+
const { register } = await import("module");
|
|
562
|
+
register("tsx/esm", pathToFileURL("./"));
|
|
563
|
+
const mod = await import(pathToFileURL(abs).href);
|
|
564
|
+
return mod.default ?? mod;
|
|
565
|
+
}
|
|
284
566
|
async function catalogPush(file, opts) {
|
|
285
567
|
const config = requireConfig();
|
|
286
568
|
const api = new ApiClient(config);
|
|
569
|
+
await printIdentity(api);
|
|
570
|
+
const ext = extname2(file).toLowerCase();
|
|
571
|
+
const isTs = ext === ".ts" || ext === ".mts";
|
|
287
572
|
let schema;
|
|
288
573
|
try {
|
|
289
|
-
|
|
290
|
-
|
|
574
|
+
if (isTs) {
|
|
575
|
+
const rawCatalog = await loadTsFile(file);
|
|
576
|
+
schema = serializeCatalog(rawCatalog);
|
|
577
|
+
} else {
|
|
578
|
+
const raw = readFileSync3(file, "utf-8");
|
|
579
|
+
schema = JSON.parse(raw);
|
|
580
|
+
}
|
|
291
581
|
} catch (err) {
|
|
292
582
|
console.error(`Failed to read ${file}: ${err.message}`);
|
|
293
583
|
process.exit(1);
|
|
294
584
|
}
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
585
|
+
const errors = validateCatalog(schema);
|
|
586
|
+
if (errors.length > 0) {
|
|
587
|
+
for (const err of errors) {
|
|
588
|
+
console.error(`Error: ${err}`);
|
|
589
|
+
}
|
|
299
590
|
process.exit(1);
|
|
300
591
|
}
|
|
592
|
+
const catalogDir = dirname(resolve2(file));
|
|
593
|
+
const assetSpinner = ora3("Checking for local assets...").start();
|
|
594
|
+
try {
|
|
595
|
+
const result = await uploadLocalAssets(schema, catalogDir, api, (msg) => {
|
|
596
|
+
assetSpinner.text = msg;
|
|
597
|
+
});
|
|
598
|
+
schema = result.schema;
|
|
599
|
+
if (result.uploaded > 0) {
|
|
600
|
+
assetSpinner.succeed(`Uploaded ${result.uploaded} local asset(s) to CDN`);
|
|
601
|
+
} else {
|
|
602
|
+
assetSpinner.succeed("No local assets to upload");
|
|
603
|
+
}
|
|
604
|
+
if (result.errors.length > 0) {
|
|
605
|
+
for (const err of result.errors) {
|
|
606
|
+
console.error(` Warning: ${err}`);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
} catch (err) {
|
|
610
|
+
assetSpinner.warn(`Asset upload failed: ${err.message} (continuing with push)`);
|
|
611
|
+
}
|
|
612
|
+
const slug = schema.slug;
|
|
613
|
+
const name = schema.catalog_id || schema.slug || file;
|
|
301
614
|
const status = opts.publish ? "published" : "draft";
|
|
302
615
|
const spinner = ora3(`Pushing catalog "${slug}"...`).start();
|
|
303
616
|
try {
|
|
@@ -364,16 +677,411 @@ async function catalogList() {
|
|
|
364
677
|
}
|
|
365
678
|
}
|
|
366
679
|
|
|
680
|
+
// src/commands/catalog-dev.ts
|
|
681
|
+
import { resolve as resolve3, dirname as dirname2, extname as extname3, join as join2 } from "path";
|
|
682
|
+
import { existsSync as existsSync2, readFileSync as readFileSync4, statSync as statSync3, watch } from "fs";
|
|
683
|
+
import { pathToFileURL as pathToFileURL2 } from "url";
|
|
684
|
+
import { createServer } from "http";
|
|
685
|
+
import ora5 from "ora";
|
|
686
|
+
var DEFAULT_PORT = 3456;
|
|
687
|
+
async function loadCatalogFile(file) {
|
|
688
|
+
const abs = resolve3(file);
|
|
689
|
+
const ext = extname3(file).toLowerCase();
|
|
690
|
+
const isTs = ext === ".ts" || ext === ".mts";
|
|
691
|
+
if (isTs) {
|
|
692
|
+
const { register } = await import("module");
|
|
693
|
+
register("tsx/esm", pathToFileURL2("./"));
|
|
694
|
+
const url = pathToFileURL2(abs).href + `?t=${Date.now()}`;
|
|
695
|
+
const mod = await import(url);
|
|
696
|
+
return serializeCatalog(mod.default ?? mod);
|
|
697
|
+
} else {
|
|
698
|
+
const raw = readFileSync4(abs, "utf-8");
|
|
699
|
+
return JSON.parse(raw);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
var MIME_TYPES = {
|
|
703
|
+
".html": "text/html",
|
|
704
|
+
".js": "application/javascript",
|
|
705
|
+
".css": "text/css",
|
|
706
|
+
".json": "application/json",
|
|
707
|
+
".png": "image/png",
|
|
708
|
+
".jpg": "image/jpeg",
|
|
709
|
+
".jpeg": "image/jpeg",
|
|
710
|
+
".gif": "image/gif",
|
|
711
|
+
".svg": "image/svg+xml",
|
|
712
|
+
".webp": "image/webp",
|
|
713
|
+
".avif": "image/avif",
|
|
714
|
+
".ico": "image/x-icon",
|
|
715
|
+
".mp4": "video/mp4",
|
|
716
|
+
".webm": "video/webm",
|
|
717
|
+
".mov": "video/quicktime",
|
|
718
|
+
".mp3": "audio/mpeg",
|
|
719
|
+
".wav": "audio/wav",
|
|
720
|
+
".pdf": "application/pdf",
|
|
721
|
+
".zip": "application/zip",
|
|
722
|
+
".woff": "font/woff",
|
|
723
|
+
".woff2": "font/woff2",
|
|
724
|
+
".ttf": "font/ttf",
|
|
725
|
+
".otf": "font/otf"
|
|
726
|
+
};
|
|
727
|
+
function getMime(filepath) {
|
|
728
|
+
const ext = extname3(filepath).toLowerCase();
|
|
729
|
+
return MIME_TYPES[ext] || "application/octet-stream";
|
|
730
|
+
}
|
|
731
|
+
function buildPreviewHtml(schema, port) {
|
|
732
|
+
const schemaJson = JSON.stringify(schema);
|
|
733
|
+
return `<!DOCTYPE html>
|
|
734
|
+
<html lang="en">
|
|
735
|
+
<head>
|
|
736
|
+
<meta charset="UTF-8" />
|
|
737
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
738
|
+
<title>${schema.slug || "Catalog"} \u2014 Local Preview</title>
|
|
739
|
+
<style>
|
|
740
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
741
|
+
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
|
|
742
|
+
.dev-banner {
|
|
743
|
+
position: fixed; top: 0; left: 0; right: 0; z-index: 99999;
|
|
744
|
+
background: #1a1a2e; color: #e0e0ff; font-size: 12px;
|
|
745
|
+
padding: 4px 12px; display: flex; align-items: center; gap: 8px;
|
|
746
|
+
font-family: monospace; border-bottom: 2px solid #6c63ff;
|
|
747
|
+
}
|
|
748
|
+
.dev-banner .dot { width: 8px; height: 8px; border-radius: 50%; background: #4ade80; }
|
|
749
|
+
.dev-banner .label { opacity: 0.7; }
|
|
750
|
+
.dev-banner .slug { font-weight: bold; color: #a5b4fc; }
|
|
751
|
+
.dev-banner .stub-tags { margin-left: auto; display: flex; gap: 6px; }
|
|
752
|
+
.dev-banner .stub-tag {
|
|
753
|
+
background: rgba(255,255,255,0.1); border-radius: 3px;
|
|
754
|
+
padding: 1px 6px; font-size: 11px; color: #fbbf24;
|
|
755
|
+
}
|
|
756
|
+
#catalog-root { padding-top: 28px; }
|
|
757
|
+
.checkout-stub {
|
|
758
|
+
background: #fef3c7; border: 2px dashed #f59e0b; border-radius: 8px;
|
|
759
|
+
padding: 20px; text-align: center; margin: 16px;
|
|
760
|
+
}
|
|
761
|
+
.checkout-stub h3 { margin: 0 0 8px; color: #92400e; }
|
|
762
|
+
.checkout-stub p { margin: 0; color: #a16207; font-size: 14px; }
|
|
763
|
+
</style>
|
|
764
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
765
|
+
</head>
|
|
766
|
+
<body>
|
|
767
|
+
<div class="dev-banner">
|
|
768
|
+
<span class="dot"></span>
|
|
769
|
+
<span class="label">LOCAL DEV</span>
|
|
770
|
+
<span class="slug">${schema.slug || "catalog"}</span>
|
|
771
|
+
<span class="stub-tags">
|
|
772
|
+
<span class="stub-tag">Checkout: stubbed</span>
|
|
773
|
+
<span class="stub-tag">Analytics: off</span>
|
|
774
|
+
</span>
|
|
775
|
+
</div>
|
|
776
|
+
<div id="catalog-root"></div>
|
|
777
|
+
|
|
778
|
+
<script type="module">
|
|
779
|
+
import React from 'https://esm.sh/react@18?bundle';
|
|
780
|
+
import ReactDOM from 'https://esm.sh/react-dom@18/client?bundle';
|
|
781
|
+
|
|
782
|
+
const schema = ${schemaJson};
|
|
783
|
+
|
|
784
|
+
// Render catalog schema as a formatted preview
|
|
785
|
+
function CatalogPreview({ catalog }) {
|
|
786
|
+
const pages = catalog.pages || {};
|
|
787
|
+
const pageKeys = Object.keys(pages);
|
|
788
|
+
const [currentPage, setCurrentPage] = React.useState(catalog.routing?.entry || pageKeys[0] || null);
|
|
789
|
+
|
|
790
|
+
const page = currentPage ? pages[currentPage] : null;
|
|
791
|
+
|
|
792
|
+
function renderComponent(comp, i) {
|
|
793
|
+
const props = comp.props || {};
|
|
794
|
+
const type = comp.type;
|
|
795
|
+
|
|
796
|
+
switch (type) {
|
|
797
|
+
case 'heading':
|
|
798
|
+
const Tag = props.level === 2 ? 'h2' : props.level === 3 ? 'h3' : 'h1';
|
|
799
|
+
return React.createElement(Tag, { key: i, className: 'text-2xl font-bold mb-4 px-4', dangerouslySetInnerHTML: { __html: props.text || '' } });
|
|
800
|
+
|
|
801
|
+
case 'paragraph':
|
|
802
|
+
return React.createElement('p', { key: i, className: 'text-gray-700 mb-4 px-4 leading-relaxed', dangerouslySetInnerHTML: { __html: props.text || '' } });
|
|
803
|
+
|
|
804
|
+
case 'image':
|
|
805
|
+
return React.createElement('div', { key: i, className: 'px-4 mb-4' },
|
|
806
|
+
React.createElement('img', {
|
|
807
|
+
src: props.src || '',
|
|
808
|
+
alt: props.alt || '',
|
|
809
|
+
className: 'max-w-full rounded-lg',
|
|
810
|
+
style: { maxHeight: '400px', objectFit: 'contain' }
|
|
811
|
+
})
|
|
812
|
+
);
|
|
813
|
+
|
|
814
|
+
case 'video':
|
|
815
|
+
const videoSrc = props.src || props.hls_url || '';
|
|
816
|
+
return React.createElement('div', { key: i, className: 'px-4 mb-4' },
|
|
817
|
+
React.createElement('video', {
|
|
818
|
+
src: videoSrc,
|
|
819
|
+
controls: true,
|
|
820
|
+
className: 'max-w-full rounded-lg',
|
|
821
|
+
poster: props.poster || undefined
|
|
822
|
+
})
|
|
823
|
+
);
|
|
824
|
+
|
|
825
|
+
case 'html':
|
|
826
|
+
return React.createElement('div', {
|
|
827
|
+
key: i,
|
|
828
|
+
className: 'px-4 mb-4',
|
|
829
|
+
dangerouslySetInnerHTML: { __html: props.content || '' }
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
case 'short_text':
|
|
833
|
+
case 'email':
|
|
834
|
+
case 'phone':
|
|
835
|
+
case 'url':
|
|
836
|
+
return React.createElement('div', { key: i, className: 'px-4 mb-4' },
|
|
837
|
+
props.label ? React.createElement('label', { className: 'block text-sm font-medium text-gray-700 mb-1' }, props.label) : null,
|
|
838
|
+
React.createElement('input', {
|
|
839
|
+
type: type === 'email' ? 'email' : type === 'phone' ? 'tel' : type === 'url' ? 'url' : 'text',
|
|
840
|
+
placeholder: props.placeholder || '',
|
|
841
|
+
className: 'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm'
|
|
842
|
+
})
|
|
843
|
+
);
|
|
844
|
+
|
|
845
|
+
case 'long_text':
|
|
846
|
+
return React.createElement('div', { key: i, className: 'px-4 mb-4' },
|
|
847
|
+
props.label ? React.createElement('label', { className: 'block text-sm font-medium text-gray-700 mb-1' }, props.label) : null,
|
|
848
|
+
React.createElement('textarea', {
|
|
849
|
+
placeholder: props.placeholder || '',
|
|
850
|
+
className: 'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm',
|
|
851
|
+
rows: 4
|
|
852
|
+
})
|
|
853
|
+
);
|
|
854
|
+
|
|
855
|
+
case 'multiple_choice':
|
|
856
|
+
return React.createElement('div', { key: i, className: 'px-4 mb-4' },
|
|
857
|
+
props.label ? React.createElement('label', { className: 'block text-sm font-medium text-gray-700 mb-2' }, props.label) : null,
|
|
858
|
+
React.createElement('div', { className: 'space-y-2' },
|
|
859
|
+
...(props.options || []).map((opt, j) =>
|
|
860
|
+
React.createElement('button', {
|
|
861
|
+
key: j,
|
|
862
|
+
className: 'block w-full text-left border border-gray-300 rounded-lg px-4 py-3 text-sm hover:border-blue-500 hover:bg-blue-50 transition'
|
|
863
|
+
}, typeof opt === 'string' ? opt : opt.label || opt.value || '')
|
|
864
|
+
)
|
|
865
|
+
)
|
|
866
|
+
);
|
|
867
|
+
|
|
868
|
+
case 'dropdown':
|
|
869
|
+
return React.createElement('div', { key: i, className: 'px-4 mb-4' },
|
|
870
|
+
props.label ? React.createElement('label', { className: 'block text-sm font-medium text-gray-700 mb-1' }, props.label) : null,
|
|
871
|
+
React.createElement('select', { className: 'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm' },
|
|
872
|
+
React.createElement('option', { value: '' }, props.placeholder || 'Select...'),
|
|
873
|
+
...(props.options || []).map((opt, j) =>
|
|
874
|
+
React.createElement('option', { key: j, value: typeof opt === 'string' ? opt : opt.value },
|
|
875
|
+
typeof opt === 'string' ? opt : opt.label || opt.value || ''
|
|
876
|
+
)
|
|
877
|
+
)
|
|
878
|
+
)
|
|
879
|
+
);
|
|
880
|
+
|
|
881
|
+
case 'payment':
|
|
882
|
+
return React.createElement('div', { key: i, className: 'checkout-stub' },
|
|
883
|
+
React.createElement('h3', null, 'Stripe Checkout'),
|
|
884
|
+
React.createElement('p', null, 'Payment would trigger here in production.'),
|
|
885
|
+
props.amount ? React.createElement('p', { className: 'mt-2 font-bold' },
|
|
886
|
+
(props.currency || 'USD').toUpperCase() + ' ' + (props.amount / 100).toFixed(2)
|
|
887
|
+
) : null
|
|
888
|
+
);
|
|
889
|
+
|
|
890
|
+
case 'banner':
|
|
891
|
+
const bannerColors = {
|
|
892
|
+
info: 'bg-blue-50 border-blue-200 text-blue-800',
|
|
893
|
+
success: 'bg-green-50 border-green-200 text-green-800',
|
|
894
|
+
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
|
|
895
|
+
error: 'bg-red-50 border-red-200 text-red-800',
|
|
896
|
+
};
|
|
897
|
+
return React.createElement('div', {
|
|
898
|
+
key: i,
|
|
899
|
+
className: 'px-4 mb-4'
|
|
900
|
+
},
|
|
901
|
+
React.createElement('div', {
|
|
902
|
+
className: 'border rounded-lg px-4 py-3 text-sm ' + (bannerColors[props.style] || bannerColors.info),
|
|
903
|
+
dangerouslySetInnerHTML: { __html: props.text || '' }
|
|
904
|
+
})
|
|
905
|
+
);
|
|
906
|
+
|
|
907
|
+
case 'divider':
|
|
908
|
+
return React.createElement('hr', { key: i, className: 'my-6 mx-4 border-gray-200' });
|
|
909
|
+
|
|
910
|
+
case 'file_download':
|
|
911
|
+
return React.createElement('div', { key: i, className: 'px-4 mb-4' },
|
|
912
|
+
React.createElement('a', {
|
|
913
|
+
href: props.src || '#',
|
|
914
|
+
download: props.filename || 'file',
|
|
915
|
+
className: 'inline-flex items-center gap-2 border border-gray-300 rounded-lg px-4 py-2 text-sm hover:bg-gray-50'
|
|
916
|
+
}, '\u{1F4E5} ' + (props.filename || props.label || 'Download File'))
|
|
917
|
+
);
|
|
918
|
+
|
|
919
|
+
default:
|
|
920
|
+
return React.createElement('div', {
|
|
921
|
+
key: i,
|
|
922
|
+
className: 'px-4 mb-4 bg-gray-50 border border-dashed border-gray-300 rounded-lg p-3 text-xs text-gray-500'
|
|
923
|
+
}, '[' + type + '] ' + (props.label || props.text || comp.id || ''));
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
if (!page) {
|
|
928
|
+
return React.createElement('div', { className: 'p-8 text-center text-gray-500' }, 'No pages found in catalog.');
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const components = page.components || [];
|
|
932
|
+
const pageTitle = page.title;
|
|
933
|
+
|
|
934
|
+
return React.createElement('div', { className: 'max-w-2xl mx-auto py-8' },
|
|
935
|
+
// Page navigation
|
|
936
|
+
React.createElement('div', { className: 'flex gap-1 px-4 mb-6 overflow-x-auto' },
|
|
937
|
+
...pageKeys.map(key =>
|
|
938
|
+
React.createElement('button', {
|
|
939
|
+
key: key,
|
|
940
|
+
onClick: () => setCurrentPage(key),
|
|
941
|
+
className: 'px-3 py-1 text-xs rounded-full whitespace-nowrap ' +
|
|
942
|
+
(key === currentPage ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200')
|
|
943
|
+
}, pages[key].title || key)
|
|
944
|
+
)
|
|
945
|
+
),
|
|
946
|
+
// Page title
|
|
947
|
+
pageTitle ? React.createElement('h1', { className: 'text-xl font-bold px-4 mb-4' }, pageTitle) : null,
|
|
948
|
+
// Components
|
|
949
|
+
...components.map(renderComponent),
|
|
950
|
+
// Navigation buttons
|
|
951
|
+
React.createElement('div', { className: 'px-4 mt-6 flex gap-3' },
|
|
952
|
+
React.createElement('button', {
|
|
953
|
+
className: 'px-6 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700',
|
|
954
|
+
onClick: () => {
|
|
955
|
+
const idx = pageKeys.indexOf(currentPage);
|
|
956
|
+
if (idx < pageKeys.length - 1) setCurrentPage(pageKeys[idx + 1]);
|
|
957
|
+
}
|
|
958
|
+
}, page.cta_text || 'Continue'),
|
|
959
|
+
)
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const root = ReactDOM.createRoot(document.getElementById('catalog-root'));
|
|
964
|
+
root.render(React.createElement(CatalogPreview, { catalog: schema }));
|
|
965
|
+
</script>
|
|
966
|
+
</body>
|
|
967
|
+
</html>`;
|
|
968
|
+
}
|
|
969
|
+
async function catalogDev(file, opts) {
|
|
970
|
+
const abs = resolve3(file);
|
|
971
|
+
const catalogDir = dirname2(abs);
|
|
972
|
+
const port = parseInt(opts.port || String(DEFAULT_PORT), 10);
|
|
973
|
+
if (!existsSync2(abs)) {
|
|
974
|
+
console.error(`File not found: ${file}`);
|
|
975
|
+
process.exit(1);
|
|
976
|
+
}
|
|
977
|
+
const spinner = ora5("Loading catalog schema...").start();
|
|
978
|
+
let schema;
|
|
979
|
+
try {
|
|
980
|
+
schema = await loadCatalogFile(abs);
|
|
981
|
+
} catch (err) {
|
|
982
|
+
spinner.fail(`Failed to load catalog: ${err.message}`);
|
|
983
|
+
process.exit(1);
|
|
984
|
+
}
|
|
985
|
+
const errors = validateCatalog(schema);
|
|
986
|
+
if (errors.length > 0) {
|
|
987
|
+
spinner.fail("Schema validation errors:");
|
|
988
|
+
for (const err of errors) {
|
|
989
|
+
console.error(` - ${err}`);
|
|
990
|
+
}
|
|
991
|
+
process.exit(1);
|
|
992
|
+
}
|
|
993
|
+
const localBaseUrl = `http://localhost:${port}/assets`;
|
|
994
|
+
schema = resolveLocalAssets(schema, catalogDir, localBaseUrl);
|
|
995
|
+
spinner.succeed(`Loaded: ${schema.slug || file}`);
|
|
996
|
+
console.log(` Pages: ${Object.keys(schema.pages || {}).length}`);
|
|
997
|
+
console.log(` Entry: ${schema.routing?.entry || "first page"}`);
|
|
998
|
+
console.log();
|
|
999
|
+
const server = createServer(async (req, res) => {
|
|
1000
|
+
const url = new URL(req.url || "/", `http://localhost:${port}`);
|
|
1001
|
+
if (url.pathname.startsWith("/assets/")) {
|
|
1002
|
+
const relativePath = decodeURIComponent(url.pathname.slice("/assets/".length));
|
|
1003
|
+
const filePath = join2(catalogDir, relativePath);
|
|
1004
|
+
const resolved = resolve3(filePath);
|
|
1005
|
+
if (!resolved.startsWith(resolve3(catalogDir))) {
|
|
1006
|
+
res.writeHead(403);
|
|
1007
|
+
res.end("Forbidden");
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
try {
|
|
1011
|
+
if (!existsSync2(resolved) || !statSync3(resolved).isFile()) {
|
|
1012
|
+
res.writeHead(404);
|
|
1013
|
+
res.end("Not found");
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
const content = readFileSync4(resolved);
|
|
1017
|
+
res.writeHead(200, {
|
|
1018
|
+
"Content-Type": getMime(resolved),
|
|
1019
|
+
"Cache-Control": "no-cache",
|
|
1020
|
+
"Access-Control-Allow-Origin": "*"
|
|
1021
|
+
});
|
|
1022
|
+
res.end(content);
|
|
1023
|
+
} catch {
|
|
1024
|
+
res.writeHead(500);
|
|
1025
|
+
res.end("Internal error");
|
|
1026
|
+
}
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
res.writeHead(200, {
|
|
1030
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1031
|
+
"Cache-Control": "no-cache"
|
|
1032
|
+
});
|
|
1033
|
+
res.end(buildPreviewHtml(schema, port));
|
|
1034
|
+
});
|
|
1035
|
+
server.listen(port, () => {
|
|
1036
|
+
console.log(` Local preview: http://localhost:${port}`);
|
|
1037
|
+
console.log(` Assets served from: ${catalogDir}`);
|
|
1038
|
+
console.log(` Watching for changes...
|
|
1039
|
+
`);
|
|
1040
|
+
});
|
|
1041
|
+
let debounce = null;
|
|
1042
|
+
watch(abs, () => {
|
|
1043
|
+
if (debounce) clearTimeout(debounce);
|
|
1044
|
+
debounce = setTimeout(async () => {
|
|
1045
|
+
const reloadSpinner = ora5("Reloading catalog...").start();
|
|
1046
|
+
try {
|
|
1047
|
+
schema = await loadCatalogFile(abs);
|
|
1048
|
+
const errs = validateCatalog(schema);
|
|
1049
|
+
if (errs.length > 0) {
|
|
1050
|
+
reloadSpinner.warn("Schema has validation errors:");
|
|
1051
|
+
for (const e of errs) console.error(` - ${e}`);
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
schema = resolveLocalAssets(schema, catalogDir, localBaseUrl);
|
|
1055
|
+
reloadSpinner.succeed(`Reloaded \u2014 refresh browser to see changes`);
|
|
1056
|
+
} catch (err) {
|
|
1057
|
+
reloadSpinner.warn(`Reload failed: ${err.message}`);
|
|
1058
|
+
}
|
|
1059
|
+
}, 300);
|
|
1060
|
+
});
|
|
1061
|
+
try {
|
|
1062
|
+
watch(catalogDir, { recursive: true }, (event, filename) => {
|
|
1063
|
+
if (!filename || filename.startsWith(".") || resolve3(join2(catalogDir, filename)) === abs) return;
|
|
1064
|
+
});
|
|
1065
|
+
} catch {
|
|
1066
|
+
}
|
|
1067
|
+
process.on("SIGINT", () => {
|
|
1068
|
+
console.log("\nStopping dev server...");
|
|
1069
|
+
server.close();
|
|
1070
|
+
process.exit(0);
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
|
|
367
1074
|
// src/commands/whoami.ts
|
|
368
1075
|
async function whoami() {
|
|
369
1076
|
const config = getConfig();
|
|
370
|
-
if (!config.
|
|
1077
|
+
if (!config.token) {
|
|
371
1078
|
console.log("\nNot configured.\n");
|
|
372
|
-
console.log("Set
|
|
373
|
-
console.log("
|
|
374
|
-
console.log("
|
|
375
|
-
console.log("\
|
|
376
|
-
console.log(
|
|
1079
|
+
console.log("Set this environment variable:");
|
|
1080
|
+
console.log(" CATALOG_KIT_TOKEN \u2014 API key (cfk_... or base64 legacy token)");
|
|
1081
|
+
console.log("\nOptionally override the API URL (defaults to https://api.catalogkit.cc):");
|
|
1082
|
+
console.log(" CATALOG_KIT_API_URL \u2014 API base URL");
|
|
1083
|
+
console.log("\nOr create ~/.catalog-kit/config.json:");
|
|
1084
|
+
console.log(' { "token": "cfk_..." }\n');
|
|
377
1085
|
return;
|
|
378
1086
|
}
|
|
379
1087
|
const tokenPreview = config.token.length > 12 ? config.token.slice(0, 8) + "..." + config.token.slice(-4) : "***";
|
|
@@ -381,28 +1089,60 @@ async function whoami() {
|
|
|
381
1089
|
api_url: ${config.api_url}`);
|
|
382
1090
|
console.log(` token: ${tokenPreview}`);
|
|
383
1091
|
try {
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
console.log(` status:
|
|
389
|
-
|
|
390
|
-
console.log(`
|
|
1092
|
+
const api = new ApiClient(config);
|
|
1093
|
+
const res = await api.get("/api/v1/me");
|
|
1094
|
+
if (res.ok && res.data) {
|
|
1095
|
+
const d = res.data;
|
|
1096
|
+
console.log(` status: authenticated`);
|
|
1097
|
+
console.log();
|
|
1098
|
+
console.log(` user_id: ${d.user_id}`);
|
|
1099
|
+
if (d.email) console.log(` email: ${d.email}`);
|
|
1100
|
+
if (d.username) console.log(` username: ${d.username}`);
|
|
1101
|
+
if (d.subdomain) console.log(` subdomain: ${d.subdomain}`);
|
|
1102
|
+
if (d.custom_domain) console.log(` custom_domain: ${d.custom_domain}`);
|
|
1103
|
+
if (d.catalog_url) console.log(` catalog_url: ${d.catalog_url}`);
|
|
1104
|
+
console.log(` credits: ${d.credits}`);
|
|
1105
|
+
console.log(` auth_method: ${d.auth_method}`);
|
|
1106
|
+
if (d.api_key_id) console.log(` api_key_id: ${d.api_key_id}`);
|
|
1107
|
+
console.log(` is_superadmin: ${d.is_superadmin}`);
|
|
1108
|
+
console.log(` stripe_key_set: ${d.stripe_key_set}`);
|
|
1109
|
+
if (d.stripe_key_last4) console.log(` stripe_key_last4: ...${d.stripe_key_last4}`);
|
|
1110
|
+
console.log(` officex: ${d.officex_connected ? "connected" : "not connected"}`);
|
|
1111
|
+
console.log(` created_at: ${d.created_at}`);
|
|
391
1112
|
}
|
|
392
1113
|
} catch (err) {
|
|
393
|
-
|
|
1114
|
+
try {
|
|
1115
|
+
const res = await fetch(`${config.api_url}/health`);
|
|
1116
|
+
if (res.ok) {
|
|
1117
|
+
const data = await res.json();
|
|
1118
|
+
console.log(` stage: ${data.stage || "unknown"}`);
|
|
1119
|
+
console.log(` status: connected (could not fetch user: ${err.message})`);
|
|
1120
|
+
} else {
|
|
1121
|
+
console.log(` status: error (${res.status})`);
|
|
1122
|
+
}
|
|
1123
|
+
} catch (healthErr) {
|
|
1124
|
+
console.log(` status: unreachable (${healthErr.message})`);
|
|
1125
|
+
}
|
|
394
1126
|
}
|
|
395
1127
|
console.log();
|
|
396
1128
|
}
|
|
397
1129
|
|
|
398
1130
|
// src/index.ts
|
|
1131
|
+
var require2 = createRequire(import.meta.url);
|
|
1132
|
+
var { version } = require2("../../package.json");
|
|
399
1133
|
var program = new Command();
|
|
400
|
-
program.name("catalogs").description("CLI for Catalog
|
|
1134
|
+
program.name("catalogs").description("CLI for Catalog Kit \u2014 upload videos, push catalogs, manage assets").version(version).option("--token <token>", "Auth token (overrides CATALOG_KIT_TOKEN env var)").hook("preAction", (thisCommand) => {
|
|
1135
|
+
const opts = thisCommand.opts();
|
|
1136
|
+
if (opts.token) {
|
|
1137
|
+
setGlobalToken(opts.token);
|
|
1138
|
+
}
|
|
1139
|
+
});
|
|
401
1140
|
var video = program.command("video").description("Video upload & transcoding");
|
|
402
1141
|
video.command("upload <file>").description("Upload a video file \u2192 transcode to HLS \u2192 return hls_url").option("--no-transcode", "Skip transcoding (upload only)").action(videoUpload);
|
|
403
1142
|
video.command("status <videoId>").description("Check transcode status for a video").action(videoStatus);
|
|
404
1143
|
var catalog = program.command("catalog").description("Catalog schema management");
|
|
405
|
-
catalog.command("push <file>").description("Create or update a catalog from a JSON schema file").option("--publish", "Set status to published (default: draft)").action(catalogPush);
|
|
1144
|
+
catalog.command("push <file>").description("Create or update a catalog from a JSON or TypeScript schema file").option("--publish", "Set status to published (default: draft)").action(catalogPush);
|
|
406
1145
|
catalog.command("list").description("List all catalogs").action(catalogList);
|
|
1146
|
+
catalog.command("dev <file>").description("Preview a catalog locally with hot reload and local asset serving").option("--port <port>", "Port to serve on (default: 3456)").action(catalogDev);
|
|
407
1147
|
program.command("whoami").description("Show current authentication info").action(whoami);
|
|
408
1148
|
program.parse();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@officexapp/catalogs-cli",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "CLI for Catalog
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "CLI for Catalog Kit — upload videos, push catalogs, manage assets",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"catalogs": "./dist/index.js"
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"dist"
|
|
16
16
|
],
|
|
17
17
|
"keywords": [
|
|
18
|
-
"catalog-
|
|
18
|
+
"catalog-kit",
|
|
19
19
|
"officex",
|
|
20
20
|
"cli",
|
|
21
21
|
"video",
|
|
@@ -31,12 +31,12 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"commander": "^12.1.0",
|
|
34
|
-
"ora": "^8.1.0"
|
|
34
|
+
"ora": "^8.1.0",
|
|
35
|
+
"tsx": "^4.19.0"
|
|
35
36
|
},
|
|
36
37
|
"devDependencies": {
|
|
37
38
|
"@types/node": "^22.0.0",
|
|
38
39
|
"tsup": "^8.3.0",
|
|
39
|
-
"tsx": "^4.19.0",
|
|
40
40
|
"typescript": "^5.6.3"
|
|
41
41
|
}
|
|
42
42
|
}
|