cutsio 0.1.0 → 0.2.1

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 +9 -2
  2. package/dist/index.js +440 -56
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -19,10 +19,13 @@ 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
23
+ cutsio welcome # replay the first-run banner
22
24
  ```
23
25
 
24
- Config is stored at `~/.cutsio/config.json` with mode `600`. You can also skip
25
- the config file entirely:
26
+ On macOS the key goes into the **Keychain**; elsewhere it is written to
27
+ `~/.cutsio/config.json` with mode `600`. You can also skip the config file
28
+ entirely:
26
29
 
27
30
  ```bash
28
31
  export CUTSIO_API_URL=https://studio.cutsio.com
@@ -35,9 +38,13 @@ export CUTSIO_API_KEY=ak_...
35
38
  npm install
36
39
  npm run dev -- whoami # runs src/index.ts directly (Node 23.6+)
37
40
  npm run typecheck
41
+ npm test # selection, paths, and state
38
42
  npm run build # bundles src -> dist/index.js
39
43
  ```
40
44
 
45
+ Set `NO_COLOR=1` to disable color and `CUTSIO_NO_ANIM=1` to disable the
46
+ first-run banner and live progress. Both are automatic when output is piped.
47
+
41
48
  ## List and select
42
49
 
43
50
  `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,48 @@ 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 { apiKey: fromEnv, migrated: false };
97
+ const stored = await getStoredKey();
98
+ if (stored) return { apiKey: stored, migrated: false };
99
+ const config = readConfig();
100
+ const legacy = config?.apiKey;
101
+ if (!legacy) return { apiKey: null, migrated: false };
102
+ if (keychainAvailable()) {
103
+ try {
104
+ await setStoredKey(legacy);
105
+ writeConfig({ apiUrl: config?.apiUrl ?? DEFAULT_API_URL });
106
+ return { apiKey: legacy, migrated: true };
107
+ } catch {
108
+ }
109
+ }
110
+ return { apiKey: legacy, migrated: false };
111
+ }
112
+ async function saveCredentials(apiUrl, apiKey) {
113
+ if (keychainAvailable()) {
114
+ await setStoredKey(apiKey);
115
+ writeConfig({ apiUrl });
116
+ return;
117
+ }
118
+ writeConfig({ apiUrl, apiKey });
119
+ }
120
+ async function clearCredentials() {
121
+ const stored = await getStoredKey();
122
+ const config = readConfig();
123
+ const hadFileKey = Boolean(config?.apiKey);
124
+ await clearStoredKey();
125
+ if (config) {
126
+ writeConfig({ apiUrl: config.apiUrl });
127
+ }
128
+ return Boolean(stored) || hadFileKey;
37
129
  }
38
130
 
39
131
  // src/core/client.ts
