@buildinternet/uploads 0.7.0 → 0.9.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 { resolveRepo, resolveCurrentPullRequest, execRunner, upsertAttachmentsComment, } from "./github-gh.js";
10
+ import { parseMetaFlags, validateMetaMap } from "./metadata.js";
11
+ import { ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, attachmentsCommentBody, normalizeGithubCoordinate, } from "./github.js";
12
+ import { resolveRepo, resolveCurrentPullRequest, classifyGhNumber, 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)
@@ -47,19 +65,32 @@ Options:
47
65
  --optimize-quality <1-100> WebP quality (default: 85)
48
66
  --keep-exif Keep EXIF/XMP/ICC when optimizing (default: strip for privacy)
49
67
  --no-git Don't derive --repo from git (or UPLOADS_NO_GIT=1)
68
+ --auto Resolve current PR/issue and stamp gh.* metadata (default on)
69
+ --no-auto Skip gh.* auto-resolution (also skipped by --no-git or UPLOADS_NO_AUTO_META=1)
50
70
  --workspace, -w <name> Override workspace (wins over UPLOADS_WORKSPACE and token inference)
51
71
  --format human|url|markdown|json
52
72
  --pr <num> Attach to a pull request: key gh/<owner>/<repo>/pull/<num>/<name> (stable URL, no hash)
53
73
  --issue <num> Attach to an issue: key gh/<owner>/<repo>/issues/<num>/<name>
54
74
  --comment With --pr/--issue: update one managed comment with attachments and linked galleries via local gh auth
55
75
  --gallery <id> Add the uploaded object to this public gallery
76
+ --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
77
+ Re-uploading to an existing key WITH --meta replaces that file's
78
+ entire metadata set; without --meta the existing metadata is
79
+ preserved. Use "uploads meta set" to edit individual keys.
80
+ --dry-run Print key + public URL without uploading. Not with --comment/--gallery
81
+
82
+ Exit codes: 0 ok · 2 usage/token/file · 3 auth/policy · 4 network · 1 other.
83
+ Scripted formats (json|url|markdown) also print failures on stdout.
56
84
 
57
85
  Examples:
58
86
  uploads put ./shot.png --repo myorg/myapp --ref 1722 --alt "New cards" --width 700
59
87
  uploads put ./mobile.png --frame phone
60
88
  uploads put ./ui.png --frame browser --frame-url "https://app.example/settings"
61
89
  uploads put ./shot.png --destination screenshots
90
+ uploads put ./capture-….webp --pr 128 --name hero.webp
91
+ uploads put ./shot.png --pr 128 --name hero.webp --dry-run --format url
62
92
  uploads put ./after.png --gallery gal_example
