@buildinternet/uploads 0.23.0 → 0.24.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
@@ -94,6 +94,14 @@ attachments when one opens: automatically via the
94
94
  workspace↔repo binding the webhook path uses. Promoted staging is cleaned up
95
95
  server-side after ~7 days (~30 for branches that never got a PR).
96
96
 
97
+ **Bare `put` stages too, by default (issue #403):** on a non-default git
98
+ branch, a `put` with none of
99
+ `--pr`/`--issue`/`--key`/`--ref`/`--prefix`/`--destination` set
100
+ (and not `--no-git`) stages exactly like `attach --branch` — same key,
101
+ same `gh.*` metadata. The classic dated layout
102
+ (`<prefix>/<repo>/<ref-or-date>/<name>`) remains the default branch/detached
103
+ HEAD/non-repo/`--no-git` behavior, and the explicit-flags opt-out.
104
+
97
105
  **Screenshot capture:** `uploads screenshot <url|file.html>` renders a page to a
98
106
  hosted image in one step — no separate browser tooling needed. `--via auto`
99
107
  (default) drives a Chrome/Chromium already on the machine (`playwright-core`
@@ -114,6 +114,10 @@ export const ROOT_COMMANDS = [
114
114
  summary: "Upload (+ URL + markdown for GitHub)",
115
115
  essential: true,
116
116
  },
117
+ {
118
+ name: "staged",
119
+ summary: "Show what's staged for a branch, and whether it will auto-attach",
120
+ },
117
121
  {
118
122
  name: "screenshot",
119
123
  usage: "screenshot <target>",
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import { UploadsError } from "./errors.js";
4
4
  import { commandWorkspace, flagString, isHelpFlag, parseArgv, parseCommandArgs, UsageError, } from "./cli-args.js";
5
5
  import { formatRootHelp, wantsFullHelp } from "./cli-help.js";
6
6
  import { colorEnabled, createStyle } from "./cli-style.js";
7
- import { runPut, runAttach, runList, runFind, runMeta, runDelete, runHealth, runDoctor, runComment, runGithub, runUsage, runReconcile, runPurgeExpired, runGallery, } from "./commands.js";
7
+ import { runPut, runAttach, runStaged, runList, runFind, runMeta, runDelete, runHealth, runDoctor, runComment, runGithub, runUsage, runReconcile, runPurgeExpired, runGallery, } from "./commands.js";
8
8
  import { runConfig } from "./commands/config.js";
9
9
  import { runSetup } from "./commands/setup.js";
10
10
  import { runLogin } from "./commands/login.js";
@@ -269,6 +269,7 @@ export async function runCli(argv) {
269
269
  break;
270
270
  case "attach":
271
271
  case "put":
272
+ case "staged":
272
273
  case "screenshot":
273
274
  case "gallery":
274
275
  case "list":
@@ -289,6 +290,9 @@ export async function runCli(argv) {
289
290
  case "put":
290
291
  code = await runPut(ctx, cmdArgs, showHelp);
291
292
  break;
293
+ case "staged":
294
+ code = await runStaged(ctx, cmdArgs, showHelp);
295
+ break;
292
296
  case "screenshot":
293
297
  code = await runScreenshot(ctx, cmdArgs, showHelp);
294
298
  break;
package/dist/client.d.ts CHANGED
@@ -249,6 +249,16 @@ export interface GithubLinkUnlinkResult {
249
249
  unlinked: boolean;
250
250
  reason?: "not_linked";
251
251
  }
252
+ /**
253
+ * `GET /v1/:workspace/github/repo-link` result (issue #398). Deliberately
254
+ * minimal relative to `GithubLinkResult`: never names the owning workspace
255
+ * when it isn't this one — "self"/"other"/"none" is all the stage-time
256
+ * warning needs, and anything richer would leak cross-tenant info to a
257
+ * caller that's just probing a repo it detected from local git context.
258
+ */
259
+ export interface GithubRepoLinkResult {
260
+ binding: "self" | "other" | "none";
261
+ }
252
262
  export interface HealthResult {
253
263
  ok: boolean;
254
264
  }
@@ -528,6 +538,14 @@ export declare function createUploadsClient(config: UploadsClientConfig): {
528
538
  * server without this route.
529
539
  */
530
540
  githubLinkClaim(repo: string): Promise<GithubLinkClaimResult>;
541
+ /**
542
+ * Tri-state binding status for `repo` relative to this workspace (issue
543
+ * #398, `attach --branch`'s stage-time warning) — never names another
544
+ * workspace, unlike `githubLinkStatus` above. Throws `UploadsError`
545
+ * (status 404) on an older/self-hosted server without this route; the
546
+ * caller (the stage warning) treats ANY failure here as "stay silent".
547
+ */
548
+ githubRepoLinkStatus(repo: string): Promise<GithubRepoLinkResult>;
531
549
  /**
532
550
  * GitHub App configuration + webhook event subscription check. Throws
533
551
  * `UploadsError` (status 404) on an older/self-hosted server without
package/dist/client.js CHANGED
@@ -516,6 +516,16 @@ export function createUploadsClient(config) {
516
516
  headers: { "Content-Type": "application/json" },
517
517
  });
518
518
  },
519
+ /**
520
+ * Tri-state binding status for `repo` relative to this workspace (issue
521
+ * #398, `attach --branch`'s stage-time warning) — never names another
522
+ * workspace, unlike `githubLinkStatus` above. Throws `UploadsError`
523
+ * (status 404) on an older/self-hosted server without this route; the
524
+ * caller (the stage warning) treats ANY failure here as "stay silent".
525
+ */
526
+ async githubRepoLinkStatus(repo) {
527
+ return request("GET", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/repo-link?repo=${encodeURIComponent(repo)}`);
528
+ },
519
529
  /**
520
530
  * GitHub App configuration + webhook event subscription check. Throws
521
531
  * `UploadsError` (status 404) on an older/self-hosted server without
@@ -82,6 +82,13 @@ export interface UploadPreparedImageOptions {
82
82
  optimize: OptimizeImageOptions;
83
83
  /** gh attachment key wins over `key` when both are set (matches every call site). */
84
84
  ghTarget?: GhTarget;
85
+ /**
86
+ * Branch-staged key (issue #403): wins over `key`, loses to `ghTarget` (the
87
+ * two are mutually exclusive at every call site — a PR/issue target always
88
+ * implies staging is moot). Produces the exact same key as `attach
89
+ * --branch` for the same filename via `ghBranchAttachmentKey`.
90
+ */
91
+ ghBranchTarget?: BranchTarget;
85
92
  key?: string;
86
93
  prefix?: string;
87
94
  repo?: string;
@@ -258,6 +265,8 @@ export declare function uploadPuts(opts: {
258
265
  /** Single-file --key. */
259
266
  explicitKey?: string;
260
267
  ghTarget?: GhTarget;
268
+ /** Branch-staged key (issue #403) — see UploadPreparedImageOptions.ghBranchTarget. */
269
+ ghBranchTarget?: BranchTarget;
261
270
  prefix?: string;
262
271
  repo?: string;
263
272
  ref?: string;
@@ -290,6 +299,106 @@ export declare function uploadPuts(opts: {
290
299
  firstError?: unknown;
291
300
  }>;
292
301
  export declare function runAttach(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
302
+ /**
303
+ * One source of truth for the "staged, but not going to auto-attach" advisory
304
+ * text (issue #398), shared by the `attach --branch`/bare-`put` stage-time
305
+ * warning below and the `uploads staged` view (issue #405) — both surfaces
306
+ * must say the exact same thing for the same binding state, verified by
307
+ * tests on both call sites. Returns undefined for `"self"` (the happy path —
308
+ * callers each phrase that themselves) and any unrecognized value.
309
+ */
310
+ export declare function stagingBindingAdvisory(binding: string, repo: string): string | undefined;
311
+ /**
312
+ * Best-effort stage-time binding warning (issue #398): after `attach
313
+ * --branch` stages files, checks whether `repo` is bound to THIS workspace —
314
+ * webhook auto-promotion at PR open only fires for a repo already bound
315
+ * (#297), and staging alone never binds one. Fires only for `binding: "none"`
316
+ * (unbound) or `"other"` (bound elsewhere); `"self"` and any failure
317
+ * (network, non-200, older server without the route, `binding: "unknown"`)
318
+ * are silent — this is advisory only and must never make staging look like
319
+ * it failed. Same suppression as the #393 put nudge: `--quiet`,
320
+ * `UPLOADS_NO_NUDGE=1` (env or config).
321
+ */
322
+ export declare function resolveStageBindingWarning(opts: {
323
+ ctx: CliContext;
324
+ defaults: PutDefaults;
325
+ repo: string;
326
+ }): Promise<string | undefined>;
327
+ /**
328
+ * Bare-put branch-staging trigger (issue #403): put on a non-default git
329
+ * branch stages to the branch prefix by default — the branch becomes the
330
+ * organizing unit instead of the date, superseding the #393 CLI nudge for
331
+ * this case (see `putStagingNoteText`). Reuses the same detection stack as
332
+ * `resolvePutNudge` (`deriveRepoFromGit` / `resolveCurrentBranch` /
333
+ * `resolveDefaultBranch` / main-master fallback) plus a `resolveRepo` lookup
334
+ * (needed for the "owner/name" staging key) and an explicit-flag opt-out:
335
+ * `ghTarget`/`keyHint`/`refArg`/`prefixArg`/`destinationArg` set, or `noGit`,
336
+ * forces the classic dated (or typed-destination) layout. Never throws — any
337
+ * failure (not a repo, detached HEAD, gh missing/unauthenticated/timed out,
338
+ * unresolvable repo) degrades to "no staging", leaving the caller to fall
339
+ * back to the dated path.
340
+ *
341
+ * Plain-params (not CLI `flags`) so both `runPut` and the local stdio MCP
342
+ * `put` tool — same staging default, issue #403's scope — can call this
343
+ * without either depending on the other's argument shape.
344
+ */
345
+ export declare function resolvePutStagingTarget(opts: {
346
+ ghTarget: GhTarget | undefined;
347
+ keyHint: string | undefined;
348
+ refArg: string | undefined;
349
+ prefixArg: string | undefined;
350
+ /** Explicit `--destination` (CLI) / `destination` (MCP) also opts out — it
351
+ * resolves to its own prefix via `resolvePutPrefix`, which staging would
352
+ * otherwise silently override. */
353
+ destinationArg: string | undefined;
354
+ noGit: boolean;
355
+ repoArg: string | undefined;
356
+ run: CommandRunner;
357
+ }): BranchTarget | undefined;
358
+ /**
359
+ * The bare-put staging note's wording (issue #403): replaces the #393 nudge
360
+ * for the (now default) case where a bare put on a non-default branch stages
361
+ * to the branch prefix instead of landing on the dated layout. Used verbatim
362
+ * for both the human-mode stderr line and the JSON `hint` field.
363
+ */
364
+ export declare function putStagingNoteText(branch: string): string;
365
+ /** One file currently staged for a branch (issue #405). */
366
+ export interface StagedFile {
367
+ /** Full object key (`gh/<owner>/<repo>/branch/<branch>/<filename>`). */
368
+ key: string;
369
+ /** `key` with the staging prefix stripped. */
370
+ filename: string;
371
+ size?: number;
372
+ /** `gh.staged-at` metadata (ISO 8601 UTC), when present. */
373
+ stagedAt?: string;
374
+ url: string | null;
375
+ }
376
+ /** Tri-state binding, folded into a ready-to-render advisory (issue #405/#398). */
377
+ export interface StagedBinding {
378
+ state: "self" | "other" | "none" | "unknown";
379
+ /** True only for "self" — the only state where staged files actually auto-attach. */
380
+ autoAttach: boolean;
381
+ message: string;
382
+ }
383
+ export interface StagedResult {
384
+ repo: string;
385
+ branch: string;
386
+ files: StagedFile[];
387
+ binding: StagedBinding;
388
+ }
389
+ /**
390
+ * Shared core for `uploads staged` (CLI) and the `staged` MCP tool (issue
391
+ * #405): one `list` call against the branch staging prefix
392
+ * (`ghBranchKeyPrefix` — never hand-built) plus the #398 binding check. Never
393
+ * throws on the binding check (see `resolveStagedBinding`); a failed `list`
394
+ * call still propagates, same as every other read command.
395
+ */
396
+ export declare function resolveStaged(opts: {
397
+ client: UploadsClient;
398
+ repo: string;
399
+ branch: string;
400
+ }): Promise<StagedResult>;
401
+ export declare function runStaged(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
293
402
  export declare function runPut(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
294
403
  export declare function runGallery(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
295
404
  export declare function runList(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
package/dist/commands.js CHANGED
@@ -11,7 +11,7 @@ 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, ghKeyPrefix, ghMetadataFromTarget, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, normalizeGithubCoordinate, } from "./github.js";
14
+ import { ghAttachmentKey, ghBranchAttachmentKey, ghBranchKeyPrefix, ghKeyPrefix, ghMetadataFromTarget, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, normalizeGithubCoordinate, } from "./github.js";
15
15
  import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, resolveDefaultBranch, classifyGhNumber, execRunner, timedExecRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, } from "./github-gh.js";
16
16
  import { deriveRepoFromGit } from "./keys.js";
17
17
  import { resolvePutPrefix } from "./destinations.js";
@@ -373,7 +373,11 @@ export async function uploadPreparedImage(client, bytes, sourceName, opts) {
373
373
  frameFit: opts.frame.frameFit,
374
374
  optimize: opts.optimize,
375
375
  });
376
- let key = opts.ghTarget ? ghAttachmentKey(opts.ghTarget, prepared.filename) : opts.key;
376
+ let key = opts.ghTarget
377
+ ? ghAttachmentKey(opts.ghTarget, prepared.filename)
378
+ : opts.ghBranchTarget
379
+ ? ghBranchAttachmentKey(opts.ghBranchTarget.repo, opts.ghBranchTarget.branch, prepared.filename)
380
+ : opts.key;
377
381
  if (key && prepared.optimized)
378
382
  key = rewriteKeyExtension(key, prepared.filename);
379
383
  const result = await client.put(prepared.bytes, {
@@ -743,6 +747,7 @@ export async function uploadPuts(opts) {
743
747
  frame: opts.frame,
744
748
  optimize: opts.optimize,
745
749
  ghTarget: opts.ghTarget,
750
+ ghBranchTarget: opts.ghBranchTarget,
746
751
  key: opts.explicitKey,
747
752
  prefix: opts.prefix,
748
753
  repo: opts.repo,
@@ -1009,8 +1014,17 @@ async function runAttachBranch(ctx, parsed, branch, run) {
1009
1014
  if (uploads.length === 0 && failures.length === 1 && parsed.positionals.length === 1) {
1010
1015
  throw firstError instanceof Error ? firstError : new Error(String(firstError));
1011
1016
  }
1017
+ // Stage-time binding warning (issue #398): only worth checking once staging
1018
+ // actually produced something to warn about. Best-effort — see
1019
+ // resolveStageBindingWarning; never affects exit code or the upload itself.
1020
+ const bindingWarning = uploads.length > 0 ? await resolveStageBindingWarning({ ctx, defaults, repo }) : undefined;
1012
1021
  if (ctx.json) {
1013
- await writeJson({ target, uploads, failures });
1022
+ await writeJson({
1023
+ target,
1024
+ uploads,
1025
+ failures,
1026
+ ...(bindingWarning ? { hint: bindingWarning } : {}),
1027
+ });
1014
1028
  }
1015
1029
  else {
1016
1030
  for (const result of uploads) {
@@ -1034,9 +1048,58 @@ async function runAttachBranch(ctx, parsed, branch, run) {
1034
1048
  process.stderr.write(`>> staged: these auto-attach to this branch's PR when it opens ` +
1035
1049
  `(or run \`uploads attach --promote\` after opening)\n`);
1036
1050
  }
1051
+ if (bindingWarning)
1052
+ process.stderr.write(`${bindingWarning}\n`);
1037
1053
  }
1038
1054
  return failures.length === 0 ? 0 : 1;
1039
1055
  }
1056
+ /**
1057
+ * One source of truth for the "staged, but not going to auto-attach" advisory
1058
+ * text (issue #398), shared by the `attach --branch`/bare-`put` stage-time
1059
+ * warning below and the `uploads staged` view (issue #405) — both surfaces
1060
+ * must say the exact same thing for the same binding state, verified by
1061
+ * tests on both call sites. Returns undefined for `"self"` (the happy path —
1062
+ * callers each phrase that themselves) and any unrecognized value.
1063
+ */
1064
+ export function stagingBindingAdvisory(binding, repo) {
1065
+ switch (binding) {
1066
+ case "none":
1067
+ return (`staged, but ${repo} isn't linked to your workspace yet — staged files only ` +
1068
+ `auto-attach on PR open for linked repos. Link it once with: uploads attach <file> ` +
1069
+ `(on any PR) or uploads github link. After the PR opens: uploads attach --promote`);
1070
+ case "other":
1071
+ return (`staged, but ${repo} is linked to a different workspace — these files won't ` +
1072
+ `auto-attach from here.`);
1073
+ default:
1074
+ return undefined; // "self", or any unrecognized value
1075
+ }
1076
+ }
1077
+ /**
1078
+ * Best-effort stage-time binding warning (issue #398): after `attach
1079
+ * --branch` stages files, checks whether `repo` is bound to THIS workspace —
1080
+ * webhook auto-promotion at PR open only fires for a repo already bound
1081
+ * (#297), and staging alone never binds one. Fires only for `binding: "none"`
1082
+ * (unbound) or `"other"` (bound elsewhere); `"self"` and any failure
1083
+ * (network, non-200, older server without the route, `binding: "unknown"`)
1084
+ * are silent — this is advisory only and must never make staging look like
1085
+ * it failed. Same suppression as the #393 put nudge: `--quiet`,
1086
+ * `UPLOADS_NO_NUDGE=1` (env or config).
1087
+ */
1088
+ export async function resolveStageBindingWarning(opts) {
1089
+ const { ctx, defaults, repo } = opts;
1090
+ if (ctx.quiet)
1091
+ return undefined;
1092
+ if (defaults.noNudge)
1093
+ return undefined;
1094
+ try {
1095
+ const { binding } = await ctx.client.githubRepoLinkStatus(repo);
1096
+ const advisory = stagingBindingAdvisory(binding, repo);
1097
+ return advisory ? `note: ${advisory}` : undefined;
1098
+ }
1099
+ catch {
1100
+ return undefined; // any failure (network, non-200, older server) — stay silent
1101
+ }
1102
+ }
1040
1103
  /**
1041
1104
  * `attach --promote` with zero file arguments: resolve the PR target (same
1042
1105
  * resolution as the default `runAttach` path), promote this workspace's
@@ -1156,6 +1219,191 @@ function resolvePutNudge(opts) {
1156
1219
  return undefined;
1157
1220
  }
1158
1221
  }
1222
+ /**
1223
+ * Bare-put branch-staging trigger (issue #403): put on a non-default git
1224
+ * branch stages to the branch prefix by default — the branch becomes the
1225
+ * organizing unit instead of the date, superseding the #393 CLI nudge for
1226
+ * this case (see `putStagingNoteText`). Reuses the same detection stack as
1227
+ * `resolvePutNudge` (`deriveRepoFromGit` / `resolveCurrentBranch` /
1228
+ * `resolveDefaultBranch` / main-master fallback) plus a `resolveRepo` lookup
1229
+ * (needed for the "owner/name" staging key) and an explicit-flag opt-out:
1230
+ * `ghTarget`/`keyHint`/`refArg`/`prefixArg`/`destinationArg` set, or `noGit`,
1231
+ * forces the classic dated (or typed-destination) layout. Never throws — any
1232
+ * failure (not a repo, detached HEAD, gh missing/unauthenticated/timed out,
1233
+ * unresolvable repo) degrades to "no staging", leaving the caller to fall
1234
+ * back to the dated path.
1235
+ *
1236
+ * Plain-params (not CLI `flags`) so both `runPut` and the local stdio MCP
1237
+ * `put` tool — same staging default, issue #403's scope — can call this
1238
+ * without either depending on the other's argument shape.
1239
+ */
1240
+ export function resolvePutStagingTarget(opts) {
1241
+ const { ghTarget, keyHint, refArg, prefixArg, destinationArg, noGit, repoArg, run } = opts;
1242
+ if (ghTarget || keyHint || noGit)
1243
+ return undefined;
1244
+ if (refArg || prefixArg || destinationArg)
1245
+ return undefined;
1246
+ try {
1247
+ if (deriveRepoFromGit(run) === undefined)
1248
+ return undefined; // not a (usable) git repo
1249
+ let branch;
1250
+ try {
1251
+ branch = resolveCurrentBranch(run);
1252
+ }
1253
+ catch {
1254
+ return undefined; // detached HEAD, or git unavailable
1255
+ }
1256
+ const defaultBranch = resolveDefaultBranch(run);
1257
+ const onDefaultBranch = defaultBranch
1258
+ ? branch === defaultBranch
1259
+ : branch === "main" || branch === "master"; // undetermined: err toward the dated layout
1260
+ if (onDefaultBranch)
1261
+ return undefined;
1262
+ // Same bounded-timeout treatment as the #393 nudge's `gh pr view` call —
1263
+ // this is best-effort, and must never be felt as a hang.
1264
+ const timed = run === execRunner ? timedExecRunner(PUT_NUDGE_GH_TIMEOUT_MS) : run;
1265
+ const repo = resolveRepo(repoArg, timed);
1266
+ return { repo, branch };
1267
+ }
1268
+ catch {
1269
+ return undefined; // gh/git unavailable, or repo unresolvable — dated layout
1270
+ }
1271
+ }
1272
+ /**
1273
+ * The bare-put staging note's wording (issue #403): replaces the #393 nudge
1274
+ * for the (now default) case where a bare put on a non-default branch stages
1275
+ * to the branch prefix instead of landing on the dated layout. Used verbatim
1276
+ * for both the human-mode stderr line and the JSON `hint` field.
1277
+ */
1278
+ export function putStagingNoteText(branch) {
1279
+ return (`note: staged for branch ${branch} — auto-attaches to this branch's PR when it opens ` +
1280
+ `(or run: uploads attach --promote once it exists). Use --ref/--prefix for a plain dated upload.`);
1281
+ }
1282
+ /**
1283
+ * Binding lookup for `uploads staged`, folded into a renderable `StagedBinding`.
1284
+ * `"none"`/`"other"` reuse `stagingBindingAdvisory` — the exact #398 wording,
1285
+ * one source of truth. `"self"` gets its own message (the #398 warning stays
1286
+ * silent on "self"; this view is the one place that names the happy path
1287
+ * explicitly). Any failure (network, non-200, older server without the
1288
+ * route) degrades to `"unknown"` rather than throwing — this is a read-only
1289
+ * view and a binding check failing must never make it fail outright.
1290
+ */
1291
+ async function resolveStagedBinding(client, repo) {
1292
+ try {
1293
+ const { binding } = await client.githubRepoLinkStatus(repo);
1294
+ switch (binding) {
1295
+ case "self":
1296
+ return {
1297
+ state: "self",
1298
+ autoAttach: true,
1299
+ message: "these auto-attach when this branch's PR opens",
1300
+ };
1301
+ case "none":
1302
+ case "other":
1303
+ return {
1304
+ state: binding,
1305
+ autoAttach: false,
1306
+ // stagingBindingAdvisory is total for "none"/"other" — never undefined here.
1307
+ message: stagingBindingAdvisory(binding, repo),
1308
+ };
1309
+ default:
1310
+ return { state: "unknown", autoAttach: false, message: "binding status unrecognized" };
1311
+ }
1312
+ }
1313
+ catch {
1314
+ return {
1315
+ state: "unknown",
1316
+ autoAttach: false,
1317
+ message: "could not check binding status (offline, or an older server without this route)",
1318
+ };
1319
+ }
1320
+ }
1321
+ /**
1322
+ * Shared core for `uploads staged` (CLI) and the `staged` MCP tool (issue
1323
+ * #405): one `list` call against the branch staging prefix
1324
+ * (`ghBranchKeyPrefix` — never hand-built) plus the #398 binding check. Never
1325
+ * throws on the binding check (see `resolveStagedBinding`); a failed `list`
1326
+ * call still propagates, same as every other read command.
1327
+ */
1328
+ export async function resolveStaged(opts) {
1329
+ const { client, repo, branch } = opts;
1330
+ const prefix = ghBranchKeyPrefix(repo, branch);
1331
+ const [list, binding] = await Promise.all([
1332
+ client.list({ prefix, metadata: true }),
1333
+ resolveStagedBinding(client, repo),
1334
+ ]);
1335
+ const files = list.items.map((item) => ({
1336
+ key: item.key,
1337
+ filename: item.key.slice(prefix.length),
1338
+ size: item.size,
1339
+ stagedAt: item.metadata?.["gh.staged-at"],
1340
+ url: item.url,
1341
+ }));
1342
+ return { repo, branch, files, binding };
1343
+ }
1344
+ const STAGED_HELP = `uploads staged [--branch <name>] [--repo <owner/name>] [--format json] [--workspace <name>]
1345
+
1346
+ Read-only view of what's staged for a branch (\`attach --branch\` / bare
1347
+ \`put\` on a non-default branch, issue #403) and whether it will auto-attach
1348
+ once a PR opens. One \`list\` call against the branch staging prefix
1349
+ (gh/<owner>/<repo>/branch/<branch>/) plus a binding check — files:read only,
1350
+ no new server surface.
1351
+
1352
+ Defaults: current git branch (same resolution as \`attach --branch\`, worktree-
1353
+ safe), repo from --repo / gh / git remote (same as every other command).
1354
+
1355
+ Binding: self means these files auto-attach when this branch's PR opens; none
1356
+ or other means they won't (repo unlinked, or linked to a different
1357
+ workspace) — same advisory as the attach --branch stage-time warning.
1358
+
1359
+ Examples:
1360
+ uploads staged
1361
+ uploads staged --branch feature/thing --repo owner/name
1362
+ uploads staged --format json
1363
+ `;
1364
+ export async function runStaged(ctx, args, help = false, run = execRunner) {
1365
+ const parsed = parseCommandArgs(args);
1366
+ if (help || parsed.help) {
1367
+ writeCommandHelp(STAGED_HELP);
1368
+ return 0;
1369
+ }
1370
+ const format = ctx.json
1371
+ ? "json"
1372
+ : (() => {
1373
+ const raw = flagString(parsed.flags, "--format");
1374
+ if (!raw || raw === "human")
1375
+ return "human";
1376
+ if (raw === "json")
1377
+ return "json";
1378
+ throw new UsageError(`invalid --format: ${raw} (expected: json)`);
1379
+ })();
1380
+ const repo = resolveRepo(flagString(parsed.flags, "--repo"), run);
1381
+ const branch = flagString(parsed.flags, "--branch") ?? resolveCurrentBranch(run);
1382
+ const result = await resolveStaged({ client: ctx.client, repo, branch });
1383
+ if (format === "json") {
1384
+ // Always a valid JSON document, even with zero files — never empty
1385
+ // stdout (issue #405 explicitly calls out find --format json's empty-
1386
+ // stdout-on-no-matches wart as a wrong pattern to avoid here).
1387
+ await writeJson(result);
1388
+ return 0;
1389
+ }
1390
+ if (result.files.length === 0) {
1391
+ await writeStdout(`nothing staged for ${branch} in ${repo}\n`);
1392
+ return 0;
1393
+ }
1394
+ for (const file of result.files) {
1395
+ const size = file.size !== undefined ? formatByteSize(file.size) : "? B";
1396
+ const staged = file.stagedAt ? ` staged ${file.stagedAt}` : "";
1397
+ await writeStdout(`${file.filename} ${size}${staged} ${file.url ?? "(no url)"}\n`);
1398
+ }
1399
+ process.stderr.write(`binding: ${result.binding.state} — ${result.binding.message}\n`);
1400
+ // Promote is pointless advice when the repo belongs to another workspace —
1401
+ // the cross-tenant gate (#297) would reject it from here.
1402
+ if (result.binding.state !== "other") {
1403
+ process.stderr.write(`once the PR exists: uploads attach --promote\n`);
1404
+ }
1405
+ return 0;
1406
+ }
1159
1407
  export async function runPut(ctx, args, help = false, run = execRunner) {
1160
1408
  if (help) {
1161
1409
  writeCommandHelp(PUT_HELP);
@@ -1274,7 +1522,25 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1274
1522
  })()
1275
1523
  : defaults.width;
1276
1524
  const noGit = flagBool(parsed.flags, "--no-git") || defaults.noGit === true;
1277
- // gh.* metadata: explicit --pr/--issue target wins over --meta; otherwise
1525
+ // Bare-put branch staging (issue #403): a bare put (no --pr/--issue/--key/
1526
+ // --ref/--prefix/--destination, not --no-git) on a non-default git branch
1527
+ // stages to the branch prefix — identical key/metadata to `attach
1528
+ // --branch` — instead of the dated layout. Computed before gh.* metadata
1529
+ // resolution below since it takes over that resolution entirely (branch
1530
+ // metadata, not PR/issue metadata) and supersedes the #393 nudge for this
1531
+ // case.
1532
+ const stagingTarget = resolvePutStagingTarget({
1533
+ ghTarget,
1534
+ keyHint,
1535
+ refArg: flagString(parsed.flags, "--ref"),
1536
+ prefixArg: prefixFlag,
1537
+ destinationArg: destFlag,
1538
+ noGit,
1539
+ repoArg: flagString(parsed.flags, "--repo") ?? defaults.repo,
1540
+ run,
1541
+ });
1542
+ // gh.* metadata: explicit --pr/--issue target wins over --meta; staging
1543
+ // wins over --meta the same way (matches attach --branch); otherwise
1278
1544
  // best-effort auto resolution (on by default) where --meta wins. --no-git,
1279
1545
  // --no-auto, or UPLOADS_NO_AUTO_META disable auto; --auto forces past the
1280
1546
  // config default but never past --no-git (no repo to resolve).
@@ -1286,6 +1552,14 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1286
1552
  metadata = merged;
1287
1553
  attachedRef = merged["gh.ref"];
1288
1554
  }
1555
+ 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;
1562
+ }
1289
1563
  else {
1290
1564
  // gh.* additionally needs git, which the shared derived gate ignores.
1291
1565
  if (!noGit && derivedMetaEnabled(parsed.flags, defaults)) {
@@ -1306,18 +1580,31 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1306
1580
  }
1307
1581
  }
1308
1582
  }
1309
- // Bare-put nudge (issue #393): computed once, used for both the trailing
1310
- // stderr line (human mode) and the JSON `hint` field below. Best-effort —
1311
- // see resolvePutNudge; never affects exit code, stdout, or the upload.
1312
- const nudge = resolvePutNudge({
1313
- ctx,
1314
- flags: parsed.flags,
1315
- ghTarget,
1316
- keyHint,
1317
- noGit,
1318
- defaults,
1319
- run,
1320
- });
1583
+ // Bare-put nudge (issue #393): only relevant when staging didn't take over
1584
+ // once `stagingTarget` resolves, staging IS the upgrade the nudge used to
1585
+ // point at, so this is skipped entirely rather than firing redundantly.
1586
+ // Still fires as before for a bare put that lands on the dated layout with
1587
+ // a detectable PR (e.g. an explicit --ref/--prefix opts out of staging).
1588
+ // Computed once, used for both the trailing stderr line (human mode) and
1589
+ // the JSON `hint` field below. Best-effort — see resolvePutNudge; never
1590
+ // affects exit code, stdout, or the upload.
1591
+ const nudge = stagingTarget
1592
+ ? undefined
1593
+ : resolvePutNudge({
1594
+ ctx,
1595
+ flags: parsed.flags,
1596
+ ghTarget,
1597
+ keyHint,
1598
+ noGit,
1599
+ defaults,
1600
+ run,
1601
+ });
1602
+ // Staging note (issue #403): same suppression as the #393 nudge
1603
+ // (--quiet, UPLOADS_NO_NUDGE=1 env/config); staging itself is NOT gated by
1604
+ // either — only whether the note is printed/hinted.
1605
+ const stagingNote = stagingTarget && !ctx.quiet && !defaults.noNudge
1606
+ ? putStagingNoteText(stagingTarget.branch)
1607
+ : undefined;
1321
1608
  const logHuman = !ctx.quiet && format === "human";
1322
1609
  if (logHuman) {
1323
1610
  if (multi) {
@@ -1336,6 +1623,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1336
1623
  nameOverride: nameFlag,
1337
1624
  explicitKey: keyHint,
1338
1625
  ghTarget,
1626
+ ghBranchTarget: stagingTarget,
1339
1627
  prefix: resolvedPrefix ?? defaults.prefix,
1340
1628
  repo: flagString(parsed.flags, "--repo") ?? defaults.repo,
1341
1629
  ref: flagString(parsed.flags, "--ref") ?? defaults.ref,
@@ -1354,6 +1642,19 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1354
1642
  if (uploads.length === 0 && failures.length > 0 && !multi) {
1355
1643
  throw firstError instanceof Error ? firstError : new Error(String(firstError));
1356
1644
  }
1645
+ // Stage-time binding warning (issue #398/#400): same check `attach
1646
+ // --branch` runs, now also on the bare-put staging path. Best-effort — see
1647
+ // resolveStageBindingWarning; never affects exit code or the upload.
1648
+ const bindingWarning = stagingTarget && uploads.length > 0
1649
+ ? await resolveStageBindingWarning({ ctx, defaults, repo: stagingTarget.repo })
1650
+ : undefined;
1651
+ // One JSON `hint` slot, shared with the #393 nudge (mutually exclusive with
1652
+ // it — nudge is undefined whenever staging took over). When staging fires,
1653
+ // prefer the more actionable binding warning over the generic staging note
1654
+ // (mirrors attach --branch, whose only JSON hint content IS the binding
1655
+ // warning); stderr prints the nudge/staging-note and binding-warning lines
1656
+ // independently, below.
1657
+ const jsonHint = nudge ?? bindingWarning ?? stagingNote;
1357
1658
  const galleriesByKey = new Map();
1358
1659
  let galleryHadError = false;
1359
1660
  if (galleryId && uploads.length > 0) {
@@ -1399,7 +1700,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1399
1700
  failures,
1400
1701
  comment,
1401
1702
  commentError,
1402
- ...(nudge ? { hint: nudge } : {}),
1703
+ ...(jsonHint ? { hint: jsonHint } : {}),
1403
1704
  });
1404
1705
  }
1405
1706
  else {
@@ -1432,6 +1733,10 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1432
1733
  }
1433
1734
  if (nudge)
1434
1735
  process.stderr.write(`${nudge}\n`);
1736
+ if (stagingNote)
1737
+ process.stderr.write(`${stagingNote}\n`);
1738
+ if (bindingWarning)
1739
+ process.stderr.write(`${bindingWarning}\n`);
1435
1740
  }
