cutsio 0.0.0-stage → 0.2.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 +132 -2
  2. package/dist/index.js +1375 -0
  3. package/package.json +35 -4
package/dist/index.js ADDED
@@ -0,0 +1,1375 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, writeFileSync as writeFileSync3 } from "node:fs";
5
+ import { join as join3 } from "node:path";
6
+ import process2 from "node:process";
7
+
8
+ // src/config.ts
9
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { dirname, join } from "node:path";
12
+
13
+ // src/secrets.ts
14
+ import { execFile } from "node:child_process";
15
+ import { promisify } from "node:util";
16
+ var run = promisify(execFile);
17
+ var SERVICE = "cutsio-cli";
18
+ var ACCOUNT = "api-key";
19
+ function keychainAvailable() {
20
+ return process.platform === "darwin";
21
+ }
22
+ async function getStoredKey() {
23
+ if (!keychainAvailable()) return null;
24
+ try {
25
+ const { stdout: stdout3 } = await run("security", [
26
+ "find-generic-password",
27
+ "-a",
28
+ ACCOUNT,
29
+ "-s",
30
+ SERVICE,
31
+ "-w"
32
+ ]);
33
+ const value = stdout3.trim();
34
+ return value.length > 0 ? value : null;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+ async function setStoredKey(key) {
40
+ await run("security", [
41
+ "add-generic-password",
42
+ "-U",
43
+ "-a",
44
+ ACCOUNT,
45
+ "-s",
46
+ SERVICE,
47
+ "-w",
48
+ key
49
+ ]);
50
+ }
51
+ async function clearStoredKey() {
52
+ if (!keychainAvailable()) return;
53
+ try {
54
+ await run("security", ["delete-generic-password", "-a", ACCOUNT, "-s", SERVICE]);
55
+ } catch {
56
+ }
57
+ }
58
+
59
+ // src/config.ts
60
+ var DEFAULT_API_URL = "https://studio.cutsio.com";
61
+ function configDir() {
62
+ return join(homedir(), ".cutsio");
63
+ }
64
+ function configPath() {
65
+ return join(configDir(), "config.json");
66
+ }
67
+ function keyStorageDescription() {
68
+ return keychainAvailable() ? "macOS Keychain" : `${configPath()} (mode 600)`;
69
+ }
70
+ function readConfig() {
71
+ const path = configPath();
72
+ if (!existsSync(path)) return null;
73
+ try {
74
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
75
+ if (typeof parsed.apiUrl !== "string") return null;
76
+ return {
77
+ apiUrl: parsed.apiUrl,
78
+ ...typeof parsed.apiKey === "string" && parsed.apiKey.length > 0 ? { apiKey: parsed.apiKey } : {}
79
+ };
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+ function writeConfig(config) {
85
+ const path = configPath();
86
+ mkdirSync(dirname(path), { recursive: true, mode: 448 });
87
+ const payload = config.apiKey ? { apiUrl: config.apiUrl, apiKey: config.apiKey } : { apiUrl: config.apiUrl };
88
+ writeFileSync(path, JSON.stringify(payload, null, 2), { mode: 384 });
89
+ chmodSync(path, 384);
90
+ }
91
+ function resolveApiUrl(flag) {
92
+ return flag || process.env.CUTSIO_API_URL || readConfig()?.apiUrl || DEFAULT_API_URL;
93
+ }
94
+ async function resolveApiKey() {
95
+ const fromEnv = process.env.CUTSIO_API_KEY;
96
+ if (fromEnv && fromEnv.length > 0) return fromEnv;
97
+ const stored = await getStoredKey();
98
+ if (stored) return stored;
99
+ const config = readConfig();
100
+ const legacy = config?.apiKey;
101
+ if (!legacy) return null;
102
+ if (keychainAvailable()) {
103
+ try {
104
+ await setStoredKey(legacy);
105
+ writeConfig({ apiUrl: config?.apiUrl ?? DEFAULT_API_URL });
106
+ } catch {
107
+ }
108
+ }
109
+ return legacy;
110
+ }
111
+ async function saveCredentials(apiUrl, apiKey) {
112
+ if (keychainAvailable()) {
113
+ await setStoredKey(apiKey);
114
+ writeConfig({ apiUrl });
115
+ return;
116
+ }
117
+ writeConfig({ apiUrl, apiKey });
118
+ }
119
+ async function clearCredentials() {
120
+ const stored = await getStoredKey();
121
+ const config = readConfig();
122
+ const hadFileKey = Boolean(config?.apiKey);
123
+ await clearStoredKey();
124
+ if (config) {
125
+ writeConfig({ apiUrl: config.apiUrl });
126
+ }
127
+ return Boolean(stored) || hadFileKey;
128
+ }
129
+
130
+ // src/core/client.ts
131
+ var ApiError = class extends Error {
132
+ status;
133
+ constructor(message, status) {
134
+ super(message);
135
+ this.name = "ApiError";
136
+ this.status = status;
137
+ }
138
+ };
139
+ var CutsioClient = class {
140
+ base;
141
+ apiKey;
142
+ constructor(apiUrl, apiKey) {
143
+ this.base = apiUrl.replace(/\/+$/, "");
144
+ this.apiKey = apiKey;
145
+ }
146
+ async whoami() {
147
+ return await this.request("/api/desktop/whoami");
148
+ }
149
+ async fetchManifest() {
150
+ let orgId = "";
151
+ let folders = [];
152
+ let collections = [];
153
+ const videos = [];
154
+ let cursor = null;
155
+ for (; ; ) {
156
+ const params = new URLSearchParams({ limit: "100" });
157
+ if (cursor !== null) params.set("cursor", String(cursor));
158
+ const page = await this.request(
159
+ `/api/desktop/manifest?${params.toString()}`
160
+ );
161
+ orgId = page.orgId;
162
+ folders = page.folders;
163
+ collections = page.collections ?? [];
164
+ videos.push(...page.videos);
165
+ if (!page.hasMore || page.nextCursor === null) break;
166
+ cursor = page.nextCursor;
167
+ }
168
+ return { orgId, folders, collections, videos };
169
+ }
170
+ async prepare(videoIds) {
171
+ const data = await this.request("/api/desktop/prepare", {
172
+ method: "POST",
173
+ body: JSON.stringify({ videoIds })
174
+ });
175
+ return Array.isArray(data.results) ? data.results : [];
176
+ }
177
+ async request(path, init) {
178
+ const headers = {
179
+ Authorization: `Bearer ${this.apiKey}`,
180
+ ...init?.body ? { "Content-Type": "application/json" } : {},
181
+ ...init?.headers ?? {}
182
+ };
183
+ const response = await fetch(`${this.base}${path}`, { ...init, headers });
184
+ if (!response.ok) {
185
+ let message = `HTTP ${response.status}`;
186
+ try {
187
+ const body = await response.json();
188
+ if (typeof body.error === "string") message = body.error;
189
+ } catch {
190
+ }
191
+ throw new ApiError(message, response.status);
192
+ }
193
+ return response.json();
194
+ }
195
+ };
196
+
197
+ // src/core/paths.ts
198
+ function sanitizeSegment(value) {
199
+ const cleaned = value.replace(/[/\\?%*:|"<>]/g, "-").replace(/\s+/g, " ").replace(/^[.\s]+|[.\s]+$/g, "").slice(0, 80).trim();
200
+ return cleaned.length > 0 ? cleaned : "untitled";
201
+ }
202
+ function buildFolderPaths(folders) {
203
+ const byId = new Map(folders.map((folder) => [folder.id, folder]));
204
+ const memo = /* @__PURE__ */ new Map();
205
+ const resolve = (id, seen) => {
206
+ const cached = memo.get(id);
207
+ if (cached !== void 0) return cached;
208
+ const folder = byId.get(id);
209
+ if (!folder || seen.has(id)) return "";
210
+ seen.add(id);
211
+ const parent = folder.parent_id ? resolve(folder.parent_id, seen) : "";
212
+ const path = parent ? `${parent}/${sanitizeSegment(folder.name)}` : sanitizeSegment(folder.name);
213
+ memo.set(id, path);
214
+ return path;
215
+ };
216
+ for (const folder of folders) resolve(folder.id, /* @__PURE__ */ new Set());
217
+ return memo;
218
+ }
219
+ function videoRelativePath(video, folderPaths) {
220
+ const directory = video.folder_id ? folderPaths.get(video.folder_id) ?? "" : "";
221
+ const name = `${sanitizeSegment(video.title)} [${video.id.slice(0, 8)}].mp4`;
222
+ return directory ? `${directory}/${name}` : name;
223
+ }
224
+
225
+ // src/core/selection.ts
226
+ function isEmptySelection(selection) {
227
+ return !selection.all && !selection.unfiled && selection.folderRefs.length === 0 && selection.collectionRefs.length === 0 && selection.videoIds.length === 0 && selection.searchTerms.length === 0;
228
+ }
229
+ function folderSubtreeIds(manifest) {
230
+ const children = /* @__PURE__ */ new Map();
231
+ for (const folder of manifest.folders) {
232
+ if (!folder.parent_id) continue;
233
+ const list = children.get(folder.parent_id) ?? [];
234
+ list.push(folder.id);
235
+ children.set(folder.parent_id, list);
236
+ }
237
+ const subtrees = /* @__PURE__ */ new Map();
238
+ for (const folder of manifest.folders) {
239
+ const ids = /* @__PURE__ */ new Set([folder.id]);
240
+ const stack = [folder.id];
241
+ for (; ; ) {
242
+ const id = stack.pop();
243
+ if (id === void 0) break;
244
+ for (const child of children.get(id) ?? []) {
245
+ if (ids.has(child)) continue;
246
+ ids.add(child);
247
+ stack.push(child);
248
+ }
249
+ }
250
+ subtrees.set(folder.id, ids);
251
+ }
252
+ return subtrees;
253
+ }
254
+ function folderVideoCounts(manifest) {
255
+ const subtrees = folderSubtreeIds(manifest);
256
+ const direct = /* @__PURE__ */ new Map();
257
+ const subtree = /* @__PURE__ */ new Map();
258
+ for (const video of manifest.videos) {
259
+ if (!video.folder_id) continue;
260
+ direct.set(video.folder_id, (direct.get(video.folder_id) ?? 0) + 1);
261
+ }
262
+ for (const [folderId, ids] of subtrees) {
263
+ let count = 0;
264
+ for (const video of manifest.videos) {
265
+ if (video.folder_id && ids.has(video.folder_id)) count += 1;
266
+ }
267
+ subtree.set(folderId, count);
268
+ }
269
+ return { direct, subtree };
270
+ }
271
+ function resolveSelection(manifest, selection) {
272
+ if (selection.all) return { videos: manifest.videos, errors: [] };
273
+ const errors = [];
274
+ const selected = /* @__PURE__ */ new Map();
275
+ const folderPaths = buildFolderPaths(manifest.folders);
276
+ const pathIndex = /* @__PURE__ */ new Map();
277
+ for (const [id, path] of folderPaths) pathIndex.set(path.toLowerCase(), id);
278
+ const nameIndex = /* @__PURE__ */ new Map();
279
+ for (const folder of manifest.folders) {
280
+ const key = folder.name.toLowerCase();
281
+ nameIndex.set(key, [...nameIndex.get(key) ?? [], folder.id]);
282
+ }
283
+ const resolveFolder = (ref) => {
284
+ if (folderPaths.has(ref)) return ref;
285
+ const byPath = pathIndex.get(ref.toLowerCase());
286
+ if (byPath) return byPath;
287
+ const byName = nameIndex.get(ref.toLowerCase());
288
+ if (byName && byName.length === 1) return byName[0] ?? null;
289
+ return null;
290
+ };
291
+ const subtrees = folderSubtreeIds(manifest);
292
+ const folderIds = /* @__PURE__ */ new Set();
293
+ for (const ref of selection.folderRefs) {
294
+ const id = resolveFolder(ref);
295
+ if (!id) {
296
+ const byName = nameIndex.get(ref.toLowerCase());
297
+ errors.push(
298
+ byName && byName.length > 1 ? `Folder name is ambiguous: ${ref} (use the path or id)` : `Folder not found: ${ref}`
299
+ );
300
+ continue;
301
+ }
302
+ for (const descendant of subtrees.get(id) ?? [id]) folderIds.add(descendant);
303
+ }
304
+ if (folderIds.size > 0) {
305
+ for (const video of manifest.videos) {
306
+ if (video.folder_id && folderIds.has(video.folder_id)) selected.set(video.id, video);
307
+ }
308
+ }
309
+ const collectionIds = /* @__PURE__ */ new Set();
310
+ for (const ref of selection.collectionRefs) {
311
+ const matches = manifest.collections.filter(
312
+ (collection) => collection.id === ref || collection.title.toLowerCase() === ref.toLowerCase()
313
+ );
314
+ if (matches.length === 0) {
315
+ errors.push(`Collection not found: ${ref}`);
316
+ continue;
317
+ }
318
+ for (const collection of matches) collectionIds.add(collection.id);
319
+ }
320
+ if (collectionIds.size > 0) {
321
+ for (const video of manifest.videos) {
322
+ if (video.collection_id && collectionIds.has(video.collection_id)) selected.set(video.id, video);
323
+ }
324
+ }
325
+ const byId = new Map(manifest.videos.map((video) => [video.id, video]));
326
+ for (const id of selection.videoIds) {
327
+ const video = byId.get(id);
328
+ if (!video) {
329
+ errors.push(`Video not found: ${id}`);
330
+ continue;
331
+ }
332
+ selected.set(video.id, video);
333
+ }
334
+ if (selection.unfiled) {
335
+ for (const video of manifest.videos) {
336
+ if (!video.folder_id) selected.set(video.id, video);
337
+ }
338
+ }
339
+ for (const term of selection.searchTerms) {
340
+ const needle = term.toLowerCase();
341
+ let matches = 0;
342
+ for (const video of manifest.videos) {
343
+ if (!video.title.toLowerCase().includes(needle)) continue;
344
+ selected.set(video.id, video);
345
+ matches += 1;
346
+ }
347
+ if (matches === 0) errors.push(`No video titles match: ${term}`);
348
+ }
349
+ return { videos: [...selected.values()], errors };
350
+ }
351
+
352
+ // src/commands/list.ts
353
+ async function listCommand(flags) {
354
+ const apiKey = await resolveApiKey();
355
+ if (!apiKey) {
356
+ console.error("No API key. Run `cutsio login` or set CUTSIO_API_KEY.");
357
+ return 1;
358
+ }
359
+ const client = new CutsioClient(resolveApiUrl(flags.url), apiKey);
360
+ let manifest;
361
+ try {
362
+ manifest = await client.fetchManifest();
363
+ } catch (error) {
364
+ console.error(error instanceof Error ? error.message : String(error));
365
+ return 1;
366
+ }
367
+ if (flags.json === "true") {
368
+ console.log(JSON.stringify(manifest, null, 2));
369
+ return 0;
370
+ }
371
+ const search = (flags.search ?? "").trim().toLowerCase();
372
+ if (search) {
373
+ const matches = manifest.videos.filter((video) => video.title.toLowerCase().includes(search));
374
+ console.log(`Title matches (${matches.length}) for "${flags.search}":`);
375
+ if (matches.length === 0) console.log(" (none)");
376
+ for (const video of matches) {
377
+ console.log(` [${video.id}] ${video.status.padEnd(10)} ${video.title}`);
378
+ }
379
+ return 0;
380
+ }
381
+ const folderPaths = buildFolderPaths(manifest.folders);
382
+ const { subtree } = folderVideoCounts(manifest);
383
+ console.log(`Collections (${manifest.collections.length}) \u2014 select with --collection <name|id>`);
384
+ if (manifest.collections.length === 0) console.log(" (none)");
385
+ for (const collection of manifest.collections) {
386
+ const count = manifest.videos.filter((video) => video.collection_id === collection.id).length;
387
+ console.log(` ${pad(count)} ${collection.title} [${collection.id}]`);
388
+ }
389
+ console.log(`
390
+ Folders (${manifest.folders.length}) \u2014 select with --folder <path|id>`);
391
+ if (manifest.folders.length === 0) console.log(" (none)");
392
+ const rows = [...folderPaths.entries()].sort((a, b) => a[1].localeCompare(b[1]));
393
+ for (const [id, path] of rows) {
394
+ console.log(` ${pad(subtree.get(id) ?? 0)} ${path}/ [${id}]`);
395
+ }
396
+ const unfiled = manifest.videos.filter((video) => !video.folder_id).length;
397
+ if (unfiled > 0) console.log(` ${pad(unfiled)} (no folder)`);
398
+ if (flags.videos === "true") {
399
+ console.log(`
400
+ Videos (${manifest.videos.length}) \u2014 select with --video <id>`);
401
+ for (const video of manifest.videos) {
402
+ console.log(` [${video.id}] ${video.status.padEnd(10)} ${video.title}`);
403
+ }
404
+ }
405
+ return 0;
406
+ }
407
+ function pad(count) {
408
+ return `${count} video${count === 1 ? " " : "s"}`.padStart(9);
409
+ }
410
+
411
+ // src/commands/login.ts
412
+ import { stdin, stdout } from "node:process";
413
+ var MAX_ATTEMPTS = 3;
414
+ var KEY_PATTERN = /^ak_[A-Za-z0-9]{20,}$/;
415
+ async function loginCommand(flags) {
416
+ const apiUrl = resolveApiUrl(flags.url);
417
+ const interactiveTty = Boolean(stdin.isTTY) && typeof stdin.setRawMode === "function";
418
+ console.log(`Signing in to ${apiUrl}`);
419
+ console.log("Create an organization API key in Cutsio (Account or Organization \u2192 API Keys).");
420
+ console.log("The secret is shown once, when you create the key.\n");
421
+ const pipedAnswers = interactiveTty ? null : await readPipedAnswers();
422
+ let pipedIndex = 0;
423
+ let fromFlag = flags.key;
424
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
425
+ let raw;
426
+ if (fromFlag !== void 0) {
427
+ raw = fromFlag;
428
+ fromFlag = void 0;
429
+ } else if (pipedAnswers) {
430
+ const next = pipedAnswers[pipedIndex];
431
+ pipedIndex += 1;
432
+ if (next === void 0) break;
433
+ raw = next;
434
+ } else {
435
+ raw = await promptSecret("Paste API key: ");
436
+ }
437
+ const apiKey = normalizeKey(raw);
438
+ if (!apiKey) {
439
+ console.error("No API key entered.\n");
440
+ continue;
441
+ }
442
+ if (!KEY_PATTERN.test(apiKey)) {
443
+ console.error('That does not look like a Cutsio API key (expected something starting with "ak_").\n');
444
+ continue;
445
+ }
446
+ try {
447
+ const who = await new CutsioClient(apiUrl, apiKey).whoami();
448
+ if (!who.orgId) {
449
+ console.error("That key is not attached to an organization. Create an organization API key.\n");
450
+ continue;
451
+ }
452
+ await saveCredentials(apiUrl, apiKey);
453
+ console.log(`
454
+ Saved. Organization ${who.orgId}${who.name ? ` (key "${who.name}")` : ""}.`);
455
+ console.log(`Key stored in ${keyStorageDescription()}.
456
+ `);
457
+ console.log("Next:");
458
+ console.log(" cutsio list");
459
+ console.log(" cutsio sync --out ~/Cutsio --interactive");
460
+ return 0;
461
+ } catch (error) {
462
+ console.error(`${explain(error, apiUrl)}
463
+ `);
464
+ }
465
+ }
466
+ console.error("Could not authenticate. Nothing was saved.");
467
+ return 1;
468
+ }
469
+ async function readPipedAnswers() {
470
+ const chunks = [];
471
+ for await (const chunk of stdin) {
472
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
473
+ }
474
+ return Buffer.concat(chunks).toString("utf8").split(/\r?\n/);
475
+ }
476
+ function normalizeKey(raw) {
477
+ let value = raw.trim();
478
+ const quoted = value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'");
479
+ if (quoted && value.length > 1) {
480
+ value = value.slice(1, -1).trim();
481
+ }
482
+ return value.replace(/^bearer\s+/i, "").trim();
483
+ }
484
+ function explain(error, apiUrl) {
485
+ if (error instanceof ApiError) {
486
+ if (error.status === 401) {
487
+ return [
488
+ "That key was rejected.",
489
+ " - If you copied it from the key list, that is the key ID, not the secret.",
490
+ " The secret is only shown once at creation \u2014 create a new key.",
491
+ ` - If it was created in a different environment than ${apiUrl}, it will not exist here.`
492
+ ].join("\n");
493
+ }
494
+ if (error.status === 403) {
495
+ return `That key authenticated but has no organization. ${error.message}`;
496
+ }
497
+ return `The server rejected the key (HTTP ${error.status}): ${error.message}`;
498
+ }
499
+ const message = error instanceof Error ? error.message : String(error);
500
+ return `Could not reach ${apiUrl}: ${message}`;
501
+ }
502
+ async function promptSecret(question) {
503
+ stdout.write(question);
504
+ stdin.setRawMode?.(true);
505
+ stdin.resume();
506
+ stdin.setEncoding("utf8");
507
+ return new Promise((resolve) => {
508
+ let value = "";
509
+ const finish = () => {
510
+ stdin.setRawMode?.(false);
511
+ stdin.pause();
512
+ stdin.off("data", onData);
513
+ stdout.write("\n");
514
+ resolve(value);
515
+ };
516
+ const onData = (chunk) => {
517
+ for (const char of chunk) {
518
+ if (char === "\r" || char === "\n" || char === "") {
519
+ finish();
520
+ return;
521
+ }
522
+ if (char === "") {
523
+ stdin.setRawMode?.(false);
524
+ stdout.write("\n");
525
+ process.exit(130);
526
+ }
527
+ if (char === "\x7F" || char === "\b") {
528
+ if (value.length > 0) {
529
+ value = value.slice(0, -1);
530
+ stdout.write("\b \b");
531
+ }
532
+ continue;
533
+ }
534
+ if (char < " ") continue;
535
+ value += char;
536
+ stdout.write("*");
537
+ }
538
+ };
539
+ stdin.on("data", onData);
540
+ });
541
+ }
542
+
543
+ // src/commands/logout.ts
544
+ async function logoutCommand() {
545
+ const hadCredentials = await clearCredentials();
546
+ if (!hadCredentials) {
547
+ console.log("No saved credentials.");
548
+ } else {
549
+ console.log(`Signed out. The API key was removed from ${keyStorageDescription()}.`);
550
+ }
551
+ if (process.env.CUTSIO_API_KEY) {
552
+ console.log("CUTSIO_API_KEY is still set in this shell \u2014 unset it to finish signing out.");
553
+ }
554
+ return 0;
555
+ }
556
+
557
+ // src/core/sync.ts
558
+ import { existsSync as existsSync4, statSync as statSync2 } from "node:fs";
559
+ import { join as join2 } from "node:path";
560
+
561
+ // src/core/download.ts
562
+ import { spawn } from "node:child_process";
563
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, renameSync, statSync } from "node:fs";
564
+ import { dirname as dirname2 } from "node:path";
565
+ function partPathFor(destPath) {
566
+ return `${destPath}.part`;
567
+ }
568
+ async function downloadResumable(url, destPath, onProgress) {
569
+ mkdirSync2(dirname2(destPath), { recursive: true });
570
+ const part = partPathFor(destPath);
571
+ const resumedFrom = existsSync2(part) ? statSync(part).size : 0;
572
+ const total = await remoteSize(url);
573
+ let timer = null;
574
+ if (onProgress) {
575
+ onProgress(resumedFrom, total);
576
+ timer = setInterval(() => {
577
+ try {
578
+ onProgress(statSync(part).size, total);
579
+ } catch {
580
+ }
581
+ }, 200);
582
+ }
583
+ let code;
584
+ let stderr;
585
+ try {
586
+ ({ code, stderr } = await runCurl([
587
+ "-sS",
588
+ "--fail",
589
+ "--location",
590
+ "--retry",
591
+ "5",
592
+ "--retry-delay",
593
+ "2",
594
+ "--retry-all-errors",
595
+ "--connect-timeout",
596
+ "30",
597
+ "-C",
598
+ "-",
599
+ "-o",
600
+ part,
601
+ url
602
+ ]));
603
+ } finally {
604
+ if (timer) clearInterval(timer);
605
+ }
606
+ if (code === 0) {
607
+ const bytes = statSync(part).size;
608
+ onProgress?.(bytes, total ?? bytes);
609
+ renameSync(part, destPath);
610
+ return { ok: true, bytes: statSync(destPath).size, resumedFrom };
611
+ }
612
+ if (total !== null && existsSync2(part) && statSync(part).size === total) {
613
+ onProgress?.(total, total);
614
+ renameSync(part, destPath);
615
+ return { ok: true, bytes: total, resumedFrom };
616
+ }
617
+ const lastLine = stderr.trim().split("\n").filter(Boolean).at(-1);
618
+ return { ok: false, error: `curl exited ${code}${lastLine ? `: ${lastLine}` : ""}` };
619
+ }
620
+ function runCurl(args) {
621
+ return new Promise((resolve) => {
622
+ const child = spawn("curl", args, { stdio: ["ignore", "ignore", "pipe"] });
623
+ let stderr = "";
624
+ child.stderr.on("data", (chunk) => {
625
+ stderr += chunk.toString();
626
+ });
627
+ child.on("error", (error) => resolve({ code: -1, stderr: error.message }));
628
+ child.on("close", (code) => resolve({ code: code ?? -1, stderr }));
629
+ });
630
+ }
631
+ async function remoteSize(url) {
632
+ try {
633
+ const response = await fetch(url, { method: "HEAD" });
634
+ if (!response.ok) return null;
635
+ const header = response.headers.get("content-length");
636
+ const value = header ? Number(header) : Number.NaN;
637
+ return Number.isFinite(value) ? value : null;
638
+ } catch {
639
+ return null;
640
+ }
641
+ }
642
+
643
+ // src/core/state.ts
644
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync2, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "node:fs";
645
+ import { dirname as dirname3 } from "node:path";
646
+ var STATE_FILE = ".cutsio-state.json";
647
+ var StateOrgMismatchError = class extends Error {
648
+ constructor(message) {
649
+ super(message);
650
+ this.name = "StateOrgMismatchError";
651
+ }
652
+ };
653
+ function emptyState(orgId) {
654
+ return { schema: 1, orgId, updatedAt: Date.now(), videos: {} };
655
+ }
656
+ function loadState(path, orgId) {
657
+ if (!existsSync3(path)) return emptyState(orgId);
658
+ try {
659
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
660
+ if (parsed.schema !== 1 || typeof parsed.videos !== "object" || parsed.videos === null) {
661
+ return emptyState(orgId);
662
+ }
663
+ if (parsed.orgId && parsed.orgId !== orgId) {
664
+ throw new StateOrgMismatchError(
665
+ `This directory is already syncing ${parsed.orgId}. Use a different --out directory for ${orgId}.`
666
+ );
667
+ }
668
+ return { schema: 1, orgId, updatedAt: Date.now(), videos: parsed.videos };
669
+ } catch (error) {
670
+ if (error instanceof StateOrgMismatchError) throw error;
671
+ return emptyState(orgId);
672
+ }
673
+ }
674
+ function saveState(path, state) {
675
+ mkdirSync3(dirname3(path), { recursive: true });
676
+ state.updatedAt = Date.now();
677
+ const temp = `${path}.tmp`;
678
+ writeFileSync2(temp, JSON.stringify(state, null, 2));
679
+ renameSync2(temp, path);
680
+ }
681
+
682
+ // src/core/sync.ts
683
+ var PREPARE_BATCH_SIZE = 50;
684
+ var INITIAL_PREPARE_DELAY_MS = 3e3;
685
+ var MAX_PREPARE_DELAY_MS = 3e4;
686
+ async function sync(client, options) {
687
+ const { onEvent, outDir } = options;
688
+ const manifest = options.manifest ?? await client.fetchManifest();
689
+ const resolved = resolveSelection(manifest, options.selection);
690
+ if (resolved.errors.length > 0) {
691
+ throw new Error(`Selection problems:
692
+ ${resolved.errors.join("\n ")}`);
693
+ }
694
+ if (resolved.videos.length === 0) {
695
+ throw new Error("Selection matched no videos.");
696
+ }
697
+ const folderPaths = buildFolderPaths(manifest.folders);
698
+ const statePath = join2(outDir, STATE_FILE);
699
+ const state = loadState(statePath, manifest.orgId);
700
+ onEvent({
701
+ type: "manifest",
702
+ orgId: manifest.orgId,
703
+ folders: manifest.folders.length,
704
+ videos: manifest.videos.length
705
+ });
706
+ const pending = [];
707
+ let unchanged = 0;
708
+ let skipped = 0;
709
+ for (const video of resolved.videos) {
710
+ if (video.status !== "ready") {
711
+ skipped += 1;
712
+ onEvent({ type: "file-skipped", id: video.id, title: video.title, reason: `status: ${video.status}` });
713
+ continue;
714
+ }
715
+ if (video.moderation_status === "failed" || video.moderation_status === "errored") {
716
+ skipped += 1;
717
+ onEvent({ type: "file-skipped", id: video.id, title: video.title, reason: "restricted" });
718
+ continue;
719
+ }
720
+ const relPath = videoRelativePath(video, folderPaths);
721
+ const absPath = join2(outDir, relPath);
722
+ const stored = state.videos[video.id];
723
+ const isCurrent = stored !== void 0 && stored.version === video.version && stored.path === relPath && existsSync4(absPath) && (stored.sizeBytes === null || statSync2(absPath).size === stored.sizeBytes);
724
+ if (isCurrent) {
725
+ unchanged += 1;
726
+ continue;
727
+ }
728
+ pending.push({ video, absPath, relPath, resuming: existsSync4(partPathFor(absPath)) });
729
+ }
730
+ const selected = options.limit && options.limit > 0 ? pending.slice(0, options.limit) : pending;
731
+ onEvent({ type: "planned", total: resolved.videos.length, pending: selected.length, unchanged });
732
+ if (options.dryRun) {
733
+ for (const item of selected) {
734
+ onEvent({
735
+ type: "file-start",
736
+ id: item.video.id,
737
+ title: item.video.title,
738
+ path: item.relPath,
739
+ resuming: item.resuming
740
+ });
741
+ }
742
+ return { downloaded: 0, unchanged, skipped, failed: 0 };
743
+ }
744
+ let downloaded = 0;
745
+ let failed = 0;
746
+ let prepareDelayMs = INITIAL_PREPARE_DELAY_MS;
747
+ const deadline = Date.now() + options.maxWaitSeconds * 1e3;
748
+ const queue = new Map(selected.map((item) => [item.video.id, item]));
749
+ while (queue.size > 0) {
750
+ const ready = [];
751
+ const ids = [...queue.keys()];
752
+ for (let index = 0; index < ids.length; index += PREPARE_BATCH_SIZE) {
753
+ const batch = ids.slice(index, index + PREPARE_BATCH_SIZE);
754
+ const results = await client.prepare(batch);
755
+ for (const result of results) {
756
+ const item = queue.get(result.id);
757
+ if (!item) continue;
758
+ switch (result.status) {
759
+ case "ready":
760
+ if (result.url) {
761
+ ready.push({ id: result.id, url: result.url });
762
+ } else {
763
+ queue.delete(result.id);
764
+ failed += 1;
765
+ onEvent({ type: "file-failed", id: result.id, title: item.video.title, error: "no download URL returned" });
766
+ }
767
+ break;
768
+ case "preparing":
769
+ break;
770
+ case "not_found":
771
+ case "not_ready":
772
+ queue.delete(result.id);
773
+ skipped += 1;
774
+ onEvent({ type: "file-skipped", id: result.id, title: item.video.title, reason: result.status });
775
+ break;
776
+ case "forbidden":
777
+ queue.delete(result.id);
778
+ skipped += 1;
779
+ onEvent({ type: "file-skipped", id: result.id, title: item.video.title, reason: "restricted" });
780
+ break;
781
+ default:
782
+ queue.delete(result.id);
783
+ failed += 1;
784
+ onEvent({ type: "file-failed", id: result.id, title: item.video.title, error: `prepare: ${result.status}` });
785
+ break;
786
+ }
787
+ }
788
+ }
789
+ if (ready.length > 0) {
790
+ prepareDelayMs = INITIAL_PREPARE_DELAY_MS;
791
+ onEvent({
792
+ type: "status",
793
+ preparing: Math.max(0, queue.size - ready.length),
794
+ downloading: ready.length,
795
+ queued: queue.size
796
+ });
797
+ await runPool(ready, options.concurrency, async ({ id, url }) => {
798
+ const item = queue.get(id);
799
+ if (!item) return;
800
+ onEvent({
801
+ type: "file-start",
802
+ id,
803
+ title: item.video.title,
804
+ path: item.relPath,
805
+ resuming: item.resuming
806
+ });
807
+ const report = (bytes, total) => onEvent({ type: "file-progress", id, bytes, total });
808
+ let result = await downloadResumable(url, item.absPath, report);
809
+ if (!result.ok) {
810
+ const [refreshed] = await client.prepare([id]);
811
+ if (refreshed?.status === "ready" && refreshed.url) {
812
+ result = await downloadResumable(refreshed.url, item.absPath, report);
813
+ }
814
+ }
815
+ if (result.ok) {
816
+ state.videos[id] = {
817
+ version: item.video.version,
818
+ sizeBytes: result.bytes,
819
+ path: item.relPath
820
+ };
821
+ saveState(statePath, state);
822
+ queue.delete(id);
823
+ downloaded += 1;
824
+ onEvent({ type: "file-done", id, title: item.video.title, path: item.relPath, bytes: result.bytes });
825
+ } else {
826
+ queue.delete(id);
827
+ failed += 1;
828
+ onEvent({ type: "file-failed", id, title: item.video.title, error: result.error });
829
+ }
830
+ });
831
+ }
832
+ if (queue.size === 0) break;
833
+ onEvent({
834
+ type: "status",
835
+ preparing: queue.size,
836
+ downloading: 0,
837
+ queued: queue.size
838
+ });
839
+ if (Date.now() > deadline) {
840
+ for (const item of queue.values()) {
841
+ failed += 1;
842
+ onEvent({
843
+ type: "file-failed",
844
+ id: item.video.id,
845
+ title: item.video.title,
846
+ error: "timed out waiting for the master"
847
+ });
848
+ }
849
+ break;
850
+ }
851
+ await sleep(prepareDelayMs);
852
+ prepareDelayMs = Math.min(prepareDelayMs * 2, MAX_PREPARE_DELAY_MS);
853
+ }
854
+ return { downloaded, unchanged, skipped, failed };
855
+ }
856
+ async function runPool(items, concurrency, worker) {
857
+ let index = 0;
858
+ const workerCount = Math.max(1, Math.min(concurrency, items.length));
859
+ await Promise.all(
860
+ Array.from({ length: workerCount }, async () => {
861
+ for (; ; ) {
862
+ const current = index;
863
+ index += 1;
864
+ const item = items[current];
865
+ if (item === void 0) return;
866
+ await worker(item);
867
+ }
868
+ })
869
+ );
870
+ }
871
+ function sleep(ms) {
872
+ return new Promise((resolve) => setTimeout(resolve, ms));
873
+ }
874
+
875
+ // src/ui/format.ts
876
+ function formatBytes(bytes) {
877
+ const units = ["B", "KB", "MB", "GB", "TB"];
878
+ let value = bytes;
879
+ let unit = 0;
880
+ while (value >= 1024 && unit < units.length - 1) {
881
+ value /= 1024;
882
+ unit += 1;
883
+ }
884
+ return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
885
+ }
886
+
887
+ // src/ui/theme.ts
888
+ var colorEnabled = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
889
+ function paint(code) {
890
+ return colorEnabled ? (text) => `\x1B[${code}m${text}\x1B[0m` : (text) => text;
891
+ }
892
+ var color = {
893
+ dim: paint(2),
894
+ bold: paint(1),
895
+ red: paint(31),
896
+ green: paint(32),
897
+ yellow: paint(33),
898
+ blue: paint(34),
899
+ magenta: paint(35),
900
+ cyan: paint(36)
901
+ };
902
+ var isInteractive = colorEnabled;
903
+ var animationsEnabled = colorEnabled && process.env.CUTSIO_NO_ANIM !== "1";
904
+ function sleep2(ms) {
905
+ return new Promise((resolve) => setTimeout(resolve, ms));
906
+ }
907
+
908
+ // src/ui/progress.ts
909
+ var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
910
+ var BAR_WIDTH = 22;
911
+ var ProgressView = class {
912
+ active = /* @__PURE__ */ new Map();
913
+ interactive = isInteractive;
914
+ statusLine = null;
915
+ renderedLines = 0;
916
+ frame = 0;
917
+ timer = null;
918
+ dirty = false;
919
+ constructor() {
920
+ if (!this.interactive) return;
921
+ this.timer = setInterval(() => {
922
+ if (this.active.size === 0) return;
923
+ this.frame += 1;
924
+ this.render();
925
+ }, 120);
926
+ this.timer.unref();
927
+ }
928
+ status(text) {
929
+ if (!this.interactive) return;
930
+ this.statusLine = text;
931
+ this.dirty = true;
932
+ }
933
+ /** Print a line above the live area without tearing it. */
934
+ log(message) {
935
+ if (!this.interactive) {
936
+ console.log(message);
937
+ return;
938
+ }
939
+ this.clear();
940
+ process.stdout.write(`${message}
941
+ `);
942
+ this.render();
943
+ }
944
+ error(message) {
945
+ if (!this.interactive) {
946
+ console.error(message);
947
+ return;
948
+ }
949
+ this.clear();
950
+ process.stderr.write(`${message}
951
+ `);
952
+ this.render();
953
+ }
954
+ start(id, label) {
955
+ if (!this.interactive) {
956
+ console.log(` fetch ${label}`);
957
+ return;
958
+ }
959
+ const now = Date.now();
960
+ this.active.set(id, {
961
+ label,
962
+ bytes: 0,
963
+ total: null,
964
+ lastBytes: 0,
965
+ lastAt: now,
966
+ speed: 0
967
+ });
968
+ this.dirty = true;
969
+ this.render();
970
+ }
971
+ progress(id, bytes, total) {
972
+ if (!this.interactive) return;
973
+ const item = this.active.get(id);
974
+ if (!item) return;
975
+ const now = Date.now();
976
+ const elapsed = (now - item.lastAt) / 1e3;
977
+ if (elapsed >= 0.3) {
978
+ const delta = bytes - item.lastBytes;
979
+ if (delta >= 0) item.speed = delta / elapsed;
980
+ item.lastBytes = bytes;
981
+ item.lastAt = now;
982
+ }
983
+ item.bytes = bytes;
984
+ if (total !== null) item.total = total;
985
+ this.dirty = true;
986
+ }
987
+ finish(id, ok, text) {
988
+ if (!this.interactive) {
989
+ if (ok) console.log(` done ${text}`);
990
+ else console.error(` FAILED ${text}`);
991
+ return;
992
+ }
993
+ this.clear();
994
+ const line = ok ? ` ${color.green("\u2713")} ${text}` : ` ${color.red("\u2717")} ${text}`;
995
+ (ok ? process.stdout : process.stderr).write(`${line}
996
+ `);
997
+ this.active.delete(id);
998
+ this.render();
999
+ }
1000
+ stop() {
1001
+ if (this.timer) clearInterval(this.timer);
1002
+ this.timer = null;
1003
+ if (!this.interactive) return;
1004
+ this.clear();
1005
+ this.active.clear();
1006
+ this.statusLine = null;
1007
+ }
1008
+ clear() {
1009
+ if (this.renderedLines === 0) return;
1010
+ process.stdout.write(`\x1B[${this.renderedLines}A\x1B[J`);
1011
+ this.renderedLines = 0;
1012
+ }
1013
+ render() {
1014
+ if (!this.interactive) return;
1015
+ if (this.active.size === 0 && !this.statusLine) {
1016
+ this.clear();
1017
+ return;
1018
+ }
1019
+ const lines = [];
1020
+ if (this.statusLine) {
1021
+ const spinner = FRAMES[this.frame % FRAMES.length] ?? FRAMES[0];
1022
+ lines.push(` ${color.cyan(spinner ?? "")} ${color.dim(this.statusLine)}`);
1023
+ }
1024
+ for (const item of this.active.values()) {
1025
+ lines.push(` ${color.bold(item.label)}`);
1026
+ lines.push(` ${this.bar(item)}`);
1027
+ }
1028
+ this.clear();
1029
+ process.stdout.write(`${lines.join("\n")}
1030
+ `);
1031
+ this.renderedLines = lines.length;
1032
+ this.dirty = false;
1033
+ }
1034
+ bar(item) {
1035
+ const ratio = item.total && item.total > 0 ? Math.min(1, item.bytes / item.total) : null;
1036
+ const filled = ratio === null ? 0 : Math.round(ratio * BAR_WIDTH);
1037
+ const track = color.cyan("\u2588".repeat(filled)) + color.dim("\u2591".repeat(BAR_WIDTH - filled));
1038
+ const percent = ratio === null ? " ? " : `${Math.floor(ratio * 100)}%`.padStart(4);
1039
+ const size = item.total ? `${formatBytes(item.bytes)} / ${formatBytes(item.total)}` : formatBytes(item.bytes);
1040
+ const speed = item.speed > 0 ? `${formatBytes(item.speed)}/s` : "";
1041
+ return `${track} ${percent} ${size} ${color.dim(speed)}`.trimEnd();
1042
+ }
1043
+ };
1044
+
1045
+ // src/commands/selection-prompt.ts
1046
+ import { stdin as stdin2, stdout as stdout2 } from "node:process";
1047
+ import { createInterface } from "node:readline/promises";
1048
+ function countLabel(count) {
1049
+ return `${count} video${count === 1 ? "" : "s"}`;
1050
+ }
1051
+ async function promptSelection(manifest) {
1052
+ const { subtree } = folderVideoCounts(manifest);
1053
+ const entries = [
1054
+ ...manifest.collections.map((collection) => ({
1055
+ kind: "collection",
1056
+ ref: collection.title,
1057
+ label: `collection ${collection.title} \u2014 ${countLabel(
1058
+ manifest.videos.filter((video) => video.collection_id === collection.id).length
1059
+ )}`
1060
+ })),
1061
+ ...[...buildFolderPaths(manifest.folders).entries()].sort((a, b) => a[1].localeCompare(b[1])).map(([id, path]) => ({
1062
+ kind: "folder",
1063
+ ref: path,
1064
+ label: `folder ${path}/ \u2014 ${countLabel(subtree.get(id) ?? 0)}`
1065
+ }))
1066
+ ];
1067
+ const unfiledCount = manifest.videos.filter((video) => !video.folder_id).length;
1068
+ if (unfiledCount > 0) {
1069
+ entries.push({
1070
+ kind: "unfiled",
1071
+ ref: "",
1072
+ label: `unfiled (no folder) \u2014 ${countLabel(unfiledCount)}`
1073
+ });
1074
+ }
1075
+ if (entries.length === 0) {
1076
+ console.error("Nothing to select: this workspace has no folders or collections.");
1077
+ return null;
1078
+ }
1079
+ console.log("Select what to sync:");
1080
+ entries.forEach((entry, index) => {
1081
+ console.log(` ${String(index + 1).padStart(3)}) ${entry.label}`);
1082
+ });
1083
+ const rl = createInterface({ input: stdin2, output: stdout2 });
1084
+ try {
1085
+ for (; ; ) {
1086
+ const answer = (await rl.question('Numbers separated by commas, "all", or blank to cancel: ')).trim();
1087
+ if (answer === "") return null;
1088
+ if (answer.toLowerCase() === "all") {
1089
+ return { all: true, unfiled: false, folderRefs: [], collectionRefs: [], videoIds: [], searchTerms: [] };
1090
+ }
1091
+ const indexes = answer.split(/[,\s]+/).filter(Boolean).map(Number);
1092
+ const invalid = indexes.some(
1093
+ (value) => !Number.isInteger(value) || value < 1 || value > entries.length
1094
+ );
1095
+ if (invalid || indexes.length === 0) {
1096
+ console.log(`Enter numbers between 1 and ${entries.length}.`);
1097
+ continue;
1098
+ }
1099
+ const picked = indexes.map((index) => entries[index - 1]).filter((entry) => entry !== void 0);
1100
+ return {
1101
+ all: false,
1102
+ unfiled: picked.some((entry) => entry.kind === "unfiled"),
1103
+ folderRefs: picked.filter((entry) => entry.kind === "folder").map((entry) => entry.ref),
1104
+ collectionRefs: picked.filter((entry) => entry.kind === "collection").map((entry) => entry.ref),
1105
+ videoIds: [],
1106
+ searchTerms: []
1107
+ };
1108
+ }
1109
+ } finally {
1110
+ rl.close();
1111
+ }
1112
+ }
1113
+
1114
+ // src/commands/sync.ts
1115
+ async function syncCommand(flags) {
1116
+ const apiKey = await resolveApiKey();
1117
+ if (!apiKey) {
1118
+ console.error("No API key. Run `cutsio login` or set CUTSIO_API_KEY.");
1119
+ return 1;
1120
+ }
1121
+ const client = new CutsioClient(resolveApiUrl(flags.url), apiKey);
1122
+ const view = new ProgressView();
1123
+ const onEvent = (event) => {
1124
+ switch (event.type) {
1125
+ case "manifest":
1126
+ view.log(
1127
+ `${color.dim("workspace")} ${event.orgId} ${color.dim("\xB7")} ${event.videos} videos ${color.dim("\xB7")} ${event.folders} folders`
1128
+ );
1129
+ break;
1130
+ case "planned":
1131
+ view.log(
1132
+ `${color.dim("selected")} ${event.total} ${color.dim("\xB7")} pending ${event.pending} ${color.dim("\xB7")} unchanged ${event.unchanged}`
1133
+ );
1134
+ break;
1135
+ case "status":
1136
+ view.status(
1137
+ `${event.downloading} downloading \xB7 ${event.preparing} preparing \xB7 ${event.queued} remaining`
1138
+ );
1139
+ break;
1140
+ case "file-start":
1141
+ view.start(event.id, `${event.resuming ? "resuming " : ""}${event.path}`);
1142
+ break;
1143
+ case "file-progress":
1144
+ view.progress(event.id, event.bytes, event.total);
1145
+ break;
1146
+ case "file-done":
1147
+ view.finish(event.id, true, `${event.path} (${formatBytes(event.bytes)})`);
1148
+ break;
1149
+ case "file-skipped":
1150
+ view.log(` ${color.yellow("skip")} ${event.title} \u2014 ${event.reason}`);
1151
+ break;
1152
+ case "file-failed":
1153
+ view.finish(event.id, false, `${event.title} \u2014 ${event.error}`);
1154
+ break;
1155
+ case "warning":
1156
+ view.log(` ${color.yellow("warning")} ${event.message}`);
1157
+ break;
1158
+ }
1159
+ };
1160
+ let selection = {
1161
+ all: flags.all === "true",
1162
+ folderRefs: splitList(flags.folder),
1163
+ collectionRefs: splitList(flags.collection),
1164
+ videoIds: splitList(flags.video),
1165
+ searchTerms: splitList(flags.search),
1166
+ unfiled: flags.unfiled === "true"
1167
+ };
1168
+ let manifest;
1169
+ try {
1170
+ if (flags.interactive === "true") {
1171
+ view.stop();
1172
+ manifest = await client.fetchManifest();
1173
+ const prompted = await promptSelection(manifest);
1174
+ if (!prompted) {
1175
+ console.log("Cancelled. Nothing downloaded.");
1176
+ return 0;
1177
+ }
1178
+ selection = mergeSelections(selection, prompted);
1179
+ }
1180
+ if (isEmptySelection(selection)) {
1181
+ console.error("Refusing to sync the whole workspace. Choose one of:\n");
1182
+ console.error(" --all every video in the workspace");
1183
+ console.error(" --folder <path|id,...> a folder and everything under it");
1184
+ console.error(" --collection <name|id,...> a collection");
1185
+ console.error(" --video <id,...> specific videos");
1186
+ console.error(" --search <term,...> video titles containing a term");
1187
+ console.error(" --unfiled videos that are in no folder");
1188
+ console.error(" --interactive pick from a menu\n");
1189
+ console.error("`cutsio list` prints names, paths, and ids.");
1190
+ return 1;
1191
+ }
1192
+ const summary = await sync(client, {
1193
+ outDir: flags.out ?? "./cutsio",
1194
+ concurrency: positiveInt(flags.concurrency, 3),
1195
+ dryRun: flags["dry-run"] === "true",
1196
+ maxWaitSeconds: positiveInt(flags["max-wait"], 600),
1197
+ limit: positiveInt(flags.limit, 0),
1198
+ selection,
1199
+ manifest,
1200
+ onEvent
1201
+ });
1202
+ view.stop();
1203
+ const failing = summary.failed > 0;
1204
+ const summaryLine = `${summary.downloaded} downloaded \xB7 ${summary.unchanged} unchanged \xB7 ${summary.skipped} skipped \xB7 ${summary.failed} failed`;
1205
+ console.log(`
1206
+ ${failing ? color.red(summaryLine) : color.green(summaryLine)}`);
1207
+ return failing ? 1 : 0;
1208
+ } catch (error) {
1209
+ view.stop();
1210
+ console.error(error instanceof Error ? error.message : String(error));
1211
+ return 1;
1212
+ }
1213
+ }
1214
+ function splitList(value) {
1215
+ if (!value) return [];
1216
+ return [...new Set(value.split(",").map((entry) => entry.trim()).filter(Boolean))];
1217
+ }
1218
+ function mergeSelections(a, b) {
1219
+ return {
1220
+ all: a.all || b.all,
1221
+ unfiled: a.unfiled || b.unfiled,
1222
+ folderRefs: [.../* @__PURE__ */ new Set([...a.folderRefs, ...b.folderRefs])],
1223
+ collectionRefs: [.../* @__PURE__ */ new Set([...a.collectionRefs, ...b.collectionRefs])],
1224
+ videoIds: [.../* @__PURE__ */ new Set([...a.videoIds, ...b.videoIds])],
1225
+ searchTerms: [.../* @__PURE__ */ new Set([...a.searchTerms, ...b.searchTerms])]
1226
+ };
1227
+ }
1228
+ function positiveInt(value, fallback) {
1229
+ const parsed = Number(value);
1230
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
1231
+ }
1232
+
1233
+ // src/commands/whoami.ts
1234
+ async function whoamiCommand(flags) {
1235
+ const apiKey = await resolveApiKey();
1236
+ if (!apiKey) {
1237
+ console.error("No API key. Run `cutsio login` or set CUTSIO_API_KEY.");
1238
+ return 1;
1239
+ }
1240
+ const client = new CutsioClient(resolveApiUrl(flags.url), apiKey);
1241
+ try {
1242
+ console.log(JSON.stringify(await client.whoami(), null, 2));
1243
+ return 0;
1244
+ } catch (error) {
1245
+ console.error(error instanceof Error ? error.message : String(error));
1246
+ return 1;
1247
+ }
1248
+ }
1249
+
1250
+ // src/ui/banner.ts
1251
+ var WORDMARK = [
1252
+ " \u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 ",
1253
+ "\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2551 \u2588\u2588\u2551\u255A\u2550\u2550\u2588\u2588\u2554\u2550\u2550\u255D\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2557",
1254
+ "\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551",
1255
+ "\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u255A\u2550\u2550\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551",
1256
+ "\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2551\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D",
1257
+ " \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D "
1258
+ ];
1259
+ var SHADES = [96, 96, 36, 36, 34, 34];
1260
+ function shade(line, index) {
1261
+ if (!animationsEnabled) return line;
1262
+ const code = SHADES[index] ?? 36;
1263
+ return `\x1B[${code}m${line}\x1B[0m`;
1264
+ }
1265
+ async function revealBanner() {
1266
+ process.stdout.write("\n");
1267
+ for (let index = 0; index < WORDMARK.length; index += 1) {
1268
+ process.stdout.write(`${shade(WORDMARK[index] ?? "", index)}
1269
+ `);
1270
+ if (animationsEnabled) await sleep2(38);
1271
+ }
1272
+ process.stdout.write(`${color.dim(" mezzanine sync for your Mac")}
1273
+
1274
+ `);
1275
+ if (animationsEnabled) await sleep2(60);
1276
+ }
1277
+
1278
+ // src/index.ts
1279
+ function parseFlags(args) {
1280
+ const flags = {};
1281
+ for (let index = 0; index < args.length; index += 1) {
1282
+ const arg = args[index];
1283
+ if (!arg || !arg.startsWith("--")) continue;
1284
+ const body = arg.slice(2);
1285
+ const separator = body.indexOf("=");
1286
+ const key = separator === -1 ? body : body.slice(0, separator);
1287
+ const inline = separator === -1 ? void 0 : body.slice(separator + 1);
1288
+ if (!key) continue;
1289
+ if (inline !== void 0) {
1290
+ flags[key] = inline;
1291
+ continue;
1292
+ }
1293
+ const next = args[index + 1];
1294
+ if (next !== void 0 && !next.startsWith("--")) {
1295
+ flags[key] = next;
1296
+ index += 1;
1297
+ } else {
1298
+ flags[key] = "true";
1299
+ }
1300
+ }
1301
+ return flags;
1302
+ }
1303
+ function printHelp() {
1304
+ console.log(`cutsio \u2014 sync Cutsio mezzanine masters to a local folder
1305
+
1306
+ Usage:
1307
+ cutsio login [--url URL] [--key KEY]
1308
+ cutsio logout
1309
+ cutsio whoami [--url URL]
1310
+ cutsio list [--videos] [--search TERM] [--json] [--url URL]
1311
+ cutsio sync [--all | --folder REFS | --collection REFS | --video IDS | --search TERMS | --unfiled | --interactive]
1312
+ [--out DIR] [--concurrency N] [--max-wait SECONDS] [--limit N] [--dry-run] [--url URL]
1313
+
1314
+ Selection:
1315
+ REFS and IDS are comma-separated. Folders accept a path (e.g. test/sub1a) or id;
1316
+ collections accept a title or id. A folder selection includes its subfolders.
1317
+ --search matches video titles anywhere in the workspace; --unfiled selects
1318
+ videos that are in no folder.
1319
+
1320
+ Environment:
1321
+ CUTSIO_API_URL API base URL (default ${DEFAULT_API_URL})
1322
+ CUTSIO_API_KEY Organization API key
1323
+ NO_COLOR Disable color
1324
+ CUTSIO_NO_ANIM=1 Disable the banner and live progress`);
1325
+ }
1326
+ async function maybeWelcome() {
1327
+ if (!animationsEnabled) return;
1328
+ const marker = join3(configDir(), ".welcomed");
1329
+ if (existsSync5(marker)) return;
1330
+ await revealBanner();
1331
+ try {
1332
+ mkdirSync4(configDir(), { recursive: true, mode: 448 });
1333
+ writeFileSync3(marker, "");
1334
+ } catch {
1335
+ }
1336
+ }
1337
+ async function main() {
1338
+ const [, , command, ...rest] = process2.argv;
1339
+ const flags = parseFlags(rest);
1340
+ switch (command) {
1341
+ case "login":
1342
+ case "whoami":
1343
+ case "list":
1344
+ case "sync":
1345
+ await maybeWelcome();
1346
+ break;
1347
+ default:
1348
+ break;
1349
+ }
1350
+ switch (command) {
1351
+ case "login":
1352
+ return loginCommand(flags);
1353
+ case "logout":
1354
+ return logoutCommand();
1355
+ case "whoami":
1356
+ return whoamiCommand(flags);
1357
+ case "list":
1358
+ return listCommand(flags);
1359
+ case "sync":
1360
+ return syncCommand(flags);
1361
+ case void 0:
1362
+ case "help":
1363
+ case "--help":
1364
+ case "-h":
1365
+ printHelp();
1366
+ return 0;
1367
+ default:
1368
+ console.error(`Unknown command: ${command}`);
1369
+ printHelp();
1370
+ return 1;
1371
+ }
1372
+ }
1373
+ main().then((code) => {
1374
+ process2.exitCode = code;
1375
+ });