@buildinternet/uploads 0.43.0 → 0.44.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
@@ -152,6 +152,7 @@ Options:
152
152
  --no-git Don't derive --repo from git (or UPLOADS_NO_GIT=1)
153
153
  --auto Resolve current PR/issue and stamp gh.* metadata (default on)
154
154
  --no-auto Skip gh.* auto-resolution (also skipped by --no-git or UPLOADS_NO_AUTO_META=1)
155
+ --no-pr Skip auto-PR context (or UPLOADS_NO_AUTO_PR=1) — see below
155
156
  --workspace, -w <name> Override workspace (wins over UPLOADS_WORKSPACE and token inference)
156
157
  --format human|url|markdown|json
157
158
  --pr <num> Attach to a pull request: key gh/<owner>/<repo>/pull/<num>/<name> (stable URL, no hash)
@@ -175,9 +176,17 @@ Options:
175
176
  --dry-run Print key + public URL without uploading; reports if the key would replace
176
177
  (or, on a strict key, be refused). Not with --gallery
177
178
 
178
- A bare put (no --pr/--issue/--key) on a non-default git branch prints a one-line
179
- nudge toward --pr/attach --branch (stderr in human mode, a "hint" field in
180
- --format json). Suppress with --quiet, UPLOADS_NO_NUDGE=1, or config UPLOADS_NO_NUDGE=1.
179
+ A bare put (no --pr/--issue/--key/--ref/--prefix/--destination) on a git branch
180
+ that maps to exactly one open PR now behaves as if --pr <n> had been passed
181
+ (issue #700): stable gh/ key, managed comment sync — instead of the #403
182
+ branch-staging default. A one-line note announces this (stderr in human mode,
183
+ the "hint" field in --format json). Opt out with --no-pr, UPLOADS_NO_AUTO_PR=1,
184
+ or config UPLOADS_NO_AUTO_PR=1; never fires outside a git repo, on the default
185
+ branch, with --no-git, or when no single open PR can be resolved (falls back to
186
+ branch staging, then the plain dated layout). When it doesn't fire and the
187
+ upload lands on the dated layout with a detectable PR, a similar one-line nudge
188
+ names the PR and a ready-made follow-up (uploads attach --pr <n> <key>...).
189
+ Suppress either note with --quiet, UPLOADS_NO_NUDGE=1, or config UPLOADS_NO_NUDGE=1.
181
190
 
182
191
  Exit codes: 0 ok · 2 usage/token/file · 3 auth/policy · 4 network · 1 other (incl. partial multi-file failure).
183
192
  Scripted formats (json|url|markdown) also print failures on stdout.
@@ -647,7 +656,7 @@ export async function syncAttachmentsComment(client, target, run, workspace, opt
647
656
  }
648
657
  // Append only on the local-gh path: bot posts already carry the uploads-sh
649
658
  // bot identity, so this note would be wrong there.
650
- const body = `${attachmentsCommentBody(items, previewGalleries, marker, renderOptions)}\n${GH_FALLBACK_AUTHOR_NOTE}`;
659
+ const body = `${attachmentsCommentBody(items, previewGalleries, marker, renderOptions, target)}\n${GH_FALLBACK_AUTHOR_NOTE}`;
651
660
  const count = items.length + previewGalleries.length;
652
661
  // Empty (count 0) renders the neutral empty-state body but must not create a
653
662
  // comment — it only rewrites one that already exists (`action: "skipped"`
@@ -683,6 +692,15 @@ derived metadata (path/url/env/viewport/state) is merged in automatically —
683
692
  explicit --meta/--state always win. A regenerated or edited file loses its
684
693
  sidecar silently (hash no longer matches).
685
694
 
695
+ An argument that doesn't exist on disk but resolves as an already-uploaded
696
+ object — a bare key (e.g. "f/AbC123/shot.webp") or an uploads.sh URL (storage
697
+ host, embed host, or /f/ page) — attaches via a server-side copy instead of a
698
+ re-upload: the source's own derived metadata (path/url/viewport/state/…)
699
+ rides along, and gh.repo/gh.kind/gh.number/gh.ref are stamped fresh. Copy by
700
+ default; --move deletes the source after a successful copy. A path that
701
+ exists on disk always wins as a local file, even if it also happens to look
702
+ like a key.
703
+
686
704
  Branch staging (pre-PR): --branch [name] stages files against a git branch
687
705
  before a pull request exists, e.g. for a coding agent working a branch that
688
706
  hasn't opened a PR yet. Key: gh/<owner>/<repo>/branch/<branch>/<filename>
@@ -714,6 +732,8 @@ Options:
714
732
  resolved PR and refresh the comment; not with
715
733
  --branch/--issue/--no-promote
716
734
  --no-promote Skip auto-promoting branch-staged attachments (default path only)
735
+ --move With an already-uploaded key/URL argument: delete the source
736
+ object after a successful server-side copy (default: copy)
717
737
  --repo <owner/repo> Repository (default: gh/git inference)
718
738
  --no-comment Upload only; don't create/update the managed comment
719
739
  --content-type <mime> Override Content-Type (applied to every file; ignored when optimize rewrites)
@@ -768,6 +788,57 @@ sentMetadata) {
768
788
  const missingPath = uploads.some((u, i) => u.contentType.startsWith("image/") && !sentMetadata[i]?.path);
769
789
  return missingPath ? "tip: add --meta path=/route so this shot is findable by page" : undefined;
770
790
  }
