@buildinternet/uploads 0.6.0 → 0.8.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/commands.js CHANGED
@@ -1,18 +1,32 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { basename } from "node:path";
3
3
  import { createUploadsClient } from "./client.js";
4
- import { parseCommandArgs, flagString, flagBool, flagInt, UsageError, } from "./cli-args.js";
4
+ import { parseCommandArgs, flagString, flagBool, flagInt, flagValues, UsageError, } from "./cli-args.js";
5
5
  import { resolvePutDefaults, workspaceMismatch, workspaceFromToken, } from "./config.js";
6
6
  import { buildMarkdown } from "./embed.js";
7
+ import { urlForGithubEmbed } from "./public-urls.js";
7
8
  import { UploadsError } from "./errors.js";
8
9
  import { writeJson, writeStdout } from "./io.js";
9
- import { ghAttachmentKey, ghKeyPrefix, attachmentsCommentBody, normalizeGithubCoordinate, } from "./github.js";
10
+ import { parseMetaFlags, validateMetaMap } from "./metadata.js";
11
+ import { ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, attachmentsCommentBody, normalizeGithubCoordinate, } from "./github.js";
10
12
  import { resolveRepo, resolveCurrentPullRequest, execRunner, upsertAttachmentsComment, } from "./github-gh.js";
11
13
  import { resolvePutPrefix } from "./destinations.js";
12
14
  import { optimizeImageForUpload, rewriteKeyExtension, } from "./optimize.js";
13
15
  import { applyFrame, resolveFrameId } from "./frame.js";
14
16
  import { buildCliProvenance } from "./provenance.js";
15
17
  import { packageVersion } from "./package-version.js";
18
+ /** Read a local file (or `-` for stdin). Missing path → FILE_NOT_FOUND (exit 2). */
19
+ export function readFileArg(fileArg) {
20
+ try {
21
+ return new Uint8Array(readFileSync(fileArg === "-" ? 0 : fileArg));
22
+ }
23
+ catch (err) {
24
+ if (err?.code === "ENOENT") {
25
+ throw new UploadsError(`file not found: ${fileArg}`, "FILE_NOT_FOUND");
26
+ }
27
+ throw err;
28
+ }
29
+ }
16
30
  // --- put ---
17
31
  const PUT_HELP = `uploads put <file> [options]
18
32
 
@@ -30,8 +44,12 @@ Uploads are public. --pr/--issue keys include the repo, number, and filename and
30
44
  remain public even for private/internal GitHub repositories. Upload only media
31
45
  that is safe at a predictable public URL.
32
46
 
47
+ Human/json output includes durable url and (when dual-host applies) embedUrl.
48
+ MARKDOWN prefers embedUrl for GitHub. Override: UPLOADS_EMBED_PUBLIC_BASE_URL.
49
+
33
50
  Options:
34
51
  --key <key> Object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.<ext>)
52
+ --name <leaf> Clean key leaf + default alt (no '/'); keeps --pr/default path. Not with --key
35
53
  --destination <id> Typed root: screenshots | gh | f (sets --prefix)
36
54
  --prefix <path> Key prefix (default: screenshots, or UPLOADS_DEFAULT_PREFIX)
37
55
  --repo <owner/repo> Repo segment (default: git remote, or UPLOADS_DEFAULT_REPO)
@@ -53,13 +71,24 @@ Options:
53
71
  --issue <num> Attach to an issue: key gh/<owner>/<repo>/issues/<num>/<name>
54
72
  --comment With --pr/--issue: update one managed comment with attachments and linked galleries via local gh auth
55
73
  --gallery <id> Add the uploaded object to this public gallery
74
+ --meta <k=v> Queryable custom metadata (repeatable; value may contain "="): key ^[a-z][a-z0-9._-]{0,63}$, value 1-512 printable ASCII, max 24 pairs
75
+ Re-uploading to an existing key WITH --meta replaces that file's
76
+ entire metadata set; without --meta the existing metadata is
77
+ preserved. Use "uploads meta set" to edit individual keys.
78
+ --dry-run Print key + public URL without uploading. Not with --comment/--gallery
79
+
80
+ Exit codes: 0 ok · 2 usage/token/file · 3 auth/policy · 4 network · 1 other.
81
+ Scripted formats (json|url|markdown) also print failures on stdout.
56
82
 
57
83
  Examples:
58
84
  uploads put ./shot.png --repo myorg/myapp --ref 1722 --alt "New cards" --width 700
59
85
  uploads put ./mobile.png --frame phone
60
86
  uploads put ./ui.png --frame browser --frame-url "https://app.example/settings"
61
87
  uploads put ./shot.png --destination screenshots
88
+ uploads put ./capture-….webp --pr 128 --name hero.webp
89
+ uploads put ./shot.png --pr 128 --name hero.webp --dry-run --format url
62
90
  uploads put ./after.png --gallery gal_example
91
+ uploads put ./shot.png --meta app=myapp --meta page=settings
63
92
  `;