1436
1741
  return failures.length === 0 && !galleryHadError ? 0 : 1;
1437
1742
  }
@@ -1463,7 +1768,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1463
1768
  frame: result.frame,
1464
1769
  gallery,
1465
1770
  ...(dryRun ? { dryRun: true } : {}),
1466
- ...(nudge ? { hint: nudge } : {}),
1771
+ ...(jsonHint ? { hint: jsonHint } : {}),
1467
1772
  });
1468
1773
  break;
1469
1774
  case "url":
@@ -1485,6 +1790,10 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1485
1790
  }
1486
1791
  if (nudge && format !== "json")
1487
1792
  process.stderr.write(`${nudge}\n`);
1793
+ if (stagingNote && format !== "json")
1794
+ process.stderr.write(`${stagingNote}\n`);
1795
+ if (bindingWarning && format !== "json")
1796
+ process.stderr.write(`${bindingWarning}\n`);
1488
1797
  return gallery?.error ? 1 : 0;
1489
1798
  }
1490
1799
  // --- galleries ---
package/dist/mcp/tools.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import { createUploadsClient } from "../client.js";
2
- import { buildDoctorReport, makeGhTarget, syncAttachmentsComment, uploadAttachments, uploadPreparedImage, uploadPuts, } from "../commands.js";
2
+ import { buildDoctorReport, makeGhTarget, 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 } from "../github.js";
6
+ import { ghKeyPrefix, ghMetadataForBranch } 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";
10
- import { execRunner, ghMetadataFromTargetWithTitle, resolveCurrentPullRequest, resolveRepo, } from "../github-gh.js";
10
+ import { execRunner, ghMetadataFromTargetWithTitle, resolveCurrentBranch, resolveCurrentPullRequest, resolveRepo, } from "../github-gh.js";
11
11
  import { appProp, canonicalMetaFromArgs, METADATA_DESCRIPTION, metadataArgWithCanonical, metadataProp, optBool, optPosInt, optString, optStringArray, optStringRecord, stateProp, usage, } from "./args.js";
