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