@buildinternet/uploads 0.19.0 → 0.22.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
@@ -8,7 +8,9 @@ import { buildMarkdown } from "./embed.js";
8
8
  import { urlForGithubEmbed } from "./public-urls.js";
9
9
  import { UploadsError } from "./errors.js";
10
10
  import { writeJson, writeStdout } from "./io.js";
11
+ import { imageFactsFromBytes } from "./image-facts.js";
11
12
  import { parseMetaFlags, validateMetaMap } from "./metadata.js";
13
+ import { mergeDerivedMeta, nearMissMetaWarnings, validateStateValue } from "./metadata-vocab.js";
12
14
  import { ghAttachmentKey, ghBranchAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, normalizeGithubCoordinate, } from "./github.js";
13
15
  import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, classifyGhNumber, execRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, } from "./github-gh.js";
14
16
  import { resolvePutPrefix } from "./destinations.js";
@@ -57,10 +59,15 @@ Uploads are public. --pr/--issue keys include the repo, number, and filename and
57
59
  remain public even for private/internal GitHub repositories. Upload only media
58
60
  that is safe at a predictable public URL.
59
61
 
60
- Re-uploading the same key overwrites in place (no prompt) so embeds hot-swap;
61
- human mode prints ">> replaced existing object (same URL)" after a real put,
62
- or ">> would replace existing object (same URL)" on --dry-run when the key
63
- already exists.
62
+ Overwrite semantics depend on the key (issue #174): --pr/--issue always
63
+ hot-swap in place (no prompt) so embeds stay stable human mode prints
64
+ ">> replaced existing object (same URL)" after a real put, or ">> would
65
+ replace existing object (same URL)" on --dry-run. Every other key (--key, or
66
+ the default put path) is strict: re-uploading to an existing key REFUSES with
67
+ a "key_exists" error (JSON includes the existing object's url) unless you
68
+ pass --replace, or set UPLOADS_OVERWRITE=1 to restore old always-overwrite
69
+ behavior for those paths. --dry-run reports ">> would refuse: key already
70
+ exists" instead of writing.
64
71
 
65
72
  Human/json output includes durable url and (when dual-host applies) embedUrl.
66
73
  MARKDOWN prefers embedUrl for GitHub. Override: UPLOADS_EMBED_PUBLIC_BASE_URL.
@@ -97,7 +104,12 @@ Options:
97
104
  Re-uploading to an existing key WITH --meta replaces that file's
98
105
  entire metadata set; without --meta the existing metadata is
99
106
  preserved. Use "uploads meta set" to edit individual keys.
100
- --dry-run Print key + public URL without uploading; reports if the key would replace an existing object. Not with --comment/--gallery
107
+ --state <s> before|after|empty|error|loading the UI state shown (sets meta state=)
108
+ --app <name> Surface shown: web, ios, android, cli (sets meta app=)
109
+ --replace Allow overwriting an existing object on a strict (--key/default) key
110
+ (or UPLOADS_OVERWRITE=1). No effect on --pr/--issue, which always overwrite.
111
+ --dry-run Print key + public URL without uploading; reports if the key would replace
112
+ (or, on a strict key, be refused). Not with --comment/--gallery
101
113
 
102
114
  Exit codes: 0 ok · 2 usage/token/file · 3 auth/policy · 4 network · 1 other (incl. partial multi-file failure).
103
115
  Scripted formats (json|url|markdown) also print failures on stdout.
@@ -111,7 +123,7 @@ Examples:
111
123
  uploads put ./capture-….webp --pr 128 --name hero.webp
112
124
  uploads put ./shot.png --pr 128 --name hero.webp --dry-run --format url
113
125
  uploads put ./after.png --gallery gal_example
114
- uploads put ./shot.png --meta app=myapp --meta page=settings
126
+ uploads put ./shot.png --meta path=/settings --state after --app web
115
127
  `;
116
128
  /**
117
129
  * Turns a pr/issue pair (+ optional repo) into a GhTarget; undefined when
@@ -233,6 +245,50 @@ export function optimizeOptionsFromFlags(flags, defaults) {
233
245
  keepExif: flagBool(flags, "--keep-exif") || defaults.keepExif === true,
234
246
  };
235
247
  }
248
+ /**
249
+ * Whether the derived-metadata tier is on — screenshot capture facts and EXIF
250
+ * promotion. `--no-auto` and `UPLOADS_NO_AUTO_META=1` turn it off; `--auto`
251
+ * forces past the config default.
252
+ *
253
+ * Deliberately *not* gated on `--no-git`. That flag means "don't shell out to
254
+ * git", which says nothing about a viewport or a URL path — a capture of a
255
+ * local .html file outside any repo should still record what it captured.
256
+ * `--no-git` still disables gh.* below, which genuinely needs a repo.
257
+ */
258
+ export function derivedMetaEnabled(flags, defaults) {
259
+ return (!flagBool(flags, "--no-auto") && (flagBool(flags, "--auto") || defaults.noAutoMeta !== true));
260
+ }
261
+ /**
262
+ * Warn about metadata keys that look like misspellings of canonical ones, then
263
+ * return the map unchanged — we nag, we never rewrite a caller's key.
264
+ */
265
+ export function warnNearMissMeta(ctx, meta) {
266
+ if (!ctx.quiet) {
267
+ for (const warning of nearMissMetaWarnings(Object.keys(meta))) {
268
+ process.stderr.write(`!! ${warning}\n`);
269
+ }
270
+ }
271
+ return meta;
272
+ }
273
+ /**
274
+ * Canonical `state`/`app` pairs from their dedicated flags. Shared by put,
275
+ * attach and screenshot. These are sugar for the matching `--meta` keys; the
276
+ * point is `--help` discoverability and `--state` validation.
277
+ */
278
+ export function stateAppMetaFromFlags(flags) {
279
+ const meta = {};
280
+ const state = flagString(flags, "--state");
281
+ if (state !== undefined)
282
+ meta.state = validateStateValue(state);
283
+ const app = flagString(flags, "--app");
284
+ if (app !== undefined) {
285
+ const normalized = app.trim().toLowerCase();
286
+ if (normalized.length === 0)
287
+ throw new UsageError("--app requires a value");
288
+ meta.app = normalized;
289
+ }
290
+ return meta;
291
+ }
236
292
  function formatOptimizeNote(opt) {
237
293
  if (opt.optimized) {
238
294
  return `optimized ${formatByteSize(opt.originalBytes)} → ${formatByteSize(opt.outputBytes)} (${opt.filename})`;
@@ -242,8 +298,14 @@ function formatOptimizeNote(opt) {
242
298
  }
243
299
  return undefined;
244
300
  }
245
- function writeReplacedNote(replaced, quiet, dryRun = false) {
246
- if (!quiet && replaced) {
301
+ function writeReplacedNote(replaced, quiet, dryRun = false, wouldRefuse = false) {
302
+ if (quiet)
303
+ return;
304
+ if (dryRun && wouldRefuse) {
305
+ process.stderr.write(`>> would refuse: key already exists (pass --replace to overwrite; or set UPLOADS_OVERWRITE=1)\n`);
306
+ return;
307
+ }
308
+ if (replaced) {
247
309
  process.stderr.write(dryRun
248
310
  ? `>> would replace existing object (same URL)\n`
249
311
  : `>> replaced existing object (same URL)\n`);
@@ -273,6 +335,19 @@ export async function prepareImageForUpload(bytes, filename, opts) {
273
335
  const optimized = await optimizeImageForUpload(currentBytes, currentName, opts.optimize);
274
336
  return { ...optimized, frame: frameMeta };
275
337
  }
338
+ /**
339
+ * Merge an image's own EXIF-derived facts under any explicit metadata.
340
+ * Best-effort by contract: `imageFactsFromBytes` never rejects, and a full key
341
+ * budget drops the derived pairs rather than failing the upload. Returns the
342
+ * input untouched (including `undefined`) when there is nothing to add, so a
343
+ * metadata-free upload stays metadata-free.
344
+ */
345
+ async function mergeImageFacts(bytes, metadata) {
346
+ const facts = await imageFactsFromBytes(bytes);
347
+ if (Object.keys(facts).length === 0)
348
+ return metadata;
349
+ return mergeDerivedMeta(metadata ?? {}, facts);
350
+ }
276
351
  /**
277
352
  * Shared bytes-oriented upload tail: frame + optimize the bytes, resolve the
278
353
  * object key (gh attachment key wins over an explicit key; extension
@@ -283,6 +358,10 @@ export async function prepareImageForUpload(bytes, filename, opts) {
283
358
  * concurrency and delegate here per item.
284
359
  */
285
360
  export async function uploadPreparedImage(client, bytes, sourceName, opts) {
361
+ // Read EXIF from the original bytes before the optimizer strips it.
362
+ const metadata = opts.deriveImageFacts
363
+ ? await mergeImageFacts(bytes, opts.metadata)
364
+ : opts.metadata;
286
365
  const prepared = await prepareImageForUpload(bytes, sourceName, {
287
366
  frameId: opts.frame.frameId,
288
367
  frameUrl: opts.frame.frameUrl,
@@ -301,6 +380,7 @@ export async function uploadPreparedImage(client, bytes, sourceName, opts) {
301
380
  contentType: prepared.optimized ? prepared.contentType : opts.contentType,
302
381
  deriveRepoFromGit: opts.deriveRepoFromGit,
303
382
  dryRun: opts.dryRun,
383
+ replace: opts.replace,
304
384
  provenance: buildCliProvenance({
305
385
  sourceName,
306
386
  client: opts.provenanceClient,
@@ -308,7 +388,7 @@ export async function uploadPreparedImage(client, bytes, sourceName, opts) {
308
388
  frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
309
389
  keepExif: opts.optimize.keepExif === true,
310
390
  }),
311
- metadata: opts.metadata,
391
+ metadata,
312
392
  });
313
393
  const markdown = buildMarkdown(urlForGithubEmbed(result.url, result.embedUrl), {
314
394
  alt: opts.alt(prepared),
@@ -347,8 +427,7 @@ export function commentViaSuffix(via) {
347
427
  /**
348
428
  * Thrown by `syncAttachmentsComment` when the server declines with
349
429
  * `not_authorized` (issue #297 baseline control) — this repo is bound to a
350
- * different workspace, or unbound and unclaimable by the communal `default`
351
- * workspace. Deliberately not caught by the generic "bot endpoint
430
+ * different workspace. Deliberately not caught by the generic "bot endpoint
352
431
  * unreachable" fallback below: falling back to gh here would let the
353
432
  * human's own credentials post anyway, defeating the point of the
354
433
  * server-side gate.
@@ -385,12 +464,27 @@ export async function syncAttachmentsComment(client, target, run, workspace) {
385
464
  }
386
465
  }
387
466
  // gh fallback: gather from this workspace's own data and post via local `gh`.
388
- // Note (issue #304): this CLI process has no server-side WorkspaceRecord in
389
- // scope, so it cannot honor a workspace's githubCommentLinkToFilePage=false
390
- // it always links to the file page here, matching the default. This only
391
- // diverges from the bot-posted comment for a workspace that both sets the
392
- // flag false and falls through to this gh-fallback path.
393
- const items = (await client.listAll({ prefix: ghKeyPrefix(target) })).map(({ key, url, embedUrl, pageUrl }) => ({ key, url, embedUrl, pageUrl }));
467
+ // Note (issues #304, #365): this CLI process has no server-side
468
+ // WorkspaceRecord in scope, so it cannot honor a workspace's
469
+ // githubCommentLinkToFilePage=false or githubCommentShowMetadata=false it
470
+ // always links to the file page and always shows metadata here, matching the
471
+ // defaults. This only diverges from the bot-posted comment for a workspace
472
+ // that both sets one of those flags false and falls through to this path.
473
+ const items = (await client.listAll({ prefix: ghKeyPrefix(target), metadata: true })).map(({ key, url, embedUrl, pageUrl, metadata }) => {
474
+ // The list endpoint returns every metadata key; the comment renders only
475
+ // these two. Narrowing here keeps both render paths byte-identical.
476
+ const path = metadata?.path;
477
+ const state = metadata?.state;
478
+ return {
479
+ key,
480
+ url,
481
+ embedUrl,
482
+ pageUrl,
483
+ ...(path || state
484
+ ? { meta: { ...(path ? { path } : {}), ...(state ? { state } : {}) } }
485
+ : {}),
486
+ };
487
+ });
394
488
  const galleries = [];
395
489
  let cursor;
396
490
  do {
@@ -504,13 +598,15 @@ Options:
504
598
  Because attach always sends its own gh.* pairs, re-attaching to
505
599
  the same key always replaces that file's entire metadata set
506
600
  (never preserves) — use "uploads meta set" to add to it instead.
601
+ --state <s> before|after|empty|error|loading — the UI state shown (sets meta state=)
602
+ --app <name> Surface shown: web, ios, android, cli (sets meta app=)
507
603
 
508
604
  Examples:
509
605
  uploads attach ./before.png ./after.png
510
606
  uploads attach ./mobile.png --frame phone
511
607
  uploads attach ./shot.png --pr 123 --repo myorg/myapp
512
608
  uploads attach ./artifact.zip --issue 45 --no-comment
513
- uploads attach ./shot.png --meta app=myapp --meta page=settings
609
+ uploads attach ./shot.png --meta path=/settings --state after
514
610
  uploads attach ./shot.png --branch
515
611
  uploads attach ./shot.png --branch feature/new-settings
516
612
  uploads attach --promote
@@ -528,7 +624,13 @@ async function uploadAttachmentBatch(opts) {
528
624
  const slots = await mapBounded(opts.files, opts.concurrency ?? UPLOAD_BATCH_CONCURRENCY, async (file) => {
529
625
  try {
530
626
  const sourceName = basename(file);
531
- const prepared = await prepareImageForUpload(readFileArg(file), sourceName, {
627
+ const bytes = readFileArg(file);
628
+ // Same EXIF promotion uploadPreparedImage does; attach keeps its own
629
+ // per-file tail (it builds keys differently), so it opts in here too.
630
+ const metadata = opts.deriveImageFacts
631
+ ? await mergeImageFacts(bytes, opts.metadata)
632
+ : opts.metadata;
633
+ const prepared = await prepareImageForUpload(bytes, sourceName, {
532
634
  ...opts.frame,
533
635
  optimize: opts.optimize,
534
636
  });
@@ -543,7 +645,7 @@ async function uploadAttachmentBatch(opts) {
543
645
  frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
544
646
  keepExif: opts.optimize.keepExif === true,
545
647
  }),
546
- metadata: opts.metadata,
648
+ metadata,
547
649
  });
548
650
  return {
549
651
  ok: true,
@@ -643,7 +745,9 @@ export async function uploadPuts(opts) {
643
745
  deriveRepoFromGit: opts.deriveRepoFromGit,
644
746
  contentType: opts.contentType,
645
747
  dryRun: opts.dryRun,
748
+ replace: opts.replace,
646
749
  metadata: opts.metadata,
750
+ deriveImageFacts: opts.deriveImageFacts,
647
751
  provenanceClient: opts.provenanceClient,
648
752
  alt: () => opts.alt ?? basename(sourceName),
649
753
  width: opts.width,
@@ -761,8 +865,12 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
761
865
  // Validate the merged map (not just the extras) so the 24-key/8KB caps are
762
866
  // enforced client-side even when extras alone are under the cap but extras
763
867
  // + the gh.* pairs push the merged map over it.
764
- const metaExtras = parseMetaFlags(flagValues(parsed.flags, "--meta"));
765
- const metadata = { ...metaExtras, ...ghMetadataFromTargetWithTitle(target, run) };
868
+ const metaExtras = warnNearMissMeta(ctx, parseMetaFlags(flagValues(parsed.flags, "--meta")));
869
+ const metadata = {
870
+ ...metaExtras,
871
+ ...stateAppMetaFromFlags(parsed.flags),
872
+ ...ghMetadataFromTargetWithTitle(target, run),
873
+ };
766
874
  if (Object.keys(metadata).length > 0)
767
875
  validateMetaMap(metadata);
768
876
  const logHuman = !ctx.quiet && !ctx.json;
@@ -778,6 +886,7 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
778
886
  optimize: optimizeOpts,
779
887
  frame: frameOpts,
780
888
  metadata,
889
+ deriveImageFacts: derivedMetaEnabled(parsed.flags, defaults),
781
890
  });
782
891
  // Single-file total failure: rethrow so CLI exit codes stay auth/network-aware.
783
892
  if (uploads.length === 0 && failures.length === 1 && parsed.positionals.length === 1) {
@@ -868,8 +977,12 @@ async function runAttachBranch(ctx, parsed, branch, run) {
868
977
  const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, defaults);
869
978
  const frameOpts = frameOptionsFromFlags(parsed.flags);
870
979
  const contentTypeOverride = flagString(parsed.flags, "--content-type");
871
- const metaExtras = parseMetaFlags(flagValues(parsed.flags, "--meta"));
872
- const metadata = { ...metaExtras, ...ghMetadataForBranch(repo, branch) };
980
+ const metaExtras = warnNearMissMeta(ctx, parseMetaFlags(flagValues(parsed.flags, "--meta")));
981
+ const metadata = {
982
+ ...metaExtras,
983
+ ...stateAppMetaFromFlags(parsed.flags),
984
+ ...ghMetadataForBranch(repo, branch),
985
+ };
873
986
  validateMetaMap(metadata);
874
987
  const logHuman = !ctx.quiet && !ctx.json;
875
988
  if (logHuman) {
@@ -885,6 +998,7 @@ async function runAttachBranch(ctx, parsed, branch, run) {
885
998
  optimize: optimizeOpts,
886
999
  frame: frameOpts,
887
1000
  metadata,
1001
+ deriveImageFacts: derivedMetaEnabled(parsed.flags, defaults),
888
1002
  });
889
1003
  // Single-file total failure: rethrow so CLI exit codes stay auth/network-aware.
890
1004
  if (uploads.length === 0 && failures.length === 1 && parsed.positionals.length === 1) {
@@ -987,10 +1101,20 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
987
1101
  const galleryId = flagString(parsed.flags, "--gallery");
988
1102
  const nameFlag = flagString(parsed.flags, "--name");
989
1103
  const dryRun = flagBool(parsed.flags, "--dry-run");
1104
+ // Strict-overwrite escape hatch (issue #174): only matters on non-gh/ keys
1105
+ // (--key or the default put path) — the server ignores `replace` on
1106
+ // managed gh/ paths (--pr/--issue), which always hot-swap regardless.
1107
+ const replaceFlag = flagBool(parsed.flags, "--replace") || process.env.UPLOADS_OVERWRITE === "1";
990
1108
  // Validate --meta up front (fail fast, before reading/optimizing the file).
991
1109
  const userMeta = (() => {
992
1110
  const pairs = flagValues(parsed.flags, "--meta");
993
- return pairs.length > 0 ? parseMetaFlags(pairs) : undefined;
1111
+ const fromMeta = warnNearMissMeta(ctx, pairs.length > 0 ? parseMetaFlags(pairs) : {});
1112
+ // Dedicated flags are explicit input and win over a same-named --meta pair.
1113
+ const merged = { ...fromMeta, ...stateAppMetaFromFlags(parsed.flags) };
1114
+ if (Object.keys(merged).length === 0)
1115
+ return undefined;
1116
+ validateMetaMap(merged);
1117
+ return merged;
994
1118
  })();
995
1119
  if (wantComment && typeof parsed.flags.get("--comment") === "string") {
996
1120
  throw new UsageError("--comment takes no value — place it after the file argument");
@@ -1084,10 +1208,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1084
1208
  attachedRef = merged["gh.ref"];
1085
1209
  }
1086
1210
  else {
1087
- const autoEnabled = !noGit &&
1088
- !flagBool(parsed.flags, "--no-auto") &&
1089
- (flagBool(parsed.flags, "--auto") || defaults.noAutoMeta !== true);
1090
- if (autoEnabled) {
1211
+ // gh.* additionally needs git, which the shared derived gate ignores.
1212
+ if (!noGit && derivedMetaEnabled(parsed.flags, defaults)) {
1091
1213
  const autoTarget = resolveAutoGhTarget(flagString(parsed.flags, "--repo") ?? defaults.repo, flagString(parsed.flags, "--ref") ?? defaults.ref, run);
1092
1214
  if (autoTarget) {
1093
1215
  const autoMeta = ghMetadataFromTargetWithTitle(autoTarget, run);
@@ -1129,9 +1251,11 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1129
1251
  deriveRepoFromGit: !noGit,
1130
1252
  contentType: contentTypeOverride,
1131
1253
  dryRun,
1254
+ replace: replaceFlag,
1132
1255
  optimize: optimizeOpts,
1133
1256
  frame: frameOpts,
1134
1257
  metadata,
1258
+ deriveImageFacts: derivedMetaEnabled(parsed.flags, defaults),
1135
1259
  alt: altFlag,
1136
1260
  width,
1137
1261
  });
@@ -1195,7 +1319,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1195
1319
  const note = formatOptimizeNote(result.optimize);
1196
1320
  if (note)
1197
1321
  process.stderr.write(`>> ${basename(result.file)}: ${note}\n`);
1198
- writeReplacedNote(result.replaced, false, dryRun);
1322
+ writeReplacedNote(result.replaced, false, dryRun, result.wouldRefuse);
1199
1323
  process.stderr.write(`>> key: ${result.key}${dryRun ? " (dry run — not uploaded)" : ""}\n`);
1200
1324
  }
1201
1325
  const gallery = galleriesByKey.get(result.key);
@@ -1227,7 +1351,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1227
1351
  const note = formatOptimizeNote(result.optimize);
1228
1352
  if (note)
1229
1353
  process.stderr.write(`>> ${note}\n`);
1230
- writeReplacedNote(result.replaced, ctx.quiet, dryRun);
1354
+ writeReplacedNote(result.replaced, ctx.quiet, dryRun, result.wouldRefuse);
1231
1355
  process.stderr.write(`>> key: ${result.key}${dryRun ? " (dry run — not uploaded)" : ""}\n\n`);
1232
1356
  }
1233
1357
  switch (format) {
@@ -1569,7 +1693,7 @@ Human-friendly alias for \`uploads list --meta k=v...\` — same metadata filter
1569
1693
 
1570
1694
  Examples:
1571
1695
  uploads find gh.repo=buildinternet/uploads gh.number=123
1572
- uploads find app=myapp page=settings --prefix screenshots/
1696
+ uploads find path=/settings state=after --prefix screenshots/
1573
1697
  `;
1574
1698
  export async function runFind(ctx, args, help = false) {
1575
1699
  const parsed = parseCommandArgs(args);
@@ -1596,8 +1720,8 @@ Commands:
1596
1720
 
1597
1721
  Examples:
1598
1722
  uploads meta get screenshots/myapp/42/shot.png
1599
- uploads meta set screenshots/myapp/42/shot.png app=myapp page=settings
1600
- uploads meta set screenshots/myapp/42/shot.png --delete app --delete page
1723
+ uploads meta set screenshots/myapp/42/shot.png path=/settings state=after
1724
+ uploads meta set screenshots/myapp/42/shot.png --delete path --delete state
1601
1725
  `;
1602
1726
  export async function runMeta(ctx, args, help = false) {
1603
1727
  const parsed = parseCommandArgs(args);
@@ -1694,10 +1818,9 @@ App is installed on the repo; otherwise via your local gh auth. Finds its own
1694
1818
  prior comment via a hidden marker and edits it in place; never touches other
1695
1819
  comments or the description.
1696
1820
 
1697
- If this repo is bound to a different workspace (or unbound and you're on the
1698
- communal "default" workspace), the bot post is declined and this command
1699
- fails rather than silently falling back to gh — see \`uploads github link
1700
- --status\`.
1821
+ If this repo is bound to a different workspace, the bot post is declined and
1822
+ this command fails rather than silently falling back to gh — see
1823
+ \`uploads github link --status\`.
1701
1824
 
1702
1825
  Examples:
1703
1826
  uploads --env-file .env comment --pr 123
@@ -1822,7 +1945,19 @@ async function runGithubLink(ctx, repo, statusOnly) {
1822
1945
  return 0;
1823
1946
  }
1824
1947
  if (!statusOnly && result.claimed === false) {
1825
- process.stderr.write(`note: ${repo} is already bound to a different workspace ("${result.workspace}") first-claim-wins, not overwritten. Run "uploads github unlink --repo ${repo}" from that workspace, or ask an operator to reassign it.\n`);
1948
+ // Cross-tenant authorization (issue #297): `reason: "not_authorized"`
1949
+ // means the repo is unbound but this workspace couldn't be verified as
1950
+ // entitled to claim it (no linked GitHub account, or that account lacks
1951
+ // push access) — distinct from the older "someone else already owns it"
1952
+ // case, which still reports `result.workspace`.
1953
+ if (result.reason === "not_authorized") {
1954
+ process.stderr.write(`note: ${repo} isn't linked to any workspace yet, and this workspace couldn't be ` +
1955
+ `verified as entitled to claim it. Link a GitHub account with push access to ` +
1956
+ `${repo}, or ask an operator to bind it explicitly.\n`);
1957
+ }
1958
+ else {
1959
+ process.stderr.write(`note: ${repo} is already bound to a different workspace ("${result.workspace}") — first-claim-wins, not overwritten. Run "uploads github unlink --repo ${repo}" from that workspace, or ask an operator to reassign it.\n`);
1960
+ }
1826
1961
  }
1827
1962
  await writeStdout(formatGithubLink(repo, result));
1828
1963
  return 0;
package/dist/errors.d.ts CHANGED
@@ -1,6 +1,14 @@
1
- export type UploadsErrorCode = "MISSING_TOKEN" | "NO_PUBLIC_URL" | "FILE_NOT_FOUND" | "NOT_FOUND" | "UNAUTHORIZED" | "INSUFFICIENT_SCOPE" | "INVALID_KEY" | "KEY_POLICY" | "STORAGE_QUOTA" | "UPLOAD_BUDGET" | "GITHUB_REQUIRED" | "API_ERROR" | "NETWORK" | "USAGE" | "BROWSER_NOT_FOUND" | "RENDER_FAILED" | "RATE_LIMITED";
1
+ export type UploadsErrorCode = "MISSING_TOKEN" | "NO_PUBLIC_URL" | "FILE_NOT_FOUND" | "NOT_FOUND" | "UNAUTHORIZED" | "INSUFFICIENT_SCOPE" | "INVALID_KEY" | "KEY_POLICY" | "STORAGE_QUOTA" | "UPLOAD_BUDGET" | "GITHUB_REQUIRED" | "KEY_EXISTS" | "API_ERROR" | "NETWORK" | "USAGE" | "BROWSER_NOT_FOUND" | "RENDER_FAILED" | "RATE_LIMITED";
2
2
  export declare class UploadsError extends Error {
3
3
  readonly code: UploadsErrorCode;
4
4
  readonly status?: number;
5
- constructor(message: string, code: UploadsErrorCode, status?: number);
5
+ /**
6
+ * The existing object's public URL, set only for `KEY_EXISTS` (strict
7
+ * overwrite refusal, issue #174) — lets a catch site point the caller at
8
+ * what's already there without a follow-up lookup.
9
+ */
10
+ readonly existingUrl?: string;
11
+ constructor(message: string, code: UploadsErrorCode, status?: number, opts?: {
12
+ existingUrl?: string;
13
+ });
6
14
  }
package/dist/errors.js CHANGED
@@ -1,10 +1,17 @@
1
1
  export class UploadsError extends Error {
2
2
  code;
3
3
  status;
4
- constructor(message, code, status) {
4
+ /**
5
+ * The existing object's public URL, set only for `KEY_EXISTS` (strict
6
+ * overwrite refusal, issue #174) — lets a catch site point the caller at
7
+ * what's already there without a follow-up lookup.
8
+ */
9
+ existingUrl;
10
+ constructor(message, code, status, opts) {
5
11
  super(message);
6
12
  this.name = "UploadsError";
7
13
  this.code = code;
8
14
  this.status = status;
15
+ this.existingUrl = opts?.existingUrl;
9
16
  }
10
17
  }
package/dist/github.d.ts CHANGED
@@ -75,6 +75,17 @@ export interface AttachmentItem {
75
75
  embedUrl?: string | null;
76
76
  /** Canonical `/f/` file-page URL (server-computed). Preferred click-through target; falls back to `url`. */
77
77
  pageUrl?: string | null;
78
+ /**
79
+ * The only canonical metadata the managed comment renders (issue #365).
80
+ * Deliberately two named fields rather than `Record<string, string>`: the
81
+ * comment is posted publicly, and keeping the set narrow at the type level
82
+ * mirrors the server-side query filter that never fetches EXIF-derived
83
+ * keys like `device`/`software` for this path.
84
+ */
85
+ meta?: {
86
+ path?: string;
87
+ state?: string;
88
+ };
78
89
  }
79
90
  /** A public gallery linked to the PR or issue whose managed comment is syncing. */
80
91
  export interface GalleryCommentItem {
package/dist/github.js CHANGED
@@ -177,6 +177,51 @@ function escapeHtmlAttr(s) {
177
177
  function escapeHtmlText(s) {
178
178
  return escapeHtmlAttr(s).replace(/'/g, "&#39;").replace(/>/g, "&gt;");
179
179
  }
180
+ /**
181
+ * Backslash-escape the markdown metacharacters that can appear in a metadata
182
+ * value. `~` is in the set because GitHub's strikethrough extension treats a
183
+ * matching pair of ONE or two tildes as markup, so an unescaped `/a~b~c` would
184
+ * render with `b` struck through.
185
+ */
186
+ function escapeMarkdownText(s) {
187
+ return s.replace(/([\\`*_[\]~])/g, "\\$1");
188
+ }
189
+ /**
190
+ * An attachment's caption parts — `path`, then `state` (issue #365). Empty
191
+ * when neither is usable, so callers emit nothing at all and a body with no
192
+ * metadata stays byte-identical to the pre-#365 render.
193
+ *
194
+ * Neither value is pre-sanitized: metadata values are printable ASCII up to
195
+ * 512 chars, and while the CLI validates `--state` against a closed enum,
196
+ * `PATCH /v1/:workspace/files/:key` can set any valid metadata value. A
197
+ * whitespace-only value passes that validation (length-1 printable ASCII), so
198
+ * treat it as absent rather than rendering a dangling separator.
199
+ */
200
+ function metaCaptionParts(meta) {
201
+ const parts = [];
202
+ for (const value of [meta?.path, meta?.state]) {
203
+ const trimmed = value?.trim();
204
+ if (trimmed)
205
+ parts.push(trimmed);
206
+ }
207
+ return parts;
208
+ }
209
+ /** `<sub>` caption body for an inline image, or null when there is nothing to say. */
210
+ function metaCaptionHtml(meta) {
211
+ const parts = metaCaptionParts(meta);
212
+ return parts.length > 0 ? parts.map(escapeHtmlText).join(" · ") : null;
213
+ }
214
+ /**
215
+ * ` · …` suffix for a markdown list row, or `""` when there is nothing to add.
216
+ * HTML-escapes first, then markdown-escapes: HTML escaping introduces no
217
+ * backslashes or brackets, so the markdown pass cannot corrupt its entities.
218
+ */
219
+ function metaCaptionMarkdown(meta) {
220
+ const parts = metaCaptionParts(meta);
221
+ if (parts.length === 0)
222
+ return "";
223
+ return ` · ${parts.map((p) => escapeMarkdownText(escapeHtmlText(p))).join(" · ")}`;
224
+ }
180
225
  /**
181
226
  * Render the one marker-owned GitHub comment. When there are no galleries this
182
227
  * intentionally preserves the legacy attachment-only body byte-for-byte.
@@ -224,13 +269,16 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
224
269
  const href = escapeHtmlAttr(link ?? src);
225
270
  const imgSrc = escapeHtmlAttr(src);
226
271
  lines.push(`<a href="${href}"><img width="${w}" alt="${alt}" src="${imgSrc}"></a>`);
272
+ const caption = metaCaptionHtml(item.meta);
273
+ if (caption)
274
+ lines.push(`<sub>${caption}</sub>`);
227
275
  lines.push("");
228
276
  }
229
277
  else if (link) {
230
- lines.push(`- [${name}](${link})`);
278
+ lines.push(`- [${name}](${link})${metaCaptionMarkdown(item.meta)}`);
231
279
  }
232
280
  else {
233
- lines.push(`- ${name}`);
281
+ lines.push(`- ${name}${metaCaptionMarkdown(item.meta)}`);
234
282
  }
235
283
  }
236
284
  if (overflowImages.length > 0) {
@@ -239,7 +287,8 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
239
287
  for (const item of overflowImages) {
240
288
  const name = item.key.slice(item.key.lastIndexOf("/") + 1);
241
289
  const link = item.pageUrl ?? item.url;
242
- lines.push(link ? `- [${name}](${link})` : `- ${name}`);
290
+ const suffix = metaCaptionMarkdown(item.meta);
291
+ lines.push(link ? `- [${name}](${link})${suffix}` : `- ${name}${suffix}`);
243
292
  }
244
293
  lines.push("", "</details>", "");
245
294
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Map exif-reader's parsed tags onto canonical keys. Pure and total: junk
3
+ * input yields `{}`. Only `device`, `software` and `captured` are ever read —
4
+ * every other tag, including all of GPSInfo, is ignored by construction.
5
+ */
6
+ export declare function factsFromExifTags(tags: unknown): Record<string, string>;
7
+ /**
8
+ * Read canonical facts from image bytes. Best-effort by contract: any failure
9
+ * (not an image, corrupt EXIF, unsupported format) yields `{}` and must never
10
+ * fail the upload.
11
+ */
12
+ export declare function imageFactsFromBytes(bytes: Uint8Array): Promise<Record<string, string>>;