yungle-cli 0.2.0 → 0.3.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 +8 -5
  2. package/dist/yungle.mjs +774 -200
  3. package/package.json +2 -2
package/dist/yungle.mjs CHANGED
@@ -161,25 +161,213 @@ var require_src = __commonJS({
161
161
  import { isAbsolute, join as join6, relative as relative2, resolve as resolve3 } from "node:path";
162
162
 
163
163
  // ../../packages/api-client/dist/index.mjs
164
+ var MIN_RENEW_DELAY_MS = 6e4;
165
+ var RETRY_DELAY_MS = 6e4;
166
+ function tokenExpiry(token) {
167
+ const dot = token.indexOf(".");
168
+ if (dot <= 0) return null;
169
+ try {
170
+ const b642 = token.slice(0, dot).replace(/-/g, "+").replace(/_/g, "/");
171
+ const json = atob(b642);
172
+ const exp = JSON.parse(json).exp;
173
+ return typeof exp === "number" ? exp : null;
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+ function renewDelayMs(expiresAt, now) {
179
+ return Math.max(MIN_RENEW_DELAY_MS, Math.floor((expiresAt - now) / 2));
180
+ }
181
+ function createTokenKeeper(initial, deps) {
182
+ const now = deps.now ?? (() => Date.now());
183
+ const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
184
+ const clearTimer = deps.clearTimer ?? ((h2) => clearTimeout(h2));
185
+ let token = initial;
186
+ let expiresAt = tokenExpiry(initial);
187
+ let stopped = false;
188
+ let handle = null;
189
+ const schedule = (ms) => {
190
+ if (stopped) return;
191
+ handle = setTimer(() => void attempt(), ms);
192
+ };
193
+ const attempt = async () => {
194
+ if (stopped) return;
195
+ let outcome;
196
+ try {
197
+ outcome = await deps.renew(token);
198
+ } catch {
199
+ outcome = "retry";
200
+ }
201
+ if (stopped) return;
202
+ if (outcome === "final") {
203
+ stopped = true;
204
+ return;
205
+ }
206
+ if (outcome === "retry") {
207
+ if (expiresAt !== null && now() >= expiresAt) {
208
+ stopped = true;
209
+ return;
210
+ }
211
+ schedule(RETRY_DELAY_MS);
212
+ return;
213
+ }
214
+ token = outcome.token;
215
+ expiresAt = outcome.expiresAt;
216
+ schedule(renewDelayMs(expiresAt, now()));
217
+ };
218
+ schedule(expiresAt === null ? 60 * 6e4 : renewDelayMs(expiresAt, now()));
219
+ return {
220
+ current: () => token,
221
+ stop: () => {
222
+ stopped = true;
223
+ if (handle !== null) clearTimer(handle);
224
+ }
225
+ };
226
+ }
227
+ async function renewUploadToken(origin, current, doFetch = globalThis.fetch.bind(globalThis)) {
228
+ const res = await doFetch(`${origin.replace(/\/+$/, "")}/api/uploads/token`, {
229
+ method: "POST",
230
+ headers: { "x-yungle-upload-token": current }
231
+ });
232
+ if (res.status === 401 || res.status === 404) return "final";
233
+ if (!res.ok) return "retry";
234
+ const body = await res.json();
235
+ return { token: body.uploadToken, expiresAt: Date.parse(body.expiresAt) };
236
+ }
237
+ var MiB = 1024 * 1024;
238
+ function chunkSize(size) {
239
+ return Math.max(64 * MiB, (Math.floor(size / (9e3 * MiB)) + 2) * MiB);
240
+ }
241
+ var UploadError = class extends Error {
242
+ constructor(message, status2) {
243
+ super(message);
244
+ this.status = status2;
245
+ this.name = "UploadError";
246
+ }
247
+ status;
248
+ };
249
+ function transient(status2) {
250
+ return status2 === 0 || status2 === 408 || status2 === 409 || status2 === 423 || status2 === 429 || status2 >= 500;
251
+ }
252
+ var b64 = (s) => {
253
+ const bytes = new TextEncoder().encode(s);
254
+ let bin = "";
255
+ for (const b of bytes) bin += String.fromCharCode(b);
256
+ return btoa(bin);
257
+ };
258
+ async function uploadFile(o2) {
259
+ const doFetch = o2.fetch ?? globalThis.fetch.bind(globalThis);
260
+ const size = o2.source.size;
261
+ const delays = o2.retryDelaysMs ?? [0, 1e3, 3e3, 5e3, 1e4, 3e4];
262
+ const keeper = o2.renew ? createTokenKeeper(o2.target.uploadToken, {
263
+ renew: async (t2) => {
264
+ const outcome = await o2.renew(t2);
265
+ if (typeof outcome === "object") o2.onToken?.(outcome.token);
266
+ return outcome;
267
+ }
268
+ }) : null;
269
+ const auth = () => ({ "Tus-Resumable": "1.0.0", "x-yungle-upload-token": keeper?.current() ?? o2.target.uploadToken });
270
+ const request = async (url, init) => {
271
+ let res;
272
+ try {
273
+ res = await doFetch(url, { ...init, signal: o2.signal });
274
+ } catch (err) {
275
+ if (o2.signal?.aborted) throw err;
276
+ throw new UploadError(`Connection failed: ${err.message}`, 0);
277
+ }
278
+ if (!res.ok) throw new UploadError(`Upload of ${o2.target.name} got HTTP ${res.status}.`, res.status);
279
+ return res;
280
+ };
281
+ const head = async (url) => Number((await request(url, { method: "HEAD", headers: auth() })).headers.get("upload-offset") ?? 0);
282
+ try {
283
+ let url = o2.uploadUrl;
284
+ let offset = 0;
285
+ if (url) {
286
+ try {
287
+ offset = await head(url);
288
+ } catch (err) {
289
+ if (!(err instanceof UploadError && err.status === 404)) throw err;
290
+ url = void 0;
291
+ }
292
+ }
293
+ if (!url) {
294
+ const created = await request(o2.endpoint, {
295
+ method: "POST",
296
+ headers: {
297
+ ...auth(),
298
+ "Upload-Length": String(size),
299
+ "Upload-Metadata": `fileId ${b64(o2.target.id)},token ${b64(o2.target.uploadToken)},filename ${b64(o2.target.name)}`
300
+ }
301
+ });
302
+ url = new URL(created.headers.get("location") ?? "", o2.endpoint).toString();
303
+ o2.onUploadUrl?.(url);
304
+ }
305
+ o2.onProgress?.(offset, size);
306
+ const step = chunkSize(size);
307
+ let failures = 0;
308
+ let stalls = 0;
309
+ while (offset < size) {
310
+ try {
311
+ const res = await request(url, {
312
+ method: "PATCH",
313
+ headers: { ...auth(), "Upload-Offset": String(offset), "Content-Type": "application/offset+octet-stream" },
314
+ body: o2.source.slice(offset, Math.min(size, offset + step))
315
+ });
316
+ const next2 = Number(res.headers.get("upload-offset"));
317
+ if (!(next2 > offset)) {
318
+ if (++stalls > 3) throw new UploadError(`Upload of ${o2.target.name} made no progress.`, res.status);
319
+ } else stalls = 0;
320
+ offset = next2;
321
+ failures = 0;
322
+ o2.onProgress?.(offset, size);
323
+ } catch (err) {
324
+ if (o2.signal?.aborted) throw err;
325
+ if (!(err instanceof UploadError) || !transient(err.status) || failures >= delays.length) throw err;
326
+ await new Promise((r2) => setTimeout(r2, delays[failures++]));
327
+ try {
328
+ offset = await head(url);
329
+ } catch (headErr) {
330
+ if (!(headErr instanceof UploadError) || !transient(headErr.status)) throw headErr;
331
+ }
332
+ }
333
+ }
334
+ return { uploadUrl: url };
335
+ } finally {
336
+ keeper?.stop();
337
+ }
338
+ }
164
339
  var YungleApiError = class extends Error {
165
- constructor(status2, code, message, details) {
340
+ constructor(status2, code, message, details, extras = {}) {
166
341
  super(message);
167
342
  this.status = status2;
168
343
  this.code = code;
169
344
  this.details = details;
170
345
  this.name = "YungleApiError";
346
+ if (extras.docs !== void 0) this.docs = extras.docs;
347
+ if (extras.retryAfterHeader !== void 0) this.retryAfterHeader = extras.retryAfterHeader;
171
348
  }
172
349
  status;
173
350
  code;
174
351
  details;
352
+ /** Where `code` is explained, with what to do about it. Absent on older servers. */
353
+ docs;
354
+ retryAfterHeader;
175
355
  /** Worth trying again: throttled, or our fault. */
176
356
  get retryable() {
177
357
  return this.status === 429 || this.status >= 500;
178
358
  }
179
- /** Seconds to wait, when the server said. */
359
+ /**
360
+ * Seconds to wait, when the server said — in the body's
361
+ * `details.retryAfterSeconds` or, failing that, the `Retry-After` header.
362
+ *
363
+ * The header matters on its own: the monthly-allowance 429 states its wait
364
+ * (an hour) only there, and reading the body alone sent clients back in half
365
+ * a second to be refused again.
366
+ */
180
367
  get retryAfterSeconds() {
181
368
  const value = this.details?.retryAfterSeconds;
182
- return typeof value === "number" ? value : null;
369
+ if (typeof value === "number") return value;
370
+ return this.retryAfterHeader ?? null;
183
371
  }
184
372
  };
185
373
  var DEFAULT_BASE = "https://yungle.co/api/v1";
@@ -190,7 +378,7 @@ function pageQuery(page, extra = {}) {
190
378
  const q = params.toString();
191
379
  return q ? `?${q}` : "";
192
380
  }
193
- var CLIENT_VERSION = true ? "0.2.0" : "dev";
381
+ var CLIENT_VERSION = true ? "0.3.0" : "dev";
194
382
  var YungleClient = class {
195
383
  baseUrl;
196
384
  apiKey;
@@ -205,6 +393,27 @@ var YungleClient = class {
205
393
  this.maxRetries = options.maxRetries ?? 3;
206
394
  this.userAgent = options.userAgent ?? `yungle-client/${CLIENT_VERSION}`;
207
395
  }
396
+ /** The site root this client talks to (`https://yungle.co`), for the few endpoints outside `/api/v1`. */
397
+ get origin() {
398
+ return this.baseUrl.replace(/\/api\/v1$/, "");
399
+ }
400
+ /**
401
+ * Trade a still-valid upload token for a fresh one (two more hours). Returns
402
+ * `'final'` when the token is dead or the upload finished, `'retry'` on a
403
+ * transient failure. `createTokenKeeper(token, { renew: (t) => client.renewUploadToken(t) })`
404
+ * schedules this for you.
405
+ */
406
+ renewUploadToken(token) {
407
+ return renewUploadToken(this.origin, token, this.doFetch);
408
+ }
409
+ /**
410
+ * Upload one file to a target from `createTransfer` / `addTransferFiles` /
411
+ * `addCollectionFiles`: resumable, retried, and with its token renewed for
412
+ * as long as it runs. In Node, pass `await fs.openAsBlob(path)` as `source`.
413
+ */
414
+ uploadFile(tusEndpoint, target, source, opts = {}) {
415
+ return uploadFile({ ...opts, endpoint: tusEndpoint, target, source, renew: (t2) => this.renewUploadToken(t2), fetch: this.doFetch });
416
+ }
208
417
  // ── Account ───────────────────────────────────────────────────────────────
209
418
  me() {
210
419
  return this.request("GET", "/me");
@@ -230,6 +439,10 @@ var YungleClient = class {
230
439
  /**
231
440
  * Create a draft. Nothing is live and nobody is emailed until `finalizeTransfer`.
232
441
  */
442
+ /**
443
+ * A draft transfer. `files` are uploaded by you (tus, to the returned
444
+ * targets); `imports` are fetched by Yungle from URLs. Send either or both.
445
+ */
233
446
  createTransfer(input) {
234
447
  return this.request("POST", "/transfers", input);
235
448
  }
@@ -245,8 +458,32 @@ var YungleClient = class {
245
458
  finalizeTransfer(id, input = {}) {
246
459
  return this.request("POST", `/transfers/${enc(id)}/finalize`, input, { idempotent: true });
247
460
  }
248
- addTransferFiles(id, files) {
249
- return this.request("POST", `/transfers/${enc(id)}/files`, { files });
461
+ /**
462
+ * End-to-end encrypted transfers only: a file's sealed `{ name, path, size,
463
+ * type }` and/or thumbnail, from `yungle-e2e`. Called after the file exists,
464
+ * because each blob binds the file's id.
465
+ */
466
+ sealTransferFile(id, fileId, sealed) {
467
+ return this.request("POST", `/transfers/${enc(id)}/files/${enc(fileId)}/sealed`, sealed, { idempotent: true });
468
+ }
469
+ addTransferFiles(id, files, imports) {
470
+ return this.request("POST", `/transfers/${enc(id)}/files`, { files, ...imports?.length ? { imports } : {} });
471
+ }
472
+ /** Where a URL import stands: `importing` (with `receivedBytes`), `ready` or `failed`. */
473
+ getImport(fileId) {
474
+ return this.request("GET", `/imports/${enc(fileId)}`);
475
+ }
476
+ /**
477
+ * Poll `getImport` until every import is `ready` or `failed`, every
478
+ * `intervalMs` (default 5 s). Resolves with the final statuses; never throws
479
+ * for a failed import — check `status`.
480
+ */
481
+ async waitForImports(fileIds, intervalMs = 5e3) {
482
+ for (; ; ) {
483
+ const all = await Promise.all(fileIds.map((id) => this.getImport(id)));
484
+ if (all.every((s) => s.status !== "importing")) return all;
485
+ await sleep(intervalMs);
486
+ }
250
487
  }
251
488
  removeTransferFile(id, fileId) {
252
489
  return this.request("DELETE", `/transfers/${enc(id)}/files/${enc(fileId)}`);
@@ -258,6 +495,22 @@ var YungleClient = class {
258
495
  revokeTransfer(id) {
259
496
  return this.request("DELETE", `/transfers/${enc(id)}`);
260
497
  }
498
+ /**
499
+ * Signed download URLs for your own transfer. Fetching them is not a
500
+ * recipient download — it never appears in receipts. Throws
501
+ * `e2ee_unsupported` for an end-to-end encrypted transfer.
502
+ */
503
+ transferDownloadLinks(id) {
504
+ return this.request("GET", `/transfers/${enc(id)}/download-links`);
505
+ }
506
+ /**
507
+ * A link someone shared with you (`…/t/…` or `…/c/…`), as signed download
508
+ * URLs — what a person opening it in a browser could download, under the same
509
+ * rules. Pass `password` for a protected link. Any key works, free included.
510
+ */
511
+ resolveLink(url, password2) {
512
+ return this.request("POST", "/links/resolve", { url, ...password2 ? { password: password2 } : {} });
513
+ }
261
514
  transferDownloads(id) {
262
515
  return this.request("GET", `/transfers/${enc(id)}/downloads`);
263
516
  }
@@ -268,6 +521,13 @@ var YungleClient = class {
268
521
  createCollection(input) {
269
522
  return this.request("POST", "/collections", input);
270
523
  }
524
+ /**
525
+ * Signed download URLs for your own collection: every file with its folder
526
+ * `path`, and a whole-collection ZIP. The URLs need no key and support `Range`.
527
+ */
528
+ collectionDownloadLinks(id) {
529
+ return this.request("GET", `/collections/${enc(id)}/download-links`);
530
+ }
271
531
  getCollection(id) {
272
532
  return this.request("GET", `/collections/${enc(id)}`);
273
533
  }
@@ -295,8 +555,8 @@ var YungleClient = class {
295
555
  cursor3 = page.nextCursor ?? void 0;
296
556
  } while (cursor3);
297
557
  }
298
- addCollectionFiles(id, files, folderId) {
299
- return this.request("POST", `/collections/${enc(id)}/files`, { files, folderId });
558
+ addCollectionFiles(id, files, folderId, imports) {
559
+ return this.request("POST", `/collections/${enc(id)}/files`, { files, folderId, ...imports?.length ? { imports } : {} });
300
560
  }
301
561
  deleteCollectionFiles(id, fileIds) {
302
562
  return this.request("DELETE", `/collections/${enc(id)}/files`, { fileIds });
@@ -352,6 +612,26 @@ var YungleClient = class {
352
612
  listWebhookEvents(id, page = {}) {
353
613
  return this.request("GET", `/webhooks/${enc(id)}/events${pageQuery(page)}`);
354
614
  }
615
+ // ── Upload requests ───────────────────────────────────────────────────────
616
+ /** Your upload links, newest first (up to 100). */
617
+ listRequests() {
618
+ return this.request("GET", "/requests");
619
+ }
620
+ /**
621
+ * A public page where anyone with the link can upload into one of your
622
+ * collections. Subscribe to the `request.submitted` webhook to hear about
623
+ * each finished submission.
624
+ */
625
+ createRequest(input) {
626
+ return this.request("POST", "/requests", input);
627
+ }
628
+ getRequest(id) {
629
+ return this.request("GET", `/requests/${enc(id)}`);
630
+ }
631
+ /** `paused` stops uploads until set back to `active`; `closed` stops them for good. */
632
+ setRequestStatus(id, status2) {
633
+ return this.request("PATCH", `/requests/${enc(id)}`, { status: status2 });
634
+ }
355
635
  // ── Contacts ──────────────────────────────────────────────────────────────
356
636
  listContacts() {
357
637
  return this.request("GET", "/contacts");
@@ -412,13 +692,27 @@ async function toError(res) {
412
692
  } catch {
413
693
  }
414
694
  const err = body?.error;
695
+ const retryAfterHeader = parseRetryAfter(res.headers.get("retry-after"));
415
696
  return new YungleApiError(
416
697
  res.status,
417
698
  err?.code ?? "http_error",
418
699
  err?.message ?? `Request failed with status ${res.status}.`,
419
- err?.details
700
+ err?.details,
701
+ {
702
+ ...typeof err?.docs === "string" ? { docs: err.docs } : {},
703
+ ...retryAfterHeader === null ? {} : { retryAfterHeader }
704
+ }
420
705
  );
421
706
  }
707
+ function parseRetryAfter(value, now = Date.now()) {
708
+ if (value === null) return null;
709
+ const trimmed = value.trim();
710
+ if (/^\d+$/.test(trimmed)) return Number(trimmed);
711
+ const at = Date.parse(trimmed);
712
+ if (Number.isNaN(at)) return null;
713
+ const seconds = Math.ceil((at - now) / 1e3);
714
+ return seconds >= 0 ? seconds : null;
715
+ }
422
716
  function backoffMs(attempt, error) {
423
717
  const stated = error.retryAfterSeconds;
424
718
  if (stated !== null) return stated * 1e3;
@@ -436,6 +730,7 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
436
730
  "password",
437
731
  "expires",
438
732
  "collection",
733
+ "transfer",
439
734
  "folder",
440
735
  "out",
441
736
  "url",
@@ -447,8 +742,11 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
447
742
  // `helpFlagsAreParsed` in args.test.ts now derives this from HELP.
448
743
  "client",
449
744
  "forward-to",
450
- "interval"
745
+ "interval",
746
+ "from-url",
747
+ "target"
451
748
  ]);
749
+ var REPEATABLE = /* @__PURE__ */ new Set(["to", "from-url"]);
452
750
  var OPTIONAL_VALUE = /* @__PURE__ */ new Set(["key"]);
453
751
  function parseArgs(argv) {
454
752
  const [command = "help", ...rest] = argv;
@@ -464,7 +762,10 @@ function parseArgs(argv) {
464
762
  const body = token.slice(2);
465
763
  const eq = body.indexOf("=");
466
764
  if (eq >= 0) {
467
- flags[body.slice(0, eq)] = body.slice(eq + 1);
765
+ const name = body.slice(0, eq);
766
+ const prior = flags[name];
767
+ flags[name] = REPEATABLE.has(name) && typeof prior === "string" ? `${prior}
768
+ ${body.slice(eq + 1)}` : body.slice(eq + 1);
468
769
  continue;
469
770
  }
470
771
  if (VALUE_FLAGS.has(body)) {
@@ -476,7 +777,9 @@ function parseArgs(argv) {
476
777
  }
477
778
  throw new Error(`--${body} needs a value.`);
478
779
  }
479
- flags[body] = value;
780
+ const prior = flags[body];
781
+ flags[body] = REPEATABLE.has(body) && typeof prior === "string" ? `${prior}
782
+ ${value}` : value;
480
783
  i2++;
481
784
  continue;
482
785
  }
@@ -489,7 +792,11 @@ function parseArgs(argv) {
489
792
  }
490
793
  function listFlag(value) {
491
794
  if (typeof value !== "string") return [];
492
- return value.split(",").map((s) => s.trim()).filter(Boolean);
795
+ return value.split(/[\n,]/).map((s) => s.trim()).filter(Boolean);
796
+ }
797
+ function urlListFlag(value) {
798
+ if (typeof value !== "string") return [];
799
+ return value.split("\n").map((s) => s.trim()).filter(Boolean);
493
800
  }
494
801
  function numberFlag(value, name) {
495
802
  if (value === void 0 || typeof value === "boolean") return void 0;
@@ -701,26 +1008,37 @@ Get started
701
1008
 
702
1009
  Send and receive
703
1010
  yungle send <paths\u2026> send files or folders, print the link
1011
+ --from-url <url> also send a file Yungle fetches from a URL (repeatable)
704
1012
  --to <email> recipient (repeatable, or comma-separated)
705
1013
  --message <text> note for the recipients
706
1014
  --title <text> label for your dashboard
707
1015
  --password <text> recipients must enter this
708
1016
  --expires <days> lifetime, clamped to your plan
709
- yungle get <link> download a transfer sent to you
1017
+ yungle get <link> download a transfer or collection shared with you
710
1018
  --out <dir> where to save (default: current directory)
711
1019
  --zip one zip instead of separate files
712
1020
  --password <text> for a protected link; asks if left out
713
- yungle status [<id|link>] who opened and downloaded a transfer
1021
+ yungle put <file> --target <target> upload one file to a target an assistant prepared
1022
+ yungle status [<id|link>] who downloaded a transfer
714
1023
  yungle transfers list your recent transfers
715
1024
  yungle revoke <id> stop a transfer's link for good
716
1025
 
717
1026
  Collections
718
1027
  yungle push <paths\u2026> --collection <id> upload into a collection
719
1028
  --folder <id> target folder
1029
+ --from-url <url> also add a file Yungle fetches from a URL (repeatable)
1030
+ yungle pull --collection <id> download your collection, folders kept
1031
+ --transfer <id> download your own transfer instead
1032
+ --out <dir> where to save (default: the collection's title)
1033
+ --zip one zip instead of separate files
720
1034
  yungle watch <dir> --collection <id> keep uploading new files as they appear
721
1035
  --interval <seconds> how often to look (default 10)
722
1036
  --existing also upload what is already in the folder
723
1037
  yungle collections list your collections
1038
+ yungle requests list upload links that feed your collections
1039
+ yungle requests new --collection <id> make one, and print the link to give people
1040
+ --title <text> shown on the upload page
1041
+ yungle requests pause|resume|close <id> stop or restart uploads through one
724
1042
  yungle contacts list your contacts
725
1043
 
726
1044
  In the browser
@@ -750,12 +1068,15 @@ var COMMANDS = [
750
1068
  "logout",
751
1069
  "send",
752
1070
  "get",
1071
+ "put",
753
1072
  "status",
754
1073
  "transfers",
755
1074
  "revoke",
756
1075
  "push",
1076
+ "pull",
757
1077
  "watch",
758
1078
  "collections",
1079
+ "requests",
759
1080
  "contacts",
760
1081
  "open",
761
1082
  "webhooks",
@@ -785,11 +1106,13 @@ function resolveAlias(argv) {
785
1106
  var EXAMPLES = {
786
1107
  login: ["yungle login", "yungle login --key # on a server: paste a key from Settings \u2192 API keys"],
787
1108
  send: [
1109
+ "yungle send --from-url https://example-bucket.s3.eu-central-1.amazonaws.com/render.mov?X-Amz-Signature=\u2026 --to client@example.com",
788
1110
  'yungle send ~/Shoot --to anna@studio.nl --message "Final selects"',
789
1111
  "yungle send report.pdf # just a link, nobody emailed",
790
1112
  "yungle send dist/ --json | jq -r .url # in a script"
791
1113
  ],
792
1114
  get: ["yungle get https://yungle.co/t/emerald-palm-a5mt", "yungle get <link> --zip --out ~/Downloads"],
1115
+ pull: ["yungle pull --collection 01JABC\u2026 --out ./wedding", "yungle pull --transfer 01JABD\u2026"],
793
1116
  status: ["yungle status # pick from your recent transfers", "yungle status https://yungle.co/t/emerald-palm-a5mt"],
794
1117
  push: ["yungle push ~/Shoot --collection 01J8Z3M9Q0W4", "yungle push raw/ --collection 01J8Z3M9Q0W4 --folder 01J8Z4AB"],
795
1118
  watch: ["yungle watch ~/Renders --collection 01J8Z3M9Q0W4", "yungle watch /Volumes/CARD --collection 01J8Z3M9Q0W4 --interval 30"],
@@ -960,7 +1283,7 @@ async function readSessions() {
960
1283
  const now = Date.now();
961
1284
  const live = {};
962
1285
  for (const [key, session] of Object.entries(parsed)) {
963
- if (session && now - session.createdAt < SESSION_TTL_MS) live[key] = session;
1286
+ if (session && now - (session.touchedAt ?? session.createdAt) < SESSION_TTL_MS) live[key] = session;
964
1287
  }
965
1288
  return live;
966
1289
  } catch {
@@ -976,6 +1299,16 @@ async function saveSession(key, session) {
976
1299
  await mkdir(dirname(RESUME_PATH), { recursive: true, mode: 448 });
977
1300
  await writeFile(RESUME_PATH, JSON.stringify(sessions), { mode: 384 });
978
1301
  }
1302
+ async function saveRenewedToken(key, fileId, uploadToken) {
1303
+ const sessions = await readSessions();
1304
+ const session = sessions[key];
1305
+ const file = session?.files.find((f) => f.id === fileId);
1306
+ if (!session || !file) return;
1307
+ file.uploadToken = uploadToken;
1308
+ session.touchedAt = Date.now();
1309
+ await mkdir(dirname(RESUME_PATH), { recursive: true, mode: 448 });
1310
+ await writeFile(RESUME_PATH, JSON.stringify(sessions), { mode: 384 });
1311
+ }
979
1312
  async function dropSession(key) {
980
1313
  const sessions = await readSessions();
981
1314
  if (!(key in sessions)) return;
@@ -1010,6 +1343,123 @@ async function clearUploadUrls(fileIds) {
1010
1343
  if (changed) await writeFile(URL_PATH, JSON.stringify(current), { mode: 384 });
1011
1344
  }
1012
1345
 
1346
+ // src/get.ts
1347
+ import { createReadStream, createWriteStream } from "node:fs";
1348
+ import { mkdir as mkdir2, rename, stat as stat2 } from "node:fs/promises";
1349
+ import { basename as basename2, join as join3 } from "node:path";
1350
+ import { Readable } from "node:stream";
1351
+ import { pipeline } from "node:stream/promises";
1352
+ var SLUG = /^[A-Za-z0-9_-]{8,128}$/;
1353
+ function parseTransferLink(input, defaultOrigin) {
1354
+ const text2 = input.trim();
1355
+ if (SLUG.test(text2)) return { origin: defaultOrigin, kind: "t", slug: text2, recipient: null, hasKey: false };
1356
+ let url;
1357
+ try {
1358
+ url = new URL(text2);
1359
+ } catch {
1360
+ throw new Error(`Not a Yungle link: ${text2}`);
1361
+ }
1362
+ const m = url.pathname.match(/^\/(t|c)\/([^/]+)\/?$/);
1363
+ if (!m || !SLUG.test(m[2])) throw new Error(`Not a Yungle link: ${text2}`);
1364
+ return {
1365
+ origin: url.origin,
1366
+ kind: m[1],
1367
+ slug: m[2],
1368
+ recipient: m[1] === "t" ? url.searchParams.get("r") : null,
1369
+ hasKey: url.hash.length > 1
1370
+ };
1371
+ }
1372
+ function safeRelativeDir(path) {
1373
+ if (!path) return "";
1374
+ return path.replace(/\\/g, "/").split("/").filter((seg) => seg && seg !== "." && seg !== "..").map(safeFileName).join("/");
1375
+ }
1376
+ function dedupePaths(entries) {
1377
+ const byDir = /* @__PURE__ */ new Map();
1378
+ entries.forEach((e2, i2) => byDir.set(e2.dir.toLowerCase(), [...byDir.get(e2.dir.toLowerCase()) ?? [], i2]));
1379
+ const out2 = new Array(entries.length);
1380
+ for (const idx of byDir.values()) {
1381
+ const names = dedupeNames(idx.map((i2) => entries[i2].name));
1382
+ idx.forEach((i2, k) => {
1383
+ out2[i2] = entries[i2].dir ? `${entries[i2].dir}/${names[k]}` : names[k];
1384
+ });
1385
+ }
1386
+ return out2;
1387
+ }
1388
+ function safeFileName(name) {
1389
+ const last = basename2(name.replace(/\\/g, "/"));
1390
+ const cleaned = last.replace(/[\x00-\x1f<>:"|?*]/g, "_").replace(/^\.+$/, "_").trim();
1391
+ return cleaned || "file";
1392
+ }
1393
+ function dedupeNames(names) {
1394
+ const used = /* @__PURE__ */ new Set();
1395
+ return names.map((name) => {
1396
+ let candidate = name;
1397
+ const dot = name.lastIndexOf(".");
1398
+ const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""];
1399
+ for (let n3 = 2; used.has(candidate.toLowerCase()); n3++) candidate = `${stem} (${n3})${ext}`;
1400
+ used.add(candidate.toLowerCase());
1401
+ return candidate;
1402
+ });
1403
+ }
1404
+ function resumeFrom(partialBytes, expectedSize) {
1405
+ if (partialBytes <= 0 || partialBytes >= expectedSize) return null;
1406
+ return partialBytes;
1407
+ }
1408
+ var ManifestError = class extends Error {
1409
+ constructor(code, message) {
1410
+ super(message);
1411
+ this.code = code;
1412
+ this.name = "ManifestError";
1413
+ }
1414
+ code;
1415
+ };
1416
+ async function fetchManifest(link, password2, userAgent) {
1417
+ const res = await fetch(`${link.origin}/api/${link.kind}/${encodeURIComponent(link.slug)}/manifest`, {
1418
+ method: "POST",
1419
+ headers: { "Content-Type": "application/json", "User-Agent": userAgent },
1420
+ body: JSON.stringify({ ...password2 ? { password: password2 } : {}, ...link.recipient ? { r: link.recipient } : {} })
1421
+ });
1422
+ const body = await res.json().catch(() => null);
1423
+ if (!res.ok || !body || body.error) {
1424
+ const code = body?.error?.code ?? `http_${res.status}`;
1425
+ const message = code === "not_found" ? `No such ${link.kind === "c" ? "collection" : "transfer"}. Check the link, or it may have been deleted.` : body?.error?.message ?? `The server answered ${res.status}.`;
1426
+ throw new ManifestError(code, message);
1427
+ }
1428
+ return body;
1429
+ }
1430
+ async function fileCrc32(path) {
1431
+ const zlib = await import("node:zlib");
1432
+ const crc32 = zlib.crc32;
1433
+ if (typeof crc32 !== "function") return null;
1434
+ let value = 0;
1435
+ for await (const chunk of createReadStream(path, { highWaterMark: 4 * 1024 * 1024 })) value = crc32(chunk, value);
1436
+ return value.toString(16).padStart(8, "0");
1437
+ }
1438
+ async function downloadTo(url, dest, expectedSize, userAgent, onBytes) {
1439
+ await mkdir2(join3(dest, ".."), { recursive: true });
1440
+ const part = `${dest}.part`;
1441
+ const have = await stat2(part).then((s) => s.size, () => 0);
1442
+ const from = expectedSize === null ? null : resumeFrom(have, expectedSize);
1443
+ const res = await fetch(url, {
1444
+ headers: { "User-Agent": userAgent, ...from !== null ? { Range: `bytes=${from}-` } : {} }
1445
+ });
1446
+ if (res.status === 410) throw new ManifestError("expired", "The transfer expired, was revoked, or reached its download limit.");
1447
+ if (!res.ok || !res.body) throw new ManifestError(`http_${res.status}`, `Download failed with ${res.status}.`);
1448
+ const appending = from !== null && res.status === 206;
1449
+ if (appending) onBytes(from);
1450
+ const counter = new TransformStream({
1451
+ transform(chunk, controller) {
1452
+ onBytes(chunk.byteLength);
1453
+ controller.enqueue(chunk);
1454
+ }
1455
+ });
1456
+ await pipeline(
1457
+ Readable.fromWeb(res.body.pipeThrough(counter)),
1458
+ createWriteStream(part, { flags: appending ? "a" : "w" })
1459
+ );
1460
+ await rename(part, dest);
1461
+ }
1462
+
1013
1463
  // src/errors.ts
1014
1464
  var CliError = class extends Error {
1015
1465
  constructor(message, hint, code = "cli_error", exitCode = 1) {
@@ -1029,7 +1479,8 @@ var NotSignedIn = class extends CliError {
1029
1479
  }
1030
1480
  };
1031
1481
  function explain(err) {
1032
- if (err instanceof CliError) return { message: err.message, hint: err.hint, code: err.code };
1482
+ if (err instanceof CliError) return { message: err.message, hint: err.hint, code: err.code, exitCode: err.exitCode };
1483
+ if (err instanceof ManifestError) return { message: err.message, code: err.code };
1033
1484
  if (err instanceof YungleApiError) {
1034
1485
  const base = { code: err.code, details: err.details };
1035
1486
  switch (err.code) {
@@ -1056,6 +1507,8 @@ function explain(err) {
1056
1507
  };
1057
1508
  case "not_found":
1058
1509
  return { ...base, message: err.message, hint: "yungle transfers (list what you have)" };
1510
+ case "e2ee_unsupported":
1511
+ return { ...base, message: err.message, hint: "Open the link in a browser; only it holds the key." };
1059
1512
  case "email_budget_exhausted":
1060
1513
  return { ...base, message: err.message, hint: "Share the link yourself; the transfer is live." };
1061
1514
  default:
@@ -1102,95 +1555,22 @@ function suggest(word, known) {
1102
1555
  return best !== null && bestD <= Math.max(1, Math.floor(best.length / 3)) ? best : null;
1103
1556
  }
1104
1557
 
1105
- // src/get.ts
1106
- import { createWriteStream } from "node:fs";
1107
- import { mkdir as mkdir2, rename, stat as stat2 } from "node:fs/promises";
1108
- import { basename as basename2, join as join3 } from "node:path";
1109
- import { Readable } from "node:stream";
1110
- import { pipeline } from "node:stream/promises";
1111
- var SLUG = /^[A-Za-z0-9_-]{8,128}$/;
1112
- function parseTransferLink(input, defaultOrigin) {
1113
- const text2 = input.trim();
1114
- if (SLUG.test(text2)) return { origin: defaultOrigin, slug: text2, recipient: null, hasKey: false };
1115
- let url;
1558
+ // src/index.ts
1559
+ import { stat as stat5, unlink } from "node:fs/promises";
1560
+
1561
+ // src/target.ts
1562
+ function unpackTarget(blob) {
1563
+ let parsed;
1116
1564
  try {
1117
- url = new URL(text2);
1565
+ parsed = JSON.parse(Buffer.from(blob.trim(), "base64url").toString("utf8"));
1118
1566
  } catch {
1119
- throw new Error(`Not a Yungle transfer link: ${text2}`);
1120
- }
1121
- const m = url.pathname.match(/^\/t\/([^/]+)\/?$/);
1122
- if (!m || !SLUG.test(m[1])) {
1123
- throw new Error(
1124
- url.pathname.startsWith("/c/") ? "That is a collection link. `yungle get` downloads transfers (/t/\u2026) for now." : `Not a Yungle transfer link: ${text2}`
1125
- );
1126
- }
1127
- return { origin: url.origin, slug: m[1], recipient: url.searchParams.get("r"), hasKey: url.hash.length > 1 };
1128
- }
1129
- function safeFileName(name) {
1130
- const last = basename2(name.replace(/\\/g, "/"));
1131
- const cleaned = last.replace(/[\x00-\x1f<>:"|?*]/g, "_").replace(/^\.+$/, "_").trim();
1132
- return cleaned || "file";
1133
- }
1134
- function dedupeNames(names) {
1135
- const used = /* @__PURE__ */ new Set();
1136
- return names.map((name) => {
1137
- let candidate = name;
1138
- const dot = name.lastIndexOf(".");
1139
- const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""];
1140
- for (let n3 = 2; used.has(candidate.toLowerCase()); n3++) candidate = `${stem} (${n3})${ext}`;
1141
- used.add(candidate.toLowerCase());
1142
- return candidate;
1143
- });
1144
- }
1145
- function resumeFrom(partialBytes, expectedSize) {
1146
- if (partialBytes <= 0 || partialBytes >= expectedSize) return null;
1147
- return partialBytes;
1148
- }
1149
- var ManifestError = class extends Error {
1150
- constructor(code, message) {
1151
- super(message);
1152
- this.code = code;
1153
- this.name = "ManifestError";
1567
+ throw new Error("That --target is not one Yungle issued (it does not decode).");
1154
1568
  }
1155
- code;
1156
- };
1157
- async function fetchManifest(link, password2, userAgent) {
1158
- const res = await fetch(`${link.origin}/api/t/${encodeURIComponent(link.slug)}/manifest`, {
1159
- method: "POST",
1160
- headers: { "Content-Type": "application/json", "User-Agent": userAgent },
1161
- body: JSON.stringify({ ...password2 ? { password: password2 } : {}, ...link.recipient ? { r: link.recipient } : {} })
1162
- });
1163
- const body = await res.json().catch(() => null);
1164
- if (!res.ok || !body || body.error) {
1165
- const code = body?.error?.code ?? `http_${res.status}`;
1166
- const message = code === "not_found" ? "No such transfer. Check the link, or it may have been deleted." : body?.error?.message ?? `The server answered ${res.status}.`;
1167
- throw new ManifestError(code, message);
1569
+ const p = parsed;
1570
+ if (typeof p?.e !== "string" || typeof p.i !== "string" || typeof p.t !== "string" || typeof p.n !== "string" || typeof p.s !== "number" || !/^https?:\/\//.test(p.e)) {
1571
+ throw new Error("That --target is incomplete; ask for a fresh upload command.");
1168
1572
  }
1169
- return body;
1170
- }
1171
- async function downloadTo(url, dest, expectedSize, userAgent, onBytes) {
1172
- await mkdir2(join3(dest, ".."), { recursive: true });
1173
- const part = `${dest}.part`;
1174
- const have = await stat2(part).then((s) => s.size, () => 0);
1175
- const from = expectedSize === null ? null : resumeFrom(have, expectedSize);
1176
- const res = await fetch(url, {
1177
- headers: { "User-Agent": userAgent, ...from !== null ? { Range: `bytes=${from}-` } : {} }
1178
- });
1179
- if (res.status === 410) throw new ManifestError("expired", "The transfer expired, was revoked, or reached its download limit.");
1180
- if (!res.ok || !res.body) throw new ManifestError(`http_${res.status}`, `Download failed with ${res.status}.`);
1181
- const appending = from !== null && res.status === 206;
1182
- if (appending) onBytes(from);
1183
- const counter = new TransformStream({
1184
- transform(chunk, controller) {
1185
- onBytes(chunk.byteLength);
1186
- controller.enqueue(chunk);
1187
- }
1188
- });
1189
- await pipeline(
1190
- Readable.fromWeb(res.body.pipeThrough(counter)),
1191
- createWriteStream(part, { flags: appending ? "a" : "w" })
1192
- );
1193
- await rename(part, dest);
1573
+ return p;
1194
1574
  }
1195
1575
 
1196
1576
  // ../../node_modules/.pnpm/@clack+core@1.5.1/node_modules/@clack/core/dist/index.mjs
@@ -2583,7 +2963,7 @@ function fail(err, json) {
2583
2963
  `);
2584
2964
  process.stderr.write("\n");
2585
2965
  }
2586
- return x.code === "usage" ? 2 : 1;
2966
+ return x.code === "usage" ? 2 : x.exitCode ?? 1;
2587
2967
  }
2588
2968
 
2589
2969
  // src/mcp-command.ts
@@ -2758,88 +3138,103 @@ async function installInto(c2, entry, io) {
2758
3138
  }
2759
3139
 
2760
3140
  // src/upload.ts
2761
- import { createReadStream } from "node:fs";
3141
+ import { createReadStream as createReadStream2 } from "node:fs";
2762
3142
  import { stat as stat4 } from "node:fs/promises";
2763
3143
  var CONCURRENCY = 3;
2764
- async function uploadAll(files, endpoint, onProgress) {
3144
+ async function uploadAll(files, endpoint, onProgress, renewal) {
2765
3145
  const queue = [...files];
2766
3146
  const workers = Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => {
2767
3147
  for (; ; ) {
2768
3148
  const next2 = queue.shift();
2769
3149
  if (!next2) return;
2770
- await uploadOne(next2, endpoint, onProgress);
3150
+ await uploadOne(next2, endpoint, onProgress, renewal);
2771
3151
  }
2772
3152
  });
2773
3153
  await Promise.all(workers);
2774
3154
  await clearUploadUrls(files.map((f) => f.target.id));
2775
3155
  }
2776
- async function uploadOne(file, endpoint, onProgress) {
3156
+ async function uploadOne(file, endpoint, onProgress, renewal) {
2777
3157
  const tus = await import("tus-js-client");
3158
+ const keeper = renewal ? createTokenKeeper(file.target.uploadToken, {
3159
+ renew: async (current) => {
3160
+ const outcome = await renewal.renew(current);
3161
+ if (typeof outcome === "object") renewal.onRenewed?.(file.target.id, outcome.token);
3162
+ return outcome;
3163
+ }
3164
+ }) : null;
2778
3165
  const { size } = await stat4(file.path);
2779
3166
  const urls = await readUploadUrls();
2780
3167
  const uploadUrl = urls[file.target.id];
2781
3168
  const started = Date.now();
2782
- await new Promise((resolve4, reject) => {
2783
- const upload = new tus.Upload(createReadStream(file.path), {
2784
- endpoint,
2785
- /**
2786
- * Reattach to an upload already in progress. The endpoint stays set as a
2787
- * fallback: if the upload was never actually created the HEAD 404s and
2788
- * tus falls back to a clean creation, which for zero progress is exactly
2789
- * right. Never re-POST one that *is* in progress — that restarts it with
2790
- * a fresh encryption key and abandons everything already sent.
2791
- */
2792
- ...uploadUrl ? { uploadUrl } : {},
2793
- uploadSize: size,
2794
- /**
2795
- * The token authorizes EVERY method, not just the creation POST — the
2796
- * server verifies HEAD, PATCH and DELETE too. Metadata below only reaches
2797
- * the server on the first POST, so it cannot carry the token alone.
2798
- */
2799
- headers: { "x-yungle-upload-token": file.target.uploadToken },
2800
- metadata: {
2801
- fileId: file.target.id,
2802
- token: file.target.uploadToken,
2803
- filename: file.target.name
2804
- },
2805
- retryDelays: [0, 1e3, 3e3, 5e3, 1e4, 3e4],
2806
- /**
2807
- * Persist the upload URL the moment the server hands one over, so a
2808
- * `kill -9` a second later still resumes. Waiting until the upload
2809
- * finishes would mean the state file only ever describes work that no
2810
- * longer needs it.
2811
- *
2812
- * `onUploadUrlAvailable` and `upload.url`, **not** `onAfterResponse` +
2813
- * `req.getURL()`: that returns the URL the request was *sent to*, which
2814
- * for the creation POST is the endpoint itself, not the `Location` the
2815
- * server answered with. The first version did that, and the symptom was
2816
- * an empty resume file with no error anywhere — a killed upload silently
2817
- * restarting from zero, which is the one thing this command exists to
2818
- * prevent.
2819
- */
2820
- onUploadUrlAvailable: () => {
2821
- const location = upload.url;
2822
- if (location && !urls[file.target.id]) {
2823
- urls[file.target.id] = location;
2824
- void saveUploadUrl(file.target.id, location);
2825
- }
2826
- },
2827
- onProgress: (sent, total) => {
2828
- const elapsed = Math.max(1, Date.now() - started) / 1e3;
2829
- onProgress({
2830
- id: file.target.id,
2831
- name: file.target.name,
2832
- sent,
2833
- total: total || size,
2834
- rate: sent / elapsed,
2835
- resumed: Boolean(uploadUrl)
2836
- });
2837
- },
2838
- onSuccess: () => resolve4(),
2839
- onError: (err) => reject(err)
3169
+ try {
3170
+ await new Promise((resolve4, reject) => {
3171
+ const upload = new tus.Upload(createReadStream2(file.path), {
3172
+ endpoint,
3173
+ /**
3174
+ * Reattach to an upload already in progress. The endpoint stays set as a
3175
+ * fallback: if the upload was never actually created the HEAD 404s and
3176
+ * tus falls back to a clean creation, which for zero progress is exactly
3177
+ * right. Never re-POST one that *is* in progress — that restarts it with
3178
+ * a fresh encryption key and abandons everything already sent.
3179
+ */
3180
+ ...uploadUrl ? { uploadUrl } : {},
3181
+ uploadSize: size,
3182
+ /**
3183
+ * The token authorizes EVERY method, not just the creation POST — the
3184
+ * server verifies HEAD, PATCH and DELETE too. Metadata below only reaches
3185
+ * the server on the first POST, so it cannot carry the token alone.
3186
+ */
3187
+ headers: { "x-yungle-upload-token": file.target.uploadToken },
3188
+ // The renewed token, once there is one — read per request, not fixed above.
3189
+ onBeforeRequest: (req) => {
3190
+ if (keeper) req.setHeader("x-yungle-upload-token", keeper.current());
3191
+ },
3192
+ metadata: {
3193
+ fileId: file.target.id,
3194
+ token: file.target.uploadToken,
3195
+ filename: file.target.name
3196
+ },
3197
+ retryDelays: [0, 1e3, 3e3, 5e3, 1e4, 3e4],
3198
+ /**
3199
+ * Persist the upload URL the moment the server hands one over, so a
3200
+ * `kill -9` a second later still resumes. Waiting until the upload
3201
+ * finishes would mean the state file only ever describes work that no
3202
+ * longer needs it.
3203
+ *
3204
+ * `onUploadUrlAvailable` and `upload.url`, **not** `onAfterResponse` +
3205
+ * `req.getURL()`: that returns the URL the request was *sent to*, which
3206
+ * for the creation POST is the endpoint itself, not the `Location` the
3207
+ * server answered with. The first version did that, and the symptom was
3208
+ * an empty resume file with no error anywhere — a killed upload silently
3209
+ * restarting from zero, which is the one thing this command exists to
3210
+ * prevent.
3211
+ */
3212
+ onUploadUrlAvailable: () => {
3213
+ const location = upload.url;
3214
+ if (location && !urls[file.target.id]) {
3215
+ urls[file.target.id] = location;
3216
+ void saveUploadUrl(file.target.id, location);
3217
+ }
3218
+ },
3219
+ onProgress: (sent, total) => {
3220
+ const elapsed = Math.max(1, Date.now() - started) / 1e3;
3221
+ onProgress({
3222
+ id: file.target.id,
3223
+ name: file.target.name,
3224
+ sent,
3225
+ total: total || size,
3226
+ rate: sent / elapsed,
3227
+ resumed: Boolean(uploadUrl)
3228
+ });
3229
+ },
3230
+ onSuccess: () => resolve4(),
3231
+ onError: (err) => reject(err)
3232
+ });
3233
+ upload.start();
2840
3234
  });
2841
- upload.start();
2842
- });
3235
+ } finally {
3236
+ keeper?.stop();
3237
+ }
2843
3238
  }
2844
3239
 
2845
3240
  // src/watch.ts
@@ -2876,7 +3271,7 @@ async function saveUploaded(dir, collectionId, uploaded) {
2876
3271
  }
2877
3272
 
2878
3273
  // src/index.ts
2879
- var CLI_VERSION = true ? "0.2.0" : "dev";
3274
+ var CLI_VERSION = true ? "0.3.0" : "dev";
2880
3275
  var USER_AGENT = `yungle-cli/${CLI_VERSION}`;
2881
3276
  async function main(argvIn) {
2882
3277
  const argv = resolveAlias(argvIn);
@@ -2912,6 +3307,10 @@ async function main(argvIn) {
2912
3307
  return await send(positionals, flags, json);
2913
3308
  case "get":
2914
3309
  return await get(positionals, flags, json);
3310
+ case "pull":
3311
+ return await pull(flags, json);
3312
+ case "put":
3313
+ return await put(positionals, flags, json);
2915
3314
  case "status":
2916
3315
  return await status(positionals, flags, json);
2917
3316
  case "transfers":
@@ -2920,6 +3319,8 @@ async function main(argvIn) {
2920
3319
  return await collections(flags, json);
2921
3320
  case "contacts":
2922
3321
  return await contacts(flags, json);
3322
+ case "requests":
3323
+ return await requests(positionals, flags, json);
2923
3324
  case "revoke":
2924
3325
  return await revoke(positionals, flags, json);
2925
3326
  case "push":
@@ -3024,8 +3425,9 @@ async function send(pathsIn, flags, json) {
3024
3425
  let paths = pathsIn;
3025
3426
  let recipients = listFlag(flags.to);
3026
3427
  let message = stringFlag(flags.message);
3428
+ const urls = urlListFlag(flags["from-url"]);
3027
3429
  let askedFor = false;
3028
- if (paths.length === 0) {
3430
+ if (paths.length === 0 && urls.length === 0) {
3029
3431
  if (!interactive(flags)) throw new CliError("Nothing to send.", "yungle send <file or folder> [--to someone@example.com]", "usage", 2);
3030
3432
  const answers = await askSend();
3031
3433
  paths = answers.paths;
@@ -3041,28 +3443,35 @@ async function send(pathsIn, flags, json) {
3041
3443
  if (!await confirm2("Send it as a link you share yourself?")) throw new CliError("Cancelled; nothing was sent.", hint, "cancelled", 130);
3042
3444
  recipients = [];
3043
3445
  }
3044
- const files = await collectFiles(paths);
3045
- if (files.length === 0) throw new CliError("Those paths contain no files (hidden files are skipped).", void 0, "usage", 2);
3446
+ const files = paths.length ? await collectFiles(paths) : [];
3447
+ if (files.length === 0 && urls.length === 0) throw new CliError("Those paths contain no files (hidden files are skipped).", void 0, "usage", 2);
3046
3448
  const total = files.reduce((n3, f) => n3 + f.size, 0);
3047
- const summary = `${plural(files.length, "file")} ${sym.dot} ${formatBytes(total)}${recipients.length ? ` to ${recipients.join(", ")}` : ""}`;
3449
+ const summary = [files.length ? `${plural(files.length, "file")} ${sym.dot} ${formatBytes(total)}` : "", urls.length ? `${plural(urls.length, "URL")} fetched by Yungle` : ""].filter(Boolean).join(" + ") + (recipients.length ? ` to ${recipients.join(", ")}` : "");
3048
3450
  if (askedFor && !await confirm2(`Send ${summary}?`)) throw new CliError("Cancelled; nothing was sent.", void 0, "cancelled", 130);
3049
3451
  heading(`Sending ${summary}`);
3050
- const key = sessionKey(files, "transfer");
3452
+ const key = sessionKey(files, `transfer${urls.length ? `:${urls.join("\n")}` : ""}`);
3051
3453
  const existing = await findSession(key);
3052
3454
  let created;
3053
3455
  if (existing && existing.kind === "transfer") {
3054
3456
  note("Resuming the interrupted upload.");
3055
- created = { transfer: { id: existing.targetId }, tusEndpoint: existing.tusEndpoint, files: existing.files };
3457
+ created = { transfer: { id: existing.targetId }, tusEndpoint: existing.tusEndpoint, files: existing.files, imports: existing.imports ?? [] };
3056
3458
  } else {
3057
3459
  const fresh = await api.createTransfer({
3058
3460
  files: toFileInputs(files),
3461
+ ...urls.length ? { imports: urls.map((url) => ({ url })) } : {},
3059
3462
  title: stringFlag(flags.title),
3060
3463
  expiresInDays: numberFlag(flags.expires, "expires")
3061
3464
  });
3062
- created = { transfer: fresh.transfer, tusEndpoint: fresh.tusEndpoint, files: fresh.files.map((f, i2) => ({ ...f, path: files[i2].path })) };
3063
- await saveSession(key, { createdAt: Date.now(), kind: "transfer", targetId: fresh.transfer.id, tusEndpoint: fresh.tusEndpoint, files: created.files });
3465
+ created = {
3466
+ transfer: fresh.transfer,
3467
+ tusEndpoint: fresh.tusEndpoint,
3468
+ files: fresh.files.map((f, i2) => ({ ...f, path: files[i2].path })),
3469
+ imports: (fresh.imports ?? []).map((i2) => ({ fileId: i2.fileId, name: i2.name, size: i2.size }))
3470
+ };
3471
+ await saveSession(key, { createdAt: Date.now(), kind: "transfer", targetId: fresh.transfer.id, tusEndpoint: fresh.tusEndpoint, files: created.files, imports: created.imports });
3064
3472
  }
3065
- await streamAll(files, created);
3473
+ if (files.length) await streamAll(files, created, api, key);
3474
+ if (created.imports.length) await awaitImports(api, created.imports);
3066
3475
  const after = await freshClient(flags, api);
3067
3476
  await dropSession(key);
3068
3477
  const finalized = await after.finalizeTransfer(created.transfer.id, {
@@ -3088,7 +3497,60 @@ async function send(pathsIn, flags, json) {
3088
3497
  t2.url
3089
3498
  );
3090
3499
  }
3091
- async function streamAll(files, targets) {
3500
+ async function put(positionals, flags, json) {
3501
+ const blob = stringFlag(flags.target);
3502
+ const path = positionals[0];
3503
+ if (!blob || !path) throw new CliError("Put what, where?", "yungle put <file> --target <target>", "usage", 2);
3504
+ let target;
3505
+ try {
3506
+ target = unpackTarget(blob);
3507
+ } catch (err) {
3508
+ throw new CliError(err.message, void 0, "usage", 2);
3509
+ }
3510
+ const abs = resolve3(path);
3511
+ const size = await stat5(abs).then((s) => s.isFile() ? s.size : -1, () => -2);
3512
+ if (size === -2) throw new CliError(`No such file: ${abs}`, void 0, "not_found");
3513
+ if (size === -1) throw new CliError(`${abs} is not a file.`, void 0, "usage", 2);
3514
+ if (size !== target.s) {
3515
+ throw new CliError(`${abs} is ${formatBytes(size)}; this target was made for ${formatBytes(target.s)}.`, "Use the file the target was created for.", "size_mismatch");
3516
+ }
3517
+ heading(`Uploading ${target.n} ${sym.dot} ${formatBytes(size)}`);
3518
+ const origin = new URL(target.e).origin;
3519
+ const started = Date.now();
3520
+ await uploadAll(
3521
+ [{ path: abs, target: { id: target.i, name: target.n, size: target.s, uploadToken: target.t } }],
3522
+ target.e,
3523
+ (p) => drawProgress(progressLine(p.sent, p.total, started, p.resumed ? "resumed" : "")),
3524
+ { renew: (t2) => renewUploadToken(origin, t2) }
3525
+ );
3526
+ endProgress();
3527
+ return out(json, { uploaded: target.n, fileId: target.i, bytes: size }, success(`Uploaded ${target.n}`), target.i);
3528
+ }
3529
+ async function awaitImports(api, imports) {
3530
+ const total = imports.reduce((n3, i2) => n3 + i2.size, 0);
3531
+ heading(`Yungle is fetching ${plural(imports.length, "file")} ${sym.dot} ${formatBytes(total)}`);
3532
+ const started = Date.now();
3533
+ for (; ; ) {
3534
+ const statuses = await Promise.all(imports.map((i2) => api.getImport(i2.fileId)));
3535
+ const received = statuses.reduce((n3, s) => n3 + s.receivedBytes, 0);
3536
+ const done = statuses.filter((s) => s.status !== "importing").length;
3537
+ drawProgress(progressLine(received, total, started, `${done}/${imports.length} fetched`));
3538
+ if (done === imports.length) {
3539
+ endProgress();
3540
+ const failed = statuses.filter((s) => s.status === "failed");
3541
+ if (failed.length) {
3542
+ throw new CliError(
3543
+ `Could not fetch ${failed.map((f) => `${f.name} (${f.error ?? "failed"})`).join(", ")}.`,
3544
+ "Check the URL is public and still valid, then run the command again.",
3545
+ "import_failed"
3546
+ );
3547
+ }
3548
+ return;
3549
+ }
3550
+ await new Promise((r2) => setTimeout(r2, 2e3));
3551
+ }
3552
+ }
3553
+ async function streamAll(files, targets, api, resumeKey) {
3092
3554
  const paired = pairWithTargets(files, targets.files);
3093
3555
  const totalBytes = paired.reduce((n3, { file }) => n3 + file.size, 0);
3094
3556
  const sentPer = /* @__PURE__ */ new Map();
@@ -3109,6 +3571,12 @@ async function streamAll(files, targets) {
3109
3571
  if (p.resumed) resumed = true;
3110
3572
  sentPer.set(p.id, p.sent);
3111
3573
  if (p.sent >= p.total) finished.add(p.id);
3574
+ },
3575
+ {
3576
+ renew: (t2) => api.renewUploadToken(t2),
3577
+ onRenewed: (fileId, token) => {
3578
+ if (resumeKey) void saveRenewedToken(resumeKey, fileId, token);
3579
+ }
3112
3580
  }
3113
3581
  );
3114
3582
  } finally {
@@ -3135,7 +3603,7 @@ async function get(positionals, flags, json) {
3135
3603
  const needs = err instanceof ManifestError && (err.code === "password_required" || err.code === "wrong_password");
3136
3604
  if (!needs || !interactive(flags)) {
3137
3605
  if (err instanceof ManifestError && err.code === "password_required") {
3138
- throw new CliError("This transfer is password protected.", `yungle get ${input} --password <password>`, "password_required");
3606
+ throw new CliError(`This ${link.kind === "c" ? "collection" : "transfer"} is password protected.`, `yungle get ${input} --password <password>`, "password_required");
3139
3607
  }
3140
3608
  throw err;
3141
3609
  }
@@ -3150,27 +3618,76 @@ async function get(positionals, flags, json) {
3150
3618
  "e2ee"
3151
3619
  );
3152
3620
  }
3153
- if (manifest.files.length === 0) return out(json, { saved: [] }, " This transfer has no files.");
3154
- const useZip = flags.zip === true && manifest.zipUrl !== null;
3155
- const jobs = useZip ? [{ url: manifest.zipUrl, name: `yungle-${link.slug}.zip`, size: null }] : dedupeNames(manifest.files.map((f) => safeFileName(f.name))).map((name, i2) => ({ url: manifest.files[i2].downloadUrl, name, size: manifest.files[i2].size }));
3621
+ return saveManifest(manifest, {
3622
+ outDir,
3623
+ zip: flags.zip === true,
3624
+ zipName: `yungle-${link.slug}.zip`,
3625
+ json,
3626
+ empty: ` This ${link.kind === "c" ? "collection" : "transfer"} has no files.`
3627
+ });
3628
+ }
3629
+ async function pull(flags, json) {
3630
+ const collectionId = stringFlag(flags.collection);
3631
+ const transferId = stringFlag(flags.transfer);
3632
+ if (!collectionId === !transferId) {
3633
+ throw new CliError("Pull what?", "yungle pull --collection <id> or yungle pull --transfer <id>", "usage", 2);
3634
+ }
3635
+ const api = client(flags);
3636
+ const manifest = collectionId ? await api.collectionDownloadLinks(collectionId) : await api.transferDownloadLinks(transferId);
3637
+ const outDir = resolve3(stringFlag(flags.out) ?? (collectionId ? safeFileName(manifest.title ?? collectionId) : "."));
3638
+ return saveManifest(manifest, {
3639
+ outDir,
3640
+ zip: flags.zip === true,
3641
+ zipName: `yungle-${collectionId ?? transferId}.zip`,
3642
+ json,
3643
+ empty: ` This ${collectionId ? "collection" : "transfer"} has no files.`
3644
+ });
3645
+ }
3646
+ async function saveManifest(manifest, opts) {
3647
+ const { outDir, json } = opts;
3648
+ if (manifest.files.length === 0) return out(json, { saved: [], skipped: [], complete: manifest.complete ?? true }, opts.empty);
3649
+ if (manifest.complete === false && !json) {
3650
+ note(`Still uploading: ${plural(manifest.files.length, "file")} have arrived so far. Run this again later for the rest.`);
3651
+ }
3652
+ const useZip = opts.zip && manifest.zipUrl !== null;
3653
+ const relPaths = dedupePaths(manifest.files.map((f) => ({ dir: safeRelativeDir(f.path), name: safeFileName(f.name) })));
3654
+ const jobs = useZip ? [{ url: manifest.zipUrl, rel: opts.zipName, size: null }] : relPaths.map((rel, i2) => ({ url: manifest.files[i2].downloadUrl, rel, size: manifest.files[i2].size, crc32: manifest.files[i2].crc32 ?? null }));
3156
3655
  const total = manifest.files.reduce((n3, f) => n3 + f.size, 0);
3157
3656
  heading(`Downloading ${plural(manifest.files.length, "file")} ${sym.dot} ${formatBytes(total)}`);
3158
3657
  let received = 0;
3159
3658
  const started = Date.now();
3160
3659
  const saved = [];
3660
+ const skipped = [];
3161
3661
  for (const [i2, job] of jobs.entries()) {
3162
- const dest = join6(outDir, job.name);
3662
+ const dest = join6(outDir, job.rel);
3663
+ if (job.size !== null && await stat5(dest).then((st) => st.size, () => -1) === job.size) {
3664
+ received += job.size;
3665
+ skipped.push(dest);
3666
+ continue;
3667
+ }
3163
3668
  await downloadTo(job.url, dest, job.size, USER_AGENT, (n3) => {
3164
3669
  received += n3;
3165
3670
  drawProgress(progressLine(Math.min(received, total), total, started, `${i2 + 1}/${jobs.length} files`));
3166
3671
  });
3672
+ const expected = "crc32" in job ? job.crc32 : null;
3673
+ if (expected) {
3674
+ const actual = await fileCrc32(dest);
3675
+ if (actual !== null && actual !== expected) {
3676
+ await unlink(dest).catch(() => void 0);
3677
+ throw new CliError(`${job.rel} arrived damaged (checksum ${actual}, expected ${expected}) and was deleted.`, "Run the same command again to fetch it afresh.", "checksum_mismatch");
3678
+ }
3679
+ }
3167
3680
  saved.push(dest);
3168
3681
  }
3169
3682
  endProgress();
3683
+ const skippedNote = skipped.length ? [o.dim(`${plural(skipped.length, "file")} already here, skipped`)] : [];
3170
3684
  return out(
3171
3685
  json,
3172
- { saved, message: manifest.message, expiresAt: manifest.expiresAt },
3173
- success(`Saved ${saved.length === 1 ? shown(saved[0]) : `${plural(saved.length, "file")} to ${shown(outDir)}`}`, manifest.message ? [o.italic(`\u201C${manifest.message}\u201D`)] : []),
3686
+ { saved, skipped, complete: manifest.complete ?? true, message: manifest.message, expiresAt: manifest.expiresAt },
3687
+ success(
3688
+ saved.length === 0 ? "Nothing new to download" : `Saved ${saved.length === 1 ? shown(saved[0]) : `${plural(saved.length, "file")} to ${shown(outDir)}`}`,
3689
+ [...manifest.message ? [o.italic(`\u201C${manifest.message}\u201D`)] : [], ...skippedNote]
3690
+ ),
3174
3691
  saved.join("\n")
3175
3692
  );
3176
3693
  }
@@ -3212,7 +3729,7 @@ async function status(positionals, flags, json) {
3212
3729
  lines.push("", table(
3213
3730
  recipients.map((r2) => [
3214
3731
  r2.email,
3215
- r2.bouncedAt ? o.red("email bounced") : r2.downloaded ? accentOut("downloaded") : r2.opened ? "opened" : r2.notifiedAt ? o.dim("emailed") : o.dim("email queued")
3732
+ r2.bouncedAt ? o.red("email bounced") : r2.downloaded ? accentOut("downloaded") : r2.notifiedAt ? o.dim("emailed") : o.dim("email queued")
3216
3733
  ]),
3217
3734
  { flex: 0, header: ["Recipient", "Status"] }
3218
3735
  ));
@@ -3261,6 +3778,51 @@ async function collections(flags, json) {
3261
3778
  collections2.map((c2) => `${c2.id} ${c2.title}`).join("\n")
3262
3779
  );
3263
3780
  }
3781
+ async function requests(positionals, flags, json) {
3782
+ const api = client(flags);
3783
+ const [sub, id] = positionals;
3784
+ if (!sub) {
3785
+ const { requests: rows } = await api.listRequests();
3786
+ if (rows.length === 0) return out(json, { requests: rows }, ' No upload requests yet. yungle requests new --collection <id> --title "\u2026"', "");
3787
+ return out(
3788
+ json,
3789
+ { requests: rows },
3790
+ table(
3791
+ rows.map((r2) => [o.dim(r2.id), r2.title, r2.status, plural(r2.submissionCount, "submission"), formatBytes(r2.receivedBytes), accentOut(r2.url)]),
3792
+ { flex: 1, header: ["ID", "Title", "Status", "Received", "Size", "Link"] }
3793
+ ),
3794
+ rows.map((r2) => `${r2.id} ${r2.url}`).join("\n")
3795
+ );
3796
+ }
3797
+ if (sub === "new") {
3798
+ const collectionId = stringFlag(flags.collection);
3799
+ const title = stringFlag(flags.title);
3800
+ if (!collectionId || !title) throw new CliError("Which collection, and what should the page say?", 'yungle requests new --collection <id> --title "Send us your raw footage"', "usage", 2);
3801
+ const { request: request2 } = await api.createRequest({
3802
+ collectionId,
3803
+ title,
3804
+ ...stringFlag(flags.message) ? { message: stringFlag(flags.message) } : {},
3805
+ ...stringFlag(flags.password) ? { password: stringFlag(flags.password) } : {}
3806
+ });
3807
+ return out(json, { request: request2 }, success(`Upload request \u201C${request2.title}\u201D is live`, [accentOut(request2.url), o.dim("Anyone with the link can upload into the collection.")]), request2.url);
3808
+ }
3809
+ if (sub === "show" && id) {
3810
+ const { request: request2, submissions } = await api.getRequest(id);
3811
+ return out(
3812
+ json,
3813
+ { request: request2, submissions },
3814
+ [
3815
+ ` ${o.bold(request2.title)} ${o.dim(request2.status)} ${accentOut(request2.url)}`,
3816
+ ...submissions.length === 0 ? [" Nothing received yet."] : [table(submissions.map((s) => [formatDate(s.createdAt), s.uploaderName ?? o.dim("\u2014"), s.uploaderEmail ?? "", plural(s.fileCount, "file"), formatBytes(s.sizeBytes)]), { flex: 1, header: ["When", "From", "Email", "Files", "Size"] })]
3817
+ ].join("\n"),
3818
+ submissions.map((s) => `${s.id} ${s.fileCount}`).join("\n")
3819
+ );
3820
+ }
3821
+ const status2 = { pause: "paused", resume: "active", close: "closed" }[sub];
3822
+ if (!status2 || !id) throw new CliError(`Unknown: requests ${sub}`, "yungle requests [new|show|pause|resume|close]", "usage", 2);
3823
+ const { request } = await api.setRequestStatus(id, status2);
3824
+ return out(json, { request }, success(`\u201C${request.title}\u201D is ${request.status}`), request.status);
3825
+ }
3264
3826
  async function contacts(flags, json) {
3265
3827
  const { contacts: contacts2 } = await client(flags).listContacts();
3266
3828
  if (contacts2.length === 0) return out(json, { contacts: contacts2 }, " No contacts yet.", "");
@@ -3288,29 +3850,40 @@ async function revoke(positionals, flags, json) {
3288
3850
  async function push(paths, flags, json) {
3289
3851
  const collectionId = stringFlag(flags.collection);
3290
3852
  if (!collectionId) throw new CliError("Which collection?", "yungle collections then yungle push <paths> --collection <id>", "usage", 2);
3291
- if (paths.length === 0) throw new CliError("Nothing to upload.", `yungle push <paths> --collection ${collectionId}`, "usage", 2);
3853
+ const urls = urlListFlag(flags["from-url"]);
3854
+ if (paths.length === 0 && urls.length === 0) throw new CliError("Nothing to upload.", `yungle push <paths> --collection ${collectionId}`, "usage", 2);
3292
3855
  const api = client(flags);
3293
- const files = await collectFiles(paths);
3294
- if (files.length === 0) throw new CliError("Those paths contain no files (hidden files are skipped).", void 0, "usage", 2);
3295
- heading(`Uploading ${plural(files.length, "file")} ${sym.dot} ${formatBytes(files.reduce((n3, f) => n3 + f.size, 0))}`);
3296
- const key = sessionKey(files, `collection:${collectionId}:${stringFlag(flags.folder) ?? ""}`);
3856
+ const files = paths.length ? await collectFiles(paths) : [];
3857
+ if (files.length === 0 && urls.length === 0) throw new CliError("Those paths contain no files (hidden files are skipped).", void 0, "usage", 2);
3858
+ if (files.length) heading(`Uploading ${plural(files.length, "file")} ${sym.dot} ${formatBytes(files.reduce((n3, f) => n3 + f.size, 0))}`);
3859
+ const key = sessionKey(files, `collection:${collectionId}:${stringFlag(flags.folder) ?? ""}${urls.length ? `:${urls.join("\n")}` : ""}`);
3297
3860
  const existing = await findSession(key);
3298
3861
  let targets;
3299
3862
  if (existing && existing.kind === "collection") {
3300
3863
  note("Resuming the interrupted upload.");
3301
- targets = { tusEndpoint: existing.tusEndpoint, files: existing.files };
3864
+ targets = { tusEndpoint: existing.tusEndpoint, files: existing.files, imports: existing.imports ?? [] };
3302
3865
  } else {
3303
- const fresh = await api.addCollectionFiles(collectionId, toFileInputs(files), stringFlag(flags.folder) ?? null);
3304
- targets = { tusEndpoint: fresh.tusEndpoint, files: fresh.files.map((f, i2) => ({ ...f, path: files[i2].path })) };
3305
- await saveSession(key, { createdAt: Date.now(), kind: "collection", targetId: collectionId, tusEndpoint: fresh.tusEndpoint, files: targets.files });
3866
+ const fresh = await api.addCollectionFiles(
3867
+ collectionId,
3868
+ toFileInputs(files),
3869
+ stringFlag(flags.folder) ?? null,
3870
+ urls.map((url) => ({ url }))
3871
+ );
3872
+ targets = {
3873
+ tusEndpoint: fresh.tusEndpoint,
3874
+ files: fresh.files.map((f, i2) => ({ ...f, path: files[i2].path })),
3875
+ imports: (fresh.imports ?? []).map((i2) => ({ fileId: i2.fileId, name: i2.name, size: i2.size }))
3876
+ };
3877
+ await saveSession(key, { createdAt: Date.now(), kind: "collection", targetId: collectionId, tusEndpoint: fresh.tusEndpoint, files: targets.files, imports: targets.imports });
3306
3878
  }
3307
- await streamAll(files, targets);
3879
+ if (files.length) await streamAll(files, targets, api, key);
3880
+ if (targets.imports.length) await awaitImports(api, targets.imports);
3308
3881
  await dropSession(key);
3309
3882
  const { collection } = await (await freshClient(flags, api)).getCollection(collectionId);
3310
3883
  return out(
3311
3884
  json,
3312
- { collection: collection.id, uploaded: files.length, url: collection.url },
3313
- success(`Uploaded ${plural(files.length, "file")} to \u201C${collection.title}\u201D`, collection.url ? [accentOut(collection.url)] : []),
3885
+ { collection: collection.id, uploaded: files.length + targets.imports.length, url: collection.url },
3886
+ success(`Added ${plural(files.length + targets.imports.length, "file")} to \u201C${collection.title}\u201D`, collection.url ? [accentOut(collection.url)] : []),
3314
3887
  collection.url ?? collection.id
3315
3888
  );
3316
3889
  }
@@ -3337,8 +3910,9 @@ async function watch(positionals, flags, json) {
3337
3910
  const { ready, seen } = planTick(current, lastSeen, uploaded);
3338
3911
  lastSeen = seen;
3339
3912
  if (ready.length > 0) {
3340
- const target = await (await freshClient(flags, api)).addCollectionFiles(collectionId, toFileInputs(ready), stringFlag(flags.folder) ?? null);
3341
- await streamAll(ready, target);
3913
+ const tickApi = await freshClient(flags, api);
3914
+ const target = await tickApi.addCollectionFiles(collectionId, toFileInputs(ready), stringFlag(flags.folder) ?? null);
3915
+ await streamAll(ready, target, tickApi, null);
3342
3916
  for (const f of ready) {
3343
3917
  uploaded.add(fingerprint(f));
3344
3918
  if (json) process.stdout.write(`${JSON.stringify({ uploaded: f.path, size: f.size })}