@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.
Files changed (3) hide show
  1. package/README.md +9 -5
  2. package/dist/index.js +816 -76
  3. package/package.json +5 -5
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @officexapp/catalogs-cli
2
2
 
3
- CLI for Catalog Funnel — upload videos, push catalog schemas, manage assets.
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 CATALOGS_API_URL="https://catalog-funnel-api-staging.cloud.zoomgtm.com"
15
- export CATALOGS_TOKEN="cfk_your_api_key_here"
14
+ export CATALOG_KIT_TOKEN="cfk_your_api_key_here"
16
15
  ```
17
16
 
18
- Or create `~/.catalogs-cli/config.json`:
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
- import { readFileSync, existsSync } from "fs";
13
- import { join } from "path";
14
- import { homedir } from "os";
15
- var CONFIG_DIR = join(homedir(), ".catalogs-cli");
16
- var CONFIG_FILE = join(CONFIG_DIR, "config.json");
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.CATALOGS_API_URL;
19
- const envToken = process.env.CATALOGS_TOKEN;
20
- if (envUrl && envToken) {
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
- "Missing auth token. Set CATALOGS_TOKEN env var or create ~/.catalogs-cli/config.json"
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 = readFileSync2(file);
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((resolve) => setTimeout(resolve, ms));
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
- const raw = readFileSync3(file, "utf-8");
290
- schema = JSON.parse(raw);
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 slug = schema.slug;
296
- const name = schema.catalog_id || schema.slug || file;
297
- if (!slug) {
298
- console.error("Schema must have a 'slug' field");
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.api_url || !config.token) {
1077
+ if (!config.token) {
371
1078
  console.log("\nNot configured.\n");
372
- console.log("Set these environment variables:");
373
- console.log(" CATALOGS_API_URL \u2014 API base URL (e.g. https://catalog-funnel-api-staging.cloud.zoomgtm.com)");
374
- console.log(" CATALOGS_TOKEN \u2014 API key (cfk_... or base64 legacy token)");
375
- console.log("\nOr create ~/.catalogs-cli/config.json:");
376
- console.log(' { "api_url": "...", "token": "..." }\n');
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 res = await fetch(`${config.api_url}/health`);
385
- if (res.ok) {
386
- const data = await res.json();
387
- console.log(` stage: ${data.stage || "unknown"}`);
388
- console.log(` status: connected`);
389
- } else {
390
- console.log(` status: error (${res.status})`);
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
- console.log(` status: unreachable (${err.message})`);
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 Funnel \u2014 upload videos, push catalogs, manage assets").version("0.1.0");
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.0",
4
- "description": "CLI for Catalog Funnel — upload videos, push catalogs, manage assets",
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-funnel",
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
  }