@buildinternet/uploads 0.26.1 → 0.28.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
@@ -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.
@@ -443,13 +450,21 @@ export function commentViaSuffix(via) {
443
450
  */
444
451
  export class GithubCommentAuthorizationError extends Error {
445
452
  }
446
- export async function syncAttachmentsComment(client, target, run, workspace) {
453
+ /**
454
+ * `opts.resync` marks an explicit `uploads comment` invocation rather than a
455
+ * background sync (attach, screenshot, put --comment). It costs the server one
456
+ * extra comment listing and in exchange collapses any duplicate managed
457
+ * comment (issue #480) — worth it on the rare, explicitly-asked-for resync,
458
+ * not on every attach.
459
+ */
460
+ export async function syncAttachmentsComment(client, target, run, workspace, opts = {}) {
447
461
  let bot;
448
462
  try {
449
463
  bot = await client.upsertGithubComment({
450
464
  repo: target.repo,
451
465
  num: target.num,
452
466
  kind: target.kind,
467
+ ...(opts.resync ? { resync: true } : {}),
453
468
  });
454
469
  }
455
470
  catch {
@@ -558,6 +573,12 @@ URL and every embed hot-swap. Human mode prints ">> replaced existing object
558
573
  Still images are optimized to WebP by default (same as put). Use --no-optimize
559
574
  to upload originals. Optional --frame wraps images in device/browser chrome.
560
575
 
576
+ If a file has a sidecar manifest (<file>.uploads.json, written by
577
+ "screenshot --out") and its content hash still matches, that capture's
578
+ derived metadata (path/url/env/viewport/state) is merged in automatically —
579
+ explicit --meta/--state always win. A regenerated or edited file loses its
580
+ sidecar silently (hash no longer matches).
581
+
561
582
  Branch staging (pre-PR): --branch [name] stages files against a git branch
562
583
  before a pull request exists, e.g. for a coding agent working a branch that
563
584
  hasn't opened a PR yet. Key: gh/<owner>/<repo>/branch/<branch>/<filename>
@@ -620,6 +641,21 @@ Examples:
620
641
  uploads attach ./shot.png --branch feature/new-settings
621
642
  uploads attach --promote
622
643
  `;
644
+ /**
645
+ * Lever 3 (issue #469): a nudge for when an image lands on a PR/issue with
646
+ * no `path` metadata — `path` is one of the highest-value queryable tags
647
+ * (same tier as `state=`), and unlike `uploads screenshot` (which derives it
648
+ * from the captured URL), a plain `attach`/`put --pr`/`put --issue` of an
649
+ * already-existing image has nothing to derive it from, so it's easy to
650
+ * forget. Fires once per batch (not per file) — checks the metadata the
651
+ * server actually stored (`PutResult.metadata`), not what was requested, so
652
+ * a merge/validation drop still surfaces the gap. Non-image uploads (zips,
653
+ * PDFs, etc.) are exempt — "findable by page" doesn't apply to them.
654
+ */
655
+ export function pathMetaHintFor(uploads) {
656
+ const missingPath = uploads.some((u) => u.contentType.startsWith("image/") && !u.metadata?.path);
657
+ return missingPath ? "tip: add --meta path=/route so this shot is findable by page" : undefined;
658
+ }
623
659
  /**
624
660
  * Shared prepare + put loop for both PR/issue attach (`uploadAttachments`)
625
661
  * and branch-staged attach (`uploadBranchAttachments`) — bounded concurrency,
@@ -634,11 +670,14 @@ async function uploadAttachmentBatch(opts) {
634
670
  try {
635
671
  const sourceName = basename(file);
636
672
  const bytes = readFileArg(file);
673
+ // Sidecar manifest from a prior `screenshot --out` of this exact file
674
+ // (issue #469 lever 2) — see mergeSidecarMeta.
675
+ const baseMetadata = mergeSidecarMeta(file, bytes, opts.metadata);
637
676
  // Same EXIF promotion uploadPreparedImage does; attach keeps its own
638
677
  // per-file tail (it builds keys differently), so it opts in here too.
639
678
  const metadata = opts.deriveImageFacts
640
- ? await mergeImageFacts(bytes, opts.metadata)
641
- : opts.metadata;
679
+ ? await mergeImageFacts(bytes, baseMetadata)
680
+ : baseMetadata;
642
681
  const prepared = await prepareImageForUpload(bytes, sourceName, {
643
682
  ...opts.frame,
644
683
  optimize: opts.optimize,
@@ -743,7 +782,11 @@ export async function uploadPuts(opts) {
743
782
  ? basename(opts.explicitKey)
744
783
  : "stdin.bin"
745
784
  : basename(file));
746
- const { result, prepared, markdown } = await uploadPreparedImage(opts.client, readFileArg(file), sourceName, {
785
+ const bytes = readFileArg(file);
786
+ // Sidecar manifest from a prior `screenshot --out` of this exact file
787
+ // (issue #469 lever 2) — see mergeSidecarMeta. Not applicable to stdin.
788
+ const metadata = file !== "-" ? mergeSidecarMeta(file, bytes, opts.metadata) : opts.metadata;
789
+ const { result, prepared, markdown } = await uploadPreparedImage(opts.client, bytes, sourceName, {
747
790
  frame: opts.frame,
748
791
  optimize: opts.optimize,
749
792
  ghTarget: opts.ghTarget,
@@ -756,7 +799,7 @@ export async function uploadPuts(opts) {
756
799
  contentType: opts.contentType,
757
800
  dryRun: opts.dryRun,
758
801
  replace: opts.replace,
759
- metadata: opts.metadata,
802
+ metadata,
760
803
  deriveImageFacts: opts.deriveImageFacts,
761
804
  provenanceClient: opts.provenanceClient,
762
805
  alt: () => opts.alt ?? basename(sourceName),
@@ -935,6 +978,8 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
935
978
  process.stderr.write(`warning: uploads succeeded but the GitHub comment failed (is gh installed and authenticated?): ${commentError}\n`);
936
979
  }
937
980
  }
981
+ // Lever 3 (issue #469): tip when an image lands here with no `path` meta.
982
+ const pathHint = uploads.length > 0 && !ctx.quiet ? pathMetaHintFor(uploads) : undefined;
938
983
  if (ctx.json) {
939
984
  await writeJson({
940
985
  target,
@@ -943,6 +988,7 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
943
988
  comment,
944
989
  commentError,
945
990
  promotion: promotion ?? null,
991
+ ...(pathHint ? { hint: pathHint } : {}),
946
992
  });
947
993
  }
948
994
  else {
@@ -971,6 +1017,8 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
971
1017
  const ref = ghMetadataFromTarget(target)["gh.ref"];
972
1018
  process.stderr.write(`>> find these later: uploads find gh.ref=${ref}\n`);
973
1019
  }
1020
+ if (pathHint)
1021
+ process.stderr.write(`${pathHint}\n`);
974
1022
  }
975
1023
  return failures.length === 0 ? 0 : 1;
976
1024
  }
@@ -1269,6 +1317,18 @@ export function resolvePutStagingTarget(opts) {
1269
1317
  return undefined; // gh/git unavailable, or repo unresolvable — dated layout
1270
1318
  }
1271
1319
  }
1320
+ /**
1321
+ * Merges a staging target's `gh.*` branch metadata over `base` and validates
1322
+ * the result (same builder, same contract as `attach --branch`) — the one
1323
+ * merge+validate step shared by every staging call site: `runPut`,
1324
+ * `runScreenshot`, and both the local stdio MCP `put` and `screenshot`
1325
+ * tools.
1326
+ */
1327
+ export function mergeStagingMeta(base, target) {
1328
+ const merged = { ...base, ...ghMetadataForBranch(target.repo, target.branch) };
1329
+ validateMetaMap(merged);
1330
+ return merged;
1331
+ }
1272
1332
  /**
1273
1333
  * The bare-put staging note's wording (issue #403): replaces the #393 nudge
1274
1334
  * for the (now default) case where a bare put on a non-default branch stages
@@ -1553,12 +1613,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1553
1613
  attachedRef = merged["gh.ref"];
1554
1614
  }
1555
1615
  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;
1616
+ metadata = mergeStagingMeta(userMeta, stagingTarget);
1562
1617
  }
1563
1618
  else {
1564
1619
  // gh.* additionally needs git, which the shared derived gate ignores.
@@ -1648,13 +1703,19 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1648
1703
  const bindingWarning = stagingTarget && uploads.length > 0
1649
1704
  ? await resolveStageBindingWarning({ ctx, defaults, repo: stagingTarget.repo })
1650
1705
  : undefined;
1706
+ // Lever 3 (issue #469): tip when a --pr/--issue put lands an image with no
1707
+ // `path` meta. Only relevant on the ghTarget path — the bare-put paths
1708
+ // above (staging/auto/dated) aren't attached to a PR/issue yet, so there's
1709
+ // nothing to look up from a page later.
1710
+ const pathHint = ghTarget && uploads.length > 0 && !ctx.quiet ? pathMetaHintFor(uploads) : undefined;
1651
1711
  // One JSON `hint` slot, shared with the #393 nudge (mutually exclusive with
1652
1712
  // it — nudge is undefined whenever staging took over). When staging fires,
1653
1713
  // prefer the more actionable binding warning over the generic staging note
1654
1714
  // (mirrors attach --branch, whose only JSON hint content IS the binding
1655
1715
  // warning); stderr prints the nudge/staging-note and binding-warning lines
1656
- // independently, below.
1657
- const jsonHint = nudge ?? bindingWarning ?? stagingNote;
1716
+ // independently, below. pathHint only ever fires on the ghTarget path, so
1717
+ // it never competes with the other three.
1718
+ const jsonHint = nudge ?? bindingWarning ?? stagingNote ?? pathHint;
1658
1719
  const galleriesByKey = new Map();
1659
1720
  let galleryHadError = false;
1660
1721
  if (galleryId && uploads.length > 0) {
@@ -1737,6 +1798,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1737
1798
  process.stderr.write(`${stagingNote}\n`);
1738
1799
  if (bindingWarning)
1739
1800
  process.stderr.write(`${bindingWarning}\n`);
1801
+ if (pathHint)
1802
+ process.stderr.write(`${pathHint}\n`);
1740
1803
  }
1741
1804
  return failures.length === 0 && !galleryHadError ? 0 : 1;
1742
1805
  }
@@ -1794,6 +1857,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1794
1857
  process.stderr.write(`${stagingNote}\n`);
1795
1858
  if (bindingWarning && format !== "json")
1796
1859
  process.stderr.write(`${bindingWarning}\n`);
1860
+ if (pathHint && format !== "json")
1861
+ process.stderr.write(`${pathHint}\n`);
1797
1862
  return gallery?.error ? 1 : 0;
1798
1863
  }
1799
1864
  // --- galleries ---
@@ -2173,12 +2238,51 @@ export async function runMeta(ctx, args, help = false) {
2173
2238
  else
2174
2239
  for (const [k, v] of Object.entries(result.metadata))
2175
2240
  await writeStdout(`${k}=${v}\n`);
2241
+ await resyncCommentAfterMetaSet(ctx, key, [...Object.keys(set ?? {}), ...del]);
2176
2242
  return 0;
2177
2243
  }
2178
2244
  default:
2179
2245
  throw new UsageError(`unknown meta command: ${action}`);
2180
2246
  }
2181
2247
  }
2248
+ /** The metadata keys the managed comment renders (path/state, PR #370). */
2249
+ const COMMENT_RENDERED_META_KEYS = ["path", "state"];
2250
+ /**
2251
+ * Best-effort managed-comment refresh after `meta set` touches a
2252
+ * display-relevant key on a PR/issue-keyed object (issue #470) — without
2253
+ * this, backfilled `path=`/`state=` never reaches the rendered comment until
2254
+ * an unrelated attach fires. Bot endpoint only (no gh fallback — this is a
2255
+ * metadata tweak, not an explicit comment command); any failure degrades to
2256
+ * a stderr hint instead of failing the metadata write that already landed.
2257
+ */
2258
+ async function resyncCommentAfterMetaSet(ctx, key, touchedKeys) {
2259
+ if (!touchedKeys.some((k) => COMMENT_RENDERED_META_KEYS.includes(k)))
2260
+ return;
2261
+ const target = parseGhKey(key);
2262
+ if (!target)
2263
+ return;
2264
+ try {
2265
+ const bot = await ctx.client.upsertGithubComment({
2266
+ repo: target.repo,
2267
+ num: target.num,
2268
+ kind: target.kind,
2269
+ resync: true,
2270
+ });
2271
+ if (bot.posted) {
2272
+ if (!ctx.quiet && !ctx.json) {
2273
+ process.stderr.write(`refreshed the managed comment on ${target.repo}#${target.num}\n`);
2274
+ }
2275
+ return;
2276
+ }
2277
+ }
2278
+ catch {
2279
+ // Fall through to the hint.
2280
+ }
2281
+ if (!ctx.quiet && !ctx.json) {
2282
+ const flag = target.kind === "pull" ? "--pr" : "--issue";
2283
+ process.stderr.write(`tip: run \`uploads comment ${flag} ${target.num}\` to refresh the PR comment\n`);
2284
+ }
2285
+ }
2182
2286
  // --- delete ---
