@buildinternet/uploads 0.2.0 → 0.4.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
@@ -1,12 +1,10 @@
1
1
  # @buildinternet/uploads
2
2
 
3
- CLI and client for **uploads.sh** — upload files, get public URLs, and produce GitHub-ready markdown. Successor to the R2 scripts in `buildinternet-skills/github-screenshots`.
3
+ CLI and client for **uploads.sh** — upload files, get public URLs, and produce GitHub-ready markdown.
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
@@ -19,7 +19,7 @@ Config (first match wins, per key):
19
19
  environment UPLOADS_API_URL, UPLOADS_TOKEN, UPLOADS_WORKSPACE
20
20
  --env-file <path>
21
21
  $BUILDINTERNET_CONFIG
22
- ~/.config/buildinternet/config (shared with github-screenshots)
22
+ ~/.config/buildinternet/config
23
23
 
24
24
  Workspace (within config layers):
25
25
  --workspace, -w override — global (before command) or per-command (after)
@@ -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.d.ts CHANGED
@@ -1,4 +1,13 @@
1
1
  import type { UploadsClientConfig } from "./config.js";
2
+ /** Allowlisted object provenance (maps to X-Uploads-Meta-* on put). */
3
+ export type ProvenanceInput = {
4
+ client?: string;
5
+ "client-version"?: string;
6
+ "source-name"?: string;
7
+ optimized?: "0" | "1";
8
+ frame?: string;
9
+ "keep-exif"?: "0" | "1";
10
+ };
2
11
  export interface PutOptions {
3
12
  key?: string;
4
13
  contentType?: string;
@@ -6,6 +15,8 @@ export interface PutOptions {
6
15
  repo?: string;
7
16
  ref?: string;
8
17
  deriveRepoFromGit?: boolean;
18
+ /** Stored as R2 custom metadata; echoed on put/head. */
19
+ provenance?: ProvenanceInput;
9
20
  }
10
21
  export interface ListOptions {
11
22
  prefix?: string;
@@ -18,6 +29,7 @@ export interface PutResult {
18
29
  url: string;
19
30
  size: number;
20
31
  contentType: string;
32
+ metadata?: Record<string, string>;
21
33
  }
22
34
  export interface ListItem {
23
35
  key: string;
@@ -35,6 +47,7 @@ export interface HeadResult {
35
47
  size: number;
36
48
  contentType: string;
37
49
  uploaded?: string;
50
+ metadata?: Record<string, string>;
38
51
  }
39
52
  export interface DeleteResult {
40
53
  key: string;
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);
@@ -114,9 +117,16 @@ export function createUploadsClient(config) {
114
117
  deriveRepoFromGit: opts.deriveRepoFromGit,
115
118
  }));
116
119
  const contentType = opts.contentType ?? inferContentType(opts.filename);
120
+ const headers = { "Content-Type": contentType };
121
+ if (opts.provenance) {
122
+ for (const [k, v] of Object.entries(opts.provenance)) {
123
+ if (v !== undefined && v !== "")
124
+ headers[`X-Uploads-Meta-${k}`] = v;
125
+ }
126
+ }
117
127
  const result = await request("PUT", `${filesBase(config)}/${encodeKeyPath(key)}`, {
118
128
  body,
119
- headers: { "Content-Type": contentType },
129
+ headers,
120
130
  });
121
131
  if (result.url == null) {
122
132
  throw new UploadsError("upload succeeded but workspace has no publicBaseUrl", "NO_PUBLIC_URL", 201);
@@ -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,39 @@ 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";
14
+ import { buildCliProvenance } from "./provenance.js";
11
15
  // --- put ---
12
16
  const PUT_HELP = `uploads put <file> [options]
13
17
 
14
18
  Upload an image for GitHub embeds. Use "-" for stdin.
15
19
 
20
+ Still images (PNG/JPEG/…) are optimized to WebP by default (long edge capped,
21
+ high quality; EXIF stripped) so GitHub embeds stay lean. Original bytes are kept
22
+ when they are already smaller, animated, or not an image. Use --no-optimize to
23
+ upload as-is, or --keep-exif when image metadata matters for the discussion.
24
+
25
+ Optional --frame wraps the image in a device/browser chrome before optimize
26
+ (default off). See: uploads put --help frames
27
+
16
28
  Options:
17
29
  --key <key> Object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.<ext>)
30
+ --destination <id> Typed root: screenshots | gh | f (sets --prefix)
18
31
  --prefix <path> Key prefix (default: screenshots, or UPLOADS_DEFAULT_PREFIX)
19
32
  --repo <owner/repo> Repo segment (default: git remote, or UPLOADS_DEFAULT_REPO)
20
33
  --ref <id> PR/issue/branch segment (default: today, or UPLOADS_DEFAULT_REF)
21
34
  --alt <text> Alt text (default: filename)
22
35
  --width <px> <img width=…> markdown (or UPLOADS_DEFAULT_WIDTH)
23
- --content-type <mime> Override Content-Type
36
+ --content-type <mime> Override Content-Type (ignored when optimize rewrites the body)
37
+ --frame <id> Device/browser frame before optimize (phone|browser|iphone-16-pro)
38
+ --frame-url <url> Address bar text for --frame browser
39
+ --frame-fit cover|contain How the shot fills the screen (default: cover)
40
+ --no-optimize Skip client-side image optimization (or UPLOADS_NO_OPTIMIZE=1)
41
+ --optimize-max-edge <px> Max long edge when optimizing (default: 2400)
42
+ --optimize-quality <1-100> WebP quality (default: 85)
43
+ --keep-exif Keep EXIF/XMP/ICC when optimizing (default: strip for privacy)
24
44
  --no-git Don't derive --repo from git (or UPLOADS_NO_GIT=1)
25
45
  --workspace, -w <name> Override workspace (wins over UPLOADS_WORKSPACE and token inference)
26
46
  --format human|url|markdown|json
@@ -30,8 +50,9 @@ Options:
30
50
 
31
51
  Examples:
32
52
  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
53
+ uploads put ./mobile.png --frame phone
54
+ uploads put ./ui.png --frame browser --frame-url "https://app.example/settings"
55
+ uploads put ./shot.png --destination screenshots
35
56
  `;
36
57
  /**
37
58
  * Turns a pr/issue pair (+ optional repo) into a GhTarget; undefined when
@@ -50,6 +71,82 @@ export function makeGhTarget(pr, issue, repoArg, run) {
50
71
  function ghTargetFromFlags(flags, run) {
51
72
  return makeGhTarget(flagInt(flags, "--pr", "--pr"), flagInt(flags, "--issue", "--issue"), flagString(flags, "--repo"), run);
52
73
  }
74
+ /** Shared put/attach optimize flags + UPLOADS_NO_OPTIMIZE default. */
75
+ export function optimizeOptionsFromFlags(flags, defaults) {
76
+ if (flags.has("--no-optimize") && typeof flags.get("--no-optimize") === "string") {
77
+ throw new UsageError("--no-optimize takes no value");
78
+ }
79
+ if (flags.has("--keep-exif") && typeof flags.get("--keep-exif") === "string") {
80
+ throw new UsageError("--keep-exif takes no value");
81
+ }
82
+ const quality = flagInt(flags, "--optimize-quality", "--optimize-quality");
83
+ if (quality !== undefined && quality > 100) {
84
+ throw new UsageError("invalid --optimize-quality: must be 1–100");
85
+ }
86
+ return {
87
+ enabled: !(flagBool(flags, "--no-optimize") || defaults.noOptimize === true),
88
+ maxEdge: flagInt(flags, "--optimize-max-edge", "--optimize-max-edge"),
89
+ quality,
90
+ keepExif: flagBool(flags, "--keep-exif") || defaults.keepExif === true,
91
+ };
92
+ }
93
+ function formatOptimizeNote(opt) {
94
+ if (opt.optimized) {
95
+ return `optimized ${opt.originalBytes} → ${opt.outputBytes} bytes (${opt.filename})`;
96
+ }
97
+ if (opt.skippedReason && opt.skippedReason !== "disabled") {
98
+ return `optimize skipped (${opt.skippedReason})`;
99
+ }
100
+ return undefined;
101
+ }
102
+ /** Frame (optional) then optimize — shared by put/attach/MCP. */
103
+ export async function prepareImageForUpload(bytes, filename, opts) {
104
+ let currentBytes = bytes;
105
+ let currentName = filename;
106
+ let frameMeta;
107
+ if (opts.frameId) {
108
+ const framed = await applyFrame(currentBytes, currentName, {
109
+ id: opts.frameId,
110
+ browserUrl: opts.frameUrl,
111
+ fit: opts.frameFit,
112
+ });
113
+ frameMeta = {
114
+ framed: framed.framed,
115
+ frameId: framed.frameId,
116
+ skippedReason: framed.skippedReason,
117
+ };
118
+ if (framed.framed) {
119
+ currentBytes = framed.bytes;
120
+ currentName = framed.filename;
121
+ }
122
+ }
123
+ const optimized = await optimizeImageForUpload(currentBytes, currentName, opts.optimize);
124
+ return { ...optimized, frame: frameMeta };
125
+ }
126
+ function frameOptionsFromFlags(flags) {
127
+ const raw = flagString(flags, "--frame");
128
+ let frameId;
129
+ try {
130
+ frameId = resolveFrameId(raw);
131
+ }
132
+ catch (err) {
133
+ throw new UsageError(err instanceof Error ? err.message : String(err));
134
+ }
135
+ const fitRaw = flagString(flags, "--frame-fit");
136
+ let frameFit;
137
+ if (fitRaw) {
138
+ if (fitRaw !== "cover" && fitRaw !== "contain") {
139
+ throw new UsageError(`invalid --frame-fit: ${fitRaw} (use cover or contain)`);
140
+ }
141
+ frameFit = fitRaw;
142
+ }
143
+ if (frameFit && !frameId)
144
+ throw new UsageError("--frame-fit requires --frame");
145
+ const frameUrl = flagString(flags, "--frame-url");
146
+ if (frameUrl && !frameId)
147
+ throw new UsageError("--frame-url requires --frame");
148
+ return { frameId, frameUrl, frameFit };
149
+ }
53
150
  /**
54
151
  * List every attachment under the target's prefix and create/update the
55
152
  * managed comment. Throws on gh failure — callers decide whether that is
@@ -69,16 +166,27 @@ const ATTACH_HELP = `uploads attach <file...> [options]
69
166
  Upload one or more stable PR/issue attachments and maintain a single GitHub
70
167
  comment. With no target, uses the pull request for the current branch.
71
168
 
169
+ Still images are optimized to WebP by default (same as put). Use --no-optimize
170
+ to upload originals. Optional --frame wraps images in device/browser chrome.
171
+
72
172
  Options:
73
173
  --pr <num> Attach to this pull request
74
174
  --issue <num> Attach to this issue
75
175
  --repo <owner/repo> Repository (default: gh/git inference)
76
176
  --no-comment Upload only; don't create/update the managed comment
77
- --content-type <mime> Override Content-Type (applied to every file)
177
+ --content-type <mime> Override Content-Type (applied to every file; ignored when optimize rewrites)
178
+ --frame <id> Device/browser frame before optimize (phone|browser|iphone-16-pro)
179
+ --frame-url <url> Address bar text for --frame browser
180
+ --frame-fit cover|contain How the shot fills the screen (default: cover)
181
+ --no-optimize Skip client-side image optimization (or UPLOADS_NO_OPTIMIZE=1)
182
+ --optimize-max-edge <px> Max long edge when optimizing (default: 2400)
183
+ --optimize-quality <1-100> WebP quality (default: 85)
184
+ --keep-exif Keep EXIF/XMP/ICC when optimizing (default: strip for privacy)
78
185
  --workspace, -w <name> Override workspace
79
186
 
80
187
  Examples:
81
188
  uploads attach ./before.png ./after.png
189
+ uploads attach ./mobile.png --frame phone
82
190
  uploads attach ./shot.png --pr 123 --repo myorg/myapp
83
191
  uploads attach ./artifact.zip --issue 45 --no-comment
84
192
  `;
@@ -98,19 +206,50 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
98
206
  const explicitTarget = ghTargetFromFlags(parsed.flags, run);
99
207
  const target = explicitTarget ??
100
208
  resolveCurrentPullRequest(resolveRepo(flagString(parsed.flags, "--repo"), run), run);
209
+ const defaults = resolvePutDefaults({ envFile: ctx.envFile });
210
+ const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, defaults);
211
+ const frameOpts = frameOptionsFromFlags(parsed.flags);
212
+ const contentTypeOverride = flagString(parsed.flags, "--content-type");
101
213
  const results = [];
102
214
  for (const file of parsed.positionals) {
103
215
  if (file === "-")
104
216
  throw new UsageError("attach does not support stdin; pass one or more file paths");
105
- const filename = basename(file);
217
+ const sourceName = basename(file);
106
218
  if (!ctx.quiet && !ctx.json)
107
219
  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"),
220
+ const prepared = await prepareImageForUpload(new Uint8Array(readFileSync(file)), sourceName, {
221
+ ...frameOpts,
222
+ optimize: optimizeOpts,
223
+ });
224
+ if (prepared.frame?.framed && !ctx.quiet && !ctx.json) {
225
+ process.stderr.write(`>> framed with ${prepared.frame.frameId}\n`);
226
+ }
227
+ const note = formatOptimizeNote(prepared);
228
+ if (note && !ctx.quiet && !ctx.json)
229
+ process.stderr.write(`>> ${note}\n`);
230
+ const result = await ctx.client.put(prepared.bytes, {
231
+ filename: prepared.filename,
232
+ key: ghAttachmentKey(target, prepared.filename),
233
+ contentType: prepared.optimized ? prepared.contentType : contentTypeOverride,
234
+ provenance: buildCliProvenance({
235
+ sourceName,
236
+ optimized: prepared.optimized,
237
+ frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
238
+ keepExif: optimizeOpts.keepExif === true,
239
+ }),
240
+ });
241
+ results.push({
242
+ ...result,
243
+ markdown: buildMarkdown(result.url, { alt: sourceName }),
244
+ optimize: {
245
+ optimized: prepared.optimized,
246
+ skippedReason: prepared.skippedReason,
247
+ originalBytes: prepared.originalBytes,
248
+ outputBytes: prepared.outputBytes,
249
+ filename: prepared.filename,
250
+ },
251
+ frame: prepared.frame,
112
252
  });
113
- results.push({ ...result, markdown: buildMarkdown(result.url, { alt: filename }) });
114
253
  }
115
254
  let comment;
116
255
  let commentError;
@@ -151,6 +290,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
151
290
  return 2;
152
291
  }
153
292
  const keyHint = flagString(parsed.flags, "--key");
293
+ const destFlag = flagString(parsed.flags, "--destination");
294
+ const prefixFlag = flagString(parsed.flags, "--prefix");
154
295
  const ghTarget = ghTargetFromFlags(parsed.flags, run);
155
296
  const wantComment = parsed.flags.has("--comment");
156
297
  if (wantComment && typeof parsed.flags.get("--comment") === "string") {
@@ -164,12 +305,23 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
164
305
  if (flagString(parsed.flags, "--ref")) {
165
306
  throw new UsageError("--ref cannot be combined with --pr/--issue");
166
307
  }
167
- if (flagString(parsed.flags, "--prefix")) {
308
+ if (prefixFlag)
168
309
  throw new UsageError("--prefix cannot be combined with --pr/--issue");
169
- }
310
+ }
311
+ let resolvedPrefix;
312
+ try {
313
+ resolvedPrefix = resolvePutPrefix({
314
+ destination: destFlag,
315
+ prefix: prefixFlag,
316
+ key: keyHint,
317
+ ghAttachment: Boolean(ghTarget),
318
+ });
319
+ }
320
+ catch (err) {
321
+ throw new UsageError(err instanceof Error ? err.message : String(err));
170
322
  }
171
323
  const bytes = fileArg === "-" ? new Uint8Array(readFileSync(0)) : new Uint8Array(readFileSync(fileArg));
172
- const filename = fileArg === "-" ? (keyHint ? basename(keyHint) : "stdin.bin") : basename(fileArg);
324
+ const sourceName = fileArg === "-" ? (keyHint ? basename(keyHint) : "stdin.bin") : basename(fileArg);
173
325
  const format = ctx.json
174
326
  ? "json"
175
327
  : (() => {
@@ -181,7 +333,15 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
181
333
  throw new UsageError(`invalid --format: ${raw}`);
182
334
  })();
183
335
  const defaults = resolvePutDefaults({ envFile: ctx.envFile });
184
- const alt = flagString(parsed.flags, "--alt") ?? basename(filename);
336
+ const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, defaults);
337
+ const frameOpts = frameOptionsFromFlags(parsed.flags);
338
+ const prepared = await prepareImageForUpload(bytes, sourceName, {
339
+ ...frameOpts,
340
+ optimize: optimizeOpts,
341
+ });
342
+ const filename = prepared.filename;
343
+ const contentTypeOverride = flagString(parsed.flags, "--content-type");
344
+ const alt = flagString(parsed.flags, "--alt") ?? basename(sourceName);
185
345
  const widthRaw = flagString(parsed.flags, "--width");
186
346
  const width = widthRaw && /^\d+$/.test(widthRaw) && Number(widthRaw) > 0
187
347
  ? Number.parseInt(widthRaw, 10)
@@ -192,24 +352,45 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
192
352
  : defaults.width;
193
353
  if (!ctx.quiet && format === "human") {
194
354
  process.stderr.write(`>> uploading ${fileArg === "-" ? "stdin" : fileArg}\n`);
355
+ if (prepared.frame?.framed)
356
+ process.stderr.write(`>> framed with ${prepared.frame.frameId}\n`);
357
+ const note = formatOptimizeNote(prepared);
358
+ if (note)
359
+ process.stderr.write(`>> ${note}\n`);
195
360
  }
196
361
  const noGit = flagBool(parsed.flags, "--no-git") || defaults.noGit === true;
197
- const result = await ctx.client.put(bytes, {
362
+ let key = ghTarget ? ghAttachmentKey(ghTarget, filename) : keyHint;
363
+ if (key && prepared.optimized)
364
+ key = rewriteKeyExtension(key, filename);
365
+ const result = await ctx.client.put(prepared.bytes, {
198
366
  filename,
199
- key: ghTarget ? ghAttachmentKey(ghTarget, filename) : keyHint,
200
- prefix: flagString(parsed.flags, "--prefix") ?? defaults.prefix,
367
+ key,
368
+ prefix: resolvedPrefix ?? defaults.prefix,
201
369
  repo: flagString(parsed.flags, "--repo") ?? defaults.repo,
202
370
  ref: flagString(parsed.flags, "--ref") ?? defaults.ref,
203
- contentType: flagString(parsed.flags, "--content-type"),
371
+ contentType: prepared.optimized ? prepared.contentType : contentTypeOverride,
204
372
  deriveRepoFromGit: !noGit,
373
+ provenance: buildCliProvenance({
374
+ sourceName,
375
+ optimized: prepared.optimized,
376
+ frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
377
+ keepExif: optimizeOpts.keepExif === true,
378
+ }),
205
379
  });
206
380
  const markdown = buildMarkdown(result.url, { alt, width });
381
+ const optimizeMeta = {
382
+ optimized: prepared.optimized,
383
+ skippedReason: prepared.skippedReason,
384
+ originalBytes: prepared.originalBytes,
385
+ outputBytes: prepared.outputBytes,
386
+ filename: prepared.filename,
387
+ };
207
388
  if (!ctx.quiet && format === "human") {
208
389
  process.stderr.write(`>> key: ${result.key}\n\n`);
209
390
  }
210
391
  switch (format) {
211
392
  case "json":
212
- await writeJson({ ...result, markdown });
393
+ await writeJson({ ...result, markdown, optimize: optimizeMeta, frame: prepared.frame });
213
394
  break;
214
395
  case "url":
215
396
  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;