@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.
- 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 +35 -3
- package/dist/client.js +23 -6
- package/dist/commands/login.d.ts +23 -1
- package/dist/commands/login.js +78 -10
- package/dist/commands/screenshot.js +15 -7
- package/dist/commands.d.ts +47 -2
- package/dist/commands.js +174 -39
- package/dist/errors.d.ts +10 -2
- package/dist/errors.js +8 -1
- package/dist/github.d.ts +11 -0
- package/dist/github.js +52 -3
- 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
|
@@ -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
|
+
}
|
package/dist/cli-catalog.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 !== "")
|
package/dist/commands/login.d.ts
CHANGED
|
@@ -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<
|
|
61
|
+
}, io: DeviceLoginIo): Promise<DeviceSession>;
|
|
40
62
|
export declare function runLogin(args: string[], opts: {
|
|
41
63
|
json?: boolean;
|
|
42
64
|
apiUrl?: string;
|
package/dist/commands/login.js
CHANGED
|
@@ -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.
|
|
14
|
-
|
|
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>
|
|
18
|
-
|
|
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
|
|
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
|
|
221
|
-
const
|
|
222
|
-
|
|
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
|
-
|
|
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 = { ...
|
|
248
|
+
metadata = { ...withFacts, ...ghMetadataFromTargetWithTitle(ghTarget, run) };
|
|
241
249
|
validateMetaMap(metadata);
|
|
242
250
|
}
|
|
243
251
|
else if (branchArg !== undefined) {
|
|
244
|
-
metadata = { ...
|
|
252
|
+
metadata = { ...withFacts, ...ghMetadataForBranch(branchRepo, branchArg) };
|
|
245
253
|
validateMetaMap(metadata);
|
|
246
254
|
}
|
|
247
|
-
else if (Object.keys(
|
|
248
|
-
validateMetaMap(
|
|
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)
|
package/dist/commands.d.ts
CHANGED
|
@@ -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
|
|
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;
|