@buildinternet/uploads 0.19.0 → 0.22.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,11 +28,21 @@ 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;
34
42
  limit?: number;
35
43
  cursor?: string;
44
+ /** Hydrate each row's queryable D1 metadata (`?metadata=1`). */
45
+ metadata?: boolean;
36
46
  }
37
47
  export interface FindFilesOptions {
38
48
  prefix?: string;
@@ -67,6 +77,11 @@ export interface PutResult {
67
77
  * at this key would overwrite. Always set by the API for put/dry-run.
68
78
  */
69
79
  replaced?: boolean;
80
+ /**
81
+ * dryRun only: true when a real put at this key would be refused (strict
82
+ * non-`gh/` key, existing object, no `replace`) instead of overwriting.
83
+ */
84
+ wouldRefuse?: boolean;
70
85
  metadata?: Record<string, string>;
71
86
  }
72
87
  export interface ListItem {
@@ -77,6 +92,8 @@ export interface ListItem {
77
92
  pageUrl?: string;
78
93
  size?: number;
79
94
  uploaded?: string;
95
+ /** Present only when listed with `metadata: true`, and only for keys that have rows. */
96
+ metadata?: Record<string, string>;
80
97
  }
81
98
  export interface ListResult {
82
99
  items: ListItem[];
@@ -175,8 +192,7 @@ export interface FindGalleriesByReferenceOptions {
175
192
  /**
176
193
  * Reasons the bot did not post. The CLI falls back to the local `gh` path
177
194
  * 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).
195
+ * the target repo is bound to a different workspace.
180
196
  * Falling back to `gh` there would let the human's own credentials post
181
197
  * anyway, defeating the point of the server-side gate, so the CLI surfaces
182
198
  * the decline instead.
@@ -219,6 +235,13 @@ export interface GithubLinkResult {
219
235
  /** POST-only: whether THIS call's workspace ended up owning the binding. */
220
236
  export interface GithubLinkClaimResult extends GithubLinkResult {
221
237
  claimed: boolean;
238
+ /**
239
+ * Present (only) when `claimed` is false because the repo is unbound and
240
+ * this workspace couldn't be verified as entitled to claim it — issue
241
+ * #297's cross-tenant authorization gate. Distinct from the "someone else
242
+ * already owns it" case, which instead reports a non-null `workspace`.
243
+ */
244
+ reason?: "not_authorized";
222
245
  }
223
246
  /** `DELETE /v1/:workspace/github/link` result (issue #318, self-serve unlink). */
224
247
  export interface GithubLinkUnlinkResult {
@@ -332,7 +355,14 @@ export interface DeviceCodeResponse {
332
355
  interval: number;
333
356
  }
334
357
  /** POST /api/auth/device/code — start a device flow. Throws on a non-2xx. */
335
- export declare function requestDeviceCode(authUrl: string, clientId?: string): Promise<DeviceCodeResponse>;
358
+ export declare function requestDeviceCode(authUrl: string, clientId?: string,
359
+ /**
360
+ * RFC 8628 `scope`. Carries the requested workspace (`workspace:<slug>`,
361
+ * plus `create`) so the approval page can validate it before approving —
362
+ * issue #362. Stored on the device-code row and echoed back at token
363
+ * exchange, possibly rewritten by the page.
364
+ */
365
+ scope?: string): Promise<DeviceCodeResponse>;
336
366
  /**
337
367
  * One poll of POST /api/auth/device/token. Unlike most calls, the "not ready
338
368
  * yet" outcomes (`authorization_pending`, `slow_down`) are EXPECTED 400s, so
@@ -430,12 +460,14 @@ export declare function extractErrorFields(body: unknown, fallback?: string): {
430
460
  message: string;
431
461
  code?: string;
432
462
  requiredScope?: string;
463
+ existingUrl?: string;
433
464
  };
434
465
  /** Fetch + parse an error-response body via {@link extractErrorFields}. */
435
466
  export declare function parseErrorEnvelope(res: Response, fallback?: string): Promise<{
436
467
  message: string;
437
468
  code?: string;
438
469
  requiredScope?: string;
470
+ existingUrl?: string;
439
471
  }>;
440
472
  export declare function createUploadsClient(config: UploadsClientConfig): {
441
473
  put(body: Uint8Array, opts: PutOptions & {
package/dist/client.js CHANGED
@@ -56,14 +56,21 @@ export function cliUserAgent(purpose = "device-login") {
56
56
  return `@buildinternet/uploads/${packageVersion()} (${purpose})`;
57
57
  }
58
58
  /** POST /api/auth/device/code — start a device flow. Throws on a non-2xx. */
59
- export function requestDeviceCode(authUrl, clientId = DEVICE_CLIENT_ID) {
59
+ export function requestDeviceCode(authUrl, clientId = DEVICE_CLIENT_ID,
60
+ /**
61
+ * RFC 8628 `scope`. Carries the requested workspace (`workspace:<slug>`,
62
+ * plus `create`) so the approval page can validate it before approving —
63
+ * issue #362. Stored on the device-code row and echoed back at token
64
+ * exchange, possibly rewritten by the page.
65
+ */
66
+ scope) {
60
67
  return jsonRequest(`${authUrl.replace(/\/$/, "")}/api/auth/device/code`, {
61
68
  method: "POST",
62
69
  headers: {
63
70
  "Content-Type": "application/json",
64
71
  "User-Agent": cliUserAgent("device-code"),
65
72
  },
66
- body: JSON.stringify({ client_id: clientId }),
73
+ body: JSON.stringify({ client_id: clientId, ...(scope ? { scope } : {}) }),
67
74
  });
68
75
  }
69
76
  export async function requestDeviceToken(authUrl, input) {
@@ -188,7 +195,7 @@ function usageBase(config) {
188
195
  function galleriesBase(config) {
189
196
  return `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/galleries`;
190
197
  }
191
- function mapApiError(status, error, code, requiredScope) {
198
+ function mapApiError(status, error, code, requiredScope, existingUrl) {
192
199
  const normalized = error.toLowerCase();
193
200
  if (status === 401 || code === "unauthorized" || normalized === "unauthorized") {
194
201
  return new UploadsError(error, "UNAUTHORIZED", status);
@@ -218,6 +225,9 @@ function mapApiError(status, error, code, requiredScope) {
218
225
  if (code === "github_required") {
219
226
  return new UploadsError(error, "GITHUB_REQUIRED", status);
220
227
  }
228
+ if (code === "key_exists") {
229
+ return new UploadsError(error, "KEY_EXISTS", status, { existingUrl });
230
+ }
221
231
  return new UploadsError(error, "API_ERROR", status);
222
232
  }
223
233
  /**
@@ -239,6 +249,7 @@ export function extractErrorFields(body, fallback = "request failed") {
239
249
  ...(typeof details?.required_scope === "string"
240
250
  ? { requiredScope: details.required_scope }
241
251
  : {}),
252
+ ...(typeof details?.url === "string" ? { existingUrl: details.url } : {}),
242
253
  };
243
254
  }
244
255
  if (typeof err === "string") {
@@ -256,8 +267,8 @@ export async function parseErrorEnvelope(res, fallback = "request failed") {
256
267
  return extractErrorFields(body, fallback);
257
268
  }
258
269
  async function parseErrorResponse(res) {
259
- const { message, code, requiredScope } = await parseErrorEnvelope(res, res.statusText || "request failed");
260
- return mapApiError(res.status, message, code, requiredScope);
270
+ const { message, code, requiredScope, existingUrl } = await parseErrorEnvelope(res, res.statusText || "request failed");
271
+ return mapApiError(res.status, message, code, requiredScope, existingUrl);
261
272
  }
262
273
  export function createUploadsClient(config) {
263
274
  async function request(method, path, opts) {
@@ -292,6 +303,8 @@ export function createUploadsClient(config) {
292
303
  params.set("limit", String(opts.limit));
293
304
  if (opts.cursor)
294
305
  params.set("cursor", opts.cursor);
306
+ if (opts.metadata)
307
+ params.set("metadata", "1");
295
308
  const qs = params.toString();
296
309
  const page = await request("GET", `${filesBase(config)}${qs ? `?${qs}` : ""}`);
297
310
  return {
@@ -318,7 +331,8 @@ export function createUploadsClient(config) {
318
331
  }));
319
332
  const contentType = opts.contentType ?? inferContentType(opts.filename);
320
333
  if (opts.dryRun) {
321
- const preview = await request("PUT", `${filesBase(config)}/${encodeKeyPath(key)}?dryRun=1`);
334
+ const qs = opts.replace ? "dryRun=1&replace=1" : "dryRun=1";
335
+ const preview = await request("PUT", `${filesBase(config)}/${encodeKeyPath(key)}?${qs}`);
322
336
  if (preview.url == null) {
323
337
  throw new UploadsError("workspace has no publicBaseUrl (cannot resolve a public URL)", "NO_PUBLIC_URL");
324
338
  }
@@ -330,9 +344,12 @@ export function createUploadsClient(config) {
330
344
  size: body.byteLength,
331
345
  contentType,
332
346
  replaced: preview.replaced === true,
347
+ wouldRefuse: preview.wouldRefuse === true,
333
348
  };
334
349
  }
335
350
  const headers = { "Content-Type": contentType };
351
+ if (opts.replace)
352
+ headers["X-Uploads-Replace"] = "1";
336
353
  if (opts.provenance) {
337
354
  for (const [k, v] of Object.entries(opts.provenance)) {
338
355
  if (v !== undefined && v !== "")
@@ -1,5 +1,16 @@
1
1
  import { parseCommandArgs } from "../cli-args.js";
2
2
  export declare function validateEnrollmentCode(raw: string): string;
3
+ /** The scope the CLI sends with its device-code request. No workspace requested → no scope. */
4
+ export declare function formatDeviceScope(workspace: string | undefined, create: boolean): string | undefined;
5
+ /**
6
+ * Read back what the approval page decided. A surviving `create` token means
7
+ * the page left the scope alone and deferred provisioning to the CLI; a bare
8
+ * `workspace:<slug>` means the browser recorded a choice and wins.
9
+ */
10
+ export declare function parseDeviceScope(scope: string | undefined): {
11
+ workspace: string | undefined;
12
+ create: boolean;
13
+ };
3
14
  export declare function resolveEnrollmentCode(parsed: ReturnType<typeof parseCommandArgs>, io?: {
4
15
  isTTY: boolean;
5
16
  readLine: () => Promise<string>;
@@ -23,10 +34,21 @@ export interface DeviceLoginIo {
23
34
  promptWorkspaceName: () => Promise<string>;
24
35
  }
25
36
  export declare const defaultDeviceIo: DeviceLoginIo;
37
+ /** A completed device authorization: the session bearer plus the (possibly rewritten) scope. */
38
+ export interface DeviceSession {
39
+ accessToken: string;
40
+ scope: string;
41
+ }
26
42
  /**
27
43
  * Browser device-authorization session only (no workspace token mint).
28
44
  * Shared by `uploads login` and `uploads invite create`.
29
45
  */
46
+ export declare function obtainDeviceSession(authUrl: string, opts?: {
47
+ noOpen?: boolean;
48
+ prompt?: string;
49
+ scope?: string;
50
+ }, io?: DeviceLoginIo): Promise<DeviceSession>;
51
+ /** Session bearer only — `invite create` has no workspace to resolve. */
30
52
  export declare function obtainDeviceAccessToken(authUrl: string, opts?: {
31
53
  noOpen?: boolean;
32
54
  prompt?: string;
@@ -36,7 +58,7 @@ export declare function pollForDeviceToken(authUrl: string, code: {
36
58
  device_code: string;
37
59
  interval: number;
38
60
  expires_in: number;
39
- }, io: DeviceLoginIo): Promise<string>;
61
+ }, io: DeviceLoginIo): Promise<DeviceSession>;
40
62
  export declare function runLogin(args: string[], opts: {
41
63
  json?: boolean;
42
64
  apiUrl?: string;
@@ -7,15 +7,17 @@ import { createUploadsClient, createWorkspaceRequest, exchangeEnrollment, listMi
7
7
  import { flagBool, flagString, parseCommandArgs, UsageError } from "../cli-args.js";
8
8
  import { parseScopes } from "./admin-enrollment.js";
9
9
  import { writeCommandHelp } from "../cli-style.js";
10
+ import { UploadsError } from "../errors.js";
10
11
  const HELP = `uploads login [options]
11
12
 
12
13
  Sign in and save workspace credentials. With no flags, opens a browser to
13
- authorize this device — the recommended way to sign in. Pass an enrollment
14
- code only if you were given one from before device login (fallback path).
14
+ authorize this device — the recommended way to sign in. The browser asks which
15
+ workspace to sign in to, so --workspace is optional. Pass an enrollment code
16
+ only if you were given one from before device login (fallback path).
15
17
 
16
18
  Options:
17
- --workspace <name> Workspace to mint a token for (device flow; required if
18
- your account can access more than one)
19
+ --workspace <name> Preselect this workspace in the browser (device flow);
20
+ you can still change it there
19
21
  --create With --workspace: create the workspace first if your
20
22
  account doesn't have it yet (device flow only) — lets
21
23
  scripted/agent logins provision without a prompt
@@ -46,6 +48,34 @@ export function validateEnrollmentCode(raw) {
46
48
  throw new UsageError("invalid enrollment code");
47
49
  return code;
48
50
  }
51
+ /**
52
+ * Device-code scope vocabulary (issue #362). Mirrors `parseDeviceScope` /
53
+ * `workspaceScopeValue` in apps/auth/src/device-workspace.ts — this package
54
+ * ships with no workspace dependencies, so the two copies are deliberately
55
+ * independent. Keep the vocabulary in sync.
56
+ */
57
+ const WORKSPACE_SCOPE_PREFIX = "workspace:";
58
+ const CREATE_SCOPE_TOKEN = "create";
59
+ /** The scope the CLI sends with its device-code request. No workspace requested → no scope. */
60
+ export function formatDeviceScope(workspace, create) {
61
+ if (!workspace)
62
+ return undefined;
63
+ return create
64
+ ? `${WORKSPACE_SCOPE_PREFIX}${workspace} ${CREATE_SCOPE_TOKEN}`
65
+ : `${WORKSPACE_SCOPE_PREFIX}${workspace}`;
66
+ }
67
+ /**
68
+ * Read back what the approval page decided. A surviving `create` token means
69
+ * the page left the scope alone and deferred provisioning to the CLI; a bare
70
+ * `workspace:<slug>` means the browser recorded a choice and wins.
71
+ */
72
+ export function parseDeviceScope(scope) {
73
+ const tokens = (scope ?? "").split(/\s+/).filter(Boolean);
74
+ const slug = tokens
75
+ .find((t) => t.startsWith(WORKSPACE_SCOPE_PREFIX))
76
+ ?.slice(WORKSPACE_SCOPE_PREFIX.length) ?? "";
77
+ return { workspace: slug || undefined, create: tokens.includes(CREATE_SCOPE_TOKEN) };
78
+ }
49
79
  async function readLine() {
50
80
  let out = "";
51
81
  for await (const chunk of stdin) {
@@ -187,8 +217,8 @@ export const defaultDeviceIo = {
187
217
  * Browser device-authorization session only (no workspace token mint).
188
218
  * Shared by `uploads login` and `uploads invite create`.
189
219
  */
190
- export async function obtainDeviceAccessToken(authUrl, opts = {}, io = defaultDeviceIo) {
191
- const code = await requestDeviceCode(authUrl);
220
+ export async function obtainDeviceSession(authUrl, opts = {}, io = defaultDeviceIo) {
221
+ const code = await requestDeviceCode(authUrl, undefined, opts.scope);
192
222
  const verifyUrl = code.verification_uri_complete ?? code.verification_uri;
193
223
  const prompt = opts.prompt ?? "To sign in, open:";
194
224
  io.write(`${prompt}\n\n ${verifyUrl}\n\nand confirm this code:\n\n ${code.user_code}\n\n`);
@@ -197,6 +227,31 @@ export async function obtainDeviceAccessToken(authUrl, opts = {}, io = defaultDe
197
227
  io.write("Waiting for approval…\n");
198
228
  return pollForDeviceToken(authUrl, code, io);
199
229
  }
230
+ /** Session bearer only — `invite create` has no workspace to resolve. */
231
+ export async function obtainDeviceAccessToken(authUrl, opts = {}, io = defaultDeviceIo) {
232
+ return (await obtainDeviceSession(authUrl, opts, io)).accessToken;
233
+ }
234
+ /**
235
+ * Turn the API's deliberately opaque 403 (`no access to this workspace` — it
236
+ * refuses to distinguish "doesn't exist" from "you're not a member", see
237
+ * apps/api/src/routes/tokens.ts) into something the user can act on, by
238
+ * listing the workspaces their own account CAN reach. Backstop only: since
239
+ * #362 the approval page catches this before approving.
240
+ */
241
+ async function describeMintFailure(apiUrl, accessToken, workspace, err) {
242
+ if (!(err instanceof UploadsError) || err.status !== 403)
243
+ throw err;
244
+ let names = [];
245
+ try {
246
+ names = (await listMintWorkspaces(apiUrl, accessToken)).workspaces.map((w) => w.workspace);
247
+ }
248
+ catch {
249
+ // Listing is best-effort — fall through to the generic hint below.
250
+ }
251
+ throw new UsageError(names.length
252
+ ? `no access to workspace "${workspace}" — this account can use: ${names.join(", ")}`
253
+ : `no access to workspace "${workspace}" — this account has no workspaces yet; pass --workspace <name> --create to provision one`);
254
+ }
200
255
  /**
201
256
  * Device-authorization login (RFC 8628): request a code, have the user approve
202
257
  * it in a browser, poll for the session token, then mint a workspace token.
@@ -217,9 +272,22 @@ async function runDeviceLogin(parsed, opts, io) {
217
272
  // Make the target explicit: a bare `uploads login` on a self-hosted install
218
273
  // would otherwise silently sign in to the cloud service.
219
274
  io.write(`signing in to ${opts.authUrl} (self-hosted? pass --api-url or set UPLOADS_API_URL)\n\n`);
220
- const accessToken = await obtainDeviceAccessToken(opts.authUrl, { noOpen: opts.noOpen }, io);
221
- const workspace = await resolveMintWorkspace(opts.apiUrl, accessToken, requestedWorkspace, io, flagBool(parsed.flags, "--create"));
222
- const minted = await mintWorkspaceToken(opts.apiUrl, accessToken, { workspace, scopes, label });
275
+ const create = flagBool(parsed.flags, "--create");
276
+ const session = await obtainDeviceSession(opts.authUrl, { noOpen: opts.noOpen, scope: formatDeviceScope(requestedWorkspace, create) }, io);
277
+ // The approval page is authoritative: it validated the workspace against the
278
+ // signed-in account's memberships (and may have created a new one) before
279
+ // approving. A scope that still carries `create` means the page deferred to
280
+ // the CLI, and an empty one means an older server that doesn't echo a
281
+ // choice — both fall back to the local resolution below.
282
+ const chosen = parseDeviceScope(session.scope);
283
+ const workspace = chosen.workspace && !chosen.create
284
+ ? chosen.workspace
285
+ : await resolveMintWorkspace(opts.apiUrl, session.accessToken, requestedWorkspace, io, create);
286
+ const minted = await mintWorkspaceToken(opts.apiUrl, session.accessToken, {
287
+ workspace,
288
+ scopes,
289
+ label,
290
+ }).catch((err) => describeMintFailure(opts.apiUrl, session.accessToken, workspace, err));
223
291
  return { workspace: minted.workspace, token: minted.token, apiUrl: opts.apiUrl };
224
292
  }
225
293
  function safeHostname() {
@@ -247,7 +315,7 @@ export async function pollForDeviceToken(authUrl, code, io) {
247
315
  }
248
316
  switch (result.status) {
249
317
  case "ok":
250
- return result.accessToken;
318
+ return { accessToken: result.accessToken, scope: result.scope };
251
319
  case "pending":
252
320
  continue;
253
321
  case "slow_down":
@@ -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;