@buildinternet/uploads 0.26.1 → 0.27.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/README.md CHANGED
@@ -98,6 +98,11 @@ explicit deletes. Promotion (auto or `--promote`) does skip files staged
98
98
  more than 30 days before the PR opens, though; they're still there, just no
99
99
  longer auto-promoted.
100
100
 
101
+ **Metadata edits re-sync the comment too:** `uploads meta set` on a `gh/…`-keyed
102
+ object refreshes the managed comment automatically when it touches `path` or
103
+ `state` — best-effort, so backfilled metadata shows up without waiting on the
104
+ next `attach`.
105
+
101
106
  **Bare `put` stages too, by default (issue #403):** on a non-default git
102
107
  branch, a `put` with none of
103
108
  `--pr`/`--issue`/`--key`/`--ref`/`--prefix`/`--destination` set
@@ -60,6 +60,7 @@ export const SCREENSHOT_FLAGS = [
60
60
  "--light",
61
61
  "--wait",
62
62
  "--out",
63
+ "--no-sidecar",
63
64
  "--no-upload",
64
65
  "--destination",
65
66
  "--prefix",
@@ -2,15 +2,16 @@ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { basename } from "node:path";
3
3
  import { flagBool, flagInt, flagString, flagValues, parseCommandArgs, UsageError, } from "../cli-args.js";
4
4
  import { writeCommandHelp } from "../cli-style.js";
5
- import { branchFromFlags, derivedMetaEnabled, frameOptionsFromFlags, ghTargetFromFlags, optimizeOptionsFromFlags, stateAppMetaFromFlags, warnNearMissMeta, syncAttachmentsComment, commentViaSuffix, uploadPreparedImage, } from "../commands.js";
5
+ import { branchFromFlags, derivedMetaEnabled, frameOptionsFromFlags, ghTargetFromFlags, optimizeOptionsFromFlags, stateAppMetaFromFlags, warnNearMissMeta, syncAttachmentsComment, commentViaSuffix, uploadPreparedImage, resolvePutStagingTarget, putStagingNoteText, resolveStageBindingWarning, mergeStagingMeta, } from "../commands.js";
6
6
  import { resolvePutDefaults } from "../config.js";
7
7
  import { loadDefaultsRaw, resolveScreenshotDefaults } from "../config-file.js";
8
8
  import { resolvePutPrefix } from "../destinations.js";
9
9
  import { execRunner, ghMetadataFromTargetWithTitle, resolveRepo, } from "../github-gh.js";
10
- import { ghBranchAttachmentKey, ghMetadataForBranch } from "../github.js";
10
+ import { ghBranchAttachmentKey } from "../github.js";
11
11
  import { safeCaptureFacts } from "../capture-facts.js";
12
12
  import { parseMetaFlags, validateMetaMap } from "../metadata.js";
13
13
  import { mergeDerivedMeta } from "../metadata-vocab.js";
14
+ import { writeSidecarMeta } from "../sidecar.js";
14
15
  import { writeJson, writeStdout } from "../io.js";
15
16
  import { assertHideSelector, captureScreenshot, parseViewport, parseWaitUntil, } from "../screenshot.js";
16
17
  const SCREENSHOT_HELP = `uploads screenshot <target> [options]
@@ -33,6 +34,15 @@ fail fast with a clear error instead of sending a doomed request.
33
34
  After capture, screenshots share the put upload pipeline: optional --frame,
34
35
  optimize-by-default, --pr/--issue attachment + --comment, --gallery, --meta.
35
36
 
37
+ Branch staging by default (pre-PR): with no --pr/--issue/--branch/--key/--ref/
38
+ --prefix/--destination, a screenshot taken on a non-default git branch stages
39
+ under gh/<owner>/<repo>/branch/<branch>/<name> instead of the dated
40
+ screenshots/<repo>/<date>/... layout — same key/metadata as an explicit
41
+ --branch, carrying every derived fact (path/url/env/viewport, --state) along.
42
+ Staged files auto-attach with full metadata the first time you attach to that
43
+ branch's PR once it opens (or run "uploads attach --promote"). Use --no-git,
44
+ or an explicit --ref/--prefix, to opt back into the dated layout.
45
+
36
46
  Options:
37
47
  --via auto|local|remote Capture backend (default: auto, or UPLOADS_SCREENSHOT_VIA)
38
48
  --browser <path> Explicit local browser executable (or UPLOADS_CHROME_PATH / CHROME_PATH)
@@ -51,7 +61,12 @@ Options:
51
61
  on --via remote — neutralizes animations via injected CSS)
52
62
  --eval <js> Run JS in the page after settle, before capture (--via local only)
53
63
  --init-script <file> Inject a JS file before navigation (--via local only)
54
- --out <file> Also write the PNG to a local file
64
+ --out <file> Also write the PNG to a local file. Also writes a sidecar manifest,
65
+ <file>.uploads.json, recording this capture's derived metadata
66
+ (path/url/env/viewport, plus --state if given) with a content hash; a
67
+ later \`put\`/\`attach\` of this exact file picks the metadata back up
68
+ automatically (explicit --meta/--state still win). See --no-sidecar.
69
+ --no-sidecar Don't write the <file>.uploads.json sidecar alongside --out
55
70
  --no-upload Skip hosting; requires --out (local file only)
56
71
  --destination <id> Typed root: screenshots | gh | f
57
72
  --prefix <path> Key prefix (default: screenshots, or UPLOADS_DEFAULT_PREFIX)
@@ -171,6 +186,9 @@ captureImpl = captureScreenshot) {
171
186
  const noUpload = flagBool(parsed.flags, "--no-upload");
172
187
  if (noUpload && !outFile)
173
188
  throw new UsageError("--no-upload requires --out");
189
+ const noSidecar = flagBool(parsed.flags, "--no-sidecar");
190
+ if (noSidecar && !outFile)
191
+ throw new UsageError("--no-sidecar requires --out");
174
192
  const keyHint = flagString(parsed.flags, "--key");
175
193
  const destFlag = flagString(parsed.flags, "--destination");
176
194
  const prefixFlag = flagString(parsed.flags, "--prefix");
@@ -210,14 +228,38 @@ captureImpl = captureScreenshot) {
210
228
  if (noUpload)
211
229
  throw new UsageError("--dry-run cannot be combined with --no-upload");
212
230
  }
231
+ const putDefaults = resolvePutDefaults({ envFile: ctx.envFile }, rawDefaults);
232
+ const noGit = flagBool(parsed.flags, "--no-git") || putDefaults.noGit === true;
213
233
  const branchRepo = branchArg !== undefined ? resolveRepo(flagString(parsed.flags, "--repo"), run) : undefined;
234
+ // Auto branch staging (issue #469 lever 1): mirrors bare `put`'s auto-staging
235
+ // (issue #403). When no --branch/--pr/--issue/--key/--ref/--prefix/--destination
236
+ // is given and git use isn't disabled, a screenshot taken on a non-default
237
+ // git branch stages the same way explicit `--branch`/bare `put` do — same
238
+ // key shape, same gh.* metadata — instead of landing on the dated
239
+ // `screenshots/<repo>/<date>/...` layout. This is what lets derived
240
+ // metadata (path/url/env/viewport, --state) ride through to PR-open
241
+ // promotion when the capture happens before the PR exists. Skipped
242
+ // entirely when --branch was given explicitly (already handled above).
243
+ const autoStagingTarget = branchArg === undefined
244
+ ? resolvePutStagingTarget({
245
+ ghTarget,
246
+ keyHint,
247
+ refArg: flagString(parsed.flags, "--ref"),
248
+ prefixArg: prefixFlag,
249
+ destinationArg: destFlag,
250
+ noGit,
251
+ repoArg: flagString(parsed.flags, "--repo") ?? putDefaults.repo,
252
+ run,
253
+ })
254
+ : undefined;
255
+ const stagingTarget = branchArg !== undefined ? { repo: branchRepo, branch: branchArg } : autoStagingTarget;
214
256
  let resolvedPrefix;
215
257
  try {
216
258
  resolvedPrefix = resolvePutPrefix({
217
259
  destination: destFlag,
218
260
  prefix: prefixFlag,
219
261
  key: keyHint,
220
- ghAttachment: Boolean(ghTarget) || branchArg !== undefined,
262
+ ghAttachment: Boolean(ghTarget) || stagingTarget !== undefined,
221
263
  });
222
264
  }
223
265
  catch (err) {
@@ -233,7 +275,6 @@ captureImpl = captureScreenshot) {
233
275
  return raw;
234
276
  throw new UsageError(`invalid --format: ${raw}`);
235
277
  })();
236
- const putDefaults = resolvePutDefaults({ envFile: ctx.envFile }, rawDefaults);
237
278
  const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, putDefaults);
238
279
  const frameOpts = frameOptionsFromFlags(parsed.flags);
239
280
  const altFlag = flagString(parsed.flags, "--alt");
@@ -248,9 +289,8 @@ captureImpl = captureScreenshot) {
248
289
  metadata = { ...withFacts, ...ghMetadataFromTargetWithTitle(ghTarget, run) };
249
290
  validateMetaMap(metadata);
250
291
  }
251
- else if (branchArg !== undefined) {
252
- metadata = { ...withFacts, ...ghMetadataForBranch(branchRepo, branchArg) };
253
- validateMetaMap(metadata);
292
+ else if (stagingTarget !== undefined) {
293
+ metadata = mergeStagingMeta(withFacts, stagingTarget);
254
294
  }
255
295
  else if (Object.keys(withFacts).length > 0) {
256
296
  validateMetaMap(withFacts);
@@ -282,6 +322,8 @@ captureImpl = captureScreenshot) {
282
322
  writeFileSync(outFile, captured.png);
283
323
  if (logHuman)
284
324
  process.stderr.write(`>> wrote ${outFile}\n`);
325
+ if (!noSidecar)
326
+ writeSidecarMeta(outFile, captured.png, withFacts);
285
327
  }
286
328
  if (noUpload) {
287
329
  if (ctx.json) {
@@ -294,8 +336,8 @@ captureImpl = captureScreenshot) {
294
336
  }
295
337
  const repo = flagString(parsed.flags, "--repo") ?? putDefaults.repo;
296
338
  const ref = flagString(parsed.flags, "--ref") ?? putDefaults.ref;
297
- const branchKey = branchArg !== undefined
298
- ? ghBranchAttachmentKey(branchRepo, branchArg, captured.filename)
339
+ const branchKey = stagingTarget !== undefined
340
+ ? ghBranchAttachmentKey(stagingTarget.repo, stagingTarget.branch, captured.filename)
299
341
  : undefined;
300
342
  const alt = altFlag ?? basename(captured.filename);
301
343
  const { result, prepared, markdown } = await uploadPreparedImage(ctx.client, captured.png, captured.filename, {
@@ -306,13 +348,26 @@ captureImpl = captureScreenshot) {
306
348
  prefix: resolvedPrefix ?? putDefaults.prefix,
307
349
  repo,
308
350
  ref,
309
- deriveRepoFromGit: !(flagBool(parsed.flags, "--no-git") || putDefaults.noGit === true),
351
+ deriveRepoFromGit: !noGit,
310
352
  dryRun,
311
353
  metadata,
312
354
  provenanceClient: "uploads-cli-screenshot",
313
355
  alt: () => alt,
314
356
  width,
315
357
  });
358
+ // Staging note (issue #469 lever 1, mirrors #403's bare-put note): only for
359
+ // the auto-staged case — explicit `--branch` keeps its own "staged: these
360
+ // auto-attach..." wording below. Same suppression as put's note (--quiet,
361
+ // UPLOADS_NO_NUDGE=1).
362
+ const stagingNote = autoStagingTarget && !ctx.quiet && !putDefaults.noNudge
363
+ ? putStagingNoteText(autoStagingTarget.branch)
364
+ : undefined;
365
+ // Stage-time binding warning (issue #398), same check bare put/attach
366
+ // --branch run, now also reachable from screenshot's staging paths
367
+ // (explicit --branch and auto-staging alike).
368
+ const bindingWarning = stagingTarget !== undefined
369
+ ? await resolveStageBindingWarning({ ctx, defaults: putDefaults, repo: stagingTarget.repo })
370
+ : undefined;
316
371
  let gallery;
317
372
  if (galleryId) {
318
373
  try {
@@ -347,8 +402,25 @@ captureImpl = captureScreenshot) {
347
402
  if (prepared.optimized) {
348
403
  process.stderr.write(`>> optimized ${prepared.originalBytes} → ${prepared.outputBytes} bytes\n`);
349
404
  }
350
- process.stderr.write(`>> key: ${result.key}${dryRun ? " (dry run — not uploaded)" : ""}\n\n`);
405
+ process.stderr.write(`>> key: ${result.key}${dryRun ? " (dry run — not uploaded)" : ""}\n`);
406
+ if (stagingTarget !== undefined) {
407
+ process.stderr.write(`>> find these later: uploads find gh.branch=${stagingTarget.branch.toLowerCase()}\n`);
408
+ if (autoStagingTarget) {
409
+ if (stagingNote)
410
+ process.stderr.write(`${stagingNote}\n`);
411
+ }
412
+ else {
413
+ process.stderr.write(`>> staged: these auto-attach to this branch's PR when it opens ` +
414
+ `(or run \`uploads attach --promote\` after opening)\n`);
415
+ }
416
+ }
417
+ if (bindingWarning)
418
+ process.stderr.write(`${bindingWarning}\n`);
419
+ process.stderr.write("\n");
351
420
  }
