@buildinternet/uploads 0.46.3 → 0.48.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/dist/client.d.ts +63 -3
- package/dist/client.js +92 -30
- package/dist/commands.d.ts +10 -8
- package/dist/commands.js +43 -14
- package/dist/format-usage.d.ts +5 -0
- package/dist/format-usage.js +14 -4
- package/dist/mcp/tools.js +17 -3
- package/package.json +1 -1
package/dist/client.d.ts
CHANGED
|
@@ -36,6 +36,13 @@ export interface PutOptions {
|
|
|
36
36
|
* `KEY_EXISTS`.
|
|
37
37
|
*/
|
|
38
38
|
replace?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Reuse this key to safely retry the same upload request (issue #829). Not
|
|
41
|
+
* generated automatically — unlike `createGallery`, a `put` is only
|
|
42
|
+
* retried by the client (see `resilientFetch`) or is server-replay-safe
|
|
43
|
+
* when the caller supplies one; omit it and no header is sent.
|
|
44
|
+
*/
|
|
45
|
+
idempotencyKey?: string;
|
|
39
46
|
}
|
|
40
47
|
export interface ListOptions {
|
|
41
48
|
prefix?: string;
|
|
@@ -44,6 +51,12 @@ export interface ListOptions {
|
|
|
44
51
|
/** Hydrate each row's queryable D1 metadata (`?metadata=1`). */
|
|
45
52
|
metadata?: boolean;
|
|
46
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Default page cap for `findFilesAll` / `uploads find --all`. Search is a
|
|
56
|
+
* bounded read on the server, so following its cursor is bounded on the client
|
|
57
|
+
* too rather than draining an unknown number of pages (issue #829 §4).
|
|
58
|
+
*/
|
|
59
|
+
export declare const FIND_FILES_MAX_PAGES = 20;
|
|
47
60
|
export interface FindFilesOptions {
|
|
48
61
|
prefix?: string;
|
|
49
62
|
limit?: number;
|
|
@@ -52,6 +65,13 @@ export interface FindFilesOptions {
|
|
|
52
65
|
* At least one of non-empty `filters` or `name` is required.
|
|
53
66
|
*/
|
|
54
67
|
name?: string;
|
|
68
|
+
/**
|
|
69
|
+
* Opaque continuation from a previous result's `cursor`. Pass it back
|
|
70
|
+
* unchanged, with the same filters/name/prefix, to fetch the next page
|
|
71
|
+
* (issue #829 §4). A cursor minted for one search path is rejected by the
|
|
72
|
+
* other, so do not hand-build one.
|
|
73
|
+
*/
|
|
74
|
+
cursor?: string;
|
|
55
75
|
}
|
|
56
76
|
export interface FindFilesItem {
|
|
57
77
|
key: string;
|
|
@@ -201,6 +221,8 @@ export interface GalleryListResult {
|
|
|
201
221
|
export interface CreateGalleryOptions {
|
|
202
222
|
title: string;
|
|
203
223
|
description?: string | null;
|
|
224
|
+
/** Reuse this key to safely retry the same create request. Generated when omitted. */
|
|
225
|
+
idempotencyKey?: string;
|
|
204
226
|
}
|
|
205
227
|
export interface AddGalleryItemOptions {
|
|
206
228
|
expectedVersion: number;
|
|
@@ -417,6 +439,23 @@ export interface UsageResult {
|
|
|
417
439
|
storageRemainingBytes?: number;
|
|
418
440
|
maxUploadsPerPeriod?: number;
|
|
419
441
|
uploadsRemaining?: number;
|
|
442
|
+
/** Bytes still on hosted storage (shared-lane residue). */
|
|
443
|
+
sharedBytes?: number;
|
|
444
|
+
/** "shared" = BYO bucket active: the storage cap meters only hosted
|
|
445
|
+
* residue; the customer's own bucket is unmetered. */
|
|
446
|
+
storageBudgetBasis?: "total" | "shared";
|
|
447
|
+
/** Bearer-safe lane summary (issue #775; servers ≥ this field's release). */
|
|
448
|
+
storage?: {
|
|
449
|
+
mode: "shared" | "byo";
|
|
450
|
+
/** Demoted former-active lanes that still serve previously uploaded files. */
|
|
451
|
+
fallbackLanes: number;
|
|
452
|
+
health: {
|
|
453
|
+
ok: boolean;
|
|
454
|
+
code?: string;
|
|
455
|
+
message?: string;
|
|
456
|
+
since?: string;
|
|
457
|
+
};
|
|
458
|
+
};
|
|
420
459
|
/** File scopes of the presented token (servers ≥ this field's release). */
|
|
421
460
|
scopes?: Array<TokenScope>;
|
|
422
461
|
/**
|
|
@@ -592,6 +631,13 @@ export declare function mintWorkspaceToken(apiUrl: string, accessToken: string,
|
|
|
592
631
|
scopes?: Array<TokenScope>;
|
|
593
632
|
label?: string;
|
|
594
633
|
ttlSeconds?: number | null;
|
|
634
|
+
/**
|
|
635
|
+
* Optional retry key. Reusing it (same effective request, within 24h)
|
|
636
|
+
* replays the original response — including the one-time plaintext token —
|
|
637
|
+
* instead of minting a second token. A changed request with the same key
|
|
638
|
+
* returns 409.
|
|
639
|
+
*/
|
|
640
|
+
idempotencyKey?: string;
|
|
595
641
|
}): Promise<MintTokenResult>;
|
|
596
642
|
/**
|
|
597
643
|
* Parse API error bodies. Prefers the nested envelope
|
|
@@ -632,10 +678,24 @@ export declare function createUploadsClient(config: UploadsClientConfig): {
|
|
|
632
678
|
* ANDed equality filter over queryable metadata and/or a case-insensitive
|
|
633
679
|
* filename substring. At least one of non-empty `filters` or `opts.name`
|
|
634
680
|
* is required. `filters` must be pre-validated when present (see
|
|
635
|
-
* `metadata.ts`). The
|
|
636
|
-
*
|
|
681
|
+
* `metadata.ts`). The page is capped server-side (100, narrowable via
|
|
682
|
+
* `limit`); when more matches exist the result carries an opaque `cursor`
|
|
683
|
+
* to pass back as `opts.cursor` for the next page, and null when the last
|
|
684
|
+
* page has been reached.
|
|
685
|
+
*/
|
|
686
|
+
findFiles: (filters?: Record<string, string>, opts?: FindFilesOptions) => Promise<FindFilesResult>;
|
|
687
|
+
/**
|
|
688
|
+
* `findFiles` followed through its `cursor`, up to `maxPages` requests
|
|
689
|
+
* (default `FIND_FILES_MAX_PAGES`). Bounded on purpose: search pages are
|
|
690
|
+
* the expensive read path, so draining is capped rather than open-ended.
|
|
691
|
+
* The returned `cursor` is non-null when the cap stopped the drain early —
|
|
692
|
+
* pass it back to continue from there.
|
|
693
|
+
*
|
|
694
|
+
* `maxPages` must be a finite positive number. Anything else (`Infinity`,
|
|
695
|
+
* `NaN`, zero, a negative) falls back to the default rather than removing
|
|
696
|
+
* the bound or silently fetching nothing.
|
|
637
697
|
*/
|
|
638
|
-
|
|
698
|
+
findFilesAll(filters?: Record<string, string>, opts?: FindFilesOptions, maxPages?: number): Promise<FindFilesResult>;
|
|
639
699
|
/** `GET /v1/:workspace/files/facets` — workspace metadata key vocabulary. */
|
|
640
700
|
listMetadataKeys(): Promise<MetadataKeysResult>;
|
|
641
701
|
/** `GET /v1/:workspace/files/facets?key=` — distinct values for one key. */
|
package/dist/client.js
CHANGED
|
@@ -4,6 +4,12 @@ import { UploadsError } from "./errors.js";
|
|
|
4
4
|
import { buildScreenshotKey } from "./keys.js";
|
|
5
5
|
import { packageVersion } from "./package-version.js";
|
|
6
6
|
import { resolveEmbedUrl } from "./public-urls.js";
|
|
7
|
+
/**
|
|
8
|
+
* Default page cap for `findFilesAll` / `uploads find --all`. Search is a
|
|
9
|
+
* bounded read on the server, so following its cursor is bounded on the client
|
|
10
|
+
* too rather than draining an unknown number of pages (issue #829 §4).
|
|
11
|
+
*/
|
|
12
|
+
export const FIND_FILES_MAX_PAGES = 20;
|
|
7
13
|
// --- Request resilience (issue #809) ---------------------------------------
|
|
8
14
|
//
|
|
9
15
|
// Bare `fetch` has no timeout — undici's default header timeout is ~5
|
|
@@ -18,12 +24,17 @@ import { resolveEmbedUrl } from "./public-urls.js";
|
|
|
18
24
|
// gap on the core path).
|
|
19
25
|
const JSON_TIMEOUT_MS = 15_000;
|
|
20
26
|
const CONTENT_TIMEOUT_MS = 60_000;
|
|
21
|
-
// At most one retry. Retried on network errors, 503, and 429 —
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
27
|
+
// At most one retry. Retried on network errors, 503, and 429 — GET always.
|
|
28
|
+
// POST and PUT are retryable only when the request carries an
|
|
29
|
+
// Idempotency-Key (gallery-create's POST, and an upload PUT that opts in per
|
|
30
|
+
// issue #829) — a bare PUT is no longer assumed byte-idempotent, since a
|
|
31
|
+
// strict (non-`gh/`) key without a caller-supplied key can 409 `key_exists`
|
|
32
|
+
// on replay. DELETE/PATCH remain single-attempt because telling "no bytes
|
|
33
|
+
// sent" apart from "the mutation already landed" isn't reliable enough to
|
|
34
|
+
// risk a double-apply.
|
|
25
35
|
const MAX_ATTEMPTS = 2;
|
|
26
|
-
const RETRYABLE_METHODS = new Set(["GET"
|
|
36
|
+
const RETRYABLE_METHODS = new Set(["GET"]);
|
|
37
|
+
const IDEMPOTENCY_KEYED_METHODS = new Set(["POST", "PUT"]);
|
|
27
38
|
const RETRYABLE_STATUSES = new Set([429, 503]);
|
|
28
39
|
const DEFAULT_RETRY_DELAY_MS = 2_000;
|
|
29
40
|
// Cap an honored X-Retry-After so a large server-suggested backoff can't
|
|
@@ -76,7 +87,10 @@ async function fetchWithTimeout(url, init, timeoutMs) {
|
|
|
76
87
|
* `!res.ok` themselves via `parseErrorResponse`.
|
|
77
88
|
*/
|
|
78
89
|
async function resilientFetch(method, url, init, timeoutMs) {
|
|
79
|
-
const
|
|
90
|
+
const normalizedMethod = method.toUpperCase();
|
|
91
|
+
const retryable = RETRYABLE_METHODS.has(normalizedMethod) ||
|
|
92
|
+
(IDEMPOTENCY_KEYED_METHODS.has(normalizedMethod) &&
|
|
93
|
+
new Headers(init.headers).has("Idempotency-Key"));
|
|
80
94
|
for (let attempt = 1;; attempt++) {
|
|
81
95
|
let res;
|
|
82
96
|
let networkErr;
|
|
@@ -265,7 +279,11 @@ export function createWorkspaceInvite(apiUrl, accessToken, workspace, input) {
|
|
|
265
279
|
export function mintWorkspaceToken(apiUrl, accessToken, input) {
|
|
266
280
|
return jsonRequest(`${apiUrl.replace(/\/$/, "")}/v1/tokens`, {
|
|
267
281
|
method: "POST",
|
|
268
|
-
headers: {
|
|
282
|
+
headers: {
|
|
283
|
+
Authorization: `Bearer ${accessToken}`,
|
|
284
|
+
"Content-Type": "application/json",
|
|
285
|
+
...(input.idempotencyKey ? { "Idempotency-Key": input.idempotencyKey } : {}),
|
|
286
|
+
},
|
|
269
287
|
body: JSON.stringify({
|
|
270
288
|
grants: [{ workspace: input.workspace, ...(input.scopes ? { scopes: input.scopes } : {}) }],
|
|
271
289
|
...(input.label ? { label: input.label } : {}),
|
|
@@ -386,14 +404,17 @@ export function createUploadsClient(config) {
|
|
|
386
404
|
params.set("limit", String(opts.limit));
|
|
387
405
|
if (opts.cursor)
|
|
388
406
|
params.set("cursor", opts.cursor);
|
|
389
|
-
|
|
390
|
-
|
|
407
|
+
// The canonical route hydrates D1 metadata by default (issue #613 — the
|
|
408
|
+
// session shape won the reconciliation); when the caller didn't ask for
|
|
409
|
+
// `opts.metadata` this client discards it anyway below, so `metadata=0`
|
|
410
|
+
// opts out of the hydration pass server-side instead of paying for work
|
|
411
|
+
// whose result is thrown away (issue #829 §5).
|
|
412
|
+
params.set("metadata", opts.metadata ? "1" : "0");
|
|
391
413
|
const qs = params.toString();
|
|
392
|
-
// Canonical list envelope is `{files, prefixes, cursor}
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
// hydrated maps when the caller didn't ask for them.
|
|
414
|
+
// Canonical list envelope is `{files, prefixes, cursor}`. This client
|
|
415
|
+
// keeps its historical `{items, cursor}` contract: rename the array and
|
|
416
|
+
// honor `opts.metadata` by stripping the hydrated maps when the caller
|
|
417
|
+
// didn't ask for them.
|
|
397
418
|
const page = await request("GET", `${canonicalFilesBase(config)}${qs ? `?${qs}` : ""}`);
|
|
398
419
|
return {
|
|
399
420
|
cursor: page.cursor,
|
|
@@ -407,6 +428,25 @@ export function createUploadsClient(config) {
|
|
|
407
428
|
}),
|
|
408
429
|
};
|
|
409
430
|
}
|
|
431
|
+
async function findFiles(filters = {}, opts = {}) {
|
|
432
|
+
const params = new URLSearchParams();
|
|
433
|
+
for (const [k, v] of Object.entries(filters))
|
|
434
|
+
params.append(`meta.${k}`, v);
|
|
435
|
+
if (opts.name)
|
|
436
|
+
params.set("name", opts.name);
|
|
437
|
+
if (opts.prefix)
|
|
438
|
+
params.set("prefix", opts.prefix);
|
|
439
|
+
if (opts.limit != null)
|
|
440
|
+
params.set("limit", String(opts.limit));
|
|
441
|
+
if (opts.cursor)
|
|
442
|
+
params.set("cursor", opts.cursor);
|
|
443
|
+
const result = await request("GET", `${canonicalFilesBase(config)}/search?${params.toString()}`);
|
|
444
|
+
return {
|
|
445
|
+
items: result.items,
|
|
446
|
+
cursor: result.cursor ?? null,
|
|
447
|
+
truncated: result.truncated,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
410
450
|
async function getGallery(id) {
|
|
411
451
|
return request("GET", `${galleriesBase(config)}/${encodeURIComponent(id)}`);
|
|
412
452
|
}
|
|
@@ -445,6 +485,8 @@ export function createUploadsClient(config) {
|
|
|
445
485
|
const headers = { "Content-Type": contentType };
|
|
446
486
|
if (opts.replace)
|
|
447
487
|
headers["X-Uploads-Replace"] = "1";
|
|
488
|
+
if (opts.idempotencyKey)
|
|
489
|
+
headers["Idempotency-Key"] = opts.idempotencyKey;
|
|
448
490
|
if (opts.provenance) {
|
|
449
491
|
for (const [k, v] of Object.entries(opts.provenance)) {
|
|
450
492
|
if (v !== undefined && v !== "")
|
|
@@ -505,21 +547,37 @@ export function createUploadsClient(config) {
|
|
|
505
547
|
* ANDed equality filter over queryable metadata and/or a case-insensitive
|
|
506
548
|
* filename substring. At least one of non-empty `filters` or `opts.name`
|
|
507
549
|
* is required. `filters` must be pre-validated when present (see
|
|
508
|
-
* `metadata.ts`). The
|
|
509
|
-
*
|
|
550
|
+
* `metadata.ts`). The page is capped server-side (100, narrowable via
|
|
551
|
+
* `limit`); when more matches exist the result carries an opaque `cursor`
|
|
552
|
+
* to pass back as `opts.cursor` for the next page, and null when the last
|
|
553
|
+
* page has been reached.
|
|
510
554
|
*/
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
555
|
+
findFiles,
|
|
556
|
+
/**
|
|
557
|
+
* `findFiles` followed through its `cursor`, up to `maxPages` requests
|
|
558
|
+
* (default `FIND_FILES_MAX_PAGES`). Bounded on purpose: search pages are
|
|
559
|
+
* the expensive read path, so draining is capped rather than open-ended.
|
|
560
|
+
* The returned `cursor` is non-null when the cap stopped the drain early —
|
|
561
|
+
* pass it back to continue from there.
|
|
562
|
+
*
|
|
563
|
+
* `maxPages` must be a finite positive number. Anything else (`Infinity`,
|
|
564
|
+
* `NaN`, zero, a negative) falls back to the default rather than removing
|
|
565
|
+
* the bound or silently fetching nothing.
|
|
566
|
+
*/
|
|
567
|
+
async findFilesAll(filters = {}, opts = {}, maxPages = FIND_FILES_MAX_PAGES) {
|
|
568
|
+
const items = [];
|
|
569
|
+
let cursor = opts.cursor;
|
|
570
|
+
let truncated;
|
|
571
|
+
const pages = Number.isFinite(maxPages) && maxPages >= 1 ? Math.floor(maxPages) : FIND_FILES_MAX_PAGES;
|
|
572
|
+
for (let page = 0; page < pages; page += 1) {
|
|
573
|
+
const result = await findFiles(filters, { ...opts, cursor });
|
|
574
|
+
items.push(...result.items);
|
|
575
|
+
truncated = result.truncated;
|
|
576
|
+
cursor = result.cursor ?? undefined;
|
|
577
|
+
if (!cursor)
|
|
578
|
+
break;
|
|
579
|
+
}
|
|
580
|
+
return { items, cursor: cursor ?? null, ...(truncated === undefined ? {} : { truncated }) };
|
|
523
581
|
},
|
|
524
582
|
/** `GET /v1/:workspace/files/facets` — workspace metadata key vocabulary. */
|
|
525
583
|
async listMetadataKeys() {
|
|
@@ -534,9 +592,13 @@ export function createUploadsClient(config) {
|
|
|
534
592
|
return { ...result, embedUrl: resolveEmbedUrl(result.url, result.embedUrl) };
|
|
535
593
|
},
|
|
536
594
|
async createGallery(opts) {
|
|
595
|
+
const { idempotencyKey = crypto.randomUUID(), ...body } = opts;
|
|
537
596
|
return request("POST", galleriesBase(config), {
|
|
538
|
-
body: new TextEncoder().encode(JSON.stringify(
|
|
539
|
-
headers: {
|
|
597
|
+
body: new TextEncoder().encode(JSON.stringify(body)),
|
|
598
|
+
headers: {
|
|
599
|
+
"Content-Type": "application/json",
|
|
600
|
+
"Idempotency-Key": idempotencyKey,
|
|
601
|
+
},
|
|
540
602
|
});
|
|
541
603
|
},
|
|
542
604
|
async getGallery(id) {
|
package/dist/commands.d.ts
CHANGED
|
@@ -615,16 +615,18 @@ export interface DoctorReport {
|
|
|
615
615
|
/** Workspace/token mismatch warning (also present in hints). */
|
|
616
616
|
warning?: string;
|
|
617
617
|
/**
|
|
618
|
-
*
|
|
619
|
-
*
|
|
620
|
-
*
|
|
621
|
-
*
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
* a fabricated mode.
|
|
618
|
+
* Storage-lane summary (issue #775): the usage endpoint carries a
|
|
619
|
+
* bearer-safe `storage` object (mode + fallback-lane count + health), so
|
|
620
|
+
* doctor reports it from the same call it already makes. `checked` is
|
|
621
|
+
* false when usage failed or the server predates the field — then `note`
|
|
622
|
+
* falls back to the honest "can't check from here" line (the full
|
|
623
|
+
* projection on `GET /me/workspaces/:name/storage` stays session-gated).
|
|
625
624
|
*/
|
|
626
625
|
storage: {
|
|
627
|
-
checked:
|
|
626
|
+
checked: boolean;
|
|
627
|
+
mode?: "shared" | "byo";
|
|
628
|
+
fallbackLanes?: number;
|
|
629
|
+
healthy?: boolean;
|
|
628
630
|
note: string;
|
|
629
631
|
};
|
|
630
632
|
hints: string[];
|
package/dist/commands.js
CHANGED
|
@@ -2574,8 +2574,10 @@ Default prefix: UPLOADS_DEFAULT_PREFIX (screenshots if unset).
|
|
|
2574
2574
|
|
|
2575
2575
|
--meta <k=v> (repeatable, ANDed) and/or --name <term> switch to the search
|
|
2576
2576
|
endpoint — returned items include their matched metadata. Combines with
|
|
2577
|
-
--prefix, not with --pr/--issue
|
|
2578
|
-
substring match on object keys.
|
|
2577
|
+
--prefix, not with --pr/--issue. --name is a case-insensitive
|
|
2578
|
+
substring match on object keys. Search pages are continued with --cursor
|
|
2579
|
+
(opaque, from the previous page); --all follows it for up to 20 pages and
|
|
2580
|
+
prints the next cursor if more remain. See also: uploads find.
|
|
2579
2581
|
|
|
2580
2582
|
Examples:
|
|
2581
2583
|
uploads list --prefix screenshots/
|
|
@@ -2592,18 +2594,21 @@ function writeTruncatedNotice(truncated, quiet, detail) {
|
|
|
2592
2594
|
}
|
|
2593
2595
|
/** `--meta` / `--name` search path, shared by `runList` and `runFind`. */
|
|
2594
2596
|
async function runFindFiles(ctx, filters, flags, name) {
|
|
2595
|
-
if (flagString(flags, "--cursor") !== undefined) {
|
|
2596
|
-
throw new UsageError("--cursor is not supported with metadata or name filters");
|
|
2597
|
-
}
|
|
2598
2597
|
const prefix = flagString(flags, "--prefix");
|
|
2599
2598
|
const limit = flagInt(flags, "--limit", "--limit");
|
|
2599
|
+
const cursor = flagString(flags, "--cursor");
|
|
2600
2600
|
const nameTerm = name ?? flagString(flags, "--name");
|
|
2601
2601
|
if (Object.keys(filters).length === 0 && !nameTerm) {
|
|
2602
2602
|
throw new UsageError("find requires at least one k=v pair, --meta k=v, or --name <term>", {
|
|
2603
2603
|
example: "uploads find path=/settings state=after",
|
|
2604
2604
|
});
|
|
2605
2605
|
}
|
|
2606
|
-
|
|
2606
|
+
// `--all` follows the search cursor, but only up to FIND_FILES_MAX_PAGES —
|
|
2607
|
+
// never an unbounded drain. `--cursor` resumes a specific page by hand.
|
|
2608
|
+
const searchOpts = { prefix, limit, name: nameTerm, cursor };
|
|
2609
|
+
const result = flagBool(flags, "--all")
|
|
2610
|
+
? await ctx.client.findFilesAll(filters, searchOpts)
|
|
2611
|
+
: await ctx.client.findFiles(filters, searchOpts);
|
|
2607
2612
|
if (ctx.json)
|
|
2608
2613
|
await writeJson(result);
|
|
2609
2614
|
else {
|
|
@@ -2617,6 +2622,9 @@ async function runFindFiles(ctx, filters, flags, name) {
|
|
|
2617
2622
|
await writeStdout(`${item.key}${item.url ? ` ${item.url}` : ""}${meta ? ` ${meta}` : ""}\n`);
|
|
2618
2623
|
}
|
|
2619
2624
|
writeTruncatedNotice(result.truncated, ctx.quiet, "more matches may exist beyond this page");
|
|
2625
|
+
// Human mode surfaces the continuation the same way `uploads list` does.
|
|
2626
|
+
if (result.cursor)
|
|
2627
|
+
process.stderr.write(`cursor: ${result.cursor}\n`);
|
|
2620
2628
|
}
|
|
2621
2629
|
return 0;
|
|
2622
2630
|
}
|
|
@@ -2632,9 +2640,6 @@ export async function runList(ctx, args, help = false, run = execRunner) {
|
|
|
2632
2640
|
if (ghTargetFromFlags(parsed.flags, run)) {
|
|
2633
2641
|
throw new UsageError("--meta/--name cannot be combined with --pr/--issue");
|
|
2634
2642
|
}
|
|
2635
|
-
if (flagBool(parsed.flags, "--all")) {
|
|
2636
|
-
throw new UsageError("--meta/--name cannot be combined with --all");
|
|
2637
|
-
}
|
|
2638
2643
|
return runFindFiles(ctx, metaPairs.length > 0 ? parseMetaFlags(metaPairs) : {}, parsed.flags, nameFlag);
|
|
2639
2644
|
}
|
|
2640
2645
|
const defaults = resolvePutDefaults({ envFile: ctx.envFile });
|
|
@@ -2694,11 +2699,16 @@ export async function runList(ctx, args, help = false, run = execRunner) {
|
|
|
2694
2699
|
return 0;
|
|
2695
2700
|
}
|
|
2696
2701
|
// --- find ---
|
|
2697
|
-
const FIND_HELP = `uploads find [k=v...] [--meta k=v]... [--name <term>] [--prefix <p>] [--limit <n>] [--workspace <name>]
|
|
2702
|
+
const FIND_HELP = `uploads find [k=v...] [--meta k=v]... [--name <term>] [--prefix <p>] [--limit <n>] [--cursor <c>] [--all] [--workspace <name>]
|
|
2698
2703
|
|
|
2699
2704
|
Find objects by queryable metadata (ANDed equality) and/or a case-insensitive
|
|
2700
2705
|
filename substring. Same output as \`uploads list --meta\` / \`--name\`.
|
|
2701
2706
|
|
|
2707
|
+
Results are paged. When more matches exist the next page's opaque cursor is
|
|
2708
|
+
printed to stderr (and carried in --json as \`cursor\`); pass it back with
|
|
2709
|
+
--cursor. --all follows the cursor for you, up to 20 pages, then prints the
|
|
2710
|
+
cursor to resume from.
|
|
2711
|
+
|
|
2702
2712
|
Pairs are positional k=v, or spelled --meta k=v. A bare positional without
|
|
2703
2713
|
\`=\` is treated as --name (e.g. \`uploads find hero\`). At least one of a
|
|
2704
2714
|
meta pair or a name term is required.
|
|
@@ -2709,6 +2719,7 @@ Examples:
|
|
|
2709
2719
|
uploads find --meta path=/settings
|
|
2710
2720
|
uploads find hero
|
|
2711
2721
|
uploads find --name hero --meta app=web
|
|
2722
|
+
uploads find app=web --all --json
|
|
2712
2723
|
`;
|
|
2713
2724
|
export async function runFind(ctx, args, help = false) {
|
|
2714
2725
|
const parsed = parseCommandArgs(args);
|
|
@@ -3472,6 +3483,10 @@ export async function buildDoctorReport(config, client, detectRoots) {
|
|
|
3472
3483
|
}
|
|
3473
3484
|
}
|
|
3474
3485
|
let usage;
|
|
3486
|
+
let storage = {
|
|
3487
|
+
checked: false,
|
|
3488
|
+
note: "not checked from the CLI — this server doesn't report a storage summary on the usage endpoint; sign in on the web (Account → workspace → Settings) to view mode and verification status",
|
|
3489
|
+
};
|
|
3475
3490
|
let scopes;
|
|
3476
3491
|
if (authOk) {
|
|
3477
3492
|
try {
|
|
@@ -3482,6 +3497,23 @@ export async function buildDoctorReport(config, client, detectRoots) {
|
|
|
3482
3497
|
objects: snap.objects,
|
|
3483
3498
|
uploadsInPeriod: snap.uploadsInPeriod,
|
|
3484
3499
|
};
|
|
3500
|
+
if (snap.storage) {
|
|
3501
|
+
const laneNote = snap.storage.fallbackLanes > 0
|
|
3502
|
+
? ` (${snap.storage.fallbackLanes} previous lane${snap.storage.fallbackLanes === 1 ? "" : "s"} still serving old files)`
|
|
3503
|
+
: "";
|
|
3504
|
+
const healthNote = snap.storage.health.ok
|
|
3505
|
+
? ""
|
|
3506
|
+
: " — not working; rotate credentials on the web settings page";
|
|
3507
|
+
storage = {
|
|
3508
|
+
checked: true,
|
|
3509
|
+
mode: snap.storage.mode,
|
|
3510
|
+
fallbackLanes: snap.storage.fallbackLanes,
|
|
3511
|
+
healthy: snap.storage.health.ok,
|
|
3512
|
+
note: (snap.storage.mode === "byo" ? "your bucket" : "hosted storage") +
|
|
3513
|
+
laneNote +
|
|
3514
|
+
healthNote,
|
|
3515
|
+
};
|
|
3516
|
+
}
|
|
3485
3517
|
scopes = snap.scopes;
|
|
3486
3518
|
if (scopes && !scopes.includes("files:delete")) {
|
|
3487
3519
|
hints.push("token lacks files:delete (`uploads delete` will be forbidden) — re-run `uploads login` for a full-scope token");
|
|
@@ -3511,10 +3543,7 @@ export async function buildDoctorReport(config, client, detectRoots) {
|
|
|
3511
3543
|
usage,
|
|
3512
3544
|
scopes,
|
|
3513
3545
|
warning: mismatch,
|
|
3514
|
-
storage
|
|
3515
|
-
checked: false,
|
|
3516
|
-
note: "not checked from the CLI — storage settings (shared vs. bring-your-own-bucket) live behind a signed-in session; sign in on the web (Account → workspace → Settings) to view mode and verification status",
|
|
3517
|
-
},
|
|
3546
|
+
storage,
|
|
3518
3547
|
hints,
|
|
3519
3548
|
browser,
|
|
3520
3549
|
};
|
package/dist/format-usage.d.ts
CHANGED
|
@@ -9,6 +9,11 @@ export type UsageSnapshotLike = {
|
|
|
9
9
|
storageRemainingBytes?: number;
|
|
10
10
|
maxUploadsPerPeriod?: number;
|
|
11
11
|
uploadsRemaining?: number;
|
|
12
|
+
/** Bytes still on hosted storage (shared-lane residue). */
|
|
13
|
+
sharedBytes?: number;
|
|
14
|
+
/** "shared" = BYO bucket active: the storage cap meters only hosted
|
|
15
|
+
* residue; bytes in the customer's own bucket are unmetered. */
|
|
16
|
+
storageBudgetBasis?: "total" | "shared";
|
|
12
17
|
/** Catalog plan id when the API reports it (`free` | `pro`). */
|
|
13
18
|
plan?: string;
|
|
14
19
|
};
|
package/dist/format-usage.js
CHANGED
|
@@ -11,9 +11,14 @@ import { formatByteSize, formatMarketedBytes } from "./format-bytes.js";
|
|
|
11
11
|
import { BRAND } from "./cli-brand.js";
|
|
12
12
|
/** True when the API reported any cumulative workspace quota. */
|
|
13
13
|
export function isUsageMetered(result) {
|
|
14
|
-
return (usagePct(result
|
|
14
|
+
return (usagePct(storageMeteredBytes(result), result.maxStorageBytes) !== null ||
|
|
15
15
|
usagePct(result.uploadsInPeriod, result.maxUploadsPerPeriod) !== null);
|
|
16
16
|
}
|
|
17
|
+
/** The usage number the storage cap actually meters — hosted residue only
|
|
18
|
+
* when a BYO bucket is active (`storageBudgetBasis: "shared"`). */
|
|
19
|
+
function storageMeteredBytes(result) {
|
|
20
|
+
return result.storageBudgetBasis === "shared" ? (result.sharedBytes ?? 0) : result.bytes;
|
|
21
|
+
}
|
|
17
22
|
/** 0–100, one decimal. Missing/invalid caps → no bar. Matches web `usagePct`. */
|
|
18
23
|
export function usagePct(value, max) {
|
|
19
24
|
if (typeof max !== "number" || !(max > 0) || !Number.isFinite(value))
|
|
@@ -112,21 +117,26 @@ export function formatUsageHuman(result, opts = {}) {
|
|
|
112
117
|
const label = planLabel(result.plan);
|
|
113
118
|
if (label)
|
|
114
119
|
lines.push(`plan: ${label}`);
|
|
115
|
-
const
|
|
120
|
+
const byoActive = result.storageBudgetBasis === "shared";
|
|
121
|
+
const meteredBytes = storageMeteredBytes(result);
|
|
122
|
+
const storagePct = usagePct(meteredBytes, result.maxStorageBytes);
|
|
116
123
|
if (storagePct !== null && result.maxStorageBytes != null) {
|
|
117
124
|
// Caps (and remaining-against-cap) use SI marketed formatting so Free's
|
|
118
125
|
// 250_000_000 reads as "250 MB", not binary "238.4 MB". Used bytes share
|
|
119
126
|
// the same base on this line so the three numbers stay coherent.
|
|
120
|
-
const detail = `${formatMarketedBytes(
|
|
127
|
+
const detail = `${formatMarketedBytes(meteredBytes)} / ${formatMarketedBytes(result.maxStorageBytes)}` +
|
|
121
128
|
(result.storageRemainingBytes != null
|
|
122
129
|
? ` (${formatMarketedBytes(result.storageRemainingBytes)} free)`
|
|
123
130
|
: "");
|
|
124
131
|
const bar = formatProgressBar(storagePct, { width, color });
|
|
125
|
-
lines.push(`storage: ${bar} ${detail}`);
|
|
132
|
+
lines.push(`storage: ${bar} ${detail}${byoActive ? " on hosted storage" : ""}`);
|
|
126
133
|
}
|
|
127
134
|
else {
|
|
128
135
|
lines.push(`storage: ${formatByteSize(result.bytes)}`);
|
|
129
136
|
}
|
|
137
|
+
if (byoActive) {
|
|
138
|
+
lines.push("note: your own bucket is unmetered — the storage quota only counts files on hosted storage");
|
|
139
|
+
}
|
|
130
140
|
lines.push(`objects: ${formatCount(result.objects)}`);
|
|
131
141
|
const uploadsPct = usagePct(result.uploadsInPeriod, result.maxUploadsPerPeriod);
|
|
132
142
|
if (uploadsPct !== null && result.maxUploadsPerPeriod != null) {
|
package/dist/mcp/tools.js
CHANGED
|
@@ -1298,7 +1298,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
1298
1298
|
title: "Find files",
|
|
1299
1299
|
annotations: mcpRead,
|
|
1300
1300
|
securitySchemes: mcpOAuthRead,
|
|
1301
|
-
description: "Find objects whose queryable custom metadata matches ALL of `filters` (ANDed equality) and/or whose key contains `name` (case-insensitive substring). At least one of `filters` or `name` is required. Returns each match's key, public URL, full metadata map,
|
|
1301
|
+
description: "Find objects whose queryable custom metadata matches ALL of `filters` (ANDed equality) and/or whose key contains `name` (case-insensitive substring). At least one of `filters` or `name` is required. Returns each match's key, public URL, full metadata map, optional `truncated`, and a `cursor` to pass back for the next page (null when there are none; set `all` to follow it for you). Same as `uploads find k=v...` / `uploads find --name <term>`.",
|
|
1302
1302
|
inputSchema: {
|
|
1303
1303
|
type: "object",
|
|
1304
1304
|
properties: {
|
|
@@ -1315,6 +1315,14 @@ export function createUploadsMcpTools(opts) {
|
|
|
1315
1315
|
description: "Key prefix filter, combinable with filters/name.",
|
|
1316
1316
|
},
|
|
1317
1317
|
limit: { type: "number", description: "Page size (default 50, max 500)." },
|
|
1318
|
+
cursor: {
|
|
1319
|
+
type: "string",
|
|
1320
|
+
description: "Opaque continuation from a previous call's `cursor`. Pass it back unchanged with the same filters/name to get the next page; a null `cursor` means there are no more pages.",
|
|
1321
|
+
},
|
|
1322
|
+
all: {
|
|
1323
|
+
type: "boolean",
|
|
1324
|
+
description: "Follow the cursor and return every page, up to a bounded number of requests. A non-null `cursor` in the result means that bound was reached before the end — pass it back to continue.",
|
|
1325
|
+
},
|
|
1318
1326
|
workspace: workspaceProp,
|
|
1319
1327
|
},
|
|
1320
1328
|
additionalProperties: false,
|
|
@@ -1329,11 +1337,17 @@ export function createUploadsMcpTools(opts) {
|
|
|
1329
1337
|
if (hasMeta)
|
|
1330
1338
|
validateMetaMap(filters);
|
|
1331
1339
|
const { client } = await clientFor(args);
|
|
1332
|
-
|
|
1340
|
+
const opts = {
|
|
1333
1341
|
name,
|
|
1334
1342
|
prefix: optString(args, "prefix"),
|
|
1335
1343
|
limit: optPosInt(args, "limit"),
|
|
1336
|
-
|
|
1344
|
+
cursor: optString(args, "cursor"),
|
|
1345
|
+
};
|
|
1346
|
+
// `all` drains through findFilesAll, which caps its own page count —
|
|
1347
|
+
// unlike `list`'s `all`, this never runs unbounded.
|
|
1348
|
+
return optBool(args, "all")
|
|
1349
|
+
? client.findFilesAll(filters, opts)
|
|
1350
|
+
: client.findFiles(filters, opts);
|
|
1337
1351
|
},
|
|
1338
1352
|
},
|
|
1339
1353
|
{
|