@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 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 canonical search route is non-paginated (server cap
636
- * 100, narrowable via `limit`), so `cursor` is always null.
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
- findFiles(filters?: Record<string, string>, opts?: FindFilesOptions): Promise<FindFilesResult>;
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 — and only for
22
- // GET/PUT, since a `put` is byte-idempotent (same key, same bytes) but a
23
- // POST/DELETE/PATCH might not be, and telling "no bytes sent" apart from "the
24
- // mutation already landed" isn't reliable enough to risk a double-apply.
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", "PUT"]);
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 retryable = RETRYABLE_METHODS.has(method.toUpperCase());
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: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
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
- if (opts.metadata)
390
- params.set("metadata", "1");
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}` with queryable
393
- // metadata always hydrated (issue #613 the session shape won the
394
- // reconciliation). This client keeps its historical `{items, cursor}`
395
- // contract: rename the array and honor `opts.metadata` by stripping the
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 canonical search route is non-paginated (server cap
509
- * 100, narrowable via `limit`), so `cursor` is always null.
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
- async findFiles(filters = {}, opts = {}) {
512
- const params = new URLSearchParams();
513
- for (const [k, v] of Object.entries(filters))
514
- params.append(`meta.${k}`, v);
515
- if (opts.name)
516
- params.set("name", opts.name);
517
- if (opts.prefix)
518
- params.set("prefix", opts.prefix);
519
- if (opts.limit != null)
520
- params.set("limit", String(opts.limit));
521
- const result = await request("GET", `${canonicalFilesBase(config)}/search?${params.toString()}`);
522
- return { items: result.items, cursor: null, truncated: result.truncated };
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(opts)),
539
- headers: { "Content-Type": "application/json" },
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) {
@@ -615,16 +615,18 @@ export interface DoctorReport {
615
615
  /** Workspace/token mismatch warning (also present in hints). */
616
616
  warning?: string;
617
617
  /**
618
- * Bring-your-own-bucket storage status (issue #583 Phase 3). `GET
619
- * /me/workspaces/:name/storage` is session-gated (Better Auth cookie or
620
- * bearer via the AUTH service see `session-auth.ts`); the CLI only ever
621
- * holds a minted `up_<workspace>_…` workspace token, never a session
622
- * bearer, so doctor cannot reach that route today. Until a token-authed
623
- * read path exists, this is an honest "can't check from here" rather than
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: false;
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/--all. --name is a case-insensitive
2578
- substring match on object keys. See also: uploads find.
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
- const result = await ctx.client.findFiles(filters, { prefix, limit, name: nameTerm });
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
  };
@@ -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
  };
@@ -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.bytes, result.maxStorageBytes) !== null ||
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 storagePct = usagePct(result.bytes, result.maxStorageBytes);
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(result.bytes)} / ${formatMarketedBytes(result.maxStorageBytes)}` +
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, and optional `truncated`. Same as `uploads find k=v...` / `uploads find --name <term>`.",
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
- return client.findFiles(filters, {
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
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.46.3",
3
+ "version": "0.48.0",
4
4
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
5
  "type": "module",
6
6
  "sideEffects": false,