@buildinternet/uploads 0.7.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 {
@@ -183,6 +212,7 @@ export async function syncAttachmentsComment(client, target, run) {
183
212
  .slice(0, 3)
184
213
  .map((item) => ({
185
214
  url: item.url,
215
+ embedUrl: item.embedUrl,
186
216
  alt: item.altText ?? item.objectKey,
187
217
  itemUrl: item.pageUrl,
188
218
  })),
@@ -226,12 +256,19 @@ Options:
226
256
  --optimize-quality <1-100> WebP quality (default: 85)
227
257
  --keep-exif Keep EXIF/XMP/ICC when optimizing (default: strip for privacy)
228
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.
229
265
 
230
266
  Examples:
231
267
  uploads attach ./before.png ./after.png
232
268
  uploads attach ./mobile.png --frame phone
233
269
  uploads attach ./shot.png --pr 123 --repo myorg/myapp
234
270
  uploads attach ./artifact.zip --issue 45 --no-comment
271
+ uploads attach ./shot.png --meta app=myapp --meta page=settings
235
272
  `;
236
273
  export async function runAttach(ctx, args, help = false, run = execRunner) {
237
274
  const parsed = parseCommandArgs(args);
@@ -253,6 +290,15 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
253
290
  const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, defaults);
254
291
  const frameOpts = frameOptionsFromFlags(parsed.flags);
255
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);
256
302
  const results = [];
257
303
  for (const file of parsed.positionals) {
258
304
  if (file === "-")
@@ -260,7 +306,7 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
260
306
  const sourceName = basename(file);
261
307
  if (!ctx.quiet && !ctx.json)
262
308
  process.stderr.write(`>> uploading ${file}\n`);
263
- const prepared = await prepareImageForUpload(new Uint8Array(readFileSync(file)), sourceName, {
309
+ const prepared = await prepareImageForUpload(readFileArg(file), sourceName, {
264
310
  ...frameOpts,
265
311
  optimize: optimizeOpts,
266
312
  });
@@ -280,10 +326,12 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
280
326
  frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
281
327
  keepExif: optimizeOpts.keepExif === true,
282
328
  }),
329
+ metadata,
283
330
  });
331
+ const embedSrc = urlForGithubEmbed(result.url, result.embedUrl);
284
332
  results.push({
285
333
  ...result,
286
- markdown: buildMarkdown(result.url, { alt: sourceName }),
334
+ markdown: buildMarkdown(embedSrc, { alt: sourceName }),
287
335
  optimize: {
288
336
  optimized: prepared.optimized,
289
337
  skippedReason: prepared.skippedReason,
@@ -310,7 +358,8 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
310
358
  }
311
359
  else {
312
360
  for (const result of results) {
313
- 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`);
314
363
  }
315
364
  if (!ctx.quiet && comment)
316
365
  process.stderr.write(`>> attachments comment ${comment.action}\n`);
@@ -338,20 +387,41 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
338
387
  const ghTarget = ghTargetFromFlags(parsed.flags, run);
339
388
  const wantComment = parsed.flags.has("--comment");
340
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
+ })();
341
397
  if (wantComment && typeof parsed.flags.get("--comment") === "string") {
342
398
  throw new UsageError("--comment takes no value — place it after the file argument");
343
399
  }
344
400
  if (wantComment && !ghTarget)
345
401
  throw new UsageError("--comment requires --pr or --issue");
346
402
  if (ghTarget) {
347
- if (keyHint)
348
- 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
+ }
349
406
  if (flagString(parsed.flags, "--ref")) {
350
407
  throw new UsageError("--ref cannot be combined with --pr/--issue");
351
408
  }
352
409
  if (prefixFlag)
353
410
  throw new UsageError("--prefix cannot be combined with --pr/--issue");
354
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
+ }
355
425
  let resolvedPrefix;
