@buildinternet/uploads 0.23.0 → 0.25.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 +14 -2
- package/dist/cli-catalog.js +4 -0
- package/dist/cli.js +5 -1
- package/dist/client.d.ts +18 -0
- package/dist/client.js +10 -0
- package/dist/commands.d.ts +109 -0
- package/dist/commands.js +327 -18
- package/dist/github.d.ts +4 -0
- package/dist/github.js +142 -1
- package/dist/mcp/tools.js +57 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -91,8 +91,20 @@ attachments when one opens: automatically via the
|
|
|
91
91
|
[GitHub App](https://uploads.sh/docs/github-app) webhook, or on the first
|
|
92
92
|
`attach` after the PR exists (`--promote` forces it with no new files,
|
|
93
93
|
`--no-promote` opts out). `uploads github link` inspects or claims the
|
|
94
|
-
workspace↔repo binding the webhook path uses.
|
|
95
|
-
|
|
94
|
+
workspace↔repo binding the webhook path uses. Promotion is copy-and-keep —
|
|
95
|
+
the staged original is never deleted, so any URL already embedded keeps
|
|
96
|
+
serving — and staged objects follow only normal per-workspace retention and
|
|
97
|
+
explicit deletes. Promotion (auto or `--promote`) does skip files staged
|
|
98
|
+
more than 30 days before the PR opens, though; they're still there, just no
|
|
99
|
+
longer auto-promoted.
|
|
100
|
+
|
|
101
|
+
**Bare `put` stages too, by default (issue #403):** on a non-default git
|
|
102
|
+
branch, a `put` with none of
|
|
103
|
+
`--pr`/`--issue`/`--key`/`--ref`/`--prefix`/`--destination` set
|
|
104
|
+
(and not `--no-git`) stages exactly like `attach --branch` — same key,
|
|
105
|
+
same `gh.*` metadata. The classic dated layout
|
|
106
|
+
(`<prefix>/<repo>/<ref-or-date>/<name>`) remains the default branch/detached
|
|
107
|
+
HEAD/non-repo/`--no-git` behavior, and the explicit-flags opt-out.
|
|
96
108
|
|
|
97
109
|
**Screenshot capture:** `uploads screenshot <url|file.html>` renders a page to a
|
|
98
110
|
hosted image in one step — no separate browser tooling needed. `--via auto`
|
package/dist/cli-catalog.js
CHANGED
|
@@ -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
|
package/dist/commands.d.ts
CHANGED
|
@@ -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
|
|
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({
|
|
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
|
-
//
|
|
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):
|
|
1310
|
-
//
|
|
1311
|
-
//
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
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/github.d.ts
CHANGED
|
@@ -123,6 +123,10 @@ export declare const ATTACHMENT_IMAGE_WIDTH_WIDE = 640;
|
|
|
123
123
|
* practical signal (we don't re-fetch dimensions when rebuilding the comment).
|
|
124
124
|
*/
|
|
125
125
|
export declare function attachmentImageWidth(filename: string): number;
|
|
126
|
+
/** Max display width for one image inside a before/after pair row — smaller
|
|
127
|
+
* than a standalone image so two side by side stay under GitHub's comment
|
|
128
|
+
* column width (and don't overflow on mobile). */
|
|
129
|
+
export declare const ATTACHMENT_IMAGE_WIDTH_PAIR = 320;
|
|
126
130
|
/**
|
|
127
131
|
* Render the one marker-owned GitHub comment. When there are no galleries this
|
|
128
132
|
* intentionally preserves the legacy attachment-only body byte-for-byte.
|
package/dist/github.js
CHANGED
|
@@ -255,6 +255,120 @@ function metaCaptionMarkdown(meta) {
|
|
|
255
255
|
return "";
|
|
256
256
|
return ` · ${parts.map((p) => escapeMarkdownText(escapeHtmlText(p))).join(" · ")}`;
|
|
257
257
|
}
|
|
258
|
+
/** Extract the filename stem's before/after token (issue #419 fallback pairing).
|
|
259
|
+
* `base` is the stem lowercased with the token removed; `null` when the stem
|
|
260
|
+
* carries no recognizable before/after token. Requires a separator (`-`, `_`,
|
|
261
|
+
* or `.`) between the token and the rest of the name — except when the token
|
|
262
|
+
* IS the whole stem (`before.png`) — so `beforehand.png` doesn't false-match. */
|
|
263
|
+
// Token bounded by `-`, `_`, `.`, or stem start/end, so `hero-before.webp`
|
|
264
|
+
// and `paired-view-before-desktop.webp` match but `beforehand.webp` does
|
|
265
|
+
// not. Mirrors before-after.ts's TOKEN_RE (file page), applied to the stem.
|
|
266
|
+
const STEM_TOKEN_RE = /(^|[-_.])(before|after)($|[-_.])/i;
|
|
267
|
+
function filenameStemToken(name) {
|
|
268
|
+
const dot = name.lastIndexOf(".");
|
|
269
|
+
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
270
|
+
const m = STEM_TOKEN_RE.exec(stem);
|
|
271
|
+
if (!m)
|
|
272
|
+
return null;
|
|
273
|
+
const state = m[2].toLowerCase();
|
|
274
|
+
const tokenStart = m.index + m[1].length;
|
|
275
|
+
const tokenEnd = tokenStart + m[2].length;
|
|
276
|
+
// Base = stem with the token and one adjoining delimiter removed, so
|
|
277
|
+
// `paired-view-before-desktop` and `paired-view-after-desktop` both
|
|
278
|
+
// collapse to `paired-view-desktop` and group together.
|
|
279
|
+
const base = m[1].length > 0
|
|
280
|
+
? stem.slice(0, m.index) + stem.slice(tokenEnd)
|
|
281
|
+
: stem.slice(tokenEnd + m[3].length);
|
|
282
|
+
return { base: base.toLowerCase(), state };
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Pair up attachments for the before/after side-by-side row (issue #419).
|
|
286
|
+
* `isImageAt[i]` mirrors the renderer's own image test — only images pair;
|
|
287
|
+
* videos and non-image links render exactly as before.
|
|
288
|
+
*
|
|
289
|
+
* Priority order, checked independently per candidate item so rule 2 only
|
|
290
|
+
* ever claims items rule 1 left untouched:
|
|
291
|
+
* 1. Same `path` metadata (trimmed, not bare `/`), one item `state=before`
|
|
292
|
+
* and one `state=after`. Ambiguous groups (more than one of a state)
|
|
293
|
+
* don't pair — no way to know which side goes with which.
|
|
294
|
+
* 2. No usable `path` metadata: filename stems that differ only by a
|
|
295
|
+
* before/after token, same extension. Same ambiguity rule.
|
|
296
|
+
*/
|
|
297
|
+
function pairAttachments(items, isImageAt) {
|
|
298
|
+
const partnerOf = new Map();
|
|
299
|
+
const roleOf = new Map();
|
|
300
|
+
const pair = (beforeIdx, afterIdx) => {
|
|
301
|
+
partnerOf.set(beforeIdx, afterIdx);
|
|
302
|
+
partnerOf.set(afterIdx, beforeIdx);
|
|
303
|
+
roleOf.set(beforeIdx, "before");
|
|
304
|
+
roleOf.set(afterIdx, "after");
|
|
305
|
+
};
|
|
306
|
+
// Priority 1: same path metadata, exactly one before + one after.
|
|
307
|
+
const pathGroups = new Map();
|
|
308
|
+
items.forEach((item, i) => {
|
|
309
|
+
if (!isImageAt[i])
|
|
310
|
+
return;
|
|
311
|
+
const path = item.meta?.path?.trim();
|
|
312
|
+
if (!path || path === "/")
|
|
313
|
+
return;
|
|
314
|
+
const state = item.meta?.state?.trim().toLowerCase();
|
|
315
|
+
if (state !== "before" && state !== "after")
|
|
316
|
+
return;
|
|
317
|
+
const g = pathGroups.get(path) ?? { before: [], after: [] };
|
|
318
|
+
g[state].push(i);
|
|
319
|
+
pathGroups.set(path, g);
|
|
320
|
+
});
|
|
321
|
+
for (const g of pathGroups.values()) {
|
|
322
|
+
if (g.before.length === 1 && g.after.length === 1)
|
|
323
|
+
pair(g.before[0], g.after[0]);
|
|
324
|
+
}
|
|
325
|
+
// Priority 2: no usable path metadata — filename stem token, same extension.
|
|
326
|
+
const stemGroups = new Map();
|
|
327
|
+
items.forEach((item, i) => {
|
|
328
|
+
if (!isImageAt[i] || partnerOf.has(i))
|
|
329
|
+
return;
|
|
330
|
+
const path = item.meta?.path?.trim();
|
|
331
|
+
if (path && path !== "/")
|
|
332
|
+
return; // usable path metadata — rule 1 owns this item
|
|
333
|
+
const name = item.key.slice(item.key.lastIndexOf("/") + 1);
|
|
334
|
+
const tok = filenameStemToken(name);
|
|
335
|
+
if (!tok)
|
|
336
|
+
return;
|
|
337
|
+
const dot = name.lastIndexOf(".");
|
|
338
|
+
const ext = dot > 0 ? name.slice(dot).toLowerCase() : "";
|
|
339
|
+
const key = `${tok.base}${ext}`;
|
|
340
|
+
const g = stemGroups.get(key) ?? { before: [], after: [] };
|
|
341
|
+
g[tok.state].push(i);
|
|
342
|
+
stemGroups.set(key, g);
|
|
343
|
+
});
|
|
344
|
+
for (const g of stemGroups.values()) {
|
|
345
|
+
if (g.before.length === 1 && g.after.length === 1)
|
|
346
|
+
pair(g.before[0], g.after[0]);
|
|
347
|
+
}
|
|
348
|
+
return { partnerOf, roleOf };
|
|
349
|
+
}
|
|
350
|
+
/** Max display width for one image inside a before/after pair row — smaller
|
|
351
|
+
* than a standalone image so two side by side stay under GitHub's comment
|
|
352
|
+
* column width (and don't overflow on mobile). */
|
|
353
|
+
export const ATTACHMENT_IMAGE_WIDTH_PAIR = 320;
|
|
354
|
+
function renderPairCell(item, label) {
|
|
355
|
+
const name = item.key.slice(item.key.lastIndexOf("/") + 1);
|
|
356
|
+
const src = item.embedUrl ?? item.url;
|
|
357
|
+
const link = item.pageUrl ?? item.url;
|
|
358
|
+
const w = Math.min(attachmentImageWidth(name), ATTACHMENT_IMAGE_WIDTH_PAIR);
|
|
359
|
+
const alt = escapeHtmlAttr(name);
|
|
360
|
+
const href = escapeHtmlAttr((link ?? src));
|
|
361
|
+
const imgSrc = escapeHtmlAttr(src);
|
|
362
|
+
const caption = metaCaptionHtml(item.meta);
|
|
363
|
+
const captionHtml = caption ? `<br><sub>${caption}</sub>` : "";
|
|
364
|
+
return `<td align="center"><sub><strong>${label}</strong></sub><br><a href="${href}"><img width="${w}" alt="${alt}" src="${imgSrc}"></a>${captionHtml}</td>`;
|
|
365
|
+
}
|
|
366
|
+
/** One side-by-side before/after row (issue #419): a single HTML table so
|
|
367
|
+
* GitHub renders both images on one line, with `Before`/`After` labels and
|
|
368
|
+
* each side's usual path/state caption preserved underneath. */
|
|
369
|
+
function renderPairRow(beforeItem, afterItem) {
|
|
370
|
+
return `<table><tr>${renderPairCell(beforeItem, "Before")}${renderPairCell(afterItem, "After")}</tr></table>`;
|
|
371
|
+
}
|
|
258
372
|
/**
|
|
259
373
|
* Render the one marker-owned GitHub comment. When there are no galleries this
|
|
260
374
|
* intentionally preserves the legacy attachment-only body byte-for-byte.
|
|
@@ -279,9 +393,36 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
|
|
|
279
393
|
}
|
|
280
394
|
if (sorted.length > 0 || sortedGalleries.length === 0)
|
|
281
395
|
lines.push("### 📎 Attachments", "");
|
|
396
|
+
const isImageAt = sorted.map((item) => {
|
|
397
|
+
const name = item.key.slice(item.key.lastIndexOf("/") + 1);
|
|
398
|
+
const src = item.embedUrl ?? item.url;
|
|
399
|
+
return Boolean(src) && inferContentType(name).startsWith("image/");
|
|
400
|
+
});
|
|
401
|
+
const { partnerOf, roleOf } = pairAttachments(sorted, isImageAt);
|
|
402
|
+
const consumedByPair = new Set();
|
|
282
403
|
let inlinedImages = 0;
|
|
283
404
|
const overflowImages = [];
|
|
284
|
-
for (
|
|
405
|
+
for (let idx = 0; idx < sorted.length; idx++) {
|
|
406
|
+
if (consumedByPair.has(idx))
|
|
407
|
+
continue;
|
|
408
|
+
const item = sorted[idx];
|
|
409
|
+
const partnerIdx = partnerOf.get(idx);
|
|
410
|
+
if (partnerIdx !== undefined) {
|
|
411
|
+
const partner = sorted[partnerIdx];
|
|
412
|
+
if (inlinedImages + 2 <= MAX_INLINE_ATTACHMENT_IMAGES) {
|
|
413
|
+
inlinedImages += 2;
|
|
414
|
+
consumedByPair.add(partnerIdx);
|
|
415
|
+
const beforeItem = roleOf.get(idx) === "before" ? item : partner;
|
|
416
|
+
const afterItem = roleOf.get(idx) === "before" ? partner : item;
|
|
417
|
+
lines.push(renderPairRow(beforeItem, afterItem), "");
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
// Cap already full for a two-image row — degrade this pair to two
|
|
421
|
+
// ordinary overflow entries rather than only half-rendering the row.
|
|
422
|
+
overflowImages.push(item, partner);
|
|
423
|
+
consumedByPair.add(partnerIdx);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
285
426
|
const name = item.key.slice(item.key.lastIndexOf("/") + 1);
|
|
286
427
|
const stable = item.url;
|
|
287
428
|
const src = item.embedUrl ?? item.url;
|
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.",
|