yungle-cli 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hein de Wilde
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # yungle-cli
2
+
3
+ Send and sync files with [Yungle](https://yungle.co) from the command line.
4
+
5
+ ```bash
6
+ npm install -g yungle-cli
7
+ yungle auth login
8
+ yungle send ~/Shoot --to client@example.com
9
+ ```
10
+
11
+ ## Why a CLI
12
+
13
+ Yungle's upload engine is resumable at part granularity and sized for 100 GB
14
+ transfers. Until this existed, the only thing that could drive it was a browser
15
+ tab that had to stay open — which is the wrong process to hand an eight-hour
16
+ upload to. This one survives a closed laptop lid: interrupt `yungle send`, run
17
+ the same command again, and it continues from the last committed part instead of
18
+ starting over.
19
+
20
+ ## Commands
21
+
22
+ | | |
23
+ |---|---|
24
+ | `yungle auth login` | Save an API key (or set `YUNGLE_API_KEY`) |
25
+ | `yungle auth status` | Which key is in use, and what it may do |
26
+ | `yungle send <paths…>` | Send files; prints the share link |
27
+ | `yungle push <paths…> --collection <id>` | Upload into a collection |
28
+ | `yungle ls transfers\|collections\|contacts` | List things |
29
+ | `yungle rm transfer <id>` | Revoke a transfer |
30
+
31
+ `--json` on any command prints a machine-readable object on stdout; progress
32
+ goes to stderr, so `yungle send … --json \| jq` works.
33
+
34
+ Folders keep their structure. Hidden files are skipped when walking a directory
35
+ and kept when named directly.
36
+
37
+ ## Resuming
38
+
39
+ State lives in `~/.cache/yungle/`. A session is keyed by the *local files* —
40
+ their paths, sizes and modification times — because that is all the next
41
+ invocation has to go on; it cannot know the transfer id of a process that was
42
+ killed. Run the same command with the same files and it resumes; change a file
43
+ and it starts over, because resuming into a row whose declared size no longer
44
+ matches fails at the very end with an error that points nowhere useful.
45
+
46
+ Sessions expire after two hours, matching the lifetime of the upload tokens
47
+ they hold.
48
+
49
+ ## Credentials
50
+
51
+ Read from `YUNGLE_API_KEY` first, then `~/.config/yungle/config.json` (written
52
+ `0600`). A key acts as one workspace and can be revoked at any time from
53
+ Settings → API keys.
54
+
55
+ The API is available on any paid Yungle plan. Full documentation:
56
+ <https://yungle.co/developers>
57
+
58
+ ## What it cannot do
59
+
60
+ Your vault and end-to-end encrypted transfers are not reachable through the API
61
+ this is built on. Their keys are derived in the client and never sent to us, so
62
+ a server-side API has nothing to offer — see
63
+ <https://yungle.co/developers/unsupported>. Reaching them from a local client is
64
+ possible in principle and is not built yet.
@@ -0,0 +1,868 @@
1
+ #!/usr/bin/env node
2
+
3
+ // ../../packages/api-client/src/index.ts
4
+ var YungleApiError = class extends Error {
5
+ constructor(status, code, message, details) {
6
+ super(message);
7
+ this.status = status;
8
+ this.code = code;
9
+ this.details = details;
10
+ this.name = "YungleApiError";
11
+ }
12
+ status;
13
+ code;
14
+ details;
15
+ /** Worth trying again: throttled, or our fault. */
16
+ get retryable() {
17
+ return this.status === 429 || this.status >= 500;
18
+ }
19
+ /** Seconds to wait, when the server said. */
20
+ get retryAfterSeconds() {
21
+ const value = this.details?.retryAfterSeconds;
22
+ return typeof value === "number" ? value : null;
23
+ }
24
+ };
25
+ var DEFAULT_BASE = "https://yungle.co/api/v1";
26
+ var YungleClient = class {
27
+ baseUrl;
28
+ apiKey;
29
+ doFetch;
30
+ maxRetries;
31
+ constructor(options) {
32
+ if (!options.apiKey) throw new Error("An API key is required.");
33
+ this.apiKey = options.apiKey;
34
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE).replace(/\/+$/, "");
35
+ this.doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
36
+ this.maxRetries = options.maxRetries ?? 3;
37
+ }
38
+ // ── Account ───────────────────────────────────────────────────────────────
39
+ me() {
40
+ return this.request("GET", "/me");
41
+ }
42
+ // ── Transfers ─────────────────────────────────────────────────────────────
43
+ listTransfers() {
44
+ return this.request("GET", "/transfers");
45
+ }
46
+ /**
47
+ * Create a draft. Nothing is live and nobody is emailed until `finalizeTransfer`.
48
+ */
49
+ createTransfer(input) {
50
+ return this.request("POST", "/transfers", input);
51
+ }
52
+ getTransfer(id) {
53
+ return this.request("GET", `/transfers/${enc(id)}`);
54
+ }
55
+ /**
56
+ * Send it. Omit `recipients` for a link-only transfer, which costs nothing
57
+ * from the daily email budget.
58
+ *
59
+ * Idempotent server-side, so a lost response is safe to repeat.
60
+ */
61
+ finalizeTransfer(id, input = {}) {
62
+ return this.request("POST", `/transfers/${enc(id)}/finalize`, input, { idempotent: true });
63
+ }
64
+ addTransferFiles(id, files) {
65
+ return this.request("POST", `/transfers/${enc(id)}/files`, { files });
66
+ }
67
+ removeTransferFile(id, fileId) {
68
+ return this.request("DELETE", `/transfers/${enc(id)}/files/${enc(fileId)}`);
69
+ }
70
+ updateTransfer(id, input) {
71
+ return this.request("PATCH", `/transfers/${enc(id)}`, input);
72
+ }
73
+ /** Immediate and irreversible — the encryption keys are destroyed. */
74
+ revokeTransfer(id) {
75
+ return this.request("DELETE", `/transfers/${enc(id)}`);
76
+ }
77
+ transferDownloads(id) {
78
+ return this.request("GET", `/transfers/${enc(id)}/downloads`);
79
+ }
80
+ // ── Collections ───────────────────────────────────────────────────────────
81
+ listCollections() {
82
+ return this.request("GET", "/collections");
83
+ }
84
+ createCollection(input) {
85
+ return this.request("POST", "/collections", input);
86
+ }
87
+ getCollection(id) {
88
+ return this.request("GET", `/collections/${enc(id)}`);
89
+ }
90
+ updateCollection(id, input) {
91
+ return this.request("PATCH", `/collections/${enc(id)}`, input);
92
+ }
93
+ deleteCollection(id) {
94
+ return this.request("DELETE", `/collections/${enc(id)}`);
95
+ }
96
+ /** `folderId` omitted lists everything; `'root'` lists the top level only. */
97
+ listCollectionFiles(id, folderId) {
98
+ const query = folderId ? `?folderId=${encodeURIComponent(folderId)}` : "";
99
+ return this.request("GET", `/collections/${enc(id)}/files${query}`);
100
+ }
101
+ addCollectionFiles(id, files, folderId) {
102
+ return this.request("POST", `/collections/${enc(id)}/files`, { files, folderId });
103
+ }
104
+ deleteCollectionFiles(id, fileIds) {
105
+ return this.request("DELETE", `/collections/${enc(id)}/files`, { fileIds });
106
+ }
107
+ listFolders(id) {
108
+ return this.request("GET", `/collections/${enc(id)}/folders`);
109
+ }
110
+ createFolder(id, input) {
111
+ return this.request("POST", `/collections/${enc(id)}/folders`, input);
112
+ }
113
+ listGuests(id) {
114
+ return this.request("GET", `/collections/${enc(id)}/guests`);
115
+ }
116
+ inviteGuests(id, emails) {
117
+ return this.request("POST", `/collections/${enc(id)}/guests`, { emails });
118
+ }
119
+ removeGuest(id, guestId) {
120
+ return this.request("DELETE", `/collections/${enc(id)}/guests/${enc(guestId)}`);
121
+ }
122
+ // ── Contacts ──────────────────────────────────────────────────────────────
123
+ listContacts() {
124
+ return this.request("GET", "/contacts");
125
+ }
126
+ createContact(input) {
127
+ return this.request("POST", "/contacts", input);
128
+ }
129
+ getContact(id) {
130
+ return this.request("GET", `/contacts/${enc(id)}`);
131
+ }
132
+ /** A full replacement: any field omitted is cleared. */
133
+ updateContact(id, input) {
134
+ return this.request("PATCH", `/contacts/${enc(id)}`, input);
135
+ }
136
+ deleteContact(id) {
137
+ return this.request("DELETE", `/contacts/${enc(id)}`);
138
+ }
139
+ // ── Transport ─────────────────────────────────────────────────────────────
140
+ /**
141
+ * `idempotent` widens what may be retried.
142
+ *
143
+ * GET and DELETE are safe by nature. POST is not — retrying `createTransfer`
144
+ * after a 500 would make a second draft — so it is opt-in per method, and set
145
+ * only where the server documents the operation as repeatable. Retrying a
146
+ * *throttle* is always fine regardless: a 429 means nothing happened.
147
+ */
148
+ async request(method, path, body, opts) {
149
+ const safe = method === "GET" || method === "DELETE" || opts?.idempotent === true;
150
+ let attempt = 0;
151
+ for (; ; ) {
152
+ const res = await this.doFetch(`${this.baseUrl}${path}`, {
153
+ method,
154
+ headers: {
155
+ Authorization: `Bearer ${this.apiKey}`,
156
+ Accept: "application/json",
157
+ ...body === void 0 ? {} : { "Content-Type": "application/json" }
158
+ },
159
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
160
+ });
161
+ if (res.ok) return await res.json();
162
+ const error = await toError(res);
163
+ const mayRetry = error.status === 429 || safe && error.retryable;
164
+ if (!mayRetry || attempt >= this.maxRetries) throw error;
165
+ await sleep(backoffMs(attempt, error));
166
+ attempt++;
167
+ }
168
+ }
169
+ };
170
+ function enc(segment) {
171
+ return encodeURIComponent(segment);
172
+ }
173
+ async function toError(res) {
174
+ let body = null;
175
+ try {
176
+ body = await res.json();
177
+ } catch {
178
+ }
179
+ const err = body?.error;
180
+ return new YungleApiError(
181
+ res.status,
182
+ err?.code ?? "http_error",
183
+ err?.message ?? `Request failed with status ${res.status}.`,
184
+ err?.details
185
+ );
186
+ }
187
+ function backoffMs(attempt, error) {
188
+ const stated = error.retryAfterSeconds;
189
+ if (stated !== null) return stated * 1e3;
190
+ return Math.min(2 ** attempt * 500, 8e3) + Math.floor(Math.random() * 250);
191
+ }
192
+ function sleep(ms) {
193
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
194
+ }
195
+
196
+ // src/index.ts
197
+ import { createInterface } from "node:readline/promises";
198
+
199
+ // src/args.ts
200
+ var VALUE_FLAGS = /* @__PURE__ */ new Set([
201
+ "to",
202
+ "message",
203
+ "title",
204
+ "password",
205
+ "expires",
206
+ "collection",
207
+ "folder",
208
+ "out",
209
+ "url",
210
+ "key"
211
+ ]);
212
+ function parseArgs(argv) {
213
+ const [command = "help", ...rest] = argv;
214
+ const positionals = [];
215
+ const flags = {};
216
+ for (let i = 0; i < rest.length; i++) {
217
+ const token = rest[i];
218
+ if (token === "--") {
219
+ positionals.push(...rest.slice(i + 1));
220
+ break;
221
+ }
222
+ if (token.startsWith("--")) {
223
+ const body = token.slice(2);
224
+ const eq = body.indexOf("=");
225
+ if (eq >= 0) {
226
+ flags[body.slice(0, eq)] = body.slice(eq + 1);
227
+ continue;
228
+ }
229
+ if (VALUE_FLAGS.has(body)) {
230
+ const value = rest[i + 1];
231
+ if (value === void 0 || value.startsWith("--")) {
232
+ throw new Error(`--${body} needs a value.`);
233
+ }
234
+ flags[body] = value;
235
+ i++;
236
+ continue;
237
+ }
238
+ flags[body] = true;
239
+ continue;
240
+ }
241
+ positionals.push(token);
242
+ }
243
+ return { command, positionals, flags };
244
+ }
245
+ function listFlag(value) {
246
+ if (typeof value !== "string") return [];
247
+ return value.split(",").map((s) => s.trim()).filter(Boolean);
248
+ }
249
+ function numberFlag(value, name) {
250
+ if (value === void 0 || typeof value === "boolean") return void 0;
251
+ const n = Number(value);
252
+ if (!Number.isInteger(n) || n <= 0) throw new Error(`--${name} must be a positive whole number.`);
253
+ return n;
254
+ }
255
+ function stringFlag(value) {
256
+ return typeof value === "string" ? value : void 0;
257
+ }
258
+
259
+ // src/config.ts
260
+ import { createHash } from "node:crypto";
261
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
262
+ import { homedir } from "node:os";
263
+ import { dirname, join } from "node:path";
264
+ function xdg(envVar, fallback) {
265
+ const configured = process.env[envVar];
266
+ return configured && configured.startsWith("/") ? configured : join(homedir(), fallback);
267
+ }
268
+ var CONFIG_PATH = join(xdg("XDG_CONFIG_HOME", ".config"), "yungle", "config.json");
269
+ var RESUME_PATH = join(xdg("XDG_CACHE_HOME", ".cache"), "yungle", "uploads.json");
270
+ async function readConfig() {
271
+ try {
272
+ return JSON.parse(await readFile(CONFIG_PATH, "utf8"));
273
+ } catch {
274
+ return {};
275
+ }
276
+ }
277
+ async function writeConfig(config) {
278
+ await mkdir(dirname(CONFIG_PATH), { recursive: true, mode: 448 });
279
+ await writeFile(CONFIG_PATH, `${JSON.stringify(config, null, 2)}
280
+ `, { mode: 384 });
281
+ await chmod(CONFIG_PATH, 384);
282
+ }
283
+ async function resolveKey() {
284
+ const fromEnv = process.env.YUNGLE_API_KEY?.trim();
285
+ const config = await readConfig();
286
+ const baseUrl = process.env.YUNGLE_API_URL?.trim() || config.baseUrl;
287
+ if (fromEnv) return { apiKey: fromEnv, baseUrl, source: "YUNGLE_API_KEY" };
288
+ if (config.apiKey) return { apiKey: config.apiKey, baseUrl, source: CONFIG_PATH };
289
+ return null;
290
+ }
291
+ var SESSION_TTL_MS = 2 * 60 * 60 * 1e3;
292
+ function sessionKey(inputs, target) {
293
+ const parts = inputs.map((f) => [f.path, f.size, Math.floor(f.mtimeMs)]).sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
294
+ return createHash("sha256").update(JSON.stringify([target, parts])).digest("hex").slice(0, 32);
295
+ }
296
+ async function readSessions() {
297
+ try {
298
+ const parsed = JSON.parse(await readFile(RESUME_PATH, "utf8"));
299
+ const now = Date.now();
300
+ const live = {};
301
+ for (const [key, session] of Object.entries(parsed)) {
302
+ if (session && now - session.createdAt < SESSION_TTL_MS) live[key] = session;
303
+ }
304
+ return live;
305
+ } catch {
306
+ return {};
307
+ }
308
+ }
309
+ async function findSession(key) {
310
+ return (await readSessions())[key] ?? null;
311
+ }
312
+ async function saveSession(key, session) {
313
+ const sessions = await readSessions();
314
+ sessions[key] = session;
315
+ await mkdir(dirname(RESUME_PATH), { recursive: true });
316
+ await writeFile(RESUME_PATH, JSON.stringify(sessions));
317
+ }
318
+ async function dropSession(key) {
319
+ const sessions = await readSessions();
320
+ if (!(key in sessions)) return;
321
+ delete sessions[key];
322
+ await mkdir(dirname(RESUME_PATH), { recursive: true });
323
+ await writeFile(RESUME_PATH, JSON.stringify(sessions));
324
+ }
325
+ var URL_PATH = RESUME_PATH.replace(/uploads\.json$/, "upload-urls.json");
326
+ async function readUploadUrls() {
327
+ try {
328
+ return JSON.parse(await readFile(URL_PATH, "utf8"));
329
+ } catch {
330
+ return {};
331
+ }
332
+ }
333
+ async function saveUploadUrl(fileId, uploadUrl) {
334
+ const current = await readUploadUrls();
335
+ if (current[fileId] === uploadUrl) return;
336
+ current[fileId] = uploadUrl;
337
+ await mkdir(dirname(URL_PATH), { recursive: true });
338
+ await writeFile(URL_PATH, JSON.stringify(current));
339
+ }
340
+ async function clearUploadUrls(fileIds) {
341
+ const current = await readUploadUrls();
342
+ let changed = false;
343
+ for (const id of fileIds) {
344
+ if (id in current) {
345
+ delete current[id];
346
+ changed = true;
347
+ }
348
+ }
349
+ if (changed) await writeFile(URL_PATH, JSON.stringify(current));
350
+ }
351
+
352
+ // src/files.ts
353
+ import { readdir, stat } from "node:fs/promises";
354
+ import { basename, join as join2, relative, resolve, sep } from "node:path";
355
+ function isHidden(name) {
356
+ return name.startsWith(".");
357
+ }
358
+ async function collectFiles(inputs) {
359
+ const out2 = [];
360
+ for (const input of inputs) {
361
+ const absolute = resolve(input);
362
+ const info = await stat(absolute);
363
+ if (info.isDirectory()) {
364
+ await walk(absolute, basename(absolute), out2);
365
+ } else {
366
+ out2.push({
367
+ path: absolute,
368
+ name: basename(absolute),
369
+ size: info.size,
370
+ relativeDir: "",
371
+ mtimeMs: info.mtimeMs
372
+ });
373
+ }
374
+ }
375
+ return out2;
376
+ }
377
+ async function walk(dir, prefix, out2) {
378
+ const entries = await readdir(dir, { withFileTypes: true });
379
+ for (const entry of entries) {
380
+ if (isHidden(entry.name)) continue;
381
+ const full = join2(dir, entry.name);
382
+ if (entry.isDirectory()) {
383
+ await walk(full, `${prefix}/${entry.name}`, out2);
384
+ } else if (entry.isFile()) {
385
+ const info = await stat(full);
386
+ out2.push({
387
+ path: full,
388
+ name: entry.name,
389
+ size: info.size,
390
+ relativeDir: prefix,
391
+ mtimeMs: info.mtimeMs
392
+ });
393
+ }
394
+ }
395
+ }
396
+ function toFileInputs(files) {
397
+ return files.map((f) => ({
398
+ name: f.name,
399
+ size: f.size,
400
+ ...f.relativeDir ? { path: f.relativeDir } : {}
401
+ }));
402
+ }
403
+ function pairWithTargets(files, targets) {
404
+ if (files.length !== targets.length) {
405
+ throw new Error(
406
+ `The server returned ${targets.length} upload targets for ${files.length} files. Refusing to guess which is which.`
407
+ );
408
+ }
409
+ return files.map((file, i) => ({ file, target: targets[i] }));
410
+ }
411
+ function formatBytes(bytes) {
412
+ if (bytes < 1024) return `${bytes} B`;
413
+ const units = ["KB", "MB", "GB", "TB"];
414
+ let value = bytes / 1024;
415
+ let unit = 0;
416
+ while (value >= 1024 && unit < units.length - 1) {
417
+ value /= 1024;
418
+ unit++;
419
+ }
420
+ return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
421
+ }
422
+
423
+ // src/upload.ts
424
+ import { createReadStream } from "node:fs";
425
+ import { stat as stat2 } from "node:fs/promises";
426
+ var CONCURRENCY = 3;
427
+ async function uploadAll(files, endpoint, onProgress) {
428
+ const queue = [...files];
429
+ const workers = Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => {
430
+ for (; ; ) {
431
+ const next = queue.shift();
432
+ if (!next) return;
433
+ await uploadOne(next, endpoint, onProgress);
434
+ }
435
+ });
436
+ await Promise.all(workers);
437
+ await clearUploadUrls(files.map((f) => f.target.id));
438
+ }
439
+ async function uploadOne(file, endpoint, onProgress) {
440
+ const tus = await import("tus-js-client");
441
+ const { size } = await stat2(file.path);
442
+ const urls = await readUploadUrls();
443
+ const uploadUrl = urls[file.target.id];
444
+ const started = Date.now();
445
+ await new Promise((resolve2, reject) => {
446
+ const upload = new tus.Upload(createReadStream(file.path), {
447
+ endpoint,
448
+ /**
449
+ * Reattach to an upload already in progress. The endpoint stays set as a
450
+ * fallback: if the upload was never actually created the HEAD 404s and
451
+ * tus falls back to a clean creation, which for zero progress is exactly
452
+ * right. Never re-POST one that *is* in progress — that restarts it with
453
+ * a fresh encryption key and abandons everything already sent.
454
+ */
455
+ ...uploadUrl ? { uploadUrl } : {},
456
+ uploadSize: size,
457
+ /**
458
+ * The token authorizes EVERY method, not just the creation POST — the
459
+ * server verifies HEAD, PATCH and DELETE too. Metadata below only reaches
460
+ * the server on the first POST, so it cannot carry the token alone.
461
+ */
462
+ headers: { "x-yungle-upload-token": file.target.uploadToken },
463
+ metadata: {
464
+ fileId: file.target.id,
465
+ token: file.target.uploadToken,
466
+ filename: file.target.name
467
+ },
468
+ retryDelays: [0, 1e3, 3e3, 5e3, 1e4, 3e4],
469
+ /**
470
+ * Persist the upload URL the moment the server hands one over, so a
471
+ * `kill -9` a second later still resumes. Waiting until the upload
472
+ * finishes would mean the state file only ever describes work that no
473
+ * longer needs it.
474
+ *
475
+ * `onUploadUrlAvailable` and `upload.url`, **not** `onAfterResponse` +
476
+ * `req.getURL()`: that returns the URL the request was *sent to*, which
477
+ * for the creation POST is the endpoint itself, not the `Location` the
478
+ * server answered with. The first version did that, and the symptom was
479
+ * an empty resume file with no error anywhere — a killed upload silently
480
+ * restarting from zero, which is the one thing this command exists to
481
+ * prevent.
482
+ */
483
+ onUploadUrlAvailable: () => {
484
+ const location = upload.url;
485
+ if (location && !urls[file.target.id]) {
486
+ urls[file.target.id] = location;
487
+ void saveUploadUrl(file.target.id, location);
488
+ }
489
+ },
490
+ onProgress: (sent, total) => {
491
+ const elapsed = Math.max(1, Date.now() - started) / 1e3;
492
+ onProgress({
493
+ id: file.target.id,
494
+ name: file.target.name,
495
+ sent,
496
+ total: total || size,
497
+ rate: sent / elapsed,
498
+ resumed: Boolean(uploadUrl)
499
+ });
500
+ },
501
+ onSuccess: () => resolve2(),
502
+ onError: (err) => reject(err)
503
+ });
504
+ upload.start();
505
+ });
506
+ }
507
+
508
+ // src/index.ts
509
+ var HELP = `yungle \u2014 send and sync files with Yungle
510
+
511
+ yungle auth login save an API key
512
+ yungle auth status show which key is in use
513
+ yungle send <paths\u2026> send files, print the share link
514
+ --to <email> recipient (repeatable, or comma-separated)
515
+ --message <text> note for the recipients
516
+ --title <text> label for your dashboard
517
+ --password <text> recipients must enter this
518
+ --expires <days> lifetime, clamped to your plan
519
+ yungle push <paths\u2026> --collection <id> upload into a collection
520
+ --folder <id> target folder
521
+ yungle ls transfers|collections|contacts
522
+ yungle rm transfer <id> revoke a transfer
523
+
524
+ Global flags
525
+ --json machine-readable output
526
+ --url <base> override the API base URL
527
+
528
+ Credentials are read from YUNGLE_API_KEY, then ${CONFIG_PATH}.
529
+ Docs: https://yungle.co/developers`;
530
+ async function main(argv) {
531
+ let parsed;
532
+ try {
533
+ parsed = parseArgs(argv);
534
+ } catch (err) {
535
+ process.stderr.write(`${err.message}
536
+ `);
537
+ return 2;
538
+ }
539
+ const { command, positionals, flags } = parsed;
540
+ const json = flags.json === true;
541
+ try {
542
+ switch (command) {
543
+ case "help":
544
+ case "--help":
545
+ case "-h":
546
+ process.stdout.write(`${HELP}
547
+ `);
548
+ return 0;
549
+ case "auth":
550
+ return await auth(positionals, flags, json);
551
+ case "send":
552
+ return await send(positionals, flags, json);
553
+ case "push":
554
+ return await push(positionals, flags, json);
555
+ case "ls":
556
+ return await list(positionals, flags, json);
557
+ case "rm":
558
+ return await remove(positionals, flags, json);
559
+ default:
560
+ process.stderr.write(`Unknown command: ${command}
561
+
562
+ ${HELP}
563
+ `);
564
+ return 2;
565
+ }
566
+ } catch (err) {
567
+ return fail(err, json);
568
+ }
569
+ }
570
+ async function auth(positionals, flags, json) {
571
+ const sub = positionals[0] ?? "status";
572
+ if (sub === "login") {
573
+ let key = stringFlag(flags.key);
574
+ if (!key) {
575
+ process.stderr.write(
576
+ `Create a key at https://yungle.co/dashboard/settings/api
577
+ and paste it here.
578
+
579
+ `
580
+ );
581
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
582
+ key = (await rl.question("API key: ")).trim();
583
+ rl.close();
584
+ }
585
+ if (!key) {
586
+ process.stderr.write("No key given.\n");
587
+ return 2;
588
+ }
589
+ const client2 = new YungleClient({ apiKey: key, baseUrl: stringFlag(flags.url) });
590
+ const me = await client2.me();
591
+ const config = await readConfig();
592
+ await writeConfig({ ...config, apiKey: key, baseUrl: stringFlag(flags.url) ?? config.baseUrl });
593
+ return out(
594
+ json,
595
+ { ok: true, workspace: me.workspace.email, plan: me.plan.name, scopes: me.key.scopes },
596
+ `Signed in as ${me.workspace.email ?? me.workspace.id} (${me.plan.name ?? "free"}).
597
+ Saved to ${CONFIG_PATH}`
598
+ );
599
+ }
600
+ if (sub === "status") {
601
+ const resolved = await resolveKey();
602
+ if (!resolved) {
603
+ return out(json, { authenticated: false }, "Not signed in. Run `yungle auth login`.", 1);
604
+ }
605
+ const me = await client(flags).me();
606
+ return out(
607
+ json,
608
+ { authenticated: true, source: resolved.source, ...me },
609
+ [
610
+ `Workspace: ${me.workspace.email ?? me.workspace.id}`,
611
+ `Plan: ${me.plan.name ?? "free"}`,
612
+ `Storage: ${formatBytes(me.plan.usedBytes)} of ${formatBytes(me.plan.quotaBytes)}`,
613
+ `Key: ${me.key.name} (${me.key.scopes.join(", ")})`,
614
+ `Source: ${resolved.source}`
615
+ ].join("\n")
616
+ );
617
+ }
618
+ process.stderr.write("Usage: yungle auth login | yungle auth status\n");
619
+ return 2;
620
+ }
621
+ async function send(paths, flags, json) {
622
+ if (paths.length === 0) {
623
+ process.stderr.write("Nothing to send. Give me one or more files or folders.\n");
624
+ return 2;
625
+ }
626
+ const api = client(flags);
627
+ const files = await collectFiles(paths);
628
+ if (files.length === 0) {
629
+ process.stderr.write("Those paths contained no files.\n");
630
+ return 2;
631
+ }
632
+ const total = files.reduce((n, f) => n + f.size, 0);
633
+ progress(`Sending ${files.length} file${files.length === 1 ? "" : "s"} \xB7 ${formatBytes(total)}
634
+ `);
635
+ const key = sessionKey(files, "transfer");
636
+ const existing = await findSession(key);
637
+ let created;
638
+ if (existing && existing.kind === "transfer") {
639
+ progress(" resuming the interrupted upload\n");
640
+ created = {
641
+ transfer: { id: existing.targetId },
642
+ tusEndpoint: existing.tusEndpoint,
643
+ files: existing.files
644
+ };
645
+ } else {
646
+ const fresh = await api.createTransfer({
647
+ files: toFileInputs(files),
648
+ title: stringFlag(flags.title),
649
+ expiresInDays: numberFlag(flags.expires, "expires")
650
+ });
651
+ created = { transfer: fresh.transfer, tusEndpoint: fresh.tusEndpoint, files: fresh.files.map((f, i) => ({ ...f, path: files[i].path })) };
652
+ await saveSession(key, {
653
+ createdAt: Date.now(),
654
+ kind: "transfer",
655
+ targetId: fresh.transfer.id,
656
+ tusEndpoint: fresh.tusEndpoint,
657
+ files: created.files
658
+ });
659
+ }
660
+ await streamAll(files, created, api);
661
+ await dropSession(key);
662
+ const recipients = listFlag(flags.to);
663
+ const finalized = await api.finalizeTransfer(created.transfer.id, {
664
+ ...recipients.length ? { recipients } : {},
665
+ ...stringFlag(flags.message) ? { message: stringFlag(flags.message) } : {},
666
+ ...stringFlag(flags.password) ? { password: stringFlag(flags.password) } : {},
667
+ ...stringFlag(flags.title) ? { title: stringFlag(flags.title) } : {}
668
+ });
669
+ return out(
670
+ json,
671
+ {
672
+ id: finalized.transfer.id,
673
+ url: finalized.transfer.url,
674
+ expiresAt: finalized.transfer.expiresAt,
675
+ notified: finalized.notified
676
+ },
677
+ [
678
+ finalized.transfer.url,
679
+ finalized.notified.length ? `Emailed ${finalized.notified.join(", ")}` : "Link only \u2014 nobody was emailed."
680
+ ].join("\n")
681
+ );
682
+ }
683
+ async function push(paths, flags, json) {
684
+ const collectionId = stringFlag(flags.collection);
685
+ if (!collectionId) {
686
+ process.stderr.write("Which collection? Pass --collection <id>.\n");
687
+ return 2;
688
+ }
689
+ if (paths.length === 0) {
690
+ process.stderr.write("Nothing to push.\n");
691
+ return 2;
692
+ }
693
+ const api = client(flags);
694
+ const files = await collectFiles(paths);
695
+ if (files.length === 0) {
696
+ process.stderr.write("Those paths contained no files.\n");
697
+ return 2;
698
+ }
699
+ const total = files.reduce((n, f) => n + f.size, 0);
700
+ progress(`Uploading ${files.length} file${files.length === 1 ? "" : "s"} \xB7 ${formatBytes(total)}
701
+ `);
702
+ const key = sessionKey(files, `collection:${collectionId}:${stringFlag(flags.folder) ?? ""}`);
703
+ const existing = await findSession(key);
704
+ let targets;
705
+ if (existing && existing.kind === "collection") {
706
+ progress(" resuming the interrupted upload\n");
707
+ targets = { tusEndpoint: existing.tusEndpoint, files: existing.files };
708
+ } else {
709
+ const fresh = await api.addCollectionFiles(
710
+ collectionId,
711
+ toFileInputs(files),
712
+ stringFlag(flags.folder) ?? null
713
+ );
714
+ targets = {
715
+ tusEndpoint: fresh.tusEndpoint,
716
+ files: fresh.files.map((f, i) => ({ ...f, path: files[i].path }))
717
+ };
718
+ await saveSession(key, {
719
+ createdAt: Date.now(),
720
+ kind: "collection",
721
+ targetId: collectionId,
722
+ tusEndpoint: fresh.tusEndpoint,
723
+ files: targets.files
724
+ });
725
+ }
726
+ await streamAll(files, targets, api);
727
+ await dropSession(key);
728
+ const collection = await api.getCollection(collectionId);
729
+ return out(
730
+ json,
731
+ { collection: collection.collection.id, uploaded: files.length, url: collection.collection.url },
732
+ `Uploaded ${files.length} file${files.length === 1 ? "" : "s"} to \u201C${collection.collection.title}\u201D.
733
+ ${collection.collection.url ?? ""}`
734
+ );
735
+ }
736
+ async function list(positionals, flags, json) {
737
+ const what = positionals[0] ?? "transfers";
738
+ const api = client(flags);
739
+ if (what === "transfers") {
740
+ const { transfers } = await api.listTransfers();
741
+ return out(
742
+ json,
743
+ { transfers },
744
+ transfers.length === 0 ? "No transfers yet." : transfers.map(
745
+ (t) => `${t.id} ${pad(t.title ?? t.firstFileName ?? "(untitled)", 32)} ${pad(formatBytes(t.sizeBytes), 9)} ${t.downloadCount} dl ${t.url}`
746
+ ).join("\n")
747
+ );
748
+ }
749
+ if (what === "collections") {
750
+ const { collections } = await api.listCollections();
751
+ return out(
752
+ json,
753
+ { collections },
754
+ collections.length === 0 ? "No collections yet." : collections.map(
755
+ (c) => `${c.id} ${pad(c.title, 32)} ${pad(`${c.fileCount} files`, 12)} ${formatBytes(c.sizeBytes)}`
756
+ ).join("\n")
757
+ );
758
+ }
759
+ if (what === "contacts") {
760
+ const { contacts } = await api.listContacts();
761
+ return out(
762
+ json,
763
+ { contacts },
764
+ contacts.length === 0 ? "No contacts yet." : contacts.map((c) => `${c.id} ${pad([c.firstName, c.lastName].filter(Boolean).join(" ") || "\u2014", 24)} ${c.email}`).join("\n")
765
+ );
766
+ }
767
+ process.stderr.write("Usage: yungle ls transfers|collections|contacts\n");
768
+ return 2;
769
+ }
770
+ async function remove(positionals, flags, json) {
771
+ const [what, id] = positionals;
772
+ if (what !== "transfer" || !id) {
773
+ process.stderr.write("Usage: yungle rm transfer <id>\n");
774
+ return 2;
775
+ }
776
+ const { transfer } = await client(flags).revokeTransfer(id);
777
+ return out(
778
+ json,
779
+ { id: transfer.id, status: transfer.status },
780
+ `Revoked. The link no longer works and the content is unrecoverable.`
781
+ );
782
+ }
783
+ function client(flags) {
784
+ const apiKey = process.env.YUNGLE_API_KEY?.trim() ?? cachedKey;
785
+ if (!apiKey) throw new NotSignedIn();
786
+ return new YungleClient({ apiKey, baseUrl: stringFlag(flags.url) ?? cachedBaseUrl });
787
+ }
788
+ var NotSignedIn = class extends Error {
789
+ constructor() {
790
+ super("Not signed in. Run `yungle auth login`, or set YUNGLE_API_KEY.");
791
+ this.name = "NotSignedIn";
792
+ }
793
+ };
794
+ var cachedKey;
795
+ var cachedBaseUrl;
796
+ async function loadCredentials() {
797
+ const resolved = await resolveKey();
798
+ cachedKey = resolved?.apiKey;
799
+ cachedBaseUrl = resolved?.baseUrl;
800
+ }
801
+ async function streamAll(files, targets, _api) {
802
+ const paired = pairWithTargets(files, targets.files);
803
+ const totalBytes = paired.reduce((n, { file }) => n + file.size, 0);
804
+ const sentPer = /* @__PURE__ */ new Map();
805
+ const finished = /* @__PURE__ */ new Set();
806
+ const started = Date.now();
807
+ let anyResumed = false;
808
+ const render = () => {
809
+ let sent = 0;
810
+ for (const value of sentPer.values()) sent += value;
811
+ const pct = totalBytes ? Math.floor(sent / totalBytes * 100) : 100;
812
+ const rate = sent / Math.max(1, (Date.now() - started) / 1e3);
813
+ progress(
814
+ `\r ${String(pct).padStart(3)}% ${pad(`${formatBytes(sent)} of ${formatBytes(totalBytes)}`, 22)}${pad(`${formatBytes(rate)}/s`, 12)}${finished.size}/${paired.length} files${anyResumed ? " (resumed)" : ""} `
815
+ );
816
+ };
817
+ await uploadAll(
818
+ paired.map(({ file, target }) => ({ path: file.path, target })),
819
+ targets.tusEndpoint,
820
+ (p) => {
821
+ if (p.resumed) anyResumed = true;
822
+ sentPer.set(p.id, p.sent);
823
+ if (p.sent >= p.total) finished.add(p.id);
824
+ render();
825
+ }
826
+ );
827
+ render();
828
+ progress("\n");
829
+ }
830
+ function progress(text) {
831
+ if (process.stderr.isTTY) process.stderr.write(text);
832
+ }
833
+ function pad(text, width) {
834
+ return text.length > width ? `${text.slice(0, width - 1)}\u2026` : text.padEnd(width);
835
+ }
836
+ function out(json, data, human, code = 0) {
837
+ process.stdout.write(json ? `${JSON.stringify(data, null, 2)}
838
+ ` : `${human}
839
+ `);
840
+ return code;
841
+ }
842
+ function fail(err, json) {
843
+ if (err instanceof YungleApiError) {
844
+ if (json) {
845
+ process.stdout.write(`${JSON.stringify({ error: { code: err.code, message: err.message, details: err.details } }, null, 2)}
846
+ `);
847
+ } else {
848
+ process.stderr.write(`${err.message}
849
+ `);
850
+ if (err.code === "upgrade_required") {
851
+ process.stderr.write("See https://yungle.co/dashboard/settings/plan\n");
852
+ }
853
+ }
854
+ return 1;
855
+ }
856
+ const message = err instanceof Error ? err.message : String(err);
857
+ if (json) process.stdout.write(`${JSON.stringify({ error: { code: "cli_error", message } }, null, 2)}
858
+ `);
859
+ else process.stderr.write(`${message}
860
+ `);
861
+ return 1;
862
+ }
863
+ export {
864
+ loadCredentials,
865
+ main
866
+ };
867
+ await loadCredentials();
868
+ process.exit(await main(process.argv.slice(2)));
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "yungle-cli",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Send and sync files with Yungle from the command line.",
6
+ "bin": {
7
+ "yungle": "./dist/yungle.mjs"
8
+ },
9
+ "exports": {
10
+ ".": "./src/index.ts"
11
+ },
12
+ "scripts": {
13
+ "build": "node build.mjs",
14
+ "typecheck": "tsc --noEmit",
15
+ "test": "node --import tsx --test src/*.test.ts",
16
+ "lint": "eslint .",
17
+ "dev": "node --import tsx bin/yungle.mjs",
18
+ "prepublishOnly": "node build.mjs"
19
+ },
20
+ "dependencies": {
21
+ "tus-js-client": "^4.3.1"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^22.10.5",
25
+ "esbuild": "^0.28.1",
26
+ "tsx": "^4.19.2",
27
+ "typescript": "^5.7.3",
28
+ "@yungle/api-client": "workspace:*"
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "keywords": [
36
+ "yungle",
37
+ "file-transfer",
38
+ "cli",
39
+ "upload",
40
+ "tus",
41
+ "resumable"
42
+ ],
43
+ "license": "MIT",
44
+ "author": "Hein de Wilde",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/heindewilde/yungle.git"
48
+ },
49
+ "homepage": "https://yungle.co/developers",
50
+ "bugs": {
51
+ "url": "https://github.com/heindewilde/yungle/issues"
52
+ },
53
+ "engines": {
54
+ "node": ">=22"
55
+ }
56
+ }