@buildinternet/uploads 0.19.0 → 0.21.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.
@@ -504,13 +583,15 @@ Options:
504
583
  Because attach always sends its own gh.* pairs, re-attaching to
505
584
  the same key always replaces that file's entire metadata set
506
585
  (never preserves) — use "uploads meta set" to add to it instead.
586
+ --state <s> before|after|empty|error|loading — the UI state shown (sets meta state=)
587
+ --app <name> Surface shown: web, ios, android, cli (sets meta app=)
507
588
 
508
589
  Examples:
509
590
  uploads attach ./before.png ./after.png
510
591
  uploads attach ./mobile.png --frame phone
511
592
  uploads attach ./shot.png --pr 123 --repo myorg/myapp
512
593
  uploads attach ./artifact.zip --issue 45 --no-comment
513
- uploads attach ./shot.png --meta app=myapp --meta page=settings
594
+ uploads attach ./shot.png --meta path=/settings --state after
514
595
  uploads attach ./shot.png --branch
515
596
  uploads attach ./shot.png --branch feature/new-settings
516
597
  uploads attach --promote
@@ -528,7 +609,13 @@ async function uploadAttachmentBatch(opts) {
528
609
  const slots = await mapBounded(opts.files, opts.concurrency ?? UPLOAD_BATCH_CONCURRENCY, async (file) => {
529
610
  try {
530
611
  const sourceName = basename(file);
531
- const prepared = await prepareImageForUpload(readFileArg(file), sourceName, {
612
+ const bytes = readFileArg(file);
613
+ // Same EXIF promotion uploadPreparedImage does; attach keeps its own
614
+ // per-file tail (it builds keys differently), so it opts in here too.
615
+ const metadata = opts.deriveImageFacts
616
+ ? await mergeImageFacts(bytes, opts.metadata)
617
+ : opts.metadata;
618
+ const prepared = await prepareImageForUpload(bytes, sourceName, {
532
619
  ...opts.frame,
533
620
  optimize: opts.optimize,
534
621
  });
@@ -543,7 +630,7 @@ async function uploadAttachmentBatch(opts) {
543
630
  frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
544
631
  keepExif: opts.optimize.keepExif === true,
545
632
  }),
546
- metadata: opts.metadata,
633
+ metadata,
547
634
  });
548
635
  return {
549
636
  ok: true,
@@ -643,7 +730,9 @@ export async function uploadPuts(opts) {
643
730
  deriveRepoFromGit: opts.deriveRepoFromGit,
644
731
  contentType: opts.contentType,
645
732
  dryRun: opts.dryRun,
733
+ replace: opts.replace,
646
734
  metadata: opts.metadata,
735
+ deriveImageFacts: opts.deriveImageFacts,
647
736
  provenanceClient: opts.provenanceClient,
648
737
  alt: () => opts.alt ?? basename(sourceName),
649
738
  width: opts.width,
@@ -761,8 +850,12 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
761
850
  // Validate the merged map (not just the extras) so the 24-key/8KB caps are
762
851
  // enforced client-side even when extras alone are under the cap but extras
763
852
  // + the gh.* pairs push the merged map over it.
764
- const metaExtras = parseMetaFlags(flagValues(parsed.flags, "--meta"));
765
- const metadata = { ...metaExtras, ...ghMetadataFromTargetWithTitle(target, run) };
853
+ const metaExtras = warnNearMissMeta(ctx, parseMetaFlags(flagValues(parsed.flags, "--meta")));
854
+ const metadata = {
855
+ ...metaExtras,
856
+ ...stateAppMetaFromFlags(parsed.flags),
857
+ ...ghMetadataFromTargetWithTitle(target, run),
858
+ };
766
859
  if (Object.keys(metadata).length > 0)
767
860
  validateMetaMap(metadata);
768
861
  const logHuman = !ctx.quiet && !ctx.json;
@@ -778,6 +871,7 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
778
871
  optimize: optimizeOpts,
779
872
  frame: frameOpts,
780
873
  metadata,
874
+ deriveImageFacts: derivedMetaEnabled(parsed.flags, defaults),
781
875
  });
782
876
  // Single-file total failure: rethrow so CLI exit codes stay auth/network-aware.
783
877
  if (uploads.length === 0 && failures.length === 1 && parsed.positionals.length === 1) {
@@ -868,8 +962,12 @@ async function runAttachBranch(ctx, parsed, branch, run) {
868
962
  const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, defaults);
869
963
  const frameOpts = frameOptionsFromFlags(parsed.flags);
870
964
  const contentTypeOverride = flagString(parsed.flags, "--content-type");
871
- const metaExtras = parseMetaFlags(flagValues(parsed.flags, "--meta"));
872
- const metadata = { ...metaExtras, ...ghMetadataForBranch(repo, branch) };
965
+ const metaExtras = warnNearMissMeta(ctx, parseMetaFlags(flagValues(parsed.flags, "--meta")));
966
+ const metadata = {
967
+ ...metaExtras,
968
+ ...stateAppMetaFromFlags(parsed.flags),
969
+ ...ghMetadataForBranch(repo, branch),
970
+ };
873
971
  validateMetaMap(metadata);
874
972
  const logHuman = !ctx.quiet && !ctx.json;
875
973
  if (logHuman) {
@@ -885,6 +983,7 @@ async function runAttachBranch(ctx, parsed, branch, run) {
885
983
  optimize: optimizeOpts,
886
984
  frame: frameOpts,
887
985
  metadata,
986
+ deriveImageFacts: derivedMetaEnabled(parsed.flags, defaults),
888
987
  });
889
988
  // Single-file total failure: rethrow so CLI exit codes stay auth/network-aware.
890
989
  if (uploads.length === 0 && failures.length === 1 && parsed.positionals.length === 1) {
@@ -987,10 +1086,20 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
987
1086
  const galleryId = flagString(parsed.flags, "--gallery");
988
1087
  const nameFlag = flagString(parsed.flags, "--name");
989
1088
  const dryRun = flagBool(parsed.flags, "--dry-run");
1089
+ // Strict-overwrite escape hatch (issue #174): only matters on non-gh/ keys
1090
+ // (--key or the default put path) — the server ignores `replace` on
1091
+ // managed gh/ paths (--pr/--issue), which always hot-swap regardless.
1092
+ const replaceFlag = flagBool(parsed.flags, "--replace") || process.env.UPLOADS_OVERWRITE === "1";
990
1093
  // Validate --meta up front (fail fast, before reading/optimizing the file).
991
1094
  const userMeta = (() => {
992
1095
  const pairs = flagValues(parsed.flags, "--meta");
993
- return pairs.length > 0 ? parseMetaFlags(pairs) : undefined;
1096
+ const fromMeta = warnNearMissMeta(ctx, pairs.length > 0 ? parseMetaFlags(pairs) : {});
1097
+ // Dedicated flags are explicit input and win over a same-named --meta pair.
1098
+ const merged = { ...fromMeta, ...stateAppMetaFromFlags(parsed.flags) };
1099
+ if (Object.keys(merged).length === 0)
1100
+ return undefined;
1101
+ validateMetaMap(merged);
1102
+ return merged;
994
1103
  })();
995
1104
  if (wantComment && typeof parsed.flags.get("--comment") === "string") {
996
1105
  throw new UsageError("--comment takes no value — place it after the file argument");
@@ -1084,10 +1193,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1084
1193
  attachedRef = merged["gh.ref"];
1085
1194
  }
1086
1195
  else {
1087
- const autoEnabled = !noGit &&
1088
- !flagBool(parsed.flags, "--no-auto") &&
1089
- (flagBool(parsed.flags, "--auto") || defaults.noAutoMeta !== true);
1090
- if (autoEnabled) {
1196
+ // gh.* additionally needs git, which the shared derived gate ignores.
1197
+ if (!noGit && derivedMetaEnabled(parsed.flags, defaults)) {
1091
1198
  const autoTarget = resolveAutoGhTarget(flagString(parsed.flags, "--repo") ?? defaults.repo, flagString(parsed.flags, "--ref") ?? defaults.ref, run);
1092
1199
  if (autoTarget) {
1093
1200
  const autoMeta = ghMetadataFromTargetWithTitle(autoTarget, run);
@@ -1129,9 +1236,11 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1129
1236
  deriveRepoFromGit: !noGit,
1130
1237
  contentType: contentTypeOverride,
1131
1238
  dryRun,
1239
+ replace: replaceFlag,
1132
1240
  optimize: optimizeOpts,
1133
1241
  frame: frameOpts,
1134
1242
  metadata,
1243
+ deriveImageFacts: derivedMetaEnabled(parsed.flags, defaults),
1135
1244
  alt: altFlag,
1136
1245
  width,
1137
1246
  });
@@ -1195,7 +1304,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1195
1304
  const note = formatOptimizeNote(result.optimize);
1196
1305
  if (note)
1197
1306
  process.stderr.write(`>> ${basename(result.file)}: ${note}\n`);
1198
- writeReplacedNote(result.replaced, false, dryRun);
1307
+ writeReplacedNote(result.replaced, false, dryRun, result.wouldRefuse);
1199
1308
  process.stderr.write(`>> key: ${result.key}${dryRun ? " (dry run — not uploaded)" : ""}\n`);
1200
1309
  }
1201
1310
  const gallery = galleriesByKey.get(result.key);
@@ -1227,7 +1336,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1227
1336
  const note = formatOptimizeNote(result.optimize);
1228
1337
  if (note)
1229
1338
  process.stderr.write(`>> ${note}\n`);
1230
- writeReplacedNote(result.replaced, ctx.quiet, dryRun);
1339
+ writeReplacedNote(result.replaced, ctx.quiet, dryRun, result.wouldRefuse);
1231
1340
  process.stderr.write(`>> key: ${result.key}${dryRun ? " (dry run — not uploaded)" : ""}\n\n`);
1232
1341
  }
1233
1342
  switch (format) {
@@ -1569,7 +1678,7 @@ Human-friendly alias for \`uploads list --meta k=v...\` — same metadata filter
1569
1678
 
1570
1679
  Examples:
1571
1680
  uploads find gh.repo=buildinternet/uploads gh.number=123
1572
- uploads find app=myapp page=settings --prefix screenshots/
1681
+ uploads find path=/settings state=after --prefix screenshots/
1573
1682
  `;
1574
1683
  export async function runFind(ctx, args, help = false) {
1575
1684
  const parsed = parseCommandArgs(args);
@@ -1596,8 +1705,8 @@ Commands:
1596
1705
 
1597
1706
  Examples:
1598
1707
  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
1708
+ uploads meta set screenshots/myapp/42/shot.png path=/settings state=after
1709
+ uploads meta set screenshots/myapp/42/shot.png --delete path --delete state
1601
1710
  `;
1602
1711
  export async function runMeta(ctx, args, help = false) {
1603
1712
  const parsed = parseCommandArgs(args);
@@ -1694,10 +1803,9 @@ App is installed on the repo; otherwise via your local gh auth. Finds its own
1694
1803
  prior comment via a hidden marker and edits it in place; never touches other
1695
1804
  comments or the description.
1696
1805
 
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\`.
1806
+ If this repo is bound to a different workspace, the bot post is declined and
1807
+ this command fails rather than silently falling back to gh — see
1808
+ \`uploads github link --status\`.
1701
1809
 
1702
1810
  Examples:
1703
1811
  uploads --env-file .env comment --pr 123
@@ -1822,7 +1930,19 @@ async function runGithubLink(ctx, repo, statusOnly) {
1822
1930
  return 0;
1823
1931
  }
1824
1932
  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`);
1933
+ // Cross-tenant authorization (issue #297): `reason: "not_authorized"`
1934
+ // means the repo is unbound but this workspace couldn't be verified as
1935
+ // entitled to claim it (no linked GitHub account, or that account lacks
1936
+ // push access) — distinct from the older "someone else already owns it"
1937
+ // case, which still reports `result.workspace`.
1938
+ if (result.reason === "not_authorized") {
1939
+ process.stderr.write(`note: ${repo} isn't linked to any workspace yet, and this workspace couldn't be ` +
1940
+ `verified as entitled to claim it. Link a GitHub account with push access to ` +
1941
+ `${repo}, or ask an operator to bind it explicitly.\n`);
1942
+ }
1943
+ else {
1944
+ 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`);
1945
+ }
1826
1946
  }
1827
1947
  await writeStdout(formatGithubLink(repo, result));
1828
1948
  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
  }
@@ -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>>;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Canonical metadata promoted from an image's own EXIF, read *before* the
3
+ * optimizer strips it from the bytes. `--keep-exif` is orthogonal: it governs
4
+ * whether the uploaded bytes retain EXIF, not whether we promote these keys.
5
+ *
6
+ * Promotion is allowlist-only. Anything not named here is discarded, and the
7
+ * denials (GPS, serials, personal names, free-form comments) are load-bearing:
8
+ * promoted values render on the public /f/ page.
9
+ *
10
+ * Design: .context/2026-07-21-upload-metadata-vocabulary-design.md
11
+ */
12
+ import exifReader from "exif-reader";
13
+ import sharp from "sharp";
14
+ import { dropUnsafeMetaValues } from "./metadata.js";
15
+ import { formatViewport } from "./metadata-vocab.js";
16
+ /** Below this, the image is a 1:1 photo rather than a scaled screen capture. */
17
+ const SCREEN_CAPTURE_MIN_DENSITY = 72;
18
+ function asString(value) {
19
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
20
+ }
21
+ /** EXIF's `YYYY:MM:DD HH:MM:SS` (or a parsed Date) → ISO 8601, zone-honest. */
22
+ function formatCaptured(raw, offset) {
23
+ let stamp;
24
+ if (raw instanceof Date && !Number.isNaN(raw.getTime())) {
25
+ // exif-reader builds this Date from the EXIF wall-clock digits as if UTC,
26
+ // so the ISO prefix reproduces those digits exactly.
27
+ stamp = raw.toISOString().slice(0, 19);
28
+ }
29
+ else {
30
+ const text = asString(raw);
31
+ const match = text && /^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}:\d{2}:\d{2})$/.exec(text);
32
+ if (match)
33
+ stamp = `${match[1]}-${match[2]}-${match[3]}T${match[4]}`;
34
+ }
35
+ if (!stamp)
36
+ return undefined;
37
+ // Only claim a zone when EXIF actually carried one. Never append a bare "Z".
38
+ const zone = offset && /^[+-]\d{2}:\d{2}$/.test(offset) ? offset : "";
39
+ return `${stamp}${zone}`;
40
+ }
41
+ /** Combine Make + Model without repeating the make (`Canon` + `Canon EOS R5`). */
42
+ function formatDevice(make, model) {
43
+ if (!model)
44
+ return make;
45
+ if (!make)
46
+ return model;
47
+ return model.toLowerCase().startsWith(make.toLowerCase()) ? model : `${make} ${model}`;
48
+ }
49
+ /**
50
+ * Map exif-reader's parsed tags onto canonical keys. Pure and total: junk
51
+ * input yields `{}`. Only `device`, `software` and `captured` are ever read —
52
+ * every other tag, including all of GPSInfo, is ignored by construction.
53
+ */
54
+ export function factsFromExifTags(tags) {
55
+ const facts = {};
56
+ if (!tags || typeof tags !== "object")
57
+ return facts;
58
+ const root = tags;
59
+ const image = (root.Image ?? {});
60
+ const photo = (root.Photo ?? {});
61
+ const device = formatDevice(asString(image.Make), asString(image.Model));
62
+ if (device)
63
+ facts.device = device;
64
+ const software = asString(image.Software);
65
+ if (software)
66
+ facts.software = software;
67
+ const captured = formatCaptured(photo.DateTimeOriginal, asString(photo.OffsetTimeOriginal));
68
+ if (captured)
69
+ facts.captured = captured;
70
+ // Derived values must satisfy the metadata contract or be dropped silently —
71
+ // same posture as the existing best-effort gh.title.
72
+ return dropUnsafeMetaValues(facts);
73
+ }
74
+ /**
75
+ * Read canonical facts from image bytes. Best-effort by contract: any failure
76
+ * (not an image, corrupt EXIF, unsupported format) yields `{}` and must never
77
+ * fail the upload.
78
+ */
79
+ export async function imageFactsFromBytes(bytes) {
80
+ if (bytes.byteLength === 0)
81
+ return {};
82
+ let meta;
83
+ try {
84
+ meta = await sharp(bytes, { failOn: "none" }).metadata();
85
+ }
86
+ catch {
87
+ return {};
88
+ }
89
+ if (!meta.format)
90
+ return {};
91
+ const facts = {};
92
+ // A density above 72dpi means a scaled screen capture: recover the logical
93
+ // size the user actually saw. Camera photos report 72 and are skipped.
94
+ const { width, height, density } = meta;
95
+ if (width && height && density && density > SCREEN_CAPTURE_MIN_DENSITY) {
96
+ const scale = density / SCREEN_CAPTURE_MIN_DENSITY;
97
+ facts.viewport = formatViewport(width / scale, height / scale, scale);
98
+ }
99
+ if (meta.exif) {
100
+ try {
101
+ Object.assign(facts, factsFromExifTags(exifReader(meta.exif)));
102
+ }
103
+ catch {
104
+ // Unparseable EXIF is not an error — keep whatever we already derived.
105
+ }
106
+ }
107
+ return facts;
108
+ }
@@ -1,6 +1,8 @@
1
1
  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
+ /** A boolean flag argument; missing/null reads as `false`. */
5
+ export declare function optBool(args: ToolArgs, name: string): boolean;
4
6
  export declare function optPosInt(args: ToolArgs, name: string): number | undefined;
5
7
  /** A JSON-object argument of string→string pairs (e.g. a `metadata` or `filters` param). */
6
8
  export declare function optStringRecord(args: ToolArgs, name: string): Record<string, string> | undefined;
@@ -11,7 +13,7 @@ export declare function optStringArray(args: ToolArgs, name: string): string[] |
11
13
  * `filters` params across the CLI/local MCP (put/attach/set_metadata/
12
14
  * find_files) and the remote MCP worker (set_metadata/find_files).
13
15
  */
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).";
16
+ 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. Canonical keys, which uploads.sh derives automatically where it can: url, path, env, theme, viewport, device, software, captured. Use `path` for the route (e.g. /settings) \u2014 that is the key `find_files` searches by, so spell it `path` and not route/page/screen. `gh.*` is reserved by convention for GitHub PR/issue attachment context (repo/kind/number/ref).";
15
17
  export declare const metadataProp: {
16
18
  type: string;
17
19
  additionalProperties: {
@@ -19,3 +21,24 @@ export declare const metadataProp: {
19
21
  };
20
22
  description: string;
21
23
  };
24
+ export declare const stateProp: {
25
+ type: string;
26
+ enum: ("before" | "after" | "empty" | "error" | "loading")[];
27
+ description: string;
28
+ };
29
+ export declare const appProp: {
30
+ type: string;
31
+ description: string;
32
+ };
33
+ /**
34
+ * Canonical `state`/`app` pairs from their dedicated tool params. The schema
35
+ * enum already constrains `state` for well-behaved clients; re-validate here
36
+ * because a schema is a hint, not an enforcement boundary.
37
+ */
38
+ export declare function canonicalMetaFromArgs(args: ToolArgs): Record<string, string>;
39
+ /**
40
+ * The `metadata` tool arg merged with the canonical `state`/`app` params.
41
+ * `undefined` (leave stored metadata untouched) is preserved only when the
42
+ * caller supplied none of the three.
43
+ */
44
+ export declare function metadataArgWithCanonical(args: ToolArgs): Record<string, string> | undefined;