cutsio 0.1.0 → 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 +8 -2
  2. package/dist/index.js +425 -58
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -19,10 +19,12 @@ Create an **organization** API key in Cutsio: Settings → Organization → API
19
19
  ```bash
20
20
  cutsio login --url https://studio.cutsio.com --key ak_...
21
21
  cutsio whoami
22
+ cutsio logout # removes the stored key
22
23
  ```
23
24
 
24
- Config is stored at `~/.cutsio/config.json` with mode `600`. You can also skip
25
- the config file entirely:
25
+ On macOS the key goes into the **Keychain**; elsewhere it is written to
26
+ `~/.cutsio/config.json` with mode `600`. You can also skip the config file
27
+ entirely:
26
28
 
27
29
  ```bash
28
30
  export CUTSIO_API_URL=https://studio.cutsio.com
@@ -35,9 +37,13 @@ export CUTSIO_API_KEY=ak_...
35
37
  npm install
36
38
  npm run dev -- whoami # runs src/index.ts directly (Node 23.6+)
37
39
  npm run typecheck
40
+ npm test # selection, paths, and state
38
41
  npm run build # bundles src -> dist/index.js
39
42
  ```
40
43
 
44
+ Set `NO_COLOR=1` to disable color and `CUTSIO_NO_ANIM=1` to disable the
45
+ first-run banner and live progress. Both are automatic when output is piped.
46
+
41
47
  ## List and select
42
48
 
43
49
  `sync` never downloads the whole workspace unless you ask for it. Start by
package/dist/index.js CHANGED
@@ -1,23 +1,82 @@
1
1
  #!/usr/bin/env node
2
2
 
3
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";
4
6
  import process2 from "node:process";
5
7
 
6
8
  // src/config.ts
7
9
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
10
  import { homedir } from "node:os";
9
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
10
60
  var DEFAULT_API_URL = "https://studio.cutsio.com";
61
+ function configDir() {
62
+ return join(homedir(), ".cutsio");
63
+ }
11
64
  function configPath() {
12
- return join(homedir(), ".cutsio", "config.json");
65
+ return join(configDir(), "config.json");
66
+ }
67
+ function keyStorageDescription() {
68
+ return keychainAvailable() ? "macOS Keychain" : `${configPath()} (mode 600)`;
13
69
  }
14
70
  function readConfig() {
15
71
  const path = configPath();
16
72
  if (!existsSync(path)) return null;
17
73
  try {
18
74
  const parsed = JSON.parse(readFileSync(path, "utf8"));
19
- if (typeof parsed.apiUrl !== "string" || typeof parsed.apiKey !== "string") return null;
20
- return { apiUrl: parsed.apiUrl, apiKey: parsed.apiKey };
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
+ };
21
80
  } catch {
22
81
  return null;
23
82
  }