64
93
  /**
65
94
  * Turns a pr/issue pair (+ optional repo) into a GhTarget; undefined when
@@ -160,7 +189,7 @@ function frameOptionsFromFlags(flags) {
160
189
  * fatal (`comment` command) or a warning (`put --comment`).
161
190
  */
162
191
  export async function syncAttachmentsComment(client, target, run) {
163
- const items = (await client.listAll({ prefix: ghKeyPrefix(target) })).map(({ key, url }) => ({ key, url }));
192
+ const items = (await client.listAll({ prefix: ghKeyPrefix(target) })).map(({ key, url, embedUrl }) => ({ key, url, embedUrl }));
164
193
  const galleries = [];
165
194
  let cursor;
166
195
  do {
@@ -181,7 +210,12 @@ export async function syncAttachmentsComment(client, target, run) {
181
210
  previews: detail.items
182
211
  .filter((item) => item.status === "available" && item.url && item.contentType?.startsWith("image/"))
183
212
  .slice(0, 3)
184
- .map((item) => ({ url: item.url, alt: item.altText ?? item.objectKey })),
213
+ .map((item) => ({
214
+ url: item.url,
215
+ embedUrl: item.embedUrl,
216
+ alt: item.altText ?? item.objectKey,
217
+ itemUrl: item.pageUrl,
218
+ })),
185
219
  };
186
220
  }
187
221
  catch {
@@ -222,12 +256,19 @@ Options:
222
256
  --optimize-quality <1-100> WebP quality (default: 85)
223
257
  --keep-exif Keep EXIF/XMP/ICC when optimizing (default: strip for privacy)
224
258
  --workspace, -w <name> Override workspace
259
+ --meta <k=v> Extra queryable metadata (repeatable; value may contain "=").
260
+ gh.repo/gh.kind/gh.number/gh.ref are always set from the resolved
261
+ target — a --meta pair with the same key is overridden by it.
262
+ Because attach always sends its own gh.* pairs, re-attaching to
263
+ the same key always replaces that file's entire metadata set
264
+ (never preserves) — use "uploads meta set" to add to it instead.
225
265
 
226
266
  Examples:
227
267
  uploads attach ./before.png ./after.png
228
268
  uploads attach ./mobile.png --frame phone
229
269
  uploads attach ./shot.png --pr 123 --repo myorg/myapp
230
270
  uploads attach ./artifact.zip --issue 45 --no-comment
271
+ uploads attach ./shot.png --meta app=myapp --meta page=settings
231
272
  `;
232
273
  export async function runAttach(ctx, args, help = false, run = execRunner) {
233
274
  const parsed = parseCommandArgs(args);
@@ -249,6 +290,15 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
249
290
  const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, defaults);
250
291
  const frameOpts = frameOptionsFromFlags(parsed.flags);
251
292
  const contentTypeOverride = flagString(parsed.flags, "--content-type");
293
+ // User-supplied extras first, then the resolved target's gh.* — explicit
294
+ // target pairs always win over a same-named --meta extra (documented above).
295
+ // Validate the merged map (not just the extras) so the 24-key/8KB caps are
296
+ // enforced client-side even when extras alone are under the cap but extras
297
+ // + the 4 gh.* pairs push the merged map over it.
298
+ const metaExtras = parseMetaFlags(flagValues(parsed.flags, "--meta"));
299
+ const metadata = { ...metaExtras, ...ghMetadataFromTarget(target) };
300
+ if (Object.keys(metadata).length > 0)
301
+ validateMetaMap(metadata);
252
302
  const results = [];
253
303
  for (const file of parsed.positionals) {
254
304
  if (file === "-")
@@ -256,7 +306,7 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
256
306
  const sourceName = basename(file);
257
307
  if (!ctx.quiet && !ctx.json)
258
308
  process.stderr.write(`>> uploading ${file}\n`);
259
- const prepared = await prepareImageForUpload(new Uint8Array(readFileSync(file)), sourceName, {
309
+ const prepared = await prepareImageForUpload(readFileArg(file), sourceName, {
260
310
  ...frameOpts,
261
311
  optimize: optimizeOpts,
262
312
  });
@@ -276,10 +326,12 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
276
326
  frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
277
327
  keepExif: optimizeOpts.keepExif === true,
278
328
  }),
329
+ metadata,
279
330
  });
331
+ const embedSrc = urlForGithubEmbed(result.url, result.embedUrl);
280
332
  results.push({
281
333
  ...result,
282
- markdown: buildMarkdown(result.url, { alt: sourceName }),
334
+ markdown: buildMarkdown(embedSrc, { alt: sourceName }),
283
335
  optimize: {
284
336
  optimized: prepared.optimized,
285
337
  skippedReason: prepared.skippedReason,
@@ -306,7 +358,8 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
306
358
  }
307
359
  else {
308
360
  for (const result of results) {
309
- await writeStdout(`URL: ${result.url}\nMARKDOWN: ${result.markdown}\n`);
361
+ const embedLine = result.embedUrl ? `EMBED: ${result.embedUrl}\n` : "";
362
+ await writeStdout(`URL: ${result.url}\n${embedLine}MARKDOWN: ${result.markdown}\n`);
310
363
  }
311
364
  if (!ctx.quiet && comment)
312
365
  process.stderr.write(`>> attachments comment ${comment.action}\n`);
@@ -334,20 +387,41 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
334
387
  const ghTarget = ghTargetFromFlags(parsed.flags, run);
335
388
  const wantComment = parsed.flags.has("--comment");
336
389
  const galleryId = flagString(parsed.flags, "--gallery");
390
+ const nameFlag = flagString(parsed.flags, "--name");
391
+ const dryRun = flagBool(parsed.flags, "--dry-run");
392
+ // Validate --meta up front (fail fast, before reading/optimizing the file).
393
+ const metadata = (() => {
394
+ const pairs = flagValues(parsed.flags, "--meta");
395
+ return pairs.length > 0 ? parseMetaFlags(pairs) : undefined;
396
+ })();
337
397
  if (wantComment && typeof parsed.flags.get("--comment") === "string") {
338
398
  throw new UsageError("--comment takes no value — place it after the file argument");
339
399
  }
340
400
  if (wantComment && !ghTarget)
341
401
  throw new UsageError("--comment requires --pr or --issue");
342
402
  if (ghTarget) {
343
- if (keyHint)
344
- throw new UsageError("--key cannot be combined with --pr/--issue");
403
+ if (keyHint) {
404
+ throw new UsageError("--key cannot be combined with --pr/--issue; use --name <leaf> to set a clean filename on the stable path");
405
+ }
345
406
  if (flagString(parsed.flags, "--ref")) {
346
407
  throw new UsageError("--ref cannot be combined with --pr/--issue");
347
408
  }
348
409
  if (prefixFlag)
349
410
  throw new UsageError("--prefix cannot be combined with --pr/--issue");
350
411
  }
412
+ if (nameFlag !== undefined) {
413
+ if (nameFlag === "" || nameFlag.includes("/")) {
414
+ throw new UsageError("--name must be a bare filename with no '/'");
415
+ }
416
+ if (keyHint)
417
+ throw new UsageError("--name cannot be combined with --key");
418
+ }
419
+ if (dryRun) {
420
+ if (wantComment)
421
+ throw new UsageError("--dry-run cannot be combined with --comment");
422
+ if (galleryId)
423
+ throw new UsageError("--dry-run cannot be combined with --gallery");
424
+ }
351
425
  let resolvedPrefix;
352
426
  try {
353
427
  resolvedPrefix = resolvePutPrefix({
@@ -360,8 +434,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
360
434
  catch (err) {
361
435
  throw new UsageError(err instanceof Error ? err.message : String(err));
362
436
  }
363
- const bytes = fileArg === "-" ? new Uint8Array(readFileSync(0)) : new Uint8Array(readFileSync(fileArg));
364
- const sourceName = fileArg === "-" ? (keyHint ? basename(keyHint) : "stdin.bin") : basename(fileArg);
437
+ const bytes = readFileArg(fileArg);
438
+ const sourceName = nameFlag ?? (fileArg === "-" ? (keyHint ? basename(keyHint) : "stdin.bin") : basename(fileArg));
365
439
  const format = ctx.json
366
440
  ? "json"
367
441
  : (() => {
@@ -375,6 +449,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
375
449
  const defaults = resolvePutDefaults({ envFile: ctx.envFile });
376
450
  const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, defaults);
377
451
  const frameOpts = frameOptionsFromFlags(parsed.flags);
452
+ // Optimize even on --dry-run so the preview key extension/hash match a real put.
378
453
  const prepared = await prepareImageForUpload(bytes, sourceName, {
379
454
  ...frameOpts,
380
455
  optimize: optimizeOpts,
@@ -391,7 +466,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
391
466
  })()
392
467
  : defaults.width;
393
468
  if (!ctx.quiet && format === "human") {
394
- process.stderr.write(`>> uploading ${fileArg === "-" ? "stdin" : fileArg}\n`);
469
+ process.stderr.write(`>> ${dryRun ? "dry run" : "uploading"} ${fileArg === "-" ? "stdin" : fileArg}\n`);
395
470
  if (prepared.frame?.framed)
396
471
  process.stderr.write(`>> framed with ${prepared.frame.frameId}\n`);
397
472
  const note = formatOptimizeNote(prepared);
@@ -410,14 +485,17 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
410
485
  ref: flagString(parsed.flags, "--ref") ?? defaults.ref,
411
486
  contentType: prepared.optimized ? prepared.contentType : contentTypeOverride,
412
487
  deriveRepoFromGit: !noGit,
488
+ dryRun,
413
489
  provenance: buildCliProvenance({
414
490
  sourceName,
415
491
  optimized: prepared.optimized,
416
492
  frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
417
493
  keepExif: optimizeOpts.keepExif === true,
418
494
  }),
495
+ metadata,
419
496
  });
420
- const markdown = buildMarkdown(result.url, { alt, width });
497
+ const embedSrc = urlForGithubEmbed(result.url, result.embedUrl);
498
+ const markdown = buildMarkdown(embedSrc, { alt, width });
421
499
  let gallery;
422
500
  if (galleryId) {
423
501
  try {
@@ -442,7 +520,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
442
520
  filename: prepared.filename,
443
521
  };
444
522
  if (!ctx.quiet && format === "human") {
445
- process.stderr.write(`>> key: ${result.key}\n\n`);
523
+ process.stderr.write(`>> key: ${result.key}${dryRun ? " (dry run — not uploaded)" : ""}\n\n`);
446
524
  }
447
525
  switch (format) {
448
526
  case "json":
@@ -452,6 +530,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
452
530
  optimize: optimizeMeta,
453
531
  frame: prepared.frame,
454
532
  gallery,
533
+ ...(dryRun ? { dryRun: true } : {}),
455
534
  });
456
535
  break;
457
536
  case "url":
@@ -460,8 +539,10 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
460
539
  case "markdown":
461
540
  await writeStdout(`${markdown}\n`);
462
541
  break;
463
- default:
464
- await writeStdout(`URL: ${result.url}\nMARKDOWN: ${markdown}${gallery?.url ? `\nGALLERY: ${gallery.url}` : ""}\n`);
542
+ default: {
543
+ const embedLine = result.embedUrl ? `EMBED: ${result.embedUrl}\n` : "";
544
+ await writeStdout(`URL: ${result.url}\n${embedLine}MARKDOWN: ${markdown}${gallery?.url ? `\nGALLERY: ${gallery.url}` : ""}\n`);
545
+ }
465
546
  }
466
547
  if (gallery?.url && format !== "human") {
467
548
  process.stderr.write(`gallery: ${gallery.url}\n`);
@@ -699,21 +780,51 @@ export async function runGallery(ctx, args, help = false) {
699
780
  }
700
781
  }
701
782
  // --- list ---
702
- const LIST_HELP = `uploads list [--prefix <p>] [--pr <num> | --issue <num>] [--repo <owner/name>] [--limit <n>] [--cursor <c>] [--all] [--workspace <name>]
783
+ const LIST_HELP = `uploads list [--prefix <p>] [--pr <num> | --issue <num>] [--repo <owner/name>] [--limit <n>] [--cursor <c>] [--all] [--meta <k=v>]... [--workspace <name>]
703
784
 
704
785
  Default prefix: UPLOADS_DEFAULT_PREFIX (screenshots if unset).
705
786
 
787
+ --meta <k=v> (repeatable, ANDed) switches to the metadata filter endpoint —
788
+ returned items include their matched metadata. Combines with --prefix, not
789
+ with --pr/--issue/--all. See also: uploads find (positional-pair alias).
790
+
706
791
  Examples:
707
792
  uploads list --prefix screenshots/
708
793
  uploads list --pr 123
709
794
  uploads list --all --json
795
+ uploads list --meta gh.repo=buildinternet/uploads --meta gh.number=123
710
796
  `;
797
+ /** `--meta k=v` (repeatable) filter path, shared by `runList` and `runFind`. */
798
+ async function runFindFiles(ctx, filters, flags) {
799
+ if (flagString(flags, "--cursor") !== undefined) {
800
+ throw new UsageError("--cursor is not supported with metadata filters");
801
+ }
802
+ const prefix = flagString(flags, "--prefix");
803
+ const limit = flagInt(flags, "--limit", "--limit");
804
+ const result = await ctx.client.findFiles(filters, { prefix, limit });
805
+ if (ctx.json)
806
+ await writeJson(result);
807
+ else
808
+ for (const item of result.items)
809
+ await writeStdout(`${item.key}${item.url ? ` ${item.url}` : ""}\n`);
810
+ return 0;
811
+ }
711
812
  export async function runList(ctx, args, help = false, run = execRunner) {
712
813
  const parsed = parseCommandArgs(args);
713
814
  if (help || parsed.help) {
714
815
  process.stderr.write(LIST_HELP);
715
816
  return 0;
716
817
  }
818
+ const metaPairs = flagValues(parsed.flags, "--meta");
819
+ if (metaPairs.length > 0) {
820
+ if (ghTargetFromFlags(parsed.flags, run)) {
821
+ throw new UsageError("--meta cannot be combined with --pr/--issue");
822
+ }
823
+ if (flagBool(parsed.flags, "--all")) {
824
+ throw new UsageError("--meta cannot be combined with --all");
825
+ }
826
+ return runFindFiles(ctx, parseMetaFlags(metaPairs), parsed.flags);
827
+ }
717
828
  const defaults = resolvePutDefaults({ envFile: ctx.envFile });
718
829
  const prefixFlag = flagString(parsed.flags, "--prefix");
719
830
  let prefix = prefixFlag ?? (defaults.prefix ? `${defaults.prefix}/` : undefined);
@@ -746,6 +857,89 @@ export async function runList(ctx, args, help = false, run = execRunner) {
746
857
  }
747
858
  return 0;
748
859
  }
860
+ // --- find ---
861
+ const FIND_HELP = `uploads find k=v [k=v...] [--prefix <p>] [--limit <n>] [--workspace <name>]
862
+
863
+ Human-friendly alias for \`uploads list --meta k=v...\` — same metadata filter
864
+ (ANDed equality), same output; pairs are positional instead of repeated flags.
865
+
866
+ Examples:
867
+ uploads find gh.repo=buildinternet/uploads gh.number=123
868
+ uploads find app=myapp page=settings --prefix screenshots/
869
+ `;
870
+ export async function runFind(ctx, args, help = false) {
871
+ const parsed = parseCommandArgs(args);
872
+ if (help || parsed.help) {
873
+ process.stderr.write(FIND_HELP);
874
+ return 0;
875
+ }
876
+ if (parsed.positionals.length === 0) {
877
+ process.stderr.write(FIND_HELP);
878
+ return 2;
879
+ }
880
+ const filters = parseMetaFlags(parsed.positionals);
881
+ return runFindFiles(ctx, filters, parsed.flags);
882
+ }
883
+ // --- meta ---
884
+ const META_HELP = `uploads meta <command> [args]
885
+
886
+ Read/write an object's queryable custom metadata (D1-backed key-value pairs;
887
+ distinct from the R2 provenance headers put on upload).
888
+
889
+ Commands:
890
+ get <key> Show metadata for an object
891
+ set <key> k=v [k=v...] [--delete k]... Merge-set and/or delete pairs
892
+
893
+ Examples:
894
+ uploads meta get screenshots/myapp/42/shot.png
895
+ uploads meta set screenshots/myapp/42/shot.png app=myapp page=settings
896
+ uploads meta set screenshots/myapp/42/shot.png --delete app --delete page
897
+ `;
898
+ export async function runMeta(ctx, args, help = false) {
899
+ const parsed = parseCommandArgs(args);
900
+ const action = parsed.positionals[0];
901
+ if (help || parsed.help || !action) {
902
+ process.stderr.write(META_HELP);
903
+ return help || parsed.help ? 0 : 2;
904
+ }
905
+ switch (action) {
906
+ case "get": {
907
+ const key = parsed.positionals[1];
908
+ if (!key)
909
+ throw new UsageError("meta get requires an object key");
910
+ const result = await ctx.client.getMetadata(key);
911
+ if (ctx.json)
912
+ await writeJson(result);
913
+ else
914
+ for (const [k, v] of Object.entries(result.metadata))
915
+ await writeStdout(`${k}=${v}\n`);
916
+ return 0;
917
+ }
918
+ case "set": {
919
+ const key = parsed.positionals[1];
920
+ if (!key)
921
+ throw new UsageError("meta set requires an object key");
922
+ const pairs = parsed.positionals.slice(2);
923
+ const del = flagValues(parsed.flags, "--delete");
924
+ if (pairs.length === 0 && del.length === 0) {
925
+ throw new UsageError("meta set requires k=v pairs and/or --delete <key>");
926
+ }
927
+ const set = pairs.length > 0 ? parseMetaFlags(pairs) : undefined;
928
+ const result = await ctx.client.patchMetadata(key, {
929
+ set,
930
+ delete: del.length > 0 ? del : undefined,
931
+ });
932
+ if (ctx.json)
933
+ await writeJson(result);
934
+ else
935
+ for (const [k, v] of Object.entries(result.metadata))
936
+ await writeStdout(`${k}=${v}\n`);
937
+ return 0;
938
+ }
939
+ default:
940
+ throw new UsageError(`unknown meta command: ${action}`);
941
+ }
942
+ }
749
943
  // --- delete ---
750
944
  const DELETE_HELP = `uploads delete <key> [--dry-run] [--workspace <name>]
751
945
 
package/dist/config.js CHANGED
@@ -140,11 +140,14 @@ export function resolveConfig(flags) {
140
140
  }
141
141
  function missingTokenMessage(configPath) {
142
142
  return [
143
- "UPLOADS_TOKEN is required.",
144
- " uploads login # exchange an admin-provided enrollment code",
143
+ "You're not signed in yet — one quick step and you're set:",
144
+ "",
145
+ " uploads login # open a browser and authorize this device",
146
+ "",
147
+ "Already have a token?",
145
148
  ` uploads setup --token <token> # guided setup → ${configPath}`,
146
149
  ` uploads config init --token <token> # writes ${configPath}`,
147
- " or set UPLOADS_TOKEN in env, pass --token, or use --env-file",
150
+ " or set UPLOADS_TOKEN / pass --token / use --env-file",
148
151
  ].join("\n");
149
152
  }
