Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/canonical-upload-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@buildinternet/uploads": minor
---

Add a canonical metadata vocabulary for uploads. `screenshot` now derives
`url`, `path`, `env`, `theme` and `viewport` from the capture, and `put`
promotes an allowlist of image EXIF (`viewport`, `device`, `software`,
`captured`) into queryable metadata before stripping it from the bytes. New
`--state` and `--app` flags, and matching MCP params, cover what the CLI
cannot derive. `uploads find path=/settings state=after` is the payoff.

The MCP `metadata` description previously suggested `page` and `resolution`;
it now names the canonical keys and points at `path` as the one to search by.

Two behavior changes worth reading before upgrading:

- `device` and `software` come from EXIF that was previously discarded, and
promoted metadata renders on the public file page. GPS coordinates, serial
numbers and personal-name tags are never promoted.
- Metadata sent on a put fully replaces that key's stored set. Because derived
keys count as metadata, a re-upload that derives anything now replaces the
set where it previously left it untouched. Pass `--no-auto` when re-uploading
a key whose metadata you curated with `uploads meta set`.

Opt out of the whole derived tier with `--no-auto` or `UPLOADS_NO_AUTO_META=1`.
1 change: 1 addition & 0 deletions packages/uploads/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"provenance": true
},
"dependencies": {
"exif-reader": "^2.0.3",
"sharp": "^0.35.3"
},
"optionalDependencies": {
Expand Down
66 changes: 66 additions & 0 deletions packages/uploads/src/capture-facts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Canonical metadata derived from a screenshot capture, where the CLI knows
* the inputs exactly. Pure: no I/O, no throwing — an unparseable URL simply
* yields fewer keys.
*
* Design: .context/2026-07-21-upload-metadata-vocabulary-design.md
*/
import { dropUnsafeMetaValues } from "./metadata.js";
import { formatViewport } from "./metadata-vocab.js";
import { classifyTarget, type ScreenshotTarget, type ScreenshotViewport } from "./screenshot.js";

export interface CaptureFactsInput {
target: ScreenshotTarget;
viewport: ScreenshotViewport;
/** Only set when the caller forced a scheme (`--dark` / `--light`). */
colorScheme?: "dark" | "light";
}

/**
* Derive `url`/`path`/`env`/`theme`/`viewport` from a capture. `env` is only
* ever `local`: inferring `prod` from "not localhost" would mislabel every
* staging and preview URL, and wrong metadata is worse than absent metadata.
*/
export function captureFacts(input: CaptureFactsInput): Record<string, string> {
const facts: Record<string, string> = {};

facts.viewport = formatViewport(
input.viewport.width,
input.viewport.height,
input.viewport.deviceScaleFactor,
);

if (input.colorScheme) facts.theme = input.colorScheme;

if (input.target.kind === "url") {
facts.url = input.target.url;
try {
facts.path = new URL(input.target.url).pathname || "/";
} catch {
// classifyTarget already validated this, but never let a URL parse
// failure cost us the other facts.
}
if (input.target.localOnly) facts.env = "local";
}

// A long query string can exceed the 512-char value cap; drop rather than
// let a derived value fail the upload.
return dropUnsafeMetaValues(facts);
}

/**
* `captureFacts` for a raw target string, never at the cost of the capture
* itself: an unclassifiable target yields no facts rather than an error.
* Shared by the CLI and MCP screenshot paths.
*/
export function safeCaptureFacts(
target: string,
viewport: ScreenshotViewport,
colorScheme: "dark" | "light" | undefined,
): Record<string, string> {
try {
return captureFacts({ target: classifyTarget(target), viewport, colorScheme });
} catch {
return {}; // derived metadata must never fail a capture
}
}
4 changes: 4 additions & 0 deletions packages/uploads/src/cli-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export const PUT_LIKE_FLAGS: readonly string[] = [
"--frame-url",
"--gallery",
"--meta",
"--state",
"--app",
"--workspace",
"-w",
"--help",
Expand Down Expand Up @@ -94,6 +96,8 @@ export const SCREENSHOT_FLAGS: readonly string[] = [
"--comment",
"--gallery",
"--meta",
"--state",
"--app",
"--dry-run",
"--format",
"--workspace",
Expand Down
8 changes: 4 additions & 4 deletions packages/uploads/src/cli-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,10 @@ ${section(style, "Examples:")}
${style.command("uploads put")} ./shot.png --pr 123 --name hero.png
${style.command("uploads put")} ./after.png --pr 123 --comment
${style.command("uploads put")} ./bug.png --issue 45
${style.command("uploads put")} ./shot.png --meta app=myapp --meta page=settings
${style.command("uploads put")} ./shot.png --meta path=/settings --state after
${style.command("uploads attach")} ./before.png ./after.png
${style.command("uploads attach")} ./shot.png --pr 123 --repo myorg/myapp
${style.command("uploads attach")} ./shot.png --meta app=myapp --meta page=settings
${style.command("uploads attach")} ./shot.png --meta path=/settings --state after
${style.command("uploads doctor")}
${style.command("uploads install")}
${style.command("uploads logout")}
Expand Down Expand Up @@ -181,11 +181,11 @@ ${section(style, "Examples:")}
${style.command("uploads put")} ./after.png --pr 123 --comment
${style.command("uploads put")} ./bug.png --issue 45 --repo myorg/myapp
${style.command("uploads put")} ./shot.png --dry-run --format url
${style.command("uploads put")} ./shot.png --meta app=myapp --meta page=settings
${style.command("uploads put")} ./shot.png --meta path=/settings --state after
${style.command("uploads attach")} ./before.png ./after.png
${style.command("uploads attach")} ./shot.png --pr 123 --repo myorg/myapp
${style.command("uploads attach")} ./artifact.zip --issue 45 --no-comment
${style.command("uploads attach")} ./shot.png --meta app=myapp --meta page=settings
${style.command("uploads attach")} ./shot.png --meta path=/settings --state after
${style.command("uploads gallery")} create --title "Release screenshots"
${style.command("uploads doctor")}
${style.command("uploads logout")}
Expand Down
Loading
Loading