12
12
  import { batchFailureMessage, ToolBatchError } from "./server.js";
13
13
  import { attachmentFromText, buildReportPayload, parseReportType, REPORT_TYPES, submitReport, validateReportMessage, } from "../report.js";
@@ -415,9 +415,35 @@ export function createUploadsMcpTools(opts) {
415
415
  const alt = optString(args, "alt");
416
416
  const width = optPosInt(args, "width") ?? defaults.width;
417
417
  const contentType = optString(args, "contentType");
418
+ // Bare-put branch staging (issue #403): local stdio MCP put mirrors
419
+ // the CLI default — no pr/issue/key/ref/prefix/destination, not
420
+ // noGit, on a non-default git branch stages to the branch prefix
421
+ // (identical key/metadata to `attach --branch`) instead of the
422
+ // dated layout. Never throws — see resolvePutStagingTarget.
423
+ const stagingTarget = resolvePutStagingTarget({
424
+ ghTarget: target,
425
+ keyHint: keyArg,
426
+ refArg,
427
+ prefixArg,
428
+ destinationArg: destArg,
429
+ noGit,
430
+ repoArg: optString(args, "repo") ?? defaults.repo,
431
+ run,
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;
418
443
  const putShared = {
419
444
  client,
420
445
  ghTarget: target,
446
+ ghBranchTarget: stagingTarget,
421
447
  prefix: resolvedPrefix ?? defaults.prefix,
422
448
  repo: optString(args, "repo") ?? defaults.repo,
423
449
  ref: refArg ?? defaults.ref,
@@ -427,7 +453,7 @@ export function createUploadsMcpTools(opts) {
427
453
  replace: replaceArg,
428
454
  optimize: optimizeOpts,
429
455
  frame: frameOpts,
430
- metadata,
456
+ metadata: putMetadata,
431
457
  // The shared metadata description promises uploads.sh derives these
432
458
  // "automatically where it can" — MCP has no --no-auto, so always on.
433
459
  deriveImageFacts: true,
@@ -458,6 +484,7 @@ export function createUploadsMcpTools(opts) {
458
484
  frame: frameOpts,
459
485
  optimize: optimizeOpts,
460
486
  ghTarget: target,
487
+ ghBranchTarget: stagingTarget,
461
488
  key: keyArg,
462
489
  prefix: resolvedPrefix ?? defaults.prefix,
463
490
  repo: optString(args, "repo") ?? defaults.repo,
@@ -466,7 +493,7 @@ export function createUploadsMcpTools(opts) {
466
493
  deriveRepoFromGit: !noGit,
467
494
  dryRun,
468
495
  replace: replaceArg,
469
- metadata,
496
+ metadata: putMetadata,
470
497
  provenanceClient: "uploads-mcp",
471
498
  alt: () => alt ?? sourceName,
472
499
  width,
@@ -925,6 +952,31 @@ export function createUploadsMcpTools(opts) {
925
952
  return client.list({ prefix, limit, cursor });
926
953
  },
927
954
  },
955
+ {
956
+ name: "staged",
957
+ description: "Read-only view of what's staged for a git branch (attach --branch / bare put on a non-default branch) and whether it will auto-attach once a PR opens. One list call against the branch staging prefix plus a repo-binding check (files:read only). Returns { repo, branch, files, binding }; binding.state is self/other/none/unknown and binding.autoAttach is true only for self.",
958
+ inputSchema: {
959
+ type: "object",
960
+ properties: {
961
+ branch: {
962
+ type: "string",
963
+ description: "Branch name (default: current git branch, worktree-safe).",
964
+ },
965
+ repo: {
966
+ type: "string",
967
+ description: "owner/name repo (default: gh/git remote inference).",
968
+ },
969
+ workspace: workspaceProp,
970
+ },
971
+ additionalProperties: false,
972
+ },
973
+ async handler(args) {
974
+ const { client } = clientFor(args);
975
+ const repo = resolveRepo(optString(args, "repo"), run);
976
+ const branch = optString(args, "branch") ?? resolveCurrentBranch(run);
977
+ return resolveStaged({ client, repo, branch });
978
+ },
979
+ },
928
980
  {
929
981
  name: "delete",
930
982
  description: "Delete an uploaded object by key. Set dryRun to preview without deleting.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.23.0",
3
+ "version": "0.24.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,