@buildinternet/uploads 0.48.1 → 0.50.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 +3 -1
- package/dist/cli-help.js +2 -0
- package/dist/client.d.ts +2 -0
- package/dist/commands/completion.js +5 -1
- package/dist/commands/mcp.js +1 -1
- package/dist/commands.d.ts +10 -1
- package/dist/commands.js +132 -34
- package/dist/comment-render.generated.d.ts +10 -0
- package/dist/comment-render.generated.js +65 -9
- package/dist/fetch-upload-source.d.ts +35 -0
- package/dist/fetch-upload-source.js +185 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp/args.d.ts +6 -6
- package/dist/mcp/args.js +8 -6
- package/dist/mcp/output-schemas.d.ts +1 -1
- package/dist/mcp/output-schemas.js +25 -4
- package/dist/mcp/server.d.ts +5 -5
- package/dist/mcp/server.js +5 -5
- package/dist/mcp/tools.d.ts +1 -1
- package/dist/mcp/tools.js +100 -34
- package/dist/private-host.d.ts +14 -0
- package/dist/private-host.js +92 -0
- package/dist/screenshot.d.ts +2 -8
- package/dist/screenshot.js +2 -51
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,6 +27,8 @@ uploads put ./ui.png --frame browser --frame-url "https://app.example"
|
|
|
27
27
|
uploads put ./after.png --pr 123
|
|
28
28
|
uploads put ./capture-2026-…Z.png --pr 123 --name hero.png # clean leaf, stable path
|
|
29
29
|
uploads put ./shot.png --pr 123 --name hero.png --dry-run --format url # preview URL, no upload
|
|
30
|
+
uploads put --url https://cdn.example/shot.png --pr 123
|
|
31
|
+
uploads put --url http://localhost:4321/shot.png
|
|
30
32
|
uploads gallery create --title "Release screenshots"
|
|
31
33
|
uploads put ./after.png --gallery gal_example
|
|
32
34
|
# custom metadata (queryable): page URL, in-app path, which surface
|
|
@@ -196,7 +198,7 @@ Config layers (first match wins): CLI flags → env vars → `--env-file` → `~
|
|
|
196
198
|
|
|
197
199
|
## MCP server
|
|
198
200
|
|
|
199
|
-
`uploads mcp` serves the Model Context Protocol over stdio (newline-delimited JSON-RPC, no extra dependencies). Tools include file operations plus public gallery workflows: `gallery_create`, `gallery_get`, `gallery_add`, `gallery_link`, and `gallery_find_by_reference`. Gallery tools return API-provided canonical URLs and never need GitHub credentials. The remaining stdio tools are `put`, `attach`, `list`, `delete`, `get_metadata`, `set_metadata`, `find_files`, `usage`, `reconcile`, `purge_expired`, `comment`, `
|
|
201
|
+
`uploads mcp` serves the Model Context Protocol over stdio (newline-delimited JSON-RPC, no extra dependencies). Tools include file operations plus public gallery workflows: `gallery_create`, `gallery_get`, `gallery_add`, `gallery_link`, and `gallery_find_by_reference`. Gallery tools return API-provided canonical URLs and never need GitHub credentials. The remaining stdio tools are `put`, `attach`, `list`, `delete`, `get_metadata`, `set_metadata`, `find_files`, `usage`, `reconcile`, `purge_expired`, `comment`, `whoami`, and `doctor` — with the same config resolution and defaults, plus a per-call `workspace` argument. `put` and `attach` accept a `metadata` param (same `gh.*` auto-injection as the CLI's `attach`); `get_metadata`, `set_metadata`, and `find_files` mirror `uploads meta get` / `meta set` / `find`. Interactive/credential commands (`setup`, `login`, `admin`, `config`) are not exposed. A token isn't required to start the server; auth errors surface per tool call (`whoami` needs no auth).
|
|
200
202
|
|
|
201
203
|
```json
|
|
202
204
|
{ "command": "uploads", "args": ["--env-file", "/path/to/.env", "mcp"] }
|
package/dist/cli-help.js
CHANGED
|
@@ -106,6 +106,7 @@ ${section(style, "Examples:")}
|
|
|
106
106
|
${style.command("uploads whoami")}
|
|
107
107
|
${style.command("uploads put")} ./shot.png --pr 123 --name hero.png
|
|
108
108
|
${style.command("uploads put")} ./after.png --pr 123
|
|
109
|
+
${style.command("uploads put")} --url https://cdn.example/shot.png --pr 123
|
|
109
110
|
${style.command("uploads put")} ./bug.png --issue 45
|
|
110
111
|
${style.command("uploads put")} ./shot.png --meta path=/settings --state after
|
|
111
112
|
${style.command("uploads attach")} ./before.png ./after.png
|
|
@@ -155,6 +156,7 @@ ${section(style, "Examples:")}
|
|
|
155
156
|
${style.command("uploads whoami")}
|
|
156
157
|
${style.command("uploads put")} ./shot.png --pr 123 --name hero.png
|
|
157
158
|
${style.command("uploads put")} ./after.png --pr 123
|
|
159
|
+
${style.command("uploads put")} --url https://cdn.example/shot.png --pr 123
|
|
158
160
|
${style.command("uploads put")} ./bug.png --issue 45 --repo myorg/myapp
|
|
159
161
|
${style.command("uploads put")} ./shot.png --dry-run --format url
|
|
160
162
|
${style.command("uploads put")} ./shot.png --meta path=/settings --state after
|
package/dist/client.d.ts
CHANGED
|
@@ -441,6 +441,8 @@ export interface UsageResult {
|
|
|
441
441
|
uploadsRemaining?: number;
|
|
442
442
|
/** Bytes still on hosted storage (shared-lane residue). */
|
|
443
443
|
sharedBytes?: number;
|
|
444
|
+
/** Objects still on hosted storage (shared-lane residue). */
|
|
445
|
+
sharedObjects?: number;
|
|
444
446
|
/** "shared" = BYO bucket active: the storage cap meters only hosted
|
|
445
447
|
* residue; the customer's own bucket is unmetered. */
|
|
446
448
|
storageBudgetBasis?: "total" | "shared";
|
|
@@ -124,7 +124,10 @@ ${subMaps.join("\n")}
|
|
|
124
124
|
|
|
125
125
|
if [[ "$cur" == -* ]]; then
|
|
126
126
|
case "$cmd" in
|
|
127
|
-
put
|
|
127
|
+
put)
|
|
128
|
+
COMPREPLY=( $(compgen -W "\${put_flags[*]} --url" -- "$cur") )
|
|
129
|
+
;;
|
|
130
|
+
attach)
|
|
128
131
|
COMPREPLY=( $(compgen -W "\${put_flags[*]}" -- "$cur") )
|
|
129
132
|
;;
|
|
130
133
|
screenshot)
|
|
@@ -284,6 +287,7 @@ function fishScript() {
|
|
|
284
287
|
continue;
|
|
285
288
|
lines.push(`complete -c uploads -n '__fish_seen_subcommand_from put attach' -l ${flag.slice(2)}`);
|
|
286
289
|
}
|
|
290
|
+
lines.push(`complete -c uploads -n '__fish_seen_subcommand_from put' -l url -r`);
|
|
287
291
|
for (const flag of SCREENSHOT_FLAGS) {
|
|
288
292
|
if (!flag.startsWith("--"))
|
|
289
293
|
continue;
|
package/dist/commands/mcp.js
CHANGED
|
@@ -10,7 +10,7 @@ const MCP_HELP = `uploads [globals] mcp
|
|
|
10
10
|
|
|
11
11
|
Serve the Model Context Protocol (MCP) over stdio for agent clients. Tools
|
|
12
12
|
mirror the CLI commands: put, attach, list, delete, usage, reconcile,
|
|
13
|
-
purge_expired, comment,
|
|
13
|
+
purge_expired, comment, whoami, doctor.
|
|
14
14
|
Global flags before "mcp" (--api-url, --token, --workspace, --env-file)
|
|
15
15
|
configure every tool call; a per-call "workspace" argument overrides
|
|
16
16
|
--workspace, like the CLI's per-command flag.
|
package/dist/commands.d.ts
CHANGED
|
@@ -350,7 +350,16 @@ export type PutUploadItem = PutResult & {
|
|
|
350
350
|
*/
|
|
351
351
|
export declare function uploadPuts(opts: {
|
|
352
352
|
client: UploadsClient;
|
|
353
|
-
files
|
|
353
|
+
files?: readonly string[];
|
|
354
|
+
/**
|
|
355
|
+
* In-memory bodies (CLI `--url`, MCP `contentUrl`). Mutually exclusive
|
|
356
|
+
* with `files`. `source` is the failure/progress label (the URL).
|
|
357
|
+
*/
|
|
358
|
+
byteSources?: readonly {
|
|
359
|
+
bytes: Uint8Array;
|
|
360
|
+
filename: string;
|
|
361
|
+
source: string;
|
|
362
|
+
}[];
|
|
354
363
|
/** Single-file --name leaf override. */
|
|
355
364
|
nameOverride?: string;
|
|
356
365
|
/** Single-file --key. */
|
package/dist/commands.js
CHANGED
|
@@ -8,6 +8,7 @@ import { buildUploadMarkdown } from "./embed.js";
|
|
|
8
8
|
import { readLocalRepoCommentConfig, resolveCommentOptions } from "./comment-config.js";
|
|
9
9
|
import { urlForGithubEmbed } from "./public-urls.js";
|
|
10
10
|
import { UploadsError } from "./errors.js";
|
|
11
|
+
import { fetchUploadSource, resolveUploadFilename } from "./fetch-upload-source.js";
|
|
11
12
|
import { writeJson, writeStdout } from "./io.js";
|
|
12
13
|
import { imageFactsFromBytes } from "./image-facts.js";
|
|
13
14
|
import { parseMetaFlags, validateMetaMap } from "./metadata.js";
|
|
@@ -94,8 +95,12 @@ export function readFileArg(fileArg) {
|
|
|
94
95
|
}
|
|
95
96
|
// --- put ---
|
|
96
97
|
const PUT_HELP = `uploads put <file...> [options]
|
|
98
|
+
uploads put --url <url> [options]
|
|
97
99
|
|
|
98
100
|
Upload one or more images for GitHub embeds. Use "-" for stdin (single file only).
|
|
101
|
+
Pass --url (repeatable) to fetch a file instead of a local path. Public HTTPS,
|
|
102
|
+
or http://localhost / 127.0.0.1 / *.localhost on this machine. Other private
|
|
103
|
+
hosts are rejected. The filename comes from the URL path, or --name.
|
|
99
104
|
|
|
100
105
|
Multiple files upload in parallel (bounded concurrency). One bad file does not
|
|
101
106
|
block the rest; multi-file JSON is { uploads, failures } (exit 1 when any failed).
|
|
@@ -135,6 +140,7 @@ MARKDOWN prefers embedUrl for GitHub. Override: UPLOADS_EMBED_PUBLIC_BASE_URL.
|
|
|
135
140
|
Options:
|
|
136
141
|
--key <key> Object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.<ext>). Single file only
|
|
137
142
|
--name <leaf> Clean key leaf + default alt (no '/'); keeps --pr/default path. Single file only. Not with --key
|
|
143
|
+
--url <url> Fetch this URL and upload its body (repeatable). Public HTTPS, or http://localhost on the CLI. Not with file arguments
|
|
138
144
|
--destination <id> Typed root: screenshots | gh | f (sets --prefix)
|
|
139
145
|
--prefix <path> Key prefix (default: screenshots, or UPLOADS_DEFAULT_PREFIX)
|
|
140
146
|
--repo <owner/repo> Repo segment (default: git remote, or UPLOADS_DEFAULT_REPO)
|
|
@@ -201,6 +207,8 @@ Examples:
|
|
|
201
207
|
uploads put ./shot.png --pr 128 --name hero.webp --dry-run --format url
|
|
202
208
|
uploads put ./after.png --gallery gal_example
|
|
203
209
|
uploads put ./shot.png --meta path=/settings --state after --app web
|
|
210
|
+
uploads put --url https://cdn.example/shot.png --pr 128 --name hero.png
|
|
211
|
+
uploads put --url http://localhost:4321/shot.png
|
|
204
212
|
`;
|
|
205
213
|
/**
|
|
206
214
|
* Turns a pr/issue pair (+ optional repo) into a GhTarget; undefined when
|
|
@@ -641,6 +649,13 @@ export async function syncAttachmentsComment(client, target, run, workspace, opt
|
|
|
641
649
|
// byte-identical.
|
|
642
650
|
const path = metadata?.path;
|
|
643
651
|
const state = metadata?.state;
|
|
652
|
+
// Server-derived image dimensions (parity with the bot path's
|
|
653
|
+
// COMMENT_META_KEYS hydration): finite positive numbers only, field
|
|
654
|
+
// omitted entirely when neither parses.
|
|
655
|
+
const imgWidth = Number(metadata?.["image.width"]);
|
|
656
|
+
const imgHeight = Number(metadata?.["image.height"]);
|
|
657
|
+
const hasImgWidth = Number.isFinite(imgWidth) && imgWidth > 0;
|
|
658
|
+
const hasImgHeight = Number.isFinite(imgHeight) && imgHeight > 0;
|
|
644
659
|
return {
|
|
645
660
|
key,
|
|
646
661
|
url,
|
|
@@ -649,6 +664,14 @@ export async function syncAttachmentsComment(client, target, run, workspace, opt
|
|
|
649
664
|
...(path || state
|
|
650
665
|
? { meta: { ...(path ? { path } : {}), ...(state ? { state } : {}) } }
|
|
651
666
|
: {}),
|
|
667
|
+
...(hasImgWidth || hasImgHeight
|
|
668
|
+
? {
|
|
669
|
+
imageMeta: {
|
|
670
|
+
...(hasImgWidth ? { width: imgWidth } : {}),
|
|
671
|
+
...(hasImgHeight ? { height: imgHeight } : {}),
|
|
672
|
+
},
|
|
673
|
+
}
|
|
674
|
+
: {}),
|
|
652
675
|
};
|
|
653
676
|
}));
|
|
654
677
|
const galleries = [];
|
|
@@ -1027,13 +1050,21 @@ function errorDetail(err) {
|
|
|
1027
1050
|
* Same partial-failure shape as uploadAttachments.
|
|
1028
1051
|
*/
|
|
1029
1052
|
export async function uploadPuts(opts) {
|
|
1030
|
-
|
|
1053
|
+
const files = opts.files ?? [];
|
|
1054
|
+
const byteSources = opts.byteSources ?? [];
|
|
1055
|
+
if (files.length > 0 && byteSources.length > 0) {
|
|
1056
|
+
throw new UsageError("internal: uploadPuts files and byteSources are mutually exclusive");
|
|
1057
|
+
}
|
|
1058
|
+
const count = files.length + byteSources.length;
|
|
1059
|
+
if (count === 0)
|
|
1060
|
+
throw new UsageError("put requires at least one file");
|
|
1061
|
+
if (count > 1 && files.some((f) => f === "-")) {
|
|
1031
1062
|
throw new UsageError("stdin (-) cannot be combined with multiple file arguments");
|
|
1032
1063
|
}
|
|
1033
|
-
if (
|
|
1064
|
+
if (count > 1 && opts.explicitKey) {
|
|
1034
1065
|
throw new UsageError("--key cannot be combined with multiple files");
|
|
1035
1066
|
}
|
|
1036
|
-
if (
|
|
1067
|
+
if (count > 1 && opts.nameOverride) {
|
|
1037
1068
|
throw new UsageError("--name cannot be combined with multiple files");
|
|
1038
1069
|
}
|
|
1039
1070
|
// Resolved once for the whole batch (issue #631) — never per file.
|
|
@@ -1048,18 +1079,33 @@ export async function uploadPuts(opts) {
|
|
|
1048
1079
|
branch: opts.ghBranchTarget.branch,
|
|
1049
1080
|
})
|
|
1050
1081
|
: undefined;
|
|
1051
|
-
const
|
|
1082
|
+
const items = byteSources.length > 0
|
|
1083
|
+
? byteSources.map((s) => ({
|
|
1084
|
+
source: s.source,
|
|
1085
|
+
filename: opts.nameOverride ?? s.filename,
|
|
1086
|
+
bytes: s.bytes,
|
|
1087
|
+
}))
|
|
1088
|
+
: files.map((file) => ({
|
|
1089
|
+
source: file,
|
|
1090
|
+
path: file,
|
|
1091
|
+
}));
|
|
1092
|
+
const slots = await mapBounded(items, opts.concurrency ?? UPLOAD_BATCH_CONCURRENCY, async (item) => {
|
|
1093
|
+
const file = item.source;
|
|
1052
1094
|
try {
|
|
1053
|
-
const
|
|
1095
|
+
const bytes = item.bytes ?? readFileArg(item.path ?? file);
|
|
1096
|
+
const sourceName = item.filename ??
|
|
1097
|
+
opts.nameOverride ??
|
|
1054
1098
|
(file === "-"
|
|
1055
1099
|
? opts.explicitKey
|
|
1056
1100
|
? basename(opts.explicitKey)
|
|
1057
1101
|
: "stdin.bin"
|
|
1058
1102
|
: basename(file));
|
|
1059
|
-
const bytes = readFileArg(file);
|
|
1060
1103
|
// Sidecar manifest from a prior `screenshot --out` of this exact file
|
|
1061
|
-
// (issue #469 lever 2) — see mergeSidecarMeta. Not applicable to stdin
|
|
1062
|
-
|
|
1104
|
+
// (issue #469 lever 2) — see mergeSidecarMeta. Not applicable to stdin
|
|
1105
|
+
// or URL fetches.
|
|
1106
|
+
const metadata = item.path && item.path !== "-"
|
|
1107
|
+
? mergeSidecarMeta(item.path, bytes, opts.metadata)
|
|
1108
|
+
: opts.metadata;
|
|
1063
1109
|
const { result, prepared, markdown, sentMetadata } = await uploadPreparedImage(opts.client, bytes, sourceName, {
|
|
1064
1110
|
frame: opts.frame,
|
|
1065
1111
|
optimize: opts.optimize,
|
|
@@ -1883,12 +1929,23 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
1883
1929
|
return 0;
|
|
1884
1930
|
}
|
|
1885
1931
|
const files = parsed.positionals;
|
|
1886
|
-
if (
|
|
1887
|
-
throw new UsageError("
|
|
1932
|
+
if (parsed.flags.get("--url") === true) {
|
|
1933
|
+
throw new UsageError("missing value for --url", {
|
|
1934
|
+
example: "uploads put --url https://cdn.example/shot.png --pr 123",
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1937
|
+
const urlArgs = flagValues(parsed.flags, "--url");
|
|
1938
|
+
if (files.length > 0 && urlArgs.length > 0) {
|
|
1939
|
+
throw new UsageError("--url cannot be combined with file arguments", {
|
|
1940
|
+
example: "uploads put --url https://cdn.example/shot.png --pr 123",
|
|
1941
|
+
});
|
|
1942
|
+
}
|
|
1943
|
+
if (files.length === 0 && urlArgs.length === 0) {
|
|
1944
|
+
throw new UsageError("put requires at least one file or --url", {
|
|
1888
1945
|
example: "uploads put ./shot.png --pr 123",
|
|
1889
1946
|
});
|
|
1890
1947
|
}
|
|
1891
|
-
const multi = files.length > 1;
|
|
1948
|
+
const multi = files.length > 1 || urlArgs.length > 1;
|
|
1892
1949
|
// Resolved early (issue #700): both the auto-PR opt-out default and the
|
|
1893
1950
|
// `--no-git`-gated staging/auto-PR detection below need it before the rest
|
|
1894
1951
|
// of put's flag parsing.
|
|
@@ -2135,36 +2192,77 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
2135
2192
|
const logHuman = !ctx.quiet && format === "human";
|
|
2136
2193
|
if (logHuman) {
|
|
2137
2194
|
if (multi) {
|
|
2138
|
-
|
|
2195
|
+
const n = files.length > 0 ? files.length : urlArgs.length;
|
|
2196
|
+
process.stderr.write(`>> ${dryRun ? "dry run for" : "uploading"} ${n} files\n`);
|
|
2139
2197
|
}
|
|
2140
2198
|
else {
|
|
2141
|
-
const fileArg = files[0];
|
|
2199
|
+
const fileArg = files[0] ?? urlArgs[0];
|
|
2142
2200
|
process.stderr.write(`>> ${dryRun ? "dry run" : "uploading"} ${fileArg === "-" ? "stdin" : fileArg}\n`);
|
|
2143
2201
|
}
|
|
2144
2202
|
if (attachedRef)
|
|
2145
2203
|
process.stderr.write(`>> attached to ${attachedRef}\n`);
|
|
2146
2204
|
}
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2205
|
+
let byteSources;
|
|
2206
|
+
const urlFetchFailures = [];
|
|
2207
|
+
let urlFetchFirstError;
|
|
2208
|
+
if (urlArgs.length > 0) {
|
|
2209
|
+
byteSources = [];
|
|
2210
|
+
for (const raw of urlArgs) {
|
|
2211
|
+
try {
|
|
2212
|
+
const filename = resolveUploadFilename(raw, !multi ? nameFlag : undefined, "--url", {
|
|
2213
|
+
allowLoopback: true,
|
|
2214
|
+
});
|
|
2215
|
+
const bytes = await fetchUploadSource(raw, {
|
|
2216
|
+
label: "--url",
|
|
2217
|
+
userAgent: "uploads.sh/cli",
|
|
2218
|
+
allowLoopback: true,
|
|
2219
|
+
});
|
|
2220
|
+
byteSources.push({ bytes, filename, source: raw });
|
|
2221
|
+
}
|
|
2222
|
+
catch (err) {
|
|
2223
|
+
urlFetchFirstError ??= err;
|
|
2224
|
+
urlFetchFailures.push({ file: raw, error: errorDetail(err) });
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
let uploads;
|
|
2229
|
+
let failures;
|
|
2230
|
+
let firstError;
|
|
2231
|
+
let sentMetadata;
|
|
2232
|
+
if (byteSources && byteSources.length === 0) {
|
|
2233
|
+
uploads = [];
|
|
2234
|
+
failures = urlFetchFailures;
|
|
2235
|
+
firstError = urlFetchFirstError;
|
|
2236
|
+
sentMetadata = [];
|
|
2237
|
+
}
|
|
2238
|
+
else {
|
|
2239
|
+
const batch = await uploadPuts({
|
|
2240
|
+
client: ctx.client,
|
|
2241
|
+
files: byteSources ? undefined : files,
|
|
2242
|
+
byteSources,
|
|
2243
|
+
nameOverride: byteSources ? undefined : nameFlag,
|
|
2244
|
+
explicitKey: keyHint,
|
|
2245
|
+
ghTarget: effectiveGhTarget,
|
|
2246
|
+
ghBranchTarget: stagingTarget,
|
|
2247
|
+
prefix: resolvedPrefix ?? defaults.prefix,
|
|
2248
|
+
repo: flagString(parsed.flags, "--repo") ?? defaults.repo,
|
|
2249
|
+
ref: flagString(parsed.flags, "--ref") ?? defaults.ref,
|
|
2250
|
+
deriveRepoFromGit: !noGit,
|
|
2251
|
+
contentType: contentTypeOverride,
|
|
2252
|
+
dryRun,
|
|
2253
|
+
replace: replaceFlag,
|
|
2254
|
+
optimize: optimizeOpts,
|
|
2255
|
+
frame: frameOpts,
|
|
2256
|
+
metadata,
|
|
2257
|
+
deriveImageFacts: derivedMetaEnabled(parsed.flags, defaults),
|
|
2258
|
+
alt: altFlag,
|
|
2259
|
+
width,
|
|
2260
|
+
});
|
|
2261
|
+
uploads = batch.uploads;
|
|
2262
|
+
failures = [...urlFetchFailures, ...batch.failures];
|
|
2263
|
+
firstError = urlFetchFirstError ?? batch.firstError;
|
|
2264
|
+
sentMetadata = batch.sentMetadata;
|
|
2265
|
+
}
|
|
2168
2266
|
// Single-file total failure: rethrow so CLI exit codes stay auth/network-aware.
|
|
2169
2267
|
if (uploads.length === 0 && failures.length > 0 && !multi) {
|
|
2170
2268
|
throw firstError instanceof Error ? firstError : new Error(String(firstError));
|
|
@@ -107,6 +107,11 @@ export interface AttachmentItem {
|
|
|
107
107
|
width?: number;
|
|
108
108
|
height?: number;
|
|
109
109
|
};
|
|
110
|
+
/** Server-derived pixel dimensions for an image (never client-settable). */
|
|
111
|
+
imageMeta?: {
|
|
112
|
+
width?: number;
|
|
113
|
+
height?: number;
|
|
114
|
+
};
|
|
110
115
|
}
|
|
111
116
|
/** A public gallery linked to the PR or issue whose managed comment is syncing. */
|
|
112
117
|
export interface GalleryCommentItem {
|
|
@@ -144,6 +149,11 @@ export declare function attachmentPairWidth(density?: AttachmentDensity): number
|
|
|
144
149
|
* only affects managed-comment auto layout; other callers leave it `"dense"`.
|
|
145
150
|
*/
|
|
146
151
|
export declare function attachmentImageWidth(filename: string, density?: AttachmentDensity): number;
|
|
152
|
+
/**
|
|
153
|
+
* Icons and other small assets: both dimensions known and no edge above this
|
|
154
|
+
* flow inline at natural size rather than each becoming a full row.
|
|
155
|
+
*/
|
|
156
|
+
export declare const SMALL_ASSET_MAX_EDGE = 200;
|
|
147
157
|
/**
|
|
148
158
|
* Render the one marker-owned GitHub comment. When there are no galleries this
|
|
149
159
|
* intentionally preserves the legacy attachment-only body byte-for-byte.
|
|
@@ -183,19 +183,32 @@ function formatDuration(seconds) {
|
|
|
183
183
|
return `${h}:${String(m).padStart(2, "0")}:${ss}`;
|
|
184
184
|
}
|
|
185
185
|
/**
|
|
186
|
-
* Display width for
|
|
187
|
-
* density table's tiers — a raw 1920 would blow out the
|
|
188
|
-
* the result is capped at the real width so a small
|
|
186
|
+
* Display width for an image or video-poster embed. Real dimensions only
|
|
187
|
+
* *select* among the density table's tiers — a raw 1920 would blow out the
|
|
188
|
+
* comment column — and the result is capped at the real width so a small
|
|
189
|
+
* asset is never upscaled into a blurry tile. Without dims (either missing),
|
|
190
|
+
* falls back to the filename heuristic exactly, keeping dimension-less
|
|
191
|
+
* renders byte-identical to before dims existed.
|
|
189
192
|
*/
|
|
190
|
-
function
|
|
191
|
-
const w =
|
|
192
|
-
const h =
|
|
193
|
+
function naturalMediaWidth(dims, filename, density = "dense") {
|
|
194
|
+
const w = dims?.width ?? 0;
|
|
195
|
+
const h = dims?.height ?? 0;
|
|
193
196
|
if (w <= 0 || h <= 0)
|
|
194
197
|
return attachmentImageWidth(filename, density);
|
|
195
198
|
const table = WIDTH_BY_DENSITY[density];
|
|
196
199
|
const chosen = h > w ? table.portrait : w / h >= 16 / 9 ? table.wide : table.default;
|
|
197
200
|
return Math.min(chosen, w);
|
|
198
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* Icons and other small assets: both dimensions known and no edge above this
|
|
204
|
+
* flow inline at natural size rather than each becoming a full row.
|
|
205
|
+
*/
|
|
206
|
+
export const SMALL_ASSET_MAX_EDGE = 200;
|
|
207
|
+
function isSmallAsset(dims) {
|
|
208
|
+
const w = dims?.width ?? 0;
|
|
209
|
+
const h = dims?.height ?? 0;
|
|
210
|
+
return w > 0 && h > 0 && Math.max(w, h) <= SMALL_ASSET_MAX_EDGE;
|
|
211
|
+
}
|
|
199
212
|
function escapeHtmlAttr(s) {
|
|
200
213
|
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
201
214
|
}
|
|
@@ -360,7 +373,7 @@ function renderPairCell(item, label, options, density) {
|
|
|
360
373
|
const name = item.key.slice(item.key.lastIndexOf("/") + 1);
|
|
361
374
|
const src = item.embedUrl ?? item.url;
|
|
362
375
|
const link = item.pageUrl ?? item.url;
|
|
363
|
-
const autoPx = Math.min(
|
|
376
|
+
const autoPx = Math.min(naturalMediaWidth(item.imageMeta, name, density), attachmentPairWidth(density));
|
|
364
377
|
const w = resolvedWidth(autoPx, options);
|
|
365
378
|
const alt = escapeHtmlAttr(name);
|
|
366
379
|
const href = escapeHtmlAttr((link ?? src));
|
|
@@ -480,7 +493,7 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
|
|
|
480
493
|
}
|
|
481
494
|
if (isPosterVideo) {
|
|
482
495
|
inlinedImages++;
|
|
483
|
-
const autoPx =
|
|
496
|
+
const autoPx = naturalMediaWidth(item.videoMeta, name, density);
|
|
484
497
|
const w = resolvedWidth(autoPx, options);
|
|
485
498
|
const href = escapeHtmlAttr(link ?? item.posterUrl);
|
|
486
499
|
lines.push(`<a href="${href}">${imgTag(w, escapeHtmlAttr(name), escapeHtmlAttr(item.posterUrl))}</a>`);
|
|
@@ -496,10 +509,53 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
|
|
|
496
509
|
lines.push(parts.join(" · "), "");
|
|
497
510
|
}
|
|
498
511
|
else if (isImage) {
|
|
512
|
+
// Small-asset flow layout: consecutive captionless small images (icons)
|
|
513
|
+
// join onto one line so GitHub flows them horizontally at natural size
|
|
514
|
+
// instead of stacking a giant column per icon. Auto mode only — a
|
|
515
|
+
// numeric imageWidth override or "full" keeps today's one-per-row form.
|
|
516
|
+
const smallGroupable = (candidate, i) => {
|
|
517
|
+
if (options.imageWidth !== "auto")
|
|
518
|
+
return false;
|
|
519
|
+
if (consumedByPair.has(i) || partnerOf.has(i))
|
|
520
|
+
return false;
|
|
521
|
+
if (!isSmallAsset(candidate.imageMeta))
|
|
522
|
+
return false;
|
|
523
|
+
if (metaCaptionValues(candidate.meta, options).length > 0)
|
|
524
|
+
return false;
|
|
525
|
+
const n = candidate.key.slice(candidate.key.lastIndexOf("/") + 1);
|
|
526
|
+
const s = candidate.embedUrl ?? candidate.url;
|
|
527
|
+
return Boolean(s) && inferContentType(n).startsWith("image/");
|
|
528
|
+
};
|
|
529
|
+
if (smallGroupable(item, idx)) {
|
|
530
|
+
const group = [item];
|
|
531
|
+
let j = idx + 1;
|
|
532
|
+
// Each grouped image still counts toward maxInlineImages.
|
|
533
|
+
while (j < sorted.length &&
|
|
534
|
+
inlinedImages + group.length < options.maxInlineImages &&
|
|
535
|
+
smallGroupable(sorted[j], j)) {
|
|
536
|
+
group.push(sorted[j]);
|
|
537
|
+
j++;
|
|
538
|
+
}
|
|
539
|
+
if (group.length > 1) {
|
|
540
|
+
inlinedImages += group.length;
|
|
541
|
+
lines.push(group
|
|
542
|
+
.map((g) => {
|
|
543
|
+
const gName = g.key.slice(g.key.lastIndexOf("/") + 1);
|
|
544
|
+
const gSrc = (g.embedUrl ?? g.url);
|
|
545
|
+
const gHref = escapeHtmlAttr((g.pageUrl ?? g.url ?? gSrc));
|
|
546
|
+
// Natural width — a small asset is never upscaled.
|
|
547
|
+
const gW = g.imageMeta?.width;
|
|
548
|
+
return `<a href="${gHref}">${imgTag(gW, escapeHtmlAttr(gName), escapeHtmlAttr(gSrc))}</a>`;
|
|
549
|
+
})
|
|
550
|
+
.join(" "), "");
|
|
551
|
+
idx = j - 1;
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
499
555
|
inlinedImages++;
|
|
500
556
|
// Markdown ![]() has no width control — phone frames become full-column giants.
|
|
501
557
|
// img src uses embed host when available (Camo revalidates); click-through prefers the file page.
|
|
502
|
-
const autoPx =
|
|
558
|
+
const autoPx = naturalMediaWidth(item.imageMeta, name, density);
|
|
503
559
|
const w = resolvedWidth(autoPx, options);
|
|
504
560
|
const alt = escapeHtmlAttr(name);
|
|
505
561
|
const href = escapeHtmlAttr(link ?? src);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export declare const FETCH_UPLOAD_SOURCE_TIMEOUT_MS = 15000;
|
|
2
|
+
export declare const FETCH_UPLOAD_SOURCE_MAX_REDIRECTS = 5;
|
|
3
|
+
/** Client-side cap when the caller does not pass a workspace policy ceiling. */
|
|
4
|
+
export declare const FETCH_UPLOAD_SOURCE_DEFAULT_MAX_BYTES: number;
|
|
5
|
+
export interface FetchableUploadUrlOptions {
|
|
6
|
+
/**
|
|
7
|
+
* CLI / stdio MCP only. Permit loopback (`localhost`, `*.localhost`,
|
|
8
|
+
* `127.0.0.0/8`, `::1`) and `http` on those hosts. LAN, link-local, and
|
|
9
|
+
* `.internal` stay rejected. Hosted MCP must not set this.
|
|
10
|
+
*/
|
|
11
|
+
allowLoopback?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface FetchUploadSourceOptions extends FetchableUploadUrlOptions {
|
|
14
|
+
maxBytes?: number;
|
|
15
|
+
fetch?: typeof fetch;
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
/** Human label in errors (`--url`, `contentUrl`). */
|
|
19
|
+
label?: string;
|
|
20
|
+
userAgent?: string;
|
|
21
|
+
}
|
|
22
|
+
/** Parse and reject URLs we will not fetch. Used on the original URL and every redirect. */
|
|
23
|
+
export declare function assertFetchableUploadUrl(raw: string, label?: string, opts?: FetchableUploadUrlOptions): URL;
|
|
24
|
+
/** Filename leaf from a URL path (`https://cdn.example/a/shot.png?x=1` → `shot.png`). */
|
|
25
|
+
export declare function filenameFromUploadUrl(url: URL): string | undefined;
|
|
26
|
+
/** `filename` if given, else the URL path leaf. Throws USAGE when neither works. */
|
|
27
|
+
export declare function resolveUploadFilename(rawUrl: string, filename: string | undefined, label?: string, opts?: FetchableUploadUrlOptions): string;
|
|
28
|
+
/**
|
|
29
|
+
* GET `url` and return the body bytes, capped at `maxBytes`.
|
|
30
|
+
*
|
|
31
|
+
* Redirects are followed manually so each hop is re-validated (scheme, no
|
|
32
|
+
* credentials, host policy). A public origin cannot redirect onto loopback
|
|
33
|
+
* even when `allowLoopback` is set. Auth headers are never forwarded.
|
|
34
|
+
*/
|
|
35
|
+
export declare function fetchUploadSource(raw: string, opts?: FetchUploadSourceOptions): Promise<Uint8Array>;
|