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.
- package/README.md +8 -5
- package/dist/yungle.mjs +807 -213
- 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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
423
|
-
|
|
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
|
-
|
|
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]
|
|
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(
|
|
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
|
|
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/
|
|
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/
|
|
1106
|
-
import {
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
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
|
-
|
|
1577
|
+
parsed = JSON.parse(Buffer.from(blob.trim(), "base64url").toString("utf8"));
|
|
1118
1578
|
} catch {
|
|
1119
|
-
throw new Error(
|
|
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
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
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
|
|
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
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
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
|
-
|
|
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.
|
|
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)}
|
|
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,
|
|
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 = {
|
|
3063
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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(
|
|
3304
|
-
|
|
3305
|
-
|
|
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(`
|
|
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
|
|
3341
|
-
await
|
|
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 })}
|