@buildinternet/uploads 0.1.1 → 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/dist/commands.js CHANGED
@@ -5,29 +5,41 @@ import { parseCommandArgs, flagString, flagBool, flagInt, UsageError, } from "./
5
5
  import { resolvePutDefaults, workspaceMismatch, workspaceFromToken, } from "./config.js";
6
6
  import { buildMarkdown } from "./embed.js";
7
7
  import { UploadsError } from "./errors.js";
8
+ import { writeJson, writeStdout } from "./io.js";
8
9
  import { ghAttachmentKey, ghKeyPrefix, attachmentsCommentBody, } from "./github.js";
9
10
  import { resolveRepo, resolveCurrentPullRequest, execRunner, upsertAttachmentsComment, } from "./github-gh.js";
10
- async function writeStdout(text) {
11
- if (!process.stdout.write(text)) {
12
- await new Promise((resolve) => process.stdout.once("drain", resolve));
13
- }
14
- }
15
- async function writeJson(value) {
16
- await writeStdout(JSON.stringify(value, null, 2) + "\n");
17
- }
11
+ import { resolvePutPrefix } from "./destinations.js";
12
+ import { optimizeImageForUpload, rewriteKeyExtension, } from "./optimize.js";
13
+ import { applyFrame, resolveFrameId } from "./frame.js";
18
14
  // --- put ---
19
15
  const PUT_HELP = `uploads put <file> [options]
20
16
 
21
17
  Upload an image for GitHub embeds. Use "-" for stdin.
22
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
+
23
27
  Options:
24
28
  --key <key> Object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.<ext>)
29
+ --destination <id> Typed root: screenshots | gh | f (sets --prefix)
25
30
  --prefix <path> Key prefix (default: screenshots, or UPLOADS_DEFAULT_PREFIX)
26
31
  --repo <owner/repo> Repo segment (default: git remote, or UPLOADS_DEFAULT_REPO)
27
32
  --ref <id> PR/issue/branch segment (default: today, or UPLOADS_DEFAULT_REF)
28
33
  --alt <text> Alt text (default: filename)
29
34
  --width <px> <img width=…> markdown (or UPLOADS_DEFAULT_WIDTH)
30
- --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)
31
43
  --no-git Don't derive --repo from git (or UPLOADS_NO_GIT=1)
32
44
  --workspace, -w <name> Override workspace (wins over UPLOADS_WORKSPACE and token inference)
33
45
  --format human|url|markdown|json
@@ -37,34 +49,110 @@ Options:
37
49
 
38
50
  Examples:
39
51
  uploads put ./shot.png --repo myorg/myapp --ref 1722 --alt "New cards" --width 700
40
- uploads --env-file .env put ./shot.png
41
- 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
42
55
  `;
