@buildinternet/uploads 0.22.1 → 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,8 +11,9 @@ 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";
15
- import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, classifyGhNumber, execRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, } from "./github-gh.js";
14
+ import { ghAttachmentKey, ghBranchAttachmentKey, ghBranchKeyPrefix, ghKeyPrefix, ghMetadataFromTarget, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, normalizeGithubCoordinate, } from "./github.js";
15
+ import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, resolveDefaultBranch, classifyGhNumber, execRunner, timedExecRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, } from "./github-gh.js";
16
+ import { deriveRepoFromGit } from "./keys.js";
16
17
  import { resolvePutPrefix } from "./destinations.js";
17
18
  import { optimizeImageForUpload, rewriteKeyExtension, } from "./optimize.js";
18
19
  import { applyFrame, resolveFrameId } from "./frame.js";
@@ -111,6 +112,10 @@ Options:
111
112
  --dry-run Print key + public URL without uploading; reports if the key would replace
112
113
  (or, on a strict key, be refused). Not with --comment/--gallery
113
114
 
115
+ A bare put (no --pr/--issue/--key) on a non-default git branch prints a one-line
116
+ nudge toward --pr/attach --branch (stderr in human mode, a "hint" field in
117
+ --format json). Suppress with --quiet, UPLOADS_NO_NUDGE=1, or config UPLOADS_NO_NUDGE=1.
118
+
114
119
  Exit codes: 0 ok · 2 usage/token/file · 3 auth/policy · 4 network · 1 other (incl. partial multi-file failure).
115
120
  Scripted formats (json|url|markdown) also print failures on stdout.
116
121
 
@@ -368,7 +373,11 @@ export async function uploadPreparedImage(client, bytes, sourceName, opts) {
368
373
  frameFit: opts.frame.frameFit,
369
374
  optimize: opts.optimize,
370
375
  });
371
- 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;
372
381
  if (key && prepared.optimized)
373
382
  key = rewriteKeyExtension(key, prepared.filename);