@@ -260,7 +352,8 @@ function resolveSelection(manifest, selection) {
260
352
 
261
353
  // src/commands/list.ts
262
354
  async function listCommand(flags) {
263
- const apiKey = resolveApiKey();
355
+ const { apiKey, migrated } = await resolveApiKey();
356
+ if (migrated) console.error("Moved your API key into the macOS Keychain.");
264
357
  if (!apiKey) {
265
358
  console.error("No API key. Run `cutsio login` or set CUTSIO_API_KEY.");
266
359
  return 1;
@@ -358,10 +451,10 @@ async function loginCommand(flags) {
358
451
  console.error("That key is not attached to an organization. Create an organization API key.\n");
359
452
  continue;
360
453
  }
361
- writeConfig({ apiUrl, apiKey });
454
+ await saveCredentials(apiUrl, apiKey);
362
455
  console.log(`
363
456
  Saved. Organization ${who.orgId}${who.name ? ` (key "${who.name}")` : ""}.`);
364
- console.log(`Config: ${configPath()}
457
+ console.log(`Key stored in ${keyStorageDescription()}.
365
458
  `);
366
459
  console.log("Next:");
367
460
  console.log(" cutsio list");
@@ -449,6 +542,20 @@ async function promptSecret(question) {
449
542
  });
450
543
  }
451
544
 
545
+ // src/commands/logout.ts
546
+ async function logoutCommand() {
547
+ const hadCredentials = await clearCredentials();
548
+ if (!hadCredentials) {
549
+ console.log("No saved credentials.");
550
+ } else {
551
+ console.log(`Signed out. The API key was removed from ${keyStorageDescription()}.`);
552
+ }
553
+ if (process.env.CUTSIO_API_KEY) {
554
+ console.log("CUTSIO_API_KEY is still set in this shell \u2014 unset it to finish signing out.");
555
+ }
556
+ return 0;
557
+ }
558
+
452
559
  // src/core/sync.ts
453
560
  import { existsSync as existsSync4, statSync as statSync2 } from "node:fs";
454
561
  import { join as join2 } from "node:path";
@@ -460,35 +567,54 @@ import { dirname as dirname2 } from "node:path";
460
567
  function partPathFor(destPath) {
461
568
  return `${destPath}.part`;
462
569
  }
463
- async function downloadResumable(url, destPath) {
570
+ async function downloadResumable(url, destPath, onProgress) {
464
571
  mkdirSync2(dirname2(destPath), { recursive: true });
465
572
  const part = partPathFor(destPath);
466
573
  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
- ]);
574
+ const total = await remoteSize(url);
575
+ let timer = null;
576
+ if (onProgress) {
577
+ onProgress(resumedFrom, total);
578
+ timer = setInterval(() => {
579
+ try {
580
+ onProgress(statSync(part).size, total);
581
+ } catch {
582
+ }
583
+ }, 200);
584
+ }
585
+ let code;
586
+ let stderr;
587
+ try {
588
+ ({ code, stderr } = await runCurl([
589
+ "-sS",
590
+ "--fail",
591
+ "--location",
592
+ "--retry",
593
+ "5",
594
+ "--retry-delay",
595
+ "2",
596
+ "--retry-all-errors",
597
+ "--connect-timeout",
598
+ "30",
599
+ "-C",
600
+ "-",
601
+ "-o",
602
+ part,
603
+ url
604
+ ]));
605
+ } finally {
606
+ if (timer) clearInterval(timer);
607
+ }
484
608
  if (code === 0) {
609
+ const bytes = statSync(part).size;
610
+ onProgress?.(bytes, total ?? bytes);
485
611
  renameSync(part, destPath);
486
612
  return { ok: true, bytes: statSync(destPath).size, resumedFrom };
487
613
  }
488
- const remote = await remoteSize(url);
489
- if (remote !== null && existsSync2(part) && statSync(part).size === remote) {
614
+ if (total !== null && existsSync2(part) && statSync(part).size === total) {
615
+ onProgress?.(total, total);
490
616
  renameSync(part, destPath);
491
- return { ok: true, bytes: remote, resumedFrom };
617
+ return { ok: true, bytes: total, resumedFrom };
492
618
  }
493
619
  const lastLine = stderr.trim().split("\n").filter(Boolean).at(-1);
494
620
  return { ok: false, error: `curl exited ${code}${lastLine ? `: ${lastLine}` : ""}` };
@@ -664,6 +790,12 @@ async function sync(client, options) {
664
790
  }
665
791
  if (ready.length > 0) {
666
792
  prepareDelayMs = INITIAL_PREPARE_DELAY_MS;
793
+ onEvent({
794
+ type: "status",
795
+ preparing: Math.max(0, queue.size - ready.length),
796
+ downloading: ready.length,
797
+ queued: queue.size
798
+ });
667
799
  await runPool(ready, options.concurrency, async ({ id, url }) => {
668
800
  const item = queue.get(id);
669
801
  if (!item) return;
@@ -674,11 +806,12 @@ async function sync(client, options) {
674
806
  path: item.relPath,
675
807
  resuming: item.resuming
676
808
  });
677
- let result = await downloadResumable(url, item.absPath);
809
+ const report = (bytes, total) => onEvent({ type: "file-progress", id, bytes, total });
810
+ let result = await downloadResumable(url, item.absPath, report);
678
811
  if (!result.ok) {
679
812
  const [refreshed] = await client.prepare([id]);
680
813
  if (refreshed?.status === "ready" && refreshed.url) {
681
- result = await downloadResumable(refreshed.url, item.absPath);
814
+ result = await downloadResumable(refreshed.url, item.absPath, report);
682
815
  }
683
816
  }
684
817
  if (result.ok) {
@@ -699,6 +832,12 @@ async function sync(client, options) {
699
832
  });
700
833
  }
701
834
  if (queue.size === 0) break;
835
+ onEvent({
836
+ type: "status",
837
+ preparing: queue.size,
838
+ downloading: 0,
839
+ queued: queue.size
840
+ });
702
841
  if (Date.now() > deadline) {
703
842
  for (const item of queue.values()) {
704
843
  failed += 1;
@@ -735,6 +874,176 @@ function sleep(ms) {
735
874
  return new Promise((resolve) => setTimeout(resolve, ms));
736
875
  }
737
876
 
877
+ // src/ui/format.ts
878
+ function formatBytes(bytes) {
879
+ const units = ["B", "KB", "MB", "GB", "TB"];
880
+ let value = bytes;
881
+ let unit = 0;
882
+ while (value >= 1024 && unit < units.length - 1) {
883
+ value /= 1024;
884
+ unit += 1;
885
+ }
886
+ return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
887
+ }
888
+
889
+ // src/ui/theme.ts
890
+ var colorEnabled = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
891
+ function paint(code) {
892
+ return colorEnabled ? (text) => `\x1B[${code}m${text}\x1B[0m` : (text) => text;
893
+ }
894
+ var color = {
895
+ dim: paint(2),
896
+ bold: paint(1),
897
+ red: paint(31),
898
+ green: paint(32),
899
+ yellow: paint(33),
900
+ blue: paint(34),
901
+ magenta: paint(35),
902
+ cyan: paint(36)
903
+ };
904
+ var isInteractive = colorEnabled;
905
+ var animationsEnabled = colorEnabled && process.env.CUTSIO_NO_ANIM !== "1";
906
+ function sleep2(ms) {
907
+ return new Promise((resolve) => setTimeout(resolve, ms));
908
+ }
909
+
910
+ // src/ui/progress.ts
911
+ var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
912
+ var BAR_WIDTH = 22;
913
+ var ProgressView = class {
914
+ active = /* @__PURE__ */ new Map();
915
+ interactive = isInteractive;
916
+ statusLine = null;
917
+ renderedLines = 0;
918
+ frame = 0;
919
+ timer = null;
920
+ dirty = false;
921
+ constructor() {
922
+ if (!this.interactive) return;
923
+ this.timer = setInterval(() => {
924
+ if (this.active.size === 0) return;
925
+ this.frame += 1;
926
+ this.render();
927
+ }, 120);
928
+ this.timer.unref();
929
+ }
930
+ status(text) {
931
+ if (!this.interactive) return;
932
+ this.statusLine = text;
933
+ this.dirty = true;
934
+ }
935
+ /** Print a line above the live area without tearing it. */
936
+ log(message) {
937
+ if (!this.interactive) {
938
+ console.log(message);
939
+ return;
940
+ }
941
+ this.clear();
942
+ process.stdout.write(`${message}
943
+ `);
944
+ this.render();
945
+ }
946
+ error(message) {
947
+ if (!this.interactive) {
948
+ console.error(message);
949
+ return;
950
+ }
951
+ this.clear();
952
+ process.stderr.write(`${message}
953
+ `);
954
+ this.render();
955
+ }
956
+ start(id, label) {
957
+ if (!this.interactive) {
958
+ console.log(` fetch ${label}`);
959
+ return;
960
+ }
961
+ const now = Date.now();
962
+ this.active.set(id, {
963
+ label,
964
+ bytes: 0,
965
+ total: null,
966
+ lastBytes: 0,
967
+ lastAt: now,
968
+ speed: 0
969
+ });
970
+ this.dirty = true;
971
+ this.render();
972
+ }
973
+ progress(id, bytes, total) {
974
+ if (!this.interactive) return;
975
+ const item = this.active.get(id);
976
+ if (!item) return;
977
+ const now = Date.now();
978
+ const elapsed = (now - item.lastAt) / 1e3;
979
+ if (elapsed >= 0.3) {
980
+ const delta = bytes - item.lastBytes;
981
+ if (delta >= 0) item.speed = delta / elapsed;
982
+ item.lastBytes = bytes;
983
+ item.lastAt = now;
984
+ }
985
+ item.bytes = bytes;
986
+ if (total !== null) item.total = total;
987
+ this.dirty = true;
988
+ }
989
+ finish(id, ok, text) {
990
+ if (!this.interactive) {
991
+ if (ok) console.log(` done ${text}`);
992
+ else console.error(` FAILED ${text}`);
993
+ return;
994
+ }
995
+ this.clear();
996
+ const line = ok ? ` ${color.green("\u2713")} ${text}` : ` ${color.red("\u2717")} ${text}`;
997
+ (ok ? process.stdout : process.stderr).write(`${line}
998
+ `);
999
+ this.active.delete(id);
1000
+ this.render();
1001
+ }
1002
+ stop() {
1003
+ if (this.timer) clearInterval(this.timer);
1004
+ this.timer = null;
1005
+ if (!this.interactive) return;
1006
+ this.clear();
1007
+ this.active.clear();
1008
+ this.statusLine = null;
1009
+ }
1010
+ clear() {
1011
+ if (this.renderedLines === 0) return;
1012
+ process.stdout.write(`\x1B[${this.renderedLines}A\x1B[J`);
1013
+ this.renderedLines = 0;
1014
+ }
1015
+ render() {
1016
+ if (!this.interactive) return;
1017
+ if (this.active.size === 0 && !this.statusLine) {
1018
+ this.clear();
1019
+ return;
1020
+ }
1021
+ const lines = [];
1022
+ if (this.statusLine) {
1023
+ const spinner = FRAMES[this.frame % FRAMES.length] ?? FRAMES[0];
1024
+ lines.push(` ${color.cyan(spinner ?? "")} ${color.dim(this.statusLine)}`);
1025
+ }
1026
+ for (const item of this.active.values()) {
1027
+ lines.push(` ${color.bold(item.label)}`);
1028
+ lines.push(` ${this.bar(item)}`);
1029
+ }
1030
+ this.clear();
1031
+ process.stdout.write(`${lines.join("\n")}
1032
+ `);
1033
+ this.renderedLines = lines.length;
1034
+ this.dirty = false;
1035
+ }
1036
+ bar(item) {
1037
+ const ratio = item.total && item.total > 0 ? Math.min(1, item.bytes / item.total) : null;
1038
+ const filled = ratio === null ? 0 : Math.round(ratio * BAR_WIDTH);
1039
+ const track = color.cyan("\u2588".repeat(filled)) + color.dim("\u2591".repeat(BAR_WIDTH - filled));
1040
+ const percent = ratio === null ? " ? " : `${Math.floor(ratio * 100)}%`.padStart(4);
1041
+ const size = item.total ? `${formatBytes(item.bytes)} / ${formatBytes(item.total)}` : formatBytes(item.bytes);
1042
+ const speed = item.speed > 0 ? `${formatBytes(item.speed)}/s` : "";
1043
+ return `${track} ${percent} ${size} ${color.dim(speed)}`.trimEnd();
1044
+ }
1045
+ };
1046
+
738
1047
  // src/commands/selection-prompt.ts
739
1048
  import { stdin as stdin2, stdout as stdout2 } from "node:process";
740
1049
  import { createInterface } from "node:readline/promises";
@@ -806,34 +1115,48 @@ async function promptSelection(manifest) {
806
1115
 
807
1116
  // src/commands/sync.ts
808
1117
  async function syncCommand(flags) {
809
- const apiKey = resolveApiKey();
1118
+ const { apiKey, migrated } = await resolveApiKey();
1119
+ if (migrated) console.error("Moved your API key into the macOS Keychain.");
810
1120
  if (!apiKey) {
811
1121
  console.error("No API key. Run `cutsio login` or set CUTSIO_API_KEY.");
812
1122
  return 1;
813
1123
  }
814
1124
  const client = new CutsioClient(resolveApiUrl(flags.url), apiKey);
1125
+ const view = new ProgressView();
815
1126
  const onEvent = (event) => {
816
1127
  switch (event.type) {
817
1128
  case "manifest":
818
- console.log(`Manifest: ${event.videos} videos, ${event.folders} folders (${event.orgId})`);
1129
+ view.log(
1130
+ `${color.dim("workspace")} ${event.orgId} ${color.dim("\xB7")} ${event.videos} videos ${color.dim("\xB7")} ${event.folders} folders`
1131
+ );
819
1132
  break;
820
1133
  case "planned":
821
- console.log(`Selected ${event.total}, pending ${event.pending}, unchanged ${event.unchanged}`);
1134
+ view.log(
1135
+ `${color.dim("selected")} ${event.total} ${color.dim("\xB7")} pending ${event.pending} ${color.dim("\xB7")} unchanged ${event.unchanged}`
1136
+ );
1137
+ break;
1138
+ case "status":
1139
+ view.status(
1140
+ `${event.downloading} downloading \xB7 ${event.preparing} preparing \xB7 ${event.queued} remaining`
1141
+ );
822
1142
  break;
823
1143
  case "file-start":
824
- console.log(` ${event.resuming ? "resume " : "fetch "} ${event.path}`);
1144
+ view.start(event.id, `${event.resuming ? "resuming " : ""}${event.path}`);
1145
+ break;
1146
+ case "file-progress":
1147
+ view.progress(event.id, event.bytes, event.total);
825
1148
  break;
826
1149
  case "file-done":
827
- console.log(` done ${event.path} (${formatBytes(event.bytes)})`);
1150
+ view.finish(event.id, true, `${event.path} (${formatBytes(event.bytes)})`);
828
1151
  break;
829
1152
  case "file-skipped":
830
- console.log(` skip ${event.title} \u2014 ${event.reason}`);
1153
+ view.log(` ${color.yellow("skip")} ${event.title} \u2014 ${event.reason}`);
831
1154
  break;
832
1155
  case "file-failed":
833
- console.error(` FAILED ${event.title} \u2014 ${event.error}`);
1156
+ view.finish(event.id, false, `${event.title} \u2014 ${event.error}`);
834
1157
  break;
835
1158
  case "warning":
836
- console.warn(`warning: ${event.message}`);
1159
+ view.log(` ${color.yellow("warning")} ${event.message}`);
837
1160
  break;
838
1161
  }
839
1162
  };
@@ -848,6 +1171,7 @@ async function syncCommand(flags) {
848
1171
  let manifest;
849
1172
  try {
850
1173
  if (flags.interactive === "true") {
1174
+ view.stop();
851
1175
  manifest = await client.fetchManifest();
852
1176
  const prompted = await promptSelection(manifest);
853
1177
  if (!prompted) {
@@ -878,12 +1202,14 @@ async function syncCommand(flags) {
878
1202
  manifest,
879
1203
  onEvent
880
1204
  });
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;
1205
+ view.stop();
1206
+ const failing = summary.failed > 0;
1207
+ const summaryLine = `${summary.downloaded} downloaded \xB7 ${summary.unchanged} unchanged \xB7 ${summary.skipped} skipped \xB7 ${summary.failed} failed`;
1208
+ console.log(`
1209
+ ${failing ? color.red(summaryLine) : color.green(summaryLine)}`);
1210
+ return failing ? 1 : 0;
886
1211
  } catch (error) {
1212
+ view.stop();
887
1213
  console.error(error instanceof Error ? error.message : String(error));
888
1214
  return 1;
889
1215
  }
@@ -906,20 +1232,49 @@ function positiveInt(value, fallback) {
906
1232
  const parsed = Number(value);
907
1233
  return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
908
1234
  }
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;
1235
+
1236
+ // src/ui/banner.ts
1237
+ var WORDMARK = [
1238
+ " \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 ",
1239
+ "\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",
1240
+ "\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",
1241
+ "\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",
1242
+ "\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",
1243
+ " \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 "
1244
+ ];
1245
+ var SHADES = [96, 96, 36, 36, 34, 34];
1246
+ function shade(line, index) {
1247
+ if (!animationsEnabled) return line;
1248
+ const code = SHADES[index] ?? 36;
1249
+ return `\x1B[${code}m${line}\x1B[0m`;
1250
+ }
1251
+ async function revealBanner() {
1252
+ process.stdout.write("\n");
1253
+ for (let index = 0; index < WORDMARK.length; index += 1) {
1254
+ process.stdout.write(`${shade(WORDMARK[index] ?? "", index)}
1255
+ `);
1256
+ if (animationsEnabled) await sleep2(38);
916
1257
  }
917
- return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
1258
+ process.stdout.write(`${color.dim(" mezzanine sync for your Mac")}
1259
+
1260
+ `);
1261
+ if (animationsEnabled) await sleep2(60);
1262
+ }
1263
+
1264
+ // src/commands/welcome.ts
1265
+ async function welcomeCommand() {
1266
+ await revealBanner();
1267
+ console.log(` ${color.dim("login")} cutsio login`);
1268
+ console.log(` ${color.dim("browse")} cutsio list`);
1269
+ console.log(` ${color.dim("sync")} cutsio sync --out ~/Cutsio --interactive`);
1270
+ console.log();
1271
+ return 0;
918
1272
  }
919
1273
 
920
1274
  // src/commands/whoami.ts
921
1275
  async function whoamiCommand(flags) {
922
- const apiKey = resolveApiKey();
1276
+ const { apiKey, migrated } = await resolveApiKey();
1277
+ if (migrated) console.error("Moved your API key into the macOS Keychain.");
923
1278
  if (!apiKey) {
924
1279
  console.error("No API key. Run `cutsio login` or set CUTSIO_API_KEY.");
925
1280
  return 1;
@@ -964,6 +1319,8 @@ function printHelp() {
964
1319
 
965
1320
  Usage:
966
1321
  cutsio login [--url URL] [--key KEY]
1322
+ cutsio logout
1323
+ cutsio welcome
967
1324
  cutsio whoami [--url URL]
968
1325
  cutsio list [--videos] [--search TERM] [--json] [--url URL]
969
1326
  cutsio sync [--all | --folder REFS | --collection REFS | --video IDS | --search TERMS | --unfiled | --interactive]
@@ -977,14 +1334,41 @@ Selection:
977
1334
 
978
1335
  Environment:
979
1336
  CUTSIO_API_URL API base URL (default ${DEFAULT_API_URL})
980
- CUTSIO_API_KEY Organization API key`);
1337
+ CUTSIO_API_KEY Organization API key
1338
+ NO_COLOR Disable color
1339
+ CUTSIO_NO_ANIM=1 Disable the banner and live progress`);
1340
+ }
1341
+ async function maybeWelcome() {
1342
+ if (!animationsEnabled) return;
1343
+ const marker = join3(configDir(), ".welcomed");
1344
+ if (existsSync5(marker)) return;
1345
+ await revealBanner();
1346
+ try {
1347
+ mkdirSync4(configDir(), { recursive: true, mode: 448 });
1348
+ writeFileSync3(marker, "");
1349
+ } catch {
1350
+ }
981
1351
  }
982
1352
  async function main() {
983
1353
  const [, , command, ...rest] = process2.argv;
984
1354
  const flags = parseFlags(rest);
1355
+ switch (command) {
1356
+ case "login":
1357
+ case "whoami":
1358
+ case "list":
1359
+ case "sync":
1360
+ await maybeWelcome();
1361
+ break;
1362
+ default:
1363
+ break;
1364
+ }
985
1365
  switch (command) {
986
1366
  case "login":
987
1367
  return loginCommand(flags);
1368
+ case "logout":
1369
+ return logoutCommand();
1370
+ case "welcome":
1371
+ return welcomeCommand();
988
1372
  case "whoami":
989
1373
  return whoamiCommand(flags);
990
1374
  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.1",
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",