@buildinternet/uploads 0.7.0 → 0.8.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.
@@ -1,4 +1,4 @@
1
- export { optPosInt, optString, usage, type ToolArgs } from "./args.js";
1
+ export { METADATA_DESCRIPTION, metadataProp, optPosInt, optString, optStringArray, optStringRecord, usage, type ToolArgs, } from "./args.js";
2
2
  export interface McpTool {
3
3
  name: string;
4
4
  description: string;
@@ -8,7 +8,7 @@
8
8
  * stdio transport lives in ./stdio.ts; logs must never go to stdout.
9
9
  */
10
10
  import { UploadsError } from "../errors.js";
11
- export { optPosInt, optString, usage } from "./args.js";
11
+ export { METADATA_DESCRIPTION, metadataProp, optPosInt, optString, optStringArray, optStringRecord, usage, } from "./args.js";
12
12
  const SUPPORTED_PROTOCOL_VERSIONS = new Set(["2025-06-18", "2025-03-26", "2024-11-05"]);
13
13
  const LATEST_PROTOCOL_VERSION = "2025-06-18";
14
14
  function response(id, result) {
package/dist/mcp/tools.js CHANGED
@@ -5,19 +5,20 @@
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.
7
7
  */
8
- import { readFileSync } from "node:fs";
9
8
  import { basename } from "node:path";
10
9
  import { createUploadsClient } from "../client.js";
11
- import { buildDoctorReport, makeGhTarget, prepareImageForUpload, syncAttachmentsComment, } from "../commands.js";
10
+ import { buildDoctorReport, makeGhTarget, prepareImageForUpload, readFileArg, syncAttachmentsComment, } from "../commands.js";
12
11
  import { resolveFrameId } from "../frame.js";
13
12
  import { resolveConfig, resolvePutDefaults, } from "../config.js";
14
13
  import { buildMarkdown } from "../embed.js";
14
+ import { urlForGithubEmbed } from "../public-urls.js";
15
15
  import { resolvePutPrefix } from "../destinations.js";
16
- import { ghAttachmentKey, ghKeyPrefix } from "../github.js";
16
+ import { ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget } from "../github.js";
17
+ import { validateMetaMap } from "../metadata.js";
17
18
  import { rewriteKeyExtension } from "../optimize.js";
18
19
  import { buildCliProvenance } from "../provenance.js";
19
20
  import { execRunner, resolveCurrentPullRequest, resolveRepo, } from "../github-gh.js";
20
- import { optPosInt, optString, usage } from "./args.js";
21
+ import { METADATA_DESCRIPTION, metadataProp, optPosInt, optString, optStringArray, optStringRecord, usage, } from "./args.js";
21
22
  function optBool(args, name) {
22
23
  const v = args[name];
23
24
  if (v === undefined || v === null)
@@ -26,15 +27,6 @@ function optBool(args, name) {
26
27
  usage(`${name} must be a boolean`);
27
28
  return v;
28
29
  }
29
- function optStringArray(args, name) {
30
- const v = args[name];
31
- if (v === undefined || v === null)
32
- return undefined;
33
- if (!Array.isArray(v) || v.some((item) => typeof item !== "string")) {
34
- usage(`${name} must be an array of strings`);
35
- }
36
- return v;
37
- }
38
30
  function mcpOptimizeOptions(args, defaults) {
39
31
  const quality = optPosInt(args, "optimizeQuality");
40
32
  if (quality !== undefined && quality > 100)
@@ -276,7 +268,7 @@ export function createUploadsMcpTools(opts) {
276
268
  },
277
269
  {
278
270
  name: "put",
279
- description: "Upload a file to uploads.sh and get a public URL plus GitHub-ready embed markdown (the returned `markdown` is ready to paste into a PR or issue). Pass `file` (a local path) or `contentBase64` + `filename` for in-memory content; with `pr`/`issue` the key is stable (same filename → same URL) and `comment` syncs the managed attachments comment. All uploads are public; pr/issue keys are predictable and remain public for private/internal GitHub repositories, so upload only non-sensitive media.",
271
+ description: "Upload a file to uploads.sh and get a public URL plus GitHub-ready embed markdown. Returns `url` (durable CDN) and `embedUrl` (same object, freshness-oriented host for GitHub Camo prefer this in PR/issue markdown). The returned `markdown` already uses embedUrl when available. Pass `file` or `contentBase64` + `filename`; with `pr`/`issue` the key is stable and `comment` syncs the managed attachments comment. All uploads are public; pr/issue keys are predictable, so upload only non-sensitive media.",
280
272
  inputSchema: {
281
273
  type: "object",
282
274
  properties: {
@@ -290,7 +282,7 @@ export function createUploadsMcpTools(opts) {
290
282
  },
291
283
  filename: {
292
284
  type: "string",
293
- description: "Filename for contentBase64 content (drives the key and content type).",
285
+ description: "Filename for contentBase64 content (drives the key and content type). With `file`, overrides the key's leaf (clean name) while keeping the pr/default path.",
294
286
  },
295
287
  key: {
296
288
  type: "string",
@@ -345,6 +337,11 @@ export function createUploadsMcpTools(opts) {
345
337
  type: "boolean",
346
338
  description: "With pr/issue: create or update the managed attachments comment via local gh auth (best-effort).",
347
339
  },
340
+ dryRun: {
341
+ type: "boolean",
342
+ description: "Resolve key + public URL without uploading. Not with comment.",
343
+ },
344
+ metadata: metadataProp,
348
345
  workspace: workspaceProp,
349
346
  },
350
347
  additionalProperties: false,
@@ -361,12 +358,15 @@ export function createUploadsMcpTools(opts) {
361
358
  }
362
359
  const target = ghTargetFromArgs(args, run);
363
360
  const wantComment = optBool(args, "comment");
361
+ const dryRun = optBool(args, "dryRun");
364
362
  const keyArg = optString(args, "key");
365
363
  const destArg = optString(args, "destination");
366
364
  const prefixArg = optString(args, "prefix");
367
365
  const refArg = optString(args, "ref");
368
366
  if (wantComment && !target)
369
367
  usage("comment requires pr or issue");
368
+ if (dryRun && wantComment)
369
+ usage("dryRun cannot be combined with comment");
370
370
  if (target) {
371
371
  if (keyArg)
372
372
  usage("key cannot be combined with pr/issue");
@@ -375,6 +375,12 @@ export function createUploadsMcpTools(opts) {
375
375
  if (prefixArg)
376
376
  usage("prefix cannot be combined with pr/issue");
377
377
  }
378
+ // Validate up front (fail fast, before reading/optimizing the file).
379
+ // undefined leaves existing metadata untouched; an object (even {})
380
+ // fully replaces it — see metadataProp's description.
381
+ const metadata = optStringRecord(args, "metadata");
382
+ if (metadata)
383
+ validateMetaMap(metadata);
378
384
  let resolvedPrefix;
379
385
  try {
380
386
  resolvedPrefix = resolvePutPrefix({
@@ -389,7 +395,7 @@ export function createUploadsMcpTools(opts) {
389
395
  }
390
396
  const { client } = clientFor(args);
391
397
  const bytes = file !== undefined
392
- ? new Uint8Array(readFileSync(file))
398
+ ? readFileArg(file)
393
399
  : new Uint8Array(Buffer.from(contentBase64, "base64"));
394
400
  const sourceName = file !== undefined ? (filenameArg ?? basename(file)) : filenameArg;
395
401
  const defaults = resolvePutDefaults({ envFile: globals.envFile });
@@ -412,6 +418,7 @@ export function createUploadsMcpTools(opts) {
412
418
  ref: refArg ?? defaults.ref,
413
419
  contentType: prepared.optimized ? prepared.contentType : optString(args, "contentType"),
414
420
  deriveRepoFromGit: !noGit,
421
+ dryRun,
415
422
  provenance: buildCliProvenance({
416
423
  sourceName,
417
424
  client: "uploads-mcp",
@@ -419,8 +426,9 @@ export function createUploadsMcpTools(opts) {
419
426
  frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
420
427
  keepExif: optimizeOpts.keepExif === true,
421
428
  }),
429
+ metadata,
422
430
  });
423
- const markdown = buildMarkdown(result.url, {
431
+ const markdown = buildMarkdown(urlForGithubEmbed(result.url, result.embedUrl), {
424
432
  alt: optString(args, "alt") ?? sourceName,
425
433
  width: optPosInt(args, "width") ?? defaults.width,
426
434
  });
@@ -435,12 +443,18 @@ export function createUploadsMcpTools(opts) {
435
443
  const { comment, commentError } = await syncComment(client, target);
436
444
  return { ...result, markdown, optimize, frame: prepared.frame, comment, commentError };
437
445
  }
438
- return { ...result, markdown, optimize, frame: prepared.frame };
446
+ return {
447
+ ...result,
448
+ markdown,
449
+ optimize,
450
+ frame: prepared.frame,
451
+ ...(dryRun ? { dryRun: true } : {}),
452
+ };
439
453
  },
440
454
  },
441
455
  {
442
456
  name: "attach",
443
- description: "Upload one or more files as stable PR/issue attachments and maintain a single managed GitHub comment listing them (each upload's `markdown` is ready to paste into GitHub). With no pr/issue, targets the pull request for the current branch. Attachments are public and their repo/number/filename keys are predictable even for private/internal GitHub repositories; upload only non-sensitive media.",
457
+ description: "Upload one or more files as stable PR/issue attachments and maintain a single managed GitHub comment listing them. Each upload returns `url`, `embedUrl` (when dual-host applies), and `markdown` (uses embedUrl for GitHub). With no pr/issue, targets the pull request for the current branch. Attachments are public and keys are predictable; upload only non-sensitive media.",
444
458
  inputSchema: {
445
459
  type: "object",
446
460
  properties: {
@@ -475,6 +489,11 @@ export function createUploadsMcpTools(opts) {
475
489
  description: "Keep EXIF/XMP/ICC when optimizing (default: strip for privacy on public embeds).",
476
490
  },
477
491
  ...frameProps,
492
+ metadata: {
493
+ ...metadataProp,
494
+ 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. " +
495
+ METADATA_DESCRIPTION,
496
+ },
478
497
  workspace: workspaceProp,
479
498
  },
480
499
  required: ["files"],
@@ -492,10 +511,23 @@ export function createUploadsMcpTools(opts) {
492
511
  const defaults = resolvePutDefaults({ envFile: globals.envFile });
493
512
  const frameOpts = mcpFrameOptions(args);
494
513
  const optimizeOpts = mcpOptimizeOptions(args, defaults);
514
+ // User-supplied extras first, then the resolved target's gh.* —
515
+ // explicit target pairs always win over a same-named metadata extra
516
+ // (mirrors runAttach in ../commands.js). Validate the merged map (not
517
+ // just the extras) so the 24-key/8KB caps are enforced client-side —
518
+ // extras alone might pass while extras + the 4 gh.* pairs exceed the
519
+ // cap, which would otherwise only be caught server-side after upload.
520
+ const metaExtras = optStringRecord(args, "metadata") ?? {};
521
+ const metadata = { ...metaExtras, ...ghMetadataFromTarget(target) };
522
+ if (Object.keys(metadata).length > 0)
523
+ validateMetaMap(metadata);
495
524
  const uploads = [];
496
525
  for (const file of files) {
497
526
  const sourceName = basename(file);
498
- const prepared = await prepareImageForUpload(new Uint8Array(readFileSync(file)), sourceName, { ...frameOpts, optimize: optimizeOpts });
527
+ const prepared = await prepareImageForUpload(readFileArg(file), sourceName, {
528
+ ...frameOpts,
529
+ optimize: optimizeOpts,
530
+ });
499
531
  const result = await client.put(prepared.bytes, {
500
532
  filename: prepared.filename,
501
533
  key: ghAttachmentKey(target, prepared.filename),
@@ -507,10 +539,13 @@ export function createUploadsMcpTools(opts) {
507
539
  frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
508
540
  keepExif: optimizeOpts.keepExif === true,
509
541
  }),
542
+ metadata,
510
543
  });
511
544
  uploads.push({
512
545
  ...result,
513
- markdown: buildMarkdown(result.url, { alt: sourceName }),
546
+ markdown: buildMarkdown(urlForGithubEmbed(result.url, result.embedUrl), {
547
+ alt: sourceName,
548
+ }),
514
549
  frame: prepared.frame,
515
550
  optimize: {
516
551
  optimized: prepared.optimized,
@@ -591,6 +626,71 @@ export function createUploadsMcpTools(opts) {
591
626
  return client.delete(key);
592
627
  },
593
628
  },
629
+ {
630
+ name: "set_metadata",
631
+ description: "Merge-set and/or delete an object's queryable custom metadata (D1-backed key-value pairs; distinct from the R2 provenance headers put on upload). `set` pairs win over `delete` when a key appears in both. " +
632
+ METADATA_DESCRIPTION +
633
+ " Requires at least one of `set` or `delete`. Same as `uploads meta set`.",
634
+ inputSchema: {
635
+ type: "object",
636
+ properties: {
637
+ key: { type: "string", description: "Object key to update." },
638
+ set: { ...metadataProp, description: "Keys to set/overwrite. " + METADATA_DESCRIPTION },
639
+ delete: {
640
+ type: "array",
641
+ items: { type: "string" },
642
+ description: "Keys to remove.",
643
+ },
644
+ workspace: workspaceProp,
645
+ },
646
+ required: ["key"],
647
+ additionalProperties: false,
648
+ },
649
+ async handler(args) {
650
+ const key = optString(args, "key");
651
+ if (!key)
652
+ usage("key is required");
653
+ const set = optStringRecord(args, "set");
654
+ const del = optStringArray(args, "delete");
655
+ if ((!set || Object.keys(set).length === 0) && (!del || del.length === 0)) {
656
+ usage("set_metadata requires set and/or delete");
657
+ }
658
+ if (set)
659
+ validateMetaMap(set);
660
+ const { client } = clientFor(args);
661
+ return client.patchMetadata(key, { set, delete: del });
662
+ },
663
+ },
664
+ {
665
+ name: "find_files",
666
+ description: "Find objects in the workspace whose queryable custom metadata matches ALL of `filters` (ANDed equality). Returns each match's key, public URL, and full metadata map. Same as `uploads find k=v...` / `uploads list --meta k=v`.",
667
+ inputSchema: {
668
+ type: "object",
669
+ properties: {
670
+ filters: {
671
+ ...metadataProp,
672
+ description: "Metadata equality filters (at least one pair). " + METADATA_DESCRIPTION,
673
+ },
674
+ prefix: { type: "string", description: "Key prefix filter, combinable with filters." },
675
+ limit: { type: "number", description: "Page size (default 50, max 500)." },
676
+ workspace: workspaceProp,
677
+ },
678
+ required: ["filters"],
679
+ additionalProperties: false,
680
+ },
681
+ async handler(args) {
682
+ const filters = optStringRecord(args, "filters");
683
+ if (!filters || Object.keys(filters).length === 0) {
684
+ usage("filters must have at least one key");
685
+ }
686
+ validateMetaMap(filters);
687
+ const { client } = clientFor(args);
688
+ return client.findFiles(filters, {
689
+ prefix: optString(args, "prefix"),
690
+ limit: optPosInt(args, "limit"),
691
+ });
692
+ },
693
+ },
594
694
  {
595
695
  name: "usage",
596
696
  description: "Workspace storage and monthly upload counters (and remaining headroom when budgets are configured). Same as `uploads usage`.",
@@ -0,0 +1,32 @@
1
+ /** Lowercase key, optionally dot-namespaced (e.g. `gh.repo`). Mirrors META_KEY_RE server-side. */
2
+ export declare const META_KEY_RE: RegExp;
3
+ /** Max value length in characters (mirrors META_VALUE_MAX server-side). */
4
+ export declare const META_VALUE_MAX = 512;
5
+ /** Cap on keys per request (mirrors META_MAX_KEYS server-side). */
6
+ export declare const META_MAX_KEYS = 24;
7
+ /** Cap on total UTF-8 key+value bytes per request (mirrors META_MAX_TOTAL_BYTES server-side). */
8
+ export declare const META_MAX_TOTAL_BYTES = 8192;
9
+ /** Throws `UsageError` with a readable message if `key`/`value` violate the metadata rules. */
10
+ export declare function validateMetaEntry(key: string, value: string): void;
11
+ /**
12
+ * Split `k=v` on the FIRST "=" (so values may themselves contain "="), then
13
+ * validate the pair. Throws `UsageError` on malformed input.
14
+ */
15
+ export declare function parseMetaPair(raw: string): [string, string];
16
+ /**
17
+ * Parse and validate a batch of `k=v` pairs (e.g. every `--meta` occurrence,
18
+ * or `meta set`'s positional pairs) into a map. Fails fast on the first
19
+ * invalid pair or when the batch exceeds `META_MAX_KEYS` or
20
+ * `META_MAX_TOTAL_BYTES`. Later duplicate keys in the same batch win
21
+ * (last write).
22
+ */
23
+ export declare function parseMetaFlags(pairs: string[]): Record<string, string>;
24
+ /**
25
+ * Validates a pre-parsed key→value metadata map (e.g. an MCP tool's object
26
+ * argument) against the same rules as {@link parseMetaFlags} — the same
27
+ * per-entry validation (`validateMetaEntry`, which `parseMetaPair` also
28
+ * uses), the same key-count cap, and the same aggregate byte cap — without
29
+ * round-tripping through "k=v" string reconstruction and re-parsing first.
30
+ * Throws `UsageError`.
31
+ */
32
+ export declare function validateMetaMap(meta: Record<string, string>): void;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Client-side validation for `--meta k=v` / `meta set` pairs, mirroring the
3
+ * server rules in `apps/api/src/file-metadata.ts` (`.context/2026-07-13-file-metadata-design.md`).
4
+ * Validating here lets the CLI fail fast with a readable message instead of a
5
+ * round-trip 400 — the server remains the source of truth and re-validates on
6
+ * every write.
7
+ */
8
+ import { UsageError } from "./cli-args.js";
9
+ /** Lowercase key, optionally dot-namespaced (e.g. `gh.repo`). Mirrors META_KEY_RE server-side. */
10
+ export const META_KEY_RE = /^[a-z][a-z0-9._-]{0,63}$/;
11
+ /** Max value length in characters (mirrors META_VALUE_MAX server-side). */
12
+ export const META_VALUE_MAX = 512;
13
+ /** Cap on keys per request (mirrors META_MAX_KEYS server-side). */
14
+ export const META_MAX_KEYS = 24;
15
+ /** Cap on total UTF-8 key+value bytes per request (mirrors META_MAX_TOTAL_BYTES server-side). */
16
+ export const META_MAX_TOTAL_BYTES = 8192;
17
+ // Printable ASCII only — same rule as the server's file-metadata.ts.
18
+ const VALUE_SAFE_RE = /^[\x20-\x7E]+$/;
19
+ const encoder = new TextEncoder();
20
+ /**
21
+ * Server-computed/server-reserved keys the API rejects as custom metadata:
22
+ * `content-sha256` (server-computed provenance) and `visibility` (names the
23
+ * R2-backed public/private gate, not a piece of D1 custom metadata — mirrors
24
+ * apps/api/src/file-metadata.ts's RESERVED_META_KEYS). `gh.*` is NOT reserved
25
+ * here: it's system-managed by convention (attach flow), not blocked — the
26
+ * server happily accepts user-supplied `gh.*` extras via `--meta`.
27
+ */
28
+ const RESERVED_META_KEYS = new Set(["content-sha256", "visibility"]);
29
+ /** Throws `UsageError` with a readable message if `key`/`value` violate the metadata rules. */
30
+ export function validateMetaEntry(key, value) {
31
+ if (!META_KEY_RE.test(key)) {
32
+ throw new UsageError(`invalid metadata key: "${key}" (must match ^[a-z][a-z0-9._-]{0,63}$)`);
33
+ }
34
+ if (RESERVED_META_KEYS.has(key)) {
35
+ throw new UsageError(`invalid metadata key: "${key}" is reserved (server-computed)`);
36
+ }
37
+ if (value.length < 1 || value.length > META_VALUE_MAX || !VALUE_SAFE_RE.test(value)) {
38
+ throw new UsageError(`invalid metadata value for key "${key}": must be 1-${META_VALUE_MAX} printable ASCII characters`);
39
+ }
40
+ }
41
+ /**
42
+ * Split `k=v` on the FIRST "=" (so values may themselves contain "="), then
43
+ * validate the pair. Throws `UsageError` on malformed input.
44
+ */
45
+ export function parseMetaPair(raw) {
46
+ const eq = raw.indexOf("=");
47
+ if (eq === -1) {
48
+ throw new UsageError(`invalid --meta value: "${raw}" (expected key=value)`);
49
+ }
50
+ const key = raw.slice(0, eq);
51
+ const value = raw.slice(eq + 1);
52
+ validateMetaEntry(key, value);
53
+ return [key, value];
54
+ }
55
+ /** Throws `UsageError` if the aggregate UTF-8 key+value bytes of `entries` exceed `META_MAX_TOTAL_BYTES`. */
56
+ function enforceMetaByteCap(entries) {
57
+ let totalBytes = 0;
58
+ for (const [key, value] of entries) {
59
+ totalBytes += encoder.encode(key).byteLength + encoder.encode(value).byteLength;
60
+ }
61
+ if (totalBytes > META_MAX_TOTAL_BYTES) {
62
+ throw new UsageError(`metadata too large: ${totalBytes} bytes of keys+values exceeds the ${META_MAX_TOTAL_BYTES}-byte limit per request`);
63
+ }
64
+ }
65
+ /**
66
+ * Parse and validate a batch of `k=v` pairs (e.g. every `--meta` occurrence,
67
+ * or `meta set`'s positional pairs) into a map. Fails fast on the first
68
+ * invalid pair or when the batch exceeds `META_MAX_KEYS` or
69
+ * `META_MAX_TOTAL_BYTES`. Later duplicate keys in the same batch win
70
+ * (last write).
71
+ */
72
+ export function parseMetaFlags(pairs) {
73
+ if (pairs.length > META_MAX_KEYS) {
74
+ throw new UsageError(`too many --meta pairs: at most ${META_MAX_KEYS} per request`);
75
+ }
76
+ const result = {};
77
+ for (const pair of pairs) {
78
+ const [key, value] = parseMetaPair(pair);
79
+ result[key] = value;
80
+ }
81
+ // Aggregate byte cap over the deduplicated map — same accounting as the
82
+ // server (sum of UTF-8 key+value bytes, META_MAX_TOTAL_BYTES).
83
+ enforceMetaByteCap(Object.entries(result));
84
+ return result;
85
+ }
86
+ /**
87
+ * Validates a pre-parsed key→value metadata map (e.g. an MCP tool's object
88
+ * argument) against the same rules as {@link parseMetaFlags} — the same
89
+ * per-entry validation (`validateMetaEntry`, which `parseMetaPair` also
90
+ * uses), the same key-count cap, and the same aggregate byte cap — without
91
+ * round-tripping through "k=v" string reconstruction and re-parsing first.
92
+ * Throws `UsageError`.
93
+ */
94
+ export function validateMetaMap(meta) {
95
+ const entries = Object.entries(meta);
96
+ if (entries.length > META_MAX_KEYS) {
97
+ throw new UsageError(`too many --meta pairs: at most ${META_MAX_KEYS} per request`);
98
+ }
99
+ for (const [key, value] of entries)
100
+ validateMetaEntry(key, value);
101
+ enforceMetaByteCap(entries);
102
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Client-side dual-host helpers (published package; no @uploads/storage dep).
3
+ * Keep behavior aligned with packages/storage.
4
+ *
5
+ * `UPLOADS_EMBED_PUBLIC_BASE_URL`: unset = default twin; empty = disable; URL = override.
6
+ */
7
+ export declare const DEFAULT_EMBED_PUBLIC_BASE_URL = "https://embed.uploads.sh";
8
+ export type ClientEmbedUrlOptions = {
9
+ embedBaseUrl?: string | null;
10
+ publicBaseUrl?: string | null;
11
+ };
12
+ export declare function embedBaseUrlFromEnv(env?: NodeJS.ProcessEnv): string | null | undefined;
13
+ export declare function resolveEmbedBaseUrl(publicBaseUrl?: string | null, embedBaseUrl?: string | null): string | null;
14
+ export declare function embedUrlFromPublic(publicObjectUrl: string | null | undefined, opts?: ClientEmbedUrlOptions): string | null;
15
+ /** Prefer API `embedUrl`; otherwise derive (incl. env override). */
16
+ export declare function resolveEmbedUrl(publicObjectUrl: string | null | undefined, apiEmbedUrl?: string | null, opts?: ClientEmbedUrlOptions): string | null;
17
+ /** Prefer embed host for GitHub markdown; fall back to stable public URL. */
18
+ export declare function urlForGithubEmbed(publicObjectUrl: string | null | undefined, apiEmbedUrl?: string | null, opts?: ClientEmbedUrlOptions): string | null;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Client-side dual-host helpers (published package; no @uploads/storage dep).
3
+ * Keep behavior aligned with packages/storage.
4
+ *
5
+ * `UPLOADS_EMBED_PUBLIC_BASE_URL`: unset = default twin; empty = disable; URL = override.
6
+ */
7
+ export const DEFAULT_EMBED_PUBLIC_BASE_URL = "https://embed.uploads.sh";
8
+ const DEFAULT_EMBEDDABLE_HOSTS = new Set(["storage.uploads.sh", "store.uploads.sh"]);
9
+ export function embedBaseUrlFromEnv(env = process.env) {
10
+ if (!Object.prototype.hasOwnProperty.call(env, "UPLOADS_EMBED_PUBLIC_BASE_URL")) {
11
+ return undefined;
12
+ }
13
+ return env.UPLOADS_EMBED_PUBLIC_BASE_URL ?? "";
14
+ }
15
+ export function resolveEmbedBaseUrl(publicBaseUrl, embedBaseUrl) {
16
+ if (embedBaseUrl != null) {
17
+ const trimmed = embedBaseUrl.trim();
18
+ return trimmed ? trimmed.replace(/\/$/, "") : null;
19
+ }
20
+ if (!publicBaseUrl)
21
+ return null;
22
+ try {
23
+ const host = new URL(publicBaseUrl).hostname.toLowerCase();
24
+ if (DEFAULT_EMBEDDABLE_HOSTS.has(host))
25
+ return DEFAULT_EMBED_PUBLIC_BASE_URL;
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ return null;
31
+ }
32
+ export function embedUrlFromPublic(publicObjectUrl, opts = {}) {
33
+ if (!publicObjectUrl)
34
+ return null;
35
+ let publicBaseUrl = opts.publicBaseUrl ?? null;
36
+ if (!publicBaseUrl) {
37
+ try {
38
+ const u = new URL(publicObjectUrl);
39
+ if (DEFAULT_EMBEDDABLE_HOSTS.has(u.hostname.toLowerCase())) {
40
+ publicBaseUrl = `${u.protocol}//${u.host}`;
41
+ }
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ const embedBase = resolveEmbedBaseUrl(publicBaseUrl, opts.embedBaseUrl);
48
+ if (!embedBase || !publicBaseUrl)
49
+ return null;
50
+ const stableBase = publicBaseUrl.replace(/\/$/, "");
51
+ if (publicObjectUrl === stableBase || publicObjectUrl.startsWith(`${stableBase}/`)) {
52
+ return `${embedBase}${publicObjectUrl.slice(stableBase.length)}`;
53
+ }
54
+ return null;
55
+ }
56
+ /** Prefer API `embedUrl`; otherwise derive (incl. env override). */
57
+ export function resolveEmbedUrl(publicObjectUrl, apiEmbedUrl, opts = {}) {
58
+ if (apiEmbedUrl)
59
+ return apiEmbedUrl;
60
+ const embedBaseUrl = opts.embedBaseUrl !== undefined ? opts.embedBaseUrl : embedBaseUrlFromEnv();
61
+ return embedUrlFromPublic(publicObjectUrl, { ...opts, embedBaseUrl });
62
+ }
63
+ /** Prefer embed host for GitHub markdown; fall back to stable public URL. */
64
+ export function urlForGithubEmbed(publicObjectUrl, apiEmbedUrl, opts = {}) {
65
+ if (!publicObjectUrl)
66
+ return null;
67
+ return resolveEmbedUrl(publicObjectUrl, apiEmbedUrl, opts) ?? publicObjectUrl;
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
5
  "type": "module",
6
6
  "sideEffects": false,