@mediaruntime/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.
package/dist/cli.js ADDED
@@ -0,0 +1,895 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { createHash, randomUUID as randomUUID2 } from "crypto";
5
+ import { constants, realpathSync } from "fs";
6
+ import {
7
+ access,
8
+ link,
9
+ rename,
10
+ rm
11
+ } from "fs/promises";
12
+ import { createWriteStream } from "fs";
13
+ import { basename, dirname, resolve } from "path";
14
+ import { Readable, Transform } from "stream";
15
+ import { pipeline } from "stream/promises";
16
+ import { fileURLToPath } from "url";
17
+ import {
18
+ JobWaitTimeoutError,
19
+ MediaRuntime,
20
+ MediaRuntimeApiError,
21
+ MediaRuntimeConnectionError
22
+ } from "@mediaruntime/node";
23
+
24
+ // src/errors.ts
25
+ var CliError = class extends Error {
26
+ code;
27
+ exitCode;
28
+ constructor(code, message, exitCode, options) {
29
+ super(message, options);
30
+ this.name = "CliError";
31
+ this.code = code;
32
+ this.exitCode = exitCode;
33
+ Object.setPrototypeOf(this, new.target.prototype);
34
+ }
35
+ };
36
+ var UsageError = class extends CliError {
37
+ constructor(message, options) {
38
+ super("usage_error", message, 2, options);
39
+ this.name = "UsageError";
40
+ }
41
+ };
42
+ var TriggerDeliveryError = class extends CliError {
43
+ constructor(message, options) {
44
+ super("trigger_delivery_error", message, 8, options);
45
+ this.name = "TriggerDeliveryError";
46
+ }
47
+ };
48
+ var BundleDownloadError = class extends CliError {
49
+ constructor(message, options) {
50
+ super("bundle_download_error", message, 9, options);
51
+ this.name = "BundleDownloadError";
52
+ }
53
+ };
54
+
55
+ // src/commands/jobs.ts
56
+ var UNSUCCESSFUL_TERMINAL_STATUSES = /* @__PURE__ */ new Set(["FAILED", "REJECTED", "PARTIAL"]);
57
+ function optionValue(args, index, option) {
58
+ const value = args[index + 1];
59
+ if (!value || value.startsWith("--")) throw new UsageError(`${option} requires a value`);
60
+ return value;
61
+ }
62
+ function detailsProjection(job) {
63
+ return {
64
+ id: job.id,
65
+ status: job.status,
66
+ tier: job.tier,
67
+ usage: job.usage,
68
+ billing: job.billing,
69
+ bundle: {
70
+ available: job.bundle.available,
71
+ expiresAt: job.bundle.expiresAt,
72
+ sizeBytes: job.bundle.sizeBytes,
73
+ sha256: job.bundle.sha256,
74
+ retentionDays: job.bundle.retentionDays
75
+ },
76
+ media: job.media,
77
+ metadata: job.metadata,
78
+ error: job.error,
79
+ createdAt: job.createdAt,
80
+ updatedAt: job.updatedAt,
81
+ startedAt: job.startedAt,
82
+ completedAt: job.completedAt
83
+ };
84
+ }
85
+ function humanDetails(job) {
86
+ const lines = [
87
+ `Job: ${job.id}`,
88
+ `Status: ${job.status}`,
89
+ `Tier: ${job.tier.billed ?? job.tier.effective ?? job.tier.requested ?? "-"}`,
90
+ `Bundle: ${job.bundle.available ? "available" : "unavailable"}`
91
+ ];
92
+ if (job.bundle.sizeBytes !== null) lines.push(`Bundle size: ${job.bundle.sizeBytes} bytes`);
93
+ if (job.error) lines.push(`Error: ${job.error}`);
94
+ return `${lines.join("\n")}
95
+ `;
96
+ }
97
+ function cell(value) {
98
+ if (value === null || value === void 0 || value === "") return "-";
99
+ return String(value);
100
+ }
101
+ function humanList(page) {
102
+ const rows = page.jobs.map((job) => [
103
+ job.id,
104
+ job.status,
105
+ cell(job.tierBilled),
106
+ cell(job.unitsTotal),
107
+ job.bundleAvailable ? "yes" : "no",
108
+ cell(job.updatedAt)
109
+ ]);
110
+ const headings = ["ID", "STATUS", "TIER", "UNITS", "BUNDLE", "UPDATED"];
111
+ const widths = headings.map(
112
+ (heading, index) => Math.max(heading.length, ...rows.map((row) => row[index]?.length ?? 0))
113
+ );
114
+ const render = (row) => row.map((value, index) => value.padEnd(widths[index] ?? value.length)).join(" ").trimEnd();
115
+ const lines = [render(headings), render(widths.map((width) => "-".repeat(width))), ...rows.map(render)];
116
+ if (page.nextCursor) lines.push(`Next cursor: ${page.nextCursor}`);
117
+ return `${lines.join("\n")}
118
+ `;
119
+ }
120
+ async function listCommand(args, dependencies) {
121
+ let status;
122
+ let limit;
123
+ let cursor;
124
+ let json = false;
125
+ for (let index = 0; index < args.length; index += 1) {
126
+ const argument = args[index];
127
+ if (argument === "--status") {
128
+ status = optionValue(args, index, argument).toUpperCase();
129
+ index += 1;
130
+ } else if (argument === "--limit") {
131
+ const value = Number(optionValue(args, index, argument));
132
+ if (!Number.isSafeInteger(value) || value < 1 || value > 100) {
133
+ throw new UsageError("--limit must be an integer between 1 and 100");
134
+ }
135
+ limit = value;
136
+ index += 1;
137
+ } else if (argument === "--cursor") {
138
+ cursor = optionValue(args, index, argument);
139
+ index += 1;
140
+ } else if (argument === "--json") {
141
+ json = true;
142
+ } else {
143
+ throw new UsageError(`Unknown jobs list option: ${argument}`);
144
+ }
145
+ }
146
+ const params = {
147
+ ...status === void 0 ? {} : { status },
148
+ ...limit === void 0 ? {} : { limit },
149
+ ...cursor === void 0 ? {} : { cursor }
150
+ };
151
+ const page = await dependencies.jobs.list(params);
152
+ dependencies.writeStdout(json ? `${JSON.stringify(page)}
153
+ ` : humanList(page));
154
+ return 0;
155
+ }
156
+ async function getCommand(args, dependencies) {
157
+ let jobId;
158
+ let download;
159
+ let force = false;
160
+ let json = false;
161
+ for (let index = 0; index < args.length; index += 1) {
162
+ const argument = args[index];
163
+ if (argument === "--download") {
164
+ download = optionValue(args, index, argument);
165
+ index += 1;
166
+ } else if (argument === "--force") {
167
+ force = true;
168
+ } else if (argument === "--json") {
169
+ json = true;
170
+ } else if (argument?.startsWith("-")) {
171
+ throw new UsageError(`Unknown jobs get option: ${argument}`);
172
+ } else if (!jobId) {
173
+ jobId = argument;
174
+ } else {
175
+ throw new UsageError("jobs get accepts exactly one job ID");
176
+ }
177
+ }
178
+ if (!jobId) throw new UsageError("Usage: mediaruntime jobs get <job_id>");
179
+ if (force && !download) throw new UsageError("--force requires --download");
180
+ const job = await dependencies.jobs.get(jobId);
181
+ if (!json) dependencies.writeStdout(humanDetails(job));
182
+ if (UNSUCCESSFUL_TERMINAL_STATUSES.has(String(job.status).toUpperCase())) {
183
+ if (json) dependencies.writeStdout(`${JSON.stringify(detailsProjection(job))}
184
+ `);
185
+ return 6;
186
+ }
187
+ if (download) {
188
+ if (String(job.status).toUpperCase() !== "COMPLETED" || !job.bundle.available || !job.bundle.downloadUrl) {
189
+ throw new BundleDownloadError("Bundle download requires a COMPLETED job with an available canonical bundle");
190
+ }
191
+ try {
192
+ await dependencies.downloadBundle(job.bundle.downloadUrl, download, {
193
+ force,
194
+ expectedSizeBytes: job.bundle.sizeBytes,
195
+ expectedSha256: job.bundle.sha256
196
+ });
197
+ } catch (error) {
198
+ if (error instanceof BundleDownloadError) throw error;
199
+ throw new BundleDownloadError(`Could not download bundle to ${download}`, { cause: error });
200
+ }
201
+ if (!json) dependencies.writeStdout(`Downloaded bundle to ${download}
202
+ `);
203
+ }
204
+ if (json) dependencies.writeStdout(`${JSON.stringify(detailsProjection(job))}
205
+ `);
206
+ return 0;
207
+ }
208
+ async function runJobsCommand(args, dependencies) {
209
+ const subcommand = args[0];
210
+ if (subcommand === "list") return listCommand(args.slice(1), dependencies);
211
+ if (subcommand === "get") return getCommand(args.slice(1), dependencies);
212
+ throw new UsageError("Usage: mediaruntime jobs <list|get>");
213
+ }
214
+
215
+ // src/commands/run.ts
216
+ var UNSUCCESSFUL_TERMINAL_STATUSES2 = /* @__PURE__ */ new Set(["FAILED", "REJECTED", "PARTIAL"]);
217
+ var OUTPUT_ALIASES = /* @__PURE__ */ new Set([
218
+ "video.web",
219
+ "video.streaming",
220
+ "video.social",
221
+ "audio.web",
222
+ "audio.transcription",
223
+ "image.web"
224
+ ]);
225
+ function optionValue2(args, index, option) {
226
+ const value = args[index + 1];
227
+ if (!value || value.startsWith("--")) throw new UsageError(`${option} requires a value`);
228
+ return value;
229
+ }
230
+ function parseMetadata(value) {
231
+ let parsed;
232
+ try {
233
+ parsed = JSON.parse(value);
234
+ } catch {
235
+ throw new UsageError("--metadata must be a valid JSON object");
236
+ }
237
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
238
+ throw new UsageError("--metadata must be a JSON object");
239
+ }
240
+ return parsed;
241
+ }
242
+ function parseRunOptions(args) {
243
+ let source;
244
+ const outputs = [];
245
+ let metadata;
246
+ let idempotencyKey;
247
+ let wait = false;
248
+ let timeoutMs;
249
+ let download;
250
+ let force = false;
251
+ let json = false;
252
+ for (let index = 0; index < args.length; index += 1) {
253
+ const argument = args[index];
254
+ if (argument === "--output" || argument === "-o") {
255
+ outputs.push(optionValue2(args, index, argument));
256
+ index += 1;
257
+ } else if (argument === "--metadata") {
258
+ metadata = parseMetadata(optionValue2(args, index, argument));
259
+ index += 1;
260
+ } else if (argument === "--idempotency-key") {
261
+ idempotencyKey = optionValue2(args, index, argument);
262
+ index += 1;
263
+ } else if (argument === "--wait") {
264
+ wait = true;
265
+ } else if (argument === "--timeout-ms") {
266
+ const value = Number(optionValue2(args, index, argument));
267
+ if (!Number.isSafeInteger(value) || value <= 0) {
268
+ throw new UsageError("--timeout-ms must be a positive integer");
269
+ }
270
+ timeoutMs = value;
271
+ index += 1;
272
+ } else if (argument === "--download") {
273
+ download = optionValue2(args, index, argument);
274
+ wait = true;
275
+ index += 1;
276
+ } else if (argument === "--force") {
277
+ force = true;
278
+ } else if (argument === "--json") {
279
+ json = true;
280
+ } else if (argument?.startsWith("-")) {
281
+ throw new UsageError(`Unknown run option: ${argument}`);
282
+ } else if (source === void 0) {
283
+ source = argument;
284
+ } else {
285
+ throw new UsageError("run accepts exactly one source");
286
+ }
287
+ }
288
+ if (!source) throw new UsageError("Usage: mediaruntime run <source> --output <alias> [--wait]");
289
+ if (outputs.length === 0) throw new UsageError("run requires at least one --output");
290
+ const unsupported = outputs.find((output) => !OUTPUT_ALIASES.has(output));
291
+ if (unsupported) throw new UsageError(`Unsupported output alias: ${unsupported}`);
292
+ if (force && !download) throw new UsageError("--force requires --download");
293
+ if (timeoutMs !== void 0 && !wait) throw new UsageError("--timeout-ms requires --wait or --download");
294
+ return {
295
+ source,
296
+ outputs,
297
+ ...metadata === void 0 ? {} : { metadata },
298
+ ...idempotencyKey === void 0 ? {} : { idempotencyKey },
299
+ wait,
300
+ ...timeoutMs === void 0 ? {} : { timeoutMs },
301
+ ...download === void 0 ? {} : { download },
302
+ force,
303
+ json
304
+ };
305
+ }
306
+ function receiptProjection(job) {
307
+ return {
308
+ id: job.id,
309
+ status: job.status,
310
+ tier: job.tier,
311
+ requiredTier: job.requiredTier,
312
+ outputs: job.outputs,
313
+ message: job.message
314
+ };
315
+ }
316
+ function detailsProjection2(job) {
317
+ return {
318
+ id: job.id,
319
+ status: job.status,
320
+ tier: job.tier,
321
+ usage: job.usage,
322
+ billing: job.billing,
323
+ bundle: {
324
+ available: job.bundle.available,
325
+ expiresAt: job.bundle.expiresAt,
326
+ sizeBytes: job.bundle.sizeBytes,
327
+ sha256: job.bundle.sha256,
328
+ retentionDays: job.bundle.retentionDays
329
+ },
330
+ media: job.media,
331
+ metadata: job.metadata,
332
+ error: job.error,
333
+ createdAt: job.createdAt,
334
+ updatedAt: job.updatedAt,
335
+ startedAt: job.startedAt,
336
+ completedAt: job.completedAt
337
+ };
338
+ }
339
+ function writeHumanReceipt(job, write) {
340
+ write(`Job: ${job.id}
341
+ Status: ${job.status}
342
+ Tier: ${job.tier || "-"}
343
+ `);
344
+ }
345
+ function writeHumanDetails(job, write) {
346
+ const error = job.error ? `
347
+ Error: ${job.error}` : "";
348
+ write(
349
+ `Job: ${job.id}
350
+ Status: ${job.status}
351
+ Bundle: ${job.bundle.available ? "available" : "unavailable"}${error}
352
+ `
353
+ );
354
+ }
355
+ async function runCommand(args, dependencies) {
356
+ const options = parseRunOptions(args);
357
+ const params = {
358
+ source: options.source,
359
+ outputs: options.outputs,
360
+ ...options.metadata === void 0 ? {} : { metadata: options.metadata },
361
+ ...options.idempotencyKey === void 0 ? {} : { idempotencyKey: options.idempotencyKey }
362
+ };
363
+ const submitted = await dependencies.jobs.create(params);
364
+ if (!options.wait) {
365
+ if (options.json) dependencies.writeStdout(`${JSON.stringify(receiptProjection(submitted))}
366
+ `);
367
+ else writeHumanReceipt(submitted, dependencies.writeStdout);
368
+ return UNSUCCESSFUL_TERMINAL_STATUSES2.has(String(submitted.status).toUpperCase()) ? 6 : 0;
369
+ }
370
+ const details = await submitted.wait(
371
+ options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs }
372
+ );
373
+ if (!options.json) writeHumanDetails(details, dependencies.writeStdout);
374
+ if (String(details.status).toUpperCase() !== "COMPLETED") {
375
+ if (options.json) dependencies.writeStdout(`${JSON.stringify(detailsProjection2(details))}
376
+ `);
377
+ return 6;
378
+ }
379
+ if (options.download) {
380
+ const url = details.bundle.downloadUrl;
381
+ if (!details.bundle.available || !url) {
382
+ throw new BundleDownloadError("Completed job does not have an available canonical bundle");
383
+ }
384
+ try {
385
+ await dependencies.downloadBundle(url, options.download, {
386
+ force: options.force,
387
+ expectedSizeBytes: details.bundle.sizeBytes,
388
+ expectedSha256: details.bundle.sha256
389
+ });
390
+ } catch (error) {
391
+ if (error instanceof BundleDownloadError) throw error;
392
+ throw new BundleDownloadError(`Could not download bundle to ${options.download}`, { cause: error });
393
+ }
394
+ if (!options.json) dependencies.writeStdout(`Downloaded bundle to ${options.download}
395
+ `);
396
+ }
397
+ if (options.json) dependencies.writeStdout(`${JSON.stringify(detailsProjection2(details))}
398
+ `);
399
+ return 0;
400
+ }
401
+
402
+ // src/commands/trigger.ts
403
+ import { randomBytes, randomUUID } from "crypto";
404
+ import { readFile } from "fs/promises";
405
+
406
+ // src/trigger/destination.ts
407
+ function isLoopbackDestination(value) {
408
+ let hostname = value.hostname.toLowerCase();
409
+ if (hostname.endsWith(".")) hostname = hostname.slice(0, -1);
410
+ if (hostname.startsWith("[") && hostname.endsWith("]")) {
411
+ hostname = hostname.slice(1, -1);
412
+ }
413
+ if (hostname === "localhost" || hostname === "::1" || hostname === "0:0:0:0:0:0:0:1") {
414
+ return true;
415
+ }
416
+ const octets = hostname.split(".");
417
+ if (octets.length !== 4 || octets.some((part) => !/^\d{1,3}$/.test(part))) return false;
418
+ const numbers = octets.map(Number);
419
+ return numbers[0] === 127 && numbers.every((part) => part >= 0 && part <= 255);
420
+ }
421
+ function parseLocalDestination(raw) {
422
+ let destination;
423
+ try {
424
+ destination = new URL(raw);
425
+ } catch {
426
+ throw new UsageError("--to must be an absolute local HTTP(S) URL");
427
+ }
428
+ if (destination.protocol !== "http:" && destination.protocol !== "https:") {
429
+ throw new UsageError("--to must use http:// or https://");
430
+ }
431
+ if (destination.username || destination.password) {
432
+ throw new UsageError("--to must not contain URL credentials");
433
+ }
434
+ if (!isLoopbackDestination(destination)) {
435
+ throw new UsageError(
436
+ "trigger only sends to loopback destinations (localhost, 127.0.0.0/8, or ::1)"
437
+ );
438
+ }
439
+ return destination;
440
+ }
441
+
442
+ // src/trigger/event.ts
443
+ var SUPPORTED_TRIGGER_EVENTS = [
444
+ "job.completed",
445
+ "job.failed",
446
+ "job.rejected"
447
+ ];
448
+ function isTriggerEventType(value) {
449
+ return SUPPORTED_TRIGGER_EVENTS.includes(value);
450
+ }
451
+ function createSyntheticTerminalEvent(type, options) {
452
+ const status = type.slice("job.".length).toUpperCase();
453
+ const occurredAt = new Date(options.timestamp * 1e3).toISOString();
454
+ const base = {
455
+ event_id: options.eventId,
456
+ job_id: options.jobId,
457
+ account_id: options.accountId,
458
+ status
459
+ };
460
+ if (type === "job.completed") {
461
+ const expiresAt = new Date((options.timestamp + 7 * 24 * 60 * 60) * 1e3).toISOString();
462
+ return {
463
+ ...base,
464
+ completedAt: occurredAt,
465
+ delivery: {
466
+ mode: "PULL",
467
+ retentionDays: 7,
468
+ expiresAt,
469
+ bundle: {
470
+ type: "zip",
471
+ filename: `${options.jobId}_outputs.zip`,
472
+ size_bytes: 0,
473
+ sha256: "0".repeat(64),
474
+ download: {
475
+ url: `http://127.0.0.1/__mediaruntime_synthetic__/${encodeURIComponent(options.jobId)}.zip`,
476
+ expiresAt
477
+ }
478
+ },
479
+ layout: "bundle_relative_v1",
480
+ deliverables: [],
481
+ manifests: {}
482
+ },
483
+ billing: { status: "PAID" },
484
+ usage: { units_total: 0 },
485
+ meta: { synthetic: true, request_metadata: {} }
486
+ };
487
+ }
488
+ const failed = type === "job.failed";
489
+ return {
490
+ ...base,
491
+ [failed ? "failedAt" : "rejectedAt"]: occurredAt,
492
+ delivery: { mode: "NONE" },
493
+ billing: { status: "RELEASED" },
494
+ error: {
495
+ code: failed ? "SYNTHETIC_ENGINE_FAILED" : "SYNTHETIC_REJECTED",
496
+ message: `Synthetic ${type} event from the MediaRuntime CLI`
497
+ },
498
+ meta: { synthetic: true, request_metadata: {} }
499
+ };
500
+ }
501
+
502
+ // src/trigger/signature.ts
503
+ import { createHmac } from "crypto";
504
+ var WEBHOOK_HEADERS = {
505
+ id: "X-Transcoder-Id",
506
+ timestamp: "X-Transcoder-Timestamp",
507
+ signature: "X-Transcoder-Signature"
508
+ };
509
+ function signSyntheticWebhook(payload, options) {
510
+ if (!options.eventId.trim()) throw new Error("Webhook event ID must not be empty");
511
+ if (!Number.isInteger(options.timestamp) || options.timestamp < 0) {
512
+ throw new Error("Webhook timestamp must be a non-negative integer");
513
+ }
514
+ if (!options.secret) throw new Error("Webhook secret must not be empty");
515
+ const body = JSON.stringify(payload);
516
+ const bodyBytes = Buffer.from(body, "utf8");
517
+ const prefix = Buffer.from(`${options.timestamp}.${options.eventId}.`, "utf8");
518
+ const digest = createHmac("sha256", options.secret).update(prefix).update(bodyBytes).digest("hex");
519
+ return {
520
+ body,
521
+ headers: {
522
+ "Content-Type": "application/json",
523
+ [WEBHOOK_HEADERS.id]: options.eventId,
524
+ [WEBHOOK_HEADERS.timestamp]: String(options.timestamp),
525
+ [WEBHOOK_HEADERS.signature]: `t=${options.timestamp},v1=${digest}`
526
+ }
527
+ };
528
+ }
529
+
530
+ // src/commands/trigger.ts
531
+ function requireOptionValue(args, index, option) {
532
+ const value = args[index + 1];
533
+ if (!value || value.startsWith("--")) throw new UsageError(`${option} requires a value`);
534
+ return value;
535
+ }
536
+ function parseTriggerArguments(args) {
537
+ const typeValue = args[0];
538
+ if (!typeValue || typeValue.startsWith("--")) {
539
+ throw new UsageError(
540
+ `trigger requires an event type: ${SUPPORTED_TRIGGER_EVENTS.join(", ")}`
541
+ );
542
+ }
543
+ if (!isTriggerEventType(typeValue)) {
544
+ throw new UsageError(
545
+ `Unsupported trigger event ${JSON.stringify(typeValue)}; expected ${SUPPORTED_TRIGGER_EVENTS.join(", ")}`
546
+ );
547
+ }
548
+ let to;
549
+ let secretFile;
550
+ let generateSecret = false;
551
+ let jobId;
552
+ let accountId;
553
+ let json = false;
554
+ for (let index = 1; index < args.length; index += 1) {
555
+ const option = args[index];
556
+ if (!option) continue;
557
+ if (option === "--generate-secret") {
558
+ generateSecret = true;
559
+ continue;
560
+ }
561
+ if (option === "--json") {
562
+ json = true;
563
+ continue;
564
+ }
565
+ if (["--to", "--secret-file", "--job-id", "--account-id"].includes(option)) {
566
+ const value = requireOptionValue(args, index, option);
567
+ index += 1;
568
+ if (option === "--to") to = value;
569
+ else if (option === "--secret-file") secretFile = value;
570
+ else if (option === "--job-id") jobId = value;
571
+ else accountId = value;
572
+ continue;
573
+ }
574
+ throw new UsageError(`Unknown trigger option: ${option}`);
575
+ }
576
+ if (!to) throw new UsageError("trigger requires an explicit --to local URL");
577
+ if (generateSecret && secretFile) {
578
+ throw new UsageError("--generate-secret and --secret-file are mutually exclusive");
579
+ }
580
+ if (generateSecret && json) {
581
+ throw new UsageError("--generate-secret and --json are mutually exclusive");
582
+ }
583
+ if (jobId !== void 0 && !jobId.trim()) throw new UsageError("--job-id must not be empty");
584
+ if (accountId !== void 0 && !accountId.trim()) {
585
+ throw new UsageError("--account-id must not be empty");
586
+ }
587
+ return {
588
+ type: typeValue,
589
+ destination: parseLocalDestination(to),
590
+ ...secretFile === void 0 ? {} : { secretFile },
591
+ generateSecret,
592
+ ...jobId === void 0 ? {} : { jobId: jobId.trim() },
593
+ ...accountId === void 0 ? {} : { accountId: accountId.trim() },
594
+ json
595
+ };
596
+ }
597
+ async function resolveSecret(args, dependencies) {
598
+ if (args.generateSecret) {
599
+ const generated = Buffer.from(dependencies.randomBytes(32)).toString("base64url");
600
+ dependencies.writeStdout(
601
+ `Generated webhook secret (configure the local receiver with this value): ${generated}
602
+ `
603
+ );
604
+ return generated;
605
+ }
606
+ let secret;
607
+ if (args.secretFile) {
608
+ try {
609
+ secret = await dependencies.readFile(args.secretFile, "utf8");
610
+ } catch (error) {
611
+ throw new UsageError(`Could not read webhook secret file: ${args.secretFile}`, { cause: error });
612
+ }
613
+ } else {
614
+ secret = dependencies.env.MEDIARUNTIME_WEBHOOK_SECRET;
615
+ }
616
+ const normalized = secret?.trim();
617
+ if (!normalized) {
618
+ throw new UsageError(
619
+ "Set MEDIARUNTIME_WEBHOOK_SECRET, pass --secret-file, or explicitly use --generate-secret"
620
+ );
621
+ }
622
+ return normalized;
623
+ }
624
+ async function runTriggerCommand(args, dependencies = {}) {
625
+ const parsed = parseTriggerArguments(args);
626
+ const deps = {
627
+ env: dependencies.env ?? process.env,
628
+ fetch: dependencies.fetch ?? globalThis.fetch,
629
+ now: dependencies.now ?? Date.now,
630
+ randomUUID: dependencies.randomUUID ?? randomUUID,
631
+ randomBytes: dependencies.randomBytes ?? randomBytes,
632
+ readFile: dependencies.readFile ?? readFile,
633
+ writeStdout: dependencies.writeStdout ?? ((text) => process.stdout.write(text))
634
+ };
635
+ const secret = await resolveSecret(parsed, deps);
636
+ const timestamp = Math.floor(deps.now() / 1e3);
637
+ const unique = deps.randomUUID();
638
+ const eventId = `evt_cli_${unique}`;
639
+ const jobId = parsed.jobId ?? `job_cli_${unique}`;
640
+ const accountId = parsed.accountId ?? "acc_cli_local";
641
+ const payload = createSyntheticTerminalEvent(parsed.type, {
642
+ eventId,
643
+ jobId,
644
+ accountId,
645
+ timestamp
646
+ });
647
+ const signed = signSyntheticWebhook(payload, { eventId, timestamp, secret });
648
+ const response = await deps.fetch(parsed.destination, {
649
+ method: "POST",
650
+ headers: signed.headers,
651
+ body: signed.body,
652
+ redirect: "manual",
653
+ signal: AbortSignal.timeout(1e4)
654
+ });
655
+ if (!response.ok) {
656
+ throw new TriggerDeliveryError(
657
+ `Local webhook endpoint returned HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`
658
+ );
659
+ }
660
+ if (parsed.json) {
661
+ deps.writeStdout(`${JSON.stringify({
662
+ eventId,
663
+ type: parsed.type,
664
+ jobId,
665
+ status: payload.status,
666
+ destination: parsed.destination.href,
667
+ httpStatus: response.status
668
+ })}
669
+ `);
670
+ } else {
671
+ deps.writeStdout(
672
+ `Sent synthetic ${parsed.type} event ${eventId} to ${parsed.destination.href} (HTTP ${response.status})
673
+ `
674
+ );
675
+ }
676
+ return 0;
677
+ }
678
+
679
+ // src/cli.ts
680
+ var VERSION = "0.1.0";
681
+ var HELP = `MediaRuntime CLI
682
+
683
+ Usage:
684
+ mediaruntime run <source> --output <alias> [--output <alias>] [--wait]
685
+ mediaruntime jobs list [--status <status>] [--limit <n>] [--cursor <cursor>]
686
+ mediaruntime jobs get <job_id> [--download <bundle.zip>] [--force]
687
+ mediaruntime trigger <job.completed|job.failed|job.rejected> --to <local-url>
688
+
689
+ Global options:
690
+ --base-url <url> Developer override; normally leave unset
691
+ --help Show help
692
+ --version Show version
693
+
694
+ Run/jobs options:
695
+ --json Machine-readable, URL-redacted output
696
+
697
+ Authentication uses MEDIARUNTIME_API_KEY.
698
+ `;
699
+ function isFileExistsError(error) {
700
+ return error instanceof Error && "code" in error && error.code === "EEXIST";
701
+ }
702
+ async function exists(path) {
703
+ try {
704
+ await access(path, constants.F_OK);
705
+ return true;
706
+ } catch {
707
+ return false;
708
+ }
709
+ }
710
+ async function downloadBundleAtomically(url, destination, options) {
711
+ const target = resolve(destination);
712
+ if (!options.force && await exists(target)) {
713
+ throw new BundleDownloadError(`Refusing to overwrite existing file: ${destination}`);
714
+ }
715
+ const temporary = resolve(
716
+ dirname(target),
717
+ `.${basename(target)}.${randomUUID2()}.part`
718
+ );
719
+ try {
720
+ let response;
721
+ try {
722
+ response = await fetch(url);
723
+ } catch (error) {
724
+ throw new BundleDownloadError("Bundle download failed before receiving a response", { cause: error });
725
+ }
726
+ if (!response.ok) {
727
+ await response.body?.cancel();
728
+ throw new BundleDownloadError(`Bundle download failed with HTTP ${response.status}`);
729
+ }
730
+ if (!response.body) throw new BundleDownloadError("Bundle download returned an empty response body");
731
+ const hash = createHash("sha256");
732
+ let sizeBytes = 0;
733
+ const integrity = new Transform({
734
+ transform(chunk, _encoding, callback) {
735
+ sizeBytes += chunk.length;
736
+ hash.update(chunk);
737
+ callback(null, chunk);
738
+ }
739
+ });
740
+ await pipeline(
741
+ Readable.fromWeb(response.body),
742
+ integrity,
743
+ createWriteStream(temporary, { flags: "wx", mode: 384 })
744
+ );
745
+ const sha256 = hash.digest("hex");
746
+ if (options.expectedSizeBytes !== void 0 && options.expectedSizeBytes !== null && sizeBytes !== options.expectedSizeBytes) {
747
+ throw new BundleDownloadError(
748
+ `Bundle size mismatch: expected ${options.expectedSizeBytes} bytes, received ${sizeBytes}`
749
+ );
750
+ }
751
+ if (options.expectedSha256 && sha256 !== options.expectedSha256.toLowerCase()) {
752
+ throw new BundleDownloadError("Bundle SHA-256 mismatch");
753
+ }
754
+ if (options.force) {
755
+ await rename(temporary, target);
756
+ } else {
757
+ try {
758
+ await link(temporary, target);
759
+ } catch (error) {
760
+ if (isFileExistsError(error)) {
761
+ throw new BundleDownloadError(`Refusing to overwrite existing file: ${destination}`);
762
+ }
763
+ throw error;
764
+ }
765
+ await rm(temporary);
766
+ }
767
+ } catch (error) {
768
+ await rm(temporary, { force: true });
769
+ if (error instanceof BundleDownloadError) throw error;
770
+ throw new BundleDownloadError(`Could not write bundle to ${destination}`, { cause: error });
771
+ }
772
+ }
773
+ function normalizeBaseUrl(raw) {
774
+ let value;
775
+ try {
776
+ value = new URL(raw);
777
+ } catch {
778
+ throw new UsageError("--base-url must be an absolute HTTP(S) origin");
779
+ }
780
+ if (value.username || value.password || value.search || value.hash || value.pathname !== "/") {
781
+ throw new UsageError("--base-url must be an origin without credentials, path, query, or fragment");
782
+ }
783
+ if (value.protocol !== "https:" && !(value.protocol === "http:" && isLoopbackDestination(value))) {
784
+ throw new UsageError("--base-url must use HTTPS unless it points to loopback");
785
+ }
786
+ return value.origin;
787
+ }
788
+ function extractGlobalOptions(argv) {
789
+ const args = [...argv];
790
+ let baseUrl;
791
+ while (args[0] === "--base-url") {
792
+ const value = args[1];
793
+ if (!value || value.startsWith("--")) throw new UsageError("--base-url requires a value");
794
+ baseUrl = normalizeBaseUrl(value);
795
+ args.splice(0, 2);
796
+ }
797
+ return { ...baseUrl === void 0 ? {} : { baseUrl }, args };
798
+ }
799
+ function safeApiError(error) {
800
+ return {
801
+ name: error.name,
802
+ message: error.message,
803
+ code: error.code,
804
+ status: error.status,
805
+ retryable: error.retryable,
806
+ requestId: error.requestId ?? null,
807
+ field: error.field ?? null
808
+ };
809
+ }
810
+ function exitCodeFor(error) {
811
+ if (error instanceof CliError) return error.exitCode;
812
+ if (error instanceof JobWaitTimeoutError) return 7;
813
+ if (error instanceof MediaRuntimeConnectionError) return 5;
814
+ if (error instanceof MediaRuntimeApiError) {
815
+ if ([401, 402, 403].includes(error.status)) return 3;
816
+ if (error.status >= 500) return 5;
817
+ return 4;
818
+ }
819
+ if (error instanceof DOMException && error.name === "AbortError") return 130;
820
+ if (error instanceof TypeError && /fetch|network|socket|connect/i.test(error.message)) return 5;
821
+ return 1;
822
+ }
823
+ function safeError(error, exitCode) {
824
+ if (error instanceof MediaRuntimeApiError) return { ...safeApiError(error), exitCode };
825
+ return {
826
+ code: error instanceof CliError ? error.code : "cli_error",
827
+ message: error instanceof Error ? error.message : "Unknown error",
828
+ exitCode
829
+ };
830
+ }
831
+ async function executeCli(argv, dependencies = {}) {
832
+ const writeStdout = dependencies.writeStdout ?? ((text) => process.stdout.write(text));
833
+ const writeStderr = dependencies.writeStderr ?? ((text) => process.stderr.write(text));
834
+ const json = argv.includes("--json");
835
+ try {
836
+ const global = extractGlobalOptions(argv);
837
+ const command = global.args[0];
838
+ if (!command || command === "--help" || command === "-h") {
839
+ writeStdout(HELP);
840
+ return 0;
841
+ }
842
+ if (command === "--version" || command === "-v") {
843
+ writeStdout(`${VERSION}
844
+ `);
845
+ return 0;
846
+ }
847
+ if (command === "trigger") {
848
+ return await runTriggerCommand(global.args.slice(1), { writeStdout });
849
+ }
850
+ const configuredBaseUrl = global.baseUrl ?? process.env.MEDIARUNTIME_API_URL;
851
+ const baseUrl = configuredBaseUrl ? normalizeBaseUrl(configuredBaseUrl) : void 0;
852
+ if (!dependencies.createClient && !process.env.MEDIARUNTIME_API_KEY?.trim()) {
853
+ throw new UsageError("Set MEDIARUNTIME_API_KEY before using run or jobs");
854
+ }
855
+ const client = dependencies.createClient?.({
856
+ ...baseUrl === void 0 ? {} : { baseUrl }
857
+ }) ?? new MediaRuntime({ ...baseUrl === void 0 ? {} : { baseUrl } });
858
+ const downloadBundle = dependencies.downloadBundle ?? downloadBundleAtomically;
859
+ const commandDependencies = { jobs: client.jobs, writeStdout, downloadBundle };
860
+ if (command === "run") return await runCommand(global.args.slice(1), commandDependencies);
861
+ if (command === "jobs") {
862
+ return await runJobsCommand(global.args.slice(1), commandDependencies);
863
+ }
864
+ throw new UsageError(`Unknown command: ${command}`);
865
+ } catch (error) {
866
+ const exitCode = exitCodeFor(error);
867
+ if (json) {
868
+ writeStderr(`${JSON.stringify({ error: safeError(error, exitCode) })}
869
+ `);
870
+ } else {
871
+ const requestId = error instanceof MediaRuntimeApiError && error.requestId ? ` (request ${error.requestId})` : "";
872
+ writeStderr(`Error: ${error instanceof Error ? error.message : "Unknown error"}${requestId}
873
+ `);
874
+ }
875
+ return exitCode;
876
+ }
877
+ }
878
+ var isEntrypoint = false;
879
+ if (process.argv[1] !== void 0) {
880
+ try {
881
+ isEntrypoint = realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
882
+ } catch {
883
+ isEntrypoint = false;
884
+ }
885
+ }
886
+ if (isEntrypoint) process.exitCode = await executeCli(process.argv.slice(2));
887
+ export {
888
+ createSyntheticTerminalEvent,
889
+ downloadBundleAtomically,
890
+ executeCli,
891
+ parseTriggerArguments,
892
+ runTriggerCommand,
893
+ signSyntheticWebhook
894
+ };
895
+ //# sourceMappingURL=cli.js.map