@buildinternet/uploads 0.2.0 → 0.3.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/README.md CHANGED
@@ -4,9 +4,7 @@ CLI and client for **uploads.sh** — upload files, get public URLs, and produce
4
4
 
5
5
  ## CLI
6
6
 
7
- Binary: `uploads` (also `pnpm uploads` from repo root after `pnpm install`).
8
-
9
- Install globally or run a pinned version without installing:
7
+ Binary: **`uploads`**. Install globally (or use a pinned `npx` one-shot):
10
8
 
11
9
  ```bash
12
10
  npm install --global @buildinternet/uploads
@@ -14,13 +12,21 @@ npx @buildinternet/uploads@0.1.0 --help
14
12
  ```
15
13
 
16
14
  ```bash
17
- pnpm uploads setup --env-file .env
18
- pnpm uploads attach ./before.png ./after.png --env-file .env
19
- pnpm uploads put ./shot.png --env-file .env
20
- pnpm uploads put ./after.png --pr 123 --comment --env-file .env
21
- pnpm uploads doctor --env-file .env
15
+ uploads setup
16
+ uploads attach ./before.png ./after.png
17
+ uploads put ./shot.png
18
+ uploads put ./shot.png --destination screenshots
19
+ uploads put ./shot.png --no-optimize
20
+ uploads put ./mobile.png --frame phone
21
+ uploads put ./ui.png --frame browser --frame-url "https://app.example"
22
+ uploads put ./after.png --pr 123 --comment
23
+ uploads doctor
22
24
  ```
23
25
 
26
+ Inside this monorepo only, `pnpm uploads …` builds the package first so you pick
27
+ up local source; product docs and PR “how to try it” examples should use the
28
+ global `uploads` form above.
29
+
24
30
  Commands: `attach`, `put`, `comment`, `list`, `delete`, `usage`, `reconcile`,
25
31
  `purge-expired`, `setup`, `install`, `config`, `doctor`, `health`, `mcp`.
26
32
 
@@ -29,6 +35,21 @@ infers the pull request for the current branch via `gh`, uploads stable URLs, an
29
35
  or updates one managed attachments comment. Use `--pr`, `--issue`, and `--repo` to select
30
36
  the target explicitly, or `--no-comment` to upload without changing GitHub comments.
31
37
 
