@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 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
@@ -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"];
@@ -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 : "request failed",
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: "request failed" };
234
+ return { message: fallback };
233
235
  }
234
- async function parseErrorResponse(res) {
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
- const { message, code } = extractErrorFields(body);
237
- return mapApiError(res.status, message || res.statusText || "request failed", code);
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
+ }