791
+ /**
792
+ * Bounded fan-out for `uploads attach`'s already-uploaded-object args (issue
793
+ * #702) — attach args are independent server calls (no shared batch state
794
+ * like `uploadAttachments`'s optimize/frame prep), so this stays a thin
795
+ * wrapper rather than a variant of that function.
796
+ */
797
+ const ATTACH_EXISTING_CONCURRENCY = 4;
798
+ async function attachExistingBatch(client, target, sources, move) {
799
+ const outcomes = await mapBounded(sources, ATTACH_EXISTING_CONCURRENCY, async (source) => {
800
+ try {
801
+ const result = await client.attachExisting({
802
+ source,
803
+ repo: target.repo,
804
+ pr: target.kind === "pull" ? target.num : undefined,
805
+ issue: target.kind === "issues" ? target.num : undefined,
806
+ move,
807
+ });
808
+ return { ok: true, source, result };
809
+ }
810
+ catch (err) {
811
+ const message = err instanceof UploadsError && err.code === "NOT_FOUND"
812
+ ? `not a local file, and no such object in this workspace: ${source}`
813
+ : err instanceof Error
814
+ ? err.message
815
+ : String(err);
816
+ return {
817
+ ok: false,
818
+ source,
819
+ cause: err,
820
+ error: {
821
+ message,
822
+ code: err instanceof UploadsError ? err.code : undefined,
823
+ status: err instanceof UploadsError ? err.status : undefined,
824
+ },
825
+ };
826
+ }
827
+ });
828
+ const results = [];
829
+ const failures = [];
830
+ let firstError;
831
+ for (const outcome of outcomes) {
832
+ if (outcome.ok) {
833
+ results.push(outcome.result);
834
+ }
835
+ else {
836
+ failures.push({ file: outcome.source, error: outcome.error });
837
+ firstError ??= outcome.cause;
838
+ }
839
+ }
840
+ return { results, failures, firstError };
841
+ }
771
842
  /**
772
843
  * Shared prepare + put loop for both PR/issue attach (`uploadAttachments`)
773
844
  * and branch-staged attach (`uploadBranchAttachments`) — bounded concurrency,
@@ -1021,6 +1092,9 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
1021
1092
  if (parsed.flags.has("--no-promote") && typeof parsed.flags.get("--no-promote") === "string") {
1022
1093
  throw new UsageError("--no-promote takes no value — place it after the file arguments");
1023
1094
  }
1095
+ if (parsed.flags.has("--move") && typeof parsed.flags.get("--move") === "string") {
1096
+ throw new UsageError("--move takes no value — place it after the file arguments");
1097
+ }
1024
1098
  if (parsed.flags.has("--promote")) {
1025
1099
  if (parsed.positionals.length > 0) {
1026
1100
  throw new UsageError("--promote takes no file arguments — attaching a file to a PR already auto-promotes " +
@@ -1073,23 +1147,61 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
1073
1147
  };
1074
1148
  if (Object.keys(metadata).length > 0)
1075
1149
  validateMetaMap(metadata);
1150
+ // Args that exist on disk are always local files, even if they'd also
1151
+ // parse as a key/URL. Everything else is a candidate for the server-side
1152
+ // attach-existing path (issue #702) — resolved/validated server-side, so a
1153
+ // typo'd path and a genuinely-missing object key report the same way.
1154
+ const localFiles = parsed.positionals.filter((p) => existsSync(p));
1155
+ const remoteArgs = parsed.positionals.filter((p) => !existsSync(p));
1156
+ const moveExisting = parsed.flags.has("--move");
1157
+ if (moveExisting && remoteArgs.length === 0) {
1158
+ throw new UsageError("--move only applies to already-uploaded key/URL arguments");
1159
+ }
1076
1160
  const logHuman = !ctx.quiet && !ctx.json;
1077
- if (logHuman) {
1078
- const n = parsed.positionals.length;
1161
+ if (logHuman && localFiles.length > 0) {
1162
+ const n = localFiles.length;
1079
1163
  process.stderr.write(`>> uploading ${n} file${n === 1 ? "" : "s"}\n`);
1080
1164
  }
1081
- const { uploads, failures, firstError, sentMetadata } = await uploadAttachments({
1082
- client: ctx.client,
1083
- target,
1084
- files: parsed.positionals,
1085
- contentType: contentTypeOverride,
1086
- optimize: optimizeOpts,
1087
- frame: frameOpts,
1088
- metadata,
1089
- deriveImageFacts: derivedMetaEnabled(parsed.flags, defaults),
1090
- });
1091
- // Single-file total failure: rethrow so CLI exit codes stay auth/network-aware.
1092
- if (uploads.length === 0 && failures.length === 1 && parsed.positionals.length === 1) {
1165
+ const uploadResult = localFiles.length > 0
1166
+ ? await uploadAttachments({
1167
+ client: ctx.client,
1168
+ target,
1169
+ files: localFiles,
1170
+ contentType: contentTypeOverride,
1171
+ optimize: optimizeOpts,
1172
+ frame: frameOpts,
1173
+ metadata,
1174
+ deriveImageFacts: derivedMetaEnabled(parsed.flags, defaults),
1175
+ })
1176
+ : { uploads: [], failures: [], firstError: undefined, sentMetadata: [] };
1177
+ const { uploads, sentMetadata } = uploadResult;
1178
+ const localFailures = uploadResult.failures;
1179
+ if (logHuman && remoteArgs.length > 0) {
1180
+ const n = remoteArgs.length;
1181
+ process.stderr.write(`>> attaching ${n} existing object${n === 1 ? "" : "s"}\n`);
1182
+ }
1183
+ const remoteResult = remoteArgs.length > 0
1184
+ ? await attachExistingBatch(ctx.client, target, remoteArgs, moveExisting)
1185
+ : {
1186
+ results: [],
1187
+ failures: [],
1188
+ firstError: undefined,
1189
+ };
1190
+ const { results: attachedExisting, failures: remoteFailures } = remoteResult;
1191
+ const failures = [...localFailures, ...remoteFailures];
1192
+ const firstError = localFailures.length > 0 ? uploadResult.firstError : remoteResult.firstError;
1193
+ // Single-arg total failure: rethrow so CLI exit codes stay auth/network-aware.
1194
+ // The remote-attach path's friendlier not-found message (attachExistingBatch)
1195
+ // wins over the raw client error text, but the original error's class/code
1196
+ // (UploadsError) is preserved so exit-code mapping stays unaffected.
1197
+ if (uploads.length === 0 &&
1198
+ attachedExisting.length === 0 &&
1199
+ failures.length === 1 &&
1200
+ parsed.positionals.length === 1) {
1201
+ const only = failures[0];
1202
+ if (firstError instanceof UploadsError && firstError.message !== only.error.message) {
1203
+ throw new UploadsError(only.error.message, firstError.code, firstError.status);
1204
+ }
1093
1205
  throw firstError instanceof Error ? firstError : new Error(String(firstError));
1094
1206
  }
1095
1207
  // Auto-promote: before the comment sync, best-effort promote this
@@ -1131,6 +1243,7 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
1131
1243
  await writeJson({
1132
1244
  target,
1133
1245
  uploads,
1246
+ attachedExisting,
1134
1247
  failures,
1135
1248
  comment,
1136
1249
  commentError,
@@ -1152,6 +1265,13 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
1152
1265
  const embedLine = result.embedUrl ? `EMBED: ${result.embedUrl}\n` : "";
1153
1266
  await writeStdout(`URL: ${result.url}\n${embedLine}MARKDOWN: ${result.markdown}\n`);
1154
1267
  }
1268
+ for (const attached of attachedExisting) {
1269
+ if (logHuman) {
1270
+ process.stderr.write(`>> ${attached.source.key}: attached${attached.moved ? " (moved)" : ""} as ${attached.key}\n`);
1271
+ }
1272
+ const embedLine = attached.embedUrl ? `EMBED: ${attached.embedUrl}\n` : "";
1273
+ await writeStdout(`URL: ${attached.url}\n${embedLine}`);
1274
+ }
1155
1275
  for (const failure of failures) {
1156
1276
  process.stderr.write(`warning: could not upload ${failure.file}: ${failure.error.message}\n`);
1157
1277
  }
@@ -1347,36 +1467,54 @@ async function runAttachPromoteOnly(ctx, parsed, run) {
1347
1467
  * enough to never be felt as a hang. */
