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