2183
2287
  const DELETE_HELP = `uploads delete <key> [--dry-run] [--workspace <name>]
2184
2288
 
@@ -2241,7 +2345,9 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
2241
2345
  const target = ghTargetFromFlags(parsed.flags, run);
2242
2346
  if (!target)
2243
2347
  throw new UsageError("comment requires --pr or --issue");
2244
- const result = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace);
2348
+ const result = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace, {
2349
+ resync: true,
2350
+ });
2245
2351
  if (ctx.json) {
2246
2352
  await writeJson({ ...target, ...result });
2247
2353
  }
@@ -55,7 +55,8 @@ export declare function resolveGhTitle(target: GhTarget, run?: CommandRunner): s
55
55
  export declare function ghMetadataFromTargetWithTitle(target: GhTarget, run?: CommandRunner): Record<string, string>;
56
56
  /**
57
57
  * Create the managed attachments comment, or edit it in place if it already
58
- * exists. Never touches any other comment. Body is passed via stdin
58
+ * exists. Never touches any other comment except best-effort deletes of
59
+ * duplicate marker comments (see below). Body is passed via stdin
59
60
  * (`-F body=@-`) so it is never shell-interpolated.
60
61
  *
61
62
  * `marker` identifies which comment to hunt for (see `findManagedComment`);
@@ -63,6 +64,27 @@ export declare function ghMetadataFromTargetWithTitle(target: GhTarget, run?: Co
63
64
  * (built via `attachmentsCommentBody(items, galleries, marker)`), so patching
64
65
  * an adopted legacy comment migrates it to the namespaced marker in place.
65
66
  * Defaults to the shared legacy marker for backward compatibility.
67
+ *
68
+ * Self-healing dedupe (issue #486, mirroring the bot path's #470/#484 fix):
69
+ * a create race (two concurrent `uploads attach` runs, neither finding an
70
+ * existing comment) can leave more than one marker comment on the thread.
71
+ * This path has no id cache, so unlike the bot path a duplicate here never
72
+ * heals on its own — every sync just patches the oldest and leaves the rest
73
+ * stale. After patching (or creating), any extra exact-`marker` hits are
74
+ * deleted best-effort via `gh api -X DELETE`; a failed delete is swallowed
75
+ * and never fails the caller's command, and the next sync retries anyway.
76
+ *
77
+ * On why this duplicates the bot path rather than deferring to it: the gh
78
+ * fallback is a supported path, not a stopgap, so it is held at behavioral
79
+ * parity deliberately. This file already reimplements the hunt, the legacy
80
+ * adoption and the create-vs-patch gate against a different transport (the
81
+ * `gh` subprocess, not the App's token), and #486 existed precisely because
82
+ * the two drifted. Treat any behavior change to `upsertBotComment`
83
+ * (apps/api/src/github-comment.ts) as owing a matching change here. Note
84
+ * this is the one place the CLI deletes a GitHub resource under the
85
+ * invoking human's own credentials — bounded to comments carrying this
86
+ * workspace's exact namespaced marker, whose content is always
87
+ * regenerable.
66
88
  */
67
89
  export declare function upsertAttachmentsComment(target: GhTarget, body: string, run?: CommandRunner, marker?: string, opts?: {
68
90
  createIfMissing?: boolean;
package/dist/github-gh.js CHANGED
@@ -187,7 +187,9 @@ export function ghMetadataFromTargetWithTitle(target, run = execRunner) {
187
187
  /**
188
188
  * PR comments live on the issues endpoint, so one path covers PRs and issues.
189
189
  * `--paginate` follows Link headers and merges every page into one array, so the
190
- * marker comment is found even on threads past 100 comments.
190
+ * marker comment is found even on threads past 100 comments. GitHub returns
191
+ * comments oldest-first, so `hits[0]` (after merging paginated pages, which
192
+ * preserve that order) is the oldest exact-`marker` hit.
191
193
  *
192
194
  * Hunts for `marker` (the namespaced, per-workspace marker) first; when none
193
195
  * is found, falls back to a comment carrying the shared legacy
@@ -195,6 +197,12 @@ export function ghMetadataFromTargetWithTitle(target, run = execRunner) {
195
197
  * migrated in place. When `marker` IS the legacy marker (no workspace to
196
198
  * namespace with) this collapses to a single hunt, unchanged from pre-4b
197
199
  * behavior.
200
+ *
201
+ * Collects EVERY comment carrying `marker` (a create race can leave more
202
+ * than one — issue #486, mirroring the bot path's #470 fix): the oldest is
203
+ * `comment`, the rest come back as `extras` for the caller to delete. Only
204
+ * exact-`marker` hits are ever extras — a legacy (unnamespaced) comment may
205
+ * belong to a different workspace, so it is adopted at most, never deleted.
198
206
  */
199
207
  function findManagedComment(target, run, marker) {
200
208
  const raw = run("gh", [
@@ -203,16 +211,25 @@ function findManagedComment(target, run, marker) {
203
211
  "--paginate",
204
212
  ]);
205
213
  const comments = JSON.parse(raw);
206
- const namespacedHit = comments.find((c) => typeof c.body === "string" && c.body.includes(marker));
207
- if (namespacedHit)
208
- return namespacedHit;
214
+ const hits = comments.filter((c) => typeof c.body === "string" && c.body.includes(marker));
215
+ if (hits.length > 0) {
216
+ // In legacy mode (no workspace to namespace with) our "exact" marker IS
217
+ // the shared one, so a second hit is not our own duplicate — it may be
218
+ // another workspace's comment. Adopt the oldest and never delete: the
219
+ // adopt-only contract is about the marker being ambiguous, which is just
220
+ // as true when it is the marker we are hunting on.
221
+ const extras = marker === ATTACHMENTS_MARKER ? undefined : hits.slice(1);
222
+ return { comment: hits[0], extras };
223
+ }
209
224
  if (marker === ATTACHMENTS_MARKER)
210
- return undefined;
211
- return comments.find((c) => typeof c.body === "string" && c.body.includes(ATTACHMENTS_MARKER));
225
+ return {};
226
+ const legacyHit = comments.find((c) => typeof c.body === "string" && c.body.includes(ATTACHMENTS_MARKER));
227
+ return { comment: legacyHit };
212
228
  }
213
229
  /**
214
230
  * Create the managed attachments comment, or edit it in place if it already
215
- * exists. Never touches any other comment. Body is passed via stdin
231
+ * exists. Never touches any other comment except best-effort deletes of
232
+ * duplicate marker comments (see below). Body is passed via stdin
216
233
  * (`-F body=@-`) so it is never shell-interpolated.
217
234
  *
218
235
  * `marker` identifies which comment to hunt for (see `findManagedComment`);
@@ -220,10 +237,41 @@ function findManagedComment(target, run, marker) {
220
237
  * (built via `attachmentsCommentBody(items, galleries, marker)`), so patching
221
238
  * an adopted legacy comment migrates it to the namespaced marker in place.
222
239
  * Defaults to the shared legacy marker for backward compatibility.
240
+ *
241
+ * Self-healing dedupe (issue #486, mirroring the bot path's #470/#484 fix):
242
+ * a create race (two concurrent `uploads attach` runs, neither finding an
243
+ * existing comment) can leave more than one marker comment on the thread.
244
+ * This path has no id cache, so unlike the bot path a duplicate here never
245
+ * heals on its own — every sync just patches the oldest and leaves the rest
246
+ * stale. After patching (or creating), any extra exact-`marker` hits are
247
+ * deleted best-effort via `gh api -X DELETE`; a failed delete is swallowed
248
+ * and never fails the caller's command, and the next sync retries anyway.
249
+ *
250
+ * On why this duplicates the bot path rather than deferring to it: the gh
251
+ * fallback is a supported path, not a stopgap, so it is held at behavioral
252
+ * parity deliberately. This file already reimplements the hunt, the legacy
253
+ * adoption and the create-vs-patch gate against a different transport (the
254
+ * `gh` subprocess, not the App's token), and #486 existed precisely because
255
+ * the two drifted. Treat any behavior change to `upsertBotComment`
256
+ * (apps/api/src/github-comment.ts) as owing a matching change here. Note
257
+ * this is the one place the CLI deletes a GitHub resource under the
258
+ * invoking human's own credentials — bounded to comments carrying this
259
+ * workspace's exact namespaced marker, whose content is always
260
+ * regenerable.
223
261
  */
224
262
  export function upsertAttachmentsComment(target, body, run = execRunner, marker = ATTACHMENTS_MARKER, opts = {}) {
225
263
  const createIfMissing = opts.createIfMissing ?? true;
226
- const existing = findManagedComment(target, run, marker);
264
+ const { comment: existing, extras } = findManagedComment(target, run, marker);
265
+ const deleteExtras = () => {
266
+ for (const extra of extras ?? []) {
267
+ try {
268
+ run("gh", ["api", `repos/${target.repo}/issues/comments/${extra.id}`, "-X", "DELETE"]);
269
+ }
270
+ catch {
271
+ // Best effort only — a failed delete must never fail the caller's command.
272
+ }
273
+ }
274
+ };
227
275
  if (existing) {
228
276
  run("gh", [
229
277
  "api",
@@ -233,12 +281,15 @@ export function upsertAttachmentsComment(target, body, run = execRunner, marker
233
281
  "-F",
234
282
  "body=@-",
235
283
  ], body);
284
+ deleteExtras();
236
285
  return { action: "updated" };
237
286
  }
238
287
  // Patch-only (createIfMissing false, i.e. an empty body) with no existing
239
288
  // comment: nothing to do — never create one just to say it's empty.
240
289
  if (!createIfMissing)
241
290
  return { action: "skipped" };
291
+ // No existing marker hit means `extras` is necessarily empty here (see
292
+ // `findManagedComment`) — nothing to delete after a create.
242
293
  run("gh", ["api", `repos/${target.repo}/issues/${target.num}/comments`, "-F", "body=@-"], body);
243
294
  return { action: "created" };
244
295
  }
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}/`;
@@ -0,0 +1,14 @@
1
+ export type InstallKind = "global" | "workspace" | "npx" | "unknown";
2
+ export type PackageManager = "npm" | "pnpm" | "bun";
3
+ export interface InstallSource {
4
+ kind: InstallKind;
5
+ /** Falls back to npm for every non-global kind. */
6
+ manager: PackageManager;
7
+ /** Upgrades the global install. Only meaningful when kind is "global". */
8
+ upgradeCommand: string[];
9
+ }
10
+ /**
11
+ * @param modulePath Absolute path of a file inside the installed package,
12
+ * normally `realpathSync(fileURLToPath(import.meta.url))`.
13
+ */
14
+ export declare function detectInstallSource(modulePath: string): InstallSource;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Classify where the running CLI was installed from.
3
+ *
4
+ * `uploads update` upgrades the global npm package. That is only safe when the
5
+ * CLI actually came from a global install — upgrading a workspace checkout
6
+ * would overwrite a developer's build with the published version.
7
+ *
8
+ * Pure and path-only: no filesystem or process access, so it is fully testable.
9
+ */
10
+ import { PACKAGE_NAME } from "./update-check.js";
11
+ const UPGRADE_COMMANDS = {
12
+ npm: ["npm", "install", "-g", `${PACKAGE_NAME}@latest`],
13
+ pnpm: ["pnpm", "add", "-g", `${PACKAGE_NAME}@latest`],
14
+ bun: ["bun", "add", "-g", `${PACKAGE_NAME}@latest`],
15
+ };
16
+ function classify(path) {
17
+ // npx is checked first: a cache entry can also contain a global-looking marker.
18
+ if (path.includes("/_npx/"))
19
+ return { kind: "npx", manager: "npm" };
20
+ if (path.includes("/.bun/install/global/"))
21
+ return { kind: "global", manager: "bun" };
22
+ if (path.includes("/pnpm/global/"))
23
+ return { kind: "global", manager: "pnpm" };
24
+ if (path.includes("/lib/node_modules/"))
25
+ return { kind: "global", manager: "npm" };
26
+ // Windows npm globals have no `lib` segment: `<prefix>\npm\node_modules\<pkg>`.
27
+ if (path.includes("/npm/node_modules/"))
28
+ return { kind: "global", manager: "npm" };
29
+ // No node_modules segment at all means we are running out of a source checkout.
30
+ if (!path.includes("/node_modules/"))
31
+ return { kind: "workspace", manager: "npm" };
32
+ return { kind: "unknown", manager: "npm" };
33
+ }
34
+ /**
35
+ * @param modulePath Absolute path of a file inside the installed package,
36
+ * normally `realpathSync(fileURLToPath(import.meta.url))`.
37
+ */
38
+ export function detectInstallSource(modulePath) {
39
+ const normalized = modulePath.split("\\").join("/");
40
+ const { kind, manager } = classify(normalized);
41
+ return { kind, manager, upgradeCommand: UPGRADE_COMMANDS[manager] };
42
+ }
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,
@@ -1142,7 +1158,10 @@ export function createUploadsMcpTools(opts) {
1142
1158
  if (!target)
1143
1159
  usage("comment requires pr or issue");
1144
1160
  const { config, client } = clientFor(args);
1145
- const result = await syncAttachmentsComment(client, target, run, config.workspace);
1161
+ // Explicit resync, same as `uploads comment` (issue #480).
1162
+ const result = await syncAttachmentsComment(client, target, run, config.workspace, {
1163
+ resync: true,
1164
+ });
1146
1165
  return { ...target, ...result };
1147
1166
  },
1148
1167
  },