1348
1468
  const PUT_NUDGE_GH_TIMEOUT_MS = 3000;
1349
1469
  /**
1350
- * The bare-put nudge's wording (issue #393): teaches `--pr`/`attach --branch`
1351
- * as an upgrade from a targetless `put`. `pr` present → names the PR;
1352
- * otherwise a generic variant that still points at `--pr <num>`. Used
1353
- * verbatim for both the human-mode stderr line and the JSON `hint` field.
1470
+ * The bare-put nudge's wording (issue #393, made concrete by issue #700):
1471
+ * teaches `--pr`/`attach --branch` as an upgrade from a targetless `put`.
1472
+ * `pr` present names the PR and, once upload `keys` are known, appends a
1473
+ * ready-made follow-up command naming them verbatim (e.g. `uploads attach
1474
+ * --pr 1250 f/abc123.webp`); otherwise a generic variant that still points
1475
+ * at `--pr <num>`. Used verbatim for both the human-mode stderr line and the
1476
+ * JSON `hint` field.
1354
1477
  */
1355
- function putNudgeText(branch, pr) {
1478
+ export function putNudgeText(branch, pr, keys = []) {
1356
1479
  const prClause = pr !== undefined ? ` (PR #${pr} open) — rerun with --pr ${pr}` : ` — rerun with --pr <num>`;
1357
- return (`note: on branch ${branch}${prClause} for a stable key plus a managed comment ` +
1358
- `that collects this PR's media, or stage pre-PR files with: uploads attach <file> --branch`);
1480
+ const base = `note: on branch ${branch}${prClause} for a stable key plus a managed comment ` +
1481
+ `that collects this PR's media, or stage pre-PR files with: uploads attach <file> --branch`;
1482
+ if (pr === undefined || keys.length === 0)
1483
+ return base;
1484
+ return `${base}. Already uploaded? uploads attach --pr ${pr} ${keys.join(" ")}`;
1359
1485
  }
1360
1486
  /**
1361
- * Best-effort bare-put nudge (issue #393): fires only when `put` has no
1362
- * targeting flag at all (`--pr`/`--issue`/`--key`; `--branch` too, though
1487
+ * Auto-PR note (issue #700): announces at the moment it fires that a bare
1488
+ * put/screenshot on this branch was auto-attached to `pr` the default
1489
+ * behavior change this issue introduces — and how to opt out.
1490
+ */
1491
+ export function autoPrNoteText(pr) {
1492
+ return (`note: branch maps to open PR #${pr} — auto-attached (stable key + managed comment sync). ` +
1493
+ `Opt out with --no-pr or UPLOADS_NO_AUTO_PR=1.`);
1494
+ }
1495
+ /**
1496
+ * Best-effort bare-put/screenshot nudge context (issue #393): resolves the
1497
+ * branch and, when detectable, the open PR for it — fires only when there is
1498
+ * no targeting flag at all (`--pr`/`--issue`/`--key`; `--branch` too, though
1363
1499
  * `put` doesn't currently accept it — defensive parity with `attach`), is
1364
1500
  * inside a git repo (reusing `deriveRepoFromGit`, the same detection the
1365
1501
  * default screenshot key's repo segment uses), and the current branch isn't
1366
1502
  * the default one. Never throws — any failure (not a repo, detached HEAD,
1367
1503
  * `gh` missing/unauthenticated/timed out) degrades to "no nudge" or, once a
1368
- * branch is already known, to the generic no-PR wording. Must never affect
1369
- * put's exit code, stdout, or upload behavior.
1504
+ * branch is already known, to a context with `pr: undefined` (the generic
1505
+ * no-PR wording). Must never affect put's exit code, stdout, or upload
1506
+ * behavior. Callers turn the result into text via `putNudgeText`, once any
1507
+ * upload keys are known.
1370
1508
  */
1371
- function resolvePutNudge(opts) {
1372
- const { ctx, flags, ghTarget, keyHint, noGit, defaults, run } = opts;
1373
- if (ctx.quiet)
1509
+ export function resolvePutNudgeContext(opts) {
1510
+ const { quiet, noNudge, ghTarget, keyHint, hasBranchFlag, noGit, repoArg, run } = opts;
1511
+ if (quiet)
1374
1512
  return undefined;
1375
- if (defaults.noNudge)
1513
+ if (noNudge)
1376
1514
  return undefined;
1377
1515
  if (ghTarget || keyHint || noGit)
1378
1516
  return undefined;
1379
- if (flags.has("--branch"))
1517
+ if (hasBranchFlag)
1380
1518
  return undefined; // not a real put flag today; defensive only
1381
1519
  try {
1382
1520
  if (deriveRepoFromGit(run) === undefined)
@@ -1401,19 +1539,69 @@ function resolvePutNudge(opts) {
1401
1539
  // fast/fake, and execFileSync's `timeout` option is meaningless
1402
1540
  // against anything that isn't actually shelling out.
1403
1541
  const timed = run === execRunner ? timedExecRunner(PUT_NUDGE_GH_TIMEOUT_MS) : run;
1404
- const repoArg = flagString(flags, "--repo") ?? defaults.repo;
1405
1542
  const repo = resolveRepo(repoArg, timed);
1406
1543
  pr = resolveCurrentPullRequest(repo, timed).num;
1407
1544
  }
1408
1545
  catch {
1409
1546
  pr = undefined; // gh missing/unauthenticated/timed out/no open PR — generic wording
1410
1547
  }
1411
- return putNudgeText(branch, pr);
1548
+ return { branch, pr };
1412
1549
  }
1413
1550
  catch {
1414
1551
  return undefined;
1415
1552
  }
1416
1553
  }
1554
+ /**
1555
+ * Auto-PR context (issue #700): when a bare put/screenshot has no explicit
1556
+ * destination flag at all (`--pr`/`--issue`/`--key`/`--ref`/`--prefix`/
1557
+ * `--destination`, and for `screenshot` no explicit `--branch`) and runs on a
1558
+ * branch that maps to exactly one open PR, this resolves that PR so the
1559
+ * caller can behave as if `--pr <n>` had been passed — stable key + managed
1560
+ * comment sync — instead of the #403/#469 staging default or the plain dated
1561
+ * layout. `resolveCurrentPullRequest`'s `gh pr view <branch>` lookup is
1562
+ * already the unambiguous case: it names the single open PR whose head is
1563
+ * that branch, or fails (no open PR, or `gh` unavailable/unauthenticated) —
1564
+ * there is no "ambiguous, more than one" state to further disambiguate.
1565
+ * Opt-out: `noAutoPr` (the caller folds in `--no-pr` and
1566
+ * `UPLOADS_NO_AUTO_PR=1`/config). Never fires outside a git checkout, on the
1567
+ * default branch, or with `--no-git`; any failure (not a repo, detached
1568
+ * HEAD, gh missing/unauthenticated/timed out, no open PR) degrades to
1569
+ * undefined so the caller falls back to its normal staging/dated behavior.
1570
+ */
1571
+ export function resolveAutoPrTarget(opts) {
1572
+ const { ghTarget, keyHint, refArg, prefixArg, destinationArg, branchArg, noGit, noAutoPr, repoArg, run, } = opts;
1573
+ if (noAutoPr)
1574
+ return undefined;
1575
+ if (ghTarget || keyHint || noGit)
1576
+ return undefined;
1577
+ if (refArg || prefixArg || destinationArg || branchArg !== undefined)
1578
+ return undefined;
1579
+ try {
1580
+ if (deriveRepoFromGit(run) === undefined)
1581
+ return undefined; // not a (usable) git repo
1582
+ let branch;
1583
+ try {
1584
+ branch = resolveCurrentBranch(run);
1585
+ }
1586
+ catch {
1587
+ return undefined; // detached HEAD, or git unavailable
1588
+ }
1589
+ const defaultBranch = resolveDefaultBranch(run);
1590
+ const onDefaultBranch = defaultBranch
1591
+ ? branch === defaultBranch
1592
+ : branch === "main" || branch === "master"; // undetermined: err toward the old default
1593
+ if (onDefaultBranch)
1594
+ return undefined;
1595
+ // Same bounded-timeout treatment as the #393 nudge's `gh pr view` call —
1596
+ // this must never be felt as a hang.
1597
+ const timed = run === execRunner ? timedExecRunner(PUT_NUDGE_GH_TIMEOUT_MS) : run;
1598
+ const repo = resolveRepo(repoArg, timed);
1599
+ return resolveCurrentPullRequest(repo, timed);
1600
+ }
1601
+ catch {
1602
+ return undefined; // gh/git unavailable, no open PR, or repo unresolvable
1603
+ }
1604
+ }
1417
1605
  /**
1418
1606
  * Bare-put branch-staging trigger (issue #403): put on a non-default git
1419
1607
  * branch stages to the branch prefix by default — the branch becomes the
@@ -1638,10 +1826,39 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1638
1826
  });
1639
1827
  }
1640
1828
  const multi = files.length > 1;
1829
+ // Resolved early (issue #700): both the auto-PR opt-out default and the
1830
+ // `--no-git`-gated staging/auto-PR detection below need it before the rest
1831
+ // of put's flag parsing.
1832
+ const defaults = resolvePutDefaults({ envFile: ctx.envFile });
1833
+ const noGit = flagBool(parsed.flags, "--no-git") || defaults.noGit === true;
1641
1834
  const keyHint = flagString(parsed.flags, "--key");
1642
1835
  const destFlag = flagString(parsed.flags, "--destination");
1643
1836
  const prefixFlag = flagString(parsed.flags, "--prefix");
1644
1837
  const ghTarget = ghTargetFromFlags(parsed.flags, run);
1838
+ if (parsed.flags.has("--no-pr") && typeof parsed.flags.get("--no-pr") === "string") {
1839
+ throw new UsageError("--no-pr takes no value");
1840
+ }
1841
+ const noAutoPr = flagBool(parsed.flags, "--no-pr") || defaults.noAutoPr === true;
1842
+ // Auto-PR context (issue #700): a bare put (no --pr/--issue/--key/--ref/
1843
+ // --prefix/--destination, not --no-git/--no-pr) on a branch that maps to
1844
+ // exactly one open PR behaves as if `--pr <n>` had been passed — see
1845
+ // resolveAutoPrTarget. Supersedes both the #403 staging default and the
1846
+ // #393 nudge for this case; computed before the gh.* metadata resolution
1847
+ // below since it takes over that resolution entirely.
1848
+ const autoPrTarget = ghTarget
1849
+ ? undefined
1850
+ : resolveAutoPrTarget({
1851
+ ghTarget,
1852
+ keyHint,
1853
+ refArg: flagString(parsed.flags, "--ref"),
1854
+ prefixArg: prefixFlag,
1855
+ destinationArg: destFlag,
1856
+ noGit,
1857
+ noAutoPr,
1858
+ repoArg: flagString(parsed.flags, "--repo") ?? defaults.repo,
1859
+ run,
1860
+ });
1861
+ const effectiveGhTarget = ghTarget ?? autoPrTarget;
1645
1862
  // Comment sync runs by default with --pr/--issue (matches `attach`); opt
1646
1863
  // out with --no-comment. --comment is accepted as a redundant no-op for
1647
1864
  // back-compat with scripts written before this default flipped (#537).
@@ -1676,7 +1893,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1676
1893
  if (parsed.flags.has("--no-auto") && typeof parsed.flags.get("--no-auto") === "string") {
1677
1894
  throw new UsageError("--no-auto takes no value");
1678
1895
  }
1679
- if (parsed.flags.has("--no-comment") && !ghTarget) {
1896
+ if (parsed.flags.has("--no-comment") && !effectiveGhTarget) {
1680
1897
  throw new UsageError("--no-comment requires --pr or --issue");
1681
1898
  }
1682
1899
  if (multi) {
@@ -1713,7 +1930,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1713
1930
  destination: destFlag,
1714
1931
  prefix: prefixFlag,
1715
1932
  key: keyHint,
1716
- ghAttachment: Boolean(ghTarget),
1933
+ ghAttachment: Boolean(effectiveGhTarget),
1717
1934
  });
1718
1935
  }
1719
1936
  catch (err) {
@@ -1729,7 +1946,6 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1729
1946
  return raw;
1730
1947
  throw new UsageError(`invalid --format: ${raw}`);
1731
1948
  })();
1732
- const defaults = resolvePutDefaults({ envFile: ctx.envFile });
1733
1949
  const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, defaults);
1734
1950
  const frameOpts = frameOptionsFromFlags(parsed.flags);
1735
1951
  const contentTypeOverride = flagString(parsed.flags, "--content-type");
@@ -1742,16 +1958,16 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1742
1958
  throw new UsageError(`invalid --width: ${widthRaw}`);
1743
1959
  })()
1744
1960
  : defaults.width;
1745
- const noGit = flagBool(parsed.flags, "--no-git") || defaults.noGit === true;
1746
1961
  // Bare-put branch staging (issue #403): a bare put (no --pr/--issue/--key/
1747
1962
  // --ref/--prefix/--destination, not --no-git) on a non-default git branch
1748
1963
  // stages to the branch prefix — identical key/metadata to `attach
1749
1964
  // --branch` — instead of the dated layout. Computed before gh.* metadata
1750
1965
  // resolution below since it takes over that resolution entirely (branch
1751
1966
  // metadata, not PR/issue metadata) and supersedes the #393 nudge for this
1752
- // case.
1967
+ // case. `effectiveGhTarget` (explicit --pr/--issue OR the #700 auto-PR
1968
+ // match) wins over staging, same as it wins over the dated layout.
1753
1969
  const stagingTarget = resolvePutStagingTarget({
1754
- ghTarget,
1970
+ ghTarget: effectiveGhTarget,
1755
1971
  keyHint,
1756
1972
  refArg: flagString(parsed.flags, "--ref"),
1757
1973
  prefixArg: prefixFlag,
@@ -1760,15 +1976,16 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1760
1976
  repoArg: flagString(parsed.flags, "--repo") ?? defaults.repo,
1761
1977
  run,
1762
1978
  });
1763
- // gh.* metadata: explicit --pr/--issue target wins over --meta; staging
1764
- // wins over --meta the same way (matches attach --branch); otherwise
1765
- // best-effort auto resolution (on by default) where --meta wins. --no-git,
1766
- // --no-auto, or UPLOADS_NO_AUTO_META disable auto; --auto forces past the
1767
- // config default but never past --no-git (no repo to resolve).
1979
+ // gh.* metadata: explicit --pr/--issue target (or the #700 auto-PR match)
1980
+ // wins over --meta; staging wins over --meta the same way (matches attach
1981
+ // --branch); otherwise best-effort auto resolution (on by default) where
1982
+ // --meta wins. --no-git, --no-auto, or UPLOADS_NO_AUTO_META disable auto;
1983
+ // --auto forces past the config default but never past --no-git (no repo
1984
+ // to resolve).
1768
1985
  let metadata = userMeta;
1769
1986
  let attachedRef;
1770
- if (ghTarget) {
1771
- const merged = { ...userMeta, ...ghMetadataFromTargetWithTitle(ghTarget, run) };
1987
+ if (effectiveGhTarget) {
1988
+ const merged = { ...userMeta, ...ghMetadataFromTargetWithTitle(effectiveGhTarget, run) };
1772
1989
  validateMetaMap(merged); // enforce 24-key/8KB caps on the merged map (matches attach)
1773
1990
  metadata = merged;
1774
1991
  attachedRef = merged["gh.ref"];
@@ -1818,23 +2035,26 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1818
2035
  // buckets — one advisory line at the moment the context went missing.
1819
2036
  // --no-git is an explicit choice, so it suppresses the nudge too.
1820
2037
  const contextNudge = !ctx.quiet && !defaults.noNudge && !noGit ? noProjectContextNudge(metadata) : undefined;
1821
- // Bare-put nudge (issue #393): only relevant when staging didn't take over
1822
- // — once `stagingTarget` resolves, staging IS the upgrade the nudge used to
1823
- // point at, so this is skipped entirely rather than firing redundantly.
1824
- // Still fires as before for a bare put that lands on the dated layout with
1825
- // a detectable PR (e.g. an explicit --ref/--prefix opts out of staging).
1826
- // Computed once, used for both the trailing stderr line (human mode) and
1827
- // the JSON `hint` field below. Best-effort see resolvePutNudge; never
1828
- // affects exit code, stdout, or the upload.
1829
- const nudge = stagingTarget
2038
+ // Bare-put nudge (issue #393): only relevant when neither auto-PR nor
2039
+ // staging took over — once `effectiveGhTarget`/`stagingTarget` resolves,
2040
+ // that IS the upgrade the nudge used to point at, so this is skipped
2041
+ // entirely rather than firing redundantly. Still fires as before for a
2042
+ // bare put that lands on the dated layout with a detectable PR (e.g. an
2043
+ // explicit --ref/--prefix opts out of staging AND auto-PR, or --no-pr/
2044
+ // UPLOADS_NO_AUTO_PR opts out of auto-PR specifically). The concrete
2045
+ // key-naming text (issue #700) is finished below, once upload keys exist.
2046
+ // Best-effort see resolvePutNudgeContext; never affects exit code,
2047
+ // stdout, or the upload.
2048
+ const nudgeContext = effectiveGhTarget || stagingTarget
1830
2049
  ? undefined
1831
- : resolvePutNudge({
1832
- ctx,
1833
- flags: parsed.flags,
2050
+ : resolvePutNudgeContext({
2051
+ quiet: ctx.quiet,
2052
+ noNudge: defaults.noNudge === true,
1834
2053
  ghTarget,
1835
2054
  keyHint,
2055
+ hasBranchFlag: parsed.flags.has("--branch"),
1836
2056
  noGit,
1837
- defaults,
2057
+ repoArg: flagString(parsed.flags, "--repo") ?? defaults.repo,
1838
2058
  run,
1839
2059
  });
1840
2060
  // Staging note (issue #403): same suppression as the #393 nudge
@@ -1843,6 +2063,12 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1843
2063
  const stagingNote = stagingTarget && !ctx.quiet && !defaults.noNudge
1844
2064
  ? putStagingNoteText(stagingTarget.branch)
1845
2065
  : undefined;
2066
+ // Auto-PR note (issue #700): announces the default-behavior change at the
2067
+ // moment it fires, so a bare put that silently became a --pr attach isn't
2068
+ // a surprise — names the PR and how to opt out. Same suppression as the
2069
+ // other advisories (--quiet, UPLOADS_NO_NUDGE=1); NOT gated by --no-pr/
2070
+ // UPLOADS_NO_AUTO_PR since those are what prevent it from firing at all.
2071
+ const autoPrNote = autoPrTarget && !ctx.quiet && !defaults.noNudge ? autoPrNoteText(autoPrTarget.num) : undefined;
1846
2072
  const logHuman = !ctx.quiet && format === "human";
1847
2073
  if (logHuman) {
1848
2074
  if (multi) {
@@ -1860,7 +2086,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1860
2086
  files,
1861
2087
  nameOverride: nameFlag,
1862
2088
  explicitKey: keyHint,
1863
- ghTarget,
2089
+ ghTarget: effectiveGhTarget,
1864
2090
  ghBranchTarget: stagingTarget,
1865
2091
  prefix: resolvedPrefix ?? defaults.prefix,
1866
2092
  repo: flagString(parsed.flags, "--repo") ?? defaults.repo,
@@ -1880,6 +2106,13 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1880
2106
  if (uploads.length === 0 && failures.length > 0 && !multi) {
1881
2107
  throw firstError instanceof Error ? firstError : new Error(String(firstError));
1882
2108
  }
2109
+ // Concrete bare-put nudge text (issue #700): built once upload keys exist,
2110
+ // so the ready-made follow-up names them, e.g.
2111
+ // "uploads attach --pr 1250 f/abc123.webp". Falls back to the plain
2112
+ // #393 wording when there are no successful uploads to name.
2113
+ const nudge = nudgeContext && uploads.length > 0
2114
+ ? putNudgeText(nudgeContext.branch, nudgeContext.pr, uploads.map((u) => u.key))
2115
+ : undefined;
1883
2116
  // Stage-time binding warning (issue #398/#400): same check `attach
1884
2117
  // --branch` runs, now also on the bare-put staging path. Best-effort — see
1885
2118
  // resolveStageBindingWarning; never affects exit code or the upload.
@@ -1887,20 +2120,19 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1887
2120
  ? await resolveStageBindingWarning({ ctx, defaults, repo: stagingTarget.repo })
1888
2121
  : undefined;
1889
2122
  // Lever 3 (issue #469): tip when a --pr/--issue put lands an image with no
1890
- // `path` meta. Only relevant on the ghTarget path — the bare-put paths
1891
- // above (staging/auto/dated) aren't attached to a PR/issue yet, so there's
1892
- // nothing to look up from a page later.
1893
- const pathHint = ghTarget && uploads.length > 0 && !ctx.quiet
2123
+ // `path` meta. Only relevant on the (explicit or auto) gh target path — the
2124
+ // bare-put staging/dated paths aren't attached to a PR/issue yet, so
2125
+ // there's nothing to look up from a page later.
2126
+ const pathHint = effectiveGhTarget && uploads.length > 0 && !ctx.quiet
1894
2127
  ? pathMetaHintFor(uploads, sentMetadata)
1895
2128
  : undefined;
1896
- // One JSON `hint` slot, shared with the #393 nudge (mutually exclusive with
1897
- // it nudge is undefined whenever staging took over). When staging fires,
1898
- // prefer the more actionable binding warning over the generic staging note
1899
- // (mirrors attach --branch, whose only JSON hint content IS the binding
1900
- // warning); stderr prints the nudge/staging-note and binding-warning lines
1901
- // independently, below. pathHint only ever fires on the ghTarget path, so
1902
- // it never competes with the other three.
1903
- const jsonHint = nudge ?? bindingWarning ?? stagingNote ?? pathHint ?? contextNudge;
2129
+ // One JSON `hint` slot, shared across every advisory this command can
2130
+ // surface. `autoPrNote` and `nudge` are mutually exclusive with each other
2131
+ // and with `stagingNote` (each corresponds to a different destination the
2132
+ // upload landed on); pathHint only ever fires on the gh-target path, so it
2133
+ // never competes with the other three. Same precedence stderr prints,
2134
+ // below.
2135
+ const jsonHint = autoPrNote ?? nudge ?? bindingWarning ?? stagingNote ?? pathHint ?? contextNudge;
1904
2136
  const galleriesByKey = new Map();
1905
2137
  let galleryHadError = false;
1906
2138
  if (galleryId && uploads.length > 0) {
@@ -1923,9 +2155,9 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1923
2155
  }
1924
2156
  let comment;
1925
2157
  let commentError;
1926
- if (wantComment && ghTarget && !dryRun && uploads.length > 0) {
2158
+ if (wantComment && effectiveGhTarget && !dryRun && uploads.length > 0) {
1927
2159
  try {
1928
- comment = await syncAttachmentsComment(ctx.client, ghTarget, run, ctx.config.workspace);
2160
+ comment = await syncAttachmentsComment(ctx.client, effectiveGhTarget, run, ctx.config.workspace);
1929
2161
  if (logHuman)
1930
2162
  process.stderr.write(`>> attachments comment ${comment.action}${commentViaSuffix(comment.via)}\n`);
1931
2163
  }
@@ -1977,6 +2209,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1977
2209
  for (const failure of failures) {
1978
2210
  process.stderr.write(`warning: could not upload ${failure.file}: ${failure.error.message}\n`);
1979
2211
  }
2212
+ if (autoPrNote)
2213
+ process.stderr.write(`${autoPrNote}\n`);
1980
2214
  if (nudge)
1981
2215
  process.stderr.write(`${nudge}\n`);
1982
2216
  if (stagingNote)
@@ -2040,6 +2274,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
2040
2274
  if (gallery?.error) {
2041
2275
  process.stderr.write(`warning: upload succeeded but adding it to gallery ${gallery.id} failed: ${gallery.error.message}\n`);
2042
2276
  }
2277
+ if (autoPrNote && format !== "json")
2278
+ process.stderr.write(`${autoPrNote}\n`);
2043
2279
  if (nudge && format !== "json")
2044
2280
  process.stderr.write(`${nudge}\n`);
2045
2281
  if (stagingNote && format !== "json")
@@ -13,6 +13,7 @@ export interface RepoCommentConfig {
13
13
  note?: string;
14
14
  ingestGithubAttachments?: boolean;
15
15
  ingestBotAttachments?: boolean;
16
+ adoptLinkedFiles?: boolean;
16
17
  }
17
18
  export interface WorkspaceCommentDefaults {
18
19
  imageWidth?: "full" | number;
@@ -22,6 +23,7 @@ export interface WorkspaceCommentDefaults {
22
23
  note?: string;
23
24
  ingestGithubAttachments?: boolean;
24
25
  ingestBotAttachments?: boolean;
26
+ adoptLinkedFiles?: boolean;
25
27
  }
26
28
  export interface ResolvedCommentOptions {
27
29
  imageWidth: "auto" | "full" | number;
@@ -32,6 +34,7 @@ export interface ResolvedCommentOptions {
32
34
  note: string | null;
33
35
  ingestGithubAttachments: boolean;
34
36
  ingestBotAttachments: boolean;
37
+ adoptLinkedFiles: boolean;
35
38
  }
36
39
  export type OptionSource = "repo" | "workspace" | "auto";
37
40
  export declare const AUTO_COMMENT_OPTIONS: ResolvedCommentOptions;