@buildinternet/uploads 0.48.0 → 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 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`, `health`, 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 (`health` needs no auth).
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"] }
@@ -204,6 +206,8 @@ Config layers (first match wins): CLI flags → env vars → `--env-file` → `~
204
206
 
205
207
  Or with `UPLOADS_TOKEN`/`UPLOADS_WORKSPACE` in the environment or user config. Claude Code: `claude mcp add uploads -- uploads --env-file /path/to/.env mcp`.
206
208
 
209
+ The MCP Registry lists this server as `sh.uploads/mcp`.
210
+
207
211
  For HTTP clients there's also a hosted variant at `https://agents.uploads.sh/mcp` — the workspace is inferred from the bearer token, so only the URL and token are needed (`https://agents.uploads.sh/<workspace>/mcp` and the `mcp.uploads.sh` hostname also work). Tools: file operations (including `get_metadata` / `set_metadata` / `find_files`) plus `gallery_create`, `gallery_get`, `gallery_add`, `gallery_link`, and `gallery_find_by_reference`; all use the same bearer-token workspace scopes and gallery URLs come from the API — see `apps/mcp` in the repo. The hosted `put` also accepts a `metadata` param. `uploads install` registers the skills + hosted MCP with whichever of Claude Code, Codex, and Grok are on PATH (a missing CLI is skipped) + Grok/Cursor hooks (short progress; `--verbose` for underlying output). Claude and Codex use their plugins for the same pre-PR screenshot reminder (`uploads hook pre-pr-screenshot`). Its `put` takes no content type: the stored type is sniffed server-side from the bytes and checked against the workspace allowlist, and writes are rate limited per workspace.
208
212
 
209
213
  ## Programmatic use
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|attach)
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;
@@ -2,7 +2,7 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio";
2
2
  import { AjvJsonSchemaValidator } from "@modelcontextprotocol/server/validators/ajv";
3
3
  import { parseCommandArgs } from "../cli-args.js";
4
4
  import { resolveApiUrl } from "../config.js";
5
- import { createMcpServer } from "../mcp/server.js";
5
+ import { createMcpServer, MCP_SERVER_ICONS } from "../mcp/server.js";
6
6
  import { createUploadsMcpTools } from "../mcp/tools.js";
7
7
  import { packageVersion } from "../package-version.js";
8
8
  import { writeCommandHelp } from "../cli-style.js";
@@ -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, health, doctor.
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.
@@ -35,7 +35,7 @@ export async function runMcp(args, opts, help = false) {
35
35
  // `@cfworker/json-schema` provider instead).
36
36
  const validator = new AjvJsonSchemaValidator();
37
37
  const handle = serveStdio(() => createMcpServer({
38
- serverInfo: { name: "uploads", version: packageVersion() },
38
+ serverInfo: { name: "uploads", version: packageVersion(), icons: MCP_SERVER_ICONS },
39
39
  tools: createUploadsMcpTools({ globals: opts.globals }),
40
40
  apiUrl: resolveApiUrl(opts.globals),
41
41
  validator,
@@ -350,7 +350,16 @@ export type PutUploadItem = PutResult & {
350
350
  */
351
351
  export declare function uploadPuts(opts: {
352
352
  client: UploadsClient;
353
- files: readonly string[];
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
- if (opts.files.length > 1 && opts.files.some((f) => f === "-")) {
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 (opts.files.length > 1 && opts.explicitKey) {
1049
+ if (count > 1 && opts.explicitKey) {
1034
1050
  throw new UsageError("--key cannot be combined with multiple files");
1035
1051
  }
1036
- if (opts.files.length > 1 && opts.nameOverride) {
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 slots = await mapBounded(opts.files, opts.concurrency ?? UPLOAD_BATCH_CONCURRENCY, async (file) => {
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 sourceName = opts.nameOverride ??
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
- const metadata = file !== "-" ? mergeSidecarMeta(file, bytes, opts.metadata) : opts.metadata;
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 (files.length === 0) {
1887
- throw new UsageError("put requires at least one file", {
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
- process.stderr.write(`>> ${dryRun ? "dry run for" : "uploading"} ${files.length} files\n`);
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
- const { uploads, failures, firstError, sentMetadata } = await uploadPuts({
2148
- client: ctx.client,
2149
- files,
2150
- nameOverride: nameFlag,
2151
- explicitKey: keyHint,
2152
- ghTarget: effectiveGhTarget,
2153
- ghBranchTarget: stagingTarget,
2154
- prefix: resolvedPrefix ?? defaults.prefix,
2155
- repo: flagString(parsed.flags, "--repo") ?? defaults.repo,
2156
- ref: flagString(parsed.flags, "--ref") ?? defaults.ref,
2157
- deriveRepoFromGit: !noGit,
2158
- contentType: contentTypeOverride,
2159
- dryRun,
2160
- replace: replaceFlag,
2161
- optimize: optimizeOpts,
2162
- frame: frameOpts,
2163
- metadata,
2164
- deriveImageFacts: derivedMetaEnabled(parsed.flags, defaults),
2165
- alt: altFlag,
2166
- width,
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";
@@ -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
- * Shared tool-description text for the metadata-shaped `metadata`/`set`/
15
- * `filters` params across the CLI/local MCP (put/attach/set_metadata/
16
- * find_files) and the remote MCP worker (set_metadata/find_files).
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
- * Shared tool-description text for the metadata-shaped `metadata`/`set`/
71
- * `filters` params across the CLI/local MCP (put/attach/set_metadata/
72
- * find_files) and the remote MCP worker (set_metadata/find_files).
73
- */
74
- export const METADATA_DESCRIPTION = "Queryable custom metadata (key→value), 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) — 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).";
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],