@clipdone/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 +91 -0
  2. package/bin/clipdone.js +1424 -0
  3. package/package.json +26 -0
@@ -0,0 +1,1424 @@
1
+ #!/usr/bin/env node
2
+
3
+ // ../api/src/constants.ts
4
+ var API_VERSION = "v1";
5
+ var API_PREFIX = `/api/${API_VERSION}`;
6
+ var API_OAUTH_RESOURCE = "urn:clipdone:agent-api";
7
+ var API_MAX_JSON_BODY_BYTES = 256 * 1024;
8
+ var API_MAX_UPLOAD_FILE_SIZE_BYTES = 500 * 1024 * 1024;
9
+ var API_SCOPES = [
10
+ "projects:read",
11
+ "projects:write",
12
+ "files:write",
13
+ "processing:run",
14
+ "outputs:read"
15
+ ];
16
+ var API_OAUTH_SCOPES = ["openid", "profile", "email", "offline_access", ...API_SCOPES];
17
+
18
+ // ../api/src/client.ts
19
+ var ClipDoneApiError = class extends Error {
20
+ constructor(message, status, code, requestId, details) {
21
+ super(message);
22
+ this.status = status;
23
+ this.code = code;
24
+ this.requestId = requestId;
25
+ this.details = details;
26
+ }
27
+ status;
28
+ code;
29
+ requestId;
30
+ details;
31
+ };
32
+ var ClipDoneClient = class {
33
+ constructor(options) {
34
+ this.options = options;
35
+ this.fetcher = options.fetch ?? globalThis.fetch;
36
+ }
37
+ options;
38
+ fetcher;
39
+ async request(path, init = {}) {
40
+ const token = await this.options.getAccessToken?.();
41
+ const headers = new Headers(init.headers);
42
+ headers.set("Accept", "application/json");
43
+ if (token) headers.set("Authorization", `Bearer ${token}`);
44
+ if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
45
+ if (init.idempotencyKey) headers.set("Idempotency-Key", init.idempotencyKey);
46
+ const response = await this.fetcher(`${this.options.baseUrl.replace(/\/+$/, "")}${API_PREFIX}${path}`, {
47
+ ...init,
48
+ headers
49
+ });
50
+ const payload = await response.json().catch(() => null);
51
+ if (!response.ok) {
52
+ throw new ClipDoneApiError(
53
+ payload?.error?.message ?? `Request failed with HTTP ${response.status}`,
54
+ response.status,
55
+ payload?.error?.code ?? "request_failed",
56
+ payload?.error?.requestId,
57
+ payload?.error?.details
58
+ );
59
+ }
60
+ return payload;
61
+ }
62
+ async listAll(path, collectionKey) {
63
+ const items = [];
64
+ let cursor = null;
65
+ do {
66
+ const separator = path.includes("?") ? "&" : "?";
67
+ const page = await this.request(`${path}${cursor ? `${separator}cursor=${encodeURIComponent(cursor)}&limit=100` : `${separator}limit=100`}`);
68
+ if (Array.isArray(page?.[collectionKey])) items.push(...page[collectionKey]);
69
+ cursor = page?.page?.hasMore === true && typeof page?.page?.nextCursor === "string" ? page.page.nextCursor : null;
70
+ } while (cursor);
71
+ return items;
72
+ }
73
+ getMe() {
74
+ return this.request("/me");
75
+ }
76
+ listProjects(query = "") {
77
+ return this.request(`/projects${query}`);
78
+ }
79
+ createProject(body, idempotencyKey) {
80
+ return this.request("/projects", { method: "POST", body: JSON.stringify(body), idempotencyKey });
81
+ }
82
+ getProject(projectId) {
83
+ return this.request(`/projects/${encodeURIComponent(projectId)}`);
84
+ }
85
+ updateProject(projectId, body) {
86
+ return this.request(`/projects/${encodeURIComponent(projectId)}`, { method: "PATCH", body: JSON.stringify(body) });
87
+ }
88
+ deleteProject(projectId) {
89
+ return this.request(`/projects/${encodeURIComponent(projectId)}`, { method: "DELETE" });
90
+ }
91
+ getStatus(projectId) {
92
+ return this.request(`/projects/${encodeURIComponent(projectId)}/status`);
93
+ }
94
+ listFiles(projectId) {
95
+ return this.request(`/projects/${encodeURIComponent(projectId)}/files`);
96
+ }
97
+ createUpload(projectId, body) {
98
+ return this.request(`/projects/${encodeURIComponent(projectId)}/uploads`, { method: "POST", body: JSON.stringify(body) });
99
+ }
100
+ completeUpload(uploadId) {
101
+ return this.request(`/uploads/${encodeURIComponent(uploadId)}/complete`, { method: "POST" });
102
+ }
103
+ updateFile(fileId, body) {
104
+ return this.request(`/files/${encodeURIComponent(fileId)}`, { method: "PATCH", body: JSON.stringify(body) });
105
+ }
106
+ deleteFile(fileId) {
107
+ return this.request(`/files/${encodeURIComponent(fileId)}`, { method: "DELETE" });
108
+ }
109
+ createRun(projectId, body, idempotencyKey) {
110
+ return this.request(`/projects/${encodeURIComponent(projectId)}/runs`, { method: "POST", body: JSON.stringify(body), idempotencyKey });
111
+ }
112
+ listRuns(projectId) {
113
+ return this.request(`/projects/${encodeURIComponent(projectId)}/runs`);
114
+ }
115
+ resumeRun(runId) {
116
+ return this.request(`/runs/${encodeURIComponent(runId)}/resume`, { method: "POST" });
117
+ }
118
+ listOutputs(projectId) {
119
+ return this.request(`/projects/${encodeURIComponent(projectId)}/outputs`);
120
+ }
121
+ createDownloadUrl(outputId, body = {}) {
122
+ return this.request(`/outputs/${encodeURIComponent(outputId)}/download-url`, { method: "POST", body: JSON.stringify(body) });
123
+ }
124
+ };
125
+
126
+ // src/clipdone.js
127
+ import http from "node:http";
128
+ import {
129
+ createReadStream,
130
+ createWriteStream,
131
+ existsSync,
132
+ chmodSync,
133
+ lstatSync,
134
+ mkdirSync,
135
+ readFileSync,
136
+ statSync,
137
+ writeFileSync
138
+ } from "node:fs";
139
+ import { basename, dirname, join } from "node:path";
140
+ import { homedir, platform } from "node:os";
141
+ import { spawn } from "node:child_process";
142
+ import { webcrypto } from "node:crypto";
143
+ import { Readable } from "node:stream";
144
+ import { pipeline } from "node:stream/promises";
145
+ import { fileURLToPath } from "node:url";
146
+ var CONFIG_PATH = join(homedir(), ".clipdone", "config.json");
147
+ var OAUTH_AUTHORIZE_PATH = "/api/auth/oauth2/authorize";
148
+ var OAUTH_REGISTER_PATH = "/api/auth/oauth2/register";
149
+ var OAUTH_TOKEN_PATH = "/api/auth/oauth2/token";
150
+ var OAUTH_REVOKE_PATH = "/api/auth/oauth2/revoke";
151
+ var PRODUCTION_APP_URL = "https://app.clipdone.app";
152
+ var LOCAL_APP_URL = "http://localhost:3000";
153
+ var REQUEST_TIMEOUT_MS = 15e3;
154
+ var REQUEST_GET_RETRY_COUNT = 1;
155
+ var runtimeArgs = {};
156
+ var configCache;
157
+ var BIN_DIR = dirname(fileURLToPath(import.meta.url));
158
+ var SHORT_FLAGS = {
159
+ h: "help",
160
+ p: "project",
161
+ n: "name",
162
+ t: "type",
163
+ o: "out",
164
+ f: "feedback",
165
+ k: "key"
166
+ };
167
+ function parseArgs(argv) {
168
+ const args = { _: [] };
169
+ for (let i = 0; i < argv.length; i += 1) {
170
+ const value = argv[i];
171
+ if (!value.startsWith("-") || value === "-") {
172
+ args._.push(value);
173
+ continue;
174
+ }
175
+ const key = value.startsWith("--") ? value.slice(2) : SHORT_FLAGS[value.slice(1)] ?? value.slice(1);
176
+ const next = argv[i + 1];
177
+ if (!next || next.startsWith("-")) {
178
+ args[key] = true;
179
+ } else {
180
+ args[key] = next;
181
+ i += 1;
182
+ }
183
+ }
184
+ return args;
185
+ }
186
+ function cloneJsonValue(value) {
187
+ if (value === null || value === void 0) return value;
188
+ return JSON.parse(JSON.stringify(value));
189
+ }
190
+ function readConfig() {
191
+ if (configCache) {
192
+ return cloneJsonValue(configCache);
193
+ }
194
+ let parsed = {};
195
+ if (existsSync(CONFIG_PATH)) {
196
+ assertSafeConfigPath();
197
+ hardenConfigPathPermissions();
198
+ try {
199
+ parsed = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
200
+ } catch {
201
+ parsed = {};
202
+ }
203
+ }
204
+ configCache = cloneJsonValue(parsed);
205
+ return cloneJsonValue(parsed);
206
+ }
207
+ function writeConfig(config) {
208
+ hardenConfigPathPermissions();
209
+ assertSafeConfigPath();
210
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}
211
+ `, { mode: 384 });
212
+ hardenConfigPathPermissions();
213
+ configCache = cloneJsonValue(config);
214
+ }
215
+ function assertSafeConfigPath() {
216
+ if (!existsSync(CONFIG_PATH)) return;
217
+ const stats = lstatSync(CONFIG_PATH);
218
+ if (stats.isSymbolicLink() || !stats.isFile()) {
219
+ throw new Error(`Refusing to use unsafe ClipDone config path: ${CONFIG_PATH}`);
220
+ }
221
+ }
222
+ function hardenConfigPathPermissions() {
223
+ const configDir = dirname(CONFIG_PATH);
224
+ mkdirSync(configDir, { recursive: true, mode: 448 });
225
+ try {
226
+ chmodSync(configDir, 448);
227
+ } catch {
228
+ }
229
+ if (existsSync(CONFIG_PATH)) {
230
+ assertSafeConfigPath();
231
+ try {
232
+ chmodSync(CONFIG_PATH, 384);
233
+ } catch {
234
+ }
235
+ }
236
+ }
237
+ function clearStoredAuth() {
238
+ const config = readConfig();
239
+ delete config.token;
240
+ delete config.tokenExpiresAt;
241
+ delete config.oauth;
242
+ writeConfig(config);
243
+ }
244
+ function readStoredOAuthClientRegistration(scope, resource) {
245
+ const registration = readConfig().oauthClient;
246
+ if (!registration || typeof registration !== "object") return null;
247
+ const clientId = normalizeString(registration.clientId);
248
+ const apiUrl = normalizeString(registration.apiUrl);
249
+ const storedScope = normalizeString(registration.scope);
250
+ const storedResource = normalizeString(registration.resource);
251
+ if (!clientId || !apiUrl || !storedScope || !storedResource) return null;
252
+ if (apiUrl !== apiBase()) return null;
253
+ if (storedScope !== normalizeString(scope)) return null;
254
+ if (storedResource !== normalizeString(resource)) return null;
255
+ return { clientId };
256
+ }
257
+ function writeStoredOAuthClientRegistration(clientId, scope, resource) {
258
+ const normalizedClientId = normalizeString(clientId);
259
+ const normalizedScope = normalizeString(scope);
260
+ const normalizedResource = normalizeString(resource);
261
+ if (!normalizedClientId || !normalizedScope || !normalizedResource) return;
262
+ const config = readConfig();
263
+ config.oauthClient = {
264
+ clientId: normalizedClientId,
265
+ apiUrl: apiBase(),
266
+ scope: normalizedScope,
267
+ resource: normalizedResource
268
+ };
269
+ writeConfig(config);
270
+ }
271
+ var CliHttpError = class extends Error {
272
+ constructor(message, options = {}) {
273
+ super(message);
274
+ this.name = "CliHttpError";
275
+ this.status = Number.isFinite(options.status) ? options.status : null;
276
+ this.errorCode = typeof options.errorCode === "string" ? options.errorCode : null;
277
+ this.transient = options.transient === true;
278
+ }
279
+ };
280
+ function createCliHttpError(message, options = {}) {
281
+ return new CliHttpError(message, options);
282
+ }
283
+ function extractErrorCode(data) {
284
+ return data && typeof data === "object" && typeof data.error === "string" && data.error.trim() ? data.error.trim() : null;
285
+ }
286
+ function shouldClearStoredAuthOnRefreshError(error) {
287
+ return error instanceof CliHttpError && (error.errorCode === "invalid_grant" || error.errorCode === "invalid_client");
288
+ }
289
+ async function fetchWithTimeout(url, init = {}, options = {}) {
290
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : REQUEST_TIMEOUT_MS;
291
+ const retries = Number.isFinite(options.retries) ? options.retries : 0;
292
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
293
+ const controller = new AbortController();
294
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
295
+ try {
296
+ return await fetch(url, {
297
+ ...init,
298
+ signal: controller.signal
299
+ });
300
+ } catch (error) {
301
+ const timedOut = error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError");
302
+ if (attempt >= retries) {
303
+ throw createCliHttpError(
304
+ timedOut ? `Request timed out after ${Math.round(timeoutMs / 1e3)}s.` : `Could not reach ${apiTargetLabel()}.`,
305
+ { transient: true }
306
+ );
307
+ }
308
+ } finally {
309
+ clearTimeout(timeout);
310
+ }
311
+ }
312
+ throw createCliHttpError(`Could not reach ${apiTargetLabel()}.`, { transient: true });
313
+ }
314
+ function resolveTarget() {
315
+ if (runtimeArgs.local === true) return { target: "local", locked: true, dev: true };
316
+ if (runtimeArgs.prod === true || runtimeArgs.production === true) return { target: "production", locked: true, dev: false };
317
+ const env = String(process.env.CLIPDONE_ENV || process.env.CLIPDONE_TARGET || "").trim().toLowerCase();
318
+ if (env === "local" || env === "dev" || env === "development") return { target: "local", locked: true, dev: true };
319
+ if (env === "prod" || env === "production") return { target: "production", locked: true, dev: false };
320
+ if (existsSync(join(BIN_DIR, "..", "src", "clipdone.js"))) {
321
+ return { target: "local", locked: false, dev: true };
322
+ }
323
+ return { target: "production", locked: false, dev: false };
324
+ }
325
+ function envUrl(...names) {
326
+ for (const name of names) {
327
+ const value = process.env[name]?.trim();
328
+ if (value) return value;
329
+ }
330
+ return void 0;
331
+ }
332
+ function defaultBaseForTarget(kind, target) {
333
+ if (target === "local") {
334
+ if (kind === "api") {
335
+ return envUrl("CLIPDONE_API_URL") || LOCAL_APP_URL;
336
+ }
337
+ return envUrl("CLIPDONE_APP_URL") || LOCAL_APP_URL;
338
+ }
339
+ if (kind === "api") return envUrl("CLIPDONE_API_URL") || PRODUCTION_APP_URL;
340
+ return envUrl("CLIPDONE_APP_URL") || PRODUCTION_APP_URL;
341
+ }
342
+ function configuredBase(kind) {
343
+ const config = readConfig();
344
+ const { target, locked } = resolveTarget();
345
+ const targetDefault = defaultBaseForTarget(kind, target);
346
+ const override = kind === "api" ? runtimeArgs["api-url"] : runtimeArgs["app-url"];
347
+ if (override) return override;
348
+ if (target === "production") return targetDefault;
349
+ if (kind === "api") {
350
+ return locked ? targetDefault : config.apiUrl || targetDefault;
351
+ }
352
+ return locked ? targetDefault : config.appUrl || targetDefault;
353
+ }
354
+ function apiBase() {
355
+ return configuredBase("api").replace(/\/+$/, "");
356
+ }
357
+ function appBase() {
358
+ return configuredBase("app").replace(/\/+$/, "");
359
+ }
360
+ function normalizeString(value) {
361
+ return typeof value === "string" && value.trim() ? value.trim() : null;
362
+ }
363
+ function getManualTokenOverride() {
364
+ return normalizeString(process.env.CLIPDONE_TOKEN);
365
+ }
366
+ function apiTargetLabel() {
367
+ return resolveTarget().target === "local" ? "the local ClipDone API" : "ClipDone";
368
+ }
369
+ function targetLabel() {
370
+ return resolveTarget().target === "local" ? "local" : "production";
371
+ }
372
+ function readStoredOAuthSession() {
373
+ const oauth = readConfig().oauth;
374
+ if (!oauth || typeof oauth !== "object") return null;
375
+ const accessToken = normalizeString(oauth.accessToken);
376
+ const refreshToken = normalizeString(oauth.refreshToken);
377
+ const clientId = normalizeString(oauth.clientId);
378
+ const scope = normalizeString(oauth.scope);
379
+ const resource = normalizeString(oauth.resource);
380
+ if (!accessToken || !refreshToken || !clientId || !scope || !resource) {
381
+ return null;
382
+ }
383
+ const accessTokenExpiresAt = Number(oauth.accessTokenExpiresAt);
384
+ return {
385
+ accessToken,
386
+ refreshToken,
387
+ clientId,
388
+ scope,
389
+ resource,
390
+ accessTokenExpiresAt: Number.isFinite(accessTokenExpiresAt) ? accessTokenExpiresAt : 0
391
+ };
392
+ }
393
+ function writeStoredOAuthSession(session) {
394
+ const config = readConfig();
395
+ delete config.token;
396
+ delete config.tokenExpiresAt;
397
+ config.oauth = {
398
+ accessToken: session.accessToken,
399
+ refreshToken: session.refreshToken,
400
+ clientId: session.clientId,
401
+ scope: session.scope,
402
+ resource: session.resource,
403
+ accessTokenExpiresAt: session.accessTokenExpiresAt
404
+ };
405
+ config.apiUrl = apiBase();
406
+ config.appUrl = appBase();
407
+ writeConfig(config);
408
+ }
409
+ function hasStoredAuth() {
410
+ return Boolean(getManualTokenOverride() || readStoredOAuthSession());
411
+ }
412
+ function authFailureMessage() {
413
+ if (getManualTokenOverride()) {
414
+ return "The current `CLIPDONE_TOKEN` is not valid. Update it or run `clipdone login` without that override.";
415
+ }
416
+ return "Stored CLI login is no longer valid. Run `clipdone login` again.";
417
+ }
418
+ function parseResponseBody(text) {
419
+ if (!text) return null;
420
+ try {
421
+ return JSON.parse(text);
422
+ } catch {
423
+ return text;
424
+ }
425
+ }
426
+ function isHtmlDocument(value) {
427
+ if (typeof value !== "string") return false;
428
+ const normalized = value.trim().toLowerCase();
429
+ return normalized.startsWith("<!doctype html") || normalized.startsWith("<html");
430
+ }
431
+ function compactText(value, maxLength = 220) {
432
+ if (typeof value !== "string") return null;
433
+ const normalized = value.replace(/\s+/g, " ").trim();
434
+ if (!normalized) return null;
435
+ return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 1)}\u2026` : normalized;
436
+ }
437
+ function summarizeHttpFailure(response, data, fallbackLabel) {
438
+ const contentType = response.headers.get("content-type") || "";
439
+ const statusLabel = response.statusText ? ` ${response.statusText}` : "";
440
+ if (data && typeof data === "object") {
441
+ if (typeof data.error_description === "string" && data.error_description.trim()) {
442
+ return data.error_description.trim();
443
+ }
444
+ if (typeof data.error === "string" && data.error.trim()) {
445
+ return data.error.trim();
446
+ }
447
+ if (typeof data.message === "string" && data.message.trim()) {
448
+ return data.message.trim();
449
+ }
450
+ }
451
+ if (typeof data === "string") {
452
+ const compact = compactText(data);
453
+ if (compact && !isHtmlDocument(compact) && !contentType.toLowerCase().includes("text/html")) {
454
+ return compact;
455
+ }
456
+ }
457
+ if (response.status === 404) {
458
+ return `${fallbackLabel} failed with HTTP 404. The selected ${targetLabel()} target is likely not serving the ClipDone API routes.`;
459
+ }
460
+ return `${fallbackLabel} failed with HTTP ${response.status}${statusLabel ? statusLabel : ""}.`;
461
+ }
462
+ function normalizeOAuthExpiry(data) {
463
+ const expiresAt = Number(data?.expires_at);
464
+ if (Number.isFinite(expiresAt) && expiresAt > 0) {
465
+ return expiresAt > 1e12 ? expiresAt : expiresAt * 1e3;
466
+ }
467
+ const expiresIn = Number(data?.expires_in);
468
+ if (Number.isFinite(expiresIn) && expiresIn > 0) {
469
+ return Date.now() + expiresIn * 1e3;
470
+ }
471
+ return Date.now() + 60 * 60 * 1e3;
472
+ }
473
+ function parseOAuthTokenPayload(data, fallback) {
474
+ const accessToken = normalizeString(data?.access_token);
475
+ const refreshToken = normalizeString(data?.refresh_token) || fallback?.refreshToken || null;
476
+ const clientId = normalizeString(fallback?.clientId);
477
+ const scope = normalizeString(data?.scope) || fallback?.scope || null;
478
+ const resource = normalizeString(fallback?.resource);
479
+ if (!accessToken || !refreshToken || !clientId || !scope || !resource) {
480
+ throw new Error("OAuth token response was incomplete.");
481
+ }
482
+ return {
483
+ accessToken,
484
+ refreshToken,
485
+ clientId,
486
+ scope,
487
+ resource,
488
+ accessTokenExpiresAt: normalizeOAuthExpiry(data)
489
+ };
490
+ }
491
+ async function oauthTokenRequest(params, fallbackSession) {
492
+ let response;
493
+ try {
494
+ response = await fetchWithTimeout(`${apiBase()}${OAUTH_TOKEN_PATH}`, {
495
+ method: "POST",
496
+ headers: {
497
+ Accept: "application/json",
498
+ "Content-Type": "application/x-www-form-urlencoded"
499
+ },
500
+ body: new URLSearchParams(params).toString()
501
+ });
502
+ } catch (error) {
503
+ if (error instanceof CliHttpError) throw error;
504
+ throw createCliHttpError(`Could not reach ${apiTargetLabel()}.`, { transient: true });
505
+ }
506
+ const text = await response.text();
507
+ const data = parseResponseBody(text);
508
+ if (!response.ok) {
509
+ throw createCliHttpError(summarizeHttpFailure(response, data, "OAuth token request"), {
510
+ status: response.status,
511
+ errorCode: extractErrorCode(data),
512
+ transient: response.status >= 500
513
+ });
514
+ }
515
+ return parseOAuthTokenPayload(data, fallbackSession);
516
+ }
517
+ async function oauthRegisterClient({ scope }) {
518
+ let response;
519
+ try {
520
+ response = await fetchWithTimeout(`${apiBase()}${OAUTH_REGISTER_PATH}`, {
521
+ method: "POST",
522
+ headers: {
523
+ Accept: "application/json",
524
+ "Content-Type": "application/json"
525
+ },
526
+ body: JSON.stringify({
527
+ client_name: "ClipDone CLI",
528
+ redirect_uris: ["http://127.0.0.1/callback"],
529
+ grant_types: ["authorization_code", "refresh_token"],
530
+ response_types: ["code"],
531
+ token_endpoint_auth_method: "none",
532
+ type: "native",
533
+ scope
534
+ })
535
+ });
536
+ } catch (error) {
537
+ if (error instanceof CliHttpError) throw error;
538
+ throw createCliHttpError(`Could not reach ${apiTargetLabel()}.`, { transient: true });
539
+ }
540
+ const text = await response.text();
541
+ const data = parseResponseBody(text);
542
+ if (!response.ok) {
543
+ throw createCliHttpError(summarizeHttpFailure(response, data, "OAuth client registration"), {
544
+ status: response.status,
545
+ errorCode: extractErrorCode(data),
546
+ transient: response.status >= 500
547
+ });
548
+ }
549
+ const clientId = normalizeString(data?.client_id);
550
+ if (!clientId) {
551
+ throw new Error("OAuth client registration did not return a client_id.");
552
+ }
553
+ return { clientId };
554
+ }
555
+ async function resolveOAuthClient(authConfig) {
556
+ const cached = readStoredOAuthClientRegistration(authConfig?.scope, authConfig?.resource);
557
+ if (cached) {
558
+ return cached;
559
+ }
560
+ const registered = await oauthRegisterClient({ scope: authConfig.scope });
561
+ writeStoredOAuthClientRegistration(registered.clientId, authConfig.scope, authConfig.resource);
562
+ return registered;
563
+ }
564
+ async function refreshStoredAccessToken(force = false) {
565
+ const manualToken = getManualTokenOverride();
566
+ if (manualToken) return manualToken;
567
+ const session = readStoredOAuthSession();
568
+ if (!session) return null;
569
+ if (!force && session.accessTokenExpiresAt > Date.now() + 6e4) {
570
+ return session.accessToken;
571
+ }
572
+ try {
573
+ const refreshed = await oauthTokenRequest(
574
+ {
575
+ grant_type: "refresh_token",
576
+ client_id: session.clientId,
577
+ refresh_token: session.refreshToken,
578
+ resource: session.resource
579
+ },
580
+ session
581
+ );
582
+ writeStoredOAuthSession(refreshed);
583
+ return refreshed.accessToken;
584
+ } catch (error) {
585
+ if (shouldClearStoredAuthOnRefreshError(error)) {
586
+ clearStoredAuth();
587
+ throw new Error(authFailureMessage());
588
+ }
589
+ if (error instanceof Error) throw error;
590
+ throw new Error("Could not refresh the CLI login.");
591
+ }
592
+ }
593
+ async function revokeOAuthToken(token, clientId, hint) {
594
+ if (!token || !clientId) return;
595
+ let response;
596
+ try {
597
+ response = await fetchWithTimeout(`${apiBase()}${OAUTH_REVOKE_PATH}`, {
598
+ method: "POST",
599
+ headers: {
600
+ Accept: "application/json",
601
+ "Content-Type": "application/x-www-form-urlencoded"
602
+ },
603
+ body: new URLSearchParams({
604
+ client_id: clientId,
605
+ token,
606
+ token_type_hint: hint
607
+ }).toString()
608
+ });
609
+ } catch (error) {
610
+ if (error instanceof CliHttpError) throw error;
611
+ throw createCliHttpError(`Could not reach ${apiTargetLabel()}.`, { transient: true });
612
+ }
613
+ if (!response.ok) {
614
+ const text = await response.text();
615
+ const data = parseResponseBody(text);
616
+ throw createCliHttpError(summarizeHttpFailure(response, data, "OAuth revocation"), {
617
+ status: response.status,
618
+ errorCode: extractErrorCode(data),
619
+ transient: response.status >= 500
620
+ });
621
+ }
622
+ }
623
+ async function request(path, options = {}) {
624
+ const authMode = options.auth === false || options.auth === "none" ? "none" : options.auth === "optional" ? "optional" : "required";
625
+ const manualToken = getManualTokenOverride();
626
+ const token = authMode === "none" ? null : manualToken || await refreshStoredAccessToken(false);
627
+ if (authMode === "required" && !token) {
628
+ throw new Error("Not logged in. Run `clipdone login` first.");
629
+ }
630
+ const execute = async (bearerToken) => {
631
+ try {
632
+ const client = new ClipDoneClient({
633
+ baseUrl: apiBase(),
634
+ getAccessToken: () => bearerToken,
635
+ fetch: (url, init) => fetchWithTimeout(url, init, {
636
+ retries: (options.method || "GET").toUpperCase() === "GET" ? REQUEST_GET_RETRY_COUNT : 0
637
+ })
638
+ });
639
+ return await client.request(path, {
640
+ method: options.method || "GET",
641
+ headers: options.headers,
642
+ body: options.body ? JSON.stringify(options.body) : void 0,
643
+ idempotencyKey: options.idempotencyKey
644
+ });
645
+ } catch (error) {
646
+ if (error instanceof ClipDoneApiError) throw error;
647
+ if (error instanceof CliHttpError) throw error;
648
+ throw createCliHttpError(`Could not reach ${apiTargetLabel()}.`, { transient: true });
649
+ }
650
+ };
651
+ try {
652
+ return await execute(token);
653
+ } catch (error) {
654
+ if (error instanceof ClipDoneApiError && error.status === 401 && !manualToken && authMode !== "none" && readStoredOAuthSession()) {
655
+ try {
656
+ return await execute(await refreshStoredAccessToken(true));
657
+ } catch (retryError) {
658
+ error = retryError;
659
+ }
660
+ }
661
+ if (error instanceof ClipDoneApiError && error.status === 401) {
662
+ if (!manualToken) {
663
+ clearStoredAuth();
664
+ if (authMode === "optional") return null;
665
+ }
666
+ throw new Error(authFailureMessage());
667
+ }
668
+ throw error;
669
+ }
670
+ }
671
+ async function requestAll(path, collectionKey) {
672
+ const values = [];
673
+ let cursor = null;
674
+ do {
675
+ const separator = path.includes("?") ? "&" : "?";
676
+ const page = await request(`${path}${separator}limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`);
677
+ if (Array.isArray(page?.[collectionKey])) values.push(...page[collectionKey]);
678
+ cursor = page?.page?.hasMore === true && typeof page?.page?.nextCursor === "string" ? page.page.nextCursor : null;
679
+ } while (cursor);
680
+ return values;
681
+ }
682
+ function printJson(value) {
683
+ process.stdout.write(`${JSON.stringify(value, null, 2)}
684
+ `);
685
+ }
686
+ function writeLine(text = "") {
687
+ process.stdout.write(`${text}
688
+ `);
689
+ }
690
+ function output(value) {
691
+ printJson(value);
692
+ }
693
+ function formatBytes(bytes) {
694
+ if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0) return null;
695
+ const units = ["B", "KB", "MB", "GB", "TB"];
696
+ let value = bytes;
697
+ let index = 0;
698
+ while (value >= 1024 && index < units.length - 1) {
699
+ value /= 1024;
700
+ index += 1;
701
+ }
702
+ return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
703
+ }
704
+ function normalizeStatus(value) {
705
+ const text = String(value ?? "").trim();
706
+ if (!text) return null;
707
+ return text.toLowerCase();
708
+ }
709
+ function pickObjectEntries(entries) {
710
+ return Object.fromEntries(entries.filter(([, value]) => value !== void 0 && value !== null && value !== ""));
711
+ }
712
+ function buildMeView(data) {
713
+ if (!data || data.authenticated !== true) {
714
+ return { authenticated: false };
715
+ }
716
+ return pickObjectEntries([
717
+ ["authenticated", true],
718
+ ["name", data?.name ?? void 0],
719
+ ["email", data?.email ?? void 0],
720
+ ["company", data?.company ?? void 0]
721
+ ]);
722
+ }
723
+ function buildUploadView(data, args, stats, fileName, category) {
724
+ return pickObjectEntries([
725
+ ["message", "Upload complete."],
726
+ ["projectId", args.project],
727
+ ["fileId", data?.fileId ? String(data.fileId) : void 0],
728
+ ["type", category],
729
+ ["file", fileName],
730
+ ["sizeBytes", stats.size],
731
+ ["size", formatBytes(stats.size)]
732
+ ]);
733
+ }
734
+ function buildProcessView(data, args, command) {
735
+ return pickObjectEntries([
736
+ ["message", command === "revise" ? "Revision queued." : "Processing started."],
737
+ ["projectId", args.project],
738
+ ["queued", data?.queued === true],
739
+ ["currentStage", normalizeStatus(data?.bootstrapStage)],
740
+ ["timelineVersionId", data?.timelineVersionId ? String(data.timelineVersionId) : void 0],
741
+ ["renderSkipped", data?.skippedRender === true]
742
+ ]);
743
+ }
744
+ function buildDownloadView(data, outPath) {
745
+ return outPath ? { message: `Downloaded to ${outPath}`, path: outPath } : pickObjectEntries([["downloadUrl", data?.url ?? void 0]]);
746
+ }
747
+ var AUTH_COMMANDS = /* @__PURE__ */ new Set(["login", "me", "logout", "revoke"]);
748
+ var PROJECT_RESOURCE_COMMANDS = /* @__PURE__ */ new Set(["project", "projects"]);
749
+ var FILE_RESOURCE_COMMANDS = /* @__PURE__ */ new Set(["file", "files"]);
750
+ var OUTPUT_RESOURCE_COMMANDS = /* @__PURE__ */ new Set(["output", "outputs"]);
751
+ function commandUsageError(message, usage) {
752
+ return new Error(usage ? `${message}
753
+ Usage: ${usage}` : message);
754
+ }
755
+ function requireExactPositionals(parts, count, usage) {
756
+ if (parts.length !== count) {
757
+ throw commandUsageError("Unexpected positional arguments.", usage);
758
+ }
759
+ }
760
+ function normalizeProjectAction(value) {
761
+ if (value === "show") return "get";
762
+ if (value === "run") return "process";
763
+ if (["get", "create", "update", "delete", "upload", "files", "process", "revise", "status", "outputs", "runs"].includes(value)) {
764
+ return value;
765
+ }
766
+ return null;
767
+ }
768
+ function normalizeFileAction(value) {
769
+ if (value === "remove") return "delete";
770
+ if (["update", "delete"].includes(value)) return value;
771
+ return null;
772
+ }
773
+ function normalizeOutputAction(value) {
774
+ if (["download"].includes(value)) return value;
775
+ return null;
776
+ }
777
+ function parseProjectScopedCommand(args, parts, action, actionIndex) {
778
+ const canonicalAction = normalizeProjectAction(action);
779
+ if (!canonicalAction) {
780
+ throw commandUsageError(
781
+ `Unknown projects action: ${action}.`,
782
+ "clipdone projects [list|create|<project_id> [get|update|delete|upload|files|process|revise|status|outputs|runs]]"
783
+ );
784
+ }
785
+ if (canonicalAction === "create") {
786
+ requireExactPositionals(parts, actionIndex + 1, "clipdone projects create [--name <name>] [--description <text>]");
787
+ return { key: "project-create" };
788
+ }
789
+ if (!args.project) {
790
+ throw commandUsageError(`Missing project ID for \`${canonicalAction}\`.`, `clipdone projects <project_id> ${canonicalAction}`);
791
+ }
792
+ if (canonicalAction === "upload") {
793
+ const fileIndex = actionIndex + 1;
794
+ if (typeof parts[fileIndex] !== "string") {
795
+ throw commandUsageError("Missing file path.", "clipdone projects <project_id> upload <file> --type footage|music|script");
796
+ }
797
+ requireExactPositionals(parts, fileIndex + 1, "clipdone projects <project_id> upload <file> --type footage|music|script");
798
+ if (!args.file) args.file = parts[fileIndex];
799
+ return { key: "project-upload" };
800
+ }
801
+ requireExactPositionals(parts, actionIndex + 1, `clipdone projects <project_id> ${canonicalAction}`);
802
+ return { key: canonicalAction === "get" ? "project-get" : `project-${canonicalAction}` };
803
+ }
804
+ function parseProjectCommand(args, parts) {
805
+ if (parts.length === 1) {
806
+ return { key: "projects-list" };
807
+ }
808
+ const second = parts[1];
809
+ if (second === "list" || second === "ls") {
810
+ requireExactPositionals(parts, 2, "clipdone projects list");
811
+ return { key: "projects-list" };
812
+ }
813
+ if (second === "create") {
814
+ return parseProjectScopedCommand(args, parts, "create", 1);
815
+ }
816
+ const secondAction = normalizeProjectAction(second);
817
+ if (secondAction && secondAction !== "create") {
818
+ if (typeof parts[2] !== "string") {
819
+ throw commandUsageError(`Missing project ID for \`${secondAction}\`.`, `clipdone projects <project_id> ${secondAction}`);
820
+ }
821
+ if (!args.project) args.project = parts[2];
822
+ if (secondAction === "upload") {
823
+ if (typeof parts[3] !== "string") {
824
+ throw commandUsageError("Missing file path.", "clipdone project upload <project_id> <file> --type footage|music|script");
825
+ }
826
+ requireExactPositionals(parts, 4, "clipdone project upload <project_id> <file> --type footage|music|script");
827
+ if (!args.file) args.file = parts[3];
828
+ return { key: "project-upload" };
829
+ }
830
+ requireExactPositionals(parts, 3, `clipdone project ${secondAction} <project_id>`);
831
+ return { key: secondAction === "get" ? "project-get" : `project-${secondAction}` };
832
+ }
833
+ if (!args.project) args.project = second;
834
+ if (parts.length === 2) {
835
+ return { key: "project-get" };
836
+ }
837
+ return parseProjectScopedCommand(args, parts, parts[2], 2);
838
+ }
839
+ function parseFileCommand(args, parts) {
840
+ if (parts.length === 1) {
841
+ if (args.project) {
842
+ return { key: "project-files" };
843
+ }
844
+ throw commandUsageError(
845
+ "Files are listed per project, and file mutations require a file ID.",
846
+ "clipdone projects <project_id> files | clipdone files <file_id> update | clipdone files <file_id> delete"
847
+ );
848
+ }
849
+ const secondAction = normalizeFileAction(parts[1]);
850
+ if (secondAction) {
851
+ if (typeof parts[2] !== "string") {
852
+ throw commandUsageError(`Missing file ID for \`${secondAction}\`.`, `clipdone files <file_id> ${secondAction}`);
853
+ }
854
+ if (!args.file) args.file = parts[2];
855
+ requireExactPositionals(parts, 3, `clipdone files <file_id> ${secondAction}`);
856
+ return { key: `file-${secondAction}` };
857
+ }
858
+ if (!args.file) args.file = parts[1];
859
+ const action = normalizeFileAction(parts[2]);
860
+ if (!action) {
861
+ throw commandUsageError(
862
+ `Unknown files action: ${String(parts[2] ?? "") || "(missing)"}.`,
863
+ "clipdone files <file_id> update | clipdone files <file_id> delete"
864
+ );
865
+ }
866
+ requireExactPositionals(parts, 3, `clipdone files <file_id> ${action}`);
867
+ return { key: `file-${action}` };
868
+ }
869
+ function parseOutputCommand(args, parts) {
870
+ if (parts.length === 1) {
871
+ if (args.project) {
872
+ return { key: "project-outputs" };
873
+ }
874
+ throw commandUsageError(
875
+ "Outputs are listed per project, and output downloads require an output ID.",
876
+ "clipdone projects <project_id> outputs | clipdone outputs <output_id> download"
877
+ );
878
+ }
879
+ const secondAction = normalizeOutputAction(parts[1]);
880
+ if (secondAction) {
881
+ if (typeof parts[2] !== "string") {
882
+ throw commandUsageError("Missing output ID.", "clipdone outputs <output_id> download");
883
+ }
884
+ if (!args.output) args.output = parts[2];
885
+ requireExactPositionals(parts, 3, "clipdone outputs <output_id> download");
886
+ return { key: "output-download" };
887
+ }
888
+ if (!args.output) args.output = parts[1];
889
+ const action = normalizeOutputAction(parts[2]);
890
+ if (!action) {
891
+ throw commandUsageError(
892
+ `Unknown outputs action: ${String(parts[2] ?? "") || "(missing)"}.`,
893
+ "clipdone outputs <output_id> download"
894
+ );
895
+ }
896
+ requireExactPositionals(parts, 3, "clipdone outputs <output_id> download");
897
+ return { key: "output-download" };
898
+ }
899
+ function parseAuthCommand(parts) {
900
+ if (!AUTH_COMMANDS.has(parts[1]) || parts.length !== 2) {
901
+ throw commandUsageError("Unknown auth command.", "clipdone auth login | me | logout | revoke");
902
+ }
903
+ return { key: parts[1] };
904
+ }
905
+ function parseLegacyCommand(args, parts) {
906
+ const command = parts[0];
907
+ if (AUTH_COMMANDS.has(command)) {
908
+ requireExactPositionals(parts, 1, `clipdone ${command}`);
909
+ return { key: command };
910
+ }
911
+ if (command === "profile") {
912
+ return { key: "profile" };
913
+ }
914
+ if (command === "upload") {
915
+ if (typeof parts[1] !== "string") {
916
+ throw commandUsageError("Missing file path.", "clipdone upload <file> --project <project_id> --type footage|music|script");
917
+ }
918
+ requireExactPositionals(parts, 2, "clipdone upload <file> --project <project_id> --type footage|music|script");
919
+ if (!args.file) args.file = parts[1];
920
+ return { key: "project-upload" };
921
+ }
922
+ if (command === "process" || command === "revise") {
923
+ requireExactPositionals(parts, 1, `clipdone ${command} --project <project_id>`);
924
+ return { key: `project-${command}` };
925
+ }
926
+ if (command === "status") {
927
+ requireExactPositionals(parts, 1, "clipdone status --project <project_id>");
928
+ return { key: "project-status" };
929
+ }
930
+ if (command === "download") {
931
+ if (typeof parts[1] !== "string") {
932
+ throw commandUsageError("Missing output ID.", "clipdone download <output_id> [--artifact <name>] [--out <file>]");
933
+ }
934
+ requireExactPositionals(parts, 2, "clipdone download <output_id> [--artifact <name>] [--out <file>]");
935
+ if (!args.output) args.output = parts[1];
936
+ return { key: "output-download" };
937
+ }
938
+ throw commandUsageError(`Unknown command: ${command}.`, "clipdone --help");
939
+ }
940
+ function parseTopLevelCommand(args) {
941
+ const parts = Array.isArray(args._) ? args._ : [];
942
+ if (parts.length === 0 || parts[0] === "help") {
943
+ return { key: "help" };
944
+ }
945
+ if (parts[0] === "auth") {
946
+ return parseAuthCommand(parts);
947
+ }
948
+ if (PROJECT_RESOURCE_COMMANDS.has(parts[0])) {
949
+ return parseProjectCommand(args, parts);
950
+ }
951
+ if (FILE_RESOURCE_COMMANDS.has(parts[0])) {
952
+ return parseFileCommand(args, parts);
953
+ }
954
+ if (OUTPUT_RESOURCE_COMMANDS.has(parts[0])) {
955
+ return parseOutputCommand(args, parts);
956
+ }
957
+ return parseLegacyCommand(args, parts);
958
+ }
959
+ function resolveUploadCategory(rawValue) {
960
+ const value = String(rawValue ?? "footage").trim().toLowerCase();
961
+ if (!value) return "footage";
962
+ if (["footage", "video", "visual", "image", "photo", "picture", "broll", "b-roll"].includes(value)) return "footage";
963
+ if (["music", "audio", "song"].includes(value)) return "music";
964
+ if (["script", "text"].includes(value)) return "script";
965
+ throw new Error("Unsupported upload type. Use footage, music, or script.");
966
+ }
967
+ function summarizeUploadFailure(response, bodyText) {
968
+ const compact = compactText(bodyText);
969
+ const codeMatch = typeof bodyText === "string" ? bodyText.match(/<Code>([^<]+)<\/Code>/i) : null;
970
+ const messageMatch = typeof bodyText === "string" ? bodyText.match(/<Message>([^<]+)<\/Message>/i) : null;
971
+ const errorCode = codeMatch?.[1]?.trim();
972
+ const errorMessage = messageMatch?.[1]?.trim();
973
+ if (errorCode || errorMessage) {
974
+ return `Upload failed with HTTP ${response.status}${errorCode ? ` (${errorCode})` : ""}${errorMessage ? `: ${errorMessage}` : ""}`;
975
+ }
976
+ if (compact && !isHtmlDocument(compact)) {
977
+ return `Upload failed with HTTP ${response.status}: ${compact}`;
978
+ }
979
+ return `Upload failed with HTTP ${response.status}`;
980
+ }
981
+ function printHelp() {
982
+ const target = resolveTarget();
983
+ const devHelp = target.dev;
984
+ writeLine("Usage: clipdone <command> [options]");
985
+ writeLine();
986
+ writeLine("Preferred commands:");
987
+ writeLine(" auth login Authorize this CLI in your browser");
988
+ writeLine(" me Show the current CLI login");
989
+ writeLine(" auth logout Remove the local login only");
990
+ writeLine(" auth revoke Revoke the current CLI login");
991
+ writeLine(" Login options: --no-open --print-auth-url");
992
+ writeLine(" projects List projects");
993
+ writeLine(" projects create [--name <name>] Create a project");
994
+ writeLine(" If omitted, ClipDone uses the same default name as the web app");
995
+ writeLine(" projects <project_id> Show a project summary");
996
+ writeLine(" projects <project_id> update [--name <name>] [--description <text>] [--slug <slug>]");
997
+ writeLine(" Optional: --settings-file <json> or --settings '<json>'");
998
+ writeLine(" projects <project_id> delete Delete a project");
999
+ writeLine(" projects <project_id> upload <file> --type footage|music|script");
1000
+ writeLine(" Upload media or script files");
1001
+ writeLine(" projects <project_id> files List uploaded project files");
1002
+ writeLine(" projects <project_id> process Start the first edit pass");
1003
+ writeLine(" projects <project_id> revise Create a revised version from feedback");
1004
+ writeLine(" projects <project_id> status Show project and processing status");
1005
+ writeLine(" projects <project_id> outputs List exported versions");
1006
+ writeLine(" projects <project_id> runs List processing and revision runs");
1007
+ writeLine(" outputs <output_id> download [--artifact <name>] [--out <file>]");
1008
+ writeLine(" Download the final video or a targeted artifact");
1009
+ writeLine(" files <file_id> update [--name <name>] [--description <text>]");
1010
+ writeLine(" files <file_id> delete Delete an uploaded file");
1011
+ writeLine();
1012
+ writeLine("Compatibility aliases still work:");
1013
+ writeLine(" login, logout, revoke, project ..., file ..., output ..., upload, process, revise, status, download");
1014
+ writeLine();
1015
+ writeLine("Notes:");
1016
+ writeLine(" Account setup, billing, legal acceptance, and account-profile changes stay in the browser.");
1017
+ writeLine(" Project creation, uploads, processing, revisions, status, and downloads are supported here.");
1018
+ writeLine();
1019
+ writeLine("Examples:");
1020
+ writeLine(" clipdone projects");
1021
+ writeLine(" clipdone projects create");
1022
+ writeLine(' clipdone projects create --name "Project name"');
1023
+ writeLine(" clipdone projects <id> upload ./IMG_825.mp4 --type footage");
1024
+ writeLine(' clipdone projects <id> upload "/path/with spaces/video (1).mp4" --type footage');
1025
+ writeLine(" clipdone projects <id> process");
1026
+ writeLine(" clipdone projects <id> status");
1027
+ writeLine(" clipdone auth login --no-open");
1028
+ writeLine();
1029
+ writeLine("Useful options:");
1030
+ writeLine(" --help Show command help");
1031
+ if (devHelp) {
1032
+ writeLine();
1033
+ writeLine("Development target options:");
1034
+ writeLine(" --local Use the local ClipDone target");
1035
+ writeLine(" --prod Use the production ClipDone target");
1036
+ writeLine(" --app-url <url> Override browser authorization URL");
1037
+ writeLine(" --api-url <url> Override ClipDone API URL");
1038
+ }
1039
+ }
1040
+ function readJsonFile(filePath) {
1041
+ try {
1042
+ return JSON.parse(readFileSync(filePath, "utf8"));
1043
+ } catch (error) {
1044
+ throw new Error(`Could not read JSON from ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
1045
+ }
1046
+ }
1047
+ function normalizeFeedbackNotes(value) {
1048
+ if (!value) return [];
1049
+ if (Array.isArray(value)) return value;
1050
+ if (Array.isArray(value.feedbackNotes)) return value.feedbackNotes;
1051
+ if (typeof value.content === "string") return [value];
1052
+ throw new Error("Feedback JSON must be an array, an object with feedbackNotes, or a single feedback note.");
1053
+ }
1054
+ function normalizeSubtitleEdits(value) {
1055
+ if (!value) return [];
1056
+ if (Array.isArray(value)) return value;
1057
+ if (Array.isArray(value.subtitles)) return value.subtitles;
1058
+ throw new Error("Subtitle JSON must be an array or an object with subtitles.");
1059
+ }
1060
+ function readSettingsInput(args) {
1061
+ if (typeof args["settings-file"] === "string") {
1062
+ const value = readJsonFile(args["settings-file"]);
1063
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1064
+ throw new Error("Settings file must contain a JSON object.");
1065
+ }
1066
+ return value;
1067
+ }
1068
+ if (typeof args.settings === "string") {
1069
+ try {
1070
+ const value = JSON.parse(args.settings);
1071
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1072
+ throw new Error("Settings value must be a JSON object.");
1073
+ }
1074
+ return value;
1075
+ } catch (error) {
1076
+ throw new Error(`Could not parse JSON from --settings: ${error instanceof Error ? error.message : String(error)}`);
1077
+ }
1078
+ }
1079
+ return null;
1080
+ }
1081
+ function openBrowser(url) {
1082
+ const command = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open";
1083
+ const args = platform() === "win32" ? ["/c", "start", "", url] : [url];
1084
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
1085
+ child.unref();
1086
+ }
1087
+ function base64UrlEncode(bytes) {
1088
+ return Buffer.from(bytes).toString("base64").replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
1089
+ }
1090
+ function randomState() {
1091
+ return base64UrlEncode(webcrypto.getRandomValues(new Uint8Array(24)));
1092
+ }
1093
+ function generateCodeVerifier() {
1094
+ return base64UrlEncode(webcrypto.getRandomValues(new Uint8Array(48)));
1095
+ }
1096
+ async function generateCodeChallenge(verifier) {
1097
+ const digest = await webcrypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
1098
+ return base64UrlEncode(new Uint8Array(digest));
1099
+ }
1100
+ async function login(args) {
1101
+ const authConfig = { resource: API_OAUTH_RESOURCE, scope: API_OAUTH_SCOPES.join(" ") };
1102
+ const oauthClient = await resolveOAuthClient(authConfig);
1103
+ const state = randomState();
1104
+ const codeVerifier = generateCodeVerifier();
1105
+ const codeChallenge = await generateCodeChallenge(codeVerifier);
1106
+ const server = http.createServer();
1107
+ const port = await new Promise((resolve, reject) => {
1108
+ server.once("error", reject);
1109
+ server.listen(0, "127.0.0.1", () => resolve(server.address().port));
1110
+ });
1111
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
1112
+ const authUrl = new URL(OAUTH_AUTHORIZE_PATH, appBase());
1113
+ authUrl.searchParams.set("client_id", oauthClient.clientId);
1114
+ authUrl.searchParams.set("response_type", "code");
1115
+ authUrl.searchParams.set("scope", authConfig.scope);
1116
+ authUrl.searchParams.set("state", state);
1117
+ authUrl.searchParams.set("redirect_uri", redirectUri);
1118
+ authUrl.searchParams.set("code_challenge", codeChallenge);
1119
+ authUrl.searchParams.set("code_challenge_method", "S256");
1120
+ authUrl.searchParams.set("resource", authConfig.resource);
1121
+ const browserStartUrl = new URL("/agents/start", appBase());
1122
+ browserStartUrl.search = authUrl.search;
1123
+ const authUrlText = browserStartUrl.toString();
1124
+ const shouldOpenBrowser = args["no-open"] !== true;
1125
+ const shouldPrintAuthUrl = args["print-auth-url"] === true || !shouldOpenBrowser;
1126
+ await new Promise((resolve, reject) => {
1127
+ let settled = false;
1128
+ const timeout = setTimeout(() => {
1129
+ server.close();
1130
+ reject(new Error("Login timed out."));
1131
+ }, 5 * 60 * 1e3);
1132
+ server.on("request", async (req, res) => {
1133
+ try {
1134
+ const url = new URL(req.url || "/", redirectUri);
1135
+ if (url.pathname !== "/callback") {
1136
+ res.writeHead(404).end("Not found");
1137
+ return;
1138
+ }
1139
+ if (settled) {
1140
+ res.writeHead(409).end("Authorization already completed");
1141
+ return;
1142
+ }
1143
+ if (url.searchParams.get("state") !== state) {
1144
+ res.writeHead(400).end("State mismatch");
1145
+ return;
1146
+ }
1147
+ const returnedCode = url.searchParams.get("code");
1148
+ const returnedError = url.searchParams.get("error");
1149
+ if (!returnedCode) {
1150
+ const message = returnedError ? `Authorization failed: ${returnedError}` : "Missing authorization code.";
1151
+ res.writeHead(400).end(message);
1152
+ clearTimeout(timeout);
1153
+ server.close();
1154
+ reject(new Error(message));
1155
+ return;
1156
+ }
1157
+ settled = true;
1158
+ const session = await oauthTokenRequest(
1159
+ {
1160
+ grant_type: "authorization_code",
1161
+ client_id: oauthClient.clientId,
1162
+ code: returnedCode,
1163
+ code_verifier: codeVerifier,
1164
+ redirect_uri: redirectUri,
1165
+ resource: authConfig.resource
1166
+ },
1167
+ {
1168
+ clientId: oauthClient.clientId,
1169
+ scope: authConfig.scope,
1170
+ resource: authConfig.resource
1171
+ }
1172
+ );
1173
+ writeStoredOAuthSession(session);
1174
+ res.writeHead(200, { "Content-Type": "text/plain" }).end("ClipDone CLI authorized. You can close this tab.");
1175
+ clearTimeout(timeout);
1176
+ server.close();
1177
+ resolve(void 0);
1178
+ } catch (error) {
1179
+ const message = error instanceof Error ? error.message : String(error);
1180
+ if (!res.headersSent) {
1181
+ res.writeHead(500, { "Content-Type": "text/plain" }).end(`ClipDone CLI authorization failed: ${message}`);
1182
+ }
1183
+ clearTimeout(timeout);
1184
+ server.close();
1185
+ reject(error);
1186
+ }
1187
+ });
1188
+ if (shouldPrintAuthUrl) {
1189
+ process.stderr.write(`Open this URL in a browser:
1190
+ ${authUrlText}
1191
+ `);
1192
+ }
1193
+ if (shouldOpenBrowser) {
1194
+ process.stderr.write("Opening browser for ClipDone sign-in...\n");
1195
+ try {
1196
+ openBrowser(authUrlText);
1197
+ } catch {
1198
+ if (!shouldPrintAuthUrl) {
1199
+ process.stderr.write(`Open this URL in a browser:
1200
+ ${authUrlText}
1201
+ `);
1202
+ }
1203
+ }
1204
+ } else {
1205
+ process.stderr.write("Waiting for ClipDone sign-in in your browser...\n");
1206
+ }
1207
+ });
1208
+ writeLine("Logged in.");
1209
+ }
1210
+ async function upload(args) {
1211
+ const filePath = typeof args.file === "string" ? args.file : args._[0] === "upload" ? args._[1] : null;
1212
+ if (!args.project) throw new Error("--project is required");
1213
+ if (!filePath) throw new Error("File path is required");
1214
+ const stats = statSync(filePath);
1215
+ const category = resolveUploadCategory(args.type);
1216
+ const fileName = args.name || basename(filePath);
1217
+ const contentType = args.contentType || inferContentType(fileName);
1218
+ const presign = await request(`/projects/${encodeURIComponent(args.project)}/uploads`, {
1219
+ method: "POST",
1220
+ body: {
1221
+ fileName,
1222
+ fileSize: stats.size,
1223
+ contentType,
1224
+ category
1225
+ }
1226
+ });
1227
+ const uploadResponse = await fetch(presign.uploadUrl, {
1228
+ method: "PUT",
1229
+ headers: {
1230
+ "Content-Type": contentType,
1231
+ "Content-Length": String(stats.size)
1232
+ },
1233
+ body: createReadStream(filePath),
1234
+ duplex: "half"
1235
+ });
1236
+ if (!uploadResponse.ok) {
1237
+ throw new Error(summarizeUploadFailure(uploadResponse, await uploadResponse.text()));
1238
+ }
1239
+ const asset = await request(`/uploads/${encodeURIComponent(presign.uploadId)}/complete`, { method: "POST" });
1240
+ output(buildUploadView(asset, args, stats, fileName, category));
1241
+ }
1242
+ function inferContentType(fileName) {
1243
+ const lower = fileName.toLowerCase();
1244
+ if (lower.endsWith(".mp4")) return "video/mp4";
1245
+ if (lower.endsWith(".mov")) return "video/quicktime";
1246
+ if (lower.endsWith(".webm")) return "video/webm";
1247
+ if (lower.endsWith(".png")) return "image/png";
1248
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
1249
+ if (lower.endsWith(".mp3")) return "audio/mpeg";
1250
+ if (lower.endsWith(".wav")) return "audio/wav";
1251
+ if (lower.endsWith(".txt") || lower.endsWith(".md")) return "text/plain";
1252
+ return "application/octet-stream";
1253
+ }
1254
+ async function download(args) {
1255
+ const body = {
1256
+ artifact: args.artifact,
1257
+ filename: args.filename
1258
+ };
1259
+ if (!args.output) throw new Error("Output ID is required");
1260
+ const result = await request(`/outputs/${encodeURIComponent(args.output)}/download-url`, { method: "POST", body });
1261
+ if (!args.out) {
1262
+ output(buildDownloadView(result));
1263
+ return;
1264
+ }
1265
+ const response = await fetch(result.url);
1266
+ if (!response.ok || !response.body) throw new Error(`Download failed with HTTP ${response.status}`);
1267
+ await pipeline(Readable.fromWeb(response.body), createWriteStream(args.out));
1268
+ output(buildDownloadView(result, args.out));
1269
+ }
1270
+ function buildProcessingBody(args) {
1271
+ if (!args.project) throw new Error("--project is required");
1272
+ const feedbackNotes = [];
1273
+ if (typeof args.feedback === "string" && args.feedback.trim()) {
1274
+ feedbackNotes.push({ content: args.feedback.trim() });
1275
+ }
1276
+ if (typeof args["feedback-file"] === "string") {
1277
+ feedbackNotes.push(...normalizeFeedbackNotes(readJsonFile(args["feedback-file"])));
1278
+ }
1279
+ const subtitles = typeof args["subtitles-file"] === "string" ? normalizeSubtitleEdits(readJsonFile(args["subtitles-file"])) : [];
1280
+ return {
1281
+ skipRender: args["skip-render"] === true,
1282
+ processFeedback: args["process-feedback"] === true || feedbackNotes.length > 0,
1283
+ ...feedbackNotes.length > 0 ? { feedbackNotes } : {},
1284
+ ...subtitles.length > 0 ? { subtitles } : {}
1285
+ };
1286
+ }
1287
+ async function main() {
1288
+ const args = parseArgs(process.argv.slice(2));
1289
+ runtimeArgs = args;
1290
+ if (args.help) {
1291
+ printHelp();
1292
+ return;
1293
+ }
1294
+ const commandSpec = parseTopLevelCommand(args);
1295
+ const command = commandSpec.key;
1296
+ if (command === "help") {
1297
+ printHelp();
1298
+ return;
1299
+ }
1300
+ if (command === "login") return await login(args);
1301
+ if (command === "logout") {
1302
+ if (!hasStoredAuth()) {
1303
+ output({ message: "Already logged out." });
1304
+ return;
1305
+ }
1306
+ clearStoredAuth();
1307
+ output({ message: "Logged out locally." });
1308
+ return;
1309
+ }
1310
+ if (command === "revoke") {
1311
+ if (!hasStoredAuth()) {
1312
+ output({ message: "Already logged out." });
1313
+ return;
1314
+ }
1315
+ if (getManualTokenOverride()) {
1316
+ throw new Error("`clipdone revoke` is not available while `CLIPDONE_TOKEN` is set. Remove the override or use `clipdone logout`.");
1317
+ }
1318
+ const session = readStoredOAuthSession();
1319
+ if (!session) {
1320
+ clearStoredAuth();
1321
+ output({ message: "Already logged out." });
1322
+ return;
1323
+ }
1324
+ await revokeOAuthToken(session.refreshToken, session.clientId, "refresh_token");
1325
+ await revokeOAuthToken(session.accessToken, session.clientId, "access_token");
1326
+ clearStoredAuth();
1327
+ return output({ message: "Access revoked and local token removed." });
1328
+ }
1329
+ if (command === "me") {
1330
+ if (!getManualTokenOverride() && !hasStoredAuth()) return output({ authenticated: false });
1331
+ const result = await request("/me", { auth: "optional" });
1332
+ return output(buildMeView(result ? { authenticated: true, name: result.name, email: result.email, company: result.company } : null));
1333
+ }
1334
+ if (command === "profile") {
1335
+ throw new Error("Account profile changes are only available in the ClipDone web app.");
1336
+ }
1337
+ if (command === "project-get") {
1338
+ if (!args.project) throw new Error("project_id or --project is required");
1339
+ const result = await request(`/projects/${encodeURIComponent(args.project)}`);
1340
+ return output(result);
1341
+ }
1342
+ if (command === "project-create") {
1343
+ const result = await request("/projects", {
1344
+ method: "POST",
1345
+ body: { name: args.name, description: args.description },
1346
+ idempotencyKey: `cli-project-${Date.now()}-${Math.random().toString(36).slice(2)}`
1347
+ });
1348
+ return output({ message: `Created project${result?.project?.name ? ` "${result.project.name}"` : ""}.`, projectId: result?.project?.id, name: result?.project?.name });
1349
+ }
1350
+ if (command === "project-update") {
1351
+ if (!args.project) throw new Error("project_id or --project is required");
1352
+ const settings = readSettingsInput(args);
1353
+ const result = await request(`/projects/${encodeURIComponent(args.project)}`, {
1354
+ method: "PATCH",
1355
+ body: {
1356
+ ...typeof args.name === "string" ? { name: args.name } : {},
1357
+ ...typeof args.description === "string" ? { description: args.description } : {},
1358
+ ...typeof args.slug === "string" ? { slug: args.slug } : {},
1359
+ ...settings ? { settings } : {}
1360
+ }
1361
+ });
1362
+ return output({ message: "Project updated.", projectId: String(result?.project?.id ?? args.project) });
1363
+ }
1364
+ if (command === "project-delete") {
1365
+ if (!args.project) throw new Error("project_id or --project is required");
1366
+ await request(`/projects/${encodeURIComponent(args.project)}`, { method: "DELETE" });
1367
+ return output({ message: "Project deleted.", projectId: args.project });
1368
+ }
1369
+ if (command === "project-upload") return await upload(args);
1370
+ if (command === "project-files") {
1371
+ if (!args.project) throw new Error("project_id or --project is required");
1372
+ return output({ files: await requestAll(`/projects/${encodeURIComponent(args.project)}/files`, "files") });
1373
+ }
1374
+ if (command === "project-process" || command === "project-revise") {
1375
+ if (!args.project) throw new Error("project_id or --project is required");
1376
+ const result = await request(`/projects/${encodeURIComponent(args.project)}/runs`, {
1377
+ method: "POST",
1378
+ body: buildProcessingBody(args),
1379
+ idempotencyKey: `cli-run-${Date.now()}-${Math.random().toString(36).slice(2)}`
1380
+ });
1381
+ return output(buildProcessView(result, args, command === "project-revise" ? "revise" : "process"));
1382
+ }
1383
+ if (command === "project-status") {
1384
+ if (!args.project) throw new Error("project_id or --project is required");
1385
+ const result = await request(`/projects/${encodeURIComponent(args.project)}/status`);
1386
+ return output(result);
1387
+ }
1388
+ if (command === "project-outputs") {
1389
+ if (!args.project) throw new Error("project_id or --project is required");
1390
+ return output({ outputs: await requestAll(`/projects/${encodeURIComponent(args.project)}/outputs`, "outputs") });
1391
+ }
1392
+ if (command === "project-runs") {
1393
+ if (!args.project) throw new Error("project_id or --project is required");
1394
+ return output({ runs: await requestAll(`/projects/${encodeURIComponent(args.project)}/runs`, "runs") });
1395
+ }
1396
+ if (command === "projects-list") {
1397
+ return output({ projects: await requestAll("/projects", "projects") });
1398
+ }
1399
+ if (command === "file-update") {
1400
+ const fileId = args.file || args.id;
1401
+ if (!fileId) throw new Error("--file is required");
1402
+ const result = await request(`/files/${encodeURIComponent(fileId)}`, {
1403
+ method: "PATCH",
1404
+ body: {
1405
+ ...typeof args.name === "string" ? { label: args.name } : {},
1406
+ ...typeof args.description === "string" ? { description: args.description } : {}
1407
+ }
1408
+ });
1409
+ return output({ message: "File updated.", fileId: String(result?.fileId ?? fileId) });
1410
+ }
1411
+ if (command === "file-delete") {
1412
+ const fileId = args.file || args.id;
1413
+ if (!fileId) throw new Error("--file is required");
1414
+ const result = await request(`/files/${encodeURIComponent(fileId)}`, { method: "DELETE" });
1415
+ return output({ message: "File deleted.", fileId: String(result?.fileId ?? fileId) });
1416
+ }
1417
+ if (command === "output-download") return await download(args);
1418
+ throw new Error(`Unknown command: ${command}. Run \`clipdone --help\` for available commands.`);
1419
+ }
1420
+ main().catch((error) => {
1421
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}
1422
+ `);
1423
+ process.exit(1);
1424
+ });