421
+ // One JSON `hint` slot (mirrors bare put): the binding warning is more
422
+ // actionable than the generic staging note, so it wins when both fire.
423
+ const jsonHint = bindingWarning ?? stagingNote;
352
424
  switch (format) {
353
425
  case "json":
354
426
  await writeJson({
@@ -363,6 +435,7 @@ captureImpl = captureScreenshot) {
363
435
  backend: captured.backend,
364
436
  gallery,
365
437
  ...(dryRun ? { dryRun: true } : {}),
438
+ ...(jsonHint ? { hint: jsonHint } : {}),
366
439
  });
367
440
  break;
368
441
  case "url":
@@ -167,6 +167,21 @@ export declare function commentViaSuffix(via: AttachmentsCommentResult["via"]):
167
167
  export declare class GithubCommentAuthorizationError extends Error {
168
168
  }
169
169
  export declare function syncAttachmentsComment(client: UploadsClient, target: GhTarget, run: CommandRunner, workspace?: string): Promise<AttachmentsCommentResult>;
170
+ /**
171
+ * Lever 3 (issue #469): a nudge for when an image lands on a PR/issue with
172
+ * no `path` metadata — `path` is one of the highest-value queryable tags
173
+ * (same tier as `state=`), and unlike `uploads screenshot` (which derives it
174
+ * from the captured URL), a plain `attach`/`put --pr`/`put --issue` of an
175
+ * already-existing image has nothing to derive it from, so it's easy to
176
+ * forget. Fires once per batch (not per file) — checks the metadata the
177
+ * server actually stored (`PutResult.metadata`), not what was requested, so
178
+ * a merge/validation drop still surfaces the gap. Non-image uploads (zips,
179
+ * PDFs, etc.) are exempt — "findable by page" doesn't apply to them.
180
+ */
181
+ export declare function pathMetaHintFor(uploads: {
182
+ contentType: string;
183
+ metadata?: Record<string, string>;
184
+ }[]): string | undefined;
170
185
  export type AttachUploadItem = PutResult & {
171
186
  file: string;
172
187
  markdown: string;
@@ -355,6 +370,14 @@ export declare function resolvePutStagingTarget(opts: {
355
370
  repoArg: string | undefined;
356
371
  run: CommandRunner;
357
372
  }): BranchTarget | undefined;
373
+ /**
374
+ * Merges a staging target's `gh.*` branch metadata over `base` and validates
375
+ * the result (same builder, same contract as `attach --branch`) — the one
376
+ * merge+validate step shared by every staging call site: `runPut`,
377
+ * `runScreenshot`, and both the local stdio MCP `put` and `screenshot`
378
+ * tools.
379
+ */
380
+ export declare function mergeStagingMeta(base: Record<string, string> | undefined, target: BranchTarget): Record<string, string>;
358
381
  /**
359
382
  * The bare-put staging note's wording (issue #403): replaces the #393 nudge
360
383
  * for the (now default) case where a bare put on a non-default branch stages
package/dist/commands.js CHANGED
@@ -11,7 +11,8 @@ import { writeJson, writeStdout } from "./io.js";
11
11
  import { imageFactsFromBytes } from "./image-facts.js";
12
12
  import { parseMetaFlags, validateMetaMap } from "./metadata.js";
13
13
  import { mergeDerivedMeta, nearMissMetaWarnings, validateStateValue } from "./metadata-vocab.js";
14
- import { ghAttachmentKey, ghBranchAttachmentKey, ghBranchKeyPrefix, ghKeyPrefix, ghMetadataFromTarget, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, normalizeGithubCoordinate, } from "./github.js";
14
+ import { mergeSidecarMeta } from "./sidecar.js";
15
+ import { ghAttachmentKey, ghBranchAttachmentKey, ghBranchKeyPrefix, ghKeyPrefix, ghMetadataFromTarget, parseGhKey, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, normalizeGithubCoordinate, } from "./github.js";
15
16
  import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, resolveDefaultBranch, classifyGhNumber, execRunner, timedExecRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, } from "./github-gh.js";
16
17
  import { deriveRepoFromGit } from "./keys.js";
17
18
  import { resolvePutPrefix } from "./destinations.js";
@@ -56,6 +57,12 @@ upload as-is, or --keep-exif when image metadata matters for the discussion.
56
57
  Optional --frame wraps the image in a device/browser chrome before optimize
57
58
  (default off). See: uploads put --help frames
58
59
 
60
+ If the file has a sidecar manifest (<file>.uploads.json, written by
61
+ "screenshot --out") and its content hash still matches this file, that
62
+ capture's derived metadata (path/url/env/viewport/state) is merged in
63
+ automatically — explicit --meta/--state always win. A regenerated or edited
64
+ file loses its sidecar silently (hash no longer matches).
65
+
59
66
  Uploads are public. --pr/--issue keys include the repo, number, and filename and
60
67
  remain public even for private/internal GitHub repositories. Upload only media
61
68
  that is safe at a predictable public URL.
@@ -558,6 +565,12 @@ URL and every embed hot-swap. Human mode prints ">> replaced existing object
558
565
  Still images are optimized to WebP by default (same as put). Use --no-optimize
559
566
  to upload originals. Optional --frame wraps images in device/browser chrome.
560
567
 
568
+ If a file has a sidecar manifest (<file>.uploads.json, written by
569
+ "screenshot --out") and its content hash still matches, that capture's
570
+ derived metadata (path/url/env/viewport/state) is merged in automatically —
571
+ explicit --meta/--state always win. A regenerated or edited file loses its
572
+ sidecar silently (hash no longer matches).
573
+
561
574
  Branch staging (pre-PR): --branch [name] stages files against a git branch
562
575
  before a pull request exists, e.g. for a coding agent working a branch that
563
576
  hasn't opened a PR yet. Key: gh/<owner>/<repo>/branch/<branch>/<filename>
@@ -620,6 +633,21 @@ Examples:
620
633
  uploads attach ./shot.png --branch feature/new-settings
621
634
  uploads attach --promote
622
635
  `;
636
+ /**
637
+ * Lever 3 (issue #469): a nudge for when an image lands on a PR/issue with
638
+ * no `path` metadata — `path` is one of the highest-value queryable tags
639
+ * (same tier as `state=`), and unlike `uploads screenshot` (which derives it
640
+ * from the captured URL), a plain `attach`/`put --pr`/`put --issue` of an
641
+ * already-existing image has nothing to derive it from, so it's easy to
642
+ * forget. Fires once per batch (not per file) — checks the metadata the
643
+ * server actually stored (`PutResult.metadata`), not what was requested, so
644
+ * a merge/validation drop still surfaces the gap. Non-image uploads (zips,
645
+ * PDFs, etc.) are exempt — "findable by page" doesn't apply to them.
646
+ */
647
+ export function pathMetaHintFor(uploads) {
648
+ const missingPath = uploads.some((u) => u.contentType.startsWith("image/") && !u.metadata?.path);
649
+ return missingPath ? "tip: add --meta path=/route so this shot is findable by page" : undefined;
650
+ }
623
651
  /**
624
652
  * Shared prepare + put loop for both PR/issue attach (`uploadAttachments`)
625
653
  * and branch-staged attach (`uploadBranchAttachments`) — bounded concurrency,
@@ -634,11 +662,14 @@ async function uploadAttachmentBatch(opts) {
634
662
  try {
635
663
  const sourceName = basename(file);
636
664
  const bytes = readFileArg(file);
665
+ // Sidecar manifest from a prior `screenshot --out` of this exact file
666
+ // (issue #469 lever 2) — see mergeSidecarMeta.
667
+ const baseMetadata = mergeSidecarMeta(file, bytes, opts.metadata);
637
668
  // Same EXIF promotion uploadPreparedImage does; attach keeps its own
638
669
  // per-file tail (it builds keys differently), so it opts in here too.
639
670
  const metadata = opts.deriveImageFacts
640
- ? await mergeImageFacts(bytes, opts.metadata)
641
- : opts.metadata;
671
+ ? await mergeImageFacts(bytes, baseMetadata)
672
+ : baseMetadata;
642
673
  const prepared = await prepareImageForUpload(bytes, sourceName, {
643
674
  ...opts.frame,
644
675
  optimize: opts.optimize,
@@ -743,7 +774,11 @@ export async function uploadPuts(opts) {
743
774
  ? basename(opts.explicitKey)
744
775
  : "stdin.bin"
745
776
  : basename(file));
746
- const { result, prepared, markdown } = await uploadPreparedImage(opts.client, readFileArg(file), sourceName, {
777
+ const bytes = readFileArg(file);
778
+ // Sidecar manifest from a prior `screenshot --out` of this exact file
779
+ // (issue #469 lever 2) — see mergeSidecarMeta. Not applicable to stdin.
780
+ const metadata = file !== "-" ? mergeSidecarMeta(file, bytes, opts.metadata) : opts.metadata;
781
+ const { result, prepared, markdown } = await uploadPreparedImage(opts.client, bytes, sourceName, {
747
782
  frame: opts.frame,
748
783
  optimize: opts.optimize,
749
784
  ghTarget: opts.ghTarget,
@@ -756,7 +791,7 @@ export async function uploadPuts(opts) {
756
791
  contentType: opts.contentType,
757
792
  dryRun: opts.dryRun,
758
793
  replace: opts.replace,
759
- metadata: opts.metadata,
794
+ metadata,
760
795
  deriveImageFacts: opts.deriveImageFacts,
761
796
  provenanceClient: opts.provenanceClient,
762
797
  alt: () => opts.alt ?? basename(sourceName),
@@ -935,6 +970,8 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
935
970
  process.stderr.write(`warning: uploads succeeded but the GitHub comment failed (is gh installed and authenticated?): ${commentError}\n`);
936
971
  }
937
972
  }
973
+ // Lever 3 (issue #469): tip when an image lands here with no `path` meta.
974
+ const pathHint = uploads.length > 0 && !ctx.quiet ? pathMetaHintFor(uploads) : undefined;
938
975
  if (ctx.json) {
939
976
  await writeJson({
940
977
  target,
@@ -943,6 +980,7 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
943
980
  comment,
944
981
  commentError,
945
982
  promotion: promotion ?? null,
983
+ ...(pathHint ? { hint: pathHint } : {}),
946
984
  });
947
985
  }
948
986
  else {
@@ -971,6 +1009,8 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
971
1009
  const ref = ghMetadataFromTarget(target)["gh.ref"];
972
1010
  process.stderr.write(`>> find these later: uploads find gh.ref=${ref}\n`);
973
1011
  }
1012
+ if (pathHint)
1013
+ process.stderr.write(`${pathHint}\n`);
974
1014
  }
975
1015
  return failures.length === 0 ? 0 : 1;
976
1016
  }
@@ -1269,6 +1309,18 @@ export function resolvePutStagingTarget(opts) {
1269
1309
  return undefined; // gh/git unavailable, or repo unresolvable — dated layout
1270
1310
  }
1271
1311
  }
1312
+ /**
1313
+ * Merges a staging target's `gh.*` branch metadata over `base` and validates
1314
+ * the result (same builder, same contract as `attach --branch`) — the one
1315
+ * merge+validate step shared by every staging call site: `runPut`,
1316
+ * `runScreenshot`, and both the local stdio MCP `put` and `screenshot`
1317
+ * tools.
1318
+ */
1319
+ export function mergeStagingMeta(base, target) {
1320
+ const merged = { ...base, ...ghMetadataForBranch(target.repo, target.branch) };
1321
+ validateMetaMap(merged);
1322
+ return merged;
1323
+ }
1272
1324
  /**
1273
1325
  * The bare-put staging note's wording (issue #403): replaces the #393 nudge
1274
1326
  * for the (now default) case where a bare put on a non-default branch stages
@@ -1553,12 +1605,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1553
1605
  attachedRef = merged["gh.ref"];
1554
1606
  }
1555
1607
  else if (stagingTarget) {
1556
- const merged = {
1557
- ...userMeta,
1558
- ...ghMetadataForBranch(stagingTarget.repo, stagingTarget.branch),
1559
- };
1560
- validateMetaMap(merged); // matches attach --branch's unwrapped call — same builder, same contract
1561
- metadata = merged;
1608
+ metadata = mergeStagingMeta(userMeta, stagingTarget);
1562
1609
  }
1563
1610
  else {
1564
1611
  // gh.* additionally needs git, which the shared derived gate ignores.
@@ -1648,13 +1695,19 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1648
1695
  const bindingWarning = stagingTarget && uploads.length > 0
1649
1696
  ? await resolveStageBindingWarning({ ctx, defaults, repo: stagingTarget.repo })
1650
1697
  : undefined;
1698
+ // Lever 3 (issue #469): tip when a --pr/--issue put lands an image with no
1699
+ // `path` meta. Only relevant on the ghTarget path — the bare-put paths
1700
+ // above (staging/auto/dated) aren't attached to a PR/issue yet, so there's
1701
+ // nothing to look up from a page later.
1702
+ const pathHint = ghTarget && uploads.length > 0 && !ctx.quiet ? pathMetaHintFor(uploads) : undefined;
1651
1703
  // One JSON `hint` slot, shared with the #393 nudge (mutually exclusive with
1652
1704
  // it — nudge is undefined whenever staging took over). When staging fires,
1653
1705
  // prefer the more actionable binding warning over the generic staging note
1654
1706
  // (mirrors attach --branch, whose only JSON hint content IS the binding
1655
1707
  // warning); stderr prints the nudge/staging-note and binding-warning lines
1656
- // independently, below.
1657
- const jsonHint = nudge ?? bindingWarning ?? stagingNote;
1708
+ // independently, below. pathHint only ever fires on the ghTarget path, so
1709
+ // it never competes with the other three.
1710
+ const jsonHint = nudge ?? bindingWarning ?? stagingNote ?? pathHint;
1658
1711
  const galleriesByKey = new Map();
1659
1712
  let galleryHadError = false;
1660
1713
  if (galleryId && uploads.length > 0) {
@@ -1737,6 +1790,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1737
1790
  process.stderr.write(`${stagingNote}\n`);
1738
1791
  if (bindingWarning)
1739
1792
  process.stderr.write(`${bindingWarning}\n`);
1793
+ if (pathHint)
1794
+ process.stderr.write(`${pathHint}\n`);
1740
1795
  }
1741
1796
  return failures.length === 0 && !galleryHadError ? 0 : 1;
1742
1797
  }
@@ -1794,6 +1849,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1794
1849
  process.stderr.write(`${stagingNote}\n`);
1795
1850
  if (bindingWarning && format !== "json")
1796
1851
  process.stderr.write(`${bindingWarning}\n`);
1852
+ if (pathHint && format !== "json")
1853
+ process.stderr.write(`${pathHint}\n`);
1797
1854
  return gallery?.error ? 1 : 0;
1798
1855
  }
1799
1856
  // --- galleries ---
@@ -2173,12 +2230,50 @@ export async function runMeta(ctx, args, help = false) {
2173
2230
  else
2174
2231
  for (const [k, v] of Object.entries(result.metadata))
2175
2232
  await writeStdout(`${k}=${v}\n`);
2233
+ await resyncCommentAfterMetaSet(ctx, key, [...Object.keys(set ?? {}), ...del]);
2176
2234
  return 0;
2177
2235
  }
2178
2236
  default:
2179
2237
  throw new UsageError(`unknown meta command: ${action}`);
2180
2238
  }
2181
2239
  }
2240
+ /** The metadata keys the managed comment renders (path/state, PR #370). */
2241
+ const COMMENT_RENDERED_META_KEYS = ["path", "state"];
2242
+ /**
2243
+ * Best-effort managed-comment refresh after `meta set` touches a
2244
+ * display-relevant key on a PR/issue-keyed object (issue #470) — without
2245
+ * this, backfilled `path=`/`state=` never reaches the rendered comment until
2246
+ * an unrelated attach fires. Bot endpoint only (no gh fallback — this is a
2247
+ * metadata tweak, not an explicit comment command); any failure degrades to
2248
+ * a stderr hint instead of failing the metadata write that already landed.
2249
+ */
2250
+ async function resyncCommentAfterMetaSet(ctx, key, touchedKeys) {
2251
+ if (!touchedKeys.some((k) => COMMENT_RENDERED_META_KEYS.includes(k)))
2252
+ return;
2253
+ const target = parseGhKey(key);
2254
+ if (!target)
2255
+ return;
2256
+ try {
2257
+ const bot = await ctx.client.upsertGithubComment({
2258
+ repo: target.repo,
2259
+ num: target.num,
2260
+ kind: target.kind,
2261
+ });
2262
+ if (bot.posted) {
2263
+ if (!ctx.quiet && !ctx.json) {
2264
+ process.stderr.write(`refreshed the managed comment on ${target.repo}#${target.num}\n`);
2265
+ }
2266
+ return;
2267
+ }
2268
+ }
2269
+ catch {
2270
+ // Fall through to the hint.
2271
+ }
2272
+ if (!ctx.quiet && !ctx.json) {
2273
+ const flag = target.kind === "pull" ? "--pr" : "--issue";
2274
+ process.stderr.write(`tip: run \`uploads comment ${flag} ${target.num}\` to refresh the PR comment\n`);
2275
+ }
2276
+ }
2182
2277
  // --- delete ---
2183
2278
  const DELETE_HELP = `uploads delete <key> [--dry-run] [--workspace <name>]
2184
2279
 
package/dist/github.d.ts CHANGED
@@ -15,6 +15,12 @@ export declare function isValidRepo(repo: string): boolean;
15
15
  export declare function parseRepoFromRemoteUrl(url: string): string | undefined;
16
16
  /** Normalize a GitHub issue or pull-request coordinate for gallery linking. */
17
17
  export declare function normalizeGithubCoordinate(value: string): GithubCoordinate | undefined;
18
+ /**
19
+ * Inverse of `ghKeyPrefix`: parse the PR/issue coordinate back out of a
20
+ * stable attachment key (`gh/<owner>/<name>/<kind>/<num>/<filename>`), or
21
+ * undefined for any other key shape.
22
+ */
23
+ export declare function parseGhKey(key: string): GhTarget | undefined;
18
24
  export declare function ghKeyPrefix(target: GhTarget): string;
19
25
  /**
20
26
  * Stable attachment key: same filename → same key → same public URL, so
package/dist/github.js CHANGED
@@ -53,6 +53,18 @@ export function normalizeGithubCoordinate(value) {
53
53
  number,
54
54
  };
55
55
  }
56
+ /**
57
+ * Inverse of `ghKeyPrefix`: parse the PR/issue coordinate back out of a
58
+ * stable attachment key (`gh/<owner>/<name>/<kind>/<num>/<filename>`), or
59
+ * undefined for any other key shape.
60
+ */
61
+ export function parseGhKey(key) {
62
+ const match = /^gh\/([^/]+)\/([^/]+)\/(pull|issues)\/([1-9][0-9]*)\/./.exec(key);
63
+ if (!match)
64
+ return undefined;
65
+ const [, owner, name, kind, num] = match;
66
+ return { repo: `${owner}/${name}`, kind: kind, num: Number(num) };
67
+ }
56
68
  export function ghKeyPrefix(target) {
57
69
  const [owner, name] = target.repo.split("/");
58
70
  return `gh/${sanitizeKeySegment(owner)}/${sanitizeKeySegment(name)}/${target.kind}/${target.num}/`;
package/dist/mcp/tools.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { createUploadsClient } from "../client.js";
2
- import { buildDoctorReport, makeGhTarget, resolvePutStagingTarget, resolveStaged, syncAttachmentsComment, uploadAttachments, uploadPreparedImage, uploadPuts, } from "../commands.js";
2
+ import { buildDoctorReport, makeGhTarget, mergeStagingMeta, resolvePutStagingTarget, resolveStaged, syncAttachmentsComment, uploadAttachments, uploadPreparedImage, uploadPuts, } from "../commands.js";
3
3
  import { resolveFrameId } from "../frame.js";
4
4
  import { resolveConfig, resolvePutDefaults, } from "../config.js";
5
5
  import { resolvePutPrefix } from "../destinations.js";
6
- import { ghKeyPrefix, ghMetadataForBranch } from "../github.js";
6
+ import { ghBranchAttachmentKey, ghKeyPrefix } from "../github.js";
7
7
  import { safeCaptureFacts } from "../capture-facts.js";
8
8
  import { validateMetaMap } from "../metadata.js";
9
9
  import { mergeDerivedMeta } from "../metadata-vocab.js";
@@ -430,16 +430,7 @@ export function createUploadsMcpTools(opts) {
430
430
  repoArg: optString(args, "repo") ?? defaults.repo,
431
431
  run,
432
432
  });
433
- const putMetadata = stagingTarget
434
- ? (() => {
435
- const merged = {
436
- ...metadata,
437
- ...ghMetadataForBranch(stagingTarget.repo, stagingTarget.branch),
438
- };
439
- validateMetaMap(merged); // same builder, same contract as attach --branch
440
- return merged;
441
- })()
442
- : metadata;
433
+ const putMetadata = stagingTarget ? mergeStagingMeta(metadata, stagingTarget) : metadata;
443
434
  const putShared = {
444
435
  client,
445
436
  ghTarget: target,
@@ -695,25 +686,41 @@ export function createUploadsMcpTools(opts) {
695
686
  const metadata = metadataArgWithCanonical(args);
696
687
  if (metadata)
697
688
  validateMetaMap(metadata);
689
+ const { config, client } = clientFor(args);
690
+ const defaults = resolvePutDefaults({ envFile: globals.envFile });
691
+ const frameOpts = mcpFrameOptions(args);
692
+ const optimizeOpts = mcpOptimizeOptions(args, defaults);
693
+ const noGit = optBool(args, "noGit") || defaults.noGit === true;
694
+ const alt = optString(args, "alt");
695
+ const width = optPosInt(args, "width") ?? defaults.width;
696
+ // Auto branch staging (issue #469 lever 1): mirrors the CLI screenshot
697
+ // command and the put tool above (issue #403) — no pr/issue/key/ref/
698
+ // prefix/destination, not noGit, on a non-default git branch stages
699
+ // to the branch prefix (identical key/metadata to `attach --branch`)
700
+ // instead of the dated `screenshots/<repo>/<date>/...` layout. Never
701
+ // throws — see resolvePutStagingTarget.
702
+ const stagingTarget = resolvePutStagingTarget({
703
+ ghTarget: target,
704
+ keyHint: keyArg,
705
+ refArg,
706
+ prefixArg,
707
+ destinationArg: destArg,
708
+ noGit,
709
+ repoArg: optString(args, "repo") ?? defaults.repo,
710
+ run,
711
+ });
698
712
  let resolvedPrefix;
699
713
  try {
700
714
  resolvedPrefix = resolvePutPrefix({
701
715
  destination: destArg,
702
716
  prefix: prefixArg,
703
717
  key: keyArg,
704
- ghAttachment: Boolean(target),
718
+ ghAttachment: Boolean(target) || stagingTarget !== undefined,
705
719
  });
706
720
  }
707
721
  catch (err) {
708
722
  usage(err instanceof Error ? err.message : String(err));
709
723
  }
710
- const { config, client } = clientFor(args);
711
- const defaults = resolvePutDefaults({ envFile: globals.envFile });
712
- const frameOpts = mcpFrameOptions(args);
713
- const optimizeOpts = mcpOptimizeOptions(args, defaults);
714
- const noGit = optBool(args, "noGit") || defaults.noGit === true;
715
- const alt = optString(args, "alt");
716
- const width = optPosInt(args, "width") ?? defaults.width;
717
724
  // Dynamic import only: keeps mcp/tools.ts (and therefore anything
718
725
  // that statically imports it) free of a static reference to the
719
726
  // local-backend chain. If this fails, the runtime can't do Node-side
@@ -730,9 +737,15 @@ export function createUploadsMcpTools(opts) {
730
737
  // Keep undefined when nothing at all was supplied or derived, so the
731
738
  // "omit to leave stored metadata untouched" contract still holds.
732
739
  const captureDerived = safeCaptureFacts(targetArg, viewport, colorSchemeArg);
733
- const metadataWithCaptureFacts = metadata === undefined && Object.keys(captureDerived).length === 0
740
+ const metadataBase = metadata === undefined && Object.keys(captureDerived).length === 0
734
741
  ? undefined
735
742
  : mergeDerivedMeta(metadata ?? {}, captureDerived);
743
+ // gh.* metadata: explicit pr/issue target wins; staging wins the same
744
+ // way (matches attach --branch/bare put); otherwise capture-derived +
745
+ // explicit only.
746
+ const metadataWithCaptureFacts = stagingTarget
747
+ ? mergeStagingMeta(metadataBase, stagingTarget)
748
+ : metadataBase;
736
749
  let captured;
737
750
  try {
738
751
  captured = await screenshotModule.captureScreenshot({
@@ -761,11 +774,14 @@ export function createUploadsMcpTools(opts) {
761
774
  }
762
775
  throw err;
763
776
  }
777
+ const branchKey = stagingTarget
778
+ ? ghBranchAttachmentKey(stagingTarget.repo, stagingTarget.branch, captured.filename)
779
+ : undefined;
764
780
  const { result, prepared, markdown } = await uploadPreparedImage(client, captured.png, captured.filename, {
765
781
  frame: frameOpts,
766
782
  optimize: optimizeOpts,
767
783
  ghTarget: target,
768
- key: keyArg,
784
+ key: keyArg ?? branchKey,
769
785
  prefix: resolvedPrefix ?? defaults.prefix,
770
786
  repo: optString(args, "repo") ?? defaults.repo,
771
787
  ref: refArg ?? defaults.ref,
@@ -0,0 +1,31 @@
1
+ /** The sidecar path for a given local file — `<file>.uploads.json`. */
2
+ export declare function sidecarPath(filePath: string): string;
3
+ /** Hex-encoded SHA-256 of `bytes`. */
4
+ export declare function sha256Hex(bytes: Uint8Array): string;
5
+ /** Keep only entries whose key is in the closed canonical metadata vocabulary. */
6
+ export declare function restrictToCanonicalMeta(meta: Record<string, string>): Record<string, string>;
7
+ /**
8
+ * Write a sidecar manifest next to `filePath`, recording `meta` (restricted
9
+ * to the canonical vocabulary) and the SHA-256 of `bytes` (the exact bytes
10
+ * being written to `filePath`). No-ops when `meta` is empty — an image with
11
+ * no derived metadata gets no sidecar. Best-effort: a write failure (e.g. a
12
+ * read-only directory) is swallowed, matching the rest of the derived-
13
+ * metadata pipeline's "never fail the primary operation" contract.
14
+ */
15
+ export declare function writeSidecarMeta(filePath: string, bytes: Uint8Array, meta: Record<string, string>): void;
16
+ /**
17
+ * Read back a sidecar manifest for `filePath`, only when it is present,
18
+ * well-formed, and its recorded hash matches `bytes` (the file's current
19
+ * content, as read for the upload in progress). Returns `undefined` on any
20
+ * absence, parse failure, malformed shape, or hash mismatch — a sidecar is a
21
+ * best-effort convenience and must never fail or noise an upload. Returned
22
+ * keys are always a subset of `CANONICAL_META_KEYS`, so a hand-edited
23
+ * manifest can never inject arbitrary metadata.
24
+ */
25
+ export declare function readSidecarMeta(filePath: string, bytes: Uint8Array): Record<string, string> | undefined;
26
+ /**
27
+ * Merge `filePath`'s sidecar metadata (if any, per {@link readSidecarMeta})
28
+ * under `baseMeta` — explicit metadata always wins. Shared by the `put` and
29
+ * `attach` upload loops (issue #469 lever 2).
30
+ */
31
+ export declare function mergeSidecarMeta(filePath: string, bytes: Uint8Array, baseMeta: Record<string, string> | undefined): Record<string, string> | undefined;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Sidecar manifest for `uploads screenshot --out`: derived metadata written
3
+ * next to a local screenshot file so a later `put`/`attach` of that exact
4
+ * file can recover the metadata the hosted copy would have gotten at capture
5
+ * time. See issue #469 lever 2 (the "sidecar manifest" variant — the
6
+ * alternative, content-hash-keyed server-side inheritance, is out of scope
7
+ * here).
8
+ *
9
+ * File: `<file>.uploads.json` next to `<file>`, e.g. `shot.png.uploads.json`.
10
+ * Shape: `{ version, sha256, meta }`. `sha256` is the SHA-256 of the exact
11
+ * bytes written to `<file>` at capture time — read-back compares it against
12
+ * the file's *current* bytes, so a file that was regenerated or hand-edited
13
+ * since capture silently loses its sidecar instead of attaching stale
14
+ * metadata to a different image.
15
+ *
16
+ * `meta` is filtered to the closed `CANONICAL_META_KEYS` vocabulary
17
+ * (metadata-vocab.ts) on both write and read: the sidecar is a plain JSON
18
+ * file sitting next to the image, so it must never be a channel for
19
+ * arbitrary metadata even if hand-edited.
20
+ */
21
+ import { createHash } from "node:crypto";
22
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
23
+ import { CANONICAL_META_KEYS, mergeDerivedMeta } from "./metadata-vocab.js";
24
+ const SIDECAR_VERSION = 1;
25
+ /** The sidecar path for a given local file — `<file>.uploads.json`. */
26
+ export function sidecarPath(filePath) {
27
+ return `${filePath}.uploads.json`;
28
+ }
29
+ /** Hex-encoded SHA-256 of `bytes`. */
30
+ export function sha256Hex(bytes) {
31
+ return createHash("sha256").update(bytes).digest("hex");
32
+ }
33
+ /** Keep only entries whose key is in the closed canonical metadata vocabulary. */
34
+ export function restrictToCanonicalMeta(meta) {
35
+ const out = {};
36
+ for (const key of CANONICAL_META_KEYS) {
37
+ if (Object.prototype.hasOwnProperty.call(meta, key))
38
+ out[key] = meta[key];
39
+ }
40
+ return out;
41
+ }
42
+ function isPlainStringRecord(value) {
43
+ if (typeof value !== "object" || value === null || Array.isArray(value))
44
+ return false;
45
+ return Object.values(value).every((v) => typeof v === "string");
46
+ }
47
+ /**
48
+ * Write a sidecar manifest next to `filePath`, recording `meta` (restricted
49
+ * to the canonical vocabulary) and the SHA-256 of `bytes` (the exact bytes
50
+ * being written to `filePath`). No-ops when `meta` is empty — an image with
51
+ * no derived metadata gets no sidecar. Best-effort: a write failure (e.g. a
52
+ * read-only directory) is swallowed, matching the rest of the derived-
53
+ * metadata pipeline's "never fail the primary operation" contract.
54
+ */
55
+ export function writeSidecarMeta(filePath, bytes, meta) {
56
+ const restricted = restrictToCanonicalMeta(meta);
57
+ if (Object.keys(restricted).length === 0)
58
+ return;
59
+ try {
60
+ const manifest = {
61
+ version: SIDECAR_VERSION,
62
+ sha256: sha256Hex(bytes),
63
+ meta: restricted,
64
+ };
65
+ writeFileSync(sidecarPath(filePath), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
66
+ }
67
+ catch {
68
+ // best-effort — never fail the screenshot over a sidecar write
69
+ }
70
+ }
71
+ /**
72
+ * Read back a sidecar manifest for `filePath`, only when it is present,
73
+ * well-formed, and its recorded hash matches `bytes` (the file's current
74
+ * content, as read for the upload in progress). Returns `undefined` on any
75
+ * absence, parse failure, malformed shape, or hash mismatch — a sidecar is a
76
+ * best-effort convenience and must never fail or noise an upload. Returned
77
+ * keys are always a subset of `CANONICAL_META_KEYS`, so a hand-edited
78
+ * manifest can never inject arbitrary metadata.
79
+ */
80
+ export function readSidecarMeta(filePath, bytes) {
81
+ const path = sidecarPath(filePath);
82
+ try {
83
+ if (!existsSync(path))
84
+ return undefined;
85
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
86
+ if (typeof parsed !== "object" || parsed === null)
87
+ return undefined;
88
+ const candidate = parsed;
89
+ if (candidate.version !== SIDECAR_VERSION ||
90
+ typeof candidate.sha256 !== "string" ||
91
+ !isPlainStringRecord(candidate.meta)) {
92
+ return undefined;
93
+ }
94
+ if (candidate.sha256 !== sha256Hex(bytes))
95
+ return undefined; // stale/regenerated file
96
+ const restricted = restrictToCanonicalMeta(candidate.meta);
97
+ return Object.keys(restricted).length > 0 ? restricted : undefined;
98
+ }
99
+ catch {
100
+ return undefined;
101
+ }
102
+ }
103
+ /**
104
+ * Merge `filePath`'s sidecar metadata (if any, per {@link readSidecarMeta})
105
+ * under `baseMeta` — explicit metadata always wins. Shared by the `put` and
106
+ * `attach` upload loops (issue #469 lever 2).
107
+ */
108
+ export function mergeSidecarMeta(filePath, bytes, baseMeta) {
109
+ const sidecarMeta = readSidecarMeta(filePath, bytes);
110
+ return sidecarMeta ? mergeDerivedMeta(baseMeta ?? {}, sidecarMeta) : baseMeta;
111
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.26.1",
3
+ "version": "0.27.0",
4
4
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
5
  "type": "module",
6
6
  "sideEffects": false,