@buildinternet/uploads 0.6.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.
- package/README.md +14 -3
- package/dist/cli-args.d.ts +17 -2
- package/dist/cli-args.js +50 -5
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +68 -25
- package/dist/client.d.ts +137 -0
- package/dist/client.js +178 -3
- package/dist/commands/install.js +110 -52
- package/dist/commands/invite.d.ts +10 -0
- package/dist/commands/invite.js +102 -0
- package/dist/commands/login.d.ts +29 -1
- package/dist/commands/login.js +172 -12
- package/dist/commands/setup.js +4 -2
- package/dist/commands.d.ts +4 -0
- package/dist/commands.js +211 -17
- package/dist/config.js +6 -3
- package/dist/errors.d.ts +1 -1
- package/dist/github.d.ts +17 -1
- package/dist/github.js +32 -7
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/mcp/args.d.ts +17 -0
- package/dist/mcp/args.js +42 -0
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/mcp/tools.js +121 -21
- package/dist/metadata.d.ts +32 -0
- package/dist/metadata.js +102 -0
- package/dist/public-urls.d.ts +18 -0
- package/dist/public-urls.js +68 -0
- package/package.json +1 -1
package/dist/mcp/args.js
CHANGED
|
@@ -24,3 +24,45 @@ export function optPosInt(args, name) {
|
|
|
24
24
|
}
|
|
25
25
|
return v;
|
|
26
26
|
}
|
|
27
|
+
/** A JSON-object argument of string→string pairs (e.g. a `metadata` or `filters` param). */
|
|
28
|
+
export function optStringRecord(args, name) {
|
|
29
|
+
const v = args[name];
|
|
30
|
+
if (v === undefined || v === null)
|
|
31
|
+
return undefined;
|
|
32
|
+
if (typeof v !== "object" || Array.isArray(v)) {
|
|
33
|
+
usage(`${name} must be an object of string values`);
|
|
34
|
+
}
|
|
35
|
+
// Object.create(null): a plain `{}` would silently drop a `__proto__` key
|
|
36
|
+
// (it hits the inherited setter instead of becoming an own property),
|
|
37
|
+
// turning a malicious/malformed key into a no-op rather than a rejected
|
|
38
|
+
// input. A null-prototype object makes every key a real own property so
|
|
39
|
+
// downstream validation (e.g. META_KEY_RE) sees and rejects it.
|
|
40
|
+
const result = Object.create(null);
|
|
41
|
+
for (const [key, value] of Object.entries(v)) {
|
|
42
|
+
if (typeof value !== "string")
|
|
43
|
+
usage(`${name}.${key} must be a string`);
|
|
44
|
+
result[key] = value;
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
/** A JSON-array argument of strings (e.g. a `delete` or `files` param). */
|
|
49
|
+
export function optStringArray(args, name) {
|
|
50
|
+
const v = args[name];
|
|
51
|
+
if (v === undefined || v === null)
|
|
52
|
+
return undefined;
|
|
53
|
+
if (!Array.isArray(v) || v.some((item) => typeof item !== "string")) {
|
|
54
|
+
usage(`${name} must be an array of strings`);
|
|
55
|
+
}
|
|
56
|
+
return v;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Shared tool-description text for the metadata-shaped `metadata`/`set`/
|
|
60
|
+
* `filters` params across the CLI/local MCP (put/attach/set_metadata/
|
|
61
|
+
* find_files) and the remote MCP worker (set_metadata/find_files).
|
|
62
|
+
*/
|
|
63
|
+
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. Suggested keys: app, url, page, device, resolution, commit, branch. `gh.*` is reserved by convention for GitHub PR/issue attachment context (repo/kind/number/ref).";
|
|
64
|
+
export const metadataProp = {
|
|
65
|
+
type: "object",
|
|
66
|
+
additionalProperties: { type: "string" },
|
|
67
|
+
description: METADATA_DESCRIPTION,
|
|
68
|
+
};
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -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;
|
package/dist/mcp/server.js
CHANGED
|
@@ -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 (
|
|
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
|
-
?
|
|
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 {
|
|
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
|
|
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(
|
|
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,
|
|
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;
|
package/dist/metadata.js
ADDED
|
@@ -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
|
+
}
|