@buildinternet/uploads 0.11.1 → 0.12.1
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/README.md +12 -1
- package/dist/cli-catalog.d.ts +7 -0
- package/dist/cli-catalog.js +51 -0
- package/dist/cli.js +12 -0
- package/dist/client.d.ts +16 -0
- package/dist/client.js +13 -7
- package/dist/commands/completion.js +15 -5
- package/dist/commands/screenshot.d.ts +6 -0
- package/dist/commands/screenshot.js +317 -0
- package/dist/commands.d.ts +71 -1
- package/dist/commands.js +114 -28
- package/dist/config-file.d.ts +28 -2
- package/dist/config-file.js +44 -6
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/github-gh.js +6 -0
- package/dist/mcp/tools.d.ts +7 -0
- package/dist/mcp/tools.js +250 -27
- package/dist/screenshot-local.d.ts +64 -0
- package/dist/screenshot-local.js +310 -0
- package/dist/screenshot-remote.d.ts +23 -0
- package/dist/screenshot-remote.js +79 -0
- package/dist/screenshot.d.ts +74 -0
- package/dist/screenshot.js +231 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -15,6 +15,8 @@ npx @buildinternet/uploads --help
|
|
|
15
15
|
uploads setup
|
|
16
16
|
uploads --version
|
|
17
17
|
uploads attach ./before.png ./after.png
|
|
18
|
+
uploads screenshot https://app.example --pr 123 --comment # capture + host in one step
|
|
19
|
+
uploads screenshot ./report.html --dark --selector "main"
|
|
18
20
|
uploads put ./shot.png
|
|
19
21
|
uploads put ./shot.png --destination screenshots
|
|
20
22
|
uploads put ./shot.png --no-optimize
|
|
@@ -37,7 +39,7 @@ Inside this monorepo only, `pnpm uploads …` builds the package first so you pi
|
|
|
37
39
|
up local source; product docs and PR “how to try it” examples should use the
|
|
38
40
|
global `uploads` form above.
|
|
39
41
|
|
|
40
|
-
Commands: `attach`, `put`, `gallery`, `comment`, `list`, `find`, `meta`, `delete`, `usage`,
|
|
42
|
+
Commands: `attach`, `put`, `screenshot`, `gallery`, `comment`, `list`, `find`, `meta`, `delete`, `usage`,
|
|
41
43
|
`reconcile`, `purge-expired`, `setup`, `install`, `login`, `whoami` (alias `status`),
|
|
42
44
|
`logout`, `invite`, `admin`, `config`, `telemetry`, `report`, `doctor`, `health`, `mcp`,
|
|
43
45
|
`completion`.
|
|
@@ -81,6 +83,15 @@ infers the pull request for the current branch via `gh`, uploads stable URLs, an
|
|
|
81
83
|
or updates one marker-owned GitHub comment. It keeps loose `gh/...` attachments and linked public galleries in distinct sections, shows up to three available gallery images inline, and updates that same comment in place on every sync. Use `--pr`, `--issue`, and `--repo` to select
|
|
82
84
|
the target explicitly, or `--no-comment` to upload without changing GitHub comments.
|
|
83
85
|
|
|
86
|
+
**Screenshot capture:** `uploads screenshot <url|file.html>` renders a page to a
|
|
87
|
+
hosted image in one step — no separate browser tooling needed. `--via auto`
|
|
88
|
+
(default) drives a Chrome/Chromium already on the machine (`playwright-core`
|
|
89
|
+
ships no browser; `--browser <path>`, `--cdp <endpoint>`, or the Playwright/
|
|
90
|
+
Puppeteer caches all work), and falls back to server-side rendering when none
|
|
91
|
+
is found. localhost/private URLs are local-only; `.html` files work on both
|
|
92
|
+
backends. After capture it joins the same pipeline as `put` (optimize, frames,
|
|
93
|
+
`--pr`/`--issue` comments, galleries). See `uploads screenshot --help`.
|
|
94
|
+
|
|
84
95
|
**Keys / destinations:** default put uses the `screenshots` layout. Typed destinations
|
|
85
96
|
(`--destination screenshots|gh|f`, MCP `destination`) set the root; `--pr`/`--issue`
|
|
86
97
|
use `gh/…`. Workspaces may restrict put/sign to those roots via
|
package/dist/cli-catalog.d.ts
CHANGED
|
@@ -22,6 +22,13 @@ export declare const GLOBAL_FLAGS: readonly {
|
|
|
22
22
|
}[];
|
|
23
23
|
/** Common flags for put/attach (file-oriented commands). */
|
|
24
24
|
export declare const PUT_LIKE_FLAGS: readonly string[];
|
|
25
|
+
/**
|
|
26
|
+
* All flags `uploads screenshot` actually reads (verified against
|
|
27
|
+
* commands/screenshot.ts) — kept as its own explicit list rather than
|
|
28
|
+
* spreading PUT_LIKE_FLAGS, which includes `--name`/`--no-comment` that
|
|
29
|
+
* screenshot never parses.
|
|
30
|
+
*/
|
|
31
|
+
export declare const SCREENSHOT_FLAGS: readonly string[];
|
|
25
32
|
export declare const LIST_LIKE_FLAGS: readonly string[];
|
|
26
33
|
export declare const ROOT_COMMANDS: readonly CatalogCommand[];
|
|
27
34
|
export declare const COMPLETION_SHELLS: readonly ["bash", "zsh", "fish"];
|
package/dist/cli-catalog.js
CHANGED
|
@@ -40,6 +40,51 @@ export const PUT_LIKE_FLAGS = [
|
|
|
40
40
|
"--help",
|
|
41
41
|
"-h",
|
|
42
42
|
];
|
|
43
|
+
/**
|
|
44
|
+
* All flags `uploads screenshot` actually reads (verified against
|
|
45
|
+
* commands/screenshot.ts) — kept as its own explicit list rather than
|
|
46
|
+
* spreading PUT_LIKE_FLAGS, which includes `--name`/`--no-comment` that
|
|
47
|
+
* screenshot never parses.
|
|
48
|
+
*/
|
|
49
|
+
export const SCREENSHOT_FLAGS = [
|
|
50
|
+
"--via",
|
|
51
|
+
"--browser",
|
|
52
|
+
"--cdp",
|
|
53
|
+
"--viewport",
|
|
54
|
+
"--selector",
|
|
55
|
+
"--full-page",
|
|
56
|
+
"--dark",
|
|
57
|
+
"--light",
|
|
58
|
+
"--wait",
|
|
59
|
+
"--out",
|
|
60
|
+
"--no-upload",
|
|
61
|
+
"--destination",
|
|
62
|
+
"--prefix",
|
|
63
|
+
"--repo",
|
|
64
|
+
"--ref",
|
|
65
|
+
"--key",
|
|
66
|
+
"--alt",
|
|
67
|
+
"--width",
|
|
68
|
+
"--frame",
|
|
69
|
+
"--frame-url",
|
|
70
|
+
"--frame-fit",
|
|
71
|
+
"--no-optimize",
|
|
72
|
+
"--optimize-max-edge",
|
|
73
|
+
"--optimize-quality",
|
|
74
|
+
"--keep-exif",
|
|
75
|
+
"--no-git",
|
|
76
|
+
"--pr",
|
|
77
|
+
"--issue",
|
|
78
|
+
"--comment",
|
|
79
|
+
"--gallery",
|
|
80
|
+
"--meta",
|
|
81
|
+
"--dry-run",
|
|
82
|
+
"--format",
|
|
83
|
+
"--workspace",
|
|
84
|
+
"-w",
|
|
85
|
+
"--help",
|
|
86
|
+
"-h",
|
|
87
|
+
];
|
|
43
88
|
export const LIST_LIKE_FLAGS = [
|
|
44
89
|
"--prefix",
|
|
45
90
|
"--limit",
|
|
@@ -63,6 +108,12 @@ export const ROOT_COMMANDS = [
|
|
|
63
108
|
summary: "Upload (+ URL + markdown for GitHub)",
|
|
64
109
|
essential: true,
|
|
65
110
|
},
|
|
111
|
+
{
|
|
112
|
+
name: "screenshot",
|
|
113
|
+
usage: "screenshot <target>",
|
|
114
|
+
summary: "Capture a URL or .html file and host it (local browser or remote render)",
|
|
115
|
+
essential: true,
|
|
116
|
+
},
|
|
66
117
|
{
|
|
67
118
|
name: "gallery",
|
|
68
119
|
summary: "Create and organize public media galleries",
|
package/dist/cli.js
CHANGED
|
@@ -16,6 +16,7 @@ import { runCompletion } from "./commands/completion.js";
|
|
|
16
16
|
import { runLogout, runWhoami } from "./commands/session.js";
|
|
17
17
|
import { runTelemetry } from "./commands/telemetry.js";
|
|
18
18
|
import { runReport } from "./commands/report.js";
|
|
19
|
+
import { runScreenshot } from "./commands/screenshot.js";
|
|
19
20
|
import { packageVersion } from "./package-version.js";
|
|
20
21
|
import { checkForUpdate, maybeHintUpdate } from "./update-check.js";
|
|
21
22
|
import { errorCodeFromUnknown, maybeShowFirstRunNotice, recordEvent, telemetryCommandName, } from "./telemetry.js";
|
|
@@ -75,7 +76,12 @@ function exitCode(err) {
|
|
|
75
76
|
case "STORAGE_QUOTA":
|
|
76
77
|
case "UPLOAD_BUDGET":
|
|
77
78
|
return 3;
|
|
79
|
+
case "BROWSER_NOT_FOUND":
|
|
80
|
+
return 2;
|
|
78
81
|
case "NETWORK":
|
|
82
|
+
// Transient — same "retry, don't reconfigure" family as a network
|
|
83
|
+
// hiccup, not an auth/policy/budget denial (those are exit 3).
|
|
84
|
+
case "RATE_LIMITED":
|
|
79
85
|
return 4;
|
|
80
86
|
default:
|
|
81
87
|
return 1;
|
|
@@ -99,6 +105,8 @@ const ERROR_HINTS = {
|
|
|
99
105
|
UPLOAD_BUDGET: QUOTA_HINT,
|
|
100
106
|
KEY_POLICY: "hint: use a typed destination (`--destination screenshots|gh`) or an allowed prefix; operators set allowlists with `pnpm workspace:limits --allowed-prefixes`\n",
|
|
101
107
|
UNAUTHORIZED: "hint: token rejected — run `uploads login` to sign in again, or check UPLOADS_TOKEN / --token\n",
|
|
108
|
+
BROWSER_NOT_FOUND: "hint: no local browser found; try --via remote, or install Chrome / npx playwright install chromium\n",
|
|
109
|
+
RATE_LIMITED: "hint: transient rate limit — wait ~60s and retry\n",
|
|
102
110
|
};
|
|
103
111
|
function errorOut(err, format) {
|
|
104
112
|
const payload = err instanceof UploadsError
|
|
@@ -260,6 +268,7 @@ export async function runCli(argv) {
|
|
|
260
268
|
break;
|
|
261
269
|
case "attach":
|
|
262
270
|
case "put":
|
|
271
|
+
case "screenshot":
|
|
263
272
|
case "gallery":
|
|
264
273
|
case "list":
|
|
265
274
|
case "find":
|
|
@@ -278,6 +287,9 @@ export async function runCli(argv) {
|
|
|
278
287
|
case "put":
|
|
279
288
|
code = await runPut(ctx, cmdArgs, showHelp);
|
|
280
289
|
break;
|
|
290
|
+
case "screenshot":
|
|
291
|
+
code = await runScreenshot(ctx, cmdArgs, showHelp);
|
|
292
|
+
break;
|
|
281
293
|
case "gallery":
|
|
282
294
|
code = await runGallery(ctx, cmdArgs, showHelp);
|
|
283
295
|
break;
|
package/dist/client.d.ts
CHANGED
|
@@ -336,6 +336,22 @@ export declare function mintWorkspaceToken(apiUrl: string, accessToken: string,
|
|
|
336
336
|
label?: string;
|
|
337
337
|
ttlSeconds?: number;
|
|
338
338
|
}): Promise<MintTokenResult>;
|
|
339
|
+
/**
|
|
340
|
+
* Parse API error bodies. Prefers the nested envelope
|
|
341
|
+
* `{ error: { code, type, message, details? } }`; still accepts the legacy
|
|
342
|
+
* flat `{ error: string, code?: string }` shape. Exported so other backends
|
|
343
|
+
* (e.g. the screenshot render endpoint) share this parsing instead of
|
|
344
|
+
* duplicating it — each caller supplies its own `fallback` message.
|
|
345
|
+
*/
|
|
346
|
+
export declare function extractErrorFields(body: unknown, fallback?: string): {
|
|
347
|
+
message: string;
|
|
348
|
+
code?: string;
|
|
349
|
+
};
|
|
350
|
+
/** Fetch + parse an error-response body via {@link extractErrorFields}. */
|
|
351
|
+
export declare function parseErrorEnvelope(res: Response, fallback?: string): Promise<{
|
|
352
|
+
message: string;
|
|
353
|
+
code?: string;
|
|
354
|
+
}>;
|
|
339
355
|
export declare function createUploadsClient(config: UploadsClientConfig): {
|
|
340
356
|
put(body: Uint8Array, opts: PutOptions & {
|
|
341
357
|
filename: string;
|
package/dist/client.js
CHANGED
|
@@ -210,15 +210,17 @@ function mapApiError(status, error, code) {
|
|
|
210
210
|
/**
|
|
211
211
|
* Parse API error bodies. Prefers the nested envelope
|
|
212
212
|
* `{ error: { code, type, message, details? } }`; still accepts the legacy
|
|
213
|
-
* flat `{ error: string, code?: string }` shape.
|
|
213
|
+
* flat `{ error: string, code?: string }` shape. Exported so other backends
|
|
214
|
+
* (e.g. the screenshot render endpoint) share this parsing instead of
|
|
215
|
+
* duplicating it — each caller supplies its own `fallback` message.
|
|
214
216
|
*/
|
|
215
|
-
function extractErrorFields(body) {
|
|
217
|
+
export function extractErrorFields(body, fallback = "request failed") {
|
|
216
218
|
if (typeof body === "object" && body && "error" in body) {
|
|
217
219
|
const err = body.error;
|
|
218
220
|
if (typeof err === "object" && err && "message" in err) {
|
|
219
221
|
const nested = err;
|
|
220
222
|
return {
|
|
221
|
-
message: typeof nested.message === "string" ? nested.message :
|
|
223
|
+
message: typeof nested.message === "string" ? nested.message : fallback,
|
|
222
224
|
code: typeof nested.code === "string" ? nested.code : undefined,
|
|
223
225
|
};
|
|
224
226
|
}
|
|
@@ -229,12 +231,16 @@ function extractErrorFields(body) {
|
|
|
229
231
|
return { message: err, code };
|
|
230
232
|
}
|
|
231
233
|
}
|
|
232
|
-
return { message:
|
|
234
|
+
return { message: fallback };
|
|
233
235
|
}
|
|
234
|
-
|
|
236
|
+
/** Fetch + parse an error-response body via {@link extractErrorFields}. */
|
|
237
|
+
export async function parseErrorEnvelope(res, fallback = "request failed") {
|
|
235
238
|
const body = await res.json().catch(() => ({}));
|
|
236
|
-
|
|
237
|
-
|
|
239
|
+
return extractErrorFields(body, fallback);
|
|
240
|
+
}
|
|
241
|
+
async function parseErrorResponse(res) {
|
|
242
|
+
const { message, code } = await parseErrorEnvelope(res, res.statusText || "request failed");
|
|
243
|
+
return mapApiError(res.status, message, code);
|
|
238
244
|
}
|
|
239
245
|
export function createUploadsClient(config) {
|
|
240
246
|
async function request(method, path, opts) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parseCommandArgs, UsageError } from "../cli-args.js";
|
|
2
|
-
import { COMPLETION_SHELLS, GLOBAL_FLAGS, LIST_LIKE_FLAGS, PUT_LIKE_FLAGS, ROOT_COMMANDS, isCompletionShell, } from "../cli-catalog.js";
|
|
2
|
+
import { COMPLETION_SHELLS, GLOBAL_FLAGS, LIST_LIKE_FLAGS, PUT_LIKE_FLAGS, SCREENSHOT_FLAGS, ROOT_COMMANDS, isCompletionShell, } from "../cli-catalog.js";
|
|
3
3
|
import { writeCommandHelp } from "../cli-style.js";
|
|
4
4
|
const HELP = `uploads completion <shell>
|
|
5
5
|
|
|
@@ -28,6 +28,7 @@ function bashScript() {
|
|
|
28
28
|
const globals = GLOBAL_FLAGS.map((g) => g.flag).join(" ");
|
|
29
29
|
const putFlags = PUT_LIKE_FLAGS.join(" ");
|
|
30
30
|
const listFlags = LIST_LIKE_FLAGS.join(" ");
|
|
31
|
+
const screenshotFlags = SCREENSHOT_FLAGS.join(" ");
|
|
31
32
|
const subMaps = ROOT_COMMANDS.filter((c) => c.subcommands?.length).map((c) => {
|
|
32
33
|
const names = c.subcommands.map((s) => s.name).join(" ");
|
|
33
34
|
return ` ${c.name}) subs="${names}" ;;`;
|
|
@@ -52,6 +53,7 @@ _uploads() {
|
|
|
52
53
|
local -a globals=(${globals})
|
|
53
54
|
local -a put_flags=(${putFlags})
|
|
54
55
|
local -a list_flags=(${listFlags})
|
|
56
|
+
local -a screenshot_flags=(${screenshotFlags})
|
|
55
57
|
|
|
56
58
|
# Find the first non-global positional (the subcommand).
|
|
57
59
|
local cmd="" i=1
|
|
@@ -102,6 +104,9 @@ ${subMaps.join("\n")}
|
|
|
102
104
|
put|attach)
|
|
103
105
|
COMPREPLY=( $(compgen -W "\${put_flags[*]}" -- "$cur") )
|
|
104
106
|
;;
|
|
107
|
+
screenshot)
|
|
108
|
+
COMPREPLY=( $(compgen -W "\${screenshot_flags[*]}" -- "$cur") )
|
|
109
|
+
;;
|
|
105
110
|
list|find)
|
|
106
111
|
COMPREPLY=( $(compgen -W "\${list_flags[*]}" -- "$cur") )
|
|
107
112
|
;;
|
|
@@ -114,7 +119,7 @@ ${subMaps.join("\n")}
|
|
|
114
119
|
|
|
115
120
|
# File paths for upload-style commands.
|
|
116
121
|
case "$cmd" in
|
|
117
|
-
put|attach)
|
|
122
|
+
put|attach|screenshot)
|
|
118
123
|
COMPREPLY=( $(compgen -f -- "$cur") )
|
|
119
124
|
;;
|
|
120
125
|
esac
|
|
@@ -194,7 +199,7 @@ ${globalArgs} \\
|
|
|
194
199
|
args)
|
|
195
200
|
case $line[1] in
|
|
196
201
|
${subCases}
|
|
197
|
-
put|attach)
|
|
202
|
+
put|attach|screenshot)
|
|
198
203
|
_files
|
|
199
204
|
;;
|
|
200
205
|
esac
|
|
@@ -250,13 +255,18 @@ function fishScript() {
|
|
|
250
255
|
continue;
|
|
251
256
|
lines.push(`complete -c uploads -n '__fish_seen_subcommand_from put attach' -l ${flag.slice(2)}`);
|
|
252
257
|
}
|
|
258
|
+
for (const flag of SCREENSHOT_FLAGS) {
|
|
259
|
+
if (!flag.startsWith("--"))
|
|
260
|
+
continue;
|
|
261
|
+
lines.push(`complete -c uploads -n '__fish_seen_subcommand_from screenshot' -l ${flag.slice(2)}`);
|
|
262
|
+
}
|
|
253
263
|
for (const flag of LIST_LIKE_FLAGS) {
|
|
254
264
|
if (!flag.startsWith("--"))
|
|
255
265
|
continue;
|
|
256
266
|
lines.push(`complete -c uploads -n '__fish_seen_subcommand_from list find' -l ${flag.slice(2)}`);
|
|
257
267
|
}
|
|
258
|
-
// File completion for put/attach
|
|
259
|
-
lines.push(`complete -c uploads -n '__fish_seen_subcommand_from put attach' -F`);
|
|
268
|
+
// File completion for put/attach/screenshot
|
|
269
|
+
lines.push(`complete -c uploads -n '__fish_seen_subcommand_from put attach screenshot' -F`);
|
|
260
270
|
return lines.join("\n") + "\n";
|
|
261
271
|
}
|
|
262
272
|
export function generateCompletionScript(shell) {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type CliContext } from "../commands.js";
|
|
2
|
+
import { type CommandRunner } from "../github-gh.js";
|
|
3
|
+
import { captureScreenshot } from "../screenshot.js";
|
|
4
|
+
export declare function runScreenshot(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner,
|
|
5
|
+
/** Injectable for tests — avoids launching a real browser or hitting the network. */
|
|
6
|
+
captureImpl?: typeof captureScreenshot): Promise<number>;
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { flagBool, flagInt, flagString, flagValues, parseCommandArgs, UsageError, } from "../cli-args.js";
|
|
4
|
+
import { writeCommandHelp } from "../cli-style.js";
|
|
5
|
+
import { frameOptionsFromFlags, ghTargetFromFlags, optimizeOptionsFromFlags, syncAttachmentsComment, uploadPreparedImage, } from "../commands.js";
|
|
6
|
+
import { resolvePutDefaults } from "../config.js";
|
|
7
|
+
import { loadDefaultsRaw, resolveScreenshotDefaults } from "../config-file.js";
|
|
8
|
+
import { resolvePutPrefix } from "../destinations.js";
|
|
9
|
+
import { ghMetadataFromTarget } from "../github.js";
|
|
10
|
+
import { execRunner } from "../github-gh.js";
|
|
11
|
+
import { parseMetaFlags, validateMetaMap } from "../metadata.js";
|
|
12
|
+
import { writeJson, writeStdout } from "../io.js";
|
|
13
|
+
import { captureScreenshot, parseViewport, parseWaitUntil, } from "../screenshot.js";
|
|
14
|
+
const SCREENSHOT_HELP = `uploads screenshot <target> [options]
|
|
15
|
+
|
|
16
|
+
Capture a URL or a local .html file and host it — a hosted, PR-embeddable
|
|
17
|
+
image in one step. target is an http(s) URL or a path to an .html file.
|
|
18
|
+
|
|
19
|
+
Two capture backends: "local" drives an already-installed Chrome/Chromium via
|
|
20
|
+
playwright-core (no browser download); "remote" renders server-side via the
|
|
21
|
+
uploads.sh render endpoint (no local browser needed, counts against the
|
|
22
|
+
workspace's monthly upload budget). Default --via auto prefers local when a
|
|
23
|
+
browser is found, else remote.
|
|
24
|
+
|
|
25
|
+
.html files work on both backends (sent inline to remote, ≤ 2 MiB; anything
|
|
26
|
+
they reference via file:// or relative paths only resolves with --via local).
|
|
27
|
+
localhost/private-network URLs are reachable only by the
|
|
28
|
+
local backend — with --via remote (or auto falling back to remote) these
|
|
29
|
+
fail fast with a clear error instead of sending a doomed request.
|
|
30
|
+
|
|
31
|
+
After capture, screenshots share the put upload pipeline: optional --frame,
|
|
32
|
+
optimize-by-default, --pr/--issue attachment + --comment, --gallery, --meta.
|
|
33
|
+
|
|
34
|
+
Options:
|
|
35
|
+
--via auto|local|remote Capture backend (default: auto, or UPLOADS_SCREENSHOT_VIA)
|
|
36
|
+
--browser <path> Explicit local browser executable (or UPLOADS_CHROME_PATH / CHROME_PATH)
|
|
37
|
+
--cdp <endpoint> Attach to a running Chrome via CDP (http://host:port or ws://…)
|
|
38
|
+
--viewport <WxH[@Sx]> Size + device scale factor (default: 1280x800@2)
|
|
39
|
+
--selector <css> Capture one element instead of the viewport
|
|
40
|
+
--full-page Capture the full scrollable page
|
|
41
|
+
--dark / --light Emulate prefers-color-scheme (full media-query emulation on --via local
|
|
42
|
+
only; --via remote just sets the CSS color-scheme property, so a page's
|
|
43
|
+
own prefers-color-scheme queries won't flip)
|
|
44
|
+
--wait <load|domcontentloaded|networkidle|ms> Settle strategy (default: load); a millisecond
|
|
45
|
+
count is local-only — use --via local
|
|
46
|
+
--out <file> Also write the PNG to a local file
|
|
47
|
+
--no-upload Skip hosting; requires --out (local file only)
|
|
48
|
+
--destination <id> Typed root: screenshots | gh | f
|
|
49
|
+
--prefix <path> Key prefix (default: screenshots, or UPLOADS_DEFAULT_PREFIX)
|
|
50
|
+
--repo <owner/repo> Repo segment (default: git remote, or UPLOADS_DEFAULT_REPO)
|
|
51
|
+
--ref <id> PR/issue/branch segment (default: today, or UPLOADS_DEFAULT_REF)
|
|
52
|
+
--key <key> Explicit object key; cannot combine with --pr/--issue
|
|
53
|
+
--alt <text> Alt text (default: derived filename)
|
|
54
|
+
--width <px> <img width=…> markdown
|
|
55
|
+
--frame <id> Device/browser frame before optimize (phone|browser|iphone-16-pro)
|
|
56
|
+
--frame-url <url> Address bar text for --frame browser
|
|
57
|
+
--frame-fit cover|contain How the shot fills the screen (default: cover)
|
|
58
|
+
--no-optimize Skip client-side image optimization
|
|
59
|
+
--optimize-max-edge <px> Max long edge when optimizing (default: 2400)
|
|
60
|
+
--optimize-quality <1-100> WebP quality (default: 85)
|
|
61
|
+
--keep-exif Keep EXIF/XMP/ICC when optimizing
|
|
62
|
+
--pr <num> Attach to a pull request (stable URL, no hash)
|
|
63
|
+
--issue <num> Attach to an issue
|
|
64
|
+
--comment With --pr/--issue: update the managed attachments comment
|
|
65
|
+
--gallery <id> Add the uploaded object to this public gallery
|
|
66
|
+
--meta <k=v> Queryable custom metadata (repeatable)
|
|
67
|
+
--workspace, -w <name> Override workspace
|
|
68
|
+
--dry-run Capture + resolve key/URL without uploading
|
|
69
|
+
--format human|url|markdown|json
|
|
70
|
+
|
|
71
|
+
Exit codes: 0 ok · 2 usage/no browser found/file · 3 auth/policy/budget · 4 network · 1 other.
|
|
72
|
+
|
|
73
|
+
Examples:
|
|
74
|
+
uploads screenshot https://uploads.sh
|
|
75
|
+
uploads screenshot ./card.html --out ./card.png
|
|
76
|
+
uploads screenshot https://app.example/settings --selector main --dark
|
|
77
|
+
uploads screenshot http://localhost:3000 --via local --full-page
|
|
78
|
+
uploads screenshot https://uploads.sh --pr 128 --comment
|
|
79
|
+
uploads screenshot ./card.html --no-upload --out ./card.png
|
|
80
|
+
`;
|
|
81
|
+
function colorSchemeFromFlags(flags) {
|
|
82
|
+
const dark = flagBool(flags, "--dark");
|
|
83
|
+
const light = flagBool(flags, "--light");
|
|
84
|
+
if (dark && light)
|
|
85
|
+
throw new UsageError("--dark and --light are mutually exclusive");
|
|
86
|
+
if (dark)
|
|
87
|
+
return "dark";
|
|
88
|
+
if (light)
|
|
89
|
+
return "light";
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
function viaFromFlags(flags, fallback) {
|
|
93
|
+
const raw = flagString(flags, "--via");
|
|
94
|
+
if (!raw)
|
|
95
|
+
return fallback;
|
|
96
|
+
if (raw === "auto" || raw === "local" || raw === "remote")
|
|
97
|
+
return raw;
|
|
98
|
+
throw new UsageError(`invalid --via: ${raw} (use auto, local, or remote)`);
|
|
99
|
+
}
|
|
100
|
+
export async function runScreenshot(ctx, args, help = false, run = execRunner,
|
|
101
|
+
/** Injectable for tests — avoids launching a real browser or hitting the network. */
|
|
102
|
+
captureImpl = captureScreenshot) {
|
|
103
|
+
if (help) {
|
|
104
|
+
writeCommandHelp(SCREENSHOT_HELP);
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
const parsed = parseCommandArgs(args);
|
|
108
|
+
if (parsed.help) {
|
|
109
|
+
writeCommandHelp(SCREENSHOT_HELP);
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
const target = parsed.positionals[0];
|
|
113
|
+
if (!target) {
|
|
114
|
+
writeCommandHelp(SCREENSHOT_HELP);
|
|
115
|
+
return 2;
|
|
116
|
+
}
|
|
117
|
+
if (parsed.positionals.length > 1) {
|
|
118
|
+
throw new UsageError("screenshot takes exactly one target");
|
|
119
|
+
}
|
|
120
|
+
// Read the on-disk config once and share it between the screenshot and
|
|
121
|
+
// put-style default resolvers (both would otherwise read the same file).
|
|
122
|
+
const rawDefaults = loadDefaultsRaw({ envFile: ctx.envFile });
|
|
123
|
+
const screenshotDefaults = resolveScreenshotDefaults({ envFile: ctx.envFile }, rawDefaults);
|
|
124
|
+
const via = viaFromFlags(parsed.flags, screenshotDefaults.via ?? "auto");
|
|
125
|
+
const browserPath = flagString(parsed.flags, "--browser");
|
|
126
|
+
const cdp = flagString(parsed.flags, "--cdp");
|
|
127
|
+
const viewport = parseViewport(flagString(parsed.flags, "--viewport"));
|
|
128
|
+
const selector = flagString(parsed.flags, "--selector");
|
|
129
|
+
const fullPage = flagBool(parsed.flags, "--full-page");
|
|
130
|
+
const colorScheme = colorSchemeFromFlags(parsed.flags);
|
|
131
|
+
const waitUntil = parseWaitUntil(flagString(parsed.flags, "--wait"));
|
|
132
|
+
const outFile = flagString(parsed.flags, "--out");
|
|
133
|
+
const noUpload = flagBool(parsed.flags, "--no-upload");
|
|
134
|
+
if (noUpload && !outFile)
|
|
135
|
+
throw new UsageError("--no-upload requires --out");
|
|
136
|
+
const keyHint = flagString(parsed.flags, "--key");
|
|
137
|
+
const destFlag = flagString(parsed.flags, "--destination");
|
|
138
|
+
const prefixFlag = flagString(parsed.flags, "--prefix");
|
|
139
|
+
const ghTarget = ghTargetFromFlags(parsed.flags, run);
|
|
140
|
+
const wantComment = parsed.flags.has("--comment");
|
|
141
|
+
const galleryId = flagString(parsed.flags, "--gallery");
|
|
142
|
+
const dryRun = flagBool(parsed.flags, "--dry-run");
|
|
143
|
+
if (wantComment && !ghTarget)
|
|
144
|
+
throw new UsageError("--comment requires --pr or --issue");
|
|
145
|
+
if (ghTarget) {
|
|
146
|
+
if (keyHint)
|
|
147
|
+
throw new UsageError("--key cannot be combined with --pr/--issue");
|
|
148
|
+
if (flagString(parsed.flags, "--ref"))
|
|
149
|
+
throw new UsageError("--ref cannot be combined with --pr/--issue");
|
|
150
|
+
if (prefixFlag)
|
|
151
|
+
throw new UsageError("--prefix cannot be combined with --pr/--issue");
|
|
152
|
+
}
|
|
153
|
+
if (dryRun) {
|
|
154
|
+
if (wantComment)
|
|
155
|
+
throw new UsageError("--dry-run cannot be combined with --comment");
|
|
156
|
+
if (galleryId)
|
|
157
|
+
throw new UsageError("--dry-run cannot be combined with --gallery");
|
|
158
|
+
if (noUpload)
|
|
159
|
+
throw new UsageError("--dry-run cannot be combined with --no-upload");
|
|
160
|
+
}
|
|
161
|
+
let resolvedPrefix;
|
|
162
|
+
try {
|
|
163
|
+
resolvedPrefix = resolvePutPrefix({
|
|
164
|
+
destination: destFlag,
|
|
165
|
+
prefix: prefixFlag,
|
|
166
|
+
key: keyHint,
|
|
167
|
+
ghAttachment: Boolean(ghTarget),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
throw new UsageError(err instanceof Error ? err.message : String(err));
|
|
172
|
+
}
|
|
173
|
+
const format = ctx.json
|
|
174
|
+
? "json"
|
|
175
|
+
: (() => {
|
|
176
|
+
const raw = flagString(parsed.flags, "--format");
|
|
177
|
+
if (!raw || raw === "human")
|
|
178
|
+
return "human";
|
|
179
|
+
if (raw === "url" || raw === "markdown" || raw === "json")
|
|
180
|
+
return raw;
|
|
181
|
+
throw new UsageError(`invalid --format: ${raw}`);
|
|
182
|
+
})();
|
|
183
|
+
const putDefaults = resolvePutDefaults({ envFile: ctx.envFile }, rawDefaults);
|
|
184
|
+
const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, putDefaults);
|
|
185
|
+
const frameOpts = frameOptionsFromFlags(parsed.flags);
|
|
186
|
+
const altFlag = flagString(parsed.flags, "--alt");
|
|
187
|
+
const width = flagInt(parsed.flags, "--width", "--width") ?? putDefaults.width;
|
|
188
|
+
const metaExtras = parseMetaFlags(flagValues(parsed.flags, "--meta"));
|
|
189
|
+
let metadata = metaExtras;
|
|
190
|
+
if (ghTarget) {
|
|
191
|
+
metadata = { ...metaExtras, ...ghMetadataFromTarget(ghTarget) };
|
|
192
|
+
validateMetaMap(metadata);
|
|
193
|
+
}
|
|
194
|
+
else if (Object.keys(metaExtras).length > 0) {
|
|
195
|
+
validateMetaMap(metaExtras);
|
|
196
|
+
}
|
|
197
|
+
const logHuman = !ctx.quiet && format === "human";
|
|
198
|
+
if (logHuman)
|
|
199
|
+
process.stderr.write(`>> capturing ${target}\n`);
|
|
200
|
+
const captured = await captureImpl({
|
|
201
|
+
target,
|
|
202
|
+
via,
|
|
203
|
+
browserPath,
|
|
204
|
+
cdp,
|
|
205
|
+
viewport,
|
|
206
|
+
selector,
|
|
207
|
+
fullPage,
|
|
208
|
+
colorScheme,
|
|
209
|
+
waitUntil,
|
|
210
|
+
apiUrl: ctx.config.apiUrl,
|
|
211
|
+
token: ctx.config.token,
|
|
212
|
+
});
|
|
213
|
+
if (logHuman)
|
|
214
|
+
process.stderr.write(`>> captured via ${captured.backend} backend\n`);
|
|
215
|
+
if (outFile) {
|
|
216
|
+
writeFileSync(outFile, captured.png);
|
|
217
|
+
if (logHuman)
|
|
218
|
+
process.stderr.write(`>> wrote ${outFile}\n`);
|
|
219
|
+
}
|
|
220
|
+
if (noUpload) {
|
|
221
|
+
if (ctx.json) {
|
|
222
|
+
await writeJson({ file: outFile, backend: captured.backend, size: captured.png.byteLength });
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
await writeStdout(`FILE: ${outFile}\n`);
|
|
226
|
+
}
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
229
|
+
const repo = flagString(parsed.flags, "--repo") ?? putDefaults.repo;
|
|
230
|
+
const ref = flagString(parsed.flags, "--ref") ?? putDefaults.ref;
|
|
231
|
+
const alt = altFlag ?? basename(captured.filename);
|
|
232
|
+
const { result, prepared, markdown } = await uploadPreparedImage(ctx.client, captured.png, captured.filename, {
|
|
233
|
+
frame: frameOpts,
|
|
234
|
+
optimize: optimizeOpts,
|
|
235
|
+
ghTarget,
|
|
236
|
+
key: keyHint,
|
|
237
|
+
prefix: resolvedPrefix ?? putDefaults.prefix,
|
|
238
|
+
repo,
|
|
239
|
+
ref,
|
|
240
|
+
deriveRepoFromGit: !(flagBool(parsed.flags, "--no-git") || putDefaults.noGit === true),
|
|
241
|
+
dryRun,
|
|
242
|
+
metadata,
|
|
243
|
+
provenanceClient: "uploads-cli-screenshot",
|
|
244
|
+
alt: () => alt,
|
|
245
|
+
width,
|
|
246
|
+
});
|
|
247
|
+
let gallery;
|
|
248
|
+
if (galleryId) {
|
|
249
|
+
try {
|
|
250
|
+
const current = await ctx.client.getGallery(galleryId);
|
|
251
|
+
const item = await ctx.client.addGalleryItem(galleryId, result.key, {
|
|
252
|
+
expectedVersion: current.version,
|
|
253
|
+
altText: alt,
|
|
254
|
+
});
|
|
255
|
+
gallery = { id: galleryId, url: current.url };
|
|
256
|
+
void item;
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
gallery = { id: galleryId, error: err instanceof Error ? err.message : String(err) };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
let comment;
|
|
263
|
+
let commentError;
|
|
264
|
+
if (wantComment && ghTarget) {
|
|
265
|
+
try {
|
|
266
|
+
comment = await syncAttachmentsComment(ctx.client, ghTarget, run);
|
|
267
|
+
if (logHuman)
|
|
268
|
+
process.stderr.write(`>> attachments comment ${comment.action}\n`);
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
commentError = err instanceof Error ? err.message : String(err);
|
|
272
|
+
process.stderr.write(`warning: upload succeeded but the GitHub comment failed (is gh installed and authenticated?): ${commentError}\n`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (logHuman) {
|
|
276
|
+
if (prepared.frame?.framed)
|
|
277
|
+
process.stderr.write(`>> framed with ${prepared.frame.frameId}\n`);
|
|
278
|
+
if (prepared.optimized) {
|
|
279
|
+
process.stderr.write(`>> optimized ${prepared.originalBytes} → ${prepared.outputBytes} bytes\n`);
|
|
280
|
+
}
|
|
281
|
+
process.stderr.write(`>> key: ${result.key}${dryRun ? " (dry run — not uploaded)" : ""}\n\n`);
|
|
282
|
+
}
|
|
283
|
+
switch (format) {
|
|
284
|
+
case "json":
|
|
285
|
+
await writeJson({
|
|
286
|
+
workspace: result.workspace,
|
|
287
|
+
key: result.key,
|
|
288
|
+
url: result.url,
|
|
289
|
+
embedUrl: result.embedUrl,
|
|
290
|
+
size: result.size,
|
|
291
|
+
contentType: result.contentType,
|
|
292
|
+
replaced: result.replaced,
|
|
293
|
+
markdown,
|
|
294
|
+
backend: captured.backend,
|
|
295
|
+
gallery,
|
|
296
|
+
...(dryRun ? { dryRun: true } : {}),
|
|
297
|
+
});
|
|
298
|
+
break;
|
|
299
|
+
case "url":
|
|
300
|
+
await writeStdout(`${result.url}\n`);
|
|
301
|
+
break;
|
|
302
|
+
case "markdown":
|
|
303
|
+
await writeStdout(`${markdown}\n`);
|
|
304
|
+
break;
|
|
305
|
+
default: {
|
|
306
|
+
const embedLine = result.embedUrl ? `EMBED: ${result.embedUrl}\n` : "";
|
|
307
|
+
await writeStdout(`URL: ${result.url}\n${embedLine}MARKDOWN: ${markdown}${gallery?.url ? `\nGALLERY: ${gallery.url}` : ""}\n`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (gallery?.error) {
|
|
311
|
+
process.stderr.write(`warning: upload succeeded but adding it to gallery ${gallery.id} failed: ${gallery.error}\n`);
|
|
312
|
+
}
|
|
313
|
+
if (commentError && ctx.json) {
|
|
314
|
+
// already reported to stderr above; json output stays upload-focused.
|
|
315
|
+
}
|
|
316
|
+
return gallery?.error ? 1 : 0;
|
|
317
|
+
}
|