356
426
  try {
357
427
  resolvedPrefix = resolvePutPrefix({
@@ -364,8 +434,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
364
434
  catch (err) {
365
435
  throw new UsageError(err instanceof Error ? err.message : String(err));
366
436
  }
367
- const bytes = fileArg === "-" ? new Uint8Array(readFileSync(0)) : new Uint8Array(readFileSync(fileArg));
368
- 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));
369
439
  const format = ctx.json
370
440
  ? "json"
371
441
  : (() => {
@@ -379,6 +449,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
379
449
  const defaults = resolvePutDefaults({ envFile: ctx.envFile });
380
450
  const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, defaults);
381
451
  const frameOpts = frameOptionsFromFlags(parsed.flags);
452
+ // Optimize even on --dry-run so the preview key extension/hash match a real put.
382
453
  const prepared = await prepareImageForUpload(bytes, sourceName, {
383
454
  ...frameOpts,
384
455
  optimize: optimizeOpts,
@@ -395,7 +466,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
395
466
  })()
396
467
  : defaults.width;
397
468
  if (!ctx.quiet && format === "human") {
398
- process.stderr.write(`>> uploading ${fileArg === "-" ? "stdin" : fileArg}\n`);
469
+ process.stderr.write(`>> ${dryRun ? "dry run" : "uploading"} ${fileArg === "-" ? "stdin" : fileArg}\n`);
399
470
  if (prepared.frame?.framed)
400
471
  process.stderr.write(`>> framed with ${prepared.frame.frameId}\n`);
401
472
  const note = formatOptimizeNote(prepared);
@@ -414,14 +485,17 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
414
485
  ref: flagString(parsed.flags, "--ref") ?? defaults.ref,
415
486
  contentType: prepared.optimized ? prepared.contentType : contentTypeOverride,
416
487
  deriveRepoFromGit: !noGit,
488
+ dryRun,
417
489
  provenance: buildCliProvenance({
418
490
  sourceName,
419
491
  optimized: prepared.optimized,
420
492
  frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
421
493
  keepExif: optimizeOpts.keepExif === true,
422
494
  }),
495
+ metadata,
423
496
  });
424
- const markdown = buildMarkdown(result.url, { alt, width });
497
+ const embedSrc = urlForGithubEmbed(result.url, result.embedUrl);
498
+ const markdown = buildMarkdown(embedSrc, { alt, width });
425
499
  let gallery;
426
500
  if (galleryId) {
427
501
  try {
@@ -446,7 +520,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
446
520
  filename: prepared.filename,
447
521
  };
448
522
  if (!ctx.quiet && format === "human") {
449
- process.stderr.write(`>> key: ${result.key}\n\n`);
523
+ process.stderr.write(`>> key: ${result.key}${dryRun ? " (dry run — not uploaded)" : ""}\n\n`);
450
524
  }
451
525
  switch (format) {
452
526
  case "json":
@@ -456,6 +530,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
456
530
  optimize: optimizeMeta,
457
531
  frame: prepared.frame,
458
532
  gallery,
533
+ ...(dryRun ? { dryRun: true } : {}),
459
534
  });
460
535
  break;
461
536
  case "url":
@@ -464,8 +539,10 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
464
539
  case "markdown":
465
540
  await writeStdout(`${markdown}\n`);
466
541
  break;
467
- default:
468
- 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
+ }
469
546
  }
470
547
  if (gallery?.url && format !== "human") {
471
548
  process.stderr.write(`gallery: ${gallery.url}\n`);
@@ -703,21 +780,51 @@ export async function runGallery(ctx, args, help = false) {
703
780
  }
704
781
  }
705
782
  // --- list ---
706
- 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>]
707
784
 
708
785
  Default prefix: UPLOADS_DEFAULT_PREFIX (screenshots if unset).
709
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
+
710
791
  Examples:
711
792
  uploads list --prefix screenshots/
712
793
  uploads list --pr 123
713
794
  uploads list --all --json
795
+ uploads list --meta gh.repo=buildinternet/uploads --meta gh.number=123
714
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
+ }
715
812
  export async function runList(ctx, args, help = false, run = execRunner) {
716
813
  const parsed = parseCommandArgs(args);
717
814
  if (help || parsed.help) {
718
815
  process.stderr.write(LIST_HELP);
719
816
  return 0;
720
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
+ }
721
828
  const defaults = resolvePutDefaults({ envFile: ctx.envFile });
722
829
  const prefixFlag = flagString(parsed.flags, "--prefix");
723
830
  let prefix = prefixFlag ?? (defaults.prefix ? `${defaults.prefix}/` : undefined);
@@ -750,6 +857,89 @@ export async function runList(ctx, args, help = false, run = execRunner) {
750
857
  }
751
858
  return 0;
752
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
+ }
753
943
  // --- delete ---
