@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.
@@ -0,0 +1,19 @@
1
+ import { type ScreenshotTarget, type ScreenshotViewport } from "./screenshot.js";
2
+ export interface CaptureFactsInput {
3
+ target: ScreenshotTarget;
4
+ viewport: ScreenshotViewport;
5
+ /** Only set when the caller forced a scheme (`--dark` / `--light`). */
6
+ colorScheme?: "dark" | "light";
7
+ }
8
+ /**
9
+ * Derive `url`/`path`/`env`/`theme`/`viewport` from a capture. `env` is only
10
+ * ever `local`: inferring `prod` from "not localhost" would mislabel every
11
+ * staging and preview URL, and wrong metadata is worse than absent metadata.
12
+ */
13
+ export declare function captureFacts(input: CaptureFactsInput): Record<string, string>;
14
+ /**
15
+ * `captureFacts` for a raw target string, never at the cost of the capture
16
+ * itself: an unclassifiable target yields no facts rather than an error.
17
+ * Shared by the CLI and MCP screenshot paths.
18
+ */
19
+ export declare function safeCaptureFacts(target: string, viewport: ScreenshotViewport, colorScheme: "dark" | "light" | undefined): Record<string, string>;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Canonical metadata derived from a screenshot capture, where the CLI knows
3
+ * the inputs exactly. Pure: no I/O, no throwing — an unparseable URL simply
4
+ * yields fewer keys.
5
+ *
6
+ * Design: .context/2026-07-21-upload-metadata-vocabulary-design.md
7
+ */
8
+ import { dropUnsafeMetaValues } from "./metadata.js";
9
+ import { formatViewport } from "./metadata-vocab.js";
10
+ import { classifyTarget } from "./screenshot.js";
11
+ /**
12
+ * Derive `url`/`path`/`env`/`theme`/`viewport` from a capture. `env` is only
13
+ * ever `local`: inferring `prod` from "not localhost" would mislabel every
14
+ * staging and preview URL, and wrong metadata is worse than absent metadata.
15
+ */
16
+ export function captureFacts(input) {
17
+ const facts = {};
18
+ facts.viewport = formatViewport(input.viewport.width, input.viewport.height, input.viewport.deviceScaleFactor);
19
+ if (input.colorScheme)
20
+ facts.theme = input.colorScheme;
21
+ if (input.target.kind === "url") {
22
+ facts.url = input.target.url;
23
+ try {
24
+ facts.path = new URL(input.target.url).pathname || "/";
25
+ }
26
+ catch {
27
+ // classifyTarget already validated this, but never let a URL parse
28
+ // failure cost us the other facts.
29
+ }
30
+ if (input.target.localOnly)
31
+ facts.env = "local";
32
+ }
33
+ // A long query string can exceed the 512-char value cap; drop rather than
34
+ // let a derived value fail the upload.
35
+ return dropUnsafeMetaValues(facts);
36
+ }
37
+ /**
38
+ * `captureFacts` for a raw target string, never at the cost of the capture
39
+ * itself: an unclassifiable target yields no facts rather than an error.
40
+ * Shared by the CLI and MCP screenshot paths.
41
+ */
42
+ export function safeCaptureFacts(target, viewport, colorScheme) {
43
+ try {
44
+ return captureFacts({ target: classifyTarget(target), viewport, colorScheme });
45
+ }
46
+ catch {
47
+ return {}; // derived metadata must never fail a capture
48
+ }
49
+ }
@@ -36,6 +36,8 @@ export const PUT_LIKE_FLAGS = [
36
36
  "--frame-url",
37
37
  "--gallery",
38
38
  "--meta",
39
+ "--state",
40
+ "--app",
39
41
  "--workspace",
40
42
  "-w",
41
43
  "--help",
@@ -80,6 +82,8 @@ export const SCREENSHOT_FLAGS = [
80
82
  "--comment",
81
83
  "--gallery",
82
84
  "--meta",
85
+ "--state",
86
+ "--app",
83
87
  "--dry-run",
84
88
  "--format",
85
89
  "--workspace",
package/dist/cli-help.js CHANGED
@@ -87,10 +87,10 @@ ${section(style, "Examples:")}
87
87
  ${style.command("uploads put")} ./shot.png --pr 123 --name hero.png
88
88
  ${style.command("uploads put")} ./after.png --pr 123 --comment
89
89
  ${style.command("uploads put")} ./bug.png --issue 45
90
- ${style.command("uploads put")} ./shot.png --meta app=myapp --meta page=settings
90
+ ${style.command("uploads put")} ./shot.png --meta path=/settings --state after
91
91
  ${style.command("uploads attach")} ./before.png ./after.png
92
92
  ${style.command("uploads attach")} ./shot.png --pr 123 --repo myorg/myapp
93
- ${style.command("uploads attach")} ./shot.png --meta app=myapp --meta page=settings
93
+ ${style.command("uploads attach")} ./shot.png --meta path=/settings --state after
94
94
  ${style.command("uploads doctor")}
95
95
  ${style.command("uploads install")}
96
96
  ${style.command("uploads logout")}
@@ -137,11 +137,11 @@ ${section(style, "Examples:")}
137
137
  ${style.command("uploads put")} ./after.png --pr 123 --comment
138
138
  ${style.command("uploads put")} ./bug.png --issue 45 --repo myorg/myapp
139
139
  ${style.command("uploads put")} ./shot.png --dry-run --format url
140
- ${style.command("uploads put")} ./shot.png --meta app=myapp --meta page=settings
140
+ ${style.command("uploads put")} ./shot.png --meta path=/settings --state after
141
141
  ${style.command("uploads attach")} ./before.png ./after.png
142
142
  ${style.command("uploads attach")} ./shot.png --pr 123 --repo myorg/myapp
143
143
  ${style.command("uploads attach")} ./artifact.zip --issue 45 --no-comment
144
- ${style.command("uploads attach")} ./shot.png --meta app=myapp --meta page=settings
144
+ ${style.command("uploads attach")} ./shot.png --meta path=/settings --state after
145
145
  ${style.command("uploads gallery")} create --title "Release screenshots"
146
146
  ${style.command("uploads doctor")}
147
147
  ${style.command("uploads logout")}
package/dist/client.d.ts CHANGED
@@ -28,6 +28,14 @@ export interface PutOptions {
28
28
  metadata?: Record<string, string>;
29
29
  /** Validate key + resolve public URL without writing. `size` is local bytes only. */
30
30
  dryRun?: boolean;
31
+ /**
32
+ * Opt in to overwriting an existing object on a "strict" (non-`gh/`) key —
33
+ * see issue #174. Ignored (always allowed) on managed `gh/` paths
34
+ * (`attach`, `put --pr`/`--issue`), which stay silent hot-swap. Omit/false
35
+ * on a strict path with an existing key throws `UploadsError` with code
36
+ * `KEY_EXISTS`.
37
+ */
38
+ replace?: boolean;
31
39
  }
32
40
  export interface ListOptions {
33
41
  prefix?: string;
@@ -67,6 +75,11 @@ export interface PutResult {
67
75
  * at this key would overwrite. Always set by the API for put/dry-run.
68
76
  */
69
77
  replaced?: boolean;
78
+ /**
79
+ * dryRun only: true when a real put at this key would be refused (strict
80
+ * non-`gh/` key, existing object, no `replace`) instead of overwriting.
81
+ */
82
+ wouldRefuse?: boolean;
70
83
  metadata?: Record<string, string>;
71
84
  }
72
85
  export interface ListItem {
@@ -175,8 +188,7 @@ export interface FindGalleriesByReferenceOptions {
175
188
  /**
176
189
  * Reasons the bot did not post. The CLI falls back to the local `gh` path
177
190
  * for all of these except `not_authorized` (issue #297 baseline control):
178
- * the target repo is bound to a different workspace (or is unbound and the
179
- * caller is the communal `default` workspace, which can't claim new repos).
191
+ * the target repo is bound to a different workspace.
180
192
  * Falling back to `gh` there would let the human's own credentials post
181
193
  * anyway, defeating the point of the server-side gate, so the CLI surfaces
182
194
  * the decline instead.
@@ -219,6 +231,13 @@ export interface GithubLinkResult {
219
231
  /** POST-only: whether THIS call's workspace ended up owning the binding. */
220
232
  export interface GithubLinkClaimResult extends GithubLinkResult {
221
233
  claimed: boolean;
234
+ /**
235
+ * Present (only) when `claimed` is false because the repo is unbound and
236
+ * this workspace couldn't be verified as entitled to claim it — issue
237
+ * #297's cross-tenant authorization gate. Distinct from the "someone else
238
+ * already owns it" case, which instead reports a non-null `workspace`.
239
+ */
240
+ reason?: "not_authorized";
222
241
  }
223
242
  /** `DELETE /v1/:workspace/github/link` result (issue #318, self-serve unlink). */
224
243
  export interface GithubLinkUnlinkResult {
@@ -430,12 +449,14 @@ export declare function extractErrorFields(body: unknown, fallback?: string): {
430
449
  message: string;
431
450
  code?: string;
432
451
  requiredScope?: string;
452
+ existingUrl?: string;
433
453
  };
434
454
  /** Fetch + parse an error-response body via {@link extractErrorFields}. */
435
455
  export declare function parseErrorEnvelope(res: Response, fallback?: string): Promise<{
436
456
  message: string;
437
457
  code?: string;
438
458
  requiredScope?: string;
459
+ existingUrl?: string;
439
460
  }>;
440
461
  export declare function createUploadsClient(config: UploadsClientConfig): {
441
462
  put(body: Uint8Array, opts: PutOptions & {
package/dist/client.js CHANGED
@@ -188,7 +188,7 @@ function usageBase(config) {
188
188
  function galleriesBase(config) {
189
189
  return `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/galleries`;
190
190
  }
191
- function mapApiError(status, error, code, requiredScope) {
191
+ function mapApiError(status, error, code, requiredScope, existingUrl) {
192
192
  const normalized = error.toLowerCase();
193
193
  if (status === 401 || code === "unauthorized" || normalized === "unauthorized") {
194
194
  return new UploadsError(error, "UNAUTHORIZED", status);
@@ -218,6 +218,9 @@ function mapApiError(status, error, code, requiredScope) {
218
218
  if (code === "github_required") {
219
219
  return new UploadsError(error, "GITHUB_REQUIRED", status);
220
220
  }
221
+ if (code === "key_exists") {
222
+ return new UploadsError(error, "KEY_EXISTS", status, { existingUrl });
223
+ }
221
224
  return new UploadsError(error, "API_ERROR", status);
222
225
  }
223
226
  /**
@@ -239,6 +242,7 @@ export function extractErrorFields(body, fallback = "request failed") {
239
242
  ...(typeof details?.required_scope === "string"
240
243
  ? { requiredScope: details.required_scope }
241
244
  : {}),
245
+ ...(typeof details?.url === "string" ? { existingUrl: details.url } : {}),
242
246
  };
243
247
  }
244
248
  if (typeof err === "string") {
@@ -256,8 +260,8 @@ export async function parseErrorEnvelope(res, fallback = "request failed") {
256
260
  return extractErrorFields(body, fallback);
257
261
  }
258
262
  async function parseErrorResponse(res) {
259
- const { message, code, requiredScope } = await parseErrorEnvelope(res, res.statusText || "request failed");
260
- return mapApiError(res.status, message, code, requiredScope);
263
+ const { message, code, requiredScope, existingUrl } = await parseErrorEnvelope(res, res.statusText || "request failed");
264
+ return mapApiError(res.status, message, code, requiredScope, existingUrl);
261
265
  }
262
266
  export function createUploadsClient(config) {
263
267
  async function request(method, path, opts) {
@@ -318,7 +322,8 @@ export function createUploadsClient(config) {
318
322
  }));
319
323
  const contentType = opts.contentType ?? inferContentType(opts.filename);
320
324
  if (opts.dryRun) {
321
- const preview = await request("PUT", `${filesBase(config)}/${encodeKeyPath(key)}?dryRun=1`);
325
+ const qs = opts.replace ? "dryRun=1&replace=1" : "dryRun=1";
326
+ const preview = await request("PUT", `${filesBase(config)}/${encodeKeyPath(key)}?${qs}`);
322
327
  if (preview.url == null) {
323
328
  throw new UploadsError("workspace has no publicBaseUrl (cannot resolve a public URL)", "NO_PUBLIC_URL");
324
329
  }
@@ -330,9 +335,12 @@ export function createUploadsClient(config) {
330
335
  size: body.byteLength,
331
336
  contentType,
332
337
  replaced: preview.replaced === true,
338
+ wouldRefuse: preview.wouldRefuse === true,
333
339
  };
334
340
  }
335
341
  const headers = { "Content-Type": contentType };
342
+ if (opts.replace)
343
+ headers["X-Uploads-Replace"] = "1";
336
344
  if (opts.provenance) {
337
345
  for (const [k, v] of Object.entries(opts.provenance)) {
338
346
  if (v !== undefined && v !== "")
@@ -2,13 +2,15 @@ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { basename } from "node:path";
3
3
  import { flagBool, flagInt, flagString, flagValues, parseCommandArgs, UsageError, } from "../cli-args.js";
4
4
  import { writeCommandHelp } from "../cli-style.js";
5
- import { branchFromFlags, frameOptionsFromFlags, ghTargetFromFlags, optimizeOptionsFromFlags, syncAttachmentsComment, commentViaSuffix, uploadPreparedImage, } from "../commands.js";
5
+ import { branchFromFlags, derivedMetaEnabled, frameOptionsFromFlags, ghTargetFromFlags, optimizeOptionsFromFlags, stateAppMetaFromFlags, warnNearMissMeta, syncAttachmentsComment, commentViaSuffix, uploadPreparedImage, } from "../commands.js";
6
6
  import { resolvePutDefaults } from "../config.js";
7
7
  import { loadDefaultsRaw, resolveScreenshotDefaults } from "../config-file.js";
8
8
  import { resolvePutPrefix } from "../destinations.js";
9
9
  import { execRunner, ghMetadataFromTargetWithTitle, resolveRepo, } from "../github-gh.js";
10
10
  import { ghBranchAttachmentKey, ghMetadataForBranch } from "../github.js";
11
+ import { safeCaptureFacts } from "../capture-facts.js";
11
12
  import { parseMetaFlags, validateMetaMap } from "../metadata.js";
13
+ import { mergeDerivedMeta } from "../metadata-vocab.js";
12
14
  import { writeJson, writeStdout } from "../io.js";
13
15
  import { assertHideSelector, captureScreenshot, parseViewport, parseWaitUntil, } from "../screenshot.js";
14
16
  const SCREENSHOT_HELP = `uploads screenshot <target> [options]
@@ -77,6 +79,8 @@ Options:
77
79
  otherwise via local gh.
78
80
  --gallery <id> Add the uploaded object to this public gallery
79
81
  --meta <k=v> Queryable custom metadata (repeatable)
82
+ --state <s> before|after|empty|error|loading — the UI state shown
83
+ --app <name> Surface shown: web, ios, android, cli
80
84
  --workspace, -w <name> Override workspace
81
85
  --dry-run Capture + resolve key/URL without uploading
82
86
  --format human|url|markdown|json
@@ -234,18 +238,22 @@ captureImpl = captureScreenshot) {
234
238
  const frameOpts = frameOptionsFromFlags(parsed.flags);
235
239
  const altFlag = flagString(parsed.flags, "--alt");
236
240
  const width = flagInt(parsed.flags, "--width", "--width") ?? putDefaults.width;
237
- const metaExtras = parseMetaFlags(flagValues(parsed.flags, "--meta"));
238
- let metadata = metaExtras;
241
+ const metaExtras = warnNearMissMeta(ctx, parseMetaFlags(flagValues(parsed.flags, "--meta")));
242
+ // Explicit input (--meta plus the dedicated flags) wins over capture facts.
243
+ const explicitMeta = { ...metaExtras, ...stateAppMetaFromFlags(parsed.flags) };
244
+ const deriveMeta = derivedMetaEnabled(parsed.flags, putDefaults);
245
+ const withFacts = mergeDerivedMeta(explicitMeta, deriveMeta ? safeCaptureFacts(target, viewport, colorScheme) : {});
246
+ let metadata = withFacts;
239
247
  if (ghTarget) {
240
- metadata = { ...metaExtras, ...ghMetadataFromTargetWithTitle(ghTarget, run) };
248
+ metadata = { ...withFacts, ...ghMetadataFromTargetWithTitle(ghTarget, run) };
241
249
  validateMetaMap(metadata);
242
250
  }
243
251
  else if (branchArg !== undefined) {
244
- metadata = { ...metaExtras, ...ghMetadataForBranch(branchRepo, branchArg) };
252
+ metadata = { ...withFacts, ...ghMetadataForBranch(branchRepo, branchArg) };
245
253
  validateMetaMap(metadata);
246
254
  }
247
- else if (Object.keys(metaExtras).length > 0) {
248
- validateMetaMap(metaExtras);
255
+ else if (Object.keys(withFacts).length > 0) {
256
+ validateMetaMap(withFacts);
249
257
  }
250
258
  const logHuman = !ctx.quiet && format === "human";
251
259
  if (logHuman)
@@ -41,6 +41,28 @@ export declare function ghTargetFromFlags(flags: CommandFlags["flags"], run: Com
41
41
  export declare function branchFromFlags(flags: CommandFlags["flags"], run: CommandRunner): string | undefined;
42
42
  /** Shared put/attach optimize flags + UPLOADS_NO_OPTIMIZE default. */
43
43
  export declare function optimizeOptionsFromFlags(flags: CommandFlags["flags"], defaults: PutDefaults): OptimizeImageOptions;
44
+ /**
45
+ * Whether the derived-metadata tier is on — screenshot capture facts and EXIF
46
+ * promotion. `--no-auto` and `UPLOADS_NO_AUTO_META=1` turn it off; `--auto`
47
+ * forces past the config default.
48
+ *
49
+ * Deliberately *not* gated on `--no-git`. That flag means "don't shell out to
50
+ * git", which says nothing about a viewport or a URL path — a capture of a
51
+ * local .html file outside any repo should still record what it captured.
52
+ * `--no-git` still disables gh.* below, which genuinely needs a repo.
53
+ */
54
+ export declare function derivedMetaEnabled(flags: CommandFlags["flags"], defaults: Pick<PutDefaults, "noAutoMeta">): boolean;
55
+ /**
56
+ * Warn about metadata keys that look like misspellings of canonical ones, then
57
+ * return the map unchanged — we nag, we never rewrite a caller's key.
58
+ */
59
+ export declare function warnNearMissMeta(ctx: CliContext, meta: Record<string, string>): Record<string, string>;
60
+ /**
61
+ * Canonical `state`/`app` pairs from their dedicated flags. Shared by put,
62
+ * attach and screenshot. These are sugar for the matching `--meta` keys; the
63
+ * point is `--help` discoverability and `--state` validation.
64
+ */
65
+ export declare function stateAppMetaFromFlags(flags: CommandFlags["flags"]): Record<string, string>;
44
66
  export type PreparedUpload = OptimizeImageResult & {
45
67
  frame?: Pick<FrameResult, "framed" | "frameId" | "skippedReason">;
46
68
  };
@@ -67,7 +89,19 @@ export interface UploadPreparedImageOptions {
67
89
  deriveRepoFromGit?: boolean;
68
90
  contentType?: string;
69
91
  dryRun?: boolean;
92
+ /**
93
+ * Opt in to overwriting an existing object on a strict (non-`gh/`) key —
94
+ * see issue #174. Ignored server-side on managed `gh/` paths (`ghTarget`
95
+ * set), which always hot-swap.
96
+ */
97
+ replace?: boolean;
70
98
  metadata?: Record<string, string>;
99
+ /**
100
+ * Promote this image's own EXIF allowlist into its metadata (see
101
+ * image-facts.ts). Lives here, on the shared bytes tail, so every upload
102
+ * surface — CLI put/screenshot, MCP put/screenshot — derives alike.
103
+ */
104
+ deriveImageFacts?: boolean;
71
105
  provenanceClient?: string;
72
106
  /**
73
107
  * Alt text for the markdown. Takes the prepared result so callers whose
@@ -118,8 +152,7 @@ export declare function commentViaSuffix(via: AttachmentsCommentResult["via"]):
118
152
  /**
119
153
  * Thrown by `syncAttachmentsComment` when the server declines with
120
154
  * `not_authorized` (issue #297 baseline control) — this repo is bound to a
121
- * different workspace, or unbound and unclaimable by the communal `default`
122
- * workspace. Deliberately not caught by the generic "bot endpoint
155
+ * different workspace. Deliberately not caught by the generic "bot endpoint
123
156
  * unreachable" fallback below: falling back to gh here would let the
124
157
  * human's own credentials post anyway, defeating the point of the
125
158
  * server-side gate.
@@ -164,6 +197,8 @@ export declare function uploadAttachments(opts: {
164
197
  frameFit?: "cover" | "contain";
165
198
  };
166
199
  metadata?: Record<string, string>;
200
+ /** Forwarded per file — see image-facts.ts. */
201
+ deriveImageFacts?: boolean;
167
202
  /** Provenance `client` field (default uploads-cli). */
168
203
  provenanceClient?: string;
169
204
  concurrency?: number;
@@ -196,6 +231,8 @@ export declare function uploadBranchAttachments(opts: {
196
231
  frameFit?: "cover" | "contain";
197
232
  };
198
233
  metadata?: Record<string, string>;
234
+ /** Forwarded per file — see image-facts.ts. */
235
+ deriveImageFacts?: boolean;
199
236
  provenanceClient?: string;
200
237
  concurrency?: number;
201
238
  }): Promise<{
@@ -227,6 +264,12 @@ export declare function uploadPuts(opts: {
227
264
  deriveRepoFromGit?: boolean;
228
265
  contentType?: string;
229
266
  dryRun?: boolean;
267
+ /**
268
+ * Opt in to overwriting an existing object on a strict (non-`gh/`) key —
269
+ * see issue #174. Ignored server-side when `ghTarget` targets a managed
270
+ * `gh/` path, which always hot-swaps.
271
+ */
272
+ replace?: boolean;
230
273
  optimize: OptimizeImageOptions;
231
274
  frame: {
232
275
  frameId?: string;
@@ -234,6 +277,8 @@ export declare function uploadPuts(opts: {
234
277
  frameFit?: "cover" | "contain";
235
278
  };
236
279
  metadata?: Record<string, string>;
280
+ /** Forwarded per file to `uploadPreparedImage` — see image-facts.ts. */
281
+ deriveImageFacts?: boolean;
237
282
  provenanceClient?: string;
238
283
  /** When set, used as alt for every file; else each file's basename. */
239
284
  alt?: string;