@@ -25,15 +84,47 @@ function readConfig() {
25
84
  function writeConfig(config) {
26
85
  const path = configPath();
27
86
  mkdirSync(dirname(path), { recursive: true, mode: 448 });
28
- writeFileSync(path, JSON.stringify(config, null, 2), { mode: 384 });
87
+ const payload = config.apiKey ? { apiUrl: config.apiUrl, apiKey: config.apiKey } : { apiUrl: config.apiUrl };
88
+ writeFileSync(path, JSON.stringify(payload, null, 2), { mode: 384 });
29
89
  chmodSync(path, 384);
30
90
  }
31
91
  function resolveApiUrl(flag) {
32
92
  return flag || process.env.CUTSIO_API_URL || readConfig()?.apiUrl || DEFAULT_API_URL;
33
93
  }
34
- function resolveApiKey() {
35
- const key = process.env.CUTSIO_API_KEY || readConfig()?.apiKey;
36
- return key && key.length > 0 ? key : null;
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;
37
128
  }
38
129
 
39
130
  // src/core/client.ts
@@ -260,7 +351,7 @@ function resolveSelection(manifest, selection) {
260
351
 
261
352
  // src/commands/list.ts
262
353
  async function listCommand(flags) {
263
- const apiKey = resolveApiKey();
354
+ const apiKey = await resolveApiKey();
264
355
  if (!apiKey) {
265
356
  console.error("No API key. Run `cutsio login` or set CUTSIO_API_KEY.");
266
357
  return 1;
@@ -358,10 +449,10 @@ async function loginCommand(flags) {
358
449
  console.error("That key is not attached to an organization. Create an organization API key.\n");
359
450
  continue;
360
451
  }
361
- writeConfig({ apiUrl, apiKey });
452
+ await saveCredentials(apiUrl, apiKey);
362
453
  console.log(`
363
454
  Saved. Organization ${who.orgId}${who.name ? ` (key "${who.name}")` : ""}.`);
364
- console.log(`Config: ${configPath()}
455
+ console.log(`Key stored in ${keyStorageDescription()}.
365
456
  `);
366
457
  console.log("Next:");
367
458
  console.log(" cutsio list");
@@ -449,6 +540,20 @@ async function promptSecret(question) {
449
540
  });
450
541
  }
451
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
+
452
557
  // src/core/sync.ts
453
558
  import { existsSync as existsSync4, statSync as statSync2 } from "node:fs";
454
559
  import { join as join2 } from "node:path";
@@ -460,35 +565,54 @@ import { dirname as dirname2 } from "node:path";
460
565
  function partPathFor(destPath) {
461
566
  return `${destPath}.part`;
462
567
  }
463
- async function downloadResumable(url, destPath) {
568
+ async function downloadResumable(url, destPath, onProgress) {
464
569
  mkdirSync2(dirname2(destPath), { recursive: true });
465
570
  const part = partPathFor(destPath);
466
571
  const resumedFrom = existsSync2(part) ? statSync(part).size : 0;
467
- const { code, stderr } = await runCurl([
468
- "-sS",
469
- "--fail",
470
- "--location",
471
- "--retry",
472
- "5",
473
- "--retry-delay",
474
- "2",
475
- "--retry-all-errors",
476
- "--connect-timeout",
477
- "30",
478
- "-C",
479
- "-",
480
- "-o",
481
- part,
482
- url
483
- ]);
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
+ }
484
606
  if (code === 0) {
607
+ const bytes = statSync(part).size;
608
+ onProgress?.(bytes, total ?? bytes);
485
609
  renameSync(part, destPath);
486
610
  return { ok: true, bytes: statSync(destPath).size, resumedFrom };
487
611
  }
488
- const remote = await remoteSize(url);
489
- if (remote !== null && existsSync2(part) && statSync(part).size === remote) {
612
+ if (total !== null && existsSync2(part) && statSync(part).size === total) {
613
+ onProgress?.(total, total);
490
614
  renameSync(part, destPath);
491
- return { ok: true, bytes: remote, resumedFrom };
615
+ return { ok: true, bytes: total, resumedFrom };
492
616
  }
493
617
  const lastLine = stderr.trim().split("\n").filter(Boolean).at(-1);
494
618
  return { ok: false, error: `curl exited ${code}${lastLine ? `: ${lastLine}` : ""}` };
@@ -664,6 +788,12 @@ async function sync(client, options) {
664
788
  }
665
789
  if (ready.length > 0) {
666
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
+ });
667
797
  await runPool(ready, options.concurrency, async ({ id, url }) => {
668
798
  const item = queue.get(id);
669
799
  if (!item) return;
@@ -674,11 +804,12 @@ async function sync(client, options) {
674
804
  path: item.relPath,
675
805
  resuming: item.resuming
676
806
  });
677
- let result = await downloadResumable(url, item.absPath);
807
+ const report = (bytes, total) => onEvent({ type: "file-progress", id, bytes, total });
808
+ let result = await downloadResumable(url, item.absPath, report);
678
809
  if (!result.ok) {
679
810
  const [refreshed] = await client.prepare([id]);
680
811
  if (refreshed?.status === "ready" && refreshed.url) {
681
- result = await downloadResumable(refreshed.url, item.absPath);
812
+ result = await downloadResumable(refreshed.url, item.absPath, report);
682
813
  }
683
814
  }
684
815
  if (result.ok) {
@@ -699,6 +830,12 @@ async function sync(client, options) {
699
830
  });
700
831
  }
701
832
  if (queue.size === 0) break;
833
+ onEvent({
834
+ type: "status",
835
+ preparing: queue.size,
836
+ downloading: 0,
837
+ queued: queue.size
838
+ });
702
839
  if (Date.now() > deadline) {
703
840
  for (const item of queue.values()) {
704
841
  failed += 1;
@@ -735,6 +872,176 @@ function sleep(ms) {
735
872
  return new Promise((resolve) => setTimeout(resolve, ms));
736
873
  }
737
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
+
738
1045
  // src/commands/selection-prompt.ts
739
1046
  import { stdin as stdin2, stdout as stdout2 } from "node:process";
740
1047
  import { createInterface } from "node:readline/promises";
@@ -806,34 +1113,47 @@ async function promptSelection(manifest) {
806
1113
 
807
1114
  // src/commands/sync.ts
808
1115
  async function syncCommand(flags) {
809
- const apiKey = resolveApiKey();
1116
+ const apiKey = await resolveApiKey();
810
1117
  if (!apiKey) {
811
1118
  console.error("No API key. Run `cutsio login` or set CUTSIO_API_KEY.");
812
1119
  return 1;
813
1120
  }
814
1121
  const client = new CutsioClient(resolveApiUrl(flags.url), apiKey);
1122
+ const view = new ProgressView();
815
1123
  const onEvent = (event) => {
816
1124
  switch (event.type) {
817
1125
  case "manifest":
818
- console.log(`Manifest: ${event.videos} videos, ${event.folders} folders (${event.orgId})`);
1126
+ view.log(
1127
+ `${color.dim("workspace")} ${event.orgId} ${color.dim("\xB7")} ${event.videos} videos ${color.dim("\xB7")} ${event.folders} folders`
1128
+ );
819
1129
  break;
820
1130
  case "planned":
821
- console.log(`Selected ${event.total}, pending ${event.pending}, unchanged ${event.unchanged}`);
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
+ );
822
1139
  break;
823
1140
  case "file-start":
824
- console.log(` ${event.resuming ? "resume " : "fetch "} ${event.path}`);
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);
825
1145
  break;
826
1146
  case "file-done":
827
- console.log(` done ${event.path} (${formatBytes(event.bytes)})`);
1147
+ view.finish(event.id, true, `${event.path} (${formatBytes(event.bytes)})`);
828
1148
  break;
829
1149
  case "file-skipped":
830
- console.log(` skip ${event.title} \u2014 ${event.reason}`);
1150
+ view.log(` ${color.yellow("skip")} ${event.title} \u2014 ${event.reason}`);
831
1151
  break;
832
1152
  case "file-failed":
833
- console.error(` FAILED ${event.title} \u2014 ${event.error}`);
1153
+ view.finish(event.id, false, `${event.title} \u2014 ${event.error}`);
834
1154
  break;
835
1155
  case "warning":
836
- console.warn(`warning: ${event.message}`);
1156
+ view.log(` ${color.yellow("warning")} ${event.message}`);
837
1157
  break;
838
1158
  }
839
1159
  };
@@ -848,6 +1168,7 @@ async function syncCommand(flags) {
848
1168
  let manifest;
849
1169
  try {
850
1170
  if (flags.interactive === "true") {
1171
+ view.stop();
851
1172
  manifest = await client.fetchManifest();
852
1173
  const prompted = await promptSelection(manifest);
853
1174
  if (!prompted) {
@@ -878,12 +1199,14 @@ async function syncCommand(flags) {
878
1199
  manifest,
879
1200
  onEvent
880
1201
  });
881
- console.log(
882
- `
883
- Downloaded ${summary.downloaded}, unchanged ${summary.unchanged}, skipped ${summary.skipped}, failed ${summary.failed}.`
884
- );
885
- return summary.failed > 0 ? 1 : 0;
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;
886
1208
  } catch (error) {
1209
+ view.stop();
887
1210
  console.error(error instanceof Error ? error.message : String(error));
888
1211
  return 1;
889
1212
  }
@@ -906,20 +1229,10 @@ function positiveInt(value, fallback) {
906
1229
  const parsed = Number(value);
907
1230
  return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
908
1231
  }
909
- function formatBytes(bytes) {
910
- const units = ["B", "KB", "MB", "GB", "TB"];
911
- let value = bytes;
912
- let unit = 0;
913
- while (value >= 1024 && unit < units.length - 1) {
914
- value /= 1024;
915
- unit += 1;
916
- }
917
- return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
918
- }
919
1232
 
920
1233
  // src/commands/whoami.ts
921
1234
  async function whoamiCommand(flags) {
922
- const apiKey = resolveApiKey();
1235
+ const apiKey = await resolveApiKey();
923
1236
  if (!apiKey) {
924
1237
  console.error("No API key. Run `cutsio login` or set CUTSIO_API_KEY.");
925
1238
  return 1;
@@ -934,6 +1247,34 @@ async function whoamiCommand(flags) {
934
1247
  }
935
1248
  }
936
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
+
937
1278
  // src/index.ts
938
1279
  function parseFlags(args) {
939
1280
  const flags = {};
@@ -964,6 +1305,7 @@ function printHelp() {
964
1305
 
965
1306
  Usage:
966
1307
  cutsio login [--url URL] [--key KEY]
1308
+ cutsio logout
967
1309
  cutsio whoami [--url URL]
968
1310
  cutsio list [--videos] [--search TERM] [--json] [--url URL]
969
1311
  cutsio sync [--all | --folder REFS | --collection REFS | --video IDS | --search TERMS | --unfiled | --interactive]
@@ -977,14 +1319,39 @@ Selection:
977
1319
 
978
1320
  Environment:
979
1321
  CUTSIO_API_URL API base URL (default ${DEFAULT_API_URL})
980
- CUTSIO_API_KEY Organization API key`);
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
+ }
981
1336
  }
982
1337
  async function main() {
983
1338
  const [, , command, ...rest] = process2.argv;
984
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
+ }
985
1350
  switch (command) {
986
1351
  case "login":
987
1352
  return loginCommand(flags);
1353
+ case "logout":
1354
+ return logoutCommand();
988
1355
  case "whoami":
989
1356
  return whoamiCommand(flags);
990
1357
  case "list":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cutsio",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Sync Cutsio mezzanine masters to a local folder",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,7 +26,8 @@
26
26
  "start": "node dist/index.js",
27
27
  "build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node18 --outfile=dist/index.js",
28
28
  "typecheck": "tsc --noEmit",
29
- "prepublishOnly": "npm run typecheck && npm run build"
29
+ "test": "node --test tests/*.test.ts",
30
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
30
31
  },
31
32
  "devDependencies": {
32
33
  "@types/node": "^22.10.2",