754
944
  const DELETE_HELP = `uploads delete <key> [--dry-run] [--workspace <name>]
755
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,11 +22,25 @@ 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 {
@@ -37,6 +51,7 @@ export interface GalleryCommentItem {
37
51
  previews?: {
38
52
  url: string;
39
53
  alt: string;
54
+ embedUrl?: string | null;
40
55
  itemUrl?: string;
41
56
  }[];
42
57
  }
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). */
@@ -107,7 +127,8 @@ export function attachmentsCommentBody(items, galleries = []) {
107
127
  lines.push(`#### <a href="${href}">${escapeHtmlText(gallery.title)}</a>`);
108
128
  for (const preview of gallery.previews ?? []) {
109
129
  const previewHref = preview.itemUrl ? escapeHtmlAttr(preview.itemUrl) : href;
110
- lines.push(`<a href="${previewHref}"><img width="320" alt="${escapeHtmlAttr(preview.alt)}" src="${escapeHtmlAttr(preview.url)}"></a>`);
130
+ const previewSrc = escapeHtmlAttr(preview.embedUrl ?? preview.url);
131
+ lines.push(`<a href="${previewHref}"><img width="320" alt="${escapeHtmlAttr(preview.alt)}" src="${previewSrc}"></a>`);
111
132
  }
112
133
  lines.push(`<sub><a href="${href}">Open gallery</a></sub>`, "");
113
134
  }
@@ -117,17 +138,20 @@ export function attachmentsCommentBody(items, galleries = []) {
117
138
  lines.push("### 📎 Attachments", "");
118
139
  for (const item of sorted) {
119
140
  const name = item.key.slice(item.key.lastIndexOf("/") + 1);
120
- 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/")) {
121
144
  // Markdown ![]() has no width control — phone frames become full-column giants.
122
- // 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.
123
146
  const w = attachmentImageWidth(name);
124
147
  const alt = escapeHtmlAttr(name);
125
- const href = escapeHtmlAttr(item.url);
126
- 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>`);
127
151
  lines.push("");
128
152
  }
129
- else if (item.url) {
130
- lines.push(`- [${name}](${item.url})`);
153
+ else if (stable) {
154
+ lines.push(`- [${name}](${stable})`);
131
155
  }
132
156
  else {
133
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
+ };
package/dist/mcp/args.js CHANGED
@@ -24,3 +24,45 @@ export function optPosInt(args, name) {
24
24
  }
25
25
  return v;
26
26
  }
27
+ /** A JSON-object argument of string→string pairs (e.g. a `metadata` or `filters` param). */
28
+ export function optStringRecord(args, name) {
29
+ const v = args[name];
30
+ if (v === undefined || v === null)
31
+ return undefined;
32
+ if (typeof v !== "object" || Array.isArray(v)) {
33
+ usage(`${name} must be an object of string values`);
34
+ }
35
+ // Object.create(null): a plain `{}` would silently drop a `__proto__` key
36
+ // (it hits the inherited setter instead of becoming an own property),
37
+ // turning a malicious/malformed key into a no-op rather than a rejected
38
+ // input. A null-prototype object makes every key a real own property so
39
+ // downstream validation (e.g. META_KEY_RE) sees and rejects it.
40
+ const result = Object.create(null);
41
+ for (const [key, value] of Object.entries(v)) {
42
+ if (typeof value !== "string")
43
+ usage(`${name}.${key} must be a string`);
44
+ result[key] = value;
45
+ }
46
+ return result;
47
+ }
48
+ /** A JSON-array argument of strings (e.g. a `delete` or `files` param). */
49
+ export function optStringArray(args, name) {
50
+ const v = args[name];
51
+ if (v === undefined || v === null)
52
+ return undefined;
53
+ if (!Array.isArray(v) || v.some((item) => typeof item !== "string")) {
54
+ usage(`${name} must be an array of strings`);
55
+ }
56
+ return v;
57
+ }
58
+ /**
59
+ * Shared tool-description text for the metadata-shaped `metadata`/`set`/
60
+ * `filters` params across the CLI/local MCP (put/attach/set_metadata/
61
+ * find_files) and the remote MCP worker (set_metadata/find_files).
62
+ */
63
+ export const METADATA_DESCRIPTION = "Queryable custom metadata (key→value), 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).";
64
+ export const metadataProp = {
65
+ type: "object",
66
+ additionalProperties: { type: "string" },
67
+ description: METADATA_DESCRIPTION,
68
+ };