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.
- package/README.md +8 -2
- package/dist/index.js +425 -58
- 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
|
-
|
|
25
|
-
the config file
|
|
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(
|
|
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"
|
|
20
|
-
return {
|
|
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
|
-
|
|
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
|
|
36
|
-
|
|
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
|
-
|
|
452
|
+
await saveCredentials(apiUrl, apiKey);
|
|
362
453
|
console.log(`
|
|
363
454
|
Saved. Organization ${who.orgId}${who.name ? ` (key "${who.name}")` : ""}.`);
|
|
364
|
-
console.log(`
|
|
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
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
489
|
-
|
|
612
|
+
if (total !== null && existsSync2(part) && statSync(part).size === total) {
|
|
613
|
+
onProgress?.(total, total);
|
|
490
614
|
renameSync(part, destPath);
|
|
491
|
-
return { ok: true, bytes:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1147
|
+
view.finish(event.id, true, `${event.path} (${formatBytes(event.bytes)})`);
|
|
828
1148
|
break;
|
|
829
1149
|
case "file-skipped":
|
|
830
|
-
|
|
1150
|
+
view.log(` ${color.yellow("skip")} ${event.title} \u2014 ${event.reason}`);
|
|
831
1151
|
break;
|
|
832
1152
|
case "file-failed":
|
|
833
|
-
|
|
1153
|
+
view.finish(event.id, false, `${event.title} \u2014 ${event.error}`);
|
|
834
1154
|
break;
|
|
835
1155
|
case "warning":
|
|
836
|
-
|
|
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
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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.
|
|
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
|
-
"
|
|
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",
|