43
- /** Reads --pr/--issue (+ --repo) into a GhTarget; undefined when neither flag is present. */
44
- function ghTargetFromFlags(flags, run) {
45
- const pr = flagInt(flags, "--pr", "--pr");
46
- const issue = flagInt(flags, "--issue", "--issue");
56
+ /**
57
+ * Turns a pr/issue pair (+ optional repo) into a GhTarget; undefined when
58
+ * neither is present. Shared by the CLI flags and the MCP tool arguments.
59
+ */
60
+ export function makeGhTarget(pr, issue, repoArg, run) {
47
61
  if (pr === undefined && issue === undefined)
48
62
  return undefined;
49
63
  if (pr !== undefined && issue !== undefined) {
50
64
  throw new UsageError("--pr and --issue are mutually exclusive");
51
65
  }
52
- const repo = resolveRepo(flagString(flags, "--repo"), run);
66
+ const repo = resolveRepo(repoArg, run);
53
67
  return { repo, kind: pr !== undefined ? "pull" : "issues", num: (pr ?? issue) };
54
68
  }
69
+ /** Reads --pr/--issue (+ --repo) into a GhTarget; undefined when neither flag is present. */
70
+ function ghTargetFromFlags(flags, run) {
71
+ return makeGhTarget(flagInt(flags, "--pr", "--pr"), flagInt(flags, "--issue", "--issue"), flagString(flags, "--repo"), run);
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
+ }
55
149
  /**
56
150
  * List every attachment under the target's prefix and create/update the
57
151
  * managed comment. Throws on gh failure — callers decide whether that is
58
152
  * fatal (`comment` command) or a warning (`put --comment`).
59
153
  */
60
- async function syncAttachmentsComment(ctx, target, run) {
61
- const items = [];
62
- let cursor;
63
- do {
64
- const page = await ctx.client.list({ prefix: ghKeyPrefix(target), cursor });
65
- items.push(...page.items.map(({ key, url }) => ({ key, url })));
66
- cursor = page.cursor ?? undefined;
67
- } while (cursor);
154
+ export async function syncAttachmentsComment(client, target, run) {
155
+ const items = (await client.listAll({ prefix: ghKeyPrefix(target) })).map(({ key, url }) => ({ key, url }));
68
156
  if (items.length === 0)
69
157
  return { action: "skipped", count: 0 };
70
158
  const body = attachmentsCommentBody(items);
@@ -77,16 +165,27 @@ const ATTACH_HELP = `uploads attach <file...> [options]
77
165
  Upload one or more stable PR/issue attachments and maintain a single GitHub
78
166
  comment. With no target, uses the pull request for the current branch.
79
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
+
80
171
  Options:
81
172
  --pr <num> Attach to this pull request
82
173
  --issue <num> Attach to this issue
83
174
  --repo <owner/repo> Repository (default: gh/git inference)
84
175
  --no-comment Upload only; don't create/update the managed comment
85
- --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)
86
184
  --workspace, -w <name> Override workspace
87
185
 
88
186
  Examples:
89
187
  uploads attach ./before.png ./after.png
188
+ uploads attach ./mobile.png --frame phone
90
189
  uploads attach ./shot.png --pr 123 --repo myorg/myapp
91
190
  uploads attach ./artifact.zip --issue 45 --no-comment
92
191
  `;
@@ -106,25 +205,50 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
106
205
  const explicitTarget = ghTargetFromFlags(parsed.flags, run);
107
206
  const target = explicitTarget ??
108
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");
109
212
  const results = [];
110
213
  for (const file of parsed.positionals) {
111
214
  if (file === "-")
112
215
  throw new UsageError("attach does not support stdin; pass one or more file paths");
113
- const filename = basename(file);
216
+ const sourceName = basename(file);
114
217
  if (!ctx.quiet && !ctx.json)
115
218
  process.stderr.write(`>> uploading ${file}\n`);
116
- const result = await ctx.client.put(new Uint8Array(readFileSync(file)), {
117
- filename,
118
- key: ghAttachmentKey(target, filename),
119
- 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,
120
245
  });
121
- results.push({ ...result, markdown: buildMarkdown(result.url, { alt: filename }) });
122
246
  }
123
247
  let comment;
124
248
  let commentError;
125
249
  if (!parsed.flags.has("--no-comment")) {
126
250
  try {
127
- comment = await syncAttachmentsComment(ctx, target, run);
251
+ comment = await syncAttachmentsComment(ctx.client, target, run);
128
252
  }
129
253
  catch (err) {
130
254
  commentError = err instanceof Error ? err.message : String(err);
@@ -159,6 +283,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
159
283
  return 2;
160
284
  }
161
285
  const keyHint = flagString(parsed.flags, "--key");
286
+ const destFlag = flagString(parsed.flags, "--destination");
287
+ const prefixFlag = flagString(parsed.flags, "--prefix");
162
288
  const ghTarget = ghTargetFromFlags(parsed.flags, run);
163
289
  const wantComment = parsed.flags.has("--comment");
164
290
  if (wantComment && typeof parsed.flags.get("--comment") === "string") {
@@ -172,12 +298,23 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
172
298
  if (flagString(parsed.flags, "--ref")) {
173
299
  throw new UsageError("--ref cannot be combined with --pr/--issue");
174
300
  }
175
- if (flagString(parsed.flags, "--prefix")) {
301
+ if (prefixFlag)
176
302
  throw new UsageError("--prefix cannot be combined with --pr/--issue");
177
- }
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));
178
315
  }
179
316
  const bytes = fileArg === "-" ? new Uint8Array(readFileSync(0)) : new Uint8Array(readFileSync(fileArg));
180
- const filename = fileArg === "-" ? (keyHint ? basename(keyHint) : "stdin.bin") : basename(fileArg);
317
+ const sourceName = fileArg === "-" ? (keyHint ? basename(keyHint) : "stdin.bin") : basename(fileArg);
181
318
  const format = ctx.json
182
319
  ? "json"
183
320
  : (() => {
@@ -189,7 +326,15 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
189
326
  throw new UsageError(`invalid --format: ${raw}`);
190
327
  })();
191
328
  const defaults = resolvePutDefaults({ envFile: ctx.envFile });
192
- 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);
193
338
  const widthRaw = flagString(parsed.flags, "--width");
194
339
  const width = widthRaw && /^\d+$/.test(widthRaw) && Number(widthRaw) > 0
195
340
  ? Number.parseInt(widthRaw, 10)
@@ -200,24 +345,39 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
200
345
  : defaults.width;
201
346
  if (!ctx.quiet && format === "human") {
202
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`);
203
353
  }
204
354
  const noGit = flagBool(parsed.flags, "--no-git") || defaults.noGit === true;
205
- 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, {
206
359
  filename,
207
- key: ghTarget ? ghAttachmentKey(ghTarget, filename) : keyHint,
208
- prefix: flagString(parsed.flags, "--prefix") ?? defaults.prefix,
360
+ key,
361
+ prefix: resolvedPrefix ?? defaults.prefix,
209
362
  repo: flagString(parsed.flags, "--repo") ?? defaults.repo,
210
363
  ref: flagString(parsed.flags, "--ref") ?? defaults.ref,
211
- contentType: flagString(parsed.flags, "--content-type"),
364
+ contentType: prepared.optimized ? prepared.contentType : contentTypeOverride,
212
365
  deriveRepoFromGit: !noGit,
213
366
  });
214
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
+ };
215
375
  if (!ctx.quiet && format === "human") {
216
376
  process.stderr.write(`>> key: ${result.key}\n\n`);
217
377
  }
218
378
  switch (format) {
219
379
  case "json":
220
- await writeJson({ ...result, markdown });
380
+ await writeJson({ ...result, markdown, optimize: optimizeMeta, frame: prepared.frame });
221
381
  break;
222
382
  case "url":
223
383
  await writeStdout(`${result.url}\n`);
@@ -230,7 +390,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
230
390
  }
231
391
  if (wantComment && ghTarget) {
232
392
  try {
233
- const sync = await syncAttachmentsComment(ctx, ghTarget, run);
393
+ const sync = await syncAttachmentsComment(ctx.client, ghTarget, run);
234
394
  if (!ctx.quiet && format === "human") {
235
395
  process.stderr.write(`>> attachments comment ${sync.action}\n`);
236
396
  }
@@ -270,13 +430,8 @@ export async function runList(ctx, args, help = false, run = execRunner) {
270
430
  const limit = flagInt(parsed.flags, "--limit", "--limit");
271
431
  const cursor = flagString(parsed.flags, "--cursor");
272
432
  if (flagBool(parsed.flags, "--all")) {
273
- const items = [];
274
- let next = cursor;
275
- do {
276
- const page = await ctx.client.list({ prefix, limit, cursor: next ?? undefined });
277
- items.push(...page.items);
278
- next = page.cursor;
279
- } while (next);
433
+ // --all may start from a caller-provided --cursor and drains from there.
434
+ const items = await ctx.client.listAll({ prefix, limit, cursor });
280
435
  if (ctx.json)
281
436
  await writeJson({ items, cursor: null });
282
437
  else
@@ -347,7 +502,7 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
347
502
  const target = ghTargetFromFlags(parsed.flags, run);
348
503
  if (!target)
349
504
  throw new UsageError("comment requires --pr or --issue");
350
- const result = await syncAttachmentsComment(ctx, target, run);
505
+ const result = await syncAttachmentsComment(ctx.client, target, run);
351
506
  if (ctx.json) {
352
507
  await writeJson({ ...target, ...result });
353
508
  }
@@ -358,6 +513,83 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
358
513
  }
359
514
  return 0;
360
515
  }
516
+ // --- usage / reconcile / purge ---
517
+ const USAGE_HELP = `uploads usage [--workspace <name>]
518
+
519
+ Show workspace storage and monthly upload counters (and limits when set).
520
+
521
+ Examples:
522
+ uploads --env-file .env usage
523
+ uploads usage --json
524
+ `;
525
+ export async function runUsage(ctx, args, help = false) {
526
+ if (help || parseCommandArgs(args).help) {
527
+ process.stderr.write(USAGE_HELP);
528
+ return 0;
529
+ }
530
+ const result = await ctx.client.usage();
531
+ if (ctx.json) {
532
+ await writeJson(result);
533
+ return 0;
534
+ }
535
+ const lines = [
536
+ `workspace: ${result.workspace}`,
537
+ `bytes: ${result.bytes}${result.maxStorageBytes != null ? ` / ${result.maxStorageBytes} (${result.storageRemainingBytes} remaining)` : ""}`,
538
+ `objects: ${result.objects}`,
539
+ `uploads: ${result.uploadsInPeriod} this period (${result.periodStart})${result.maxUploadsPerPeriod != null ? ` / ${result.maxUploadsPerPeriod} (${result.uploadsRemaining} remaining)` : ""}`,
540
+ `updated: ${result.updatedAt}`,
541
+ ];
542
+ await writeStdout(lines.join("\n") + "\n");
543
+ return 0;
544
+ }
545
+ const RECONCILE_HELP = `uploads reconcile [--workspace <name>]
546
+
547
+ Rebuild ledger bytes/objects from storage (source of truth). Preserves the
548
+ monthly upload counter. Requires files:write.
549
+
550
+ Examples:
551
+ uploads --env-file .env reconcile
552
+ `;
553
+ export async function runReconcile(ctx, args, help = false) {
554
+ if (help || parseCommandArgs(args).help) {
555
+ process.stderr.write(RECONCILE_HELP);
556
+ return 0;
557
+ }
558
+ const result = await ctx.client.reconcile();
559
+ if (ctx.json) {
560
+ await writeJson(result);
561
+ return 0;
562
+ }
563
+ await writeStdout(result.changed
564
+ ? `reconciled ${result.workspace}: ${result.previous.bytes}→${result.bytes} bytes, ${result.previous.objects}→${result.objects} objects\n`
565
+ : `reconciled ${result.workspace}: unchanged (${result.bytes} bytes, ${result.objects} objects)\n`);
566
+ return 0;
567
+ }
568
+ const PURGE_HELP = `uploads purge-expired [--workspace <name>]
569
+
570
+ Delete objects older than the workspace retentionDays setting, then reconcile.
571
+ Skips if retention is unset. Requires files:delete.
572
+
573
+ Examples:
574
+ uploads --env-file .env purge-expired
575
+ `;
576
+ export async function runPurgeExpired(ctx, args, help = false) {
577
+ if (help || parseCommandArgs(args).help) {
578
+ process.stderr.write(PURGE_HELP);
579
+ return 0;
580
+ }
581
+ const result = await ctx.client.purgeExpired();
582
+ if (ctx.json) {
583
+ await writeJson(result);
584
+ return 0;
585
+ }
586
+ if ("skipped" in result) {
587
+ await writeStdout(`skipped: ${result.reason}\n`);
588
+ return 0;
589
+ }
590
+ await writeStdout(`purged ${result.deleted} object(s), freed ${result.freedBytes} bytes (retention ${result.retentionDays}d)\n`);
591
+ return 0;
592
+ }
361
593
  // --- health & doctor ---
362
594
  const HEALTH_HELP = `uploads health
363
595
 
@@ -391,23 +623,20 @@ Examples:
391
623
  uploads --env-file .env doctor
392
624
  uploads --workspace acme --env-file .env doctor
393
625
  `;
394
- export async function runDoctor(ctx, args, help = false) {
395
- if (help || parseCommandArgs(args).help) {
396
- process.stderr.write(DOCTOR_HELP);
397
- return 0;
398
- }
399
- const mismatch = workspaceMismatch(ctx.config);
626
+ /** Doctor's health + auth + workspace checks, shared by the CLI and the MCP tool. */
627
+ export async function buildDoctorReport(config, client) {
628
+ const mismatch = workspaceMismatch(config);
400
629
  const hints = [];
401
630
  if (mismatch)
402
631
  hints.push(mismatch);
403
- if (ctx.config.apiUrl.includes("localhost") || ctx.config.apiUrl.includes("127.0.0.1")) {
632
+ if (config.apiUrl.includes("localhost") || config.apiUrl.includes("127.0.0.1")) {
404
633
  hints.push("local API uses dev KV — prod tokens won't work unless minted with --local");
405
634
  }
406
- const health = await ctx.client.health();
635
+ const health = await client.health();
407
636
  let authOk = false;
408
637
  let authError;
409
638
  try {
410
- await ctx.client.list({ limit: 1 });
639
+ await client.list({ limit: 1 });
411
640
  authOk = true;
412
641
  }
413
642
  catch (err) {
@@ -416,35 +645,67 @@ export async function runDoctor(ctx, args, help = false) {
416
645
  hints.push("if this token works on api.uploads.sh, set UPLOADS_API_URL=https://api.uploads.sh");
417
646
  }
418
647
  }
419
- if (!ctx.config.configExists && !ctx.config.token) {
420
- hints.push(`run uploads setup to configure ${ctx.config.configPath}`);
648
+ let usage;
649
+ if (authOk) {
650
+ try {
651
+ const snap = await client.usage();
652
+ usage = {
653
+ ok: true,
654
+ bytes: snap.bytes,
655
+ objects: snap.objects,
656
+ uploadsInPeriod: snap.uploadsInPeriod,
657
+ };
658
+ }
659
+ catch (err) {
660
+ usage = {
661
+ ok: false,
662
+ error: err instanceof UploadsError ? err.message : String(err),
663
+ };
664
+ }
665
+ }
666
+ if (!config.configExists && !config.token) {
667
+ hints.push(`run uploads setup to configure ${config.configPath}`);
421
668
  }
422
- const report = {
669
+ return {
423
670
  ok: health.ok && authOk,
424
- apiUrl: ctx.config.apiUrl,
425
- workspace: ctx.config.workspace,
426
- workspaceSource: ctx.config.workspaceSource,
427
- workspaceFromToken: workspaceFromToken(ctx.config.token),
428
- configPath: ctx.config.configPath,
429
- configExists: ctx.config.configExists,
671
+ apiUrl: config.apiUrl,
672
+ workspace: config.workspace,
673
+ workspaceSource: config.workspaceSource,
674
+ workspaceFromToken: workspaceFromToken(config.token),
675
+ configPath: config.configPath,
676
+ configExists: config.configExists,
430
677
  health,
431
678
  auth: { ok: authOk, error: authError },
679
+ usage,
680
+ warning: mismatch,
432
681
  hints,
433
682
  };
683
+ }
684
+ export async function runDoctor(ctx, args, help = false) {
685
+ if (help || parseCommandArgs(args).help) {
686
+ process.stderr.write(DOCTOR_HELP);
687
+ return 0;
688
+ }
689
+ const report = await buildDoctorReport(ctx.config, ctx.client);
434
690
  if (ctx.json) {
435
691
  await writeJson(report);
436
692
  return report.ok ? 0 : 1;
437
693
  }
438
694
  const lines = [
439
- `config: ${ctx.config.configPath}${ctx.config.configExists ? "" : " (missing)"}`,
440
- `api: ${ctx.config.apiUrl} (${health.ok ? "ok" : "failed"})`,
441
- `workspace: ${ctx.config.workspace}`,
442
- `auth: ${authOk ? "ok" : `failed — ${authError ?? "no token"}`}`,
695
+ `config: ${report.configPath}${report.configExists ? "" : " (missing)"}`,
696
+ `api: ${report.apiUrl} (${report.health.ok ? "ok" : "failed"})`,
697
+ `workspace: ${report.workspace}`,
698
+ `auth: ${report.auth.ok ? "ok" : `failed — ${report.auth.error ?? "no token"}`}`,
443
699
  ];
444
- if (mismatch)
445
- lines.push(`warning: ${mismatch}`);
446
- for (const h of hints)
447
- if (h !== mismatch)
700
+ if (report.usage) {
701
+ lines.push(report.usage.ok
702
+ ? `usage: ${report.usage.bytes} bytes, ${report.usage.objects} objects, ${report.usage.uploadsInPeriod} uploads this period`
703
+ : `usage: failed ${report.usage.error ?? "unknown"}`);
704
+ }
705
+ if (report.warning)
706
+ lines.push(`warning: ${report.warning}`);
707
+ for (const h of report.hints)
708
+ if (h !== report.warning)
448
709
  lines.push(`hint: ${h}`);
449
710
  await writeStdout(lines.join("\n") + "\n");
450
711
  return report.ok ? 0 : 1;
@@ -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;