yungle-cli 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +8 -5
  2. package/dist/yungle.mjs +807 -213
  3. package/package.json +3 -3
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.1" : "dev";
381
+ var CLIENT_VERSION = true ? "0.3.2" : "dev";
194
382
  var YungleClient = class {
195
383
  baseUrl;
196
384
  apiKey;
@@ -205,10 +393,35 @@ 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");
211
420
  }
421
+ /** Your invite code and link, and the free months it has earned. */
422
+ getReferral() {
423
+ return this.request("GET", "/me/referral");
424
+ }
212
425
  // ── Transfers ─────────────────────────────────────────────────────────────
213
426
  /**
214
427
  * One page of transfers, newest first — 100 by default, up to 500. Pass the
@@ -230,6 +443,10 @@ var YungleClient = class {
230
443
  /**
231
444
  * Create a draft. Nothing is live and nobody is emailed until `finalizeTransfer`.
232
445
  */
446
+ /**
447
+ * A draft transfer. `files` are uploaded by you (tus, to the returned
448
+ * targets); `imports` are fetched by Yungle from URLs. Send either or both.
449
+ */
233
450
  createTransfer(input) {
234
451
  return this.request("POST", "/transfers", input);
235
452
  }
@@ -245,8 +462,32 @@ var YungleClient = class {
245
462
  finalizeTransfer(id, input = {}) {
246
463
  return this.request("POST", `/transfers/${enc(id)}/finalize`, input, { idempotent: true });
247
464
  }
248
- addTransferFiles(id, files) {
249
- return this.request("POST", `/transfers/${enc(id)}/files`, { files });
465
+ /**
466
+ * End-to-end encrypted transfers only: a file's sealed `{ name, path, size,
467
+ * type }` and/or thumbnail, from `yungle-e2e`. Called after the file exists,
468
+ * because each blob binds the file's id.
469
+ */
470
+ sealTransferFile(id, fileId, sealed) {
471
+ return this.request("POST", `/transfers/${enc(id)}/files/${enc(fileId)}/sealed`, sealed, { idempotent: true });
472
+ }
473
+ addTransferFiles(id, files, imports) {
474
+ return this.request("POST", `/transfers/${enc(id)}/files`, { files, ...imports?.length ? { imports } : {} });
475
+ }
476
+ /** Where a URL import stands: `importing` (with `receivedBytes`), `ready` or `failed`. */
477
+ getImport(fileId) {
478
+ return this.request("GET", `/imports/${enc(fileId)}`);
479
+ }
480
+ /**
481
+ * Poll `getImport` until every import is `ready` or `failed`, every
482
+ * `intervalMs` (default 5 s). Resolves with the final statuses; never throws
483
+ * for a failed import — check `status`.
484
+ */
485
+ async waitForImports(fileIds, intervalMs = 5e3) {
486
+ for (; ; ) {
487
+ const all = await Promise.all(fileIds.map((id) => this.getImport(id)));
488
+ if (all.every((s) => s.status !== "importing")) return all;
489
+ await sleep(intervalMs);
490
+ }
250
491
  }
251
492
  removeTransferFile(id, fileId) {
252
493
  return this.request("DELETE", `/transfers/${enc(id)}/files/${enc(fileId)}`);
@@ -258,6 +499,22 @@ var YungleClient = class {
258
499
  revokeTransfer(id) {
259
500
  return this.request("DELETE", `/transfers/${enc(id)}`);
260
501
  }
502
+ /**
503
+ * Signed download URLs for your own transfer. Fetching them is not a
504
+ * recipient download — it never appears in receipts. Throws
505
+ * `e2ee_unsupported` for an end-to-end encrypted transfer.
506
+ */
507
+ transferDownloadLinks(id) {
508
+ return this.request("GET", `/transfers/${enc(id)}/download-links`);
509
+ }
510
+ /**
511
+ * A link someone shared with you (`…/t/…` or `…/c/…`), as signed download
512
+ * URLs — what a person opening it in a browser could download, under the same
513
+ * rules. Pass `password` for a protected link. Any key works, free included.
514
+ */
515
+ resolveLink(url, password2) {
516
+ return this.request("POST", "/links/resolve", { url, ...password2 ? { password: password2 } : {} });
517
+ }
261
518
  transferDownloads(id) {
262
519
  return this.request("GET", `/transfers/${enc(id)}/downloads`);
263
520
  }
@@ -268,6 +525,13 @@ var YungleClient = class {
268
525
  createCollection(input) {
269
526
  return this.request("POST", "/collections", input);
270
527
  }
528
+ /**
529
+ * Signed download URLs for your own collection: every file with its folder
530
+ * `path`, and a whole-collection ZIP. The URLs need no key and support `Range`.
531
+ */
532
+ collectionDownloadLinks(id) {
533
+ return this.request("GET", `/collections/${enc(id)}/download-links`);
534
+ }
271
535
  getCollection(id) {
272
536
  return this.request("GET", `/collections/${enc(id)}`);
273
537
  }
@@ -295,8 +559,8 @@ var YungleClient = class {
295
559
  cursor3 = page.nextCursor ?? void 0;
296
560
  } while (cursor3);
297
561
  }
298
- addCollectionFiles(id, files, folderId) {
299
- return this.request("POST", `/collections/${enc(id)}/files`, { files, folderId });
562
+ addCollectionFiles(id, files, folderId, imports) {
563
+ return this.request("POST", `/collections/${enc(id)}/files`, { files, folderId, ...imports?.length ? { imports } : {} });
300
564
  }
301
565
  deleteCollectionFiles(id, fileIds) {
302
566
  return this.request("DELETE", `/collections/${enc(id)}/files`, { fileIds });
@@ -352,6 +616,26 @@ var YungleClient = class {
352
616
  listWebhookEvents(id, page = {}) {
353
617
  return this.request("GET", `/webhooks/${enc(id)}/events${pageQuery(page)}`);
354
618
  }
619
+ // ── Upload requests ───────────────────────────────────────────────────────
620
+ /** Your upload links, newest first (up to 100). */
621
+ listRequests() {
622
+ return this.request("GET", "/requests");
623
+ }
624
+ /**
625
+ * A public page where anyone with the link can upload into one of your
626
+ * collections. Subscribe to the `request.submitted` webhook to hear about
627
+ * each finished submission.
628
+ */
629
+ createRequest(input) {
630
+ return this.request("POST", "/requests", input);
631
+ }
632
+ getRequest(id) {
633
+ return this.request("GET", `/requests/${enc(id)}`);
634
+ }
635
+ /** `paused` stops uploads until set back to `active`; `closed` stops them for good. */
636
+ setRequestStatus(id, status2) {
637
+ return this.request("PATCH", `/requests/${enc(id)}`, { status: status2 });
638
+ }
355
639
  // ── Contacts ──────────────────────────────────────────────────────────────
356
640
  listContacts() {
357
641
  return this.request("GET", "/contacts");
@@ -382,22 +666,31 @@ var YungleClient = class {
382
666
  const safe = method === "GET" || method === "DELETE" || idempotencyKey !== void 0 || opts?.idempotent === true;
383
667
  let attempt = 0;
384
668
  for (; ; ) {
385
- const res = await this.doFetch(`${this.baseUrl}${path}`, {
386
- method,
387
- headers: {
388
- Authorization: `Bearer ${this.apiKey}`,
389
- Accept: "application/json",
390
- "User-Agent": this.userAgent,
391
- ...idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {},
392
- ...body === void 0 ? {} : { "Content-Type": "application/json" }
393
- },
394
- ...body === void 0 ? {} : { body: JSON.stringify(body) }
395
- });
669
+ let res;
670
+ try {
671
+ res = await this.doFetch(`${this.baseUrl}${path}`, {
672
+ method,
673
+ headers: {
674
+ Authorization: `Bearer ${this.apiKey}`,
675
+ Accept: "application/json",
676
+ "User-Agent": this.userAgent,
677
+ ...idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {},
678
+ ...body === void 0 ? {} : { "Content-Type": "application/json" }
679
+ },
680
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
681
+ });
682
+ } catch (err) {
683
+ const aborted = err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError");
684
+ if (aborted || !safe || attempt >= this.maxRetries) throw err;
685
+ await sleep(backoffMs(attempt, null));
686
+ attempt++;
687
+ continue;
688
+ }
396
689
  if (res.ok) return await res.json();
397
690
  const error = await toError(res);
398
691
  const mayRetry = error.status === 429 || safe && error.retryable;
399
692
  if (!mayRetry || attempt >= this.maxRetries) throw error;
400
- await sleep(backoffMs(attempt, error));
693
+ await sleep(backoffMs(attempt, error.retryAfterSeconds));
401
694
  attempt++;
402
695
  }
403
696
  }
@@ -412,15 +705,28 @@ async function toError(res) {
412
705
  } catch {
413
706
  }
414
707
  const err = body?.error;
708
+ const retryAfterHeader = parseRetryAfter(res.headers.get("retry-after"));
415
709
  return new YungleApiError(
416
710
  res.status,
417
711
  err?.code ?? "http_error",
418
712
  err?.message ?? `Request failed with status ${res.status}.`,
419
- err?.details
713
+ err?.details,
714
+ {
715
+ ...typeof err?.docs === "string" ? { docs: err.docs } : {},
716
+ ...retryAfterHeader === null ? {} : { retryAfterHeader }
717
+ }
420
718
  );
421
719
  }
422
- function backoffMs(attempt, error) {
423
- const stated = error.retryAfterSeconds;
720
+ function parseRetryAfter(value, now = Date.now()) {
721
+ if (value === null) return null;
722
+ const trimmed = value.trim();
723
+ if (/^\d+$/.test(trimmed)) return Number(trimmed);
724
+ const at = Date.parse(trimmed);
725
+ if (Number.isNaN(at)) return null;
726
+ const seconds = Math.ceil((at - now) / 1e3);
727
+ return seconds >= 0 ? seconds : null;
728
+ }
729
+ function backoffMs(attempt, stated) {
424
730
  if (stated !== null) return stated * 1e3;
425
731
  return Math.min(2 ** attempt * 500, 8e3) + Math.floor(Math.random() * 250);
426
732
  }
@@ -436,6 +742,7 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
436
742
  "password",
437
743
  "expires",
438
744
  "collection",
745
+ "transfer",
439
746
  "folder",
440
747
  "out",
441
748
  "url",
@@ -447,8 +754,11 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
447
754
  // `helpFlagsAreParsed` in args.test.ts now derives this from HELP.
448
755
  "client",
449
756
  "forward-to",
450
- "interval"
757
+ "interval",
758
+ "from-url",
759
+ "target"
451
760
  ]);
761
+ var REPEATABLE = /* @__PURE__ */ new Set(["to", "from-url"]);
452
762
  var OPTIONAL_VALUE = /* @__PURE__ */ new Set(["key"]);
453
763
  function parseArgs(argv) {
454
764
  const [command = "help", ...rest] = argv;
@@ -464,7 +774,10 @@ function parseArgs(argv) {
464
774
  const body = token.slice(2);
465
775
  const eq = body.indexOf("=");
466
776
  if (eq >= 0) {
467
- flags[body.slice(0, eq)] = body.slice(eq + 1);
777
+ const name = body.slice(0, eq);
778
+ const prior = flags[name];
779
+ flags[name] = REPEATABLE.has(name) && typeof prior === "string" ? `${prior}
780
+ ${body.slice(eq + 1)}` : body.slice(eq + 1);
468
781
  continue;
469
782
  }
470
783
  if (VALUE_FLAGS.has(body)) {
@@ -476,7 +789,9 @@ function parseArgs(argv) {
476
789
  }
477
790
  throw new Error(`--${body} needs a value.`);
478
791
  }
479
- flags[body] = value;
792
+ const prior = flags[body];
793
+ flags[body] = REPEATABLE.has(body) && typeof prior === "string" ? `${prior}
794
+ ${value}` : value;
480
795
  i2++;
481
796
  continue;
482
797
  }
@@ -489,7 +804,11 @@ function parseArgs(argv) {
489
804
  }
490
805
  function listFlag(value) {
491
806
  if (typeof value !== "string") return [];
492
- return value.split(",").map((s) => s.trim()).filter(Boolean);
807
+ return value.split(/[\n,]/).map((s) => s.trim()).filter(Boolean);
808
+ }
809
+ function urlListFlag(value) {
810
+ if (typeof value !== "string") return [];
811
+ return value.split("\n").map((s) => s.trim()).filter(Boolean);
493
812
  }
494
813
  function numberFlag(value, name) {
495
814
  if (value === void 0 || typeof value === "boolean") return void 0;
@@ -701,15 +1020,17 @@ Get started
701
1020
 
702
1021
  Send and receive
703
1022
  yungle send <paths\u2026> send files or folders, print the link
1023
+ --from-url <url> also send a file Yungle fetches from a URL (repeatable)
704
1024
  --to <email> recipient (repeatable, or comma-separated)
705
1025
  --message <text> note for the recipients
706
1026
  --title <text> label for your dashboard
707
1027
  --password <text> recipients must enter this
708
1028
  --expires <days> lifetime, clamped to your plan
709
- yungle get <link> download a transfer sent to you
1029
+ yungle get <link> download a transfer or collection shared with you
710
1030
  --out <dir> where to save (default: current directory)
711
1031
  --zip one zip instead of separate files
712
1032
  --password <text> for a protected link; asks if left out
1033
+ yungle put <file> --target <target> upload one file to a target an assistant prepared
713
1034
  yungle status [<id|link>] who downloaded a transfer
714
1035
  yungle transfers list your recent transfers
715
1036
  yungle revoke <id> stop a transfer's link for good
@@ -717,10 +1038,19 @@ Send and receive
717
1038
  Collections
718
1039
  yungle push <paths\u2026> --collection <id> upload into a collection
719
1040
  --folder <id> target folder
1041
+ --from-url <url> also add a file Yungle fetches from a URL (repeatable)
1042
+ yungle pull --collection <id> download your collection, folders kept
1043
+ --transfer <id> download your own transfer instead
1044
+ --out <dir> where to save (default: the collection's title)
1045
+ --zip one zip instead of separate files
720
1046
  yungle watch <dir> --collection <id> keep uploading new files as they appear
721
1047
  --interval <seconds> how often to look (default 10)
722
1048
  --existing also upload what is already in the folder
723
1049
  yungle collections list your collections
1050
+ yungle requests list upload links that feed your collections
1051
+ yungle requests new --collection <id> make one, and print the link to give people
1052
+ --title <text> shown on the upload page
1053
+ yungle requests pause|resume|close <id> stop or restart uploads through one
724
1054
  yungle contacts list your contacts
725
1055
 
726
1056
  In the browser
@@ -743,19 +1073,22 @@ Global flags
743
1073
  --yes never ask; for scripts
744
1074
  --url <base> use another Yungle (development)
745
1075
 
746
- Run a command with --help for examples. Docs: https://yungle.co/developers/cli`;
1076
+ Run a command with --help for examples. Docs: https://docs.yungle.co/cli`;
747
1077
  var COMMANDS = [
748
1078
  "login",
749
1079
  "whoami",
750
1080
  "logout",
751
1081
  "send",
752
1082
  "get",
1083
+ "put",
753
1084
  "status",
754
1085
  "transfers",
755
1086
  "revoke",
756
1087
  "push",
1088
+ "pull",
757
1089
  "watch",
758
1090
  "collections",
1091
+ "requests",
759
1092
  "contacts",
760
1093
  "open",
761
1094
  "webhooks",
@@ -785,11 +1118,13 @@ function resolveAlias(argv) {
785
1118
  var EXAMPLES = {
786
1119
  login: ["yungle login", "yungle login --key # on a server: paste a key from Settings \u2192 API keys"],
787
1120
  send: [
1121
+ "yungle send --from-url https://example-bucket.s3.eu-central-1.amazonaws.com/render.mov?X-Amz-Signature=\u2026 --to client@example.com",
788
1122
  'yungle send ~/Shoot --to anna@studio.nl --message "Final selects"',
789
1123
  "yungle send report.pdf # just a link, nobody emailed",
790
1124
  "yungle send dist/ --json | jq -r .url # in a script"
791
1125
  ],
792
1126
  get: ["yungle get https://yungle.co/t/emerald-palm-a5mt", "yungle get <link> --zip --out ~/Downloads"],
1127
+ pull: ["yungle pull --collection 01JABC\u2026 --out ./wedding", "yungle pull --transfer 01JABD\u2026"],
793
1128
  status: ["yungle status # pick from your recent transfers", "yungle status https://yungle.co/t/emerald-palm-a5mt"],
794
1129
  push: ["yungle push ~/Shoot --collection 01J8Z3M9Q0W4", "yungle push raw/ --collection 01J8Z3M9Q0W4 --folder 01J8Z4AB"],
795
1130
  watch: ["yungle watch ~/Renders --collection 01J8Z3M9Q0W4", "yungle watch /Volumes/CARD --collection 01J8Z3M9Q0W4 --interval 30"],
@@ -960,7 +1295,7 @@ async function readSessions() {
960
1295
  const now = Date.now();
961
1296
  const live = {};
962
1297
  for (const [key, session] of Object.entries(parsed)) {
963
- if (session && now - session.createdAt < SESSION_TTL_MS) live[key] = session;
1298
+ if (session && now - (session.touchedAt ?? session.createdAt) < SESSION_TTL_MS) live[key] = session;
964
1299
  }
965
1300
  return live;
966
1301
  } catch {
@@ -976,6 +1311,16 @@ async function saveSession(key, session) {
976
1311
  await mkdir(dirname(RESUME_PATH), { recursive: true, mode: 448 });
977
1312
  await writeFile(RESUME_PATH, JSON.stringify(sessions), { mode: 384 });
978
1313
  }
1314
+ async function saveRenewedToken(key, fileId, uploadToken) {
1315
+ const sessions = await readSessions();
1316
+ const session = sessions[key];
1317
+ const file = session?.files.find((f) => f.id === fileId);
1318
+ if (!session || !file) return;
1319
+ file.uploadToken = uploadToken;
1320
+ session.touchedAt = Date.now();
1321
+ await mkdir(dirname(RESUME_PATH), { recursive: true, mode: 448 });
1322
+ await writeFile(RESUME_PATH, JSON.stringify(sessions), { mode: 384 });
1323
+ }
979
1324
  async function dropSession(key) {
980
1325
  const sessions = await readSessions();
981
1326
  if (!(key in sessions)) return;
@@ -1010,6 +1355,123 @@ async function clearUploadUrls(fileIds) {
1010
1355
  if (changed) await writeFile(URL_PATH, JSON.stringify(current), { mode: 384 });
1011
1356
  }
1012
1357
 
1358
+ // src/get.ts
1359
+ import { createReadStream, createWriteStream } from "node:fs";
1360
+ import { mkdir as mkdir2, rename, stat as stat2 } from "node:fs/promises";
1361
+ import { basename as basename2, join as join3 } from "node:path";
1362
+ import { Readable } from "node:stream";
1363
+ import { pipeline } from "node:stream/promises";
1364
+ var SLUG = /^[A-Za-z0-9_-]{8,128}$/;
1365
+ function parseTransferLink(input, defaultOrigin) {
1366
+ const text2 = input.trim();
1367
+ if (SLUG.test(text2)) return { origin: defaultOrigin, kind: "t", slug: text2, recipient: null, hasKey: false };
1368
+ let url;
1369
+ try {
1370
+ url = new URL(text2);
1371
+ } catch {
1372
+ throw new Error(`Not a Yungle link: ${text2}`);
1373
+ }
1374
+ const m = url.pathname.match(/^\/(t|c)\/([^/]+)\/?$/);
1375
+ if (!m || !SLUG.test(m[2])) throw new Error(`Not a Yungle link: ${text2}`);
1376
+ return {
1377
+ origin: url.origin,
1378
+ kind: m[1],
1379
+ slug: m[2],
1380
+ recipient: m[1] === "t" ? url.searchParams.get("r") : null,
1381
+ hasKey: url.hash.length > 1
1382
+ };
1383
+ }
1384
+ function safeRelativeDir(path) {
1385
+ if (!path) return "";
1386
+ return path.replace(/\\/g, "/").split("/").filter((seg) => seg && seg !== "." && seg !== "..").map(safeFileName).join("/");
1387
+ }
1388
+ function dedupePaths(entries) {
1389
+ const byDir = /* @__PURE__ */ new Map();
1390
+ entries.forEach((e2, i2) => byDir.set(e2.dir.toLowerCase(), [...byDir.get(e2.dir.toLowerCase()) ?? [], i2]));
1391
+ const out2 = new Array(entries.length);
1392
+ for (const idx of byDir.values()) {
1393
+ const names = dedupeNames(idx.map((i2) => entries[i2].name));
1394
+ idx.forEach((i2, k) => {
1395
+ out2[i2] = entries[i2].dir ? `${entries[i2].dir}/${names[k]}` : names[k];
1396
+ });
1397
+ }
1398
+ return out2;
1399
+ }
1400
+ function safeFileName(name) {
1401
+ const last = basename2(name.replace(/\\/g, "/"));
1402
+ const cleaned = last.replace(/[\x00-\x1f<>:"|?*]/g, "_").replace(/^\.+$/, "_").trim();
1403
+ return cleaned || "file";
1404
+ }
1405
+ function dedupeNames(names) {
1406
+ const used = /* @__PURE__ */ new Set();
1407
+ return names.map((name) => {
1408
+ let candidate = name;
1409
+ const dot = name.lastIndexOf(".");
1410
+ const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""];
1411
+ for (let n3 = 2; used.has(candidate.toLowerCase()); n3++) candidate = `${stem} (${n3})${ext}`;
1412
+ used.add(candidate.toLowerCase());
1413
+ return candidate;
1414
+ });
1415
+ }
1416
+ function resumeFrom(partialBytes, expectedSize) {
1417
+ if (partialBytes <= 0 || partialBytes >= expectedSize) return null;
1418
+ return partialBytes;
1419
+ }
1420
+ var ManifestError = class extends Error {
1421
+ constructor(code, message) {
1422
+ super(message);
1423
+ this.code = code;
1424
+ this.name = "ManifestError";
1425
+ }
1426
+ code;
1427
+ };
1428
+ async function fetchManifest(link, password2, userAgent) {
1429
+ const res = await fetch(`${link.origin}/api/${link.kind}/${encodeURIComponent(link.slug)}/manifest`, {
1430
+ method: "POST",
1431
+ headers: { "Content-Type": "application/json", "User-Agent": userAgent },
1432
+ body: JSON.stringify({ ...password2 ? { password: password2 } : {}, ...link.recipient ? { r: link.recipient } : {} })
1433
+ });
1434
+ const body = await res.json().catch(() => null);
1435
+ if (!res.ok || !body || body.error) {
1436
+ const code = body?.error?.code ?? `http_${res.status}`;
1437
+ 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}.`;
1438
+ throw new ManifestError(code, message);
1439
+ }
1440
+ return body;
1441
+ }
1442
+ async function fileCrc32(path) {
1443
+ const zlib = await import("node:zlib");
1444
+ const crc32 = zlib.crc32;
1445
+ if (typeof crc32 !== "function") return null;
1446
+ let value = 0;
1447
+ for await (const chunk of createReadStream(path, { highWaterMark: 4 * 1024 * 1024 })) value = crc32(chunk, value);
1448
+ return value.toString(16).padStart(8, "0");
1449
+ }
1450
+ async function downloadTo(url, dest, expectedSize, userAgent, onBytes) {
1451
+ await mkdir2(join3(dest, ".."), { recursive: true });
1452
+ const part = `${dest}.part`;
1453
+ const have = await stat2(part).then((s) => s.size, () => 0);
1454
+ const from = expectedSize === null ? null : resumeFrom(have, expectedSize);
1455
+ const res = await fetch(url, {
1456
+ headers: { "User-Agent": userAgent, ...from !== null ? { Range: `bytes=${from}-` } : {} }
1457
+ });
1458
+ if (res.status === 410) throw new ManifestError("expired", "The transfer expired, was revoked, or reached its download limit.");
1459
+ if (!res.ok || !res.body) throw new ManifestError(`http_${res.status}`, `Download failed with ${res.status}.`);
1460
+ const appending = from !== null && res.status === 206;
1461
+ if (appending) onBytes(from);
1462
+ const counter = new TransformStream({
1463
+ transform(chunk, controller) {
1464
+ onBytes(chunk.byteLength);
1465
+ controller.enqueue(chunk);
1466
+ }
1467
+ });
1468
+ await pipeline(
1469
+ Readable.fromWeb(res.body.pipeThrough(counter)),
1470
+ createWriteStream(part, { flags: appending ? "a" : "w" })
1471
+ );
1472
+ await rename(part, dest);
1473
+ }
1474
+
1013
1475
  // src/errors.ts
1014
1476
  var CliError = class extends Error {
1015
1477
  constructor(message, hint, code = "cli_error", exitCode = 1) {
@@ -1029,7 +1491,8 @@ var NotSignedIn = class extends CliError {
1029
1491
  }
1030
1492
  };
1031
1493
  function explain(err) {
1032
- if (err instanceof CliError) return { message: err.message, hint: err.hint, code: err.code };
1494
+ if (err instanceof CliError) return { message: err.message, hint: err.hint, code: err.code, exitCode: err.exitCode };
1495
+ if (err instanceof ManifestError) return { message: err.message, code: err.code };
1033
1496
  if (err instanceof YungleApiError) {
1034
1497
  const base = { code: err.code, details: err.details };
1035
1498
  switch (err.code) {
@@ -1056,6 +1519,8 @@ function explain(err) {
1056
1519
  };
1057
1520
  case "not_found":
1058
1521
  return { ...base, message: err.message, hint: "yungle transfers (list what you have)" };
1522
+ case "e2ee_unsupported":
1523
+ return { ...base, message: err.message, hint: "Open the link in a browser; only it holds the key." };
1059
1524
  case "email_budget_exhausted":
1060
1525
  return { ...base, message: err.message, hint: "Share the link yourself; the transfer is live." };
1061
1526
  default:
@@ -1102,95 +1567,22 @@ function suggest(word, known) {
1102
1567
  return best !== null && bestD <= Math.max(1, Math.floor(best.length / 3)) ? best : null;
1103
1568
  }
1104
1569
 
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;
1570
+ // src/index.ts
1571
+ import { stat as stat5, unlink } from "node:fs/promises";
1572
+
1573
+ // src/target.ts
1574
+ function unpackTarget(blob) {
1575
+ let parsed;
1116
1576
  try {
1117
- url = new URL(text2);
1577
+ parsed = JSON.parse(Buffer.from(blob.trim(), "base64url").toString("utf8"));
1118
1578
  } 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";
1579
+ throw new Error("That --target is not one Yungle issued (it does not decode).");
1154
1580
  }
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);
1581
+ const p = parsed;
1582
+ 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)) {
1583
+ throw new Error("That --target is incomplete; ask for a fresh upload command.");
1168
1584
  }
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);
1585
+ return p;
1194
1586
  }
1195
1587
 
1196
1588
  // ../../node_modules/.pnpm/@clack+core@1.5.1/node_modules/@clack/core/dist/index.mjs
@@ -2583,7 +2975,7 @@ function fail(err, json) {
2583
2975
  `);
2584
2976
  process.stderr.write("\n");
2585
2977
  }
2586
- return x.code === "usage" ? 2 : 1;
2978
+ return x.code === "usage" ? 2 : x.exitCode ?? 1;
2587
2979
  }
2588
2980
 
2589
2981
  // src/mcp-command.ts
@@ -2758,88 +3150,111 @@ async function installInto(c2, entry, io) {
2758
3150
  }
2759
3151
 
2760
3152
  // src/upload.ts
2761
- import { createReadStream } from "node:fs";
3153
+ import { createReadStream as createReadStream2 } from "node:fs";
2762
3154
  import { stat as stat4 } from "node:fs/promises";
2763
3155
  var CONCURRENCY = 3;
2764
- async function uploadAll(files, endpoint, onProgress) {
3156
+ async function uploadAll(files, endpoint, onProgress, renewal) {
2765
3157
  const queue = [...files];
2766
3158
  const workers = Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => {
2767
3159
  for (; ; ) {
2768
3160
  const next2 = queue.shift();
2769
3161
  if (!next2) return;
2770
- await uploadOne(next2, endpoint, onProgress);
3162
+ await uploadOne(next2, endpoint, onProgress, renewal);
2771
3163
  }
2772
3164
  });
2773
3165
  await Promise.all(workers);
2774
3166
  await clearUploadUrls(files.map((f) => f.target.id));
2775
3167
  }
2776
- async function uploadOne(file, endpoint, onProgress) {
3168
+ async function uploadOne(file, endpoint, onProgress, renewal) {
2777
3169
  const tus = await import("tus-js-client");
3170
+ const keeper = renewal ? createTokenKeeper(file.target.uploadToken, {
3171
+ renew: async (current) => {
3172
+ const outcome = await renewal.renew(current);
3173
+ if (typeof outcome === "object") renewal.onRenewed?.(file.target.id, outcome.token);
3174
+ return outcome;
3175
+ }
3176
+ }) : null;
2778
3177
  const { size } = await stat4(file.path);
2779
3178
  const urls = await readUploadUrls();
2780
3179
  const uploadUrl = urls[file.target.id];
2781
3180
  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)
3181
+ try {
3182
+ await new Promise((resolve4, reject) => {
3183
+ const upload = new tus.Upload(createReadStream2(file.path), {
3184
+ endpoint,
3185
+ /**
3186
+ * Reattach to an upload already in progress. The endpoint stays set as a
3187
+ * fallback: if the upload was never actually created the HEAD 404s and
3188
+ * tus falls back to a clean creation, which for zero progress is exactly
3189
+ * right. Never re-POST one that *is* in progress — that restarts it with
3190
+ * a fresh encryption key and abandons everything already sent.
3191
+ */
3192
+ ...uploadUrl ? { uploadUrl } : {},
3193
+ uploadSize: size,
3194
+ /**
3195
+ * One server part per PATCH, never the whole file. tus-js-client's
3196
+ * default is a single PATCH for everything, and the proxy in front of
3197
+ * the upload service cuts any request at 30 minutes — so a big file on a
3198
+ * slow line failed at the same point on every retry. The same rule the
3199
+ * website and the extension follow (`chunkSize` from yungle-client).
3200
+ */
3201
+ chunkSize: chunkSize(size),
3202
+ /**
3203
+ * The token authorizes EVERY method, not just the creation POST — the
3204
+ * server verifies HEAD, PATCH and DELETE too. Metadata below only reaches
3205
+ * the server on the first POST, so it cannot carry the token alone.
3206
+ */
3207
+ headers: { "x-yungle-upload-token": file.target.uploadToken },
3208
+ // The renewed token, once there is one — read per request, not fixed above.
3209
+ onBeforeRequest: (req) => {
3210
+ if (keeper) req.setHeader("x-yungle-upload-token", keeper.current());
3211
+ },
3212
+ metadata: {
3213
+ fileId: file.target.id,
3214
+ token: file.target.uploadToken,
3215
+ filename: file.target.name
3216
+ },
3217
+ retryDelays: [0, 1e3, 3e3, 5e3, 1e4, 3e4],
3218
+ /**
3219
+ * Persist the upload URL the moment the server hands one over, so a
3220
+ * `kill -9` a second later still resumes. Waiting until the upload
3221
+ * finishes would mean the state file only ever describes work that no
3222
+ * longer needs it.
3223
+ *
3224
+ * `onUploadUrlAvailable` and `upload.url`, **not** `onAfterResponse` +
3225
+ * `req.getURL()`: that returns the URL the request was *sent to*, which
3226
+ * for the creation POST is the endpoint itself, not the `Location` the
3227
+ * server answered with. The first version did that, and the symptom was
3228
+ * an empty resume file with no error anywhere — a killed upload silently
3229
+ * restarting from zero, which is the one thing this command exists to
3230
+ * prevent.
3231
+ */
3232
+ onUploadUrlAvailable: () => {
3233
+ const location = upload.url;
3234
+ if (location && !urls[file.target.id]) {
3235
+ urls[file.target.id] = location;
3236
+ void saveUploadUrl(file.target.id, location);
3237
+ }
3238
+ },
3239
+ onProgress: (sent, total) => {
3240
+ const elapsed = Math.max(1, Date.now() - started) / 1e3;
3241
+ onProgress({
3242
+ id: file.target.id,
3243
+ name: file.target.name,
3244
+ sent,
3245
+ total: total || size,
3246
+ rate: sent / elapsed,
3247
+ resumed: Boolean(uploadUrl)
3248
+ });
3249
+ },
3250
+ onSuccess: () => resolve4(),
3251
+ onError: (err) => reject(err)
3252
+ });
3253
+ upload.start();
2840
3254
  });
2841
- upload.start();
2842
- });
3255
+ } finally {
3256
+ keeper?.stop();
3257
+ }
2843
3258
  }
2844
3259
 
2845
3260
  // src/watch.ts
@@ -2876,7 +3291,7 @@ async function saveUploaded(dir, collectionId, uploaded) {
2876
3291
  }
2877
3292
 
2878
3293
  // src/index.ts
2879
- var CLI_VERSION = true ? "0.2.1" : "dev";
3294
+ var CLI_VERSION = true ? "0.3.1" : "dev";
2880
3295
  var USER_AGENT = `yungle-cli/${CLI_VERSION}`;
2881
3296
  async function main(argvIn) {
2882
3297
  const argv = resolveAlias(argvIn);
@@ -2912,6 +3327,10 @@ async function main(argvIn) {
2912
3327
  return await send(positionals, flags, json);
2913
3328
  case "get":
2914
3329
  return await get(positionals, flags, json);
3330
+ case "pull":
3331
+ return await pull(flags, json);
3332
+ case "put":
3333
+ return await put(positionals, flags, json);
2915
3334
  case "status":
2916
3335
  return await status(positionals, flags, json);
2917
3336
  case "transfers":
@@ -2920,6 +3339,8 @@ async function main(argvIn) {
2920
3339
  return await collections(flags, json);
2921
3340
  case "contacts":
2922
3341
  return await contacts(flags, json);
3342
+ case "requests":
3343
+ return await requests(positionals, flags, json);
2923
3344
  case "revoke":
2924
3345
  return await revoke(positionals, flags, json);
2925
3346
  case "push":
@@ -3024,8 +3445,9 @@ async function send(pathsIn, flags, json) {
3024
3445
  let paths = pathsIn;
3025
3446
  let recipients = listFlag(flags.to);
3026
3447
  let message = stringFlag(flags.message);
3448
+ const urls = urlListFlag(flags["from-url"]);
3027
3449
  let askedFor = false;
3028
- if (paths.length === 0) {
3450
+ if (paths.length === 0 && urls.length === 0) {
3029
3451
  if (!interactive(flags)) throw new CliError("Nothing to send.", "yungle send <file or folder> [--to someone@example.com]", "usage", 2);
3030
3452
  const answers = await askSend();
3031
3453
  paths = answers.paths;
@@ -3041,28 +3463,35 @@ async function send(pathsIn, flags, json) {
3041
3463
  if (!await confirm2("Send it as a link you share yourself?")) throw new CliError("Cancelled; nothing was sent.", hint, "cancelled", 130);
3042
3464
  recipients = [];
3043
3465
  }
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);
3466
+ const files = paths.length ? await collectFiles(paths) : [];
3467
+ if (files.length === 0 && urls.length === 0) throw new CliError("Those paths contain no files (hidden files are skipped).", void 0, "usage", 2);
3046
3468
  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(", ")}` : ""}`;
3469
+ 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
3470
  if (askedFor && !await confirm2(`Send ${summary}?`)) throw new CliError("Cancelled; nothing was sent.", void 0, "cancelled", 130);
3049
3471
  heading(`Sending ${summary}`);
3050
- const key = sessionKey(files, "transfer");
3472
+ const key = sessionKey(files, `transfer${urls.length ? `:${urls.join("\n")}` : ""}`);
3051
3473
  const existing = await findSession(key);
3052
3474
  let created;
3053
3475
  if (existing && existing.kind === "transfer") {
3054
3476
  note("Resuming the interrupted upload.");
3055
- created = { transfer: { id: existing.targetId }, tusEndpoint: existing.tusEndpoint, files: existing.files };
3477
+ created = { transfer: { id: existing.targetId }, tusEndpoint: existing.tusEndpoint, files: existing.files, imports: existing.imports ?? [] };
3056
3478
  } else {
3057
3479
  const fresh = await api.createTransfer({
3058
3480
  files: toFileInputs(files),
3481
+ ...urls.length ? { imports: urls.map((url) => ({ url })) } : {},
3059
3482
  title: stringFlag(flags.title),
3060
3483
  expiresInDays: numberFlag(flags.expires, "expires")
3061
3484
  });
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 });
3485
+ created = {
3486
+ transfer: fresh.transfer,
3487
+ tusEndpoint: fresh.tusEndpoint,
3488
+ files: fresh.files.map((f, i2) => ({ ...f, path: files[i2].path })),
3489
+ imports: (fresh.imports ?? []).map((i2) => ({ fileId: i2.fileId, name: i2.name, size: i2.size }))
3490
+ };
3491
+ await saveSession(key, { createdAt: Date.now(), kind: "transfer", targetId: fresh.transfer.id, tusEndpoint: fresh.tusEndpoint, files: created.files, imports: created.imports });
3064
3492
  }
3065
- await streamAll(files, created);
3493
+ if (files.length) await streamAll(files, created, api, key);
3494
+ if (created.imports.length) await awaitImports(api, created.imports);
3066
3495
  const after = await freshClient(flags, api);
3067
3496
  await dropSession(key);
3068
3497
  const finalized = await after.finalizeTransfer(created.transfer.id, {
@@ -3088,7 +3517,60 @@ async function send(pathsIn, flags, json) {
3088
3517
  t2.url
3089
3518
  );
3090
3519
  }
3091
- async function streamAll(files, targets) {
3520
+ async function put(positionals, flags, json) {
3521
+ const blob = stringFlag(flags.target);
3522
+ const path = positionals[0];
3523
+ if (!blob || !path) throw new CliError("Put what, where?", "yungle put <file> --target <target>", "usage", 2);
3524
+ let target;
3525
+ try {
3526
+ target = unpackTarget(blob);
3527
+ } catch (err) {
3528
+ throw new CliError(err.message, void 0, "usage", 2);
3529
+ }
3530
+ const abs = resolve3(path);
3531
+ const size = await stat5(abs).then((s) => s.isFile() ? s.size : -1, () => -2);
3532
+ if (size === -2) throw new CliError(`No such file: ${abs}`, void 0, "not_found");
3533
+ if (size === -1) throw new CliError(`${abs} is not a file.`, void 0, "usage", 2);
3534
+ if (size !== target.s) {
3535
+ 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");
3536
+ }
3537
+ heading(`Uploading ${target.n} ${sym.dot} ${formatBytes(size)}`);
3538
+ const origin = new URL(target.e).origin;
3539
+ const started = Date.now();
3540
+ await uploadAll(
3541
+ [{ path: abs, target: { id: target.i, name: target.n, size: target.s, uploadToken: target.t } }],
3542
+ target.e,
3543
+ (p) => drawProgress(progressLine(p.sent, p.total, started, p.resumed ? "resumed" : "")),
3544
+ { renew: (t2) => renewUploadToken(origin, t2) }
3545
+ );
3546
+ endProgress();
3547
+ return out(json, { uploaded: target.n, fileId: target.i, bytes: size }, success(`Uploaded ${target.n}`), target.i);
3548
+ }
3549
+ async function awaitImports(api, imports) {
3550
+ const total = imports.reduce((n3, i2) => n3 + i2.size, 0);
3551
+ heading(`Yungle is fetching ${plural(imports.length, "file")} ${sym.dot} ${formatBytes(total)}`);
3552
+ const started = Date.now();
3553
+ for (; ; ) {
3554
+ const statuses = await Promise.all(imports.map((i2) => api.getImport(i2.fileId)));
3555
+ const received = statuses.reduce((n3, s) => n3 + s.receivedBytes, 0);
3556
+ const done = statuses.filter((s) => s.status !== "importing").length;
3557
+ drawProgress(progressLine(received, total, started, `${done}/${imports.length} fetched`));
3558
+ if (done === imports.length) {
3559
+ endProgress();
3560
+ const failed = statuses.filter((s) => s.status === "failed");
3561
+ if (failed.length) {
3562
+ throw new CliError(
3563
+ `Could not fetch ${failed.map((f) => `${f.name} (${f.error ?? "failed"})`).join(", ")}.`,
3564
+ "Check the URL is public and still valid, then run the command again.",
3565
+ "import_failed"
3566
+ );
3567
+ }
3568
+ return;
3569
+ }
3570
+ await new Promise((r2) => setTimeout(r2, 2e3));
3571
+ }
3572
+ }
3573
+ async function streamAll(files, targets, api, resumeKey) {
3092
3574
  const paired = pairWithTargets(files, targets.files);
3093
3575
  const totalBytes = paired.reduce((n3, { file }) => n3 + file.size, 0);
3094
3576
  const sentPer = /* @__PURE__ */ new Map();
@@ -3109,6 +3591,12 @@ async function streamAll(files, targets) {
3109
3591
  if (p.resumed) resumed = true;
3110
3592
  sentPer.set(p.id, p.sent);
3111
3593
  if (p.sent >= p.total) finished.add(p.id);
3594
+ },
3595
+ {
3596
+ renew: (t2) => api.renewUploadToken(t2),
3597
+ onRenewed: (fileId, token) => {
3598
+ if (resumeKey) void saveRenewedToken(resumeKey, fileId, token);
3599
+ }
3112
3600
  }
3113
3601
  );
3114
3602
  } finally {
@@ -3135,7 +3623,7 @@ async function get(positionals, flags, json) {
3135
3623
  const needs = err instanceof ManifestError && (err.code === "password_required" || err.code === "wrong_password");
3136
3624
  if (!needs || !interactive(flags)) {
3137
3625
  if (err instanceof ManifestError && err.code === "password_required") {
3138
- throw new CliError("This transfer is password protected.", `yungle get ${input} --password <password>`, "password_required");
3626
+ throw new CliError(`This ${link.kind === "c" ? "collection" : "transfer"} is password protected.`, `yungle get ${input} --password <password>`, "password_required");
3139
3627
  }
3140
3628
  throw err;
3141
3629
  }
@@ -3150,27 +3638,76 @@ async function get(positionals, flags, json) {
3150
3638
  "e2ee"
3151
3639
  );
3152
3640
  }
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 }));
3641
+ return saveManifest(manifest, {
3642
+ outDir,
3643
+ zip: flags.zip === true,
3644
+ zipName: `yungle-${link.slug}.zip`,
3645
+ json,
3646
+ empty: ` This ${link.kind === "c" ? "collection" : "transfer"} has no files.`
3647
+ });
3648
+ }
3649
+ async function pull(flags, json) {
3650
+ const collectionId = stringFlag(flags.collection);
3651
+ const transferId = stringFlag(flags.transfer);
3652
+ if (!collectionId === !transferId) {
3653
+ throw new CliError("Pull what?", "yungle pull --collection <id> or yungle pull --transfer <id>", "usage", 2);
3654
+ }
3655
+ const api = client(flags);
3656
+ const manifest = collectionId ? await api.collectionDownloadLinks(collectionId) : await api.transferDownloadLinks(transferId);
3657
+ const outDir = resolve3(stringFlag(flags.out) ?? (collectionId ? safeFileName(manifest.title ?? collectionId) : "."));
3658
+ return saveManifest(manifest, {
3659
+ outDir,
3660
+ zip: flags.zip === true,
3661
+ zipName: `yungle-${collectionId ?? transferId}.zip`,
3662
+ json,
3663
+ empty: ` This ${collectionId ? "collection" : "transfer"} has no files.`
3664
+ });
3665
+ }
3666
+ async function saveManifest(manifest, opts) {
3667
+ const { outDir, json } = opts;
3668
+ if (manifest.files.length === 0) return out(json, { saved: [], skipped: [], complete: manifest.complete ?? true }, opts.empty);
3669
+ if (manifest.complete === false && !json) {
3670
+ note(`Still uploading: ${plural(manifest.files.length, "file")} have arrived so far. Run this again later for the rest.`);
3671
+ }
3672
+ const useZip = opts.zip && manifest.zipUrl !== null;
3673
+ const relPaths = dedupePaths(manifest.files.map((f) => ({ dir: safeRelativeDir(f.path), name: safeFileName(f.name) })));
3674
+ 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
3675
  const total = manifest.files.reduce((n3, f) => n3 + f.size, 0);
3157
3676
  heading(`Downloading ${plural(manifest.files.length, "file")} ${sym.dot} ${formatBytes(total)}`);
3158
3677
  let received = 0;
3159
3678
  const started = Date.now();
3160
3679
  const saved = [];
3680
+ const skipped = [];
3161
3681
  for (const [i2, job] of jobs.entries()) {
3162
- const dest = join6(outDir, job.name);
3682
+ const dest = join6(outDir, job.rel);
3683
+ if (job.size !== null && await stat5(dest).then((st) => st.size, () => -1) === job.size) {
3684
+ received += job.size;
3685
+ skipped.push(dest);
3686
+ continue;
3687
+ }
3163
3688
  await downloadTo(job.url, dest, job.size, USER_AGENT, (n3) => {
3164
3689
  received += n3;
3165
3690
  drawProgress(progressLine(Math.min(received, total), total, started, `${i2 + 1}/${jobs.length} files`));
3166
3691
  });
3692
+ const expected = "crc32" in job ? job.crc32 : null;
3693
+ if (expected) {
3694
+ const actual = await fileCrc32(dest);
3695
+ if (actual !== null && actual !== expected) {
3696
+ await unlink(dest).catch(() => void 0);
3697
+ 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");
3698
+ }
3699
+ }
3167
3700
  saved.push(dest);
3168
3701
  }
3169
3702
  endProgress();
3703
+ const skippedNote = skipped.length ? [o.dim(`${plural(skipped.length, "file")} already here, skipped`)] : [];
3170
3704
  return out(
3171
3705
  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`)] : []),
3706
+ { saved, skipped, complete: manifest.complete ?? true, message: manifest.message, expiresAt: manifest.expiresAt },
3707
+ success(
3708
+ saved.length === 0 ? "Nothing new to download" : `Saved ${saved.length === 1 ? shown(saved[0]) : `${plural(saved.length, "file")} to ${shown(outDir)}`}`,
3709
+ [...manifest.message ? [o.italic(`\u201C${manifest.message}\u201D`)] : [], ...skippedNote]
3710
+ ),
3174
3711
  saved.join("\n")
3175
3712
  );
3176
3713
  }
@@ -3261,6 +3798,51 @@ async function collections(flags, json) {
3261
3798
  collections2.map((c2) => `${c2.id} ${c2.title}`).join("\n")
3262
3799
  );
3263
3800
  }
3801
+ async function requests(positionals, flags, json) {
3802
+ const api = client(flags);
3803
+ const [sub, id] = positionals;
3804
+ if (!sub) {
3805
+ const { requests: rows } = await api.listRequests();
3806
+ if (rows.length === 0) return out(json, { requests: rows }, ' No upload requests yet. yungle requests new --collection <id> --title "\u2026"', "");
3807
+ return out(
3808
+ json,
3809
+ { requests: rows },
3810
+ table(
3811
+ rows.map((r2) => [o.dim(r2.id), r2.title, r2.status, plural(r2.submissionCount, "submission"), formatBytes(r2.receivedBytes), accentOut(r2.url)]),
3812
+ { flex: 1, header: ["ID", "Title", "Status", "Received", "Size", "Link"] }
3813
+ ),
3814
+ rows.map((r2) => `${r2.id} ${r2.url}`).join("\n")
3815
+ );
3816
+ }
3817
+ if (sub === "new") {
3818
+ const collectionId = stringFlag(flags.collection);
3819
+ const title = stringFlag(flags.title);
3820
+ 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);
3821
+ const { request: request2 } = await api.createRequest({
3822
+ collectionId,
3823
+ title,
3824
+ ...stringFlag(flags.message) ? { message: stringFlag(flags.message) } : {},
3825
+ ...stringFlag(flags.password) ? { password: stringFlag(flags.password) } : {}
3826
+ });
3827
+ 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);
3828
+ }
3829
+ if (sub === "show" && id) {
3830
+ const { request: request2, submissions } = await api.getRequest(id);
3831
+ return out(
3832
+ json,
3833
+ { request: request2, submissions },
3834
+ [
3835
+ ` ${o.bold(request2.title)} ${o.dim(request2.status)} ${accentOut(request2.url)}`,
3836
+ ...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"] })]
3837
+ ].join("\n"),
3838
+ submissions.map((s) => `${s.id} ${s.fileCount}`).join("\n")
3839
+ );
3840
+ }
3841
+ const status2 = { pause: "paused", resume: "active", close: "closed" }[sub];
3842
+ if (!status2 || !id) throw new CliError(`Unknown: requests ${sub}`, "yungle requests [new|show|pause|resume|close]", "usage", 2);
3843
+ const { request } = await api.setRequestStatus(id, status2);
3844
+ return out(json, { request }, success(`\u201C${request.title}\u201D is ${request.status}`), request.status);
3845
+ }
3264
3846
  async function contacts(flags, json) {
3265
3847
  const { contacts: contacts2 } = await client(flags).listContacts();
3266
3848
  if (contacts2.length === 0) return out(json, { contacts: contacts2 }, " No contacts yet.", "");
@@ -3288,29 +3870,40 @@ async function revoke(positionals, flags, json) {
3288
3870
  async function push(paths, flags, json) {
3289
3871
  const collectionId = stringFlag(flags.collection);
3290
3872
  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);
3873
+ const urls = urlListFlag(flags["from-url"]);
3874
+ if (paths.length === 0 && urls.length === 0) throw new CliError("Nothing to upload.", `yungle push <paths> --collection ${collectionId}`, "usage", 2);
3292
3875
  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) ?? ""}`);
3876
+ const files = paths.length ? await collectFiles(paths) : [];
3877
+ if (files.length === 0 && urls.length === 0) throw new CliError("Those paths contain no files (hidden files are skipped).", void 0, "usage", 2);
3878
+ if (files.length) heading(`Uploading ${plural(files.length, "file")} ${sym.dot} ${formatBytes(files.reduce((n3, f) => n3 + f.size, 0))}`);
3879
+ const key = sessionKey(files, `collection:${collectionId}:${stringFlag(flags.folder) ?? ""}${urls.length ? `:${urls.join("\n")}` : ""}`);
3297
3880
  const existing = await findSession(key);
3298
3881
  let targets;
3299
3882
  if (existing && existing.kind === "collection") {
3300
3883
  note("Resuming the interrupted upload.");
3301
- targets = { tusEndpoint: existing.tusEndpoint, files: existing.files };
3884
+ targets = { tusEndpoint: existing.tusEndpoint, files: existing.files, imports: existing.imports ?? [] };
3302
3885
  } 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 });
3886
+ const fresh = await api.addCollectionFiles(
3887
+ collectionId,
3888
+ toFileInputs(files),
3889
+ stringFlag(flags.folder) ?? null,
3890
+ urls.map((url) => ({ url }))
3891
+ );
3892
+ targets = {
3893
+ tusEndpoint: fresh.tusEndpoint,
3894
+ files: fresh.files.map((f, i2) => ({ ...f, path: files[i2].path })),
3895
+ imports: (fresh.imports ?? []).map((i2) => ({ fileId: i2.fileId, name: i2.name, size: i2.size }))
3896
+ };
3897
+ await saveSession(key, { createdAt: Date.now(), kind: "collection", targetId: collectionId, tusEndpoint: fresh.tusEndpoint, files: targets.files, imports: targets.imports });
3306
3898
  }
3307
- await streamAll(files, targets);
3899
+ if (files.length) await streamAll(files, targets, api, key);
3900
+ if (targets.imports.length) await awaitImports(api, targets.imports);
3308
3901
  await dropSession(key);
3309
3902
  const { collection } = await (await freshClient(flags, api)).getCollection(collectionId);
3310
3903
  return out(
3311
3904
  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)] : []),
3905
+ { collection: collection.id, uploaded: files.length + targets.imports.length, url: collection.url },
3906
+ success(`Added ${plural(files.length + targets.imports.length, "file")} to \u201C${collection.title}\u201D`, collection.url ? [accentOut(collection.url)] : []),
3314
3907
  collection.url ?? collection.id
3315
3908
  );
3316
3909
  }
@@ -3337,8 +3930,9 @@ async function watch(positionals, flags, json) {
3337
3930
  const { ready, seen } = planTick(current, lastSeen, uploaded);
3338
3931
  lastSeen = seen;
3339
3932
  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);
3933
+ const tickApi = await freshClient(flags, api);
3934
+ const target = await tickApi.addCollectionFiles(collectionId, toFileInputs(ready), stringFlag(flags.folder) ?? null);
3935
+ await streamAll(ready, target, tickApi, null);
3342
3936
  for (const f of ready) {
3343
3937
  uploaded.add(fingerprint(f));
3344
3938
  if (json) process.stdout.write(`${JSON.stringify({ uploaded: f.path, size: f.size })}