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