93
+ uploads put ./shot.png --meta app=myapp --meta page=settings
63
94
  `;
64
95
  /**
65
96
  * Turns a pr/issue pair (+ optional repo) into a GhTarget; undefined when
@@ -78,6 +109,24 @@ export function makeGhTarget(pr, issue, repoArg, run) {
78
109
  function ghTargetFromFlags(flags, run) {
79
110
  return makeGhTarget(flagInt(flags, "--pr", "--pr"), flagInt(flags, "--issue", "--issue"), flagString(flags, "--repo"), run);
80
111
  }
112
+ /**
113
+ * Best-effort GitHub target for the default put path (no --pr/--issue). A
114
+ * numeric --ref is classified as pull vs issue; otherwise the current branch's
115
+ * PR is resolved. Never throws — any failure yields undefined so the upload
116
+ * proceeds without gh metadata.
117
+ */
118
+ function resolveAutoGhTarget(repoArg, ref, run) {
119
+ try {
120
+ const repo = resolveRepo(repoArg, run);
121
+ if (ref !== undefined && /^\d+$/.test(ref) && Number(ref) > 0) {
122
+ return classifyGhNumber(repo, Number.parseInt(ref, 10), run);
123
+ }
124
+ return resolveCurrentPullRequest(repo, run);
125
+ }
126
+ catch {
127
+ return undefined;
128
+ }
129
+ }
81
130
  /** Shared put/attach optimize flags + UPLOADS_NO_OPTIMIZE default. */
82
131
  export function optimizeOptionsFromFlags(flags, defaults) {
83
132
  if (flags.has("--no-optimize") && typeof flags.get("--no-optimize") === "string") {
@@ -160,7 +209,7 @@ function frameOptionsFromFlags(flags) {
160
209
  * fatal (`comment` command) or a warning (`put --comment`).
161
210
  */
162
211
  export async function syncAttachmentsComment(client, target, run) {
163
- const items = (await client.listAll({ prefix: ghKeyPrefix(target) })).map(({ key, url }) => ({ key, url }));
212
+ const items = (await client.listAll({ prefix: ghKeyPrefix(target) })).map(({ key, url, embedUrl }) => ({ key, url, embedUrl }));
164
213
  const galleries = [];
165
214
  let cursor;
166
215
  do {
@@ -183,6 +232,7 @@ export async function syncAttachmentsComment(client, target, run) {
183
232
  .slice(0, 3)
184
233
  .map((item) => ({
185
234
  url: item.url,
235
+ embedUrl: item.embedUrl,
186
236
  alt: item.altText ?? item.objectKey,
187
237
  itemUrl: item.pageUrl,
188
238
  })),
@@ -226,12 +276,19 @@ Options:
226
276
  --optimize-quality <1-100> WebP quality (default: 85)
227
277
  --keep-exif Keep EXIF/XMP/ICC when optimizing (default: strip for privacy)
228
278
  --workspace, -w <name> Override workspace
279
+ --meta <k=v> Extra queryable metadata (repeatable; value may contain "=").
280
+ gh.repo/gh.kind/gh.number/gh.ref are always set from the resolved
281
+ target — a --meta pair with the same key is overridden by it.
282
+ Because attach always sends its own gh.* pairs, re-attaching to
283
+ the same key always replaces that file's entire metadata set
284
+ (never preserves) — use "uploads meta set" to add to it instead.
229
285
 
230
286
  Examples:
231
287
  uploads attach ./before.png ./after.png
232
288
  uploads attach ./mobile.png --frame phone
233
289
  uploads attach ./shot.png --pr 123 --repo myorg/myapp
234
290
  uploads attach ./artifact.zip --issue 45 --no-comment
291
+ uploads attach ./shot.png --meta app=myapp --meta page=settings
235
292
  `;
236
293
  export async function runAttach(ctx, args, help = false, run = execRunner) {
237
294
  const parsed = parseCommandArgs(args);
@@ -253,6 +310,15 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
253
310
  const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, defaults);
254
311
  const frameOpts = frameOptionsFromFlags(parsed.flags);
255
312
  const contentTypeOverride = flagString(parsed.flags, "--content-type");
313
+ // User-supplied extras first, then the resolved target's gh.* — explicit
314
+ // target pairs always win over a same-named --meta extra (documented above).
315
+ // Validate the merged map (not just the extras) so the 24-key/8KB caps are
316
+ // enforced client-side even when extras alone are under the cap but extras
317
+ // + the 4 gh.* pairs push the merged map over it.
318
+ const metaExtras = parseMetaFlags(flagValues(parsed.flags, "--meta"));
319
+ const metadata = { ...metaExtras, ...ghMetadataFromTarget(target) };
320
+ if (Object.keys(metadata).length > 0)
321
+ validateMetaMap(metadata);
256
322
  const results = [];
257
323
  for (const file of parsed.positionals) {
258
324
  if (file === "-")
@@ -260,7 +326,7 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
260
326
  const sourceName = basename(file);
261
327
  if (!ctx.quiet && !ctx.json)
262
328
  process.stderr.write(`>> uploading ${file}\n`);
263
- const prepared = await prepareImageForUpload(new Uint8Array(readFileSync(file)), sourceName, {
329
+ const prepared = await prepareImageForUpload(readFileArg(file), sourceName, {
264
330
  ...frameOpts,
265
331
  optimize: optimizeOpts,
266
332
  });
@@ -280,10 +346,12 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
280
346
  frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
281
347
  keepExif: optimizeOpts.keepExif === true,
282
348
  }),
349
+ metadata,
283
350
  });
