@buildinternet/uploads 0.48.1 → 0.49.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/commands/completion.js +5 -1
- package/dist/commands/mcp.js +1 -1
- package/dist/commands.d.ts +10 -1
- package/dist/commands.js +117 -34
- 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 +8 -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
|
|
@@ -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
|
|
@@ -1027,13 +1035,21 @@ function errorDetail(err) {
|
|
|
1027
1035
|
* Same partial-failure shape as uploadAttachments.
|
|
1028
1036
|
*/
|
|
1029
1037
|
export async function uploadPuts(opts) {
|
|
1030
|
-
|
|
1038
|
+
const files = opts.files ?? [];
|
|
1039
|
+
const byteSources = opts.byteSources ?? [];
|
|
1040
|
+
if (files.length > 0 && byteSources.length > 0) {
|
|
1041
|
+
throw new UsageError("internal: uploadPuts files and byteSources are mutually exclusive");
|
|
1042
|
+
}
|
|
1043
|
+
const count = files.length + byteSources.length;
|
|
1044
|
+
if (count === 0)
|
|
1045
|
+
throw new UsageError("put requires at least one file");
|
|
1046
|
+
if (count > 1 && files.some((f) => f === "-")) {
|
|
1031
1047
|
throw new UsageError("stdin (-) cannot be combined with multiple file arguments");
|
|
1032
1048
|
}
|
|
1033
|
-
if (
|
|
1049
|
+
if (count > 1 && opts.explicitKey) {
|
|
1034
1050
|
throw new UsageError("--key cannot be combined with multiple files");
|
|
1035
1051
|
}
|
|
1036
|
-
if (
|
|
1052
|
+
if (count > 1 && opts.nameOverride) {
|
|
1037
1053
|
throw new UsageError("--name cannot be combined with multiple files");
|
|
1038
1054
|
}
|
|
1039
1055
|
// Resolved once for the whole batch (issue #631) — never per file.
|
|
@@ -1048,18 +1064,33 @@ export async function uploadPuts(opts) {
|
|
|
1048
1064
|
branch: opts.ghBranchTarget.branch,
|
|
1049
1065
|
})
|
|
1050
1066
|
: undefined;
|
|
1051
|
-
const
|
|
1067
|
+
const items = byteSources.length > 0
|
|
1068
|
+
? byteSources.map((s) => ({
|
|
1069
|
+
source: s.source,
|
|
1070
|
+
filename: opts.nameOverride ?? s.filename,
|
|
1071
|
+
bytes: s.bytes,
|
|
1072
|
+
}))
|
|
1073
|
+
: files.map((file) => ({
|
|
1074
|
+
source: file,
|
|
1075
|
+
path: file,
|
|
1076
|
+
}));
|
|
1077
|
+
const slots = await mapBounded(items, opts.concurrency ?? UPLOAD_BATCH_CONCURRENCY, async (item) => {
|
|
1078
|
+
const file = item.source;
|
|
1052
1079
|
try {
|
|
1053
|
-
const
|
|
1080
|
+
const bytes = item.bytes ?? readFileArg(item.path ?? file);
|
|
1081
|
+
const sourceName = item.filename ??
|
|
1082
|
+
opts.nameOverride ??
|
|
1054
1083
|
(file === "-"
|
|
1055
1084
|
? opts.explicitKey
|
|
1056
1085
|
? basename(opts.explicitKey)
|
|
1057
1086
|
: "stdin.bin"
|
|
1058
1087
|
: basename(file));
|
|
1059
|
-
const bytes = readFileArg(file);
|
|
1060
1088
|
// Sidecar manifest from a prior `screenshot --out` of this exact file
|
|
1061
|
-
// (issue #469 lever 2) — see mergeSidecarMeta. Not applicable to stdin
|
|
1062
|
-
|
|
1089
|
+
// (issue #469 lever 2) — see mergeSidecarMeta. Not applicable to stdin
|
|
1090
|
+
// or URL fetches.
|
|
1091
|
+
const metadata = item.path && item.path !== "-"
|
|
1092
|
+
? mergeSidecarMeta(item.path, bytes, opts.metadata)
|
|
1093
|
+
: opts.metadata;
|
|
1063
1094
|
const { result, prepared, markdown, sentMetadata } = await uploadPreparedImage(opts.client, bytes, sourceName, {
|
|
1064
1095
|
frame: opts.frame,
|
|
1065
1096
|
optimize: opts.optimize,
|
|
@@ -1883,12 +1914,23 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
1883
1914
|
return 0;
|
|
1884
1915
|
}
|
|
1885
1916
|
const files = parsed.positionals;
|
|
1886
|
-
if (
|
|
1887
|
-
throw new UsageError("
|
|
1917
|
+
if (parsed.flags.get("--url") === true) {
|
|
1918
|
+
throw new UsageError("missing value for --url", {
|
|
1919
|
+
example: "uploads put --url https://cdn.example/shot.png --pr 123",
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
const urlArgs = flagValues(parsed.flags, "--url");
|
|
1923
|
+
if (files.length > 0 && urlArgs.length > 0) {
|
|
1924
|
+
throw new UsageError("--url cannot be combined with file arguments", {
|
|
1925
|
+
example: "uploads put --url https://cdn.example/shot.png --pr 123",
|
|
1926
|
+
});
|
|
1927
|
+
}
|
|
1928
|
+
if (files.length === 0 && urlArgs.length === 0) {
|
|
1929
|
+
throw new UsageError("put requires at least one file or --url", {
|
|
1888
1930
|
example: "uploads put ./shot.png --pr 123",
|
|
1889
1931
|
});
|
|
1890
1932
|
}
|
|
1891
|
-
const multi = files.length > 1;
|
|
1933
|
+
const multi = files.length > 1 || urlArgs.length > 1;
|
|
1892
1934
|
// Resolved early (issue #700): both the auto-PR opt-out default and the
|
|
1893
1935
|
// `--no-git`-gated staging/auto-PR detection below need it before the rest
|
|
1894
1936
|
// of put's flag parsing.
|
|
@@ -2135,36 +2177,77 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
2135
2177
|
const logHuman = !ctx.quiet && format === "human";
|
|
2136
2178
|
if (logHuman) {
|
|
2137
2179
|
if (multi) {
|
|
2138
|
-
|
|
2180
|
+
const n = files.length > 0 ? files.length : urlArgs.length;
|
|
2181
|
+
process.stderr.write(`>> ${dryRun ? "dry run for" : "uploading"} ${n} files\n`);
|
|
2139
2182
|
}
|
|
2140
2183
|
else {
|
|
2141
|
-
const fileArg = files[0];
|
|
2184
|
+
const fileArg = files[0] ?? urlArgs[0];
|
|
2142
2185
|
process.stderr.write(`>> ${dryRun ? "dry run" : "uploading"} ${fileArg === "-" ? "stdin" : fileArg}\n`);
|
|
2143
2186
|
}
|
|
2144
2187
|
if (attachedRef)
|
|
2145
2188
|
process.stderr.write(`>> attached to ${attachedRef}\n`);
|
|
2146
2189
|
}
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2190
|
+
let byteSources;
|
|
2191
|
+
const urlFetchFailures = [];
|
|
2192
|
+
let urlFetchFirstError;
|
|
2193
|
+
if (urlArgs.length > 0) {
|
|
2194
|
+
byteSources = [];
|
|
2195
|
+
for (const raw of urlArgs) {
|
|
2196
|
+
try {
|
|
2197
|
+
const filename = resolveUploadFilename(raw, !multi ? nameFlag : undefined, "--url", {
|
|
2198
|
+
allowLoopback: true,
|
|
2199
|
+
});
|
|
2200
|
+
const bytes = await fetchUploadSource(raw, {
|
|
2201
|
+
label: "--url",
|
|
2202
|
+
userAgent: "uploads.sh/cli",
|
|
2203
|
+
allowLoopback: true,
|
|
2204
|
+
});
|
|
2205
|
+
byteSources.push({ bytes, filename, source: raw });
|
|
2206
|
+
}
|
|
2207
|
+
catch (err) {
|
|
2208
|
+
urlFetchFirstError ??= err;
|
|
2209
|
+
urlFetchFailures.push({ file: raw, error: errorDetail(err) });
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
let uploads;
|
|
2214
|
+
let failures;
|
|
2215
|
+
let firstError;
|
|
2216
|
+
let sentMetadata;
|
|
2217
|
+
if (byteSources && byteSources.length === 0) {
|
|
2218
|
+
uploads = [];
|
|
2219
|
+
failures = urlFetchFailures;
|
|
2220
|
+
firstError = urlFetchFirstError;
|
|
2221
|
+
sentMetadata = [];
|
|
2222
|
+
}
|
|
2223
|
+
else {
|
|
2224
|
+
const batch = await uploadPuts({
|
|
2225
|
+
client: ctx.client,
|
|
2226
|
+
files: byteSources ? undefined : files,
|
|
2227
|
+
byteSources,
|
|
2228
|
+
nameOverride: byteSources ? undefined : nameFlag,
|
|
2229
|
+
explicitKey: keyHint,
|
|
2230
|
+
ghTarget: effectiveGhTarget,
|
|
2231
|
+
ghBranchTarget: stagingTarget,
|
|
2232
|
+
prefix: resolvedPrefix ?? defaults.prefix,
|
|
2233
|
+
repo: flagString(parsed.flags, "--repo") ?? defaults.repo,
|
|
2234
|
+
ref: flagString(parsed.flags, "--ref") ?? defaults.ref,
|
|
2235
|
+
deriveRepoFromGit: !noGit,
|
|
2236
|
+
contentType: contentTypeOverride,
|
|
2237
|
+
dryRun,
|
|
2238
|
+
replace: replaceFlag,
|
|
2239
|
+
optimize: optimizeOpts,
|
|
2240
|
+
frame: frameOpts,
|
|
2241
|
+
metadata,
|
|
2242
|
+
deriveImageFacts: derivedMetaEnabled(parsed.flags, defaults),
|
|
2243
|
+
alt: altFlag,
|
|
2244
|
+
width,
|
|
2245
|
+
});
|
|
2246
|
+
uploads = batch.uploads;
|
|
2247
|
+
failures = [...urlFetchFailures, ...batch.failures];
|
|
2248
|
+
firstError = urlFetchFirstError ?? batch.firstError;
|
|
2249
|
+
sentMetadata = batch.sentMetadata;
|
|
2250
|
+
}
|
|
2168
2251
|
// Single-file total failure: rethrow so CLI exit codes stay auth/network-aware.
|
|
2169
2252
|
if (uploads.length === 0 && failures.length > 0 && !multi) {
|
|
2170
2253
|
throw firstError instanceof Error ? firstError : new Error(String(firstError));
|
|
@@ -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>;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch bytes from a caller-supplied URL for `put --url` / MCP `contentUrl`.
|
|
3
|
+
*
|
|
4
|
+
* Guardrails: HTTPS only (http allowed on loopback when `allowLoopback` is
|
|
5
|
+
* set), no URL credentials, private/internal hosts rejected unless they are
|
|
6
|
+
* loopback and `allowLoopback` is set. Redirects re-checked each hop; a
|
|
7
|
+
* public origin cannot redirect onto loopback. No auth headers forwarded.
|
|
8
|
+
* The server still sniffs and size-caps after this.
|
|
9
|
+
*/
|
|
10
|
+
import { UploadsError } from "./errors.js";
|
|
11
|
+
import { isLoopbackHost, isPrivateOrLocalHost } from "./private-host.js";
|
|
12
|
+
export const FETCH_UPLOAD_SOURCE_TIMEOUT_MS = 15_000;
|
|
13
|
+
export const FETCH_UPLOAD_SOURCE_MAX_REDIRECTS = 5;
|
|
14
|
+
/** Client-side cap when the caller does not pass a workspace policy ceiling. */
|
|
15
|
+
export const FETCH_UPLOAD_SOURCE_DEFAULT_MAX_BYTES = 25 * 1024 * 1024;
|
|
16
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
17
|
+
function fail(label, message, code = "USAGE") {
|
|
18
|
+
throw new UploadsError(`${label} ${message}`, code);
|
|
19
|
+
}
|
|
20
|
+
/** Parse and reject URLs we will not fetch. Used on the original URL and every redirect. */
|
|
21
|
+
export function assertFetchableUploadUrl(raw, label = "url", opts = {}) {
|
|
22
|
+
let url;
|
|
23
|
+
try {
|
|
24
|
+
url = new URL(raw);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
fail(label, "must be a valid absolute URL");
|
|
28
|
+
}
|
|
29
|
+
const loopback = isLoopbackHost(url.hostname);
|
|
30
|
+
const allowThisLoopback = Boolean(opts.allowLoopback && loopback);
|
|
31
|
+
if (url.protocol === "http:") {
|
|
32
|
+
if (!allowThisLoopback)
|
|
33
|
+
fail(label, "must be https");
|
|
34
|
+
}
|
|
35
|
+
else if (url.protocol !== "https:") {
|
|
36
|
+
fail(label, "must be https");
|
|
37
|
+
}
|
|
38
|
+
if (url.username !== "" || url.password !== "") {
|
|
39
|
+
fail(label, "must not include credentials");
|
|
40
|
+
}
|
|
41
|
+
if (isPrivateOrLocalHost(url.hostname) && !allowThisLoopback) {
|
|
42
|
+
fail(label, "targets a private or internal network");
|
|
43
|
+
}
|
|
44
|
+
return url;
|
|
45
|
+
}
|
|
46
|
+
/** Filename leaf from a URL path (`https://cdn.example/a/shot.png?x=1` → `shot.png`). */
|
|
47
|
+
export function filenameFromUploadUrl(url) {
|
|
48
|
+
const last = url.pathname.replace(/\/+$/, "").split("/").pop();
|
|
49
|
+
if (!last)
|
|
50
|
+
return undefined;
|
|
51
|
+
let decoded = last;
|
|
52
|
+
try {
|
|
53
|
+
decoded = decodeURIComponent(last);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Keep the raw segment.
|
|
57
|
+
}
|
|
58
|
+
const cleaned = decoded.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
59
|
+
return cleaned || undefined;
|
|
60
|
+
}
|
|
61
|
+
/** `filename` if given, else the URL path leaf. Throws USAGE when neither works. */
|
|
62
|
+
export function resolveUploadFilename(rawUrl, filename, label = "url", opts = {}) {
|
|
63
|
+
if (filename)
|
|
64
|
+
return filename;
|
|
65
|
+
const derived = filenameFromUploadUrl(assertFetchableUploadUrl(rawUrl, label, opts));
|
|
66
|
+
if (!derived) {
|
|
67
|
+
throw new UploadsError(`${label} has no filename in the path; pass a filename`, "USAGE");
|
|
68
|
+
}
|
|
69
|
+
return derived;
|
|
70
|
+
}
|
|
71
|
+
function timeoutError(label) {
|
|
72
|
+
fail(label, "fetch timed out", "NETWORK");
|
|
73
|
+
}
|
|
74
|
+
function isAbortError(err) {
|
|
75
|
+
return ((err instanceof DOMException && err.name === "AbortError") ||
|
|
76
|
+
(err instanceof Error && err.name === "AbortError"));
|
|
77
|
+
}
|
|
78
|
+
async function readCappedBody(res, maxBytes, label) {
|
|
79
|
+
const declared = Number(res.headers.get("content-length"));
|
|
80
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
81
|
+
fail(label, `exceeds the upload limit (${maxBytes} bytes)`);
|
|
82
|
+
}
|
|
83
|
+
const body = res.body;
|
|
84
|
+
if (!body)
|
|
85
|
+
fail(label, "returned an empty body");
|
|
86
|
+
const reader = body.getReader();
|
|
87
|
+
const chunks = [];
|
|
88
|
+
let total = 0;
|
|
89
|
+
try {
|
|
90
|
+
for (;;) {
|
|
91
|
+
const { done, value } = await reader.read();
|
|
92
|
+
if (done)
|
|
93
|
+
break;
|
|
94
|
+
if (!value || value.byteLength === 0)
|
|
95
|
+
continue;
|
|
96
|
+
total += value.byteLength;
|
|
97
|
+
if (total > maxBytes) {
|
|
98
|
+
await reader.cancel().catch(() => undefined);
|
|
99
|
+
fail(label, `exceeds the upload limit (${maxBytes} bytes)`);
|
|
100
|
+
}
|
|
101
|
+
chunks.push(value);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
try {
|
|
106
|
+
reader.releaseLock();
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Already locked/cancelled after a size abort.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (total === 0)
|
|
113
|
+
fail(label, "returned an empty body");
|
|
114
|
+
if (chunks.length === 1)
|
|
115
|
+
return chunks[0];
|
|
116
|
+
const out = new Uint8Array(total);
|
|
117
|
+
let offset = 0;
|
|
118
|
+
for (const chunk of chunks) {
|
|
119
|
+
out.set(chunk, offset);
|
|
120
|
+
offset += chunk.byteLength;
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* GET `url` and return the body bytes, capped at `maxBytes`.
|
|
126
|
+
*
|
|
127
|
+
* Redirects are followed manually so each hop is re-validated (scheme, no
|
|
128
|
+
* credentials, host policy). A public origin cannot redirect onto loopback
|
|
129
|
+
* even when `allowLoopback` is set. Auth headers are never forwarded.
|
|
130
|
+
*/
|
|
131
|
+
export async function fetchUploadSource(raw, opts = {}) {
|
|
132
|
+
const label = opts.label ?? "url";
|
|
133
|
+
const timeoutMs = opts.timeoutMs ?? FETCH_UPLOAD_SOURCE_TIMEOUT_MS;
|
|
134
|
+
const maxBytes = opts.maxBytes ?? FETCH_UPLOAD_SOURCE_DEFAULT_MAX_BYTES;
|
|
135
|
+
const doFetch = opts.fetch ?? fetch;
|
|
136
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
137
|
+
const signal = opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout;
|
|
138
|
+
const urlOpts = { allowLoopback: opts.allowLoopback };
|
|
139
|
+
let url = assertFetchableUploadUrl(raw, label, urlOpts);
|
|
140
|
+
for (let hop = 0; hop <= FETCH_UPLOAD_SOURCE_MAX_REDIRECTS; hop++) {
|
|
141
|
+
let res;
|
|
142
|
+
try {
|
|
143
|
+
res = await doFetch(url, {
|
|
144
|
+
method: "GET",
|
|
145
|
+
redirect: "manual",
|
|
146
|
+
signal,
|
|
147
|
+
headers: {
|
|
148
|
+
accept: "*/*",
|
|
149
|
+
"user-agent": opts.userAgent ?? "uploads.sh",
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
if (isAbortError(err) || timeout.aborted)
|
|
155
|
+
timeoutError(label);
|
|
156
|
+
throw new UploadsError(`could not fetch ${label}`, "NETWORK");
|
|
157
|
+
}
|
|
158
|
+
if (REDIRECT_STATUSES.has(res.status)) {
|
|
159
|
+
const location = res.headers.get("location");
|
|
160
|
+
if (!location)
|
|
161
|
+
fail(label, "redirect is missing a Location header");
|
|
162
|
+
if (hop === FETCH_UPLOAD_SOURCE_MAX_REDIRECTS) {
|
|
163
|
+
fail(label, "redirected too many times");
|
|
164
|
+
}
|
|
165
|
+
// Loopback is only sticky while we are already on loopback. A public
|
|
166
|
+
// CDN cannot bounce the CLI onto http://127.0.0.1.
|
|
167
|
+
url = assertFetchableUploadUrl(new URL(location, url).toString(), label, {
|
|
168
|
+
allowLoopback: urlOpts.allowLoopback && isLoopbackHost(url.hostname),
|
|
169
|
+
});
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (res.status !== 200) {
|
|
173
|
+
throw new UploadsError(`could not fetch ${label} (HTTP ${res.status})`, "NETWORK");
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
return await readCappedBody(res, maxBytes, label);
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
if (isAbortError(err) || timeout.aborted)
|
|
180
|
+
timeoutError(label);
|
|
181
|
+
throw err;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
fail(label, "redirected too many times");
|
|
185
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { sanitizeKeySegment, sha256Short, deriveRepoFromGit, buildScreenshotKey
|
|
|
4
4
|
export { BUILTIN_DESTINATIONS, isBuiltinDestination, keyMatchesDestination, resolveDestinationRoot, resolvePutPrefix, type BuiltinDestinationId, } from "./destinations.js";
|
|
5
5
|
export { DEFAULT_API_URL, DEFAULT_WORKSPACE, UPLOADS_CONFIG_KEYS, defaultConfigPath, resolveConfigPath, loadConfigFile, loadEnvFile, resolveApiUrl, resolveConfig, describeConfigSources, redactToken, writeConfigKeys, configValuesFromClient, putDefaultsToConfigValues, resolvePutDefaults, mergePutDefaults, workspaceFromToken, workspaceMismatch, type UploadsClientConfig, type ResolvedConfig, type WorkspaceSource, type ConfigValueSource, type ConfigSources, type UploadsConfigKey, type UploadsConfigValues, type PutDefaults, } from "./config.js";
|
|
6
6
|
export { UploadsError, type UploadsErrorCode } from "./errors.js";
|
|
7
|
+
export { assertFetchableUploadUrl, fetchUploadSource, filenameFromUploadUrl, resolveUploadFilename, FETCH_UPLOAD_SOURCE_DEFAULT_MAX_BYTES, FETCH_UPLOAD_SOURCE_MAX_REDIRECTS, FETCH_UPLOAD_SOURCE_TIMEOUT_MS, } from "./fetch-upload-source.js";
|
|
7
8
|
export { createUploadsClient, type UploadsClient, type PutOptions, type ProvenanceInput, type ListOptions, type PutResult, type ListItem, type ListResult, type HeadResult, type DeleteResult, type GalleryItem, type Gallery, type GallerySummary, type GalleryListOptions, type GalleryListResult, type CreateGalleryOptions, type AddGalleryItemOptions, type DeleteGalleryOptions, type HealthResult, type UsageResult, type ReconcileResult, type PurgeExpiredResult, type PurgeExpiredResponse, type FindFilesOptions, type FindFilesItem, type FindFilesResult, type MetadataKeysResult, type MetadataValuesResult, type GetMetadataResult, type PatchMetadataOptions, type ResolveGhPrefixOptions, type ResolveGhPrefixResult, } from "./client.js";
|
|
8
9
|
export { buildCliProvenance } from "./provenance.js";
|
|
9
10
|
export { META_KEY_RE, META_VALUE_MAX, META_MAX_KEYS, META_MAX_TOTAL_BYTES, validateMetaEntry, parseMetaPair, parseMetaFlags, } from "./metadata.js";
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ export { sanitizeKeySegment, sha256Short, deriveRepoFromGit, buildScreenshotKey
|
|
|
4
4
|
export { BUILTIN_DESTINATIONS, isBuiltinDestination, keyMatchesDestination, resolveDestinationRoot, resolvePutPrefix, } from "./destinations.js";
|
|
5
5
|
export { DEFAULT_API_URL, DEFAULT_WORKSPACE, UPLOADS_CONFIG_KEYS, defaultConfigPath, resolveConfigPath, loadConfigFile, loadEnvFile, resolveApiUrl, resolveConfig, describeConfigSources, redactToken, writeConfigKeys, configValuesFromClient, putDefaultsToConfigValues, resolvePutDefaults, mergePutDefaults, workspaceFromToken, workspaceMismatch, } from "./config.js";
|
|
6
6
|
export { UploadsError } from "./errors.js";
|
|
7
|
+
export { assertFetchableUploadUrl, fetchUploadSource, filenameFromUploadUrl, resolveUploadFilename, FETCH_UPLOAD_SOURCE_DEFAULT_MAX_BYTES, FETCH_UPLOAD_SOURCE_MAX_REDIRECTS, FETCH_UPLOAD_SOURCE_TIMEOUT_MS, } from "./fetch-upload-source.js";
|
|
7
8
|
export { createUploadsClient, } from "./client.js";
|
|
8
9
|
export { buildCliProvenance } from "./provenance.js";
|
|
9
10
|
export { META_KEY_RE, META_VALUE_MAX, META_MAX_KEYS, META_MAX_TOTAL_BYTES, validateMetaEntry, parseMetaPair, parseMetaFlags, } from "./metadata.js";
|
package/dist/mcp/args.d.ts
CHANGED
|
@@ -10,12 +10,10 @@ export declare function optPosInt(args: ToolArgs, name: string, options?: {
|
|
|
10
10
|
export declare function optStringRecord(args: ToolArgs, name: string): Record<string, string> | undefined;
|
|
11
11
|
/** A JSON-array argument of strings (e.g. a `delete` or `files` param). */
|
|
12
12
|
export declare function optStringArray(args: ToolArgs, name: string): string[] | undefined;
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
*/
|
|
18
|
-
export declare const METADATA_DESCRIPTION = "Queryable custom metadata (key\u2192value), separate from provenance. Omit to leave any metadata already stored for this key untouched; pass an object (even {}) to fully replace it. Keys: lowercase, ^[a-z][a-z0-9._-]{0,63}$. Values: 1-512 printable ASCII characters. Caps: at most 24 keys, at most 8192 total key+value bytes. Canonical keys, which uploads.sh derives automatically where it can: url, path, env, theme, viewport, device, software, captured. Use `path` for the route (e.g. /settings) \u2014 that is the key `find_files` searches by, so spell it `path` and not route/page/screen. `gh.*` is reserved by convention for GitHub PR/issue attachment context (repo/kind/number/ref).";
|
|
13
|
+
/** Shared cue: `path` is the route `find_files` searches by. */
|
|
14
|
+
export declare const METADATA_PATH_CUE = "Use `path` for the route (e.g. /settings), not route/page/screen.";
|
|
15
|
+
/** put/screenshot/attach `metadata`. Key regex and caps stay in usage errors. */
|
|
16
|
+
export declare const METADATA_DESCRIPTION: string;
|
|
19
17
|
export declare const metadataProp: {
|
|
20
18
|
type: string;
|
|
21
19
|
additionalProperties: {
|
|
@@ -23,6 +21,8 @@ export declare const metadataProp: {
|
|
|
23
21
|
};
|
|
24
22
|
description: string;
|
|
25
23
|
};
|
|
24
|
+
/** 1×1 PNG, used only in MCP `inputSchema.examples` so copy-paste from Inspector works. */
|
|
25
|
+
export declare const MCP_EXAMPLE_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
|
|
26
26
|
export declare const stateProp: {
|
|
27
27
|
type: string;
|
|
28
28
|
enum: ("after" | "before" | "empty" | "error" | "loading")[];
|
package/dist/mcp/args.js
CHANGED
|
@@ -66,17 +66,19 @@ export function optStringArray(args, name) {
|
|
|
66
66
|
}
|
|
67
67
|
return v;
|
|
68
68
|
}
|
|
69
|
-
/**
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
69
|
+
/** Shared cue: `path` is the route `find_files` searches by. */
|
|
70
|
+
export const METADATA_PATH_CUE = "Use `path` for the route (e.g. /settings), not route/page/screen.";
|
|
71
|
+
/** put/screenshot/attach `metadata`. Key regex and caps stay in usage errors. */
|
|
72
|
+
export const METADATA_DESCRIPTION = "Queryable tags for later search (key→value). " +
|
|
73
|
+
METADATA_PATH_CUE +
|
|
74
|
+
" Omit to leave existing tags; pass an object (even {}) to replace them. `state` and `app` have their own fields.";
|
|
75
75
|
export const metadataProp = {
|
|
76
76
|
type: "object",
|
|
77
77
|
additionalProperties: { type: "string" },
|
|
78
78
|
description: METADATA_DESCRIPTION,
|
|
79
79
|
};
|
|
80
|
+
/** 1×1 PNG, used only in MCP `inputSchema.examples` so copy-paste from Inspector works. */
|
|
81
|
+
export const MCP_EXAMPLE_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
|
|
80
82
|
export const stateProp = {
|
|
81
83
|
type: "string",
|
|
82
84
|
enum: [...META_STATE_VALUES],
|
|
@@ -20,7 +20,7 @@ export declare const repoLinkStatusResultSchema: JsonSchema;
|
|
|
20
20
|
export declare const usageResultSchema: JsonSchema;
|
|
21
21
|
export declare const reconcileResultSchema: JsonSchema;
|
|
22
22
|
export declare const purgeExpiredResultSchema: JsonSchema;
|
|
23
|
-
export declare const
|
|
23
|
+
export declare const whoamiResultSchema: JsonSchema;
|
|
24
24
|
export declare const promoteToolResultSchema: JsonSchema;
|
|
25
25
|
export declare const galleryResultSchema: JsonSchema;
|
|
26
26
|
export declare const galleryFindResultSchema: JsonSchema;
|
|
@@ -223,10 +223,14 @@ export const purgeExpiredResultSchema = objectSchema({
|
|
|
223
223
|
keysTruncated: { type: "boolean" },
|
|
224
224
|
reconcile: reconcileResultSchema,
|
|
225
225
|
});
|
|
226
|
-
export const
|
|
226
|
+
export const whoamiResultSchema = objectSchema({
|
|
227
227
|
ok: { type: "boolean" },
|
|
228
|
+
workspace: { type: "string" },
|
|
229
|
+
scopes: { type: "array", items: { type: "string" } },
|
|
230
|
+
userId: nullableString,
|
|
231
|
+
signedIn: { type: "boolean" },
|
|
228
232
|
apiUrl: { type: "string" },
|
|
229
|
-
});
|
|
233
|
+
}, ["ok", "workspace"]);
|
|
230
234
|
export const promoteToolResultSchema = objectSchema({
|
|
231
235
|
// `promotion` is optional (issue #702): a `keys`-only call (no `branch`)
|
|
232
236
|
// never runs the branch sweep, so there's nothing to report under it.
|
|
@@ -307,7 +311,7 @@ export const hostedOutputSchemas = {
|
|
|
307
311
|
usage: usageResultSchema,
|
|
308
312
|
reconcile: reconcileResultSchema,
|
|
309
313
|
purge_expired: purgeExpiredResultSchema,
|
|
310
|
-
|
|
314
|
+
whoami: whoamiResultSchema,
|
|
311
315
|
};
|
|
312
316
|
/** Shared-shape stdio tools. Hosted-only tools (`promote`, `repo_link_status`) omitted. */
|
|
313
317
|
export const stdioOutputSchemas = {
|
|
@@ -340,7 +344,7 @@ export const stdioOutputSchemas = {
|
|
|
340
344
|
usage: usageResultSchema,
|
|
341
345
|
reconcile: reconcileResultSchema,
|
|
342
346
|
purge_expired: purgeExpiredResultSchema,
|
|
343
|
-
|
|
347
|
+
whoami: whoamiResultSchema,
|
|
344
348
|
report: objectSchema({
|
|
345
349
|
ok: { type: "boolean" },
|
|
346
350
|
id: { type: "string" },
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -18,18 +18,18 @@
|
|
|
18
18
|
* must never go to stdout.
|
|
19
19
|
*/
|
|
20
20
|
import { McpServer, type Implementation, type jsonSchemaValidator } from "@modelcontextprotocol/server";
|
|
21
|
-
export { appProp, canonicalMetaFromArgs, METADATA_DESCRIPTION, metadataArgWithCanonical, metadataProp, stateProp, optBool, optPosInt, optString, optStringArray, optStringRecord, usage, type ToolArgs, } from "./args.js";
|
|
21
|
+
export { appProp, canonicalMetaFromArgs, METADATA_DESCRIPTION, METADATA_PATH_CUE, MCP_EXAMPLE_PNG_BASE64, metadataArgWithCanonical, metadataProp, stateProp, optBool, optPosInt, optString, optStringArray, optStringRecord, usage, type ToolArgs, } from "./args.js";
|
|
22
22
|
export { ToolBatchError, batchFailureMessage } from "./batch-error.js";
|
|
23
23
|
export { mapBounded } from "../async.js";
|
|
24
24
|
export { McpServer, type jsonSchemaValidator };
|
|
25
|
-
export { commentResultSchema, deleteResultSchema, findFilesResultSchema, galleryFindResultSchema, galleryResultSchema,
|
|
25
|
+
export { commentResultSchema, deleteResultSchema, findFilesResultSchema, galleryFindResultSchema, galleryResultSchema, hostedOutputSchemas, listResultSchema, metadataFacetsResultSchema, metadataResultSchema, promoteToolResultSchema, purgeExpiredResultSchema, putResultSchema, reconcileResultSchema, repoLinkStatusResultSchema, stdioOutputSchemas, usageResultSchema, withOutputSchemas, } from "./output-schemas.js";
|
|
26
26
|
/** MCP tool safety hints. Required so tools/list advertises them for review. */
|
|
27
27
|
export interface McpToolAnnotations {
|
|
28
28
|
readOnlyHint: boolean;
|
|
29
29
|
destructiveHint: boolean;
|
|
30
30
|
openWorldHint: boolean;
|
|
31
31
|
}
|
|
32
|
-
/** Lookup / list /
|
|
32
|
+
/** Lookup / list / whoami. Does not change workspace or public state. */
|
|
33
33
|
export declare const mcpRead: McpToolAnnotations;
|
|
34
34
|
/** Creates or updates a public object, gallery, or comment without deleting. */
|
|
35
35
|
export declare const mcpWritePublic: McpToolAnnotations;
|
|
@@ -50,9 +50,9 @@ export type McpSecurityScheme = {
|
|
|
50
50
|
export declare const mcpOAuthRead: McpSecurityScheme[];
|
|
51
51
|
export declare const mcpOAuthWrite: McpSecurityScheme[];
|
|
52
52
|
export declare const mcpOAuthDelete: McpSecurityScheme[];
|
|
53
|
-
/** Authenticated, no particular file scope (hosted `
|
|
53
|
+
/** Authenticated, no particular file scope (hosted `whoami`). */
|
|
54
54
|
export declare const mcpOAuthAny: McpSecurityScheme[];
|
|
55
|
-
/** Callable without a token (stdio `
|
|
55
|
+
/** Callable without a token (stdio `whoami` when unsigned-in). */
|
|
56
56
|
export declare const mcpNoAuth: McpSecurityScheme[];
|
|
57
57
|
/**
|
|
58
58
|
* Thrown when a presented token is missing a required scope. wrapHandler
|
package/dist/mcp/server.js
CHANGED
|
@@ -21,12 +21,12 @@ import { fromJsonSchema, McpServer, } from "@modelcontextprotocol/server";
|
|
|
21
21
|
import { UploadsError } from "../errors.js";
|
|
22
22
|
import { errorCodeFromUnknown, recordEvent } from "../telemetry.js";
|
|
23
23
|
import { ToolBatchError } from "./batch-error.js";
|
|
24
|
-
export { appProp, canonicalMetaFromArgs, METADATA_DESCRIPTION, metadataArgWithCanonical, metadataProp, stateProp, optBool, optPosInt, optString, optStringArray, optStringRecord, usage, } from "./args.js";
|
|
24
|
+
export { appProp, canonicalMetaFromArgs, METADATA_DESCRIPTION, METADATA_PATH_CUE, MCP_EXAMPLE_PNG_BASE64, metadataArgWithCanonical, metadataProp, stateProp, optBool, optPosInt, optString, optStringArray, optStringRecord, usage, } from "./args.js";
|
|
25
25
|
export { ToolBatchError, batchFailureMessage } from "./batch-error.js";
|
|
26
26
|
export { mapBounded } from "../async.js";
|
|
27
27
|
export { McpServer };
|
|
28
|
-
export { commentResultSchema, deleteResultSchema, findFilesResultSchema, galleryFindResultSchema, galleryResultSchema,
|
|
29
|
-
/** Lookup / list /
|
|
28
|
+
export { commentResultSchema, deleteResultSchema, findFilesResultSchema, galleryFindResultSchema, galleryResultSchema, hostedOutputSchemas, listResultSchema, metadataFacetsResultSchema, metadataResultSchema, promoteToolResultSchema, purgeExpiredResultSchema, putResultSchema, reconcileResultSchema, repoLinkStatusResultSchema, stdioOutputSchemas, usageResultSchema, withOutputSchemas, } from "./output-schemas.js";
|
|
29
|
+
/** Lookup / list / whoami. Does not change workspace or public state. */
|
|
30
30
|
export const mcpRead = {
|
|
31
31
|
readOnlyHint: true,
|
|
32
32
|
destructiveHint: false,
|
|
@@ -56,9 +56,9 @@ function oauth(scopes) {
|
|
|
56
56
|
export const mcpOAuthRead = oauth(["files:read"]);
|
|
57
57
|
export const mcpOAuthWrite = oauth(["files:write"]);
|
|
58
58
|
export const mcpOAuthDelete = oauth(["files:delete"]);
|
|
59
|
-
/** Authenticated, no particular file scope (hosted `
|
|
59
|
+
/** Authenticated, no particular file scope (hosted `whoami`). */
|
|
60
60
|
export const mcpOAuthAny = oauth([]);
|
|
61
|
-
/** Callable without a token (stdio `
|
|
61
|
+
/** Callable without a token (stdio `whoami` when unsigned-in). */
|
|
62
62
|
export const mcpNoAuth = [{ type: "noauth" }];
|
|
63
63
|
/**
|
|
64
64
|
* Thrown when a presented token is missing a required scope. wrapHandler
|
package/dist/mcp/tools.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP tool set mirroring the CLI commands (put, attach, list, delete,
|
|
3
|
-
* usage, reconcile, purge_expired, comment,
|
|
3
|
+
* usage, reconcile, purge_expired, comment, whoami, doctor). Config is
|
|
4
4
|
* resolved fresh per tool call so a
|
|
5
5
|
* per-call `workspace` argument behaves like the CLI's --workspace flag, and
|
|
6
6
|
* a missing token surfaces as a tool error rather than a startup failure.
|
package/dist/mcp/tools.js
CHANGED
|
@@ -3,13 +3,14 @@ import { buildDoctorReport, ghListPrefixes, ghMergedList, makeGhTarget, mergeSta
|
|
|
3
3
|
import { resolveFrameId } from "../frame.js";
|
|
4
4
|
import { resolveConfig, resolvePutDefaults, } from "../config.js";
|
|
5
5
|
import { resolvePutPrefix } from "../destinations.js";
|
|
6
|
+
import { fetchUploadSource, resolveUploadFilename } from "../fetch-upload-source.js";
|
|
6
7
|
import { ghKeyPrefix, ghPrivateKeyPrefix } from "../github.js";
|
|
7
8
|
import { safeCaptureFacts } from "../capture-facts.js";
|
|
8
9
|
import { deriveRepoSlugFromGit } from "../keys.js";
|
|
9
10
|
import { validateMetaMap } from "../metadata.js";
|
|
10
11
|
import { mergeDerivedMeta } from "../metadata-vocab.js";
|
|
11
12
|
import { execRunner, ghMetadataFromTargetWithTitle, resolveCurrentBranch, resolveCurrentPullRequest, resolveRepo, } from "../github-gh.js";
|
|
12
|
-
import { appProp, canonicalMetaFromArgs,
|
|
13
|
+
import { appProp, canonicalMetaFromArgs, METADATA_PATH_CUE, metadataArgWithCanonical, metadataProp, optBool, optPosInt, optString, optStringArray, optStringRecord, stateProp, usage, } from "./args.js";
|
|
13
14
|
import { batchFailureMessage, mcpDestroyPublic, mcpNoAuth, mcpOAuthAny, mcpOAuthDelete, mcpOAuthRead, mcpOAuthWrite, mcpRead, mcpWriteInternal, mcpWritePublic, stdioOutputSchemas, withOutputSchemas, ToolBatchError, } from "./server.js";
|
|
14
15
|
import { attachmentFromText, buildReportPayload, parseReportType, REPORT_TYPES, submitReport, validateReportMessage, } from "../report.js";
|
|
15
16
|
import { resolveApiUrl } from "../config.js";
|
|
@@ -272,30 +273,34 @@ export function createUploadsMcpTools(opts) {
|
|
|
272
273
|
title: "Upload file",
|
|
273
274
|
annotations: mcpDestroyPublic,
|
|
274
275
|
securitySchemes: mcpOAuthWrite,
|
|
275
|
-
description: "Upload one or more files
|
|
276
|
+
description: "Upload one or more files and get a public URL plus GitHub-ready markdown. Prefer `embedUrl` in GitHub markdown. Pass `contentUrl` for a public HTTPS file, or http://localhost on this machine, instead of a local path. With `pr`/`issue`, keys are stable and the managed comment is synced. All uploads are public.",
|
|
276
277
|
inputSchema: {
|
|
277
278
|
type: "object",
|
|
278
279
|
properties: {
|
|
279
280
|
file: {
|
|
280
281
|
type: "string",
|
|
281
|
-
description: "Path of a single file to upload. Exactly one of file, files, or
|
|
282
|
+
description: "Path of a single file to upload. Exactly one of file, files, contentBase64, or contentUrl is required.",
|
|
282
283
|
},
|
|
283
284
|
files: {
|
|
284
285
|
type: "array",
|
|
285
286
|
items: { type: "string" },
|
|
286
|
-
description: "Paths of multiple files to upload in parallel. Returns { uploads, failures }. Cannot combine with file, contentBase64, key, or filename.",
|
|
287
|
+
description: "Paths of multiple files to upload in parallel. Returns { uploads, failures }. Cannot combine with file, contentBase64, contentUrl, key, or filename.",
|
|
287
288
|
},
|
|
288
289
|
contentBase64: {
|
|
289
290
|
type: "string",
|
|
290
291
|
description: "Base64-encoded file content for in-memory uploads; requires filename.",
|
|
291
292
|
},
|
|
293
|
+
contentUrl: {
|
|
294
|
+
type: "string",
|
|
295
|
+
description: "URL to fetch and upload. Public HTTPS, or http://localhost / 127.0.0.1 / *.localhost on this machine. Filename is optional when the URL path has a leaf. Other private/internal hosts are rejected. Exactly one of file, files, contentBase64, or contentUrl.",
|
|
296
|
+
},
|
|
292
297
|
filename: {
|
|
293
298
|
type: "string",
|
|
294
|
-
description: "Filename for contentBase64
|
|
299
|
+
description: "Filename for contentBase64/contentUrl (drives the key and content type). With single `file`, overrides the key's leaf (clean name) while keeping the pr/default path.",
|
|
295
300
|
},
|
|
296
301
|
key: {
|
|
297
302
|
type: "string",
|
|
298
|
-
description: "
|
|
303
|
+
description: "Override the object key. Single file only; cannot combine with `pr`/`issue`.",
|
|
299
304
|
},
|
|
300
305
|
destination: {
|
|
301
306
|
type: "string",
|
|
@@ -351,7 +356,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
351
356
|
},
|
|
352
357
|
comment: {
|
|
353
358
|
type: "boolean",
|
|
354
|
-
description: "With pr
|
|
359
|
+
description: "With `pr`/`issue` (or an auto-detected PR): create or update the managed attachments comment. Best-effort.",
|
|
355
360
|
},
|
|
356
361
|
dryRun: {
|
|
357
362
|
type: "boolean",
|
|
@@ -359,7 +364,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
359
364
|
},
|
|
360
365
|
replace: {
|
|
361
366
|
type: "boolean",
|
|
362
|
-
description: "
|
|
367
|
+
description: "Overwrite an existing object on a non-`gh/` key. Default false (or true if UPLOADS_OVERWRITE=1). No effect on `pr`/`issue` keys, which always overwrite.",
|
|
363
368
|
},
|
|
364
369
|
metadata: metadataProp,
|
|
365
370
|
state: stateProp,
|
|
@@ -367,18 +372,34 @@ export function createUploadsMcpTools(opts) {
|
|
|
367
372
|
workspace: workspaceProp,
|
|
368
373
|
},
|
|
369
374
|
additionalProperties: false,
|
|
375
|
+
examples: [
|
|
376
|
+
{ file: "./after.png", pr: 12, state: "after" },
|
|
377
|
+
{ file: "./after.png", branch: "feat/settings", state: "after" },
|
|
378
|
+
{ files: ["./before.png", "./after.png"], pr: 12 },
|
|
379
|
+
{
|
|
380
|
+
contentUrl: "https://cdn.example/settings-after.png",
|
|
381
|
+
pr: 12,
|
|
382
|
+
state: "after",
|
|
383
|
+
},
|
|
384
|
+
],
|
|
370
385
|
},
|
|
371
386
|
async handler(args) {
|
|
372
387
|
const file = optString(args, "file");
|
|
373
388
|
const filesArg = optStringArray(args, "files");
|
|
374
389
|
const contentBase64 = optString(args, "contentBase64");
|
|
390
|
+
const contentUrl = optString(args, "contentUrl");
|
|
375
391
|
if (filesArg !== undefined && filesArg.length === 0) {
|
|
376
392
|
usage("files must be a non-empty array of paths");
|
|
377
393
|
}
|
|
378
394
|
const multi = filesArg !== undefined;
|
|
379
|
-
const sources = [
|
|
395
|
+
const sources = [
|
|
396
|
+
file !== undefined,
|
|
397
|
+
multi,
|
|
398
|
+
contentBase64 !== undefined,
|
|
399
|
+
contentUrl !== undefined,
|
|
400
|
+
];
|
|
380
401
|
if (sources.filter(Boolean).length !== 1) {
|
|
381
|
-
usage("exactly one of file, files, or
|
|
402
|
+
usage("exactly one of file, files, contentBase64, or contentUrl is required");
|
|
382
403
|
}
|
|
383
404
|
const filenameArg = optString(args, "filename");
|
|
384
405
|
if (contentBase64 !== undefined && !filenameArg) {
|
|
@@ -548,10 +569,29 @@ export function createUploadsMcpTools(opts) {
|
|
|
548
569
|
}
|
|
549
570
|
return { uploads, failures, ...(hint ? { hint } : {}) };
|
|
550
571
|
}
|
|
551
|
-
// Single-file: contentBase64
|
|
552
|
-
if (contentBase64 !== undefined) {
|
|
553
|
-
|
|
554
|
-
|
|
572
|
+
// Single-file: contentBase64 / contentUrl; paths go through uploadPuts.
|
|
573
|
+
if (contentBase64 !== undefined || contentUrl !== undefined) {
|
|
574
|
+
let bytes;
|
|
575
|
+
let sourceName;
|
|
576
|
+
if (contentUrl !== undefined) {
|
|
577
|
+
try {
|
|
578
|
+
sourceName = resolveUploadFilename(contentUrl, filenameArg, "contentUrl", {
|
|
579
|
+
allowLoopback: true,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
catch (err) {
|
|
583
|
+
usage(err instanceof Error ? err.message : String(err));
|
|
584
|
+
}
|
|
585
|
+
bytes = await fetchUploadSource(contentUrl, {
|
|
586
|
+
label: "contentUrl",
|
|
587
|
+
userAgent: "uploads.sh/mcp",
|
|
588
|
+
allowLoopback: true,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
sourceName = filenameArg;
|
|
593
|
+
bytes = new Uint8Array(Buffer.from(contentBase64, "base64"));
|
|
594
|
+
}
|
|
555
595
|
const { result, prepared, markdown } = await uploadPreparedImage(client, bytes, sourceName, {
|
|
556
596
|
frame: frameOpts,
|
|
557
597
|
optimize: optimizeOpts,
|
|
@@ -639,7 +679,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
639
679
|
title: "Capture screenshot",
|
|
640
680
|
annotations: mcpDestroyPublic,
|
|
641
681
|
securitySchemes: mcpOAuthWrite,
|
|
642
|
-
description: "Capture a URL or
|
|
682
|
+
description: "Capture a URL or local HTML file and host it. Shares put's attach, comment, and metadata options. `via=local` needs Chrome; `via=remote` renders server-side. localhost URLs are local-only.",
|
|
643
683
|
inputSchema: {
|
|
644
684
|
type: "object",
|
|
645
685
|
properties: {
|
|
@@ -756,6 +796,10 @@ export function createUploadsMcpTools(opts) {
|
|
|
756
796
|
},
|
|
757
797
|
required: ["target"],
|
|
758
798
|
additionalProperties: false,
|
|
799
|
+
examples: [
|
|
800
|
+
{ target: "http://localhost:4321/settings", pr: 12, state: "after" },
|
|
801
|
+
{ target: "http://localhost:4321/settings", fullPage: true, state: "empty" },
|
|
802
|
+
],
|
|
759
803
|
},
|
|
760
804
|
async handler(args) {
|
|
761
805
|
const targetArg = optString(args, "target");
|
|
@@ -1055,17 +1099,17 @@ export function createUploadsMcpTools(opts) {
|
|
|
1055
1099
|
description: "Keep EXIF/XMP/ICC when optimizing (default: strip for privacy on public embeds).",
|
|
1056
1100
|
},
|
|
1057
1101
|
...frameProps,
|
|
1058
|
-
metadata:
|
|
1059
|
-
...metadataProp,
|
|
1060
|
-
description: "Extra queryable metadata (key→value), merged with the automatic gh.repo/gh.kind/gh.number/gh.ref pairs — a gh.* pair here loses to the resolved target's own gh.* value. " +
|
|
1061
|
-
METADATA_DESCRIPTION,
|
|
1062
|
-
},
|
|
1102
|
+
metadata: metadataProp,
|
|
1063
1103
|
state: stateProp,
|
|
1064
1104
|
app: appProp,
|
|
1065
1105
|
workspace: workspaceProp,
|
|
1066
1106
|
},
|
|
1067
1107
|
required: ["files"],
|
|
1068
1108
|
additionalProperties: false,
|
|
1109
|
+
examples: [
|
|
1110
|
+
{ files: ["./after.png"], pr: 12, state: "after" },
|
|
1111
|
+
{ files: ["./before.png", "./after.png"], pr: 12 },
|
|
1112
|
+
],
|
|
1069
1113
|
},
|
|
1070
1114
|
async handler(args) {
|
|
1071
1115
|
const files = optStringArray(args, "files");
|
|
@@ -1181,7 +1225,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
1181
1225
|
title: "List staged files",
|
|
1182
1226
|
annotations: mcpRead,
|
|
1183
1227
|
securitySchemes: mcpOAuthRead,
|
|
1184
|
-
description: "
|
|
1228
|
+
description: "List files staged for a git branch and whether they will auto-attach when a PR opens. Returns `{ repo, branch, files, binding }`.",
|
|
1185
1229
|
inputSchema: {
|
|
1186
1230
|
type: "object",
|
|
1187
1231
|
properties: {
|
|
@@ -1238,7 +1282,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
1238
1282
|
title: "Get metadata",
|
|
1239
1283
|
annotations: mcpRead,
|
|
1240
1284
|
securitySchemes: mcpOAuthRead,
|
|
1241
|
-
description: "Read
|
|
1285
|
+
description: "Read the queryable tags on one file. Returns `{ metadata }` (empty when none). Same as `uploads meta get`.",
|
|
1242
1286
|
inputSchema: {
|
|
1243
1287
|
type: "object",
|
|
1244
1288
|
properties: {
|
|
@@ -1260,14 +1304,15 @@ export function createUploadsMcpTools(opts) {
|
|
|
1260
1304
|
title: "Set metadata",
|
|
1261
1305
|
annotations: mcpWritePublic,
|
|
1262
1306
|
securitySchemes: mcpOAuthWrite,
|
|
1263
|
-
description: "
|
|
1264
|
-
METADATA_DESCRIPTION +
|
|
1265
|
-
" Requires at least one of `set` or `delete`. Same as `uploads meta set`.",
|
|
1307
|
+
description: "Set or delete queryable tags on an existing file. `set` wins over `delete` for the same key. Requires `set` and/or `delete`. Same as `uploads meta set`.",
|
|
1266
1308
|
inputSchema: {
|
|
1267
1309
|
type: "object",
|
|
1268
1310
|
properties: {
|
|
1269
1311
|
key: { type: "string", description: "Object key to update." },
|
|
1270
|
-
set: {
|
|
1312
|
+
set: {
|
|
1313
|
+
...metadataProp,
|
|
1314
|
+
description: "Keys to set or overwrite. " + METADATA_PATH_CUE,
|
|
1315
|
+
},
|
|
1271
1316
|
delete: {
|
|
1272
1317
|
type: "array",
|
|
1273
1318
|
items: { type: "string" },
|
|
@@ -1277,6 +1322,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
1277
1322
|
},
|
|
1278
1323
|
required: ["key"],
|
|
1279
1324
|
additionalProperties: false,
|
|
1325
|
+
examples: [{ key: "screenshots/settings.png", set: { path: "/settings", state: "after" } }],
|
|
1280
1326
|
},
|
|
1281
1327
|
async handler(args) {
|
|
1282
1328
|
const key = optString(args, "key");
|
|
@@ -1298,13 +1344,15 @@ export function createUploadsMcpTools(opts) {
|
|
|
1298
1344
|
title: "Find files",
|
|
1299
1345
|
annotations: mcpRead,
|
|
1300
1346
|
securitySchemes: mcpOAuthRead,
|
|
1301
|
-
description: "
|
|
1347
|
+
description: "Search files by metadata (`filters`) and/or filename substring (`name`). At least one is required. Same as `uploads find`.",
|
|
1302
1348
|
inputSchema: {
|
|
1303
1349
|
type: "object",
|
|
1304
1350
|
properties: {
|
|
1305
1351
|
filters: {
|
|
1306
1352
|
...metadataProp,
|
|
1307
|
-
description: "
|
|
1353
|
+
description: "Equality filters; all must match. " +
|
|
1354
|
+
METADATA_PATH_CUE +
|
|
1355
|
+
" Optional when `name` is set.",
|
|
1308
1356
|
},
|
|
1309
1357
|
name: {
|
|
1310
1358
|
type: "string",
|
|
@@ -1326,6 +1374,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
1326
1374
|
workspace: workspaceProp,
|
|
1327
1375
|
},
|
|
1328
1376
|
additionalProperties: false,
|
|
1377
|
+
examples: [{ filters: { path: "/settings", state: "after" } }, { name: "hero.png" }],
|
|
1329
1378
|
},
|
|
1330
1379
|
async handler(args) {
|
|
1331
1380
|
const filters = optStringRecord(args, "filters") ?? {};
|
|
@@ -1355,7 +1404,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
1355
1404
|
title: "List metadata keys",
|
|
1356
1405
|
annotations: mcpRead,
|
|
1357
1406
|
securitySchemes: mcpOAuthRead,
|
|
1358
|
-
description: "List
|
|
1407
|
+
description: "List metadata keys in the workspace (with counts). Pass `key` to list that key's values instead. Use before `find_files`. Same as `uploads meta keys`.",
|
|
1359
1408
|
inputSchema: {
|
|
1360
1409
|
type: "object",
|
|
1361
1410
|
properties: {
|
|
@@ -1434,6 +1483,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
1434
1483
|
workspace: workspaceProp,
|
|
1435
1484
|
},
|
|
1436
1485
|
additionalProperties: false,
|
|
1486
|
+
examples: [{ pr: 12 }],
|
|
1437
1487
|
},
|
|
1438
1488
|
async handler(args) {
|
|
1439
1489
|
const target = ghTargetFromArgs(args, run);
|
|
@@ -1448,16 +1498,32 @@ export function createUploadsMcpTools(opts) {
|
|
|
1448
1498
|
},
|
|
1449
1499
|
},
|
|
1450
1500
|
{
|
|
1451
|
-
name: "
|
|
1452
|
-
title: "
|
|
1501
|
+
name: "whoami",
|
|
1502
|
+
title: "Who am I",
|
|
1453
1503
|
annotations: mcpRead,
|
|
1454
1504
|
securitySchemes: mcpNoAuth,
|
|
1455
|
-
description: "
|
|
1505
|
+
description: "Show the active uploads.sh identity: workspace, API URL, and token scopes. Use this to learn which workspace you're talking to. A successful result also means the API is up. For a full setup diagnosis, use `doctor`.",
|
|
1456
1506
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
1457
1507
|
async handler(args) {
|
|
1458
1508
|
const { config, client } = await clientFor(args, false);
|
|
1459
|
-
const
|
|
1460
|
-
|
|
1509
|
+
const health = await client.health();
|
|
1510
|
+
const signedIn = Boolean(config.token);
|
|
1511
|
+
let scopes;
|
|
1512
|
+
if (signedIn) {
|
|
1513
|
+
try {
|
|
1514
|
+
scopes = (await client.usage()).scopes;
|
|
1515
|
+
}
|
|
1516
|
+
catch {
|
|
1517
|
+
// Workspace and API URL are still useful if usage is unavailable.
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
return {
|
|
1521
|
+
ok: health.ok,
|
|
1522
|
+
signedIn,
|
|
1523
|
+
workspace: config.workspace,
|
|
1524
|
+
apiUrl: config.apiUrl,
|
|
1525
|
+
...(scopes ? { scopes } : {}),
|
|
1526
|
+
};
|
|
1461
1527
|
},
|
|
1462
1528
|
},
|
|
1463
1529
|
{
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Literal-form private/loopback/link-local host checks. Mirrors the API's
|
|
3
|
+
* `isPrivateRenderTarget` so CLI `--via remote` and `put --url` reject the
|
|
4
|
+
* same targets the render/fetch endpoints would.
|
|
5
|
+
*
|
|
6
|
+
* Accepts a bare hostname or an IPv6 literal with its brackets still attached
|
|
7
|
+
* (as returned by `new URL(...).hostname`, e.g. `"[::1]"`).
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Loopback only: `localhost`, `*.localhost`, `127.0.0.0/8`, `::1`.
|
|
11
|
+
* Not RFC1918, not link-local, not `.internal` — those stay blocked even on the CLI.
|
|
12
|
+
*/
|
|
13
|
+
export declare function isLoopbackHost(hostname: string): boolean;
|
|
14
|
+
export declare function isPrivateOrLocalHost(hostname: string): boolean;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Literal-form private/loopback/link-local host checks. Mirrors the API's
|
|
3
|
+
* `isPrivateRenderTarget` so CLI `--via remote` and `put --url` reject the
|
|
4
|
+
* same targets the render/fetch endpoints would.
|
|
5
|
+
*
|
|
6
|
+
* Accepts a bare hostname or an IPv6 literal with its brackets still attached
|
|
7
|
+
* (as returned by `new URL(...).hostname`, e.g. `"[::1]"`).
|
|
8
|
+
*/
|
|
9
|
+
/** IPv4 loopback/private/link-local ranges. */
|
|
10
|
+
const PRIVATE_IPV4_RE = /^(127\.\d+\.\d+\.\d+|0\.0\.0\.0|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|169\.254\.\d+\.\d+)$/;
|
|
11
|
+
/** Hostname forms treated as local/private regardless of DNS resolution. */
|
|
12
|
+
const PRIVATE_HOSTNAME_RE = /^((.+\.)?localhost|.+\.local|.+\.internal)$/i;
|
|
13
|
+
/** IPv6 unique local addresses, fc00::/7 (RFC 4193). */
|
|
14
|
+
const IPV6_ULA_RE = /^f[cd][0-9a-f]{2}:/i;
|
|
15
|
+
/** IPv6 link-local addresses, fe80::/10. */
|
|
16
|
+
const IPV6_LINK_LOCAL_RE = /^fe[89ab][0-9a-f]:/i;
|
|
17
|
+
function isPrivateIPv4(host) {
|
|
18
|
+
return PRIVATE_IPV4_RE.test(host);
|
|
19
|
+
}
|
|
20
|
+
function stripBrackets(hostname) {
|
|
21
|
+
return /^\[.+\]$/.test(hostname) ? hostname.slice(1, -1) : hostname;
|
|
22
|
+
}
|
|
23
|
+
/** IPv4-mapped IPv6 tail (`::ffff:127.0.0.1` or `::ffff:7f00:1`) as a dotted quad. */
|
|
24
|
+
function mappedIpv4(host) {
|
|
25
|
+
const mapped = /^::ffff:(.+)$/i.exec(host);
|
|
26
|
+
if (!mapped)
|
|
27
|
+
return undefined;
|
|
28
|
+
const rest = mapped[1];
|
|
29
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(rest))
|
|
30
|
+
return rest;
|
|
31
|
+
const hex = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(rest);
|
|
32
|
+
if (!hex)
|
|
33
|
+
return undefined;
|
|
34
|
+
const hi = Number.parseInt(hex[1], 16);
|
|
35
|
+
const lo = Number.parseInt(hex[2], 16);
|
|
36
|
+
const a = (hi >> 8) & 0xff;
|
|
37
|
+
const b = hi & 0xff;
|
|
38
|
+
const c = (lo >> 8) & 0xff;
|
|
39
|
+
const d = lo & 0xff;
|
|
40
|
+
return `${a}.${b}.${c}.${d}`;
|
|
41
|
+
}
|
|
42
|
+
function isLoopbackIpv4(host) {
|
|
43
|
+
return /^127\.\d+\.\d+\.\d+$/.test(host);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Loopback only: `localhost`, `*.localhost`, `127.0.0.0/8`, `::1`.
|
|
47
|
+
* Not RFC1918, not link-local, not `.internal` — those stay blocked even on the CLI.
|
|
48
|
+
*/
|
|
49
|
+
export function isLoopbackHost(hostname) {
|
|
50
|
+
const host = stripBrackets(hostname);
|
|
51
|
+
if (host === "localhost" || host.toLowerCase().endsWith(".localhost"))
|
|
52
|
+
return true;
|
|
53
|
+
if (host === "::1")
|
|
54
|
+
return true;
|
|
55
|
+
if (isLoopbackIpv4(host))
|
|
56
|
+
return true;
|
|
57
|
+
const mapped = mappedIpv4(host);
|
|
58
|
+
return mapped !== undefined && isLoopbackIpv4(mapped);
|
|
59
|
+
}
|
|
60
|
+
export function isPrivateOrLocalHost(hostname) {
|
|
61
|
+
const host = stripBrackets(hostname);
|
|
62
|
+
if (isPrivateIPv4(host))
|
|
63
|
+
return true;
|
|
64
|
+
if (PRIVATE_HOSTNAME_RE.test(host))
|
|
65
|
+
return true;
|
|
66
|
+
if (host === "::1" || host === "::")
|
|
67
|
+
return true;
|
|
68
|
+
if (IPV6_ULA_RE.test(host))
|
|
69
|
+
return true;
|
|
70
|
+
if (IPV6_LINK_LOCAL_RE.test(host))
|
|
71
|
+
return true;
|
|
72
|
+
// IPv4-mapped IPv6, e.g. "::ffff:10.0.0.1" or "::ffff:a00:1" — private iff
|
|
73
|
+
// the mapped IPv4 quad is private.
|
|
74
|
+
const mapped = /^::ffff:(.+)$/i.exec(host);
|
|
75
|
+
if (mapped) {
|
|
76
|
+
const rest = mapped[1];
|
|
77
|
+
if (isPrivateIPv4(rest))
|
|
78
|
+
return true;
|
|
79
|
+
const hex = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(rest);
|
|
80
|
+
if (hex) {
|
|
81
|
+
const hi = Number.parseInt(hex[1], 16);
|
|
82
|
+
const lo = Number.parseInt(hex[2], 16);
|
|
83
|
+
const a = (hi >> 8) & 0xff;
|
|
84
|
+
const b = hi & 0xff;
|
|
85
|
+
const c = (lo >> 8) & 0xff;
|
|
86
|
+
const d = lo & 0xff;
|
|
87
|
+
if (isPrivateIPv4(`${a}.${b}.${c}.${d}`))
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
package/dist/screenshot.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { isPrivateOrLocalHost } from "./private-host.js";
|
|
1
2
|
import { captureRemote } from "./screenshot-remote.js";
|
|
2
3
|
import type { DetectRoots } from "./screenshot-local.js";
|
|
4
|
+
export { isPrivateOrLocalHost };
|
|
3
5
|
export type ScreenshotBackend = "auto" | "local" | "remote";
|
|
4
6
|
export type WaitUntil = "load" | "domcontentloaded" | "networkidle" | number;
|
|
5
7
|
/**
|
|
@@ -48,14 +50,6 @@ export type ScreenshotTarget = {
|
|
|
48
50
|
path: string;
|
|
49
51
|
html: string;
|
|
50
52
|
};
|
|
51
|
-
/**
|
|
52
|
-
* True for localhost / private-network / link-local hosts — only reachable
|
|
53
|
-
* by the local backend. Accepts a bare hostname or an IPv6 literal with its
|
|
54
|
-
* brackets still attached (as returned by `new URL(...).hostname`, e.g.
|
|
55
|
-
* `"[::1]"`). Mirrors the server's `isPrivateRenderTarget` so `--via remote`
|
|
56
|
-
* fails fast for the same targets the render endpoint itself would reject.
|
|
57
|
-
*/
|
|
58
|
-
export declare function isPrivateOrLocalHost(hostname: string): boolean;
|
|
59
53
|
/** Classifies a CLI target: http(s) URL, or a path to a local .html file. */
|
|
60
54
|
export declare function classifyTarget(target: string): ScreenshotTarget;
|
|
61
55
|
/** A measured element box in device (raster) pixels — CSS pixels × deviceScaleFactor. */
|
package/dist/screenshot.js
CHANGED
|
@@ -13,7 +13,9 @@ import { basename, resolve as resolvePath } from "node:path";
|
|
|
13
13
|
import { pathToFileURL } from "node:url";
|
|
14
14
|
import { UploadsError } from "./errors.js";
|
|
15
15
|
import { isMetaStateValue } from "./metadata-vocab.js";
|
|
16
|
+
import { isPrivateOrLocalHost } from "./private-host.js";
|
|
16
17
|
import { captureRemote, MAX_REMOTE_HTML_BYTES } from "./screenshot-remote.js";
|
|
18
|
+
export { isPrivateOrLocalHost };
|
|
17
19
|
/**
|
|
18
20
|
* Host selectors for framework dev toolbars/overlays that otherwise pollute a
|
|
19
21
|
* screenshot of a running dev server. Hidden (display:none) automatically when
|
|
@@ -84,57 +86,6 @@ export function parseWaitUntil(raw) {
|
|
|
84
86
|
return Number.parseInt(raw, 10);
|
|
85
87
|
throw new UploadsError(`invalid wait strategy: ${raw} (use load, domcontentloaded, networkidle, or a millisecond count)`, "USAGE");
|
|
86
88
|
}
|
|
87
|
-
/** IPv4 loopback/private/link-local ranges. Mirrors the server's isPrivateRenderTarget. */
|
|
88
|
-
const PRIVATE_IPV4_RE = /^(127\.\d+\.\d+\.\d+|0\.0\.0\.0|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|169\.254\.\d+\.\d+)$/;
|
|
89
|
-
/** Hostname forms treated as local/private regardless of DNS resolution. */
|
|
90
|
-
const PRIVATE_HOSTNAME_RE = /^((.+\.)?localhost|.+\.local|.+\.internal)$/i;
|
|
91
|
-
/** IPv6 unique local addresses, fc00::/7 (RFC 4193). */
|
|
92
|
-
const IPV6_ULA_RE = /^f[cd][0-9a-f]{2}:/i;
|
|
93
|
-
/** IPv6 link-local addresses, fe80::/10. */
|
|
94
|
-
const IPV6_LINK_LOCAL_RE = /^fe[89ab][0-9a-f]:/i;
|
|
95
|
-
function isPrivateIPv4(host) {
|
|
96
|
-
return PRIVATE_IPV4_RE.test(host);
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
|
-
* True for localhost / private-network / link-local hosts — only reachable
|
|
100
|
-
* by the local backend. Accepts a bare hostname or an IPv6 literal with its
|
|
101
|
-
* brackets still attached (as returned by `new URL(...).hostname`, e.g.
|
|
102
|
-
* `"[::1]"`). Mirrors the server's `isPrivateRenderTarget` so `--via remote`
|
|
103
|
-
* fails fast for the same targets the render endpoint itself would reject.
|
|
104
|
-
*/
|
|
105
|
-
export function isPrivateOrLocalHost(hostname) {
|
|
106
|
-
const host = /^\[.+\]$/.test(hostname) ? hostname.slice(1, -1) : hostname;
|
|
107
|
-
if (isPrivateIPv4(host))
|
|
108
|
-
return true;
|
|
109
|
-
if (PRIVATE_HOSTNAME_RE.test(host))
|
|
110
|
-
return true;
|
|
111
|
-
if (host === "::1" || host === "::")
|
|
112
|
-
return true;
|
|
113
|
-
if (IPV6_ULA_RE.test(host))
|
|
114
|
-
return true;
|
|
115
|
-
if (IPV6_LINK_LOCAL_RE.test(host))
|
|
116
|
-
return true;
|
|
117
|
-
// IPv4-mapped IPv6, e.g. "::ffff:10.0.0.1" or "::ffff:a00:1" — private iff
|
|
118
|
-
// the mapped IPv4 quad is private.
|
|
119
|
-
const mapped = /^::ffff:(.+)$/i.exec(host);
|
|
120
|
-
if (mapped) {
|
|
121
|
-
const rest = mapped[1];
|
|
122
|
-
if (isPrivateIPv4(rest))
|
|
123
|
-
return true;
|
|
124
|
-
const hex = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(rest);
|
|
125
|
-
if (hex) {
|
|
126
|
-
const hi = Number.parseInt(hex[1], 16);
|
|
127
|
-
const lo = Number.parseInt(hex[2], 16);
|
|
128
|
-
const a = (hi >> 8) & 0xff;
|
|
129
|
-
const b = hi & 0xff;
|
|
130
|
-
const c = (lo >> 8) & 0xff;
|
|
131
|
-
const d = lo & 0xff;
|
|
132
|
-
if (isPrivateIPv4(`${a}.${b}.${c}.${d}`))
|
|
133
|
-
return true;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
return false;
|
|
137
|
-
}
|
|
138
89
|
/** Classifies a CLI target: http(s) URL, or a path to a local .html file. */
|
|
139
90
|
export function classifyTarget(target) {
|
|
140
91
|
if (/^https?:\/\//i.test(target)) {
|