38
+ **Keys / destinations:** default put uses the `screenshots` layout. Typed destinations
39
+ (`--destination screenshots|gh|f`, MCP `destination`) set the root; `--pr`/`--issue`
40
+ use `gh/…`. Workspaces may restrict put/sign to those roots via
41
+ `allowedKeyPrefixes` (see [workspaces](../../docs/workspaces.md)).
42
+
43
+ **Image optimization:** by default, still images are re-encoded to WebP (long edge
44
+ capped, high quality) before upload so GitHub embeds stay small, and **EXIF is
45
+ stripped**. Pass `--keep-exif` / `UPLOADS_KEEP_EXIF=1` to preserve image metadata, or
46
+ `--no-optimize` / `UPLOADS_NO_OPTIMIZE=1` to upload originals unchanged.
47
+
48
+ **Frames (opt-in):** `--frame phone|browser|iphone-16-pro` composites chrome
49
+ **before** optimize. `phone`/`browser` are procedural; `iphone-16-pro` fetches
50
+ community art from [device-frames-media](https://github.com/jonnyjackson26/device-frames-media)
51
+ into `~/.cache/uploads/frames` (not bundled).
52
+
32
53
  Config layers (first match wins): CLI flags → env vars → `--env-file` → `~/.config/buildinternet/config`. See `config.example` for keys.
33
54
 
34
55
  ## MCP server
package/dist/cli.js CHANGED
@@ -94,6 +94,7 @@ function exitCode(err) {
94
94
  return 2;
95
95
  case "UNAUTHORIZED":
96
96
  case "NOT_FOUND":
97
+ case "KEY_POLICY":
97
98
  case "STORAGE_QUOTA":
98
99
  case "UPLOAD_BUDGET":
99
100
  return 3;
@@ -119,6 +120,17 @@ function errorOut(err, json) {
119
120
  process.stderr.write(`${msg}\n`);
120
121
  else
121
122
  process.stderr.write(`error: ${msg}\n`);
123
+ if (err instanceof UploadsError) {
124
+ if (err.code === "STORAGE_QUOTA" || err.code === "UPLOAD_BUDGET") {
125
+ process.stderr.write("hint: run `uploads usage` then delete objects or raise limits (`pnpm workspace:limits`)\n");
126
+ }
127
+ else if (err.code === "KEY_POLICY") {
128
+ process.stderr.write("hint: use a typed destination (`--destination screenshots|gh`) or an allowed prefix; operators set allowlists with `pnpm workspace:limits --allowed-prefixes`\n");
129
+ }
130
+ else if (err.status === 413 || err.message.toLowerCase().includes("too large")) {
131
+ process.stderr.write("hint: file exceeds workspace size policy (images vs video may differ); compress or raise --max-upload-bytes / --max-video-bytes\n");
132
+ }
133
+ }
122
134
  }
123
135
  }
124
136
  export async function runCli(argv) {
package/dist/client.js CHANGED
@@ -47,6 +47,9 @@ function mapApiError(status, error, code) {
47
47
  if (status === 400 && normalized === "invalid key") {
48
48
  return new UploadsError(error, "INVALID_KEY", status);
49
49
  }
50
+ if (code === "key_prefix_not_allowed" || code === "key_too_deep") {
51
+ return new UploadsError(error, "KEY_POLICY", status);
52
+ }
50
53
  // Prefer stable body.code — bare 429 is also used for write rate limits.
51
54
  if (status === 507 || code === "storage_quota_exceeded") {
52
55
  return new UploadsError(error, "STORAGE_QUOTA", status);
@@ -1,7 +1,11 @@
1
1
  import { type UploadsClient } from "./client.js";
2
+ import { type CommandFlags } from "./cli-args.js";
2
3
  import { type ResolvedConfig } from "./config.js";
3
4
  import { type GhTarget } from "./github.js";
4
5
  import { type CommandRunner } from "./github-gh.js";
6
+ import { type OptimizeImageOptions, type OptimizeImageResult } from "./optimize.js";
7
+ import { type FrameResult } from "./frame.js";
8
+ import type { PutDefaults } from "./config-file.js";
5
9
  export interface CliContext {
6
10
  config: ResolvedConfig;
7
11
  client: UploadsClient;
@@ -14,6 +18,18 @@ export interface CliContext {
14
18
  * neither is present. Shared by the CLI flags and the MCP tool arguments.
15
19
  */
16
20
  export declare function makeGhTarget(pr: number | undefined, issue: number | undefined, repoArg: string | undefined, run: CommandRunner): GhTarget | undefined;
21
+ /** Shared put/attach optimize flags + UPLOADS_NO_OPTIMIZE default. */
22
+ export declare function optimizeOptionsFromFlags(flags: CommandFlags["flags"], defaults: PutDefaults): OptimizeImageOptions;
23
+ export type PreparedUpload = OptimizeImageResult & {
24
+ frame?: Pick<FrameResult, "framed" | "frameId" | "skippedReason">;
25
+ };
26
+ /** Frame (optional) then optimize — shared by put/attach/MCP. */
27
+ export declare function prepareImageForUpload(bytes: Uint8Array, filename: string, opts: {
28
+ frameId?: string;
29
+ frameUrl?: string;
30
+ frameFit?: "cover" | "contain";
31
+ optimize: OptimizeImageOptions;
32
+ }): Promise<PreparedUpload>;
17
33
  /**
18
34
  * List every attachment under the target's prefix and create/update the
19
35
  * managed comment. Throws on gh failure — callers decide whether that is
package/dist/commands.js CHANGED
@@ -8,19 +8,38 @@ import { UploadsError } from "./errors.js";
8
8
  import { writeJson, writeStdout } from "./io.js";
9
9
  import { ghAttachmentKey, ghKeyPrefix, attachmentsCommentBody, } from "./github.js";
10
10
  import { resolveRepo, resolveCurrentPullRequest, execRunner, upsertAttachmentsComment, } from "./github-gh.js";
11
+ import { resolvePutPrefix } from "./destinations.js";
12
+ import { optimizeImageForUpload, rewriteKeyExtension, } from "./optimize.js";
13
+ import { applyFrame, resolveFrameId } from "./frame.js";
11
14
  // --- put ---
12
15
  const PUT_HELP = `uploads put <file> [options]
13
16
 
14
17
  Upload an image for GitHub embeds. Use "-" for stdin.
15
18
 
19
+ Still images (PNG/JPEG/…) are optimized to WebP by default (long edge capped,
20
+ high quality; EXIF stripped) so GitHub embeds stay lean. Original bytes are kept
21
+ when they are already smaller, animated, or not an image. Use --no-optimize to
22
+ upload as-is, or --keep-exif when image metadata matters for the discussion.
23
+
24
+ Optional --frame wraps the image in a device/browser chrome before optimize
25
+ (default off). See: uploads put --help frames
26
+
16
27
  Options:
17
28
  --key <key> Object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.<ext>)
29
+ --destination <id> Typed root: screenshots | gh | f (sets --prefix)
18
30
  --prefix <path> Key prefix (default: screenshots, or UPLOADS_DEFAULT_PREFIX)
19
31
  --repo <owner/repo> Repo segment (default: git remote, or UPLOADS_DEFAULT_REPO)
20
32
  --ref <id> PR/issue/branch segment (default: today, or UPLOADS_DEFAULT_REF)
21
33
  --alt <text> Alt text (default: filename)
22
34
  --width <px> <img width=…> markdown (or UPLOADS_DEFAULT_WIDTH)
23
- --content-type <mime> Override Content-Type
35
+ --content-type <mime> Override Content-Type (ignored when optimize rewrites the body)
36
+ --frame <id> Device/browser frame before optimize (phone|browser|iphone-16-pro)
37
+ --frame-url <url> Address bar text for --frame browser
38
+ --frame-fit cover|contain How the shot fills the screen (default: cover)
39
+ --no-optimize Skip client-side image optimization (or UPLOADS_NO_OPTIMIZE=1)
40
+ --optimize-max-edge <px> Max long edge when optimizing (default: 2400)
41
+ --optimize-quality <1-100> WebP quality (default: 85)
42
+ --keep-exif Keep EXIF/XMP/ICC when optimizing (default: strip for privacy)
24
43
  --no-git Don't derive --repo from git (or UPLOADS_NO_GIT=1)
25
44
  --workspace, -w <name> Override workspace (wins over UPLOADS_WORKSPACE and token inference)
26
45
  --format human|url|markdown|json
@@ -30,8 +49,9 @@ Options:
30
49
 
31
50
  Examples:
32
51
  uploads put ./shot.png --repo myorg/myapp --ref 1722 --alt "New cards" --width 700
33
- uploads --env-file .env put ./shot.png
34
- uploads --env-file .env put ./after.png --pr 123 --comment
52
+ uploads put ./mobile.png --frame phone
53
+ uploads put ./ui.png --frame browser --frame-url "https://app.example/settings"
54
+ uploads put ./shot.png --destination screenshots
35
55
  `;
36
56
  /**
37
57
  * Turns a pr/issue pair (+ optional repo) into a GhTarget; undefined when
@@ -50,6 +70,82 @@ export function makeGhTarget(pr, issue, repoArg, run) {
50
70
  function ghTargetFromFlags(flags, run) {
51
71
  return makeGhTarget(flagInt(flags, "--pr", "--pr"), flagInt(flags, "--issue", "--issue"), flagString(flags, "--repo"), run);
52
72
  }
73
+ /** Shared put/attach optimize flags + UPLOADS_NO_OPTIMIZE default. */
74
+ export function optimizeOptionsFromFlags(flags, defaults) {
75
+ if (flags.has("--no-optimize") && typeof flags.get("--no-optimize") === "string") {
76
+ throw new UsageError("--no-optimize takes no value");
77
+ }
78
+ if (flags.has("--keep-exif") && typeof flags.get("--keep-exif") === "string") {
79
+ throw new UsageError("--keep-exif takes no value");
80
+ }
81
+ const quality = flagInt(flags, "--optimize-quality", "--optimize-quality");
82
+ if (quality !== undefined && quality > 100) {
83
+ throw new UsageError("invalid --optimize-quality: must be 1–100");
84
+ }
85
+ return {
86
+ enabled: !(flagBool(flags, "--no-optimize") || defaults.noOptimize === true),
87
+ maxEdge: flagInt(flags, "--optimize-max-edge", "--optimize-max-edge"),
88
+ quality,
89
+ keepExif: flagBool(flags, "--keep-exif") || defaults.keepExif === true,
90
+ };
91
+ }
92
+ function formatOptimizeNote(opt) {
93
+ if (opt.optimized) {
94
+ return `optimized ${opt.originalBytes} → ${opt.outputBytes} bytes (${opt.filename})`;
95
+ }
96
+ if (opt.skippedReason && opt.skippedReason !== "disabled") {
97
+ return `optimize skipped (${opt.skippedReason})`;
98
+ }
99
+ return undefined;
100
+ }
101
+ /** Frame (optional) then optimize — shared by put/attach/MCP. */
102
+ export async function prepareImageForUpload(bytes, filename, opts) {
103
+ let currentBytes = bytes;
104
+ let currentName = filename;
105
+ let frameMeta;
106
+ if (opts.frameId) {
107
+ const framed = await applyFrame(currentBytes, currentName, {
108
+ id: opts.frameId,
109
+ browserUrl: opts.frameUrl,
110
+ fit: opts.frameFit,
111
+ });
112
+ frameMeta = {
113
+ framed: framed.framed,
114
+ frameId: framed.frameId,
115
+ skippedReason: framed.skippedReason,
116
+ };
117
+ if (framed.framed) {
118
+ currentBytes = framed.bytes;
119
+ currentName = framed.filename;
120
+ }
121
+ }
122
+ const optimized = await optimizeImageForUpload(currentBytes, currentName, opts.optimize);
123
+ return { ...optimized, frame: frameMeta };
124
+ }
125
+ function frameOptionsFromFlags(flags) {
126
+ const raw = flagString(flags, "--frame");
127
+ let frameId;
128
+ try {
129
+ frameId = resolveFrameId(raw);
130
+ }
131
+ catch (err) {
132
+ throw new UsageError(err instanceof Error ? err.message : String(err));
133
+ }
134
+ const fitRaw = flagString(flags, "--frame-fit");
135
+ let frameFit;
136
+ if (fitRaw) {
137
+ if (fitRaw !== "cover" && fitRaw !== "contain") {
138
+ throw new UsageError(`invalid --frame-fit: ${fitRaw} (use cover or contain)`);
139
+ }
140
+ frameFit = fitRaw;
141
+ }
142
+ if (frameFit && !frameId)
143
+ throw new UsageError("--frame-fit requires --frame");
144
+ const frameUrl = flagString(flags, "--frame-url");
145
+ if (frameUrl && !frameId)
146
+ throw new UsageError("--frame-url requires --frame");
147
+ return { frameId, frameUrl, frameFit };
148
+ }
53
149
  /**
54
150
  * List every attachment under the target's prefix and create/update the
55
151
  * managed comment. Throws on gh failure — callers decide whether that is
@@ -69,16 +165,27 @@ const ATTACH_HELP = `uploads attach <file...> [options]
69
165
  Upload one or more stable PR/issue attachments and maintain a single GitHub
70
166
  comment. With no target, uses the pull request for the current branch.
71
167
 
168
+ Still images are optimized to WebP by default (same as put). Use --no-optimize
169
+ to upload originals. Optional --frame wraps images in device/browser chrome.
170
+
72
171
  Options:
73
172
  --pr <num> Attach to this pull request
74
173
  --issue <num> Attach to this issue
75
174
  --repo <owner/repo> Repository (default: gh/git inference)
76
175
  --no-comment Upload only; don't create/update the managed comment
77
- --content-type <mime> Override Content-Type (applied to every file)
176
+ --content-type <mime> Override Content-Type (applied to every file; ignored when optimize rewrites)
177
+ --frame <id> Device/browser frame before optimize (phone|browser|iphone-16-pro)
178
+ --frame-url <url> Address bar text for --frame browser
179
+ --frame-fit cover|contain How the shot fills the screen (default: cover)
180
+ --no-optimize Skip client-side image optimization (or UPLOADS_NO_OPTIMIZE=1)
181
+ --optimize-max-edge <px> Max long edge when optimizing (default: 2400)
182
+ --optimize-quality <1-100> WebP quality (default: 85)
183
+ --keep-exif Keep EXIF/XMP/ICC when optimizing (default: strip for privacy)
78
184
  --workspace, -w <name> Override workspace
79
185
 
80
186
  Examples:
81
187
  uploads attach ./before.png ./after.png
188
+ uploads attach ./mobile.png --frame phone
82
189
  uploads attach ./shot.png --pr 123 --repo myorg/myapp
83
190
  uploads attach ./artifact.zip --issue 45 --no-comment
84
191
  `;
@@ -98,19 +205,44 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
98
205
  const explicitTarget = ghTargetFromFlags(parsed.flags, run);
99
206
  const target = explicitTarget ??
100
207
  resolveCurrentPullRequest(resolveRepo(flagString(parsed.flags, "--repo"), run), run);
208
+ const defaults = resolvePutDefaults({ envFile: ctx.envFile });
209
+ const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, defaults);
210
+ const frameOpts = frameOptionsFromFlags(parsed.flags);
211
+ const contentTypeOverride = flagString(parsed.flags, "--content-type");
101
212
  const results = [];
102
213
  for (const file of parsed.positionals) {
103
214
  if (file === "-")
104
215
  throw new UsageError("attach does not support stdin; pass one or more file paths");
105
- const filename = basename(file);
216
+ const sourceName = basename(file);
106
217
  if (!ctx.quiet && !ctx.json)
107
218
  process.stderr.write(`>> uploading ${file}\n`);
108
- const result = await ctx.client.put(new Uint8Array(readFileSync(file)), {
109
- filename,
110
- key: ghAttachmentKey(target, filename),
111
- contentType: flagString(parsed.flags, "--content-type"),
219
+ const prepared = await prepareImageForUpload(new Uint8Array(readFileSync(file)), sourceName, {
220
+ ...frameOpts,
221
+ optimize: optimizeOpts,
222
+ });
223
+ if (prepared.frame?.framed && !ctx.quiet && !ctx.json) {
224
+ process.stderr.write(`>> framed with ${prepared.frame.frameId}\n`);
225
+ }
226
+ const note = formatOptimizeNote(prepared);
227
+ if (note && !ctx.quiet && !ctx.json)
228
+ process.stderr.write(`>> ${note}\n`);
229
+ const result = await ctx.client.put(prepared.bytes, {
230
+ filename: prepared.filename,
231
+ key: ghAttachmentKey(target, prepared.filename),
232
+ contentType: prepared.optimized ? prepared.contentType : contentTypeOverride,
233
+ });
234
+ results.push({
235
+ ...result,
236
+ markdown: buildMarkdown(result.url, { alt: sourceName }),
237
+ optimize: {
238
+ optimized: prepared.optimized,
239
+ skippedReason: prepared.skippedReason,
240
+ originalBytes: prepared.originalBytes,
241
+ outputBytes: prepared.outputBytes,
242
+ filename: prepared.filename,
243
+ },
244
+ frame: prepared.frame,
112
245
  });
113
- results.push({ ...result, markdown: buildMarkdown(result.url, { alt: filename }) });
114
246
  }
115
247
  let comment;
116
248
  let commentError;
@@ -151,6 +283,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
151
283
  return 2;
152
284
  }
153
285
  const keyHint = flagString(parsed.flags, "--key");
286
+ const destFlag = flagString(parsed.flags, "--destination");
287
+ const prefixFlag = flagString(parsed.flags, "--prefix");
154
288
  const ghTarget = ghTargetFromFlags(parsed.flags, run);
155
289
  const wantComment = parsed.flags.has("--comment");
156
290
  if (wantComment && typeof parsed.flags.get("--comment") === "string") {
@@ -164,12 +298,23 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
164
298
  if (flagString(parsed.flags, "--ref")) {
165
299
  throw new UsageError("--ref cannot be combined with --pr/--issue");
166
300
  }
167
- if (flagString(parsed.flags, "--prefix")) {
301
+ if (prefixFlag)
168
302
  throw new UsageError("--prefix cannot be combined with --pr/--issue");
169
- }
303
+ }
304
+ let resolvedPrefix;
305
+ try {
306
+ resolvedPrefix = resolvePutPrefix({
307
+ destination: destFlag,
308
+ prefix: prefixFlag,
309
+ key: keyHint,
310
+ ghAttachment: Boolean(ghTarget),
311
+ });
312
+ }
313
+ catch (err) {
314
+ throw new UsageError(err instanceof Error ? err.message : String(err));
170
315
  }
171
316
  const bytes = fileArg === "-" ? new Uint8Array(readFileSync(0)) : new Uint8Array(readFileSync(fileArg));
172
- const filename = fileArg === "-" ? (keyHint ? basename(keyHint) : "stdin.bin") : basename(fileArg);
317
+ const sourceName = fileArg === "-" ? (keyHint ? basename(keyHint) : "stdin.bin") : basename(fileArg);
173
318
  const format = ctx.json
174
319
  ? "json"
175
320
  : (() => {
@@ -181,7 +326,15 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
181
326
  throw new UsageError(`invalid --format: ${raw}`);
182
327
  })();
183
328
  const defaults = resolvePutDefaults({ envFile: ctx.envFile });
184
- const alt = flagString(parsed.flags, "--alt") ?? basename(filename);
329
+ const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, defaults);
330
+ const frameOpts = frameOptionsFromFlags(parsed.flags);
331
+ const prepared = await prepareImageForUpload(bytes, sourceName, {
332
+ ...frameOpts,
333
+ optimize: optimizeOpts,
334
+ });
335
+ const filename = prepared.filename;
336
+ const contentTypeOverride = flagString(parsed.flags, "--content-type");
337
+ const alt = flagString(parsed.flags, "--alt") ?? basename(sourceName);
185
338
  const widthRaw = flagString(parsed.flags, "--width");
186
339
  const width = widthRaw && /^\d+$/.test(widthRaw) && Number(widthRaw) > 0
187
340
  ? Number.parseInt(widthRaw, 10)
@@ -192,24 +345,39 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
192
345
  : defaults.width;
193
346
  if (!ctx.quiet && format === "human") {
194
347
  process.stderr.write(`>> uploading ${fileArg === "-" ? "stdin" : fileArg}\n`);
348
+ if (prepared.frame?.framed)
349
+ process.stderr.write(`>> framed with ${prepared.frame.frameId}\n`);
350
+ const note = formatOptimizeNote(prepared);
351
+ if (note)
352
+ process.stderr.write(`>> ${note}\n`);
195
353
  }
196
354
  const noGit = flagBool(parsed.flags, "--no-git") || defaults.noGit === true;
197
- const result = await ctx.client.put(bytes, {
355
+ let key = ghTarget ? ghAttachmentKey(ghTarget, filename) : keyHint;
356
+ if (key && prepared.optimized)
357
+ key = rewriteKeyExtension(key, filename);
358
+ const result = await ctx.client.put(prepared.bytes, {
198
359
  filename,
199
- key: ghTarget ? ghAttachmentKey(ghTarget, filename) : keyHint,
200
- prefix: flagString(parsed.flags, "--prefix") ?? defaults.prefix,
360
+ key,
361
+ prefix: resolvedPrefix ?? defaults.prefix,
201
362
  repo: flagString(parsed.flags, "--repo") ?? defaults.repo,
202
363
  ref: flagString(parsed.flags, "--ref") ?? defaults.ref,
203
- contentType: flagString(parsed.flags, "--content-type"),
364
+ contentType: prepared.optimized ? prepared.contentType : contentTypeOverride,
204
365
  deriveRepoFromGit: !noGit,
205
366
  });
206
367
  const markdown = buildMarkdown(result.url, { alt, width });
368
+ const optimizeMeta = {
369
+ optimized: prepared.optimized,
370
+ skippedReason: prepared.skippedReason,
371
+ originalBytes: prepared.originalBytes,
372
+ outputBytes: prepared.outputBytes,
373
+ filename: prepared.filename,
374
+ };
207
375
  if (!ctx.quiet && format === "human") {
208
376
  process.stderr.write(`>> key: ${result.key}\n\n`);
209
377
  }
210
378
  switch (format) {
211
379
  case "json":
212
- await writeJson({ ...result, markdown });
380
+ await writeJson({ ...result, markdown, optimize: optimizeMeta, frame: prepared.frame });
213
381
  break;
214
382
  case "url":
215
383
  await writeStdout(`${result.url}\n`);
@@ -1,5 +1,5 @@
1
1
  import type { UploadsClientConfig } from "./config.js";
2
- export declare const UPLOADS_CONFIG_KEYS: readonly ["UPLOADS_API_URL", "UPLOADS_WORKSPACE", "UPLOADS_TOKEN", "UPLOADS_DEFAULT_PREFIX", "UPLOADS_DEFAULT_REPO", "UPLOADS_DEFAULT_REF", "UPLOADS_DEFAULT_WIDTH", "UPLOADS_NO_GIT"];
2
+ export declare const UPLOADS_CONFIG_KEYS: readonly ["UPLOADS_API_URL", "UPLOADS_WORKSPACE", "UPLOADS_TOKEN", "UPLOADS_DEFAULT_PREFIX", "UPLOADS_DEFAULT_REPO", "UPLOADS_DEFAULT_REF", "UPLOADS_DEFAULT_WIDTH", "UPLOADS_NO_GIT", "UPLOADS_NO_OPTIMIZE", "UPLOADS_KEEP_EXIF"];
3
3
  export type UploadsConfigKey = (typeof UPLOADS_CONFIG_KEYS)[number];
4
4
  export type UploadsConfigValues = Partial<Record<UploadsConfigKey, string>>;
5
5
  export interface PutDefaults {
@@ -8,6 +8,10 @@ export interface PutDefaults {
8
8
  ref?: string;
9
9
  width?: number;
10
10
  noGit?: boolean;
11
+ /** When true, put/attach skip client-side image optimization. */
12
+ noOptimize?: boolean;
13
+ /** When true, optimize keeps EXIF/XMP/ICC (default strips). */
14
+ keepExif?: boolean;
11
15
  }
12
16
  declare const PUT_DEFAULT_KEY_MAP: Record<keyof PutDefaults, UploadsConfigKey>;
13
17
  export declare function putDefaultsToConfigValues(defaults: PutDefaults): UploadsConfigValues;
@@ -10,6 +10,8 @@ export const UPLOADS_CONFIG_KEYS = [
10
10
  "UPLOADS_DEFAULT_REF",
11
11
  "UPLOADS_DEFAULT_WIDTH",
12
12
  "UPLOADS_NO_GIT",
13
+ "UPLOADS_NO_OPTIMIZE",
14
+ "UPLOADS_KEEP_EXIF",
13
15
  ];
14
16
  const PUT_DEFAULT_KEY_MAP = {
15
17
  prefix: "UPLOADS_DEFAULT_PREFIX",
@@ -17,7 +19,15 @@ const PUT_DEFAULT_KEY_MAP = {
17
19
  ref: "UPLOADS_DEFAULT_REF",
18
20
  width: "UPLOADS_DEFAULT_WIDTH",
19
21
  noGit: "UPLOADS_NO_GIT",
22
+ noOptimize: "UPLOADS_NO_OPTIMIZE",
23
+ keepExif: "UPLOADS_KEEP_EXIF",
20
24
  };
25
+ function isTruthyConfigFlag(value) {
26
+ if (!value)
27
+ return false;
28
+ const v = value.toLowerCase();
29
+ return v === "1" || v === "true" || v === "yes";
30
+ }
21
31
  export function putDefaultsToConfigValues(defaults) {
22
32
  const out = {};
23
33
  if (defaults.prefix)
@@ -30,6 +40,10 @@ export function putDefaultsToConfigValues(defaults) {
30
40
  out.UPLOADS_DEFAULT_WIDTH = String(defaults.width);
31
41
  if (defaults.noGit)
32
42
  out.UPLOADS_NO_GIT = "1";
43
+ if (defaults.noOptimize)
44
+ out.UPLOADS_NO_OPTIMIZE = "1";
45
+ if (defaults.keepExif)
46
+ out.UPLOADS_KEEP_EXIF = "1";
33
47
  return out;
34
48
  }
35
49
  function parsePutDefaultsFromRaw(raw) {
@@ -45,9 +59,12 @@ function parsePutDefaultsFromRaw(raw) {
45
59
  if (Number.isFinite(n) && n > 0)
46
60
  out.width = n;
47
61
  }
48
- if (raw.UPLOADS_NO_GIT === "1" || raw.UPLOADS_NO_GIT?.toLowerCase() === "true") {
62
+ if (isTruthyConfigFlag(raw.UPLOADS_NO_GIT))
49
63
  out.noGit = true;
50
- }
64
+ if (isTruthyConfigFlag(raw.UPLOADS_NO_OPTIMIZE))
65
+ out.noOptimize = true;
66
+ if (isTruthyConfigFlag(raw.UPLOADS_KEEP_EXIF))
67
+ out.keepExif = true;
51
68
  return out;
52
69
  }
53
70
  function parsePutDefaultsFromEnv() {
@@ -62,6 +79,10 @@ function parsePutDefaultsFromEnv() {
62
79
  raw.UPLOADS_DEFAULT_WIDTH = process.env.UPLOADS_DEFAULT_WIDTH;
63
80
  if (process.env.UPLOADS_NO_GIT)
64
81
  raw.UPLOADS_NO_GIT = process.env.UPLOADS_NO_GIT;
82
+ if (process.env.UPLOADS_NO_OPTIMIZE)
83
+ raw.UPLOADS_NO_OPTIMIZE = process.env.UPLOADS_NO_OPTIMIZE;
84
+ if (process.env.UPLOADS_KEEP_EXIF)
85
+ raw.UPLOADS_KEEP_EXIF = process.env.UPLOADS_KEEP_EXIF;
65
86
  return parsePutDefaultsFromRaw(raw);
66
87
  }
67
88
  /** XDG default shared across buildinternet skills (github-screenshots, uploads, …). */
@@ -127,6 +148,10 @@ export function mergePutDefaults(...layers) {
127
148
  out.width = layer.width;
128
149
  if (layer.noGit != null)
129
150
  out.noGit = layer.noGit;
151
+ if (layer.noOptimize != null)
152
+ out.noOptimize = layer.noOptimize;
153
+ if (layer.keepExif != null)
154
+ out.keepExif = layer.keepExif;
130
155
  }
131
156
  return out;
132
157
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Typed destination roots for put/attach. Matches the API allowlist defaults
3
+ * (`f/`, `screenshots/`, `gh/`) — see apps/api `key-policy.ts`.
4
+ */
5
+ export declare const BUILTIN_DESTINATIONS: {
6
+ readonly f: "f";
7
+ readonly screenshots: "screenshots";
8
+ readonly gh: "gh";
9
+ };
10
+ export type BuiltinDestinationId = keyof typeof BUILTIN_DESTINATIONS;
11
+ export declare function isBuiltinDestination(id: string): id is BuiltinDestinationId;
12
+ /** Root segment for a known destination, or throws with a usage-friendly message. */
13
+ export declare function resolveDestinationRoot(id: string): string;
14
+ /** True when `key` is under the destination root. */
15
+ export declare function keyMatchesDestination(key: string, destinationId: string): boolean;
16
+ /**
17
+ * Resolve CLI/MCP destination flags into a put `prefix`. Throws plain Errors
18
+ * (callers wrap as UsageError / MCP usage errors).
19
+ */
20
+ export declare function resolvePutPrefix(opts: {
21
+ destination?: string;
22
+ prefix?: string;
23
+ key?: string;
24
+ /** When true (PR/issue attach), destination must be `gh` or omitted. */
25
+ ghAttachment?: boolean;
26
+ }): string | undefined;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Typed destination roots for put/attach. Matches the API allowlist defaults
3
+ * (`f/`, `screenshots/`, `gh/`) — see apps/api `key-policy.ts`.
4
+ */
5
+ export const BUILTIN_DESTINATIONS = {
6
+ f: "f",
7
+ screenshots: "screenshots",
8
+ gh: "gh",
9
+ };
10
+ export function isBuiltinDestination(id) {
11
+ return Object.hasOwn(BUILTIN_DESTINATIONS, id);
12
+ }
13
+ /** Root segment for a known destination, or throws with a usage-friendly message. */
14
+ export function resolveDestinationRoot(id) {
15
+ if (!isBuiltinDestination(id)) {
16
+ const known = Object.keys(BUILTIN_DESTINATIONS).join(", ");
17
+ throw new Error(`unknown destination: ${id} (known: ${known})`);
18
+ }
19
+ return BUILTIN_DESTINATIONS[id];
20
+ }
21
+ /** True when `key` is under the destination root. */
22
+ export function keyMatchesDestination(key, destinationId) {
23
+ if (!isBuiltinDestination(destinationId))
24
+ return false;
25
+ const root = BUILTIN_DESTINATIONS[destinationId];
26
+ return key === root || key.startsWith(`${root}/`);
27
+ }
28
+ /**
29
+ * Resolve CLI/MCP destination flags into a put `prefix`. Throws plain Errors
30
+ * (callers wrap as UsageError / MCP usage errors).
31
+ */
32
+ export function resolvePutPrefix(opts) {
33
+ const { destination, prefix, key, ghAttachment } = opts;
34
+ if (!destination)
35
+ return prefix;
36
+ const root = resolveDestinationRoot(destination);
37
+ if (ghAttachment && destination !== "gh") {
38
+ throw new Error("destination with pr/issue must be gh (or omit it)");
39
+ }
40
+ if (key && !keyMatchesDestination(key, destination)) {
41
+ throw new Error(`key must start with destination root "${root}/"`);
42
+ }
43
+ if (prefix && prefix.replace(/\/+$/, "") !== root) {
44
+ throw new Error(`prefix (${prefix}) conflicts with destination ${destination} (root ${root})`);
45
+ }
46
+ return root;
47
+ }
package/dist/errors.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type UploadsErrorCode = "MISSING_TOKEN" | "NO_PUBLIC_URL" | "NOT_FOUND" | "UNAUTHORIZED" | "INVALID_KEY" | "STORAGE_QUOTA" | "UPLOAD_BUDGET" | "API_ERROR" | "NETWORK" | "USAGE";
1
+ export type UploadsErrorCode = "MISSING_TOKEN" | "NO_PUBLIC_URL" | "NOT_FOUND" | "UNAUTHORIZED" | "INVALID_KEY" | "KEY_POLICY" | "STORAGE_QUOTA" | "UPLOAD_BUDGET" | "API_ERROR" | "NETWORK" | "USAGE";
2
2
  export declare class UploadsError extends Error {
3
3
  readonly code: UploadsErrorCode;
4
4
  readonly status?: number;
@@ -0,0 +1,36 @@
1
+ export type FrameFit = "cover" | "contain";
2
+ export interface FrameOptions {
3
+ id: string;
4
+ fit?: FrameFit;
5
+ /** Address bar text for procedural `browser`. */
6
+ browserUrl?: string;
7
+ fetchImpl?: typeof fetch;
8
+ cacheDir?: string;
9
+ }
10
+ export interface FrameResult {
11
+ bytes: Uint8Array;
12
+ filename: string;
13
+ contentType: string;
14
+ framed: boolean;
15
+ frameId: string;
16
+ skippedReason?: string;
17
+ }
18
+ type FramePreset = {
19
+ kind: "procedural";
20
+ label: string;
21
+ } | {
22
+ kind: "remote";
23
+ label: string;
24
+ /** Directory URL with frame.png, mask.png, template.json */
25
+ assetBase: string;
26
+ };
27
+ export declare const FRAME_PRESETS: Record<string, FramePreset>;
28
+ export declare function listFramePresets(): Array<{
29
+ id: string;
30
+ label: string;
31
+ kind: string;
32
+ }>;
33
+ export declare function resolveFrameId(raw: string | undefined): string | undefined;
34
+ /** Apply a named frame. Non-images pass through. Output PNG for the optimize step. */
35
+ export declare function applyFrame(bytes: Uint8Array, filename: string, opts: FrameOptions): Promise<FrameResult>;
36
+ export {};