cutsio 0.0.0-stage → 0.1.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 +126 -2
  2. package/dist/index.js +1008 -0
  3. package/package.json +34 -4
package/README.md CHANGED
@@ -1,3 +1,127 @@
1
- # Temporary Holding Version
1
+ # cutsio
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Syncs Cutsio mezzanine masters to a local folder. Resumable, idempotent, and
4
+ safe to run repeatedly: it downloads only videos that are new or changed since
5
+ the last run.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g cutsio # then: cutsio sync …
11
+ # or run without installing
12
+ npx cutsio sync --out ~/Cutsio --collection vacation
13
+ ```
14
+
15
+ Requires Node 18+ and `curl` on `PATH` (ships with macOS).
16
+
17
+ Create an **organization** API key in Cutsio: Settings → Organization → API Keys.
18
+
19
+ ```bash
20
+ cutsio login --url https://studio.cutsio.com --key ak_...
21
+ cutsio whoami
22
+ ```
23
+
24
+ Config is stored at `~/.cutsio/config.json` with mode `600`. You can also skip
25
+ the config file entirely:
26
+
27
+ ```bash
28
+ export CUTSIO_API_URL=https://studio.cutsio.com
29
+ export CUTSIO_API_KEY=ak_...
30
+ ```
31
+
32
+ ## Development
33
+
34
+ ```bash
35
+ npm install
36
+ npm run dev -- whoami # runs src/index.ts directly (Node 23.6+)
37
+ npm run typecheck
38
+ npm run build # bundles src -> dist/index.js
39
+ ```
40
+
41
+ ## List and select
42
+
43
+ `sync` never downloads the whole workspace unless you ask for it. Start by
44
+ listing what's there:
45
+
46
+ ```bash
47
+ cutsio list # collections, folder paths, ids, and video counts
48
+ cutsio list --videos # every video with its id
49
+ cutsio list --search shot # title matches anywhere in the workspace
50
+ cutsio list --json # raw manifest
51
+ ```
52
+
53
+ ## Sync
54
+
55
+ ```bash
56
+ cutsio sync --out ~/Cutsio --collection vacation
57
+ cutsio sync --out ~/Cutsio --folder test/sub1a
58
+ cutsio sync --out ~/Cutsio --all --dry-run
59
+ cutsio sync --out ~/Cutsio --interactive
60
+ ```
61
+
62
+ Selection flags are comma-separated and can be combined (the result is a union):
63
+
64
+ | Flag | Meaning |
65
+ |---|---|
66
+ | `--all` | Every video in the workspace. Required for a full sync. |
67
+ | `--folder <path\|id,...>` | A folder **and all of its subfolders**. |
68
+ | `--collection <title\|id,...>` | Videos in a collection. |
69
+ | `--video <id,...>` | Specific videos. |
70
+ | `--search <term,...>` | Videos whose title contains a term, anywhere in the workspace. |
71
+ | `--unfiled` | Videos that are in no folder. |
72
+ | `--interactive` | Choose from a numbered menu. |
73
+
74
+ Unknown or ambiguous selectors fail before anything is downloaded. Search also
75
+ covers videos that are in no folder or collection.
76
+
77
+ | Other flag | Default | Meaning |
78
+ |---|---|---|
79
+ | `--out <dir>` | `./cutsio` | Destination root. The folder tree is mirrored underneath. |
80
+ | `--concurrency <n>` | `3` | Simultaneous downloads. |
81
+ | `--max-wait <seconds>` | `600` | How long to wait for masters still preparing. |
82
+ | `--limit <n>` | all | Cap how many videos this run downloads. |
83
+ | `--dry-run` | off | Print what would download, change nothing. |
84
+
85
+ ## How it behaves
86
+
87
+ - **Manifest diffing:** each video's `realtime_version` is stored in
88
+ `<out>/.cutsio-state.json`. A video is re-downloaded only when that version
89
+ changes or the local file is missing/short.
90
+ - **Resume:** downloads go to `<file>.part` and `curl -C -` continues from the
91
+ existing byte offset. A crashed run picks up where it stopped, even if the
92
+ signed URL was refreshed in between.
93
+ - **Expiring URLs:** Mux master URLs live 24h. On a failed transfer the CLI
94
+ re-prepares that single video once and retries.
95
+ - **Nothing is deleted.** Videos removed upstream stay on disk; the CLI never
96
+ deletes local files.
97
+ - **Filenames:** `<title> [<id8>].mp4` — the id suffix prevents collisions when
98
+ two videos share a title.
99
+ - **Not ready / restricted:** videos that are still processing, or whose
100
+ moderation failed, are skipped and reported.
101
+
102
+ ## Build
103
+
104
+ ```bash
105
+ npm run build # esbuild bundle -> dist/index.js (published artifact)
106
+ ```
107
+
108
+ `prepublishOnly` runs typecheck and build, so a stale `dist/` cannot be
109
+ published.
110
+
111
+ ## Layout
112
+
113
+ ```
114
+ src/index.ts CLI entry and flag parsing
115
+ src/commands/ login, whoami, list, sync, interactive picker
116
+ src/core/client.ts API client (manifest, prepare, whoami)
117
+ src/core/selection.ts folder / collection / search selection
118
+ src/core/sync.ts diffing and orchestration
119
+ src/core/download.ts resumable curl-backed transfer
120
+ src/core/paths.ts folder tree -> local paths
121
+ src/core/state.ts versioned state file
122
+ src/core/events.ts structured progress events (the Tauri seam)
123
+ ```
124
+
125
+ `src/core/` has no CLI-specific code. A Tauri app can bundle the compiled
126
+ binary as a sidecar and render the same events, or the core can be ported
127
+ without touching the orchestration logic.
package/dist/index.js ADDED
@@ -0,0 +1,1008 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import process2 from "node:process";
5
+
6
+ // src/config.ts
7
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { dirname, join } from "node:path";
10
+ var DEFAULT_API_URL = "https://studio.cutsio.com";
11
+ function configPath() {
12
+ return join(homedir(), ".cutsio", "config.json");
13
+ }
14
+ function readConfig() {
15
+ const path = configPath();
16
+ if (!existsSync(path)) return null;
17
+ try {
18
+ 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 };
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+ function writeConfig(config) {
26
+ const path = configPath();
27
+ mkdirSync(dirname(path), { recursive: true, mode: 448 });
28
+ writeFileSync(path, JSON.stringify(config, null, 2), { mode: 384 });
29
+ chmodSync(path, 384);
30
+ }
31
+ function resolveApiUrl(flag) {
32
+ return flag || process.env.CUTSIO_API_URL || readConfig()?.apiUrl || DEFAULT_API_URL;
33
+ }
34
+ function resolveApiKey() {
35
+ const key = process.env.CUTSIO_API_KEY || readConfig()?.apiKey;
36
+ return key && key.length > 0 ? key : null;
37
+ }
38
+
39
+ // src/core/client.ts
40
+ var ApiError = class extends Error {
41
+ status;
42
+ constructor(message, status) {
43
+ super(message);
44
+ this.name = "ApiError";
45
+ this.status = status;
46
+ }
47
+ };
48
+ var CutsioClient = class {
49
+ base;
50
+ apiKey;
51
+ constructor(apiUrl, apiKey) {
52
+ this.base = apiUrl.replace(/\/+$/, "");
53
+ this.apiKey = apiKey;
54
+ }
55
+ async whoami() {
56
+ return await this.request("/api/desktop/whoami");
57
+ }
58
+ async fetchManifest() {
59
+ let orgId = "";
60
+ let folders = [];
61
+ let collections = [];
62
+ const videos = [];
63
+ let cursor = null;
64
+ for (; ; ) {
65
+ const params = new URLSearchParams({ limit: "100" });
66
+ if (cursor !== null) params.set("cursor", String(cursor));
67
+ const page = await this.request(
68
+ `/api/desktop/manifest?${params.toString()}`
69
+ );
70
+ orgId = page.orgId;
71
+ folders = page.folders;
72
+ collections = page.collections ?? [];
73
+ videos.push(...page.videos);
74
+ if (!page.hasMore || page.nextCursor === null) break;
75
+ cursor = page.nextCursor;
76
+ }
77
+ return { orgId, folders, collections, videos };
78
+ }
79
+ async prepare(videoIds) {
80
+ const data = await this.request("/api/desktop/prepare", {
81
+ method: "POST",
82
+ body: JSON.stringify({ videoIds })
83
+ });
84
+ return Array.isArray(data.results) ? data.results : [];
85
+ }
86
+ async request(path, init) {
87
+ const headers = {
88
+ Authorization: `Bearer ${this.apiKey}`,
89
+ ...init?.body ? { "Content-Type": "application/json" } : {},
90
+ ...init?.headers ?? {}
91
+ };
92
+ const response = await fetch(`${this.base}${path}`, { ...init, headers });
93
+ if (!response.ok) {
94
+ let message = `HTTP ${response.status}`;
95
+ try {
96
+ const body = await response.json();
97
+ if (typeof body.error === "string") message = body.error;
98
+ } catch {
99
+ }
100
+ throw new ApiError(message, response.status);
101
+ }
102
+ return response.json();
103
+ }
104
+ };
105
+
106
+ // src/core/paths.ts
107
+ function sanitizeSegment(value) {
108
+ const cleaned = value.replace(/[/\\?%*:|"<>]/g, "-").replace(/\s+/g, " ").replace(/^[.\s]+|[.\s]+$/g, "").slice(0, 80).trim();
109
+ return cleaned.length > 0 ? cleaned : "untitled";
110
+ }
111
+ function buildFolderPaths(folders) {
112
+ const byId = new Map(folders.map((folder) => [folder.id, folder]));
113
+ const memo = /* @__PURE__ */ new Map();
114
+ const resolve = (id, seen) => {
115
+ const cached = memo.get(id);
116
+ if (cached !== void 0) return cached;
117
+ const folder = byId.get(id);
118
+ if (!folder || seen.has(id)) return "";
119
+ seen.add(id);
120
+ const parent = folder.parent_id ? resolve(folder.parent_id, seen) : "";
121
+ const path = parent ? `${parent}/${sanitizeSegment(folder.name)}` : sanitizeSegment(folder.name);
122
+ memo.set(id, path);
123
+ return path;
124
+ };
125
+ for (const folder of folders) resolve(folder.id, /* @__PURE__ */ new Set());
126
+ return memo;
127
+ }
128
+ function videoRelativePath(video, folderPaths) {
129
+ const directory = video.folder_id ? folderPaths.get(video.folder_id) ?? "" : "";
130
+ const name = `${sanitizeSegment(video.title)} [${video.id.slice(0, 8)}].mp4`;
131
+ return directory ? `${directory}/${name}` : name;
132
+ }
133
+
134
+ // src/core/selection.ts
135
+ function isEmptySelection(selection) {
136
+ return !selection.all && !selection.unfiled && selection.folderRefs.length === 0 && selection.collectionRefs.length === 0 && selection.videoIds.length === 0 && selection.searchTerms.length === 0;
137
+ }
138
+ function folderSubtreeIds(manifest) {
139
+ const children = /* @__PURE__ */ new Map();
140
+ for (const folder of manifest.folders) {
141
+ if (!folder.parent_id) continue;
142
+ const list = children.get(folder.parent_id) ?? [];
143
+ list.push(folder.id);
144
+ children.set(folder.parent_id, list);
145
+ }
146
+ const subtrees = /* @__PURE__ */ new Map();
147
+ for (const folder of manifest.folders) {
148
+ const ids = /* @__PURE__ */ new Set([folder.id]);
149
+ const stack = [folder.id];
150
+ for (; ; ) {
151
+ const id = stack.pop();
152
+ if (id === void 0) break;
153
+ for (const child of children.get(id) ?? []) {
154
+ if (ids.has(child)) continue;
155
+ ids.add(child);
156
+ stack.push(child);
157
+ }
158
+ }
159
+ subtrees.set(folder.id, ids);
160
+ }
161
+ return subtrees;
162
+ }
163
+ function folderVideoCounts(manifest) {
164
+ const subtrees = folderSubtreeIds(manifest);
165
+ const direct = /* @__PURE__ */ new Map();
166
+ const subtree = /* @__PURE__ */ new Map();
167
+ for (const video of manifest.videos) {
168
+ if (!video.folder_id) continue;
169
+ direct.set(video.folder_id, (direct.get(video.folder_id) ?? 0) + 1);
170
+ }
171
+ for (const [folderId, ids] of subtrees) {
172
+ let count = 0;
173
+ for (const video of manifest.videos) {
174
+ if (video.folder_id && ids.has(video.folder_id)) count += 1;
175
+ }
176
+ subtree.set(folderId, count);
177
+ }
178
+ return { direct, subtree };
179
+ }
180
+ function resolveSelection(manifest, selection) {
181
+ if (selection.all) return { videos: manifest.videos, errors: [] };
182
+ const errors = [];
183
+ const selected = /* @__PURE__ */ new Map();
184
+ const folderPaths = buildFolderPaths(manifest.folders);
185
+ const pathIndex = /* @__PURE__ */ new Map();
186
+ for (const [id, path] of folderPaths) pathIndex.set(path.toLowerCase(), id);
187
+ const nameIndex = /* @__PURE__ */ new Map();
188
+ for (const folder of manifest.folders) {
189
+ const key = folder.name.toLowerCase();
190
+ nameIndex.set(key, [...nameIndex.get(key) ?? [], folder.id]);
191
+ }
192
+ const resolveFolder = (ref) => {
193
+ if (folderPaths.has(ref)) return ref;
194
+ const byPath = pathIndex.get(ref.toLowerCase());
195
+ if (byPath) return byPath;
196
+ const byName = nameIndex.get(ref.toLowerCase());
197
+ if (byName && byName.length === 1) return byName[0] ?? null;
198
+ return null;
199
+ };
200
+ const subtrees = folderSubtreeIds(manifest);
201
+ const folderIds = /* @__PURE__ */ new Set();
202
+ for (const ref of selection.folderRefs) {
203
+ const id = resolveFolder(ref);
204
+ if (!id) {
205
+ const byName = nameIndex.get(ref.toLowerCase());
206
+ errors.push(
207
+ byName && byName.length > 1 ? `Folder name is ambiguous: ${ref} (use the path or id)` : `Folder not found: ${ref}`
208
+ );
209
+ continue;
210
+ }
211
+ for (const descendant of subtrees.get(id) ?? [id]) folderIds.add(descendant);
212
+ }
213
+ if (folderIds.size > 0) {
214
+ for (const video of manifest.videos) {
215
+ if (video.folder_id && folderIds.has(video.folder_id)) selected.set(video.id, video);
216
+ }
217
+ }
218
+ const collectionIds = /* @__PURE__ */ new Set();
219
+ for (const ref of selection.collectionRefs) {
220
+ const matches = manifest.collections.filter(
221
+ (collection) => collection.id === ref || collection.title.toLowerCase() === ref.toLowerCase()
222
+ );
223
+ if (matches.length === 0) {
224
+ errors.push(`Collection not found: ${ref}`);
225
+ continue;
226
+ }
227
+ for (const collection of matches) collectionIds.add(collection.id);
228
+ }
229
+ if (collectionIds.size > 0) {
230
+ for (const video of manifest.videos) {
231
+ if (video.collection_id && collectionIds.has(video.collection_id)) selected.set(video.id, video);
232
+ }
233
+ }
234
+ const byId = new Map(manifest.videos.map((video) => [video.id, video]));
235
+ for (const id of selection.videoIds) {
236
+ const video = byId.get(id);
237
+ if (!video) {
238
+ errors.push(`Video not found: ${id}`);
239
+ continue;
240
+ }
241
+ selected.set(video.id, video);
242
+ }
243
+ if (selection.unfiled) {
244
+ for (const video of manifest.videos) {
245
+ if (!video.folder_id) selected.set(video.id, video);
246
+ }
247
+ }
248
+ for (const term of selection.searchTerms) {
249
+ const needle = term.toLowerCase();
250
+ let matches = 0;
251
+ for (const video of manifest.videos) {
252
+ if (!video.title.toLowerCase().includes(needle)) continue;
253
+ selected.set(video.id, video);
254
+ matches += 1;
255
+ }
256
+ if (matches === 0) errors.push(`No video titles match: ${term}`);
257
+ }
258
+ return { videos: [...selected.values()], errors };
259
+ }
260
+
261
+ // src/commands/list.ts
262
+ async function listCommand(flags) {
263
+ const apiKey = resolveApiKey();
264
+ if (!apiKey) {
265
+ console.error("No API key. Run `cutsio login` or set CUTSIO_API_KEY.");
266
+ return 1;
267
+ }
268
+ const client = new CutsioClient(resolveApiUrl(flags.url), apiKey);
269
+ let manifest;
270
+ try {
271
+ manifest = await client.fetchManifest();
272
+ } catch (error) {
273
+ console.error(error instanceof Error ? error.message : String(error));
274
+ return 1;
275
+ }
276
+ if (flags.json === "true") {
277
+ console.log(JSON.stringify(manifest, null, 2));
278
+ return 0;
279
+ }
280
+ const search = (flags.search ?? "").trim().toLowerCase();
281
+ if (search) {
282
+ const matches = manifest.videos.filter((video) => video.title.toLowerCase().includes(search));
283
+ console.log(`Title matches (${matches.length}) for "${flags.search}":`);
284
+ if (matches.length === 0) console.log(" (none)");
285
+ for (const video of matches) {
286
+ console.log(` [${video.id}] ${video.status.padEnd(10)} ${video.title}`);
287
+ }
288
+ return 0;
289
+ }
290
+ const folderPaths = buildFolderPaths(manifest.folders);
291
+ const { subtree } = folderVideoCounts(manifest);
292
+ console.log(`Collections (${manifest.collections.length}) \u2014 select with --collection <name|id>`);
293
+ if (manifest.collections.length === 0) console.log(" (none)");
294
+ for (const collection of manifest.collections) {
295
+ const count = manifest.videos.filter((video) => video.collection_id === collection.id).length;
296
+ console.log(` ${pad(count)} ${collection.title} [${collection.id}]`);
297
+ }
298
+ console.log(`
299
+ Folders (${manifest.folders.length}) \u2014 select with --folder <path|id>`);
300
+ if (manifest.folders.length === 0) console.log(" (none)");
301
+ const rows = [...folderPaths.entries()].sort((a, b) => a[1].localeCompare(b[1]));
302
+ for (const [id, path] of rows) {
303
+ console.log(` ${pad(subtree.get(id) ?? 0)} ${path}/ [${id}]`);
304
+ }
305
+ const unfiled = manifest.videos.filter((video) => !video.folder_id).length;
306
+ if (unfiled > 0) console.log(` ${pad(unfiled)} (no folder)`);
307
+ if (flags.videos === "true") {
308
+ console.log(`
309
+ Videos (${manifest.videos.length}) \u2014 select with --video <id>`);
310
+ for (const video of manifest.videos) {
311
+ console.log(` [${video.id}] ${video.status.padEnd(10)} ${video.title}`);
312
+ }
313
+ }
314
+ return 0;
315
+ }
316
+ function pad(count) {
317
+ return `${count} video${count === 1 ? " " : "s"}`.padStart(9);
318
+ }
319
+
320
+ // src/commands/login.ts
321
+ import { stdin, stdout } from "node:process";
322
+ var MAX_ATTEMPTS = 3;
323
+ var KEY_PATTERN = /^ak_[A-Za-z0-9]{20,}$/;
324
+ async function loginCommand(flags) {
325
+ const apiUrl = resolveApiUrl(flags.url);
326
+ const interactiveTty = Boolean(stdin.isTTY) && typeof stdin.setRawMode === "function";
327
+ console.log(`Signing in to ${apiUrl}`);
328
+ console.log("Create an organization API key in Cutsio (Account or Organization \u2192 API Keys).");
329
+ console.log("The secret is shown once, when you create the key.\n");
330
+ const pipedAnswers = interactiveTty ? null : await readPipedAnswers();
331
+ let pipedIndex = 0;
332
+ let fromFlag = flags.key;
333
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
334
+ let raw;
335
+ if (fromFlag !== void 0) {
336
+ raw = fromFlag;
337
+ fromFlag = void 0;
338
+ } else if (pipedAnswers) {
339
+ const next = pipedAnswers[pipedIndex];
340
+ pipedIndex += 1;
341
+ if (next === void 0) break;
342
+ raw = next;
343
+ } else {
344
+ raw = await promptSecret("Paste API key: ");
345
+ }
346
+ const apiKey = normalizeKey(raw);
347
+ if (!apiKey) {
348
+ console.error("No API key entered.\n");
349
+ continue;
350
+ }
351
+ if (!KEY_PATTERN.test(apiKey)) {
352
+ console.error('That does not look like a Cutsio API key (expected something starting with "ak_").\n');
353
+ continue;
354
+ }
355
+ try {
356
+ const who = await new CutsioClient(apiUrl, apiKey).whoami();
357
+ if (!who.orgId) {
358
+ console.error("That key is not attached to an organization. Create an organization API key.\n");
359
+ continue;
360
+ }
361
+ writeConfig({ apiUrl, apiKey });
362
+ console.log(`
363
+ Saved. Organization ${who.orgId}${who.name ? ` (key "${who.name}")` : ""}.`);
364
+ console.log(`Config: ${configPath()}
365
+ `);
366
+ console.log("Next:");
367
+ console.log(" cutsio list");
368
+ console.log(" cutsio sync --out ~/Cutsio --interactive");
369
+ return 0;
370
+ } catch (error) {
371
+ console.error(`${explain(error, apiUrl)}
372
+ `);
373
+ }
374
+ }
375
+ console.error("Could not authenticate. Nothing was saved.");
376
+ return 1;
377
+ }
378
+ async function readPipedAnswers() {
379
+ const chunks = [];
380
+ for await (const chunk of stdin) {
381
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
382
+ }
383
+ return Buffer.concat(chunks).toString("utf8").split(/\r?\n/);
384
+ }
385
+ function normalizeKey(raw) {
386
+ let value = raw.trim();
387
+ const quoted = value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'");
388
+ if (quoted && value.length > 1) {
389
+ value = value.slice(1, -1).trim();
390
+ }
391
+ return value.replace(/^bearer\s+/i, "").trim();
392
+ }
393
+ function explain(error, apiUrl) {
394
+ if (error instanceof ApiError) {
395
+ if (error.status === 401) {
396
+ return [
397
+ "That key was rejected.",
398
+ " - If you copied it from the key list, that is the key ID, not the secret.",
399
+ " The secret is only shown once at creation \u2014 create a new key.",
400
+ ` - If it was created in a different environment than ${apiUrl}, it will not exist here.`
401
+ ].join("\n");
402
+ }
403
+ if (error.status === 403) {
404
+ return `That key authenticated but has no organization. ${error.message}`;
405
+ }
406
+ return `The server rejected the key (HTTP ${error.status}): ${error.message}`;
407
+ }
408
+ const message = error instanceof Error ? error.message : String(error);
409
+ return `Could not reach ${apiUrl}: ${message}`;
410
+ }
411
+ async function promptSecret(question) {
412
+ stdout.write(question);
413
+ stdin.setRawMode?.(true);
414
+ stdin.resume();
415
+ stdin.setEncoding("utf8");
416
+ return new Promise((resolve) => {
417
+ let value = "";
418
+ const finish = () => {
419
+ stdin.setRawMode?.(false);
420
+ stdin.pause();
421
+ stdin.off("data", onData);
422
+ stdout.write("\n");
423
+ resolve(value);
424
+ };
425
+ const onData = (chunk) => {
426
+ for (const char of chunk) {
427
+ if (char === "\r" || char === "\n" || char === "") {
428
+ finish();
429
+ return;
430
+ }
431
+ if (char === "") {
432
+ stdin.setRawMode?.(false);
433
+ stdout.write("\n");
434
+ process.exit(130);
435
+ }
436
+ if (char === "\x7F" || char === "\b") {
437
+ if (value.length > 0) {
438
+ value = value.slice(0, -1);
439
+ stdout.write("\b \b");
440
+ }
441
+ continue;
442
+ }
443
+ if (char < " ") continue;
444
+ value += char;
445
+ stdout.write("*");
446
+ }
447
+ };
448
+ stdin.on("data", onData);
449
+ });
450
+ }
451
+
452
+ // src/core/sync.ts
453
+ import { existsSync as existsSync4, statSync as statSync2 } from "node:fs";
454
+ import { join as join2 } from "node:path";
455
+
456
+ // src/core/download.ts
457
+ import { spawn } from "node:child_process";
458
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, renameSync, statSync } from "node:fs";
459
+ import { dirname as dirname2 } from "node:path";
460
+ function partPathFor(destPath) {
461
+ return `${destPath}.part`;
462
+ }
463
+ async function downloadResumable(url, destPath) {
464
+ mkdirSync2(dirname2(destPath), { recursive: true });
465
+ const part = partPathFor(destPath);
466
+ 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
+ ]);
484
+ if (code === 0) {
485
+ renameSync(part, destPath);
486
+ return { ok: true, bytes: statSync(destPath).size, resumedFrom };
487
+ }
488
+ const remote = await remoteSize(url);
489
+ if (remote !== null && existsSync2(part) && statSync(part).size === remote) {
490
+ renameSync(part, destPath);
491
+ return { ok: true, bytes: remote, resumedFrom };
492
+ }
493
+ const lastLine = stderr.trim().split("\n").filter(Boolean).at(-1);
494
+ return { ok: false, error: `curl exited ${code}${lastLine ? `: ${lastLine}` : ""}` };
495
+ }
496
+ function runCurl(args) {
497
+ return new Promise((resolve) => {
498
+ const child = spawn("curl", args, { stdio: ["ignore", "ignore", "pipe"] });
499
+ let stderr = "";
500
+ child.stderr.on("data", (chunk) => {
501
+ stderr += chunk.toString();
502
+ });
503
+ child.on("error", (error) => resolve({ code: -1, stderr: error.message }));
504
+ child.on("close", (code) => resolve({ code: code ?? -1, stderr }));
505
+ });
506
+ }
507
+ async function remoteSize(url) {
508
+ try {
509
+ const response = await fetch(url, { method: "HEAD" });
510
+ if (!response.ok) return null;
511
+ const header = response.headers.get("content-length");
512
+ const value = header ? Number(header) : Number.NaN;
513
+ return Number.isFinite(value) ? value : null;
514
+ } catch {
515
+ return null;
516
+ }
517
+ }
518
+
519
+ // src/core/state.ts
520
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync2, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "node:fs";
521
+ import { dirname as dirname3 } from "node:path";
522
+ var STATE_FILE = ".cutsio-state.json";
523
+ var StateOrgMismatchError = class extends Error {
524
+ constructor(message) {
525
+ super(message);
526
+ this.name = "StateOrgMismatchError";
527
+ }
528
+ };
529
+ function emptyState(orgId) {
530
+ return { schema: 1, orgId, updatedAt: Date.now(), videos: {} };
531
+ }
532
+ function loadState(path, orgId) {
533
+ if (!existsSync3(path)) return emptyState(orgId);
534
+ try {
535
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
536
+ if (parsed.schema !== 1 || typeof parsed.videos !== "object" || parsed.videos === null) {
537
+ return emptyState(orgId);
538
+ }
539
+ if (parsed.orgId && parsed.orgId !== orgId) {
540
+ throw new StateOrgMismatchError(
541
+ `This directory is already syncing ${parsed.orgId}. Use a different --out directory for ${orgId}.`
542
+ );
543
+ }
544
+ return { schema: 1, orgId, updatedAt: Date.now(), videos: parsed.videos };
545
+ } catch (error) {
546
+ if (error instanceof StateOrgMismatchError) throw error;
547
+ return emptyState(orgId);
548
+ }
549
+ }
550
+ function saveState(path, state) {
551
+ mkdirSync3(dirname3(path), { recursive: true });
552
+ state.updatedAt = Date.now();
553
+ const temp = `${path}.tmp`;
554
+ writeFileSync2(temp, JSON.stringify(state, null, 2));
555
+ renameSync2(temp, path);
556
+ }
557
+
558
+ // src/core/sync.ts
559
+ var PREPARE_BATCH_SIZE = 50;
560
+ var INITIAL_PREPARE_DELAY_MS = 3e3;
561
+ var MAX_PREPARE_DELAY_MS = 3e4;
562
+ async function sync(client, options) {
563
+ const { onEvent, outDir } = options;
564
+ const manifest = options.manifest ?? await client.fetchManifest();
565
+ const resolved = resolveSelection(manifest, options.selection);
566
+ if (resolved.errors.length > 0) {
567
+ throw new Error(`Selection problems:
568
+ ${resolved.errors.join("\n ")}`);
569
+ }
570
+ if (resolved.videos.length === 0) {
571
+ throw new Error("Selection matched no videos.");
572
+ }
573
+ const folderPaths = buildFolderPaths(manifest.folders);
574
+ const statePath = join2(outDir, STATE_FILE);
575
+ const state = loadState(statePath, manifest.orgId);
576
+ onEvent({
577
+ type: "manifest",
578
+ orgId: manifest.orgId,
579
+ folders: manifest.folders.length,
580
+ videos: manifest.videos.length
581
+ });
582
+ const pending = [];
583
+ let unchanged = 0;
584
+ let skipped = 0;
585
+ for (const video of resolved.videos) {
586
+ if (video.status !== "ready") {
587
+ skipped += 1;
588
+ onEvent({ type: "file-skipped", id: video.id, title: video.title, reason: `status: ${video.status}` });
589
+ continue;
590
+ }
591
+ if (video.moderation_status === "failed" || video.moderation_status === "errored") {
592
+ skipped += 1;
593
+ onEvent({ type: "file-skipped", id: video.id, title: video.title, reason: "restricted" });
594
+ continue;
595
+ }
596
+ const relPath = videoRelativePath(video, folderPaths);
597
+ const absPath = join2(outDir, relPath);
598
+ const stored = state.videos[video.id];
599
+ const isCurrent = stored !== void 0 && stored.version === video.version && stored.path === relPath && existsSync4(absPath) && (stored.sizeBytes === null || statSync2(absPath).size === stored.sizeBytes);
600
+ if (isCurrent) {
601
+ unchanged += 1;
602
+ continue;
603
+ }
604
+ pending.push({ video, absPath, relPath, resuming: existsSync4(partPathFor(absPath)) });
605
+ }
606
+ const selected = options.limit && options.limit > 0 ? pending.slice(0, options.limit) : pending;
607
+ onEvent({ type: "planned", total: resolved.videos.length, pending: selected.length, unchanged });
608
+ if (options.dryRun) {
609
+ for (const item of selected) {
610
+ onEvent({
611
+ type: "file-start",
612
+ id: item.video.id,
613
+ title: item.video.title,
614
+ path: item.relPath,
615
+ resuming: item.resuming
616
+ });
617
+ }
618
+ return { downloaded: 0, unchanged, skipped, failed: 0 };
619
+ }
620
+ let downloaded = 0;
621
+ let failed = 0;
622
+ let prepareDelayMs = INITIAL_PREPARE_DELAY_MS;
623
+ const deadline = Date.now() + options.maxWaitSeconds * 1e3;
624
+ const queue = new Map(selected.map((item) => [item.video.id, item]));
625
+ while (queue.size > 0) {
626
+ const ready = [];
627
+ const ids = [...queue.keys()];
628
+ for (let index = 0; index < ids.length; index += PREPARE_BATCH_SIZE) {
629
+ const batch = ids.slice(index, index + PREPARE_BATCH_SIZE);
630
+ const results = await client.prepare(batch);
631
+ for (const result of results) {
632
+ const item = queue.get(result.id);
633
+ if (!item) continue;
634
+ switch (result.status) {
635
+ case "ready":
636
+ if (result.url) {
637
+ ready.push({ id: result.id, url: result.url });
638
+ } else {
639
+ queue.delete(result.id);
640
+ failed += 1;
641
+ onEvent({ type: "file-failed", id: result.id, title: item.video.title, error: "no download URL returned" });
642
+ }
643
+ break;
644
+ case "preparing":
645
+ break;
646
+ case "not_found":
647
+ case "not_ready":
648
+ queue.delete(result.id);
649
+ skipped += 1;
650
+ onEvent({ type: "file-skipped", id: result.id, title: item.video.title, reason: result.status });
651
+ break;
652
+ case "forbidden":
653
+ queue.delete(result.id);
654
+ skipped += 1;
655
+ onEvent({ type: "file-skipped", id: result.id, title: item.video.title, reason: "restricted" });
656
+ break;
657
+ default:
658
+ queue.delete(result.id);
659
+ failed += 1;
660
+ onEvent({ type: "file-failed", id: result.id, title: item.video.title, error: `prepare: ${result.status}` });
661
+ break;
662
+ }
663
+ }
664
+ }
665
+ if (ready.length > 0) {
666
+ prepareDelayMs = INITIAL_PREPARE_DELAY_MS;
667
+ await runPool(ready, options.concurrency, async ({ id, url }) => {
668
+ const item = queue.get(id);
669
+ if (!item) return;
670
+ onEvent({
671
+ type: "file-start",
672
+ id,
673
+ title: item.video.title,
674
+ path: item.relPath,
675
+ resuming: item.resuming
676
+ });
677
+ let result = await downloadResumable(url, item.absPath);
678
+ if (!result.ok) {
679
+ const [refreshed] = await client.prepare([id]);
680
+ if (refreshed?.status === "ready" && refreshed.url) {
681
+ result = await downloadResumable(refreshed.url, item.absPath);
682
+ }
683
+ }
684
+ if (result.ok) {
685
+ state.videos[id] = {
686
+ version: item.video.version,
687
+ sizeBytes: result.bytes,
688
+ path: item.relPath
689
+ };
690
+ saveState(statePath, state);
691
+ queue.delete(id);
692
+ downloaded += 1;
693
+ onEvent({ type: "file-done", id, title: item.video.title, path: item.relPath, bytes: result.bytes });
694
+ } else {
695
+ queue.delete(id);
696
+ failed += 1;
697
+ onEvent({ type: "file-failed", id, title: item.video.title, error: result.error });
698
+ }
699
+ });
700
+ }
701
+ if (queue.size === 0) break;
702
+ if (Date.now() > deadline) {
703
+ for (const item of queue.values()) {
704
+ failed += 1;
705
+ onEvent({
706
+ type: "file-failed",
707
+ id: item.video.id,
708
+ title: item.video.title,
709
+ error: "timed out waiting for the master"
710
+ });
711
+ }
712
+ break;
713
+ }
714
+ await sleep(prepareDelayMs);
715
+ prepareDelayMs = Math.min(prepareDelayMs * 2, MAX_PREPARE_DELAY_MS);
716
+ }
717
+ return { downloaded, unchanged, skipped, failed };
718
+ }
719
+ async function runPool(items, concurrency, worker) {
720
+ let index = 0;
721
+ const workerCount = Math.max(1, Math.min(concurrency, items.length));
722
+ await Promise.all(
723
+ Array.from({ length: workerCount }, async () => {
724
+ for (; ; ) {
725
+ const current = index;
726
+ index += 1;
727
+ const item = items[current];
728
+ if (item === void 0) return;
729
+ await worker(item);
730
+ }
731
+ })
732
+ );
733
+ }
734
+ function sleep(ms) {
735
+ return new Promise((resolve) => setTimeout(resolve, ms));
736
+ }
737
+
738
+ // src/commands/selection-prompt.ts
739
+ import { stdin as stdin2, stdout as stdout2 } from "node:process";
740
+ import { createInterface } from "node:readline/promises";
741
+ function countLabel(count) {
742
+ return `${count} video${count === 1 ? "" : "s"}`;
743
+ }
744
+ async function promptSelection(manifest) {
745
+ const { subtree } = folderVideoCounts(manifest);
746
+ const entries = [
747
+ ...manifest.collections.map((collection) => ({
748
+ kind: "collection",
749
+ ref: collection.title,
750
+ label: `collection ${collection.title} \u2014 ${countLabel(
751
+ manifest.videos.filter((video) => video.collection_id === collection.id).length
752
+ )}`
753
+ })),
754
+ ...[...buildFolderPaths(manifest.folders).entries()].sort((a, b) => a[1].localeCompare(b[1])).map(([id, path]) => ({
755
+ kind: "folder",
756
+ ref: path,
757
+ label: `folder ${path}/ \u2014 ${countLabel(subtree.get(id) ?? 0)}`
758
+ }))
759
+ ];
760
+ const unfiledCount = manifest.videos.filter((video) => !video.folder_id).length;
761
+ if (unfiledCount > 0) {
762
+ entries.push({
763
+ kind: "unfiled",
764
+ ref: "",
765
+ label: `unfiled (no folder) \u2014 ${countLabel(unfiledCount)}`
766
+ });
767
+ }
768
+ if (entries.length === 0) {
769
+ console.error("Nothing to select: this workspace has no folders or collections.");
770
+ return null;
771
+ }
772
+ console.log("Select what to sync:");
773
+ entries.forEach((entry, index) => {
774
+ console.log(` ${String(index + 1).padStart(3)}) ${entry.label}`);
775
+ });
776
+ const rl = createInterface({ input: stdin2, output: stdout2 });
777
+ try {
778
+ for (; ; ) {
779
+ const answer = (await rl.question('Numbers separated by commas, "all", or blank to cancel: ')).trim();
780
+ if (answer === "") return null;
781
+ if (answer.toLowerCase() === "all") {
782
+ return { all: true, unfiled: false, folderRefs: [], collectionRefs: [], videoIds: [], searchTerms: [] };
783
+ }
784
+ const indexes = answer.split(/[,\s]+/).filter(Boolean).map(Number);
785
+ const invalid = indexes.some(
786
+ (value) => !Number.isInteger(value) || value < 1 || value > entries.length
787
+ );
788
+ if (invalid || indexes.length === 0) {
789
+ console.log(`Enter numbers between 1 and ${entries.length}.`);
790
+ continue;
791
+ }
792
+ const picked = indexes.map((index) => entries[index - 1]).filter((entry) => entry !== void 0);
793
+ return {
794
+ all: false,
795
+ unfiled: picked.some((entry) => entry.kind === "unfiled"),
796
+ folderRefs: picked.filter((entry) => entry.kind === "folder").map((entry) => entry.ref),
797
+ collectionRefs: picked.filter((entry) => entry.kind === "collection").map((entry) => entry.ref),
798
+ videoIds: [],
799
+ searchTerms: []
800
+ };
801
+ }
802
+ } finally {
803
+ rl.close();
804
+ }
805
+ }
806
+
807
+ // src/commands/sync.ts
808
+ async function syncCommand(flags) {
809
+ const apiKey = resolveApiKey();
810
+ if (!apiKey) {
811
+ console.error("No API key. Run `cutsio login` or set CUTSIO_API_KEY.");
812
+ return 1;
813
+ }
814
+ const client = new CutsioClient(resolveApiUrl(flags.url), apiKey);
815
+ const onEvent = (event) => {
816
+ switch (event.type) {
817
+ case "manifest":
818
+ console.log(`Manifest: ${event.videos} videos, ${event.folders} folders (${event.orgId})`);
819
+ break;
820
+ case "planned":
821
+ console.log(`Selected ${event.total}, pending ${event.pending}, unchanged ${event.unchanged}`);
822
+ break;
823
+ case "file-start":
824
+ console.log(` ${event.resuming ? "resume " : "fetch "} ${event.path}`);
825
+ break;
826
+ case "file-done":
827
+ console.log(` done ${event.path} (${formatBytes(event.bytes)})`);
828
+ break;
829
+ case "file-skipped":
830
+ console.log(` skip ${event.title} \u2014 ${event.reason}`);
831
+ break;
832
+ case "file-failed":
833
+ console.error(` FAILED ${event.title} \u2014 ${event.error}`);
834
+ break;
835
+ case "warning":
836
+ console.warn(`warning: ${event.message}`);
837
+ break;
838
+ }
839
+ };
840
+ let selection = {
841
+ all: flags.all === "true",
842
+ folderRefs: splitList(flags.folder),
843
+ collectionRefs: splitList(flags.collection),
844
+ videoIds: splitList(flags.video),
845
+ searchTerms: splitList(flags.search),
846
+ unfiled: flags.unfiled === "true"
847
+ };
848
+ let manifest;
849
+ try {
850
+ if (flags.interactive === "true") {
851
+ manifest = await client.fetchManifest();
852
+ const prompted = await promptSelection(manifest);
853
+ if (!prompted) {
854
+ console.log("Cancelled. Nothing downloaded.");
855
+ return 0;
856
+ }
857
+ selection = mergeSelections(selection, prompted);
858
+ }
859
+ if (isEmptySelection(selection)) {
860
+ console.error("Refusing to sync the whole workspace. Choose one of:\n");
861
+ console.error(" --all every video in the workspace");
862
+ console.error(" --folder <path|id,...> a folder and everything under it");
863
+ console.error(" --collection <name|id,...> a collection");
864
+ console.error(" --video <id,...> specific videos");
865
+ console.error(" --search <term,...> video titles containing a term");
866
+ console.error(" --unfiled videos that are in no folder");
867
+ console.error(" --interactive pick from a menu\n");
868
+ console.error("`cutsio list` prints names, paths, and ids.");
869
+ return 1;
870
+ }
871
+ const summary = await sync(client, {
872
+ outDir: flags.out ?? "./cutsio",
873
+ concurrency: positiveInt(flags.concurrency, 3),
874
+ dryRun: flags["dry-run"] === "true",
875
+ maxWaitSeconds: positiveInt(flags["max-wait"], 600),
876
+ limit: positiveInt(flags.limit, 0),
877
+ selection,
878
+ manifest,
879
+ onEvent
880
+ });
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;
886
+ } catch (error) {
887
+ console.error(error instanceof Error ? error.message : String(error));
888
+ return 1;
889
+ }
890
+ }
891
+ function splitList(value) {
892
+ if (!value) return [];
893
+ return [...new Set(value.split(",").map((entry) => entry.trim()).filter(Boolean))];
894
+ }
895
+ function mergeSelections(a, b) {
896
+ return {
897
+ all: a.all || b.all,
898
+ unfiled: a.unfiled || b.unfiled,
899
+ folderRefs: [.../* @__PURE__ */ new Set([...a.folderRefs, ...b.folderRefs])],
900
+ collectionRefs: [.../* @__PURE__ */ new Set([...a.collectionRefs, ...b.collectionRefs])],
901
+ videoIds: [.../* @__PURE__ */ new Set([...a.videoIds, ...b.videoIds])],
902
+ searchTerms: [.../* @__PURE__ */ new Set([...a.searchTerms, ...b.searchTerms])]
903
+ };
904
+ }
905
+ function positiveInt(value, fallback) {
906
+ const parsed = Number(value);
907
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
908
+ }
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
+
920
+ // src/commands/whoami.ts
921
+ async function whoamiCommand(flags) {
922
+ const apiKey = resolveApiKey();
923
+ if (!apiKey) {
924
+ console.error("No API key. Run `cutsio login` or set CUTSIO_API_KEY.");
925
+ return 1;
926
+ }
927
+ const client = new CutsioClient(resolveApiUrl(flags.url), apiKey);
928
+ try {
929
+ console.log(JSON.stringify(await client.whoami(), null, 2));
930
+ return 0;
931
+ } catch (error) {
932
+ console.error(error instanceof Error ? error.message : String(error));
933
+ return 1;
934
+ }
935
+ }
936
+
937
+ // src/index.ts
938
+ function parseFlags(args) {
939
+ const flags = {};
940
+ for (let index = 0; index < args.length; index += 1) {
941
+ const arg = args[index];
942
+ if (!arg || !arg.startsWith("--")) continue;
943
+ const body = arg.slice(2);
944
+ const separator = body.indexOf("=");
945
+ const key = separator === -1 ? body : body.slice(0, separator);
946
+ const inline = separator === -1 ? void 0 : body.slice(separator + 1);
947
+ if (!key) continue;
948
+ if (inline !== void 0) {
949
+ flags[key] = inline;
950
+ continue;
951
+ }
952
+ const next = args[index + 1];
953
+ if (next !== void 0 && !next.startsWith("--")) {
954
+ flags[key] = next;
955
+ index += 1;
956
+ } else {
957
+ flags[key] = "true";
958
+ }
959
+ }
960
+ return flags;
961
+ }
962
+ function printHelp() {
963
+ console.log(`cutsio \u2014 sync Cutsio mezzanine masters to a local folder
964
+
965
+ Usage:
966
+ cutsio login [--url URL] [--key KEY]
967
+ cutsio whoami [--url URL]
968
+ cutsio list [--videos] [--search TERM] [--json] [--url URL]
969
+ cutsio sync [--all | --folder REFS | --collection REFS | --video IDS | --search TERMS | --unfiled | --interactive]
970
+ [--out DIR] [--concurrency N] [--max-wait SECONDS] [--limit N] [--dry-run] [--url URL]
971
+
972
+ Selection:
973
+ REFS and IDS are comma-separated. Folders accept a path (e.g. test/sub1a) or id;
974
+ collections accept a title or id. A folder selection includes its subfolders.
975
+ --search matches video titles anywhere in the workspace; --unfiled selects
976
+ videos that are in no folder.
977
+
978
+ Environment:
979
+ CUTSIO_API_URL API base URL (default ${DEFAULT_API_URL})
980
+ CUTSIO_API_KEY Organization API key`);
981
+ }
982
+ async function main() {
983
+ const [, , command, ...rest] = process2.argv;
984
+ const flags = parseFlags(rest);
985
+ switch (command) {
986
+ case "login":
987
+ return loginCommand(flags);
988
+ case "whoami":
989
+ return whoamiCommand(flags);
990
+ case "list":
991
+ return listCommand(flags);
992
+ case "sync":
993
+ return syncCommand(flags);
994
+ case void 0:
995
+ case "help":
996
+ case "--help":
997
+ case "-h":
998
+ printHelp();
999
+ return 0;
1000
+ default:
1001
+ console.error(`Unknown command: ${command}`);
1002
+ printHelp();
1003
+ return 1;
1004
+ }
1005
+ }
1006
+ main().then((code) => {
1007
+ process2.exitCode = code;
1008
+ });
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "cutsio",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Sync Cutsio mezzanine masters to a local folder",
5
+ "type": "module",
6
+ "bin": {
7
+ "cutsio": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "license": "UNLICENSED",
16
+ "keywords": [
17
+ "cutsio",
18
+ "cli",
19
+ "download",
20
+ "sync",
21
+ "mux",
22
+ "mezzanine"
23
+ ],
24
+ "scripts": {
25
+ "dev": "node src/index.ts",
26
+ "start": "node dist/index.js",
27
+ "build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node18 --outfile=dist/index.js",
28
+ "typecheck": "tsc --noEmit",
29
+ "prepublishOnly": "npm run typecheck && npm run build"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^22.10.2",
33
+ "esbuild": "^0.24.0",
34
+ "typescript": "^5.7.2"
35
+ }
36
+ }