374
383
  const result = await client.put(prepared.bytes, {
@@ -738,6 +747,7 @@ export async function uploadPuts(opts) {
738
747
  frame: opts.frame,
739
748
  optimize: opts.optimize,
740
749
  ghTarget: opts.ghTarget,
750
+ ghBranchTarget: opts.ghBranchTarget,
741
751
  key: opts.explicitKey,
742
752
  prefix: opts.prefix,
743
753
  repo: opts.repo,
@@ -1004,8 +1014,17 @@ async function runAttachBranch(ctx, parsed, branch, run) {
1004
1014
  if (uploads.length === 0 && failures.length === 1 && parsed.positionals.length === 1) {
1005
1015
  throw firstError instanceof Error ? firstError : new Error(String(firstError));
1006
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;
1007
1021
  if (ctx.json) {
1008
- await writeJson({ target, uploads, failures });
1022
+ await writeJson({
1023
+ target,
1024
+ uploads,
1025
+ failures,
1026
+ ...(bindingWarning ? { hint: bindingWarning } : {}),
1027
+ });
1009
1028
  }
1010
1029
  else {
1011
1030
  for (const result of uploads) {
@@ -1029,9 +1048,58 @@ async function runAttachBranch(ctx, parsed, branch, run) {
1029
1048
  process.stderr.write(`>> staged: these auto-attach to this branch's PR when it opens ` +
1030
1049
  `(or run \`uploads attach --promote\` after opening)\n`);
1031
1050
  }
1051
+ if (bindingWarning)
1052
+ process.stderr.write(`${bindingWarning}\n`);
1032
1053
  }
1033
1054
  return failures.length === 0 ? 0 : 1;
1034
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
+ }
1035
1103
  /**
1036
1104
  * `attach --promote` with zero file arguments: resolve the PR target (same
1037
1105
  * resolution as the default `runAttach` path), promote this workspace's
@@ -1079,6 +1147,263 @@ async function runAttachPromoteOnly(ctx, parsed, run) {
1079
1147
  }
1080
1148
  return 0;
1081
1149
  }
1150
+ /** Bounds the best-effort `gh pr view` lookup the put nudge (issue #393) makes
1151
+ * on top of the normal put flow — long enough for a real gh call, short
1152
+ * enough to never be felt as a hang. */
1153
+ const PUT_NUDGE_GH_TIMEOUT_MS = 3000;
1154
+ /**
1155
+ * The bare-put nudge's wording (issue #393): teaches `--pr`/`attach --branch`
1156
+ * as an upgrade from a targetless `put`. `pr` present → names the PR;
1157
+ * otherwise a generic variant that still points at `--pr <num>`. Used
1158
+ * verbatim for both the human-mode stderr line and the JSON `hint` field.
1159
+ */
1160
+ function putNudgeText(branch, pr) {
1161
+ const prClause = pr !== undefined ? ` (PR #${pr} open) — rerun with --pr ${pr}` : ` — rerun with --pr <num>`;
1162
+ return (`note: on branch ${branch}${prClause} for a stable key plus a managed comment ` +
1163
+ `that collects this PR's media, or stage pre-PR files with: uploads attach <file> --branch`);
1164
+ }
1165
+ /**
1166
+ * Best-effort bare-put nudge (issue #393): fires only when `put` has no
1167
+ * targeting flag at all (`--pr`/`--issue`/`--key`; `--branch` too, though
1168
+ * `put` doesn't currently accept it — defensive parity with `attach`), is
1169
+ * inside a git repo (reusing `deriveRepoFromGit`, the same detection the
1170
+ * default screenshot key's repo segment uses), and the current branch isn't
1171
+ * the default one. Never throws — any failure (not a repo, detached HEAD,
1172
+ * `gh` missing/unauthenticated/timed out) degrades to "no nudge" or, once a
1173
+ * branch is already known, to the generic no-PR wording. Must never affect
1174
+ * put's exit code, stdout, or upload behavior.
1175
+ */
1176
+ function resolvePutNudge(opts) {
1177
+ const { ctx, flags, ghTarget, keyHint, noGit, defaults, run } = opts;
1178
+ if (ctx.quiet)
1179
+ return undefined;
1180
+ if (defaults.noNudge)
1181
+ return undefined;
1182
+ if (ghTarget || keyHint || noGit)
1183
+ return undefined;
1184
+ if (flags.has("--branch"))
1185
+ return undefined; // not a real put flag today; defensive only
1186
+ try {
1187
+ if (deriveRepoFromGit(run) === undefined)
1188
+ return undefined; // not a (usable) git repo
1189
+ let branch;
1190
+ try {
1191
+ branch = resolveCurrentBranch(run);
1192
+ }
1193
+ catch {
1194
+ return undefined; // detached HEAD, or git unavailable
1195
+ }
1196
+ const defaultBranch = resolveDefaultBranch(run);
1197
+ const onDefaultBranch = defaultBranch
1198
+ ? branch === defaultBranch
1199
+ : branch === "main" || branch === "master"; // undetermined: err toward not nudging
1200
+ if (onDefaultBranch)
1201
+ return undefined;
1202
+ let pr;
1203
+ try {
1204
+ // Only swap in the bounded runner for the real subprocess path — an
1205
+ // injected `run` (tests, or a future caller) is trusted to already be
1206
+ // fast/fake, and execFileSync's `timeout` option is meaningless
1207
+ // against anything that isn't actually shelling out.
1208
+ const timed = run === execRunner ? timedExecRunner(PUT_NUDGE_GH_TIMEOUT_MS) : run;
1209
+ const repoArg = flagString(flags, "--repo") ?? defaults.repo;
1210
+ const repo = resolveRepo(repoArg, timed);
1211
+ pr = resolveCurrentPullRequest(repo, timed).num;
1212
+ }
1213
+ catch {
1214
+ pr = undefined; // gh missing/unauthenticated/timed out/no open PR — generic wording
1215
+ }
1216
+ return putNudgeText(branch, pr);
1217
+ }
1218
+ catch {
1219
+ return undefined;
1220
+ }
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
+ }
1082
1407
  export async function runPut(ctx, args, help = false, run = execRunner) {
1083
1408
  if (help) {
1084
1409
  writeCommandHelp(PUT_HELP);
@@ -1197,7 +1522,25 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1197
1522
  })()
1198
1523
  : defaults.width;
1199
1524
  const noGit = flagBool(parsed.flags, "--no-git") || defaults.noGit === true;
1200
- // 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
1201
1544
  // best-effort auto resolution (on by default) where --meta wins. --no-git,
1202
1545
  // --no-auto, or UPLOADS_NO_AUTO_META disable auto; --auto forces past the
1203
1546
  // config default but never past --no-git (no repo to resolve).
@@ -1209,6 +1552,14 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1209
1552
  metadata = merged;
1210
1553
  attachedRef = merged["gh.ref"];
1211
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
+ }
1212
1563
  else {
1213
1564
  // gh.* additionally needs git, which the shared derived gate ignores.
1214
1565
  if (!noGit && derivedMetaEnabled(parsed.flags, defaults)) {
@@ -1229,6 +1580,31 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1229
1580
  }
1230
1581
  }
1231
1582
  }
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;
1232
1608
  const logHuman = !ctx.quiet && format === "human";
1233
1609
  if (logHuman) {
1234
1610
  if (multi) {
@@ -1247,6 +1623,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1247
1623
  nameOverride: nameFlag,
1248
1624
  explicitKey: keyHint,
1249
1625
  ghTarget,
1626
+ ghBranchTarget: stagingTarget,
1250
1627
  prefix: resolvedPrefix ?? defaults.prefix,
1251
1628
  repo: flagString(parsed.flags, "--repo") ?? defaults.repo,
1252
1629
  ref: flagString(parsed.flags, "--ref") ?? defaults.ref,
@@ -1265,6 +1642,19 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1265
1642
  if (uploads.length === 0 && failures.length > 0 && !multi) {
1266
1643
  throw firstError instanceof Error ? firstError : new Error(String(firstError));
1267
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;
1268
1658
  const galleriesByKey = new Map();
1269
1659
  let galleryHadError = false;
1270
1660
  if (galleryId && uploads.length > 0) {
@@ -1310,6 +1700,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1310
1700
  failures,
1311
1701
  comment,
1312
1702
  commentError,
1703
+ ...(jsonHint ? { hint: jsonHint } : {}),
1313
1704
  });
1314
1705
  }
1315
1706
  else {
@@ -1340,6 +1731,12 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1340
1731
  for (const failure of failures) {
1341
1732
  process.stderr.write(`warning: could not upload ${failure.file}: ${failure.error.message}\n`);
1342
1733
  }
1734
+ if (nudge)
1735
+ process.stderr.write(`${nudge}\n`);
1736
+ if (stagingNote)
1737
+ process.stderr.write(`${stagingNote}\n`);
1738
+ if (bindingWarning)
1739
+ process.stderr.write(`${bindingWarning}\n`);
1343
1740
  }
1344
1741
  return failures.length === 0 && !galleryHadError ? 0 : 1;
1345
1742
  }
@@ -1371,6 +1768,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1371
1768
  frame: result.frame,
1372
1769
  gallery,
1373
1770
  ...(dryRun ? { dryRun: true } : {}),
1771
+ ...(jsonHint ? { hint: jsonHint } : {}),
1374
1772
  });
1375
1773
  break;
1376
1774
  case "url":
@@ -1390,6 +1788,12 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1390
1788
  if (gallery?.error) {
1391
1789
  process.stderr.write(`warning: upload succeeded but adding it to gallery ${gallery.id} failed: ${gallery.error.message}\n`);
1392
1790
  }
1791
+ if (nudge && format !== "json")
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`);
1393
1797
  return gallery?.error ? 1 : 0;
1394
1798
  }
1395
1799
  // --- galleries ---
@@ -1,5 +1,5 @@
1
1
  import type { UploadsClientConfig } from "./config.js";
2
- export declare const UPLOADS_CONFIG_KEYS: readonly ["UPLOADS_API_URL", "UPLOADS_WORKSPACE", "UPLOADS_TOKEN", "UPLOADS_DEFAULT_PREFIX", "UPLOADS_DEFAULT_REPO", "UPLOADS_DEFAULT_REF", "UPLOADS_DEFAULT_WIDTH", "UPLOADS_NO_GIT", "UPLOADS_NO_OPTIMIZE", "UPLOADS_KEEP_EXIF", "UPLOADS_NO_AUTO_META", "UPLOADS_SCREENSHOT_VIA"];
2
+ export declare const UPLOADS_CONFIG_KEYS: readonly ["UPLOADS_API_URL", "UPLOADS_WORKSPACE", "UPLOADS_TOKEN", "UPLOADS_DEFAULT_PREFIX", "UPLOADS_DEFAULT_REPO", "UPLOADS_DEFAULT_REF", "UPLOADS_DEFAULT_WIDTH", "UPLOADS_NO_GIT", "UPLOADS_NO_OPTIMIZE", "UPLOADS_KEEP_EXIF", "UPLOADS_NO_AUTO_META", "UPLOADS_SCREENSHOT_VIA", "UPLOADS_NO_NUDGE"];
3
3
  export type UploadsConfigKey = (typeof UPLOADS_CONFIG_KEYS)[number];
4
4
  export type UploadsConfigValues = Partial<Record<UploadsConfigKey, string>>;
5
5
  export interface PutDefaults {
@@ -14,6 +14,8 @@ export interface PutDefaults {
14
14
  keepExif?: boolean;
15
15
  /** When true, `put` does NOT auto-resolve/stamp gh.* on the default path. */
16
16
  noAutoMeta?: boolean;
17
+ /** When true, `put` never prints the bare-put --pr/attach nudge (issue #393). */
18
+ noNudge?: boolean;
17
19
  }
18
20
  declare const PUT_DEFAULT_KEY_MAP: Record<keyof PutDefaults, UploadsConfigKey>;
19
21
  export declare function putDefaultsToConfigValues(defaults: PutDefaults): UploadsConfigValues;
@@ -14,6 +14,7 @@ export const UPLOADS_CONFIG_KEYS = [
14
14
  "UPLOADS_KEEP_EXIF",
15
15
  "UPLOADS_NO_AUTO_META",
16
16
  "UPLOADS_SCREENSHOT_VIA",
17
+ "UPLOADS_NO_NUDGE",
17
18
  ];
18
19
  const PUT_DEFAULT_KEY_MAP = {
19
20
  prefix: "UPLOADS_DEFAULT_PREFIX",
@@ -24,6 +25,7 @@ const PUT_DEFAULT_KEY_MAP = {
24
25
  noOptimize: "UPLOADS_NO_OPTIMIZE",
25
26
  keepExif: "UPLOADS_KEEP_EXIF",
26
27
  noAutoMeta: "UPLOADS_NO_AUTO_META",
28
+ noNudge: "UPLOADS_NO_NUDGE",
27
29
  };
28
30
  function isTruthyConfigFlag(value) {
29
31
  if (!value)
@@ -49,6 +51,8 @@ export function putDefaultsToConfigValues(defaults) {
49
51
  out.UPLOADS_KEEP_EXIF = "1";
50
52
  if (defaults.noAutoMeta)
51
53
  out.UPLOADS_NO_AUTO_META = "1";
54
+ if (defaults.noNudge)
55
+ out.UPLOADS_NO_NUDGE = "1";
52
56
  return out;
53
57
  }
54
58
  function parsePutDefaultsFromRaw(raw) {
@@ -72,6 +76,8 @@ function parsePutDefaultsFromRaw(raw) {
72
76
  out.keepExif = true;
73
77
  if (isTruthyConfigFlag(raw.UPLOADS_NO_AUTO_META))
74
78
  out.noAutoMeta = true;
79
+ if (isTruthyConfigFlag(raw.UPLOADS_NO_NUDGE))
80
+ out.noNudge = true;
75
81
  return out;
76
82
  }
77
83
  function parsePutDefaultsFromEnv() {
@@ -92,6 +98,8 @@ function parsePutDefaultsFromEnv() {
92
98
  raw.UPLOADS_KEEP_EXIF = process.env.UPLOADS_KEEP_EXIF;
93
99
  if (process.env.UPLOADS_NO_AUTO_META)
94
100
  raw.UPLOADS_NO_AUTO_META = process.env.UPLOADS_NO_AUTO_META;
101
+ if (process.env.UPLOADS_NO_NUDGE)
102
+ raw.UPLOADS_NO_NUDGE = process.env.UPLOADS_NO_NUDGE;
95
103
  return parsePutDefaultsFromRaw(raw);
96
104
  }
97
105
  /** XDG default shared across buildinternet skills (github-screenshots, uploads, …). */
@@ -163,6 +171,8 @@ export function mergePutDefaults(...layers) {
163
171
  out.keepExif = layer.keepExif;
164
172
  if (layer.noAutoMeta != null)
165
173
  out.noAutoMeta = layer.noAutoMeta;
174
+ if (layer.noNudge != null)
175
+ out.noNudge = layer.noNudge;
166
176
  }
167
177
  return out;
168
178
  }
@@ -2,6 +2,14 @@ import { type GhTarget } from "./github.js";
2
2
  /** Runs a command and returns stdout; throws on non-zero exit. Injectable for tests. */
3
3
  export type CommandRunner = (cmd: string, args: string[], input?: string) => string;
4
4
  export declare const execRunner: CommandRunner;
5
+ /**
6
+ * A `CommandRunner` bounded by `timeoutMs` (node's native `execFileSync`
7
+ * `timeout` option). There is no other subprocess-timeout wrapper in this
8
+ * codebase to reuse, so this is the minimal one: for a best-effort lookup
9
+ * that must never block its caller for long (e.g. the bare-`put` nudge's `gh
10
+ * pr view` check, issue #393), pass this instead of the default `execRunner`.
11
+ */
12
+ export declare const timedExecRunner: (timeoutMs: number) => CommandRunner;
5
13
  /**
6
14
  * Resolve "owner/name". Order: explicit --repo (validated) → `gh repo view`
7
15
  * (fork-aware) → parse the origin remote → UsageError.
@@ -11,6 +19,14 @@ export declare function resolveRepo(explicit: string | undefined, run?: CommandR
11
19
  export declare function resolveCurrentPullRequest(repo: string, run?: CommandRunner): GhTarget;
12
20
  /** Resolve the current git branch (`--branch` with no value). Throws UsageError on detached HEAD or outside a git repo. */
13
21
  export declare function resolveCurrentBranch(run?: CommandRunner): string;
22
+ /**
23
+ * Best-effort default-branch name via the local `origin/HEAD` ref (no
24
+ * network call — just reads the ref git already cached from the last
25
+ * fetch/clone). Returns undefined when it can't be determined (no origin,
26
+ * `origin/HEAD` never set, not a git repo) — callers should treat that as
27
+ * "unknown", not "no default branch exists".
28
+ */
29
+ export declare function resolveDefaultBranch(run?: CommandRunner): string | undefined;
14
30
  /**
15
31
  * Classify a bare PR/issue number via the GitHub API so the default `put`
16
32
  * path can stamp the right `gh.kind`. Returns undefined on any failure (gh
package/dist/github-gh.js CHANGED
@@ -3,6 +3,19 @@ import { UsageError } from "./cli-args.js";
3
3
  import { ATTACHMENTS_MARKER, ghMetadataFromTarget, isValidRepo, parseRepoFromRemoteUrl, } from "./github.js";
4
4
  import { META_VALUE_MAX, isMetaValueSafe } from "./metadata.js";
5
5
  export const execRunner = (cmd, args, input) => execFileSync(cmd, args, { encoding: "utf8", input, stdio: ["pipe", "pipe", "pipe"] });
6
+ /**
7
+ * A `CommandRunner` bounded by `timeoutMs` (node's native `execFileSync`
8
+ * `timeout` option). There is no other subprocess-timeout wrapper in this
9
+ * codebase to reuse, so this is the minimal one: for a best-effort lookup
10
+ * that must never block its caller for long (e.g. the bare-`put` nudge's `gh
11
+ * pr view` check, issue #393), pass this instead of the default `execRunner`.
12
+ */
13
+ export const timedExecRunner = (timeoutMs) => (cmd, args, input) => execFileSync(cmd, args, {
14
+ encoding: "utf8",
15
+ input,
16
+ stdio: ["pipe", "pipe", "pipe"],
17
+ timeout: timeoutMs,
18
+ });
6
19
  /**
7
20
  * Resolve "owner/name". Order: explicit --repo (validated) → `gh repo view`
8
21
  * (fork-aware) → parse the origin remote → UsageError.
@@ -82,6 +95,26 @@ export function resolveCurrentBranch(run = execRunner) {
82
95
  }
83
96
  return branch;
84
97
  }
98
+ /**
99
+ * Best-effort default-branch name via the local `origin/HEAD` ref (no
100
+ * network call — just reads the ref git already cached from the last
101
+ * fetch/clone). Returns undefined when it can't be determined (no origin,
102
+ * `origin/HEAD` never set, not a git repo) — callers should treat that as
103
+ * "unknown", not "no default branch exists".
104
+ */
105
+ export function resolveDefaultBranch(run = execRunner) {
106
+ try {
107
+ const out = run("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]).trim();
108
+ if (!out)
109
+ return undefined;
110
+ const slash = out.indexOf("/");
111
+ const branch = slash === -1 ? out : out.slice(slash + 1);
112
+ return branch || undefined;
113
+ }
114
+ catch {
115
+ return undefined;
116
+ }
117
+ }
85
118
  /**
86
119
  * Classify a bare PR/issue number via the GitHub API so the default `put`
87
120
  * path can stamp the right `gh.kind`. Returns undefined on any failure (gh
package/dist/github.d.ts CHANGED
@@ -86,6 +86,18 @@ export interface AttachmentItem {
86
86
  path?: string;
87
87
  state?: string;
88
88
  };
89
+ /**
90
+ * Poster frame for a video (issue #299), server-computed like `embedUrl` —
91
+ * never taken from client-settable metadata. Absent means "no poster", and
92
+ * the renderer falls back to the bullet link.
93
+ */
94
+ posterUrl?: string | null;
95
+ /** Derived video facts used for the caption and display width. */
96
+ videoMeta?: {
97
+ durationSeconds?: number;
98
+ width?: number;
99
+ height?: number;
100
+ };
89
101
  }
90
102
  /** A public gallery linked to the PR or issue whose managed comment is syncing. */
91
103
  export interface GalleryCommentItem {
package/dist/github.js CHANGED
@@ -171,6 +171,34 @@ export function attachmentImageWidth(filename) {
171
171
  }
172
172
  return ATTACHMENT_IMAGE_WIDTH_DEFAULT;
173
173
  }
174
+ /** `m:ss` under an hour, `h:mm:ss` at or above one. */
175
+ function formatDuration(seconds) {
176
+ const total = Math.floor(seconds);
177
+ const s = total % 60;
178
+ const m = Math.floor(total / 60) % 60;
179
+ const h = Math.floor(total / 3600);
180
+ const ss = String(s).padStart(2, "0");
181
+ if (h === 0)
182
+ return `${m}:${ss}`;
183
+ return `${h}:${String(m).padStart(2, "0")}:${ss}`;
184
+ }
185
+ /**
186
+ * Display width for a video poster. Real dimensions only *select* among the
187
+ * width constants — a raw 1920 would blow out the comment column — and the
188
+ * result is capped at the real width so a small clip is never upscaled.
189
+ */
190
+ function posterImageWidth(videoMeta, filename) {
191
+ const w = videoMeta?.width ?? 0;
192
+ const h = videoMeta?.height ?? 0;
193
+ if (w <= 0 || h <= 0)
194
+ return attachmentImageWidth(filename);
195
+ const chosen = h > w
196
+ ? ATTACHMENT_IMAGE_WIDTH_PORTRAIT
197
+ : w / h >= 16 / 9
198
+ ? ATTACHMENT_IMAGE_WIDTH_WIDE
199
+ : ATTACHMENT_IMAGE_WIDTH_DEFAULT;
200
+ return Math.min(chosen, w);
201
+ }
174
202
  function escapeHtmlAttr(s) {
175
203
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
176
204
  }
@@ -259,13 +287,29 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
259
287
  const src = item.embedUrl ?? item.url;
260
288
  const link = item.pageUrl ?? stable; // click-through: file page when known, else raw
261
289
  const isImage = Boolean(src) && inferContentType(name).startsWith("image/");
262
- if (isImage && inlinedImages >= MAX_INLINE_ATTACHMENT_IMAGES) {
290
+ const isPosterVideo = Boolean(item.posterUrl) && inferContentType(name).startsWith("video/");
291
+ const inlines = isImage || isPosterVideo;
292
+ if (inlines && inlinedImages >= MAX_INLINE_ATTACHMENT_IMAGES) {
263
293
  // Cap hit — defer to the collapsed overflow list below rather than
264
294
  // embedding every remaining image inline.
265
295
  overflowImages.push(item);
266
296
  continue;
267
297
  }
268
- if (isImage) {
298
+ if (isPosterVideo) {
299
+ inlinedImages++;
300
+ const w = posterImageWidth(item.videoMeta, name);
301
+ const href = escapeHtmlAttr(link ?? item.posterUrl);
302
+ lines.push(`<a href="${href}"><img width="${w}" alt="${escapeHtmlAttr(name)}" src="${escapeHtmlAttr(item.posterUrl)}"></a>`);
303
+ // GitHub strips <video>, so a still frame needs an explicit affordance
304
+ // or it reads as a screenshot.
305
+ const parts = ["▶ Play video"];
306
+ if (item.videoMeta?.durationSeconds != null) {
307
+ parts.push(formatDuration(item.videoMeta.durationSeconds));
308
+ }
309
+ parts.push(...metaCaptionParts(item.meta).map(escapeHtmlText));
310
+ lines.push(`<sub>${parts.join(" · ")}</sub>`, "");
311
+ }
312
+ else if (isImage) {
269
313
  inlinedImages++;
270
314
  // Markdown ![]() has no width control — phone frames become full-column giants.
271
315
  // img src uses embed host when available (Camo revalidates); click-through prefers the file page.
package/dist/keys.d.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  export declare function sanitizeKeySegment(s: string): string;
2
2
  export declare function sha256Short(bytes: Uint8Array): Promise<string>;
3
- export declare function deriveRepoFromGit(): string | undefined;
3
+ /**
4
+ * `run` is optional and structurally matches `CommandRunner` (github-gh.ts)
5
+ * without importing it — callers that already have an injected runner (e.g.
6
+ * the put nudge, issue #393) can pass it through for testability; the
7
+ * default (no `run`) preserves the original direct-`execSync` behavior for
8
+ * every existing caller.
9
+ */
10
+ export declare function deriveRepoFromGit(run?: (cmd: string, args: string[], input?: string) => string): string | undefined;
4
11
  export declare function buildScreenshotKey(opts: {
5
12
  filename: string;
6
13
  fileBytes: Uint8Array;
package/dist/keys.js CHANGED
@@ -9,9 +9,18 @@ export async function sha256Short(bytes) {
9
9
  .join("")
10
10
  .slice(0, 6);
11
11
  }
12
- export function deriveRepoFromGit() {
12
+ /**
13
+ * `run` is optional and structurally matches `CommandRunner` (github-gh.ts)
14
+ * without importing it — callers that already have an injected runner (e.g.
15
+ * the put nudge, issue #393) can pass it through for testability; the
16
+ * default (no `run`) preserves the original direct-`execSync` behavior for
17
+ * every existing caller.
18
+ */
19
+ export function deriveRepoFromGit(run) {
13
20
  try {
14
- const url = execSync("git config --get remote.origin.url", { encoding: "utf8" }).trim();
21
+ const url = run
22
+ ? run("git", ["config", "--get", "remote.origin.url"]).trim()
23
+ : execSync("git config --get remote.origin.url", { encoding: "utf8" }).trim();
15
24
  const match = url.match(/[/:]([^/]+?)(?:\.git)?$/);
16
25
  return match?.[1];
17
26
  }
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.22.1",
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,