@buildinternet/uploads 0.19.0 → 0.21.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/dist/capture-facts.d.ts +19 -0
- package/dist/capture-facts.js +49 -0
- package/dist/cli-catalog.js +4 -0
- package/dist/cli-help.js +4 -4
- package/dist/client.d.ts +23 -2
- package/dist/client.js +12 -4
- package/dist/commands/screenshot.js +15 -7
- package/dist/commands.d.ts +47 -2
- package/dist/commands.js +153 -33
- package/dist/errors.d.ts +10 -2
- package/dist/errors.js +8 -1
- package/dist/image-facts.d.ts +12 -0
- package/dist/image-facts.js +108 -0
- package/dist/mcp/args.d.ts +24 -1
- package/dist/mcp/args.js +61 -1
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/mcp/tools.js +42 -15
- package/dist/metadata-vocab.d.ts +24 -0
- package/dist/metadata-vocab.js +127 -0
- package/dist/metadata.d.ts +7 -0
- package/dist/metadata.js +13 -0
- package/package.json +2 -1
package/dist/mcp/args.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Workers as well as Node.
|
|
5
5
|
*/
|
|
6
6
|
import { UploadsError } from "../errors.js";
|
|
7
|
+
import { META_STATE_VALUES, validateStateValue } from "../metadata-vocab.js";
|
|
7
8
|
export function usage(msg) {
|
|
8
9
|
throw new UploadsError(msg, "USAGE");
|
|
9
10
|
}
|
|
@@ -15,6 +16,15 @@ export function optString(args, name) {
|
|
|
15
16
|
usage(`${name} must be a string`);
|
|
16
17
|
return v;
|
|
17
18
|
}
|
|
19
|
+
/** A boolean flag argument; missing/null reads as `false`. */
|
|
20
|
+
export function optBool(args, name) {
|
|
21
|
+
const v = args[name];
|
|
22
|
+
if (v === undefined || v === null)
|
|
23
|
+
return false;
|
|
24
|
+
if (typeof v !== "boolean")
|
|
25
|
+
usage(`${name} must be a boolean`);
|
|
26
|
+
return v;
|
|
27
|
+
}
|
|
18
28
|
export function optPosInt(args, name) {
|
|
19
29
|
const v = args[name];
|
|
20
30
|
if (v === undefined || v === null)
|
|
@@ -60,9 +70,59 @@ export function optStringArray(args, name) {
|
|
|
60
70
|
* `filters` params across the CLI/local MCP (put/attach/set_metadata/
|
|
61
71
|
* find_files) and the remote MCP worker (set_metadata/find_files).
|
|
62
72
|
*/
|
|
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.
|
|
73
|
+
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).";
|
|
64
74
|
export const metadataProp = {
|
|
65
75
|
type: "object",
|
|
66
76
|
additionalProperties: { type: "string" },
|
|
67
77
|
description: METADATA_DESCRIPTION,
|
|
68
78
|
};
|
|
79
|
+
export const stateProp = {
|
|
80
|
+
type: "string",
|
|
81
|
+
enum: [...META_STATE_VALUES],
|
|
82
|
+
description: "The UI state this image shows. Set it whenever the image is one side of a comparison — before/after is the most useful pair in a PR, and is what makes `find_files` with state=after work later.",
|
|
83
|
+
};
|
|
84
|
+
export const appProp = {
|
|
85
|
+
type: "string",
|
|
86
|
+
description: "Which surface is shown: web, ios, android, cli. Worth setting only when the same route exists on more than one surface.",
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Canonical `state`/`app` pairs from their dedicated tool params. The schema
|
|
90
|
+
* enum already constrains `state` for well-behaved clients; re-validate here
|
|
91
|
+
* because a schema is a hint, not an enforcement boundary.
|
|
92
|
+
*/
|
|
93
|
+
export function canonicalMetaFromArgs(args) {
|
|
94
|
+
const meta = {};
|
|
95
|
+
const state = optString(args, "state");
|
|
96
|
+
if (state !== undefined) {
|
|
97
|
+
// Delegate rather than re-checking the enum here, so MCP callers get the
|
|
98
|
+
// same near-miss suggestions ("post" → "after") the CLI gives. Only the
|
|
99
|
+
// error channel differs.
|
|
100
|
+
try {
|
|
101
|
+
meta.state = validateStateValue(state);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
usage(err instanceof Error
|
|
105
|
+
? err.message.replace(/^invalid --state: /, "invalid state: ")
|
|
106
|
+
: String(err));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const app = optString(args, "app");
|
|
110
|
+
if (app !== undefined) {
|
|
111
|
+
const normalized = app.trim().toLowerCase();
|
|
112
|
+
if (normalized.length > 0)
|
|
113
|
+
meta.app = normalized;
|
|
114
|
+
}
|
|
115
|
+
return meta;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The `metadata` tool arg merged with the canonical `state`/`app` params.
|
|
119
|
+
* `undefined` (leave stored metadata untouched) is preserved only when the
|
|
120
|
+
* caller supplied none of the three.
|
|
121
|
+
*/
|
|
122
|
+
export function metadataArgWithCanonical(args) {
|
|
123
|
+
const canonical = canonicalMetaFromArgs(args);
|
|
124
|
+
const metadataArg = optStringRecord(args, "metadata");
|
|
125
|
+
return metadataArg === undefined && Object.keys(canonical).length === 0
|
|
126
|
+
? undefined
|
|
127
|
+
: { ...metadataArg, ...canonical };
|
|
128
|
+
}
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { METADATA_DESCRIPTION, metadataProp, optPosInt, optString, optStringArray, optStringRecord, usage, type ToolArgs, } from "./args.js";
|
|
1
|
+
export { appProp, canonicalMetaFromArgs, METADATA_DESCRIPTION, metadataArgWithCanonical, metadataProp, stateProp, optBool, optPosInt, optString, optStringArray, optStringRecord, usage, type ToolArgs, } from "./args.js";
|
|
2
2
|
export { ToolBatchError, batchFailureMessage } from "./batch-error.js";
|
|
3
3
|
export { mapBounded } from "../async.js";
|
|
4
4
|
export interface McpTool {
|
package/dist/mcp/server.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { UploadsError } from "../errors.js";
|
|
11
11
|
import { errorCodeFromUnknown, recordEvent } from "../telemetry.js";
|
|
12
12
|
import { ToolBatchError } from "./batch-error.js";
|
|
13
|
-
export { METADATA_DESCRIPTION, metadataProp, optPosInt, optString, optStringArray, optStringRecord, usage, } from "./args.js";
|
|
13
|
+
export { appProp, canonicalMetaFromArgs, METADATA_DESCRIPTION, metadataArgWithCanonical, metadataProp, stateProp, optBool, optPosInt, optString, optStringArray, optStringRecord, usage, } from "./args.js";
|
|
14
14
|
export { ToolBatchError, batchFailureMessage } from "./batch-error.js";
|
|
15
15
|
export { mapBounded } from "../async.js";
|
|
16
16
|
const SUPPORTED_PROTOCOL_VERSIONS = new Set(["2025-06-18", "2025-03-26", "2024-11-05"]);
|
package/dist/mcp/tools.js
CHANGED
|
@@ -4,20 +4,14 @@ import { resolveFrameId } from "../frame.js";
|
|
|
4
4
|
import { resolveConfig, resolvePutDefaults, } from "../config.js";
|
|
5
5
|
import { resolvePutPrefix } from "../destinations.js";
|
|
6
6
|
import { ghKeyPrefix } from "../github.js";
|
|
7
|
+
import { safeCaptureFacts } from "../capture-facts.js";
|
|
7
8
|
import { validateMetaMap } from "../metadata.js";
|
|
9
|
+
import { mergeDerivedMeta } from "../metadata-vocab.js";
|
|
8
10
|
import { execRunner, ghMetadataFromTargetWithTitle, resolveCurrentPullRequest, resolveRepo, } from "../github-gh.js";
|
|
9
|
-
import { METADATA_DESCRIPTION, metadataProp, optPosInt, optString, optStringArray, optStringRecord, usage, } from "./args.js";
|
|
11
|
+
import { appProp, canonicalMetaFromArgs, METADATA_DESCRIPTION, metadataArgWithCanonical, metadataProp, optBool, optPosInt, optString, optStringArray, optStringRecord, stateProp, usage, } from "./args.js";
|
|
10
12
|
import { batchFailureMessage, ToolBatchError } from "./server.js";
|
|
11
13
|
import { attachmentFromText, buildReportPayload, parseReportType, REPORT_TYPES, submitReport, validateReportMessage, } from "../report.js";
|
|
12
14
|
import { resolveApiUrl } from "../config.js";
|
|
13
|
-
function optBool(args, name) {
|
|
14
|
-
const v = args[name];
|
|
15
|
-
if (v === undefined || v === null)
|
|
16
|
-
return false;
|
|
17
|
-
if (typeof v !== "boolean")
|
|
18
|
-
usage(`${name} must be a boolean`);
|
|
19
|
-
return v;
|
|
20
|
-
}
|
|
21
15
|
function mcpOptimizeOptions(args, defaults) {
|
|
22
16
|
const quality = optPosInt(args, "optimizeQuality");
|
|
23
17
|
if (quality !== undefined && quality > 100)
|
|
@@ -338,9 +332,15 @@ export function createUploadsMcpTools(opts) {
|
|
|
338
332
|
},
|
|
339
333
|
dryRun: {
|
|
340
334
|
type: "boolean",
|
|
341
|
-
description: "Resolve key + public URL without uploading. Not with comment.",
|
|
335
|
+
description: "Resolve key + public URL without uploading (also previews a strict-key refusal via wouldRefuse). Not with comment.",
|
|
336
|
+
},
|
|
337
|
+
replace: {
|
|
338
|
+
type: "boolean",
|
|
339
|
+
description: "Allow overwriting an existing object on a strict (non-gh/) key: explicit key, or the default put path. Default false — an existing object there is refused (key_exists) unless this is true or UPLOADS_OVERWRITE=1 is set in the server's environment. No effect on pr/issue keys, which always overwrite.",
|
|
342
340
|
},
|
|
343
341
|
metadata: metadataProp,
|
|
342
|
+
state: stateProp,
|
|
343
|
+
app: appProp,
|
|
344
344
|
workspace: workspaceProp,
|
|
345
345
|
},
|
|
346
346
|
additionalProperties: false,
|
|
@@ -372,6 +372,11 @@ export function createUploadsMcpTools(opts) {
|
|
|
372
372
|
const destArg = optString(args, "destination");
|
|
373
373
|
const prefixArg = optString(args, "prefix");
|
|
374
374
|
const refArg = optString(args, "ref");
|
|
375
|
+
// Strict-overwrite gate (issue #174): defaults false; a strict-path
|
|
376
|
+
// put (explicit key or the default path) refuses an existing object
|
|
377
|
+
// unless this is true or UPLOADS_OVERWRITE=1 is set for this process.
|
|
378
|
+
// No effect on pr/issue keys — the server always overwrites those.
|
|
379
|
+
const replaceArg = optBool(args, "replace") ?? process.env.UPLOADS_OVERWRITE === "1";
|
|
375
380
|
if (wantComment && !target)
|
|
376
381
|
usage("comment requires pr or issue");
|
|
377
382
|
if (dryRun && wantComment)
|
|
@@ -387,7 +392,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
387
392
|
// Validate up front (fail fast, before reading/optimizing the file).
|
|
388
393
|
// undefined leaves existing metadata untouched; an object (even {})
|
|
389
394
|
// fully replaces it — see metadataProp's description.
|
|
390
|
-
const metadata =
|
|
395
|
+
const metadata = metadataArgWithCanonical(args);
|
|
391
396
|
if (metadata)
|
|
392
397
|
validateMetaMap(metadata);
|
|
393
398
|
let resolvedPrefix;
|
|
@@ -419,9 +424,13 @@ export function createUploadsMcpTools(opts) {
|
|
|
419
424
|
deriveRepoFromGit: !noGit,
|
|
420
425
|
contentType,
|
|
421
426
|
dryRun,
|
|
427
|
+
replace: replaceArg,
|
|
422
428
|
optimize: optimizeOpts,
|
|
423
429
|
frame: frameOpts,
|
|
424
430
|
metadata,
|
|
431
|
+
// The shared metadata description promises uploads.sh derives these
|
|
432
|
+
// "automatically where it can" — MCP has no --no-auto, so always on.
|
|
433
|
+
deriveImageFacts: true,
|
|
425
434
|
provenanceClient: "uploads-mcp",
|
|
426
435
|
alt,
|
|
427
436
|
width,
|
|
@@ -456,6 +465,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
456
465
|
contentType,
|
|
457
466
|
deriveRepoFromGit: !noGit,
|
|
458
467
|
dryRun,
|
|
468
|
+
replace: replaceArg,
|
|
459
469
|
metadata,
|
|
460
470
|
provenanceClient: "uploads-mcp",
|
|
461
471
|
alt: () => alt ?? sourceName,
|
|
@@ -498,6 +508,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
498
508
|
size: u.size,
|
|
499
509
|
contentType: u.contentType,
|
|
500
510
|
replaced: u.replaced,
|
|
511
|
+
wouldRefuse: u.wouldRefuse,
|
|
501
512
|
markdown: u.markdown,
|
|
502
513
|
optimize: u.optimize,
|
|
503
514
|
frame: u.frame,
|
|
@@ -613,6 +624,8 @@ export function createUploadsMcpTools(opts) {
|
|
|
613
624
|
description: "Capture + resolve key/URL without uploading. Not with comment or galleryId.",
|
|
614
625
|
},
|
|
615
626
|
metadata: metadataProp,
|
|
627
|
+
state: stateProp,
|
|
628
|
+
app: appProp,
|
|
616
629
|
workspace: workspaceProp,
|
|
617
630
|
},
|
|
618
631
|
required: ["target"],
|
|
@@ -652,7 +665,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
652
665
|
if (prefixArg)
|
|
653
666
|
usage("prefix cannot be combined with pr/issue");
|
|
654
667
|
}
|
|
655
|
-
const metadata =
|
|
668
|
+
const metadata = metadataArgWithCanonical(args);
|
|
656
669
|
if (metadata)
|
|
657
670
|
validateMetaMap(metadata);
|
|
658
671
|
let resolvedPrefix;
|
|
@@ -685,6 +698,14 @@ export function createUploadsMcpTools(opts) {
|
|
|
685
698
|
catch (err) {
|
|
686
699
|
usage(`screenshot capture is unavailable in this runtime; try via: "remote" instead (${err instanceof Error ? err.message : String(err)})`);
|
|
687
700
|
}
|
|
701
|
+
const viewport = screenshotModule.parseViewport(optString(args, "viewport"));
|
|
702
|
+
// Same derivation the CLI does — explicit args win over capture facts.
|
|
703
|
+
// Keep undefined when nothing at all was supplied or derived, so the
|
|
704
|
+
// "omit to leave stored metadata untouched" contract still holds.
|
|
705
|
+
const captureDerived = safeCaptureFacts(targetArg, viewport, colorSchemeArg);
|
|
706
|
+
const metadataWithCaptureFacts = metadata === undefined && Object.keys(captureDerived).length === 0
|
|
707
|
+
? undefined
|
|
708
|
+
: mergeDerivedMeta(metadata ?? {}, captureDerived);
|
|
688
709
|
let captured;
|
|
689
710
|
try {
|
|
690
711
|
captured = await screenshotModule.captureScreenshot({
|
|
@@ -692,7 +713,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
692
713
|
via: viaArg,
|
|
693
714
|
browserPath: optString(args, "browser"),
|
|
694
715
|
cdp: optString(args, "cdp"),
|
|
695
|
-
viewport
|
|
716
|
+
viewport,
|
|
696
717
|
selector: optString(args, "selector"),
|
|
697
718
|
fullPage: optBool(args, "fullPage"),
|
|
698
719
|
colorScheme: colorSchemeArg,
|
|
@@ -723,7 +744,8 @@ export function createUploadsMcpTools(opts) {
|
|
|
723
744
|
ref: refArg ?? defaults.ref,
|
|
724
745
|
deriveRepoFromGit: !noGit,
|
|
725
746
|
dryRun,
|
|
726
|
-
metadata,
|
|
747
|
+
metadata: metadataWithCaptureFacts,
|
|
748
|
+
deriveImageFacts: true,
|
|
727
749
|
provenanceClient: "uploads-mcp-screenshot",
|
|
728
750
|
alt: (p) => alt ?? p.filename,
|
|
729
751
|
width,
|
|
@@ -809,6 +831,8 @@ export function createUploadsMcpTools(opts) {
|
|
|
809
831
|
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. " +
|
|
810
832
|
METADATA_DESCRIPTION,
|
|
811
833
|
},
|
|
834
|
+
state: stateProp,
|
|
835
|
+
app: appProp,
|
|
812
836
|
workspace: workspaceProp,
|
|
813
837
|
},
|
|
814
838
|
required: ["files"],
|
|
@@ -832,7 +856,10 @@ export function createUploadsMcpTools(opts) {
|
|
|
832
856
|
// just the extras) so the 24-key/8KB caps are enforced client-side —
|
|
833
857
|
// extras alone might pass while extras + the gh.* pairs exceed the
|
|
834
858
|
// cap, which would otherwise only be caught server-side after upload.
|
|
835
|
-
const metaExtras =
|
|
859
|
+
const metaExtras = {
|
|
860
|
+
...optStringRecord(args, "metadata"),
|
|
861
|
+
...canonicalMetaFromArgs(args),
|
|
862
|
+
};
|
|
836
863
|
const metadata = { ...metaExtras, ...ghMetadataFromTargetWithTitle(target, run) };
|
|
837
864
|
if (Object.keys(metadata).length > 0)
|
|
838
865
|
validateMetaMap(metadata);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const CANONICAL_META_KEYS: readonly ["url", "path", "env", "theme", "viewport", "device", "software", "captured", "state", "app"];
|
|
2
|
+
export type CanonicalMetaKey = (typeof CANONICAL_META_KEYS)[number];
|
|
3
|
+
/** Closed enum for `state` — the highest-value hand-supplied search facet. */
|
|
4
|
+
export declare const META_STATE_VALUES: readonly ["before", "after", "empty", "error", "loading"];
|
|
5
|
+
export type MetaStateValue = (typeof META_STATE_VALUES)[number];
|
|
6
|
+
/**
|
|
7
|
+
* Validate a `--state` value. Fails fast with a suggestion when the value is a
|
|
8
|
+
* recognized near-miss, otherwise lists the valid set.
|
|
9
|
+
*/
|
|
10
|
+
export declare function validateStateValue(raw: string): MetaStateValue;
|
|
11
|
+
/**
|
|
12
|
+
* Warning lines for supplied keys that look like misspellings of canonical
|
|
13
|
+
* ones. Callers warn and continue — we never silently rewrite a caller's key,
|
|
14
|
+
* because a wrong guess is worse than a nag.
|
|
15
|
+
*/
|
|
16
|
+
export declare function nearMissMetaWarnings(keys: string[]): string[];
|
|
17
|
+
/** Canonical value format for the `viewport` key, e.g. `1280x800@2x`. */
|
|
18
|
+
export declare function formatViewport(width: number, height: number, scale: number): string;
|
|
19
|
+
/**
|
|
20
|
+
* Merge derived pairs under the explicit ones, adding derived keys only while
|
|
21
|
+
* the result still satisfies the metadata caps. Explicit keys always win and
|
|
22
|
+
* are never dropped; a full key budget must never fail an upload.
|
|
23
|
+
*/
|
|
24
|
+
export declare function mergeDerivedMeta(explicit: Record<string, string>, derived: Record<string, string>): Record<string, string>;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical upload metadata vocabulary: a small closed set of keys that
|
|
3
|
+
* `uploads find` can rely on being spelled consistently. Most are derived (see
|
|
4
|
+
* capture-facts.ts / image-facts.ts); only `state` and `app` are typed by hand.
|
|
5
|
+
*
|
|
6
|
+
* Design: .context/2026-07-21-upload-metadata-vocabulary-design.md
|
|
7
|
+
*/
|
|
8
|
+
import { UsageError } from "./cli-args.js";
|
|
9
|
+
import { validateMetaMap } from "./metadata.js";
|
|
10
|
+
export const CANONICAL_META_KEYS = [
|
|
11
|
+
"url",
|
|
12
|
+
"path",
|
|
13
|
+
"env",
|
|
14
|
+
"theme",
|
|
15
|
+
"viewport",
|
|
16
|
+
"device",
|
|
17
|
+
"software",
|
|
18
|
+
"captured",
|
|
19
|
+
"state",
|
|
20
|
+
"app",
|
|
21
|
+
];
|
|
22
|
+
const CANONICAL_KEY_SET = new Set(CANONICAL_META_KEYS);
|
|
23
|
+
/** Closed enum for `state` — the highest-value hand-supplied search facet. */
|
|
24
|
+
export const META_STATE_VALUES = ["before", "after", "empty", "error", "loading"];
|
|
25
|
+
const STATE_VALUE_SET = new Set(META_STATE_VALUES);
|
|
26
|
+
/** Common spellings agents reach for, mapped to the canonical `state` value. */
|
|
27
|
+
const STATE_ALIASES = {
|
|
28
|
+
pre: "before",
|
|
29
|
+
prior: "before",
|
|
30
|
+
old: "before",
|
|
31
|
+
previous: "before",
|
|
32
|
+
post: "after",
|
|
33
|
+
new: "after",
|
|
34
|
+
updated: "after",
|
|
35
|
+
blank: "empty",
|
|
36
|
+
none: "empty",
|
|
37
|
+
failure: "error",
|
|
38
|
+
failed: "error",
|
|
39
|
+
err: "error",
|
|
40
|
+
spinner: "loading",
|
|
41
|
+
pending: "loading",
|
|
42
|
+
busy: "loading",
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Validate a `--state` value. Fails fast with a suggestion when the value is a
|
|
46
|
+
* recognized near-miss, otherwise lists the valid set.
|
|
47
|
+
*/
|
|
48
|
+
export function validateStateValue(raw) {
|
|
49
|
+
const value = raw.trim().toLowerCase();
|
|
50
|
+
if (STATE_VALUE_SET.has(value))
|
|
51
|
+
return value;
|
|
52
|
+
const suggestion = STATE_ALIASES[value];
|
|
53
|
+
if (suggestion) {
|
|
54
|
+
throw new UsageError(`invalid --state: "${raw}" — did you mean "${suggestion}"?`);
|
|
55
|
+
}
|
|
56
|
+
throw new UsageError(`invalid --state: "${raw}" (expected one of: ${META_STATE_VALUES.join(", ")})`);
|
|
57
|
+
}
|
|
58
|
+
/** Common misspellings of canonical keys, mapped to the canonical spelling. */
|
|
59
|
+
const META_KEY_ALIASES = {
|
|
60
|
+
route: "path",
|
|
61
|
+
page: "path",
|
|
62
|
+
screen: "path",
|
|
63
|
+
pathname: "path",
|
|
64
|
+
mode: "theme",
|
|
65
|
+
appearance: "theme",
|
|
66
|
+
colorscheme: "theme",
|
|
67
|
+
environment: "env",
|
|
68
|
+
stage: "env",
|
|
69
|
+
surface: "app",
|
|
70
|
+
platform: "app",
|
|
71
|
+
status: "state",
|
|
72
|
+
variant: "state",
|
|
73
|
+
resolution: "viewport",
|
|
74
|
+
size: "viewport",
|
|
75
|
+
dimensions: "viewport",
|
|
76
|
+
link: "url",
|
|
77
|
+
href: "url",
|
|
78
|
+
source: "url",
|
|
79
|
+
model: "device",
|
|
80
|
+
hardware: "device",
|
|
81
|
+
when: "captured",
|
|
82
|
+
date: "captured",
|
|
83
|
+
timestamp: "captured",
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Warning lines for supplied keys that look like misspellings of canonical
|
|
87
|
+
* ones. Callers warn and continue — we never silently rewrite a caller's key,
|
|
88
|
+
* because a wrong guess is worse than a nag.
|
|
89
|
+
*/
|
|
90
|
+
export function nearMissMetaWarnings(keys) {
|
|
91
|
+
const warnings = [];
|
|
92
|
+
for (const key of keys) {
|
|
93
|
+
if (CANONICAL_KEY_SET.has(key))
|
|
94
|
+
continue;
|
|
95
|
+
const canonical = META_KEY_ALIASES[key];
|
|
96
|
+
if (canonical) {
|
|
97
|
+
warnings.push(`metadata key "${key}" is not canonical — did you mean "${canonical}"?`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return warnings;
|
|
101
|
+
}
|
|
102
|
+
/** Canonical value format for the `viewport` key, e.g. `1280x800@2x`. */
|
|
103
|
+
export function formatViewport(width, height, scale) {
|
|
104
|
+
const trimmed = Number(scale.toFixed(2));
|
|
105
|
+
return `${Math.round(width)}x${Math.round(height)}@${trimmed}x`;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Merge derived pairs under the explicit ones, adding derived keys only while
|
|
109
|
+
* the result still satisfies the metadata caps. Explicit keys always win and
|
|
110
|
+
* are never dropped; a full key budget must never fail an upload.
|
|
111
|
+
*/
|
|
112
|
+
export function mergeDerivedMeta(explicit, derived) {
|
|
113
|
+
const out = { ...explicit };
|
|
114
|
+
for (const [key, value] of Object.entries(derived)) {
|
|
115
|
+
if (key in out)
|
|
116
|
+
continue;
|
|
117
|
+
const candidate = { ...out, [key]: value };
|
|
118
|
+
try {
|
|
119
|
+
validateMetaMap(candidate);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
continue; // derived key does not fit — drop it, keep going
|
|
123
|
+
}
|
|
124
|
+
out[key] = value;
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
package/dist/metadata.d.ts
CHANGED
|
@@ -16,6 +16,13 @@ export declare function validateMetaEntry(key: string, value: string): void;
|
|
|
16
16
|
* unicode (emoji, curly quotes) that the metadata value rule disallows.
|
|
17
17
|
*/
|
|
18
18
|
export declare function isMetaValueSafe(value: string): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Drop, in place, any entry whose value breaks the metadata contract. For
|
|
21
|
+
* *derived* pairs only: a fact we inferred must never fail an upload, so an
|
|
22
|
+
* over-long URL or non-ASCII EXIF string is silently discarded rather than
|
|
23
|
+
* surfaced. Never use this on caller-supplied metadata, which should error.
|
|
24
|
+
*/
|
|
25
|
+
export declare function dropUnsafeMetaValues(facts: Record<string, string>): Record<string, string>;
|
|
19
26
|
/**
|
|
20
27
|
* Split `k=v` on the FIRST "=" (so values may themselves contain "="), then
|
|
21
28
|
* validate the pair. Throws `UsageError` on malformed input.
|
package/dist/metadata.js
CHANGED
|
@@ -48,6 +48,19 @@ export function validateMetaEntry(key, value) {
|
|
|
48
48
|
export function isMetaValueSafe(value) {
|
|
49
49
|
return value.length >= 1 && value.length <= META_VALUE_MAX && VALUE_SAFE_RE.test(value);
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Drop, in place, any entry whose value breaks the metadata contract. For
|
|
53
|
+
* *derived* pairs only: a fact we inferred must never fail an upload, so an
|
|
54
|
+
* over-long URL or non-ASCII EXIF string is silently discarded rather than
|
|
55
|
+
* surfaced. Never use this on caller-supplied metadata, which should error.
|
|
56
|
+
*/
|
|
57
|
+
export function dropUnsafeMetaValues(facts) {
|
|
58
|
+
for (const [key, value] of Object.entries(facts)) {
|
|
59
|
+
if (!isMetaValueSafe(value))
|
|
60
|
+
delete facts[key];
|
|
61
|
+
}
|
|
62
|
+
return facts;
|
|
63
|
+
}
|
|
51
64
|
/**
|
|
52
65
|
* Split `k=v` on the FIRST "=" (so values may themselves contain "="), then
|
|
53
66
|
* validate the pair. Throws `UsageError` on malformed input.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@buildinternet/uploads",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.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,
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"provenance": true
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
|
+
"exif-reader": "^2.0.3",
|
|
59
60
|
"sharp": "^0.35.3"
|
|
60
61
|
},
|
|
61
62
|
"optionalDependencies": {
|