@allocator-one/harness-cli 1.16.0 → 1.17.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.
@@ -0,0 +1,1448 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ browserAvailable,
4
+ loopbackCode
5
+ } from "./chunk-WSDFLMO6.js";
6
+ import {
7
+ CommandError,
8
+ HttpError,
9
+ LocalError,
10
+ MalformedResponseError,
11
+ TransportError,
12
+ UsageError,
13
+ clean,
14
+ cleanMultiline,
15
+ deleteCredentials,
16
+ readCredentials,
17
+ resolveCacheHome,
18
+ validateBaseUrl,
19
+ writeCredentials
20
+ } from "./chunk-BHBEIT5I.js";
21
+ import {
22
+ CLI_VERSION
23
+ } from "./chunk-5THYIKLW.js";
24
+
25
+ // src/infraone/config.ts
26
+ import { createHash } from "crypto";
27
+ import { homedir } from "os";
28
+ import { isAbsolute, join } from "path";
29
+ var DEFAULT_API_URL = "https://api.infra.one/v1";
30
+ function credentialsPath(env = process.env) {
31
+ const xdg = env["XDG_CONFIG_HOME"];
32
+ const base = xdg !== void 0 && xdg !== "" && isAbsolute(xdg) ? xdg : join(homedir(), ".config");
33
+ return join(base, "infraone", "credentials.json");
34
+ }
35
+ function resolveConfig(env = process.env) {
36
+ const path = credentialsPath(env);
37
+ const stored = readCredentials(path);
38
+ const envToken = nonBlank(env["INFRAONE_TOKEN"])?.trim() ?? null;
39
+ const apiUrl = trimSlash(nonBlank(env["INFRAONE_API_URL"]) ?? (envToken === null ? stored?.url : void 0) ?? DEFAULT_API_URL);
40
+ const api = checkUrl(apiUrl, "INFRAONE_API_URL");
41
+ if (!api.ok) return api;
42
+ const explicitLogin = nonBlank(env["INFRAONE_LOGIN_URL"]);
43
+ if (explicitLogin !== null) {
44
+ const login2 = checkUrl(explicitLogin, "INFRAONE_LOGIN_URL");
45
+ if (!login2.ok) return login2;
46
+ }
47
+ const loginUrl = explicitLogin === null ? loginOrigin(apiUrl) : new URL(explicitLogin).origin;
48
+ const storedToken = stored !== null && sameBase(stored.url, apiUrl) ? stored.token : null;
49
+ const token = envToken ?? storedToken;
50
+ return {
51
+ ok: true,
52
+ config: {
53
+ apiUrl,
54
+ loginUrl,
55
+ token,
56
+ tokenSource: envToken !== null ? "environment" : storedToken !== null ? "stored login" : null,
57
+ credentialsPath: path,
58
+ cacheDir: join(resolveCacheHome(env), "infraone")
59
+ }
60
+ };
61
+ }
62
+ function loginOrigin(apiUrl) {
63
+ const url = new URL(apiUrl);
64
+ if (url.hostname.startsWith("api.")) url.hostname = `venture.${url.hostname.slice("api.".length)}`;
65
+ return url.origin;
66
+ }
67
+ function apiUrlFor(apiUrl, path) {
68
+ const base = trimSlash(apiUrl);
69
+ const versioned = /\/v\d+$/.test(base) ? path.replace(/^\/v\d+(?=\/|$)/, "") : path;
70
+ return new URL(base + versioned);
71
+ }
72
+ function baseKey(apiUrl) {
73
+ return createHash("sha256").update(canonical(apiUrl)).digest("hex").slice(0, 16);
74
+ }
75
+ function checkUrl(url, variable) {
76
+ const checked = validateBaseUrl(url);
77
+ if (checked.ok) return checked;
78
+ return {
79
+ ok: false,
80
+ message: checked.message.replaceAll("HARNESS_API_URL", variable).replaceAll("HARNESS_API_TOKEN", "INFRAONE_TOKEN")
81
+ };
82
+ }
83
+ function sameBase(left, right) {
84
+ return canonical(left) === canonical(right);
85
+ }
86
+ function canonical(url) {
87
+ try {
88
+ const parsed = new URL(url);
89
+ return `${parsed.protocol}//${parsed.host.toLowerCase()}${parsed.pathname.replace(/\/+$/, "")}`;
90
+ } catch {
91
+ return url;
92
+ }
93
+ }
94
+ function trimSlash(url) {
95
+ return url.replace(/\/+$/, "");
96
+ }
97
+ function nonBlank(value) {
98
+ return value === void 0 || value.trim() === "" ? null : value;
99
+ }
100
+
101
+ // src/infraone/client.ts
102
+ var RECEIVE_TIMEOUT_MS = 3e4;
103
+ var SPEC_FINGERPRINT_HEADER = "infraone-spec-fingerprint";
104
+ var API_VERSION_HEADER = "infraone-api-version";
105
+ var DEFAULT_MESSAGES = {
106
+ 401: "Not signed in, or the credential has expired or was revoked (run infraone login)",
107
+ 403: "The credential lacks the scope this needs",
108
+ 404: "Not found",
109
+ 409: "Conflict",
110
+ 422: "Validation error",
111
+ 428: "A person must confirm this first",
112
+ 429: "Rate limited \u2014 retry later"
113
+ };
114
+ var InfraoneClient = class {
115
+ constructor(apiUrl, token, fetchImpl) {
116
+ this.apiUrl = apiUrl;
117
+ this.token = token;
118
+ this.fetchImpl = fetchImpl ?? globalThis.fetch;
119
+ }
120
+ apiUrl;
121
+ token;
122
+ fetchImpl;
123
+ /**
124
+ * The credential header for a URL the API handed out (an upload or
125
+ * download URL): the bearer only on the API's own origin, never on a
126
+ * storage host's signed URL.
127
+ */
128
+ authorizationFor(url) {
129
+ if (this.token === null) return {};
130
+ try {
131
+ return new URL(url).origin === new URL(this.apiUrl).origin ? { authorization: `Bearer ${this.token}` } : {};
132
+ } catch {
133
+ return {};
134
+ }
135
+ }
136
+ async request(method, path, options = {}) {
137
+ const url = apiUrlFor(this.apiUrl, path);
138
+ for (const [key, value] of Object.entries(options.query ?? {})) {
139
+ if (value === void 0) continue;
140
+ for (const item of Array.isArray(value) ? value : [value]) url.searchParams.append(key, String(item));
141
+ }
142
+ const headers = {
143
+ accept: "application/json",
144
+ "infraone-client": "cli",
145
+ "user-agent": `infraone/${CLI_VERSION}`
146
+ };
147
+ if (this.token !== null && options.anonymous !== true) headers["authorization"] = `Bearer ${this.token}`;
148
+ if (options.idempotencyKey !== void 0) headers["idempotency-key"] = options.idempotencyKey;
149
+ if (options.confirmed === true) headers["infraone-confirm"] = "yes";
150
+ let body;
151
+ if (options.body !== void 0) {
152
+ headers["content-type"] = "application/json";
153
+ body = JSON.stringify(options.body);
154
+ }
155
+ let response;
156
+ try {
157
+ response = await this.fetchImpl(url, {
158
+ method,
159
+ headers,
160
+ body,
161
+ signal: AbortSignal.timeout(options.timeoutMs ?? RECEIVE_TIMEOUT_MS),
162
+ redirect: "manual"
163
+ });
164
+ } catch (error) {
165
+ throw new TransportError(transportMessage(error), transportCode(error));
166
+ }
167
+ let text;
168
+ try {
169
+ text = await response.text();
170
+ } catch (error) {
171
+ throw new TransportError(transportMessage(error), transportCode(error));
172
+ }
173
+ const parsed = parseObject(text);
174
+ if (!response.ok) {
175
+ const parsedBody = parsed ?? {};
176
+ const message = typeof parsedBody["message"] === "string" && parsedBody["message"] !== "" ? parsedBody["message"] : DEFAULT_MESSAGES[response.status] ?? `HTTP ${response.status}`;
177
+ throw new HttpError(response.status, message, parsedBody, response.headers.get("retry-after"));
178
+ }
179
+ if (parsed === null && text !== "") {
180
+ throw new MalformedResponseError(`the API answered with a non-JSON body (HTTP ${response.status})`);
181
+ }
182
+ return {
183
+ status: response.status,
184
+ body: parsed ?? {},
185
+ apiVersion: response.headers.get(API_VERSION_HEADER),
186
+ fingerprint: response.headers.get(SPEC_FINGERPRINT_HEADER)
187
+ };
188
+ }
189
+ };
190
+ function parseObject(text) {
191
+ if (text === "") return null;
192
+ try {
193
+ const value = JSON.parse(text);
194
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
195
+ } catch {
196
+ return null;
197
+ }
198
+ }
199
+ function transportCode(error) {
200
+ const cause = error instanceof Error ? error.cause : void 0;
201
+ if (typeof cause !== "object" || cause === null) return null;
202
+ const code = cause.code;
203
+ return typeof code === "string" ? code : null;
204
+ }
205
+ function transportMessage(error) {
206
+ if (error instanceof Error) {
207
+ if (error.name === "TimeoutError" || error.name === "AbortError") return "request timed out";
208
+ const cause = error.cause;
209
+ if (cause instanceof Error && cause.message !== "") return cause.message;
210
+ return error.message;
211
+ }
212
+ return String(error);
213
+ }
214
+
215
+ // src/infraone/auth.ts
216
+ var SESSION_PATH = "/v1/session";
217
+ var EXCHANGE_PATH = "/v1/cli/token-exchange";
218
+ function loginPageUrl(config) {
219
+ return `${config.loginUrl}/cli/login?client=infraone`;
220
+ }
221
+ async function login(config, options = {}) {
222
+ const notify = options.notify ?? (() => void 0);
223
+ const canOpen = options.openBrowser !== void 0 || browserAvailable({ env: options.env ?? process.env });
224
+ if (options.noBrowser === true || !canOpen) {
225
+ if (options.interactive === false || options.prompt === void 0) {
226
+ return { ok: false, message: "--no-browser needs a terminal to paste the token into; for scripts, set INFRAONE_TOKEN" };
227
+ }
228
+ notify(`Open ${loginPageUrl(config)} in a browser, authorize, and paste the token below.`);
229
+ const token = (await options.prompt()).trim();
230
+ if (token === "") return { ok: false, message: "no token entered" };
231
+ return verifyAndStore(config, token, { minted: false, kind: null }, options);
232
+ }
233
+ const callback = await loopbackCode((query) => {
234
+ query.set("client", "infraone");
235
+ return `${config.loginUrl}/cli/login?${query.toString()}`;
236
+ }, {
237
+ notify,
238
+ ...options.openBrowser === void 0 ? {} : { openBrowser: options.openBrowser },
239
+ ...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs }
240
+ });
241
+ if (!callback.ok) return callback;
242
+ try {
243
+ const exchanged = await exchangeCode(config, callback.code, callback.verifier, options);
244
+ if (!exchanged.ok) {
245
+ await callback.respond(400, "Infra One could not exchange the browser authorization. Return to the terminal for details.", "error");
246
+ return exchanged;
247
+ }
248
+ const result = await verifyAndStore(config, exchanged.token, { minted: true, kind: exchanged.kind }, options);
249
+ await (result.ok ? callback.respond(200, "You can close this browser and return to the terminal.", "success") : callback.respond(400, "Infra One could not verify and store this login. Return to the terminal for details.", "error"));
250
+ return result;
251
+ } catch (error) {
252
+ await callback.respond(500, "Infra One could not finish the login. Return to the terminal for details.", "error");
253
+ const detail = error instanceof Error ? error.message : String(error);
254
+ return { ok: false, message: `could not finish the browser login: ${detail}` };
255
+ } finally {
256
+ callback.close();
257
+ }
258
+ }
259
+ async function exchangeCode(config, code, verifier, options) {
260
+ try {
261
+ const answer = await new InfraoneClient(config.apiUrl, null, options.fetchImpl).request("POST", EXCHANGE_PATH, {
262
+ body: { code, code_verifier: verifier },
263
+ anonymous: true
264
+ });
265
+ const data = isRecord(answer.body["data"]) ? answer.body["data"] : answer.body;
266
+ const token = data["token"];
267
+ const kind = typeof data["kind"] === "string" ? data["kind"] : null;
268
+ if (typeof token === "string" && token !== "") return { ok: true, token, kind };
269
+ return { ok: false, message: "the login-code exchange returned no token \u2014 aborting" };
270
+ } catch (error) {
271
+ if (error instanceof HttpError) return { ok: false, message: `the login-code exchange failed: ${error.message}` };
272
+ if (error instanceof TransportError) {
273
+ return { ok: false, message: `the login-code exchange failed: cannot reach the API (${error.message})` };
274
+ }
275
+ throw error;
276
+ }
277
+ }
278
+ async function verifyAndStore(config, token, received, options) {
279
+ const client = new InfraoneClient(config.apiUrl, token, options.fetchImpl);
280
+ let session;
281
+ try {
282
+ session = await describeSession(client);
283
+ } catch (error) {
284
+ if (error instanceof HttpError) {
285
+ const revoked = received.minted && await revokeSession(client);
286
+ return {
287
+ ok: false,
288
+ message: `the received token failed verification (${error.message}, HTTP ${error.status}) \u2014 nothing was stored` + (revoked ? ", and the token was revoked" : "")
289
+ };
290
+ }
291
+ if (error instanceof TransportError) {
292
+ const revoked = received.minted && await revokeSession(client);
293
+ return {
294
+ ok: false,
295
+ message: `could not verify the token: cannot reach the API (${error.message}) \u2014 nothing was stored` + (revoked ? ", and the token was revoked" : received.minted ? "; it expires on its own in 30 days" : "")
296
+ };
297
+ }
298
+ throw error;
299
+ }
300
+ const stored = readCredentials(config.credentialsPath);
301
+ try {
302
+ writeCredentials(config.credentialsPath, config.apiUrl, token);
303
+ } catch (error) {
304
+ if (received.minted) await revokeSession(client);
305
+ const detail = error instanceof Error ? error.message : String(error);
306
+ return {
307
+ ok: false,
308
+ message: `could not store credentials at ${config.credentialsPath}: ${detail}` + (received.minted ? "" : " (the pasted token stays valid)")
309
+ };
310
+ }
311
+ if (stored !== null && stored.token !== token) {
312
+ await revokeSession(new InfraoneClient(stored.url, stored.token, options.fetchImpl));
313
+ }
314
+ const kind = session?.kind ?? received.kind;
315
+ return {
316
+ ok: true,
317
+ json: { url: config.apiUrl, credentials: config.credentialsPath, kind, verified: session !== null, org: session?.org ?? null },
318
+ human: `Logged in to ${config.apiUrl}${orgSuffix(session)} \u2014 stored at ${config.credentialsPath} (valid ~30 days; infraone logout revokes it).
319
+ ` + (session === null ? "This server can't describe the credential yet, so it was stored unverified.\n" : "")
320
+ };
321
+ }
322
+ async function logout(config, options = {}) {
323
+ const stored = readCredentials(config.credentialsPath);
324
+ if (stored === null) {
325
+ const envToken2 = (options.env?.["INFRAONE_TOKEN"] ?? "").trim() !== "";
326
+ return {
327
+ ok: false,
328
+ message: envToken2 ? "no stored login to remove: the INFRAONE_TOKEN in the environment is yours to manage \u2014 unset it" : "no stored login to remove"
329
+ };
330
+ }
331
+ const revoked = await revokeSession(new InfraoneClient(stored.url, stored.token, options.fetchImpl));
332
+ try {
333
+ deleteCredentials(config.credentialsPath);
334
+ } catch (error) {
335
+ const detail = error instanceof Error ? error.message : String(error);
336
+ return {
337
+ ok: false,
338
+ message: `could not remove ${config.credentialsPath} (${detail}) \u2014 delete it manually.` + (revoked ? " The token itself was revoked." : "")
339
+ };
340
+ }
341
+ const envToken = (options.env?.["INFRAONE_TOKEN"] ?? "").trim() !== "";
342
+ return {
343
+ ok: true,
344
+ json: { logged_out: true, revoked, environment_token: envToken },
345
+ human: `Logged out of ${stored.url}${revoked ? "" : " (the server did not revoke the token \u2014 it still expires on its own)"}.
346
+ ` + (envToken ? "INFRAONE_TOKEN is still set, so commands keep using it: unset it too.\n" : "")
347
+ };
348
+ }
349
+ async function whoami(config, client) {
350
+ if (config.token === null) {
351
+ return { ok: false, message: `not signed in to ${config.apiUrl}: run \`infraone login\`, or set INFRAONE_TOKEN for scripts` };
352
+ }
353
+ let session;
354
+ try {
355
+ session = await describeSession(client);
356
+ } catch (error) {
357
+ if (error instanceof HttpError) {
358
+ return {
359
+ ok: false,
360
+ message: `signed in to ${config.apiUrl} with a ${config.tokenSource} credential, but it failed verification: ${error.message} (HTTP ${error.status})`
361
+ };
362
+ }
363
+ throw error;
364
+ }
365
+ const lines = [`API: ${config.apiUrl}`, `Credential: ${config.tokenSource}${session?.kind ? `, ${clean(session.kind)}` : ""}`];
366
+ if (session?.user) lines.push(`User: ${person(session.user)}`);
367
+ if (session?.org) lines.push(`Org: ${orgName(session.org)}`);
368
+ if (session?.scopes) lines.push(`Scopes: ${session.scopes.map(clean).join(" ")}`);
369
+ if (session?.expires_at) lines.push(`Expires: ${clean(session.expires_at)}`);
370
+ lines.push(`Verified: ${session === null ? "no \u2014 this server can't describe credentials yet" : "ok"}`);
371
+ return {
372
+ ok: true,
373
+ json: {
374
+ url: config.apiUrl,
375
+ source: config.tokenSource,
376
+ verified: session !== null,
377
+ kind: session?.kind ?? null,
378
+ user: session?.user ?? null,
379
+ org: session?.org ?? null,
380
+ scopes: session?.scopes ?? null,
381
+ expires_at: session?.expires_at ?? null
382
+ },
383
+ human: lines.join("\n") + "\n"
384
+ };
385
+ }
386
+ async function describeSession(client) {
387
+ try {
388
+ const answer = await client.request("GET", SESSION_PATH);
389
+ const data = isRecord(answer.body["data"]) ? answer.body["data"] : {};
390
+ const text = (value) => typeof value === "string" ? value : null;
391
+ return {
392
+ kind: text(data["kind"]),
393
+ expires_at: text(data["expires_at"]),
394
+ scopes: Array.isArray(data["scopes"]) ? data["scopes"].filter((scope) => typeof scope === "string") : null,
395
+ user: isRecord(data["user"]) ? data["user"] : null,
396
+ org: isRecord(data["org"]) ? data["org"] : null
397
+ };
398
+ } catch (error) {
399
+ if (error instanceof HttpError && (error.status === 404 || error.status === 405)) return null;
400
+ throw error;
401
+ }
402
+ }
403
+ async function revokeSession(client) {
404
+ try {
405
+ await client.request("DELETE", SESSION_PATH);
406
+ return true;
407
+ } catch {
408
+ return false;
409
+ }
410
+ }
411
+ function orgSuffix(session) {
412
+ return session?.org ? `, org ${orgName(session.org)}` : "";
413
+ }
414
+ function orgName(org) {
415
+ const name = clean(org["name"]);
416
+ const slug = clean(org["slug"]);
417
+ return slug === "" || slug === name ? name : `${name} (${slug})`;
418
+ }
419
+ function person(user) {
420
+ const name = clean(user["name"]);
421
+ const email = clean(user["email"]);
422
+ return name === "" ? email : email === "" ? name : `${name} <${email}>`;
423
+ }
424
+ function isRecord(value) {
425
+ return typeof value === "object" && value !== null && !Array.isArray(value);
426
+ }
427
+
428
+ // src/core/transfer.ts
429
+ import { lstat, readdir, realpath as realpath2, stat as stat2 } from "fs/promises";
430
+ import { basename, extname, join as join2, relative, resolve, sep as sep2 } from "path";
431
+ import { randomUUID } from "crypto";
432
+ import { Option } from "commander";
433
+
434
+ // src/core/transfer-download.ts
435
+ import { randomBytes } from "crypto";
436
+ import { link, open, rename, rm } from "fs/promises";
437
+
438
+ // src/core/transfer-errors.ts
439
+ var PERMANENT = /^(ENOTFOUND|CERT_|ERR_TLS_|DEPTH_ZERO_SELF_SIGNED_CERT|SELF_SIGNED_CERT_IN_CHAIN|UNABLE_TO_(GET_ISSUER_CERT|VERIFY_LEAF_SIGNATURE))/;
440
+ function failureCode(error) {
441
+ const cause = error instanceof Error ? error.cause : void 0;
442
+ const code = typeof cause === "object" && cause !== null ? cause.code : void 0;
443
+ return typeof code === "string" ? code : null;
444
+ }
445
+ function transient(error) {
446
+ return !PERMANENT.test(failureCode(error) ?? "");
447
+ }
448
+ function failureMessage(error) {
449
+ if (!(error instanceof Error)) return redact(String(error));
450
+ const cause = error.cause;
451
+ return redact(cause instanceof Error && cause.message !== "" ? `${error.message}: ${cause.message}` : error.message);
452
+ }
453
+ function redact(text) {
454
+ return text.replace(/\b(https?:\/\/[^\s?#"'<>]*)[?#][^\s"'<>]*/gi, "$1?\u2026");
455
+ }
456
+
457
+ // src/core/transfer-download.ts
458
+ var DownloadError = class extends CommandError {
459
+ constructor(message) {
460
+ super(message);
461
+ this.name = "DownloadError";
462
+ }
463
+ };
464
+ var DEFAULT_RETRIES = 8;
465
+ var IDLE_TIMEOUT_MS = 12e4;
466
+ var Interrupted = class extends Error {
467
+ };
468
+ async function downloadFile(url, dest, options = {}) {
469
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
470
+ const retries = options.retries ?? DEFAULT_RETRIES;
471
+ const backoff = options.backoff ?? ((attempt) => Math.min(3e4, 500 * 2 ** (attempt - 1)));
472
+ const idleMs = options.timeoutMs ?? IDLE_TIMEOUT_MS;
473
+ const part = `${dest}.${randomBytes(6).toString("hex")}.part`;
474
+ let current = url;
475
+ let refreshed = false;
476
+ let written = 0;
477
+ let total2 = null;
478
+ let ranges = false;
479
+ let validator = null;
480
+ let failures = 0;
481
+ let mark = -1;
482
+ const file = await open(part, "wx", 384);
483
+ try {
484
+ for (; ; ) {
485
+ const controller = new AbortController();
486
+ let idle = setTimeout(() => controller.abort(), idleMs);
487
+ try {
488
+ const resuming = written > 0 && ranges;
489
+ let response;
490
+ try {
491
+ response = await fetchImpl(current, {
492
+ // Identity: a decoded body matches neither Content-Length nor a byte Range.
493
+ headers: {
494
+ "accept-encoding": "identity",
495
+ ...resuming && validator !== null ? { range: `bytes=${written}-`, "if-range": validator } : {}
496
+ },
497
+ signal: controller.signal,
498
+ // Signed URLs carry no credential, so following a redirect leaks nothing.
499
+ redirect: "follow"
500
+ });
501
+ } catch (error) {
502
+ if (!transient(error)) throw new DownloadError(`cannot reach the download server: ${failureMessage(error)}`);
503
+ throw new Interrupted(failureMessage(error));
504
+ }
505
+ if ((response.status === 403 || response.status === 410) && options.refresh !== void 0 && !refreshed) {
506
+ await response.body?.cancel();
507
+ refreshed = true;
508
+ current = await options.refresh();
509
+ continue;
510
+ }
511
+ if (response.status === 429 || response.status >= 500) {
512
+ await response.body?.cancel();
513
+ throw new Interrupted(`the download server answered HTTP ${response.status}`);
514
+ }
515
+ if (response.status === 204) throw new DownloadError("there is nothing to download: it is empty");
516
+ if (response.status === 416 && resuming) {
517
+ await response.body?.cancel().catch(() => void 0);
518
+ written = 0;
519
+ ranges = false;
520
+ await file.truncate(0);
521
+ throw new Interrupted("the resumed range no longer fits the object; starting over");
522
+ }
523
+ if (response.status !== 200 && response.status !== 206) {
524
+ await response.body?.cancel();
525
+ throw new DownloadError(`the download was refused (HTTP ${response.status})`);
526
+ }
527
+ if (response.status === 206) {
528
+ const range = contentRange(response.headers.get("content-range"));
529
+ if (!resuming) {
530
+ await response.body?.cancel().catch(() => void 0);
531
+ throw new DownloadError("the download server answered a range nobody asked for");
532
+ }
533
+ const same = response.headers.get("etag") === validator;
534
+ if (!same || range === null || range.start !== written || range.end !== (total2 ?? 0) - 1 || range.total !== total2) {
535
+ await response.body?.cancel().catch(() => void 0);
536
+ written = 0;
537
+ ranges = false;
538
+ await file.truncate(0);
539
+ throw new Interrupted("the download server answered a different range; starting over");
540
+ }
541
+ } else {
542
+ written = 0;
543
+ await file.truncate(0);
544
+ const length = response.headers.get("content-length");
545
+ total2 = length !== null && /^\d+$/.test(length) ? Number(length) : null;
546
+ const etag = response.headers.get("etag");
547
+ validator = etag !== null && !etag.startsWith("W/") ? etag : null;
548
+ ranges = response.headers.get("accept-ranges")?.toLowerCase() === "bytes" && total2 !== null && validator !== null;
549
+ }
550
+ if (response.body === null) throw new Interrupted("the download server sent no body");
551
+ const reader = response.body.getReader();
552
+ for (; ; ) {
553
+ let chunk;
554
+ try {
555
+ chunk = await reader.read();
556
+ } catch (error) {
557
+ throw new Interrupted(failureMessage(error));
558
+ }
559
+ if (chunk.done) break;
560
+ clearTimeout(idle);
561
+ idle = setTimeout(() => controller.abort(), idleMs);
562
+ for (let done = 0; done < chunk.value.length; ) {
563
+ const { bytesWritten } = await file.write(chunk.value, done, chunk.value.length - done, written + done);
564
+ done += bytesWritten;
565
+ }
566
+ written += chunk.value.length;
567
+ options.onProgress?.(written, total2);
568
+ }
569
+ if (total2 !== null && written !== total2) throw new Interrupted(`the download ended at ${written} of ${total2} bytes`);
570
+ await file.close();
571
+ await place(part, dest, options.force === true);
572
+ return written;
573
+ } catch (error) {
574
+ if (!(error instanceof Interrupted)) throw error;
575
+ if (ranges && written > mark) {
576
+ failures = 0;
577
+ mark = written;
578
+ }
579
+ failures += 1;
580
+ if (failures > retries) throw new TransportError(`the download gave up after ${retries} retries: ${error.message}`);
581
+ await sleep(backoff(failures));
582
+ } finally {
583
+ clearTimeout(idle);
584
+ }
585
+ }
586
+ } catch (error) {
587
+ await file.close().catch(() => void 0);
588
+ await rm(part, { force: true });
589
+ throw error;
590
+ }
591
+ }
592
+ async function place(part, dest, force) {
593
+ if (force) return rename(part, dest);
594
+ try {
595
+ await link(part, dest);
596
+ } catch (error) {
597
+ const code = error.code;
598
+ if (code === "EEXIST") throw new DownloadError(`${dest} appeared while downloading \u2014 pass --force to replace it`);
599
+ if (code === "EPERM" || code === "ENOTSUP" || code === "EXDEV" || code === "ENOSYS") {
600
+ throw new DownloadError(
601
+ `this file system can't move ${dest} into place without risking another file there \u2014 pass --force, or --output on another disk`
602
+ );
603
+ }
604
+ throw error;
605
+ }
606
+ await rm(part, { force: true });
607
+ }
608
+ function contentRange(header) {
609
+ const match = /^bytes (\d+)-(\d+)\/(\d+)$/i.exec(header?.trim() ?? "");
610
+ return match === null ? null : { start: Number(match[1]), end: Number(match[2]), total: Number(match[3]) };
611
+ }
612
+ function sleep(ms) {
613
+ return ms <= 0 ? Promise.resolve() : new Promise((resolve2) => setTimeout(resolve2, ms));
614
+ }
615
+
616
+ // src/core/transfer-upload.ts
617
+ import { constants } from "fs";
618
+ import { open as open2, realpath, stat } from "fs/promises";
619
+ import { sep } from "path";
620
+ var CHUNK_QUANTUM = 256 * 1024;
621
+ var DEFAULT_CHUNK_SIZE = 32 * CHUNK_QUANTUM;
622
+ var UploadError = class extends CommandError {
623
+ constructor(message) {
624
+ super(message);
625
+ this.name = "UploadError";
626
+ }
627
+ };
628
+ var DEFAULT_RETRIES2 = 8;
629
+ var CHUNK_TIMEOUT_MS = 12e4;
630
+ var SLOW_BYTES_PER_MS = 8;
631
+ function defaultBackoff(attempt) {
632
+ return Math.min(3e4, 500 * 2 ** (attempt - 1));
633
+ }
634
+ async function uploadFile(path, size, target, options = {}) {
635
+ const chunkSize = options.chunkSize ?? DEFAULT_CHUNK_SIZE;
636
+ if (chunkSize <= 0 || chunkSize % CHUNK_QUANTUM !== 0) throw new Error(`chunk size must be a positive multiple of ${CHUNK_QUANTUM}`);
637
+ const retries = options.retries ?? DEFAULT_RETRIES2;
638
+ const backoff = options.backoff ?? defaultBackoff;
639
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
640
+ const timeoutFor = (bytes2) => options.timeoutMs ?? CHUNK_TIMEOUT_MS + Math.ceil(bytes2 / SLOW_BYTES_PER_MS);
641
+ const file = await open2(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)).catch((error) => {
642
+ if (error.code === "ELOOP") throw new UploadError(`${path} became a symbolic link \u2014 nothing more was sent`);
643
+ throw error;
644
+ });
645
+ const buffer = Buffer.allocUnsafe(Math.min(chunkSize, Math.max(size, 1)));
646
+ try {
647
+ const info = await file.stat();
648
+ const swapped = options.identity !== void 0 && (info.dev !== options.identity.dev || info.ino !== options.identity.ino);
649
+ if (!info.isFile() || info.size !== size || swapped) {
650
+ throw new UploadError(`${path} changed since it was listed \u2014 nothing more was sent`);
651
+ }
652
+ if (options.within !== void 0) {
653
+ const real = await realpath(path).catch(() => "");
654
+ const now = real === "" ? null : await stat(real).catch(() => null);
655
+ if (!real.startsWith(options.within + sep) || now?.dev !== info.dev || now?.ino !== info.ino) {
656
+ throw new UploadError(`${path} no longer lies inside the folder being uploaded \u2014 nothing more was sent`);
657
+ }
658
+ }
659
+ const unchanged = async () => {
660
+ const after = await file.stat();
661
+ if (after.size !== info.size || after.mtimeMs !== info.mtimeMs || after.ctimeMs !== info.ctimeMs) {
662
+ throw new UploadError(`${path} was changed while it was being uploaded \u2014 the upload was not finished`);
663
+ }
664
+ };
665
+ let offset = 0;
666
+ let failures = 0;
667
+ let best = 0;
668
+ let resync = false;
669
+ for (; ; ) {
670
+ try {
671
+ if (resync) {
672
+ const status2 = await put(fetchImpl, target, null, `bytes */${size}`, timeoutFor(0));
673
+ if (status2.done) return await unchanged();
674
+ offset = status2.next;
675
+ resync = false;
676
+ if (offset > best) {
677
+ best = offset;
678
+ failures = 0;
679
+ }
680
+ }
681
+ if (offset >= size && size > 0) {
682
+ resync = true;
683
+ throw new TransportError("the upload ended without the server confirming it");
684
+ }
685
+ const length = Math.min(chunkSize, size - offset);
686
+ const bytes2 = length === 0 ? Buffer.alloc(0) : await readExactly(file, buffer, length, offset, path);
687
+ const range = size === 0 ? `bytes */0` : `bytes ${offset}-${offset + length - 1}/${size}`;
688
+ const status = await put(fetchImpl, target, bytes2, range, timeoutFor(length));
689
+ if (status.done) {
690
+ options.onProgress?.(size, size);
691
+ return await unchanged();
692
+ }
693
+ if (status.next < offset || status.next > offset + length) {
694
+ throw new UploadError(`the server reported ${status.next} bytes stored after a chunk ending at ${offset + length}`);
695
+ }
696
+ if (status.next === offset) throw new RetryableStatus(308);
697
+ offset = status.next;
698
+ if (offset > best) {
699
+ best = offset;
700
+ failures = 0;
701
+ }
702
+ options.onProgress?.(offset, size);
703
+ } catch (error) {
704
+ if (!retryable(error)) throw error;
705
+ failures += 1;
706
+ if (failures > retries) {
707
+ const detail = failureMessage(error);
708
+ throw new UploadError(`gave up after ${retries} retries: ${detail}`);
709
+ }
710
+ resync = true;
711
+ await sleep2(backoff(failures));
712
+ }
713
+ }
714
+ } finally {
715
+ await file.close();
716
+ }
717
+ }
718
+ var RetryableStatus = class extends Error {
719
+ constructor(status) {
720
+ super(`the upload server answered HTTP ${status}`);
721
+ this.status = status;
722
+ }
723
+ status;
724
+ };
725
+ async function put(fetchImpl, target, body, contentRange2, timeoutMs) {
726
+ let response;
727
+ try {
728
+ response = await fetchImpl(target.url, {
729
+ method: "PUT",
730
+ headers: { ...target.headers, "content-range": contentRange2 },
731
+ // An empty body still goes as one, so Content-Length is 0.
732
+ body: body === null ? new Uint8Array(0) : new Uint8Array(body.buffer, body.byteOffset, body.length),
733
+ signal: AbortSignal.timeout(timeoutMs),
734
+ redirect: "manual"
735
+ });
736
+ await response.body?.cancel();
737
+ } catch (error) {
738
+ if (!transient(error)) throw new UploadError(`cannot reach the upload server: ${failureMessage(error)}`);
739
+ throw new TransportError(failureMessage(error), failureCode(error));
740
+ }
741
+ if (response.status === 200 || response.status === 201) return { done: true, next: Number.NaN };
742
+ if (response.status === 308) return { done: false, next: storedUpTo(response.headers.get("range")) };
743
+ if (response.status === 429 || response.status >= 500) throw new RetryableStatus(response.status);
744
+ throw new UploadError(`the upload server refused the bytes (HTTP ${response.status})`);
745
+ }
746
+ function storedUpTo(range) {
747
+ if (range === null) return 0;
748
+ const match = /^bytes=0-(\d+)$/i.exec(range.trim());
749
+ if (match === null) throw new UploadError(`the upload server sent an unreadable Range header: ${range}`);
750
+ return Number(match[1]) + 1;
751
+ }
752
+ async function readExactly(file, buffer, length, position, path) {
753
+ let read = 0;
754
+ while (read < length) {
755
+ const { bytesRead } = await file.read(buffer, read, length - read, position + read);
756
+ if (bytesRead === 0) throw new UploadError(`${path} shrank while it was being uploaded`);
757
+ read += bytesRead;
758
+ }
759
+ return buffer.subarray(0, length);
760
+ }
761
+ function retryable(error) {
762
+ return error instanceof TransportError || error instanceof RetryableStatus;
763
+ }
764
+ function sleep2(ms) {
765
+ return ms <= 0 ? Promise.resolve() : new Promise((resolve2) => setTimeout(resolve2, ms));
766
+ }
767
+
768
+ // src/core/transfer.ts
769
+ var FILES_PER_SESSION = 20;
770
+ var FOLDER_FILES_PER_SESSION = 100;
771
+ var POLL_LIMIT_MS = 10 * 6e4;
772
+ var upload = {
773
+ // The session's create operation; its finalize and chunk operations run as
774
+ // ordinary requests.
775
+ handles: (spec) => spec.method === "POST" && spec.bodyFields.some((field) => field.name === "files"),
776
+ supplies: ["files"],
777
+ configure(command) {
778
+ command.argument("<paths...>", "the files and folders to upload (a folder keeps its tree)").addOption(
779
+ new Option("--on-conflict <how>", "for a name that already exists: keep both, replace it (confirmed), or skip the new file").choices(["keep", "replace", "skip"]).default("keep")
780
+ ).option("-y, --yes", "confirm --on-conflict replace without the prompt");
781
+ },
782
+ async run(context) {
783
+ const { files, left } = await collectFiles(context.invocation.extra);
784
+ for (const note of left) context.stderr(`Skipped ${clean(note)}
785
+ `);
786
+ const onConflict = String(context.invocation.options["onConflict"] ?? "keep");
787
+ const confirmed = onConflict === "replace" ? await confirmReplace(context) : context.confirmed;
788
+ const base = context.command.path;
789
+ const finalize = context.operations.find((operation) => operation.method === "POST" && isChild(base, operation.path, "/finalize"));
790
+ const show = context.operations.find((operation) => operation.method === "GET" && isChild(base, operation.path, ""));
791
+ if (finalize === void 0) throw new CommandError("this API has no finalize operation for its upload sessions \u2014 nothing was sent");
792
+ const batches = batch(files);
793
+ context.stderr(`Uploading ${files.length} file${files.length === 1 ? "" : "s"} (${bytes(total(files))})\u2026
794
+ `);
795
+ const results = [];
796
+ for (const files2 of batches) {
797
+ try {
798
+ results.push(await uploadSession(context, files2, { onConflict, confirmed, show: show !== void 0 }));
799
+ } catch (error) {
800
+ if (results.length === 0) throw error;
801
+ const detail = error instanceof Error ? error.message : String(error);
802
+ const filed = results.map((result) => clean(result["id"])).join(", ");
803
+ const unsettled = error instanceof Unsettled ? `, and session ${clean(error.sessionId)} may be too` : "";
804
+ throw new CommandError(
805
+ `${detail} \u2014 ${results.length} of ${batches.length} uploads were already filed (sessions ${filed})${unsettled}; upload only the files not in those sessions`
806
+ );
807
+ }
808
+ }
809
+ return results.length === 1 ? results[0] : results;
810
+ }
811
+ };
812
+ async function uploadSession(context, files, plan) {
813
+ const created = await context.client.request(context.command.method, context.path, {
814
+ query: context.invocation.query,
815
+ body: {
816
+ ...context.invocation.body ?? {},
817
+ files: files.map((file) => ({
818
+ name: file.name,
819
+ size: file.size,
820
+ content_type: contentType(file.name),
821
+ ...file.relativePath === null ? {} : { relative_path: file.relativePath }
822
+ }))
823
+ },
824
+ idempotencyKey: randomUUID()
825
+ });
826
+ const session = record(created.body["data"]);
827
+ const id = typeof session["id"] === "string" ? session["id"] : null;
828
+ if (id === null || !Array.isArray(session["files"])) throw new CommandError("the API answered the upload session without an id or files");
829
+ const conflicts = Array.isArray(session["conflicts"]) ? session["conflicts"].length : 0;
830
+ if (conflicts > 0) {
831
+ context.stderr(`${conflicts} name${conflicts === 1 ? "" : "s"} already exist${conflicts === 1 ? "s" : ""} there: --on-conflict ${plan.onConflict} decides.
832
+ `);
833
+ }
834
+ const remote = session["files"].map(record);
835
+ const taken = /* @__PURE__ */ new Set();
836
+ for (const file of files) {
837
+ const label = file.relativePath ?? file.name;
838
+ const entry = remote.find(
839
+ (candidate) => !taken.has(candidate) && candidate["name"] === file.name && (candidate["relative_path"] ?? null) === file.relativePath
840
+ );
841
+ if (entry === void 0 || typeof entry["upload_url"] !== "string") throw new CommandError(`the upload session has no upload_url for ${clean(label)}`);
842
+ taken.add(entry);
843
+ const url = entry["upload_url"];
844
+ await uploadFile(file.path, file.size, { url, headers: { "content-type": "application/octet-stream", ...context.client.authorizationFor(url) } }, {
845
+ onProgress: progress(context, label, file.size),
846
+ identity: file.identity,
847
+ ...file.within === null ? {} : { within: file.within }
848
+ });
849
+ if (!context.interactive) context.stderr(`Uploaded ${clean(label)} (${bytes(file.size)})
850
+ `);
851
+ }
852
+ const sessionPath = `${context.path}/${encodeURIComponent(id)}`;
853
+ let finalized;
854
+ try {
855
+ finalized = await context.client.request("POST", `${sessionPath}/finalize`, {
856
+ body: { on_conflict: plan.onConflict },
857
+ idempotencyKey: randomUUID(),
858
+ confirmed: plan.confirmed
859
+ });
860
+ } catch (error) {
861
+ if (!(error instanceof TransportError) && !(error instanceof HttpError && error.status >= 500)) throw error;
862
+ throw new Unsettled(
863
+ id,
864
+ `the upload's finalize may or may not have taken effect (${error.message}) \u2014 check session ${clean(id)} before uploading these files again`
865
+ );
866
+ }
867
+ const outcome = { ...record(finalized.body["data"]), id };
868
+ if (!plan.show || outcome["status"] === "nothing_to_upload") return outcome;
869
+ return { ...outcome, ...await settle(context, sessionPath, id), id };
870
+ }
871
+ var Unsettled = class extends CommandError {
872
+ constructor(sessionId, message) {
873
+ super(message);
874
+ this.sessionId = sessionId;
875
+ }
876
+ sessionId;
877
+ };
878
+ async function settle(context, path, id) {
879
+ const deadline = Date.now() + POLL_LIMIT_MS;
880
+ let wait = 500;
881
+ for (; ; ) {
882
+ let session;
883
+ try {
884
+ session = record((await context.client.request("GET", path)).body["data"]);
885
+ } catch (error) {
886
+ const passing = error instanceof TransportError || error instanceof HttpError && (error.status === 429 || error.status >= 500);
887
+ if (!passing || Date.now() > deadline) {
888
+ const detail = error instanceof Error ? error.message : String(error);
889
+ throw new Unsettled(id, `the upload was finalized, but its status could not be read (${path}): ${detail} \u2014 do not upload it again`);
890
+ }
891
+ await new Promise((resolve2) => setTimeout(resolve2, wait));
892
+ wait = Math.min(5e3, wait * 2);
893
+ continue;
894
+ }
895
+ const status = session["status"];
896
+ if (status === "completed") return { status, result: session["result"] ?? null };
897
+ if (status === "failed") throw new CommandError(`the upload was received but could not be filed (session ${clean(id)})`);
898
+ if (Date.now() > deadline) {
899
+ context.stderr(`Still filing the upload; check its status later (session ${clean(session["id"])}).
900
+ `);
901
+ return { status };
902
+ }
903
+ await new Promise((resolve2) => setTimeout(resolve2, wait));
904
+ wait = Math.min(5e3, wait * 2);
905
+ }
906
+ }
907
+ function batch(files) {
908
+ const batches = [];
909
+ for (const [group, limit] of [
910
+ [files.filter((file) => file.relativePath === null), FILES_PER_SESSION],
911
+ [files.filter((file) => file.relativePath !== null), FOLDER_FILES_PER_SESSION]
912
+ ]) {
913
+ for (let start = 0; start < group.length; start += limit) batches.push(group.slice(start, start + limit));
914
+ }
915
+ return batches;
916
+ }
917
+ function isChild(base, path, suffix) {
918
+ return path.startsWith(`${base}/`) && new RegExp(`^/\\{[a-z][a-z0-9_]*\\}${suffix}$`).test(path.slice(base.length));
919
+ }
920
+ async function confirmReplace(context) {
921
+ if (context.invocation.yes || context.confirmed) return true;
922
+ if (!context.interactive) {
923
+ throw new UsageError("--on-conflict replace overwrites files and needs your confirmation: run it at a terminal, or pass --yes \u2014 nothing was sent");
924
+ }
925
+ const answer = await context.prompt(`Replace files of the same name in ${await context.target()}? [y/N] `);
926
+ if (!/^y(es)?$/i.test(answer.trim())) throw new LocalError("cancelled \u2014 nothing was sent");
927
+ return true;
928
+ }
929
+ async function collectFiles(paths) {
930
+ if (paths.length === 0) throw new UsageError("name at least one file or folder to upload");
931
+ const files = [];
932
+ const left = [];
933
+ for (const given of paths) {
934
+ const absolute = resolve(given);
935
+ const info = await lstat(absolute).catch(() => null);
936
+ if (info === null) throw new UsageError(`${given} does not exist \u2014 nothing was sent`);
937
+ if (info.isFile()) {
938
+ if (info.size === 0) left.push(`${given} (empty)`);
939
+ else files.push({ path: absolute, name: basename(absolute), relativePath: null, size: info.size, identity: { dev: info.dev, ino: info.ino }, within: null });
940
+ } else if (info.isDirectory()) {
941
+ const real = await realpath2(absolute);
942
+ const resolved = await stat2(real);
943
+ if (!resolved.isDirectory() || resolved.dev !== info.dev || resolved.ino !== info.ino) {
944
+ throw new UsageError(`${given} changed while it was being read \u2014 nothing was sent`);
945
+ }
946
+ await walk(basename(absolute), real, real, files, left);
947
+ } else {
948
+ throw new UsageError(`${given} is not a file or folder \u2014 nothing was sent`);
949
+ }
950
+ }
951
+ if (files.length === 0) throw new UsageError("there are no files to upload there \u2014 nothing was sent");
952
+ for (const file of files) {
953
+ if (file.within === null) continue;
954
+ const real = await realpath2(file.path).catch(() => "");
955
+ const now = real === "" ? null : await stat2(real).catch(() => null);
956
+ if (!real.startsWith(file.within + sep2) || now?.dev !== file.identity.dev || now?.ino !== file.identity.ino) {
957
+ throw new UsageError(`${file.relativePath ?? file.name} changed while the folder was being read \u2014 nothing was sent`);
958
+ }
959
+ }
960
+ return { files, left };
961
+ }
962
+ async function walk(name, real, dir, files, left) {
963
+ const entries = (await readdir(dir, { withFileTypes: true })).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
964
+ for (const entry of entries) {
965
+ const path = join2(dir, entry.name);
966
+ const shown = [name, ...relative(real, path).split(sep2)].join("/");
967
+ if (entry.name.startsWith(".")) {
968
+ left.push(`${shown} (hidden)`);
969
+ } else if (entry.isDirectory()) {
970
+ const info = await lstat(path);
971
+ if (!info.isDirectory() || !(await realpath2(path)).startsWith(real + sep2)) left.push(`${shown} (outside the folder)`);
972
+ else await walk(name, real, path, files, left);
973
+ } else if (entry.isFile()) {
974
+ const info = await lstat(path);
975
+ if (!info.isFile()) left.push(`${shown} (not a regular file)`);
976
+ else if (info.size === 0) left.push(`${shown} (empty)`);
977
+ else files.push({ path, name: entry.name, relativePath: shown, size: info.size, identity: { dev: info.dev, ino: info.ino }, within: real });
978
+ } else {
979
+ left.push(`${shown} (not a regular file)`);
980
+ }
981
+ }
982
+ }
983
+ var TYPES = {
984
+ ".pdf": "application/pdf",
985
+ ".doc": "application/msword",
986
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
987
+ ".xls": "application/vnd.ms-excel",
988
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
989
+ ".ppt": "application/vnd.ms-powerpoint",
990
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
991
+ ".csv": "text/csv",
992
+ ".txt": "text/plain",
993
+ ".md": "text/markdown",
994
+ ".json": "application/json",
995
+ ".png": "image/png",
996
+ ".jpg": "image/jpeg",
997
+ ".jpeg": "image/jpeg",
998
+ ".gif": "image/gif",
999
+ ".webp": "image/webp",
1000
+ ".svg": "image/svg+xml",
1001
+ ".mp4": "video/mp4",
1002
+ ".mov": "video/quicktime",
1003
+ ".zip": "application/zip"
1004
+ };
1005
+ function contentType(name) {
1006
+ return TYPES[extname(name).toLowerCase()] ?? "application/octet-stream";
1007
+ }
1008
+ var download = {
1009
+ handles: (spec) => spec.method === "GET",
1010
+ supplies: [],
1011
+ configure(command) {
1012
+ command.option("-o, --output <path>", "where to save the file (default: its name, in the current folder)").option("--force", "replace an existing file at that path");
1013
+ },
1014
+ async run(context) {
1015
+ const ask = async () => {
1016
+ const answer = await context.client.request(context.command.method, context.path, {
1017
+ query: context.invocation.query
1018
+ });
1019
+ const data = record(answer.body["data"]);
1020
+ if (typeof data["url"] !== "string" || data["url"] === "") throw new CommandError("the API answered the download without a url");
1021
+ return { url: data["url"], filename: typeof data["filename"] === "string" ? data["filename"] : "" };
1022
+ };
1023
+ const first = await ask();
1024
+ const dest = await destination(first.filename, context.invocation.options);
1025
+ const size = await downloadFile(first.url, dest, {
1026
+ force: context.invocation.options["force"] === true,
1027
+ refresh: async () => (await ask()).url,
1028
+ onProgress: progress(context, basename(dest), null)
1029
+ });
1030
+ if (!context.interactive) context.stderr(`Saved ${clean(dest)} (${bytes(size)})
1031
+ `);
1032
+ return { path: dest, bytes: size };
1033
+ }
1034
+ };
1035
+ async function destination(filename, options) {
1036
+ const output = typeof options["output"] === "string" ? options["output"] : null;
1037
+ const force = options["force"] === true;
1038
+ let path;
1039
+ if (output !== null) {
1040
+ const info = await stat2(output).catch(() => null);
1041
+ path = resolve(info?.isDirectory() === true ? join2(output, safeName(filename)) : output);
1042
+ } else {
1043
+ path = resolve(safeName(filename));
1044
+ }
1045
+ if (!force && await lstat(path).catch(() => null) !== null) {
1046
+ throw new LocalError(`${path} already exists \u2014 pass --force to replace it, or --output to save elsewhere`);
1047
+ }
1048
+ return path;
1049
+ }
1050
+ function safeName(filename) {
1051
+ const name = clean(basename(filename.replaceAll("\\", "/"))).trim();
1052
+ if (name === "" || name === "." || name === "..") throw new LocalError("the API gave no usable file name \u2014 pass --output");
1053
+ return name;
1054
+ }
1055
+ function progress(context, label, size) {
1056
+ if (!context.interactive) return void 0;
1057
+ let last = 0;
1058
+ return (done, total2) => {
1059
+ const now = Date.now();
1060
+ const whole = total2 ?? size;
1061
+ if (now - last < 200 && done !== whole) return;
1062
+ last = now;
1063
+ const share = whole === null || whole === 0 ? "" : ` ${Math.floor(done / whole * 100)}%`;
1064
+ context.stderr(`\r\x1B[2K${clean(label)}${share} ${bytes(done)}${whole === null ? "" : ` of ${bytes(whole)}`}${done === whole ? "\n" : ""}`);
1065
+ };
1066
+ }
1067
+ function total(files) {
1068
+ return files.reduce((sum, file) => sum + file.size, 0);
1069
+ }
1070
+ function bytes(count) {
1071
+ const units = ["B", "KB", "MB", "GB", "TB"];
1072
+ let value = count;
1073
+ let unit = 0;
1074
+ while (value >= 1e3 && unit < units.length - 1) {
1075
+ value /= 1e3;
1076
+ unit += 1;
1077
+ }
1078
+ return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`;
1079
+ }
1080
+ function record(value) {
1081
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
1082
+ }
1083
+
1084
+ // src/infraone/transfer.ts
1085
+ var KINDS = { upload, download };
1086
+ function transferFor(spec) {
1087
+ if (spec.transfer === null) return null;
1088
+ const kind = Object.hasOwn(KINDS, spec.transfer) ? KINDS[spec.transfer] : UNKNOWN;
1089
+ return kind.handles(spec) ? kind : null;
1090
+ }
1091
+ var UNKNOWN = {
1092
+ handles: () => true,
1093
+ supplies: [],
1094
+ configure: () => void 0,
1095
+ run: async (context) => {
1096
+ throw new LocalError(`this version of infraone cannot ${context.command.transfer} files \u2014 nothing was sent`);
1097
+ }
1098
+ };
1099
+
1100
+ // src/infraone/generator.ts
1101
+ import { InvalidArgumentError, Option as Option2 } from "commander";
1102
+ var METHODS = ["get", "post", "put", "patch", "delete"];
1103
+ var RISKS = ["read", "write", "destructive", "outward"];
1104
+ var WORD = /^[a-z][a-z0-9_-]*$/;
1105
+ var PATH_PARAM = /\{([a-z][a-z0-9_]*)\}/g;
1106
+ var RESERVED = /* @__PURE__ */ new Set(["json", "all", "yes", "help", "version"]);
1107
+ function commandSpecs(spec) {
1108
+ const specs = [];
1109
+ for (const [path, item] of Object.entries(spec.paths)) {
1110
+ if (!isRecord2(item)) continue;
1111
+ for (const method of METHODS) {
1112
+ const operation = item[method];
1113
+ if (!isRecord2(operation)) continue;
1114
+ const built = commandSpec(spec, path, method.toUpperCase(), operation);
1115
+ if (built !== null) specs.push(built);
1116
+ }
1117
+ }
1118
+ return specs.sort((left, right) => left.words.join(" ").localeCompare(right.words.join(" ")));
1119
+ }
1120
+ function commandSpec(spec, path, method, operation) {
1121
+ const command = operation["x-infraone-command"];
1122
+ if (typeof command !== "string") return null;
1123
+ const words = command.trim().split(/\s+/);
1124
+ if (words.length < 2 || !words.every((word) => WORD.test(word))) return null;
1125
+ const parameters = (operation.parameters ?? []).map((parameter) => resolveParameter(spec, parameter)).filter((parameter) => parameter !== null);
1126
+ const pathNames = [...path.matchAll(PATH_PARAM)].map((match) => match[1]);
1127
+ const pathParams = [];
1128
+ for (const name of pathNames) {
1129
+ const parameter = parameters.find((candidate) => candidate.in === "path" && candidate.name === name);
1130
+ pathParams.push({
1131
+ name,
1132
+ location: "path",
1133
+ schema: resolveSchema(spec, parameter?.schema) ?? { type: "string" },
1134
+ required: true,
1135
+ description: parameter?.description ?? ""
1136
+ });
1137
+ }
1138
+ const queryParams = parameters.filter((parameter) => parameter.in === "query").map((parameter) => ({
1139
+ name: parameter.name,
1140
+ location: "query",
1141
+ schema: withItems(spec, resolveSchema(spec, parameter.schema) ?? { type: "string" }),
1142
+ required: parameter.required === true,
1143
+ description: parameter.description ?? ""
1144
+ }));
1145
+ const bodySchema = resolveSchema(spec, operation.requestBody?.content?.["application/json"]?.schema);
1146
+ const required = new Set(bodySchema?.required ?? []);
1147
+ const bodyFields = Object.entries(bodySchema?.properties ?? {}).map(([name, schema]) => {
1148
+ const resolved = withItems(spec, resolveSchema(spec, schema) ?? {});
1149
+ return { name, location: "body", schema: resolved, required: required.has(name), description: resolved.description ?? "" };
1150
+ });
1151
+ const risk = operation["x-infraone-risk"];
1152
+ const names = new Set(queryParams.map((field) => field.name));
1153
+ return {
1154
+ words,
1155
+ method,
1156
+ path,
1157
+ tool: operation["x-infraone-tool"] ?? operation.operationId ?? null,
1158
+ summary: operation.summary ?? "",
1159
+ description: operation.description ?? "",
1160
+ // An operation that does not say what it risks is treated as the
1161
+ // riskiest kind, so it asks before it runs.
1162
+ risk: typeof risk === "string" && RISKS.includes(risk) ? risk : "destructive",
1163
+ scope: operation["x-infraone-scope"] ?? null,
1164
+ transfer: operation["x-infraone-transfer"] ?? null,
1165
+ pathParams,
1166
+ queryParams,
1167
+ bodyFields,
1168
+ hasBody: bodySchema !== null,
1169
+ paged: names.has("cursor") && names.has("limit"),
1170
+ dataSchema: dataSchema(spec, operation, names.has("cursor") && names.has("limit"))
1171
+ };
1172
+ }
1173
+ function dataSchema(spec, operation, paged) {
1174
+ const ok = operation.responses?.["200"] ?? operation.responses?.["201"];
1175
+ const envelope = resolveSchema(spec, ok?.content?.["application/json"]?.schema);
1176
+ const data = resolveSchema(spec, envelope?.properties?.["data"]);
1177
+ if (data === null) return null;
1178
+ return paged && data.type === "array" ? resolveSchema(spec, data.items) : data;
1179
+ }
1180
+ function mountCommands(program, specs, action) {
1181
+ const placed = [];
1182
+ const leaves = /* @__PURE__ */ new Set();
1183
+ for (const spec of specs) {
1184
+ let parent = program;
1185
+ let blocked = false;
1186
+ for (const word of spec.words.slice(0, -1)) {
1187
+ const existing = parent.commands.find((command) => command.name() === word);
1188
+ if (existing !== void 0 && leaves.has(existing)) {
1189
+ blocked = true;
1190
+ break;
1191
+ }
1192
+ parent = existing ?? parent.command(word).description(`${word} commands`);
1193
+ }
1194
+ const leafName = spec.words.at(-1);
1195
+ if (blocked || parent.commands.some((command) => command.name() === leafName)) continue;
1196
+ const leaf = buildLeaf(parent.command(leafName), spec, action);
1197
+ leaves.add(leaf);
1198
+ placed.push(spec);
1199
+ }
1200
+ return placed;
1201
+ }
1202
+ function buildLeaf(command, spec, action) {
1203
+ const summary = clean(spec.summary === "" ? spec.words.join(" ") : spec.summary);
1204
+ command.summary(summary).description(spec.description === "" ? summary : `${summary}
1205
+
1206
+ ${cleanMultiline(spec.description)}`);
1207
+ for (const field of spec.pathParams) command.argument(`<${field.name}>`, clean(field.description));
1208
+ const transfer = transferFor(spec);
1209
+ transfer?.configure(command);
1210
+ const bound = [];
1211
+ const taken = /* @__PURE__ */ new Set();
1212
+ const supplied = (field) => field.location === "body" && transfer?.supplies.includes(field.name) === true;
1213
+ for (const field of [...spec.queryParams, ...spec.bodyFields].filter((candidate) => !supplied(candidate))) {
1214
+ const base = kebab(field.name);
1215
+ const flag = RESERVED.has(base) || taken.has(base) ? `${field.location}-${base}` : base;
1216
+ if (taken.has(flag)) continue;
1217
+ taken.add(flag);
1218
+ bound.push(...addFieldOption(command, field, flag));
1219
+ }
1220
+ command.option("--json", "print the response's data as JSON");
1221
+ if (spec.paged) command.option("--all", "follow next_cursor and print every page");
1222
+ if ((spec.risk === "destructive" || spec.risk === "outward") && !command.options.some((option) => option.long === "--yes")) {
1223
+ command.option("-y, --yes", `confirm this ${spec.risk} operation without the prompt`);
1224
+ }
1225
+ command.action(async (...args) => {
1226
+ const self = args.at(-1);
1227
+ const options = self.opts();
1228
+ const positional = self.processedArgs;
1229
+ const pathValues = {};
1230
+ spec.pathParams.forEach((field, index) => {
1231
+ pathValues[field.name] = String(positional[index]);
1232
+ });
1233
+ const query = {};
1234
+ const body = {};
1235
+ for (const { field, attribute } of bound) {
1236
+ const value = options[attribute];
1237
+ if (value === void 0) continue;
1238
+ (field.location === "query" ? query : body)[field.name] = value;
1239
+ }
1240
+ await action(spec, {
1241
+ pathValues,
1242
+ query,
1243
+ body: spec.hasBody ? body : void 0,
1244
+ json: options["json"] === true,
1245
+ all: options["all"] === true,
1246
+ yes: options["yes"] === true,
1247
+ extra: positional.slice(spec.pathParams.length).flat().map(String),
1248
+ options
1249
+ });
1250
+ });
1251
+ return command;
1252
+ }
1253
+ function addFieldOption(command, field, flag) {
1254
+ const schema = field.schema;
1255
+ const description = optionDescription(field);
1256
+ if (schema.type === "boolean") {
1257
+ const on = new Option2(`--${flag}`, description);
1258
+ const off = new Option2(`--no-${flag}`, `send ${field.name} as false`);
1259
+ command.addOption(on).addOption(off);
1260
+ return [{ field, attribute: on.attributeName() }];
1261
+ }
1262
+ const option = new Option2(`--${flag} <${valueName(schema)}>`, description);
1263
+ if (schema.type === "array") {
1264
+ const items = schema.items ?? {};
1265
+ if (items.type === "object" || items.type === "array") {
1266
+ option.argParser((value) => parseJson(value, "array"));
1267
+ } else {
1268
+ option.argParser((value, previous) => [...Array.isArray(previous) ? previous : [], scalar(value, items)]);
1269
+ }
1270
+ } else if (schema.type === "object" || schema.type === void 0) {
1271
+ option.argParser((value) => schema.type === "object" ? parseJson(value, "object") : value);
1272
+ } else if (Array.isArray(schema.enum) && schema.enum.length > 0) {
1273
+ option.choices(schema.enum.map(String)).argParser((value) => enumValue(value, schema));
1274
+ } else {
1275
+ option.argParser((value) => scalar(value, schema));
1276
+ }
1277
+ if (field.required) option.makeOptionMandatory();
1278
+ command.addOption(option);
1279
+ return [{ field, attribute: option.attributeName() }];
1280
+ }
1281
+ function optionDescription(field) {
1282
+ const parts = [field.description === "" ? field.name.replaceAll("_", " ") : clean(field.description)];
1283
+ const schema = field.schema;
1284
+ if (schema.type === "array") parts.push("(repeatable)");
1285
+ if (schema.default !== void 0) parts.push(`(default: ${clean(JSON.stringify(schema.default))})`);
1286
+ return parts.join(" ");
1287
+ }
1288
+ function valueName(schema) {
1289
+ if (schema.type === "integer" || schema.type === "number") return "n";
1290
+ if (schema.type === "object" || schema.type === "array" && (schema.items?.type === "object" || schema.items?.type === "array")) {
1291
+ return "json";
1292
+ }
1293
+ return "value";
1294
+ }
1295
+ function scalar(value, schema) {
1296
+ switch (schema.type) {
1297
+ case "integer": {
1298
+ if (!/^-?\d+$/.test(value)) throw new InvalidArgumentError("expected an integer");
1299
+ return bounded(Number.parseInt(value, 10), schema);
1300
+ }
1301
+ case "number": {
1302
+ const number = Number(value);
1303
+ if (value.trim() === "" || !Number.isFinite(number)) throw new InvalidArgumentError("expected a number");
1304
+ return bounded(number, schema);
1305
+ }
1306
+ case "boolean": {
1307
+ if (value === "true") return true;
1308
+ if (value === "false") return false;
1309
+ throw new InvalidArgumentError("expected true or false");
1310
+ }
1311
+ default:
1312
+ if (Array.isArray(schema.enum) && schema.enum.length > 0) return enumValue(value, schema);
1313
+ return value;
1314
+ }
1315
+ }
1316
+ function enumValue(value, schema) {
1317
+ const match = (schema.enum ?? []).find((candidate) => String(candidate) === value);
1318
+ if (match === void 0) throw new InvalidArgumentError(`expected one of ${(schema.enum ?? []).map(String).join(", ")}`);
1319
+ return match;
1320
+ }
1321
+ function bounded(value, schema) {
1322
+ if (schema.minimum !== void 0 && value < schema.minimum) {
1323
+ throw new InvalidArgumentError(`expected at least ${schema.minimum}`);
1324
+ }
1325
+ if (schema.maximum !== void 0 && value > schema.maximum) {
1326
+ throw new InvalidArgumentError(`expected at most ${schema.maximum}`);
1327
+ }
1328
+ return value;
1329
+ }
1330
+ function parseJson(value, kind) {
1331
+ let parsed;
1332
+ try {
1333
+ parsed = JSON.parse(value);
1334
+ } catch {
1335
+ throw new InvalidArgumentError(`expected a JSON ${kind}`);
1336
+ }
1337
+ if (kind === "array" ? !Array.isArray(parsed) : typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1338
+ throw new InvalidArgumentError(`expected a JSON ${kind}`);
1339
+ }
1340
+ return parsed;
1341
+ }
1342
+ function kebab(name) {
1343
+ return name.replaceAll("_", "-").toLowerCase();
1344
+ }
1345
+ function resolveParameter(spec, parameter) {
1346
+ if ("$ref" in parameter) {
1347
+ const name = parameter.$ref.replace(/^#\/components\/parameters\//, "");
1348
+ return spec.components?.parameters?.[name] ?? null;
1349
+ }
1350
+ return typeof parameter.name === "string" && typeof parameter.in === "string" ? parameter : null;
1351
+ }
1352
+ function withItems(spec, schema) {
1353
+ if (schema.type !== "array" || schema.items === void 0) return schema;
1354
+ return { ...schema, items: resolveSchema(spec, schema.items) ?? {} };
1355
+ }
1356
+ function resolveSchema(spec, schema, depth = 0) {
1357
+ if (schema === void 0 || !isRecord2(schema)) return null;
1358
+ if (typeof schema.$ref === "string") {
1359
+ if (depth > 16) return null;
1360
+ const name = schema.$ref.replace(/^#\/components\/schemas\//, "");
1361
+ return resolveSchema(spec, spec.components?.schemas?.[name], depth + 1);
1362
+ }
1363
+ return schema;
1364
+ }
1365
+ function isRecord2(value) {
1366
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1367
+ }
1368
+
1369
+ // src/infraone/spec.ts
1370
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
1371
+ import { join as join3 } from "path";
1372
+ var SPEC_TIMEOUT_MS = 1e4;
1373
+ async function loadSpec(config) {
1374
+ const cached = readCached(config);
1375
+ if (cached !== null) return { spec: cached.spec, fingerprint: cached.fingerprint, source: "cache" };
1376
+ return { spec: await bundledSpec(), fingerprint: null, source: "bundled" };
1377
+ }
1378
+ async function bundledSpec() {
1379
+ const module = await import("./infraone-A3KDADRM.js");
1380
+ const spec = module.default;
1381
+ if (!isSpec(spec)) throw new Error("the bundled spec/infraone.json is not an OpenAPI document");
1382
+ return spec;
1383
+ }
1384
+ async function fetchSpec(client) {
1385
+ const answer = await client.request("GET", "/v1/openapi.json", { anonymous: true, timeoutMs: SPEC_TIMEOUT_MS });
1386
+ if (!isSpec(answer.body)) throw new Error("the server's spec is not an OpenAPI document");
1387
+ return { spec: answer.body, fingerprint: answer.fingerprint, apiVersion: answer.apiVersion };
1388
+ }
1389
+ async function refreshSpec(config, client) {
1390
+ try {
1391
+ const fetched = await fetchSpec(client);
1392
+ writeCached(config, { api_url: config.apiUrl, api_version: fetched.apiVersion, fingerprint: fetched.fingerprint, spec: fetched.spec });
1393
+ return { spec: fetched.spec, fingerprint: fetched.fingerprint, source: "server" };
1394
+ } catch {
1395
+ return null;
1396
+ }
1397
+ }
1398
+ function cachePath(config) {
1399
+ return join3(config.cacheDir, `spec-${baseKey(config.apiUrl)}.json`);
1400
+ }
1401
+ function readCached(config) {
1402
+ try {
1403
+ const parsed = JSON.parse(readFileSync(cachePath(config), "utf8"));
1404
+ if (typeof parsed !== "object" || parsed === null) return null;
1405
+ const file = parsed;
1406
+ if (file["api_url"] !== config.apiUrl || !isSpec(file["spec"])) return null;
1407
+ return {
1408
+ api_url: config.apiUrl,
1409
+ api_version: typeof file["api_version"] === "string" ? file["api_version"] : null,
1410
+ fingerprint: typeof file["fingerprint"] === "string" ? file["fingerprint"] : null,
1411
+ spec: file["spec"]
1412
+ };
1413
+ } catch {
1414
+ return null;
1415
+ }
1416
+ }
1417
+ var uniqueCounter = 0;
1418
+ function writeCached(config, file) {
1419
+ const path = cachePath(config);
1420
+ const temp = `${path}.${process.pid}.${++uniqueCounter}.tmp`;
1421
+ try {
1422
+ mkdirSync(config.cacheDir, { recursive: true });
1423
+ writeFileSync(temp, JSON.stringify(file));
1424
+ renameSync(temp, path);
1425
+ } catch {
1426
+ rmSync(temp, { force: true });
1427
+ }
1428
+ }
1429
+ function isSpec(value) {
1430
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
1431
+ const paths = value["paths"];
1432
+ return typeof paths === "object" && paths !== null && !Array.isArray(paths);
1433
+ }
1434
+
1435
+ export {
1436
+ resolveConfig,
1437
+ InfraoneClient,
1438
+ login,
1439
+ logout,
1440
+ whoami,
1441
+ describeSession,
1442
+ collectFiles,
1443
+ transferFor,
1444
+ commandSpecs,
1445
+ mountCommands,
1446
+ loadSpec,
1447
+ refreshSpec
1448
+ };