150
153
  /** Warn when an explicit workspace override may not match the token's embedded workspace. */
package/dist/errors.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type UploadsErrorCode = "MISSING_TOKEN" | "NO_PUBLIC_URL" | "NOT_FOUND" | "UNAUTHORIZED" | "INVALID_KEY" | "KEY_POLICY" | "STORAGE_QUOTA" | "UPLOAD_BUDGET" | "API_ERROR" | "NETWORK" | "USAGE";
1
+ export type UploadsErrorCode = "MISSING_TOKEN" | "NO_PUBLIC_URL" | "FILE_NOT_FOUND" | "NOT_FOUND" | "UNAUTHORIZED" | "INVALID_KEY" | "KEY_POLICY" | "STORAGE_QUOTA" | "UPLOAD_BUDGET" | "API_ERROR" | "NETWORK" | "USAGE";
2
2
  export declare class UploadsError extends Error {
3
3
  readonly code: UploadsErrorCode;
4
4
  readonly status?: number;
package/dist/github.d.ts CHANGED
@@ -22,21 +22,37 @@ export declare function ghKeyPrefix(target: GhTarget): string;
22
22
  * (unlike buildScreenshotKey).
23
23
  */
24
24
  export declare function ghAttachmentKey(target: GhTarget, filename: string): string;
25
+ /**
26
+ * The four `gh.*` queryable-metadata pairs `uploads attach` writes
27
+ * automatically (`.context/2026-07-13-file-metadata-design.md`). `gh.kind`
28
+ * uses the API's singular vocabulary (`pull`/`issue`), distinct from
29
+ * `GhTarget.kind`'s URL-segment spelling (`pull`/`issues`). `gh.repo` and
30
+ * `gh.ref` are both lowercased so exact-match metadata search has one
31
+ * canonical spelling regardless of source casing (`--repo`, git remote, and
32
+ * `gh` output vary); `gh.ref` uses the same lowercased `owner/repo#number`
33
+ * coordinate as gallery GitHub references, so both surfaces resolve the same
34
+ * lookup key.
35
+ */
36
+ export declare function ghMetadataFromTarget(target: GhTarget): Record<string, string>;
25
37
  /** Hidden marker identifying the one comment this CLI manages. Never change it — existing comments are found by exact match. */
26
38
  export declare const ATTACHMENTS_MARKER = "<!-- uploads.sh:attachments -->";
27
39
  export interface AttachmentItem {
28
40
  key: string;
29
41
  url: string | null;
42
+ /** Prefer for `<img src>` on GitHub (Camo-friendly host). Falls back to `url`. */
43
+ embedUrl?: string | null;
30
44
  }
31
45
  /** A public gallery linked to the PR or issue whose managed comment is syncing. */
32
46
  export interface GalleryCommentItem {
33
47
  title: string;
34
48
  /** Canonical URL returned by the API; callers must not synthesize it. */
35
49
  url: string;
36
- /** A bounded set of available images, all of which link back to the gallery. */
50
+ /** A bounded set of available images; each links to its item page when known, else the gallery. */
37
51
  previews?: {
38
52
  url: string;
39
53
  alt: string;
54
+ embedUrl?: string | null;
55
+ itemUrl?: string;
40
56
  }[];
41
57
  }
42
58
  /** Default max width for images in the managed attachments comment (HTML img). */
package/dist/github.js CHANGED
@@ -63,6 +63,26 @@ export function ghKeyPrefix(target) {
63
63
  export function ghAttachmentKey(target, filename) {
64
64
  return `${ghKeyPrefix(target)}${sanitizeKeySegment(filename)}`;
65
65
  }
66
+ /**
67
+ * The four `gh.*` queryable-metadata pairs `uploads attach` writes
68
+ * automatically (`.context/2026-07-13-file-metadata-design.md`). `gh.kind`
69
+ * uses the API's singular vocabulary (`pull`/`issue`), distinct from
70
+ * `GhTarget.kind`'s URL-segment spelling (`pull`/`issues`). `gh.repo` and
71
+ * `gh.ref` are both lowercased so exact-match metadata search has one
72
+ * canonical spelling regardless of source casing (`--repo`, git remote, and
73
+ * `gh` output vary); `gh.ref` uses the same lowercased `owner/repo#number`
74
+ * coordinate as gallery GitHub references, so both surfaces resolve the same
75
+ * lookup key.
76
+ */
77
+ export function ghMetadataFromTarget(target) {
78
+ const repo = target.repo.toLowerCase();
79
+ return {
80
+ "gh.repo": repo,
81
+ "gh.kind": target.kind === "issues" ? "issue" : "pull",
82
+ "gh.number": String(target.num),
83
+ "gh.ref": `${repo}#${target.num}`,
84
+ };
85
+ }
66
86
  /** Hidden marker identifying the one comment this CLI manages. Never change it — existing comments are found by exact match. */
67
87
  export const ATTACHMENTS_MARKER = "<!-- uploads.sh:attachments -->";
68
88
  /** Default max width for images in the managed attachments comment (HTML img). */
@@ -106,7 +126,9 @@ export function attachmentsCommentBody(items, galleries = []) {
106
126
  const href = escapeHtmlAttr(gallery.url);
107
127
  lines.push(`#### <a href="${href}">${escapeHtmlText(gallery.title)}</a>`);
108
128
  for (const preview of gallery.previews ?? []) {
109
- lines.push(`<a href="${href}"><img width="320" alt="${escapeHtmlAttr(preview.alt)}" src="${escapeHtmlAttr(preview.url)}"></a>`);
129
+ const previewHref = preview.itemUrl ? escapeHtmlAttr(preview.itemUrl) : href;
130
+ const previewSrc = escapeHtmlAttr(preview.embedUrl ?? preview.url);
131
+ lines.push(`<a href="${previewHref}"><img width="320" alt="${escapeHtmlAttr(preview.alt)}" src="${previewSrc}"></a>`);
110
132
  }
111
133
  lines.push(`<sub><a href="${href}">Open gallery</a></sub>`, "");
112
134
  }
@@ -116,17 +138,20 @@ export function attachmentsCommentBody(items, galleries = []) {
116
138
  lines.push("### 📎 Attachments", "");
117
139
  for (const item of sorted) {
118
140
  const name = item.key.slice(item.key.lastIndexOf("/") + 1);
119
- if (item.url && inferContentType(name).startsWith("image/")) {
141
+ const stable = item.url;
142
+ const src = item.embedUrl ?? item.url;
143
+ if (src && inferContentType(name).startsWith("image/")) {
120
144
  // Markdown ![]() has no width control — phone frames become full-column giants.
121
- // Link to the asset so a click opens the full image (no "open in new tab" hunt).
145
+ // img src uses embed host when available (Camo revalidates); click-through keeps stable URL.
122
146
  const w = attachmentImageWidth(name);
123
147
  const alt = escapeHtmlAttr(name);
124
- const href = escapeHtmlAttr(item.url);
125
- lines.push(`<a href="${href}"><img width="${w}" alt="${alt}" src="${href}"></a>`);
148
+ const href = escapeHtmlAttr(stable ?? src);
149
+ const imgSrc = escapeHtmlAttr(src);
150
+ lines.push(`<a href="${href}"><img width="${w}" alt="${alt}" src="${imgSrc}"></a>`);
126
151
  lines.push("");
127
152
  }
128
- else if (item.url) {
129
- lines.push(`- [${name}](${item.url})`);
153
+ else if (stable) {
154
+ lines.push(`- [${name}](${stable})`);
130
155
  }
131
156
  else {
132
157
  lines.push(`- ${name}`);
package/dist/index.d.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  export { inferContentType, buildMarkdown } from "./embed.js";
2
+ export { DEFAULT_EMBED_PUBLIC_BASE_URL, embedBaseUrlFromEnv, embedUrlFromPublic, resolveEmbedBaseUrl, resolveEmbedUrl, urlForGithubEmbed, } from "./public-urls.js";
2
3
  export { sanitizeKeySegment, sha256Short, deriveRepoFromGit, buildScreenshotKey } from "./keys.js";
3
4
  export { BUILTIN_DESTINATIONS, isBuiltinDestination, keyMatchesDestination, resolveDestinationRoot, resolvePutPrefix, type BuiltinDestinationId, } from "./destinations.js";
4
5
  export { DEFAULT_API_URL, DEFAULT_WORKSPACE, UPLOADS_CONFIG_KEYS, defaultConfigPath, resolveConfigPath, loadConfigFile, loadEnvFile, resolveApiUrl, resolveConfig, describeConfigSources, redactToken, writeConfigKeys, configValuesFromClient, putDefaultsToConfigValues, resolvePutDefaults, mergePutDefaults, workspaceFromToken, workspaceMismatch, type UploadsClientConfig, type ResolvedConfig, type WorkspaceSource, type ConfigValueSource, type ConfigSources, type UploadsConfigKey, type UploadsConfigValues, type PutDefaults, } from "./config.js";
5
6
  export { UploadsError, type UploadsErrorCode } from "./errors.js";
6
- export { createUploadsClient, type UploadsClient, type PutOptions, type ProvenanceInput, type ListOptions, type PutResult, type ListItem, type ListResult, type HeadResult, type DeleteResult, type GalleryItem, type Gallery, type GallerySummary, type GalleryListOptions, type GalleryListResult, type CreateGalleryOptions, type AddGalleryItemOptions, type DeleteGalleryOptions, type HealthResult, type UsageResult, type ReconcileResult, type PurgeExpiredResult, type PurgeExpiredResponse, } from "./client.js";
7
+ export { createUploadsClient, type UploadsClient, type PutOptions, type ProvenanceInput, type ListOptions, type PutResult, type ListItem, type ListResult, type HeadResult, type DeleteResult, type GalleryItem, type Gallery, type GallerySummary, type GalleryListOptions, type GalleryListResult, type CreateGalleryOptions, type AddGalleryItemOptions, type DeleteGalleryOptions, type HealthResult, type UsageResult, type ReconcileResult, type PurgeExpiredResult, type PurgeExpiredResponse, type FindFilesOptions, type FindFilesItem, type FindFilesResult, type GetMetadataResult, type PatchMetadataOptions, } from "./client.js";
7
8
  export { buildCliProvenance } from "./provenance.js";
8
- export { ATTACHMENTS_MARKER, attachmentsCommentBody, ghAttachmentKey, ghKeyPrefix, isValidRepo, parseRepoFromRemoteUrl, type AttachmentItem, type GhTarget, type GhTargetKind, } from "./github.js";
9
+ export { META_KEY_RE, META_VALUE_MAX, META_MAX_KEYS, META_MAX_TOTAL_BYTES, validateMetaEntry, parseMetaPair, parseMetaFlags, } from "./metadata.js";
10
+ export { ATTACHMENTS_MARKER, attachmentsCommentBody, ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, isValidRepo, parseRepoFromRemoteUrl, type AttachmentItem, type GhTarget, type GhTargetKind, } from "./github.js";
9
11
  export { DEFAULT_OPTIMIZE_MAX_EDGE, DEFAULT_OPTIMIZE_QUALITY, optimizeImageForUpload, rewriteKeyExtension, withImageExtension, type OptimizeImageOptions, type OptimizeImageResult, type OptimizeOutputFormat, } from "./optimize.js";
10
12
  export { FRAME_PRESETS, applyFrame, listFramePresets, resolveFrameId, type FrameFit, type FrameOptions, type FrameResult, } from "./frame.js";
11
13
  export { execRunner, resolveRepo, upsertAttachmentsComment, type CommandRunner, } from "./github-gh.js";
package/dist/index.js CHANGED
@@ -1,11 +1,13 @@
1
1
  export { inferContentType, buildMarkdown } from "./embed.js";
2
+ export { DEFAULT_EMBED_PUBLIC_BASE_URL, embedBaseUrlFromEnv, embedUrlFromPublic, resolveEmbedBaseUrl, resolveEmbedUrl, urlForGithubEmbed, } from "./public-urls.js";
2
3
  export { sanitizeKeySegment, sha256Short, deriveRepoFromGit, buildScreenshotKey } from "./keys.js";
3
4
  export { BUILTIN_DESTINATIONS, isBuiltinDestination, keyMatchesDestination, resolveDestinationRoot, resolvePutPrefix, } from "./destinations.js";
4
5
  export { DEFAULT_API_URL, DEFAULT_WORKSPACE, UPLOADS_CONFIG_KEYS, defaultConfigPath, resolveConfigPath, loadConfigFile, loadEnvFile, resolveApiUrl, resolveConfig, describeConfigSources, redactToken, writeConfigKeys, configValuesFromClient, putDefaultsToConfigValues, resolvePutDefaults, mergePutDefaults, workspaceFromToken, workspaceMismatch, } from "./config.js";
5
6
  export { UploadsError } from "./errors.js";
6
7
  export { createUploadsClient, } from "./client.js";
7
8
  export { buildCliProvenance } from "./provenance.js";
8
- export { ATTACHMENTS_MARKER, attachmentsCommentBody, ghAttachmentKey, ghKeyPrefix, isValidRepo, parseRepoFromRemoteUrl, } from "./github.js";
9
+ export { META_KEY_RE, META_VALUE_MAX, META_MAX_KEYS, META_MAX_TOTAL_BYTES, validateMetaEntry, parseMetaPair, parseMetaFlags, } from "./metadata.js";
10
+ export { ATTACHMENTS_MARKER, attachmentsCommentBody, ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, isValidRepo, parseRepoFromRemoteUrl, } from "./github.js";
9
11
  export { DEFAULT_OPTIMIZE_MAX_EDGE, DEFAULT_OPTIMIZE_QUALITY, optimizeImageForUpload, rewriteKeyExtension, withImageExtension, } from "./optimize.js";
10
12
  export { FRAME_PRESETS, applyFrame, listFramePresets, resolveFrameId, } from "./frame.js";
11
13
  export { execRunner, resolveRepo, upsertAttachmentsComment, } from "./github-gh.js";
@@ -2,3 +2,20 @@ export type ToolArgs = Record<string, unknown>;
2
2
  export declare function usage(msg: string): never;
3
3
  export declare function optString(args: ToolArgs, name: string): string | undefined;
4
4
  export declare function optPosInt(args: ToolArgs, name: string): number | undefined;
5
+ /** A JSON-object argument of string→string pairs (e.g. a `metadata` or `filters` param). */
6
+ export declare function optStringRecord(args: ToolArgs, name: string): Record<string, string> | undefined;
7
+ /** A JSON-array argument of strings (e.g. a `delete` or `files` param). */
8
+ export declare function optStringArray(args: ToolArgs, name: string): string[] | undefined;
9
+ /**
10
+ * Shared tool-description text for the metadata-shaped `metadata`/`set`/
11
+ * `filters` params across the CLI/local MCP (put/attach/set_metadata/
12
+ * find_files) and the remote MCP worker (set_metadata/find_files).
13
+ */
14
+ export declare const METADATA_DESCRIPTION = "Queryable custom metadata (key\u2192value), separate from provenance. Omit to leave any metadata already stored for this key untouched; pass an object (even {}) to fully replace it. Keys: lowercase, ^[a-z][a-z0-9._-]{0,63}$. Values: 1-512 printable ASCII characters. Caps: at most 24 keys, at most 8192 total key+value bytes. Suggested keys: app, url, page, device, resolution, commit, branch. `gh.*` is reserved by convention for GitHub PR/issue attachment context (repo/kind/number/ref).";
15
+ export declare const metadataProp: {
16
+ type: string;
17
+ additionalProperties: {
18
+ type: string;
19
+ };
20
+ description: string;
21
+ };