@officexapp/catalogs-cli 0.1.0

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 +92 -0
  2. package/dist/index.js +408 -0
  3. package/package.json +42 -0
package/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # @officexapp/catalogs-cli
2
+
3
+ CLI for Catalog Funnel — upload videos, push catalog schemas, manage assets.
4
+
5
+ ## Setup
6
+
7
+ ```bash
8
+ npm install -g @officexapp/catalogs-cli
9
+ ```
10
+
11
+ Configure authentication:
12
+
13
+ ```bash
14
+ export CATALOGS_API_URL="https://catalog-funnel-api-staging.cloud.zoomgtm.com"
15
+ export CATALOGS_TOKEN="cfk_your_api_key_here"
16
+ ```
17
+
18
+ Or create `~/.catalogs-cli/config.json`:
19
+
20
+ ```json
21
+ {
22
+ "api_url": "https://catalog-funnel-api-staging.cloud.zoomgtm.com",
23
+ "token": "cfk_your_api_key_here"
24
+ }
25
+ ```
26
+
27
+ ## Commands
28
+
29
+ ### Video
30
+
31
+ Upload a video, transcode to HLS, and get the streaming URL:
32
+
33
+ ```bash
34
+ catalogs video upload ./demo.mp4
35
+ ```
36
+
37
+ Skip transcoding (upload only):
38
+
39
+ ```bash
40
+ catalogs video upload ./demo.mp4 --no-transcode
41
+ ```
42
+
43
+ Check transcode status:
44
+
45
+ ```bash
46
+ catalogs video status <videoId>
47
+ ```
48
+
49
+ ### Catalog
50
+
51
+ Push a catalog schema (creates or updates):
52
+
53
+ ```bash
54
+ catalogs catalog push ./my-funnel.json
55
+ catalogs catalog push ./my-funnel.json --publish
56
+ ```
57
+
58
+ List all catalogs:
59
+
60
+ ```bash
61
+ catalogs catalog list
62
+ ```
63
+
64
+ ### Auth
65
+
66
+ Check your current config:
67
+
68
+ ```bash
69
+ catalogs whoami
70
+ ```
71
+
72
+ ## Using HLS URLs in Catalog Schemas
73
+
74
+ After uploading a video, use the returned `hls_url` in your catalog schema:
75
+
76
+ ```json
77
+ {
78
+ "id": "comp_intro_video",
79
+ "type": "video",
80
+ "props": {
81
+ "hls_url": "https://d1k9qtz75bfygl.cloudfront.net/media/transcoded/user123/video456/index.m3u8",
82
+ "poster": "https://example.com/thumb.jpg",
83
+ "chapters": [
84
+ { "time": 0, "label": "Intro" },
85
+ { "time": 120, "label": "Demo" },
86
+ { "time": 300, "label": "Pricing" }
87
+ ],
88
+ "skippable": false,
89
+ "require_watch_percent": 80
90
+ }
91
+ }
92
+ ```
package/dist/index.js ADDED
@@ -0,0 +1,408 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
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";
10
+
11
+ // 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");
17
+ 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
+ };
53
+ }
54
+ function requireConfig() {
55
+ 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
+ if (!config.token) {
63
+ console.error(
64
+ "Missing auth token. Set CATALOGS_TOKEN env var or create ~/.catalogs-cli/config.json"
65
+ );
66
+ process.exit(1);
67
+ }
68
+ return config;
69
+ }
70
+
71
+ // src/api.ts
72
+ var ApiClient = class {
73
+ constructor(config) {
74
+ this.config = config;
75
+ }
76
+ get headers() {
77
+ return {
78
+ Authorization: `Bearer ${this.config.token}`,
79
+ "Content-Type": "application/json"
80
+ };
81
+ }
82
+ async get(path) {
83
+ const res = await fetch(`${this.config.api_url}${path}`, {
84
+ method: "GET",
85
+ headers: this.headers
86
+ });
87
+ if (!res.ok) {
88
+ const body = await res.text();
89
+ throw new Error(`GET ${path} failed (${res.status}): ${body}`);
90
+ }
91
+ return res.json();
92
+ }
93
+ async post(path, body) {
94
+ const res = await fetch(`${this.config.api_url}${path}`, {
95
+ method: "POST",
96
+ headers: this.headers,
97
+ body: body ? JSON.stringify(body) : void 0
98
+ });
99
+ if (!res.ok) {
100
+ const text = await res.text();
101
+ throw new Error(`POST ${path} failed (${res.status}): ${text}`);
102
+ }
103
+ return res.json();
104
+ }
105
+ async put(path, body) {
106
+ const res = await fetch(`${this.config.api_url}${path}`, {
107
+ method: "PUT",
108
+ headers: this.headers,
109
+ body: body ? JSON.stringify(body) : void 0
110
+ });
111
+ if (!res.ok) {
112
+ const text = await res.text();
113
+ throw new Error(`PUT ${path} failed (${res.status}): ${text}`);
114
+ }
115
+ return res.json();
116
+ }
117
+ };
118
+
119
+ // src/commands/video-upload.ts
120
+ var MIME_MAP = {
121
+ ".mp4": "video/mp4",
122
+ ".mov": "video/quicktime",
123
+ ".avi": "video/x-msvideo",
124
+ ".mkv": "video/x-matroska",
125
+ ".webm": "video/webm",
126
+ ".m4v": "video/x-m4v",
127
+ ".wmv": "video/x-ms-wmv",
128
+ ".flv": "video/x-flv"
129
+ };
130
+ function getContentType(filename) {
131
+ const ext = filename.toLowerCase().match(/\.[^.]+$/)?.[0] || "";
132
+ return MIME_MAP[ext] || "video/mp4";
133
+ }
134
+ function formatBytes(bytes) {
135
+ if (bytes < 1024) return `${bytes} B`;
136
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
137
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
138
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
139
+ }
140
+ async function videoUpload(file, opts) {
141
+ const config = requireConfig();
142
+ const api = new ApiClient(config);
143
+ const skipTranscode = opts.transcode === false;
144
+ let stat;
145
+ try {
146
+ stat = statSync(file);
147
+ } catch {
148
+ console.error(`File not found: ${file}`);
149
+ process.exit(1);
150
+ }
151
+ const filename = basename(file);
152
+ const contentType = getContentType(filename);
153
+ const sizeBytes = stat.size;
154
+ console.log(`
155
+ File: ${filename} (${formatBytes(sizeBytes)})`);
156
+ console.log(`Type: ${contentType}
157
+ `);
158
+ const spinner = ora("Requesting upload URL...").start();
159
+ let videoId;
160
+ let uploadUrl;
161
+ try {
162
+ const res = await api.post("/api/v1/videos/upload", {
163
+ filename,
164
+ content_type: contentType,
165
+ size_bytes: sizeBytes
166
+ });
167
+ videoId = res.data.video_id;
168
+ uploadUrl = res.data.upload_url;
169
+ spinner.succeed(
170
+ `Upload authorized \u2014 video_id: ${videoId} (${res.data.credits_charged} credits)`
171
+ );
172
+ } catch (err) {
173
+ spinner.fail(`Upload request failed: ${err.message}`);
174
+ process.exit(1);
175
+ }
176
+ const uploadSpinner = ora("Uploading to S3...").start();
177
+ try {
178
+ const fileBuffer = readFileSync2(file);
179
+ const res = await fetch(uploadUrl, {
180
+ method: "PUT",
181
+ headers: { "Content-Type": contentType },
182
+ body: fileBuffer
183
+ });
184
+ if (!res.ok) {
185
+ throw new Error(`S3 PUT failed: ${res.status} ${res.statusText}`);
186
+ }
187
+ uploadSpinner.succeed("Upload complete");
188
+ } catch (err) {
189
+ uploadSpinner.fail(`Upload failed: ${err.message}`);
190
+ process.exit(1);
191
+ }
192
+ if (skipTranscode) {
193
+ console.log(`
194
+ video_id: ${videoId}`);
195
+ console.log("Skipped transcoding. Run `catalogs video status` to check later.\n");
196
+ return;
197
+ }
198
+ const transcodeSpinner = ora("Starting HLS transcode...").start();
199
+ try {
200
+ const res = await api.post(`/api/v1/videos/${videoId}/transcode`);
201
+ transcodeSpinner.succeed(
202
+ `Transcode started \u2014 job_id: ${res.data.job_id} (${res.data.credits_charged} credits)`
203
+ );
204
+ } catch (err) {
205
+ transcodeSpinner.fail(`Transcode request failed: ${err.message}`);
206
+ console.log(`
207
+ video_id: ${videoId}`);
208
+ console.log("Upload succeeded but transcode failed. Retry with: catalogs video status\n");
209
+ process.exit(1);
210
+ }
211
+ const pollSpinner = ora("Transcoding (this may take a few minutes)...").start();
212
+ const startTime = Date.now();
213
+ const MAX_POLL = 10 * 60 * 1e3;
214
+ const POLL_INTERVAL = 5e3;
215
+ while (Date.now() - startTime < MAX_POLL) {
216
+ await sleep(POLL_INTERVAL);
217
+ try {
218
+ const res = await api.get(`/api/v1/videos/${videoId}/status`);
219
+ const { status, hls_url } = res.data;
220
+ if (status === "ready" && hls_url) {
221
+ pollSpinner.succeed("Transcode complete!");
222
+ console.log(`
223
+ video_id: ${videoId}`);
224
+ console.log(` hls_url: ${hls_url}`);
225
+ console.log(
226
+ `
227
+ Add to your catalog schema:
228
+ { "type": "video", "props": { "hls_url": "${hls_url}" } }
229
+ `
230
+ );
231
+ return;
232
+ }
233
+ if (status === "failed") {
234
+ pollSpinner.fail("Transcode failed");
235
+ console.log(`
236
+ video_id: ${videoId}`);
237
+ process.exit(1);
238
+ }
239
+ const elapsed = Math.round((Date.now() - startTime) / 1e3);
240
+ pollSpinner.text = `Transcoding... (${elapsed}s)`;
241
+ } catch {
242
+ }
243
+ }
244
+ pollSpinner.warn("Transcode still in progress (timed out waiting)");
245
+ console.log(`
246
+ Check status later: catalogs video status ${videoId}
247
+ `);
248
+ }
249
+ function sleep(ms) {
250
+ return new Promise((resolve) => setTimeout(resolve, ms));
251
+ }
252
+
253
+ // src/commands/video-status.ts
254
+ import ora2 from "ora";
255
+ async function videoStatus(videoId) {
256
+ const config = requireConfig();
257
+ const api = new ApiClient(config);
258
+ const spinner = ora2(`Checking status for ${videoId}...`).start();
259
+ try {
260
+ const res = await api.get(`/api/v1/videos/${videoId}/status`);
261
+ const { status, hls_url, filename, size_bytes } = res.data;
262
+ spinner.stop();
263
+ console.log(`
264
+ video_id: ${videoId}`);
265
+ console.log(` filename: ${filename}`);
266
+ if (size_bytes) {
267
+ const mb = (size_bytes / (1024 * 1024)).toFixed(1);
268
+ console.log(` size: ${mb} MB`);
269
+ }
270
+ console.log(` status: ${status}`);
271
+ if (hls_url) {
272
+ console.log(` hls_url: ${hls_url}`);
273
+ }
274
+ console.log();
275
+ } catch (err) {
276
+ spinner.fail(`Failed: ${err.message}`);
277
+ process.exit(1);
278
+ }
279
+ }
280
+
281
+ // src/commands/catalog-push.ts
282
+ import { readFileSync as readFileSync3 } from "fs";
283
+ import ora3 from "ora";
284
+ async function catalogPush(file, opts) {
285
+ const config = requireConfig();
286
+ const api = new ApiClient(config);
287
+ let schema;
288
+ try {
289
+ const raw = readFileSync3(file, "utf-8");
290
+ schema = JSON.parse(raw);
291
+ } catch (err) {
292
+ console.error(`Failed to read ${file}: ${err.message}`);
293
+ process.exit(1);
294
+ }
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");
299
+ process.exit(1);
300
+ }
301
+ const status = opts.publish ? "published" : "draft";
302
+ const spinner = ora3(`Pushing catalog "${slug}"...`).start();
303
+ try {
304
+ const listRes = await api.get("/api/v1/catalogs");
305
+ const catalogs = listRes.data || [];
306
+ const existing = catalogs.find((c) => c.slug === slug);
307
+ if (existing) {
308
+ const res = await api.put(`/api/v1/catalogs/${existing.catalog_id}`, {
309
+ schema,
310
+ status,
311
+ name
312
+ });
313
+ spinner.succeed(`Updated catalog "${slug}" (${existing.catalog_id})`);
314
+ if (res.data?.url) {
315
+ console.log(` URL: ${res.data.url}`);
316
+ }
317
+ } else {
318
+ const res = await api.post("/api/v1/catalogs", {
319
+ slug,
320
+ name,
321
+ schema,
322
+ status
323
+ });
324
+ spinner.succeed(`Created catalog "${slug}" (${res.data?.catalog_id})`);
325
+ if (res.data?.url) {
326
+ console.log(` URL: ${res.data.url}`);
327
+ }
328
+ }
329
+ console.log(` Status: ${status}
330
+ `);
331
+ } catch (err) {
332
+ spinner.fail(`Push failed: ${err.message}`);
333
+ process.exit(1);
334
+ }
335
+ }
336
+
337
+ // src/commands/catalog-list.ts
338
+ import ora4 from "ora";
339
+ async function catalogList() {
340
+ const config = requireConfig();
341
+ const api = new ApiClient(config);
342
+ const spinner = ora4("Fetching catalogs...").start();
343
+ try {
344
+ const res = await api.get("/api/v1/catalogs");
345
+ const catalogs = res.data || [];
346
+ spinner.stop();
347
+ if (catalogs.length === 0) {
348
+ console.log("\nNo catalogs found.\n");
349
+ return;
350
+ }
351
+ console.log(`
352
+ Found ${catalogs.length} catalog(s):
353
+ `);
354
+ for (const c of catalogs) {
355
+ const statusBadge = c.status === "published" ? "[published]" : "[draft]";
356
+ console.log(` ${c.slug} ${statusBadge}`);
357
+ console.log(` id: ${c.catalog_id}`);
358
+ if (c.url) console.log(` url: ${c.url}`);
359
+ console.log();
360
+ }
361
+ } catch (err) {
362
+ spinner.fail(`Failed: ${err.message}`);
363
+ process.exit(1);
364
+ }
365
+ }
366
+
367
+ // src/commands/whoami.ts
368
+ async function whoami() {
369
+ const config = getConfig();
370
+ if (!config.api_url || !config.token) {
371
+ 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');
377
+ return;
378
+ }
379
+ const tokenPreview = config.token.length > 12 ? config.token.slice(0, 8) + "..." + config.token.slice(-4) : "***";
380
+ console.log(`
381
+ api_url: ${config.api_url}`);
382
+ console.log(` token: ${tokenPreview}`);
383
+ 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})`);
391
+ }
392
+ } catch (err) {
393
+ console.log(` status: unreachable (${err.message})`);
394
+ }
395
+ console.log();
396
+ }
397
+
398
+ // src/index.ts
399
+ var program = new Command();
400
+ program.name("catalogs").description("CLI for Catalog Funnel \u2014 upload videos, push catalogs, manage assets").version("0.1.0");
401
+ var video = program.command("video").description("Video upload & transcoding");
402
+ 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
+ video.command("status <videoId>").description("Check transcode status for a video").action(videoStatus);
404
+ 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);
406
+ catalog.command("list").description("List all catalogs").action(catalogList);
407
+ program.command("whoami").description("Show current authentication info").action(whoami);
408
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@officexapp/catalogs-cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for Catalog Funnel — upload videos, push catalogs, manage assets",
5
+ "type": "module",
6
+ "bin": {
7
+ "catalogs": "./dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsup",
11
+ "dev": "tsx src/index.ts",
12
+ "prepublishOnly": "npm run build"
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "keywords": [
18
+ "catalog-funnel",
19
+ "officex",
20
+ "cli",
21
+ "video",
22
+ "hls"
23
+ ],
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/OfficeXApp/catalogs-cli.git"
27
+ },
28
+ "license": "UNLICENSED",
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "dependencies": {
33
+ "commander": "^12.1.0",
34
+ "ora": "^8.1.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^22.0.0",
38
+ "tsup": "^8.3.0",
39
+ "tsx": "^4.19.0",
40
+ "typescript": "^5.6.3"
41
+ }
42
+ }