351
+ const embedSrc = urlForGithubEmbed(result.url, result.embedUrl);
284
352
  results.push({
285
353
  ...result,
286
- markdown: buildMarkdown(result.url, { alt: sourceName }),
354
+ markdown: buildMarkdown(embedSrc, { alt: sourceName }),
287
355
  optimize: {
288
356
  optimized: prepared.optimized,
289
357
  skippedReason: prepared.skippedReason,
@@ -310,10 +378,16 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
310
378
  }
311
379
  else {
312
380
  for (const result of results) {
313
- await writeStdout(`URL: ${result.url}\nMARKDOWN: ${result.markdown}\n`);
381
+ const embedLine = result.embedUrl ? `EMBED: ${result.embedUrl}\n` : "";
382
+ await writeStdout(`URL: ${result.url}\n${embedLine}MARKDOWN: ${result.markdown}\n`);
314
383
  }
315
384
  if (!ctx.quiet && comment)
316
385
  process.stderr.write(`>> attachments comment ${comment.action}\n`);
386
+ // attach auto-writes gh.* metadata; point the user at how to find it later.
387
+ if (!ctx.quiet) {
388
+ const ref = ghMetadataFromTarget(target)["gh.ref"];
389
+ process.stderr.write(`>> find these later: uploads find gh.ref=${ref}\n`);
390
+ }
317
391
  }
318
392
  return 0;
319
393
  }
@@ -338,20 +412,47 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
338
412
  const ghTarget = ghTargetFromFlags(parsed.flags, run);
339
413
  const wantComment = parsed.flags.has("--comment");
340
414
  const galleryId = flagString(parsed.flags, "--gallery");
415
+ const nameFlag = flagString(parsed.flags, "--name");
416
+ const dryRun = flagBool(parsed.flags, "--dry-run");
417
+ // Validate --meta up front (fail fast, before reading/optimizing the file).
418
+ const userMeta = (() => {
419
+ const pairs = flagValues(parsed.flags, "--meta");
420
+ return pairs.length > 0 ? parseMetaFlags(pairs) : undefined;
421
+ })();
341
422
  if (wantComment && typeof parsed.flags.get("--comment") === "string") {
342
423
  throw new UsageError("--comment takes no value — place it after the file argument");
343
424
  }
425
+ if (parsed.flags.has("--auto") && typeof parsed.flags.get("--auto") === "string") {
426
+ throw new UsageError("--auto takes no value");
427
+ }
428
+ if (parsed.flags.has("--no-auto") && typeof parsed.flags.get("--no-auto") === "string") {
429
+ throw new UsageError("--no-auto takes no value");
430
+ }
344
431
  if (wantComment && !ghTarget)
345
432
  throw new UsageError("--comment requires --pr or --issue");
346
433
  if (ghTarget) {
347
- if (keyHint)
348
- throw new UsageError("--key cannot be combined with --pr/--issue");
434
+ if (keyHint) {
435
+ throw new UsageError("--key cannot be combined with --pr/--issue; use --name <leaf> to set a clean filename on the stable path");
436
+ }
349
437
  if (flagString(parsed.flags, "--ref")) {
350
438
  throw new UsageError("--ref cannot be combined with --pr/--issue");
351
439
  }
352
440
  if (prefixFlag)
353
441
  throw new UsageError("--prefix cannot be combined with --pr/--issue");
354
442
  }
443
+ if (nameFlag !== undefined) {
444
+ if (nameFlag === "" || nameFlag.includes("/")) {
445
+ throw new UsageError("--name must be a bare filename with no '/'");
446
+ }
447
+ if (keyHint)
448
+ throw new UsageError("--name cannot be combined with --key");
449
+ }
450
+ if (dryRun) {
451
+ if (wantComment)
452
+ throw new UsageError("--dry-run cannot be combined with --comment");
453
+ if (galleryId)
454
+ throw new UsageError("--dry-run cannot be combined with --gallery");
455
+ }
355
456
  let resolvedPrefix;
356
457
  try {
357
458
  resolvedPrefix = resolvePutPrefix({
@@ -364,8 +465,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
364
465
  catch (err) {
365
466
  throw new UsageError(err instanceof Error ? err.message : String(err));
366
467
  }
367
- const bytes = fileArg === "-" ? new Uint8Array(readFileSync(0)) : new Uint8Array(readFileSync(fileArg));
368
- const sourceName = fileArg === "-" ? (keyHint ? basename(keyHint) : "stdin.bin") : basename(fileArg);
468
+ const bytes = readFileArg(fileArg);
469
+ const sourceName = nameFlag ?? (fileArg === "-" ? (keyHint ? basename(keyHint) : "stdin.bin") : basename(fileArg));
369
470
  const format = ctx.json
370
471
  ? "json"
371
472
  : (() => {
@@ -379,6 +480,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
379
480
  const defaults = resolvePutDefaults({ envFile: ctx.envFile });
380
481
  const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, defaults);
381
482
  const frameOpts = frameOptionsFromFlags(parsed.flags);
483
+ // Optimize even on --dry-run so the preview key extension/hash match a real put.
382
484
  const prepared = await prepareImageForUpload(bytes, sourceName, {
383
485
  ...frameOpts,
384
486
  optimize: optimizeOpts,
@@ -395,7 +497,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
395
497
  })()
396
498
  : defaults.width;
397
499
  if (!ctx.quiet && format === "human") {
398
- process.stderr.write(`>> uploading ${fileArg === "-" ? "stdin" : fileArg}\n`);
500
+ process.stderr.write(`>> ${dryRun ? "dry run" : "uploading"} ${fileArg === "-" ? "stdin" : fileArg}\n`);
399
501
  if (prepared.frame?.framed)
400
502
  process.stderr.write(`>> framed with ${prepared.frame.frameId}\n`);
401
503
  const note = formatOptimizeNote(prepared);
@@ -403,6 +505,43 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
403
505
  process.stderr.write(`>> ${note}\n`);
404
506
  }
405
507
  const noGit = flagBool(parsed.flags, "--no-git") || defaults.noGit === true;
508
+ // gh.* metadata: explicit --pr/--issue target wins over --meta; otherwise
509
+ // best-effort auto resolution (on by default) where --meta wins. --no-git,
510
+ // --no-auto, or UPLOADS_NO_AUTO_META disable auto; --auto forces past the
511
+ // config default but never past --no-git (no repo to resolve).
512
+ let metadata = userMeta;
513
+ let attachedRef;
514
+ if (ghTarget) {
515
+ const merged = { ...userMeta, ...ghMetadataFromTarget(ghTarget) };
516
+ validateMetaMap(merged); // enforce 24-key/8KB caps on the merged map (matches attach)
517
+ metadata = merged;
518
+ attachedRef = merged["gh.ref"];
519
+ }
520
+ else {
521
+ const autoEnabled = !noGit &&
522
+ !flagBool(parsed.flags, "--no-auto") &&
523
+ (flagBool(parsed.flags, "--auto") || defaults.noAutoMeta !== true);
524
+ if (autoEnabled) {
525
+ const autoTarget = resolveAutoGhTarget(flagString(parsed.flags, "--repo") ?? defaults.repo, flagString(parsed.flags, "--ref") ?? defaults.ref, run);
526
+ if (autoTarget) {
527
+ const autoMeta = ghMetadataFromTarget(autoTarget);
528
+ const merged = { ...autoMeta, ...userMeta };
529
+ // Auto resolution must never fail the upload: if merging the gh.* pairs
530
+ // would exceed the metadata caps, drop them and upload with --meta only.
531
+ try {
532
+ validateMetaMap(merged);
533
+ metadata = merged;
534
+ attachedRef = merged["gh.ref"];
535
+ }
536
+ catch {
537
+ // keep metadata = userMeta (already validated); skip auto gh.*
538
+ }
539
+ }
540
+ }
541
+ }
542
+ if (attachedRef && !ctx.quiet && format === "human") {
543
+ process.stderr.write(`>> attached to ${attachedRef}\n`);
544
+ }
406
545
  let key = ghTarget ? ghAttachmentKey(ghTarget, filename) : keyHint;
407
546
  if (key && prepared.optimized)
408
547
  key = rewriteKeyExtension(key, filename);
@@ -414,14 +553,17 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
414
553
  ref: flagString(parsed.flags, "--ref") ?? defaults.ref,
415
554
  contentType: prepared.optimized ? prepared.contentType : contentTypeOverride,
416
555
  deriveRepoFromGit: !noGit,
556
+ dryRun,
417
557
  provenance: buildCliProvenance({
418
558
  sourceName,
419
559
  optimized: prepared.optimized,
420
560
  frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
421
561
  keepExif: optimizeOpts.keepExif === true,
422
562
  }),
563
+ metadata,
423
564
  });
424
- const markdown = buildMarkdown(result.url, { alt, width });
565
+ const embedSrc = urlForGithubEmbed(result.url, result.embedUrl);
566
+ const markdown = buildMarkdown(embedSrc, { alt, width });
425
567
  let gallery;
426
568
  if (galleryId) {
427
569
  try {
@@ -446,7 +588,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
446
588
  filename: prepared.filename,
447
589
  };
448
590
  if (!ctx.quiet && format === "human") {
449
- process.stderr.write(`>> key: ${result.key}\n\n`);
591
+ process.stderr.write(`>> key: ${result.key}${dryRun ? " (dry run — not uploaded)" : ""}\n\n`);
450
592
  }
451
593
  switch (format) {
452
594
  case "json":
@@ -456,6 +598,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
456
598
  optimize: optimizeMeta,
457
599
  frame: prepared.frame,
458
600
  gallery,
601
+ ...(dryRun ? { dryRun: true } : {}),
459
602
  });
460
603
  break;
461
604
  case "url":
@@ -464,8 +607,10 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
464
607
  case "markdown":
465
608
  await writeStdout(`${markdown}\n`);
466
609
  break;
467
- default:
468
- await writeStdout(`URL: ${result.url}\nMARKDOWN: ${markdown}${gallery?.url ? `\nGALLERY: ${gallery.url}` : ""}\n`);
610
+ default: {
611
+ const embedLine = result.embedUrl ? `EMBED: ${result.embedUrl}\n` : "";
612
+ await writeStdout(`URL: ${result.url}\n${embedLine}MARKDOWN: ${markdown}${gallery?.url ? `\nGALLERY: ${gallery.url}` : ""}\n`);
613
+ }
469
614
  }
470
615
  if (gallery?.url && format !== "human") {
471
616
  process.stderr.write(`gallery: ${gallery.url}\n`);
@@ -703,21 +848,58 @@ export async function runGallery(ctx, args, help = false) {
703
848
  }
704
849
  }
705
850
  // --- list ---
706
- const LIST_HELP = `uploads list [--prefix <p>] [--pr <num> | --issue <num>] [--repo <owner/name>] [--limit <n>] [--cursor <c>] [--all] [--workspace <name>]
851
+ 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
852
 
708
853
  Default prefix: UPLOADS_DEFAULT_PREFIX (screenshots if unset).
709
854
 
855
+ --meta <k=v> (repeatable, ANDed) switches to the metadata filter endpoint —
856
+ returned items include their matched metadata. Combines with --prefix, not
857
+ with --pr/--issue/--all. See also: uploads find (positional-pair alias).
858
+
710
859
  Examples:
711
860
  uploads list --prefix screenshots/
712
861
  uploads list --pr 123
713
862
  uploads list --all --json
863
+ uploads list --meta gh.repo=buildinternet/uploads --meta gh.number=123
714
864
  `;
865
+ /** `--meta k=v` (repeatable) filter path, shared by `runList` and `runFind`. */
866
+ async function runFindFiles(ctx, filters, flags) {
867
+ if (flagString(flags, "--cursor") !== undefined) {
868
+ throw new UsageError("--cursor is not supported with metadata filters");
869
+ }
870
+ const prefix = flagString(flags, "--prefix");
871
+ const limit = flagInt(flags, "--limit", "--limit");
872
+ const result = await ctx.client.findFiles(filters, { prefix, limit });
873
+ if (ctx.json)
874
+ await writeJson(result);
875
+ else
876
+ for (const item of result.items) {
877
+ // LIST_HELP promises matched metadata in the output; render it inline
878
+ // (sorted for stable output) so human mode honors that, not just --json.
879
+ const meta = Object.entries(item.metadata)
880
+ .toSorted(([a], [b]) => a.localeCompare(b))
881
+ .map(([k, v]) => `${k}=${v}`)
882
+ .join(" ");
883
+ await writeStdout(`${item.key}${item.url ? ` ${item.url}` : ""}${meta ? ` ${meta}` : ""}\n`);
884
+ }
885
+ return 0;
886
+ }
715
887
  export async function runList(ctx, args, help = false, run = execRunner) {
716
888
  const parsed = parseCommandArgs(args);
717
889
  if (help || parsed.help) {
718
890
  process.stderr.write(LIST_HELP);
719
891
  return 0;
720
892
  }
893
+ const metaPairs = flagValues(parsed.flags, "--meta");
894
+ if (metaPairs.length > 0) {
895
+ if (ghTargetFromFlags(parsed.flags, run)) {
896
+ throw new UsageError("--meta cannot be combined with --pr/--issue");
897
+ }
898
+ if (flagBool(parsed.flags, "--all")) {
899
+ throw new UsageError("--meta cannot be combined with --all");
900
+ }
901
+ return runFindFiles(ctx, parseMetaFlags(metaPairs), parsed.flags);
902
+ }
721
903
  const defaults = resolvePutDefaults({ envFile: ctx.envFile });
722
904
  const prefixFlag = flagString(parsed.flags, "--prefix");
723
905
  let prefix = prefixFlag ?? (defaults.prefix ? `${defaults.prefix}/` : undefined);
@@ -750,6 +932,94 @@ export async function runList(ctx, args, help = false, run = execRunner) {
750
932
  }
751
933
  return 0;
752
934
  }
935
+ // --- find ---
936
+ const FIND_HELP = `uploads find k=v [k=v...] [--prefix <p>] [--limit <n>] [--workspace <name>]
937
+
938
+ Human-friendly alias for \`uploads list --meta k=v...\` — same metadata filter
939
+ (ANDed equality), same output; pairs are positional instead of repeated flags.
940
+
941
+ Examples:
942
+ uploads find gh.repo=buildinternet/uploads gh.number=123
943
+ uploads find app=myapp page=settings --prefix screenshots/
944
+ `;
945
+ export async function runFind(ctx, args, help = false) {
946
+ const parsed = parseCommandArgs(args);
947
+ if (help || parsed.help) {
948
+ process.stderr.write(FIND_HELP);
949
+ return 0;
950
+ }
951
+ if (parsed.positionals.length === 0) {
952
+ process.stderr.write(FIND_HELP);
953
+ return 2;
954
+ }
955
+ const filters = parseMetaFlags(parsed.positionals);
956
+ return runFindFiles(ctx, filters, parsed.flags);
957
+ }
958
+ // --- meta ---
959
+ const META_HELP = `uploads meta <command> [args]
960
+
961
+ Read/write an object's queryable custom metadata (D1-backed key-value pairs;
962
+ distinct from the R2 provenance headers put on upload).
963
+
964
+ Commands:
965
+ get <key> Show metadata for an object
966
+ set <key> k=v [k=v...] [--delete k]... Merge-set and/or delete pairs
967
+
968
+ Examples:
969
+ uploads meta get screenshots/myapp/42/shot.png
970
+ uploads meta set screenshots/myapp/42/shot.png app=myapp page=settings
971
+ uploads meta set screenshots/myapp/42/shot.png --delete app --delete page
972
+ `;
973
+ export async function runMeta(ctx, args, help = false) {
974
+ const parsed = parseCommandArgs(args);
975
+ const action = parsed.positionals[0];
976
+ if (help || parsed.help || !action) {
977
+ process.stderr.write(META_HELP);
978
+ return help || parsed.help ? 0 : 2;
979
+ }
980
+ switch (action) {
981
+ case "get": {
982
+ const key = parsed.positionals[1];
983
+ if (!key)
984
+ throw new UsageError("meta get requires an object key");
985
+ const result = await ctx.client.getMetadata(key);
986
+ if (ctx.json)
987
+ await writeJson(result);
988
+ else if (Object.keys(result.metadata).length === 0) {
989
+ // Empty stdout reads as failure; a stderr note keeps stdout parseable.
990
+ if (!ctx.quiet)
991
+ process.stderr.write("(no metadata)\n");
992
+ }
993
+ else
994
+ for (const [k, v] of Object.entries(result.metadata))
995
+ await writeStdout(`${k}=${v}\n`);
996
+ return 0;
997
+ }
998
+ case "set": {
999
+ const key = parsed.positionals[1];
1000
+ if (!key)
1001
+ throw new UsageError("meta set requires an object key");
1002
+ const pairs = parsed.positionals.slice(2);
1003
+ const del = flagValues(parsed.flags, "--delete");
1004
+ if (pairs.length === 0 && del.length === 0) {
1005
+ throw new UsageError("meta set requires k=v pairs and/or --delete <key>");
1006
+ }
1007
+ const set = pairs.length > 0 ? parseMetaFlags(pairs) : undefined;
1008
+ const result = await ctx.client.patchMetadata(key, {
1009
+ set,
1010
+ delete: del.length > 0 ? del : undefined,
1011
+ });
1012
+ if (ctx.json)
1013
+ await writeJson(result);
1014
+ else
1015
+ for (const [k, v] of Object.entries(result.metadata))
1016
+ await writeStdout(`${k}=${v}\n`);
1017
+ return 0;
1018
+ }
1019
+ default:
1020
+ throw new UsageError(`unknown meta command: ${action}`);
1021
+ }
1022
+ }
753
1023
  // --- delete ---
754
1024
  const DELETE_HELP = `uploads delete <key> [--dry-run] [--workspace <name>]
755
1025
 
@@ -1,5 +1,5 @@
1
1
  import type { UploadsClientConfig } from "./config.js";
2
- export declare const UPLOADS_CONFIG_KEYS: readonly ["UPLOADS_API_URL", "UPLOADS_WORKSPACE", "UPLOADS_TOKEN", "UPLOADS_DEFAULT_PREFIX", "UPLOADS_DEFAULT_REPO", "UPLOADS_DEFAULT_REF", "UPLOADS_DEFAULT_WIDTH", "UPLOADS_NO_GIT", "UPLOADS_NO_OPTIMIZE", "UPLOADS_KEEP_EXIF"];
2
+ export declare const UPLOADS_CONFIG_KEYS: readonly ["UPLOADS_API_URL", "UPLOADS_WORKSPACE", "UPLOADS_TOKEN", "UPLOADS_DEFAULT_PREFIX", "UPLOADS_DEFAULT_REPO", "UPLOADS_DEFAULT_REF", "UPLOADS_DEFAULT_WIDTH", "UPLOADS_NO_GIT", "UPLOADS_NO_OPTIMIZE", "UPLOADS_KEEP_EXIF", "UPLOADS_NO_AUTO_META"];
3
3
  export type UploadsConfigKey = (typeof UPLOADS_CONFIG_KEYS)[number];
4
4
  export type UploadsConfigValues = Partial<Record<UploadsConfigKey, string>>;
5
5
  export interface PutDefaults {
@@ -12,6 +12,8 @@ export interface PutDefaults {
12
12
  noOptimize?: boolean;
13
13
  /** When true, optimize keeps EXIF/XMP/ICC (default strips). */
14
14
  keepExif?: boolean;
15
+ /** When true, `put` does NOT auto-resolve/stamp gh.* on the default path. */
16
+ noAutoMeta?: boolean;
15
17
  }
16
18
  declare const PUT_DEFAULT_KEY_MAP: Record<keyof PutDefaults, UploadsConfigKey>;
17
19
  export declare function putDefaultsToConfigValues(defaults: PutDefaults): UploadsConfigValues;
@@ -12,6 +12,7 @@ export const UPLOADS_CONFIG_KEYS = [
12
12
  "UPLOADS_NO_GIT",
13
13
  "UPLOADS_NO_OPTIMIZE",
14
14
  "UPLOADS_KEEP_EXIF",
15
+ "UPLOADS_NO_AUTO_META",
15
16
  ];
16
17
  const PUT_DEFAULT_KEY_MAP = {
17
18
  prefix: "UPLOADS_DEFAULT_PREFIX",
@@ -21,6 +22,7 @@ const PUT_DEFAULT_KEY_MAP = {
21
22
  noGit: "UPLOADS_NO_GIT",
22
23
  noOptimize: "UPLOADS_NO_OPTIMIZE",
23
24
  keepExif: "UPLOADS_KEEP_EXIF",
25
+ noAutoMeta: "UPLOADS_NO_AUTO_META",
24
26
  };
25
27
  function isTruthyConfigFlag(value) {
26
28
  if (!value)
@@ -44,6 +46,8 @@ export function putDefaultsToConfigValues(defaults) {
44
46
  out.UPLOADS_NO_OPTIMIZE = "1";
45
47
  if (defaults.keepExif)
46
48
  out.UPLOADS_KEEP_EXIF = "1";
49
+ if (defaults.noAutoMeta)
50
+ out.UPLOADS_NO_AUTO_META = "1";
47
51
  return out;
48
52
  }
49
53
  function parsePutDefaultsFromRaw(raw) {
@@ -65,6 +69,8 @@ function parsePutDefaultsFromRaw(raw) {
65
69
  out.noOptimize = true;
66
70
  if (isTruthyConfigFlag(raw.UPLOADS_KEEP_EXIF))
67
71
  out.keepExif = true;
72
+ if (isTruthyConfigFlag(raw.UPLOADS_NO_AUTO_META))
73
+ out.noAutoMeta = true;
68
74
  return out;
69
75
  }
70
76
  function parsePutDefaultsFromEnv() {
@@ -83,6 +89,8 @@ function parsePutDefaultsFromEnv() {
83
89
  raw.UPLOADS_NO_OPTIMIZE = process.env.UPLOADS_NO_OPTIMIZE;
84
90
  if (process.env.UPLOADS_KEEP_EXIF)
85
91
  raw.UPLOADS_KEEP_EXIF = process.env.UPLOADS_KEEP_EXIF;
92
+ if (process.env.UPLOADS_NO_AUTO_META)
93
+ raw.UPLOADS_NO_AUTO_META = process.env.UPLOADS_NO_AUTO_META;
86
94
  return parsePutDefaultsFromRaw(raw);
87
95
  }
88
96
  /** XDG default shared across buildinternet skills (github-screenshots, uploads, …). */
@@ -152,6 +160,8 @@ export function mergePutDefaults(...layers) {
152
160
  out.noOptimize = layer.noOptimize;
153
161
  if (layer.keepExif != null)
154
162
  out.keepExif = layer.keepExif;
163
+ if (layer.noAutoMeta != null)
164
+ out.noAutoMeta = layer.noAutoMeta;
155
165
  }
156
166
  return out;
157
167
  }
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;
@@ -9,6 +9,13 @@ export declare const execRunner: CommandRunner;
9
9
  export declare function resolveRepo(explicit: string | undefined, run?: CommandRunner): string;
10
10
  /** Resolve the pull request associated with the current branch. */
11
11
  export declare function resolveCurrentPullRequest(repo: string, run?: CommandRunner): GhTarget;
12
+ /**
13
+ * Classify a bare PR/issue number via the GitHub API so the default `put`
14
+ * path can stamp the right `gh.kind`. Returns undefined on any failure (gh
15
+ * missing, 404, network) — the caller treats that as "no gh context" and
16
+ * uploads without metadata.
17
+ */
18
+ export declare function classifyGhNumber(repo: string, num: number, run?: CommandRunner): GhTarget | undefined;
12
19
  /**
13
20
  * Create the managed attachments comment, or edit it in place if it already
14
21
  * exists. Never touches any other comment. Body is passed via stdin
package/dist/github-gh.js CHANGED
@@ -61,6 +61,30 @@ export function resolveCurrentPullRequest(repo, run = execRunner) {
61
61
  }
62
62
  throw new UsageError("could not infer a pull request for the current branch — pass --pr <num> or --issue <num>");
63
63
  }
64
+ /**
65
+ * Classify a bare PR/issue number via the GitHub API so the default `put`
66
+ * path can stamp the right `gh.kind`. Returns undefined on any failure (gh
67
+ * missing, 404, network) — the caller treats that as "no gh context" and
68
+ * uploads without metadata.
69
+ */
70
+ export function classifyGhNumber(repo, num, run = execRunner) {
71
+ try {
72
+ const out = run("gh", [
73
+ "api",
74
+ `repos/${repo}/issues/${num}`,
75
+ "--jq",
76
+ 'if .pull_request then "pull" else "issue" end',
77
+ ]).trim();
78
+ if (out === "pull")
79
+ return { repo, kind: "pull", num };
80
+ if (out === "issue")
81
+ return { repo, kind: "issues", num };
82
+ }
83
+ catch {
84
+ // gh missing / not found / network — caller skips
85
+ }
86
+ return undefined;
87
+ }
64
88
  /**
65
89
  * PR comments live on the issues endpoint, so one path covers PRs and issues.
66
90
  * Only the first 100 comments are searched (accepted v1 limitation).