@buildinternet/uploads 0.5.0 → 0.7.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
@@ -6,12 +6,13 @@ import { resolvePutDefaults, workspaceMismatch, workspaceFromToken, } from "./co
6
6
  import { buildMarkdown } from "./embed.js";
7
7
  import { UploadsError } from "./errors.js";
8
8
  import { writeJson, writeStdout } from "./io.js";
9
- import { ghAttachmentKey, ghKeyPrefix, attachmentsCommentBody, } from "./github.js";
9
+ import { ghAttachmentKey, ghKeyPrefix, attachmentsCommentBody, normalizeGithubCoordinate, } from "./github.js";
10
10
  import { resolveRepo, resolveCurrentPullRequest, execRunner, upsertAttachmentsComment, } from "./github-gh.js";
11
11
  import { resolvePutPrefix } from "./destinations.js";
12
12
  import { optimizeImageForUpload, rewriteKeyExtension, } from "./optimize.js";
13
13
  import { applyFrame, resolveFrameId } from "./frame.js";
14
14
  import { buildCliProvenance } from "./provenance.js";
15
+ import { packageVersion } from "./package-version.js";
15
16
  // --- put ---
16
17
  const PUT_HELP = `uploads put <file> [options]
17
18
 
@@ -50,13 +51,15 @@ Options:
50
51
  --format human|url|markdown|json
51
52
  --pr <num> Attach to a pull request: key gh/<owner>/<repo>/pull/<num>/<name> (stable URL, no hash)
52
53
  --issue <num> Attach to an issue: key gh/<owner>/<repo>/issues/<num>/<name>
53
- --comment With --pr/--issue: create/update the attachments comment via your local gh auth
54
+ --comment With --pr/--issue: update one managed comment with attachments and linked galleries via local gh auth
55
+ --gallery <id> Add the uploaded object to this public gallery
54
56
 
55
57
  Examples:
56
58
  uploads put ./shot.png --repo myorg/myapp --ref 1722 --alt "New cards" --width 700
57
59
  uploads put ./mobile.png --frame phone
58
60
  uploads put ./ui.png --frame browser --frame-url "https://app.example/settings"
59
61
  uploads put ./shot.png --destination screenshots
62
+ uploads put ./after.png --gallery gal_example
60
63
  `;
61
64
  /**
62
65
  * Turns a pr/issue pair (+ optional repo) into a GhTarget; undefined when
@@ -158,11 +161,43 @@ function frameOptionsFromFlags(flags) {
158
161
  */
159
162
  export async function syncAttachmentsComment(client, target, run) {
160
163
  const items = (await client.listAll({ prefix: ghKeyPrefix(target) })).map(({ key, url }) => ({ key, url }));
161
- if (items.length === 0)
164
+ const galleries = [];
165
+ let cursor;
166
+ do {
167
+ const page = await client.findGalleriesByReference({
168
+ provider: "github",
169
+ // GitHub references intentionally do not distinguish PRs from issues.
170
+ coordinate: `${target.repo.toLowerCase()}#${target.num}`,
171
+ cursor,
172
+ });
173
+ galleries.push(...page.galleries.map(({ id, title, url }) => ({ title, url, id })));
174
+ cursor = page.nextCursor ?? undefined;
175
+ } while (cursor);
176
+ const previewGalleries = await Promise.all(galleries.map(async ({ id, ...gallery }) => {
177
+ try {
178
+ const detail = await client.getGallery(id);
179
+ return {
180
+ ...gallery,
181
+ previews: detail.items
182
+ .filter((item) => item.status === "available" && item.url && item.contentType?.startsWith("image/"))
183
+ .slice(0, 3)
184
+ .map((item) => ({
185
+ url: item.url,
186
+ alt: item.altText ?? item.objectKey,
187
+ itemUrl: item.pageUrl,
188
+ })),
189
+ };
190
+ }
191
+ catch {
192
+ // A deleted or temporarily unavailable gallery still gets a safe title link.
193
+ return gallery;
194
+ }
195
+ }));
196
+ if (items.length === 0 && previewGalleries.length === 0)
162
197
  return { action: "skipped", count: 0 };
163
- const body = attachmentsCommentBody(items);
198
+ const body = attachmentsCommentBody(items, previewGalleries);
164
199
  const { created } = upsertAttachmentsComment(target, body, run);
165
- return { action: created ? "created" : "updated", count: items.length };
200
+ return { action: created ? "created" : "updated", count: items.length + previewGalleries.length };
166
201
  }
167
202
  // --- attach ---
168
203
  const ATTACH_HELP = `uploads attach <file...> [options]
@@ -302,6 +337,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
302
337
  const prefixFlag = flagString(parsed.flags, "--prefix");
303
338
  const ghTarget = ghTargetFromFlags(parsed.flags, run);
304
339
  const wantComment = parsed.flags.has("--comment");
340
+ const galleryId = flagString(parsed.flags, "--gallery");
305
341
  if (wantComment && typeof parsed.flags.get("--comment") === "string") {
306
342
  throw new UsageError("--comment takes no value — place it after the file argument");
307
343
  }
@@ -386,6 +422,22 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
386
422
  }),
387
423
  });
388
424
  const markdown = buildMarkdown(result.url, { alt, width });
425
+ let gallery;
426
+ if (galleryId) {
427
+ try {
428
+ // Gallery mutations use optimistic versions. Fetch immediately before this
429
+ // mutation so `put --gallery` composes safely with other CLI writers.
430
+ const current = await ctx.client.getGallery(galleryId);
431
+ const item = await ctx.client.addGalleryItem(galleryId, result.key, {
432
+ expectedVersion: current.version,
433
+ altText: alt,
434
+ });
435
+ gallery = { id: galleryId, url: current.url, item };
436
+ }
437
+ catch (err) {
438
+ gallery = { id: galleryId, error: galleryError(err) };
439
+ }
440
+ }
389
441
  const optimizeMeta = {
390
442
  optimized: prepared.optimized,
391
443
  skippedReason: prepared.skippedReason,
@@ -398,7 +450,13 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
398
450
  }
399
451
  switch (format) {
400
452
  case "json":
401
- await writeJson({ ...result, markdown, optimize: optimizeMeta, frame: prepared.frame });
453
+ await writeJson({
454
+ ...result,
455
+ markdown,
456
+ optimize: optimizeMeta,
457
+ frame: prepared.frame,
458
+ gallery,
459
+ });
402
460
  break;
403
461
  case "url":
404
462
  await writeStdout(`${result.url}\n`);
@@ -407,7 +465,13 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
407
465
  await writeStdout(`${markdown}\n`);
408
466
  break;
409
467
  default:
410
- await writeStdout(`URL: ${result.url}\nMARKDOWN: ${markdown}\n`);
468
+ await writeStdout(`URL: ${result.url}\nMARKDOWN: ${markdown}${gallery?.url ? `\nGALLERY: ${gallery.url}` : ""}\n`);
469
+ }
470
+ if (gallery?.url && format !== "human") {
471
+ process.stderr.write(`gallery: ${gallery.url}\n`);
472
+ }
473
+ if (gallery?.error) {
474
+ process.stderr.write(`warning: upload succeeded but adding it to gallery ${gallery.id} failed: ${gallery.error.message}\n`);
411
475
  }
412
476
  if (wantComment && ghTarget) {
413
477
  try {
@@ -421,7 +485,222 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
421
485
  process.stderr.write(`warning: upload succeeded but the GitHub comment failed (is gh installed and authenticated?): ${err instanceof Error ? err.message : String(err)}\n`);
422
486
  }
423
487
  }
424
- return 0;
488
+ return gallery?.error ? 1 : 0;
489
+ }
490
+ function galleryError(err) {
491
+ if (err instanceof UploadsError)
492
+ return { message: err.message, code: err.code, status: err.status };
493
+ return { message: err instanceof Error ? err.message : String(err) };
494
+ }
495
+ // --- galleries ---
496
+ const GALLERY_HELP = `uploads gallery <command> [args]
497
+
498
+ Public galleries can be viewed by anyone who knows the URL. Do not add sensitive media.
499
+ Deleting a gallery only removes the gallery record; it never deletes its uploaded objects.
500
+
501
+ Commands:
502
+ create --title <title> [--description <text>]
503
+ show <gallery-id>
504
+ list [--limit <n>] [--cursor <c>] [--all]
505
+ delete <gallery-id>
506
+ add <gallery-id> <object-key...> [--caption <text>] [--alt <text>]
507
+ link <gallery-id> --github <owner/repo#number|github-url>
508
+ unlink <gallery-id> --github <owner/repo#number|github-url>
509
+ list --github <owner/repo#number|github-url> [--limit <n>] [--cursor <c>] [--all]
510
+
511
+ Examples:
512
+ uploads gallery create --title "Settings redesign"
513
+ uploads gallery add gal_example screenshots/app/after.webp --alt "Updated settings page"
514
+ uploads gallery show gal_example
515
+ uploads gallery link gal_example --github buildinternet/uploads#58
516
+ uploads gallery list --github https://github.com/buildinternet/uploads/pull/58
517
+ `;
518
+ function githubCoordinateFromFlags(flags) {
519
+ const value = flagString(flags, "--github");
520
+ if (!value)
521
+ throw new UsageError("--github requires an owner/repo#number coordinate or GitHub issue/PR URL");
522
+ const normalized = normalizeGithubCoordinate(value);
523
+ if (!normalized)
524
+ throw new UsageError("--github must be owner/repo#number or an https://github.com/.../issues|pull/number URL");
525
+ return normalized.coordinate;
526
+ }
527
+ export async function runGallery(ctx, args, help = false) {
528
+ const parsed = parseCommandArgs(args);
529
+ const action = parsed.positionals[0];
530
+ if (help || parsed.help || !action) {
531
+ process.stderr.write(GALLERY_HELP);
532
+ return help || parsed.help ? 0 : 2;
533
+ }
534
+ switch (action) {
535
+ case "create": {
536
+ const title = flagString(parsed.flags, "--title");
537
+ if (!title)
538
+ throw new UsageError("gallery create requires --title");
539
+ const gallery = await ctx.client.createGallery({
540
+ title,
541
+ description: flagString(parsed.flags, "--description"),
542
+ });
543
+ if (ctx.json)
544
+ await writeJson(gallery);
545
+ else
546
+ await writeStdout(`${gallery.url}\n`);
547
+ if (!ctx.quiet && !ctx.json)
548
+ process.stderr.write("warning: galleries are public to anyone with the URL\n");
549
+ return 0;
550
+ }
551
+ case "show": {
552
+ const id = parsed.positionals[1];
553
+ if (!id)
554
+ throw new UsageError("gallery show requires a gallery ID");
555
+ const gallery = await ctx.client.getGallery(id);
556
+ if (ctx.json)
557
+ await writeJson(gallery);
558
+ else
559
+ await writeStdout(`${gallery.url}\n`);
560
+ return 0;
561
+ }
562
+ case "list": {
563
+ const limit = flagInt(parsed.flags, "--limit", "--limit");
564
+ const cursor = flagString(parsed.flags, "--cursor");
565
+ const github = parsed.flags.has("--github")
566
+ ? githubCoordinateFromFlags(parsed.flags)
567
+ : undefined;
568
+ if (flagBool(parsed.flags, "--all")) {
569
+ const galleries = [];
570
+ let nextCursor = cursor;
571
+ do {
572
+ const page = github
573
+ ? await ctx.client.findGalleriesByReference({
574
+ provider: "github",
575
+ coordinate: github,
576
+ limit,
577
+ cursor: nextCursor,
578
+ })
579
+ : await ctx.client.listGalleries({ limit, cursor: nextCursor });
580
+ galleries.push(...page.galleries);
581
+ nextCursor = page.nextCursor ?? undefined;
582
+ } while (nextCursor);
583
+ if (ctx.json)
584
+ await writeJson({ galleries, nextCursor: null });
585
+ else
586
+ for (const gallery of galleries)
587
+ await writeStdout(`${gallery.id} ${gallery.url} ${gallery.title}\n`);
588
+ return 0;
589
+ }
590
+ const page = github
591
+ ? await ctx.client.findGalleriesByReference({
592
+ provider: "github",
593
+ coordinate: github,
594
+ limit,
595
+ cursor,
596
+ })
597
+ : await ctx.client.listGalleries({ limit, cursor });
598
+ if (ctx.json)
599
+ await writeJson(page);
600
+ else {
601
+ for (const gallery of page.galleries)
602
+ await writeStdout(`${gallery.id} ${gallery.url} ${gallery.title}\n`);
603
+ if (page.nextCursor)
604
+ process.stderr.write(`cursor: ${page.nextCursor}\n`);
605
+ }
606
+ return 0;
607
+ }
608
+ case "link": {
609
+ const id = parsed.positionals[1];
610
+ if (!id)
611
+ throw new UsageError("gallery link requires a gallery ID");
612
+ const coordinate = githubCoordinateFromFlags(parsed.flags);
613
+ const current = await ctx.client.getGallery(id);
614
+ const reference = await ctx.client.linkGalleryExternalReference(id, {
615
+ expectedVersion: current.version,
616
+ provider: "github",
617
+ coordinate,
618
+ });
619
+ if (ctx.json)
620
+ await writeJson({ galleryId: id, reference });
621
+ else
622
+ await writeStdout((reference.canonicalUrl ?? reference.coordinate) + "\n");
623
+ return 0;
624
+ }
625
+ case "unlink": {
626
+ const id = parsed.positionals[1];
627
+ if (!id)
628
+ throw new UsageError("gallery unlink requires a gallery ID");
629
+ const coordinate = githubCoordinateFromFlags(parsed.flags);
630
+ const references = await ctx.client.listGalleryExternalReferences(id);
631
+ const reference = references.references.find((entry) => entry.provider === "github" && entry.coordinate === coordinate);
632
+ if (!reference) {
633
+ const output = { galleryId: id, coordinate, deleted: false };
634
+ if (ctx.json)
635
+ await writeJson(output);
636
+ else if (!ctx.quiet)
637
+ process.stderr.write("GitHub reference was already absent\n");
638
+ return 0;
639
+ }
640
+ const current = await ctx.client.getGallery(id);
641
+ const result = await ctx.client.unlinkGalleryExternalReference(id, reference.id, {
642
+ expectedVersion: current.version,
643
+ });
644
+ if (ctx.json)
645
+ await writeJson({ galleryId: id, coordinate, ...result });
646
+ else if (!ctx.quiet)
647
+ process.stderr.write("unlinked " + coordinate + "\n");
648
+ return 0;
649
+ }
650
+ case "delete": {
651
+ const id = parsed.positionals[1];
652
+ if (!id)
653
+ throw new UsageError("gallery delete requires a gallery ID");
654
+ const current = await ctx.client.getGallery(id);
655
+ const result = await ctx.client.deleteGallery(id, { expectedVersion: current.version });
656
+ if (ctx.json)
657
+ await writeJson(result);
658
+ else if (!ctx.quiet)
659
+ process.stderr.write(`deleted gallery ${result.id} (objects kept)\n`);
660
+ return 0;
661
+ }
662
+ case "add": {
663
+ const id = parsed.positionals[1];
664
+ const keys = parsed.positionals.slice(2);
665
+ if (!id || keys.length === 0)
666
+ throw new UsageError("gallery add requires a gallery ID and one or more object keys");
667
+ const caption = flagString(parsed.flags, "--caption");
668
+ const altText = flagString(parsed.flags, "--alt");
669
+ const added = [];
670
+ let galleryUrl;
671
+ const failures = [];
672
+ for (const objectKey of keys) {
673
+ try {
674
+ // Always re-read before the next write: each add increments the version,
675
+ // and this also avoids stale versions after an independent writer.
676
+ const current = await ctx.client.getGallery(id);
677
+ galleryUrl = current.url;
678
+ added.push(await ctx.client.addGalleryItem(id, objectKey, {
679
+ expectedVersion: current.version,
680
+ caption,
681
+ altText,
682
+ }));
683
+ }
684
+ catch (err) {
685
+ failures.push({ objectKey, error: galleryError(err) });
686
+ }
687
+ }
688
+ const output = { galleryId: id, galleryUrl: galleryUrl ?? null, added, failures };
689
+ if (ctx.json)
690
+ await writeJson(output);
691
+ else {
692
+ if (galleryUrl)
693
+ await writeStdout(`GALLERY: ${galleryUrl}\n`);
694
+ for (const item of added)
695
+ await writeStdout(`${item.objectKey}\n`);
696
+ for (const failure of failures)
697
+ process.stderr.write(`warning: could not add ${failure.objectKey}: ${failure.error.message}\n`);
698
+ }
699
+ return failures.length === 0 ? 0 : 1;
700
+ }
701
+ default:
702
+ throw new UsageError(`unknown gallery command: ${action}`);
703
+ }
425
704
  }
426
705
  // --- list ---
427
706
  const LIST_HELP = `uploads list [--prefix <p>] [--pr <num> | --issue <num>] [--repo <owner/name>] [--limit <n>] [--cursor <c>] [--all] [--workspace <name>]
@@ -474,8 +753,13 @@ export async function runList(ctx, args, help = false, run = execRunner) {
474
753
  // --- delete ---
475
754
  const DELETE_HELP = `uploads delete <key> [--dry-run] [--workspace <name>]
476
755
 
756
+ Options:
757
+ --dry-run Preview without deleting
758
+ --workspace, -w <name>
759
+
477
760
  Examples:
478
761
  uploads delete screenshots/myapp/42/shot-a1b2c3.png
762
+ uploads delete screenshots/myapp/42/shot-a1b2c3.png --dry-run
479
763
  `;
480
764
  export async function runDelete(ctx, args, help = false) {
481
765
  const parsed = parseCommandArgs(args);
@@ -643,6 +927,7 @@ Checks API health, token auth, and workspace/token alignment.
643
927
  Examples:
644
928
  uploads --env-file .env doctor
645
929
  uploads --workspace acme --env-file .env doctor
930
+ uploads doctor --json
646
931
  `;
647
932
  /** Doctor's health + auth + workspace checks, shared by the CLI and the MCP tool. */
648
933
  export async function buildDoctorReport(config, client) {
@@ -689,6 +974,7 @@ export async function buildDoctorReport(config, client) {
689
974
  }
690
975
  return {
691
976
  ok: health.ok && authOk,
977
+ cliVersion: packageVersion(),
692
978
  apiUrl: config.apiUrl,
693
979
  workspace: config.workspace,
694
980
  workspaceSource: config.workspaceSource,
@@ -713,6 +999,7 @@ export async function runDoctor(ctx, args, help = false) {
713
999
  return report.ok ? 0 : 1;
714
1000
  }
715
1001
  const lines = [
1002
+ `cli: @buildinternet/uploads@${report.cliVersion}`,
716
1003
  `config: ${report.configPath}${report.configExists ? "" : " (missing)"}`,
717
1004
  `api: ${report.apiUrl} (${report.health.ok ? "ok" : "failed"})`,
718
1005
  `workspace: ${report.workspace}`,
package/dist/github.d.ts CHANGED
@@ -5,9 +5,16 @@ export interface GhTarget {
5
5
  kind: GhTargetKind;
6
6
  num: number;
7
7
  }
8
+ /** A normalized GitHub issue/PR coordinate used for gallery references. */
9
+ export interface GithubCoordinate {
10
+ coordinate: string;
11
+ canonicalUrl: string;
12
+ }
8
13
  export declare function isValidRepo(repo: string): boolean;
9
14
  /** Parse "owner/name" from a git remote URL (SSH or HTTPS), else undefined. */
10
15
  export declare function parseRepoFromRemoteUrl(url: string): string | undefined;
16
+ /** Normalize a GitHub issue or pull-request coordinate for gallery linking. */
17
+ export declare function normalizeGithubCoordinate(value: string): GithubCoordinate | undefined;
11
18
  export declare function ghKeyPrefix(target: GhTarget): string;
12
19
  /**
13
20
  * Stable attachment key: same filename → same key → same public URL, so
@@ -21,6 +28,18 @@ export interface AttachmentItem {
21
28
  key: string;
22
29
  url: string | null;
23
30
  }
31
+ /** A public gallery linked to the PR or issue whose managed comment is syncing. */
32
+ export interface GalleryCommentItem {
33
+ title: string;
34
+ /** Canonical URL returned by the API; callers must not synthesize it. */
35
+ url: string;
36
+ /** A bounded set of available images; each links to its item page when known, else the gallery. */
37
+ previews?: {
38
+ url: string;
39
+ alt: string;
40
+ itemUrl?: string;
41
+ }[];
42
+ }
24
43
  /** Default max width for images in the managed attachments comment (HTML img). */
25
44
  export declare const ATTACHMENT_IMAGE_WIDTH_DEFAULT = 400;
26
45
  /** Portrait / device mockups — keep phones readable, not full-column. */
@@ -32,4 +51,8 @@ export declare const ATTACHMENT_IMAGE_WIDTH_WIDE = 640;
32
51
  * practical signal (we don't re-fetch dimensions when rebuilding the comment).
33
52
  */
34
53
  export declare function attachmentImageWidth(filename: string): number;
35
- export declare function attachmentsCommentBody(items: AttachmentItem[]): string;
54
+ /**
55
+ * Render the one marker-owned GitHub comment. When there are no galleries this
56
+ * intentionally preserves the legacy attachment-only body byte-for-byte.
57
+ */
58
+ export declare function attachmentsCommentBody(items: AttachmentItem[], galleries?: GalleryCommentItem[]): string;
package/dist/github.js CHANGED
@@ -10,6 +10,47 @@ export function parseRepoFromRemoteUrl(url) {
10
10
  const repo = match?.[1];
11
11
  return repo && isValidRepo(repo) ? repo : undefined;
12
12
  }
13
+ /** Normalize a GitHub issue or pull-request coordinate for gallery linking. */
14
+ export function normalizeGithubCoordinate(value) {
15
+ const input = value.trim();
16
+ let match = /^([^/\s#]+)\/([^/\s#]+)#([1-9][0-9]*)$/.exec(input);
17
+ if (!match) {
18
+ try {
19
+ const url = new URL(input);
20
+ if (url.protocol !== "https:" ||
21
+ url.hostname.toLowerCase() !== "github.com" ||
22
+ url.port ||
23
+ url.username ||
24
+ url.password ||
25
+ url.search ||
26
+ url.hash)
27
+ return undefined;
28
+ match = /^\/([^/]+)\/([^/]+)\/(?:issues|pull)\/([1-9][0-9]*)\/?$/.exec(url.pathname);
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ if (!match)
35
+ return undefined;
36
+ const [, ownerRaw, repositoryRaw, numberRaw] = match;
37
+ const repo = ownerRaw + "/" + repositoryRaw;
38
+ const number = Number(numberRaw);
39
+ if (!isValidRepo(repo) || !Number.isSafeInteger(number))
40
+ return undefined;
41
+ const owner = ownerRaw.toLowerCase();
42
+ const repository = repositoryRaw.toLowerCase();
43
+ const coordinate = owner + "/" + repository + "#" + number;
44
+ return {
45
+ coordinate,
46
+ canonicalUrl: "https://github.com/" +
47
+ encodeURIComponent(owner) +
48
+ "/" +
49
+ encodeURIComponent(repository) +
50
+ "/issues/" +
51
+ number,
52
+ };
53
+ }
13
54
  export function ghKeyPrefix(target) {
14
55
  const [owner, name] = target.repo.split("/");
15
56
  return `gh/${sanitizeKeySegment(owner)}/${sanitizeKeySegment(name)}/${target.kind}/${target.num}/`;
@@ -48,9 +89,32 @@ export function attachmentImageWidth(filename) {
48
89
  function escapeHtmlAttr(s) {
49
90
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
50
91
  }
51
- export function attachmentsCommentBody(items) {
92
+ function escapeHtmlText(s) {
93
+ return escapeHtmlAttr(s).replace(/'/g, "&#39;").replace(/>/g, "&gt;");
94
+ }
95
+ /**
96
+ * Render the one marker-owned GitHub comment. When there are no galleries this
97
+ * intentionally preserves the legacy attachment-only body byte-for-byte.
98
+ */
99
+ export function attachmentsCommentBody(items, galleries = []) {
52
100
  const sorted = items.toSorted((a, b) => a.key.localeCompare(b.key));
53
- const lines = [ATTACHMENTS_MARKER, "### 📎 Attachments", ""];
101
+ const sortedGalleries = galleries.toSorted((a, b) => a.title.localeCompare(b.title) || a.url.localeCompare(b.url));
102
+ const lines = [ATTACHMENTS_MARKER];
103
+ if (sortedGalleries.length > 0) {
104
+ lines.push("### 🖼️ Galleries", "");
105
+ for (const gallery of sortedGalleries) {
106
+ const href = escapeHtmlAttr(gallery.url);
107
+ lines.push(`#### <a href="${href}">${escapeHtmlText(gallery.title)}</a>`);
108
+ for (const preview of gallery.previews ?? []) {
109
+ const previewHref = preview.itemUrl ? escapeHtmlAttr(preview.itemUrl) : href;
110
+ lines.push(`<a href="${previewHref}"><img width="320" alt="${escapeHtmlAttr(preview.alt)}" src="${escapeHtmlAttr(preview.url)}"></a>`);
111
+ }
112
+ lines.push(`<sub><a href="${href}">Open gallery</a></sub>`, "");
113
+ }
114
+ lines.push("");
115
+ }
116
+ if (sorted.length > 0 || sortedGalleries.length === 0)
117
+ lines.push("### 📎 Attachments", "");
54
118
  for (const item of sorted) {
55
119
  const name = item.key.slice(item.key.lastIndexOf("/") + 1);
56
120
  if (item.url && inferContentType(name).startsWith("image/")) {
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export { sanitizeKeySegment, sha256Short, deriveRepoFromGit, buildScreenshotKey
3
3
  export { BUILTIN_DESTINATIONS, isBuiltinDestination, keyMatchesDestination, resolveDestinationRoot, resolvePutPrefix, type BuiltinDestinationId, } from "./destinations.js";
4
4
  export { DEFAULT_API_URL, DEFAULT_WORKSPACE, UPLOADS_CONFIG_KEYS, defaultConfigPath, resolveConfigPath, loadConfigFile, loadEnvFile, resolveApiUrl, resolveConfig, describeConfigSources, redactToken, writeConfigKeys, configValuesFromClient, putDefaultsToConfigValues, resolvePutDefaults, mergePutDefaults, workspaceFromToken, workspaceMismatch, type UploadsClientConfig, type ResolvedConfig, type WorkspaceSource, type ConfigValueSource, type ConfigSources, type UploadsConfigKey, type UploadsConfigValues, type PutDefaults, } from "./config.js";
5
5
  export { UploadsError, type UploadsErrorCode } from "./errors.js";
6
- export { createUploadsClient, type UploadsClient, type PutOptions, type ProvenanceInput, type ListOptions, type PutResult, type ListItem, type ListResult, type HeadResult, type DeleteResult, type HealthResult, type UsageResult, type ReconcileResult, type PurgeExpiredResult, type PurgeExpiredResponse, } from "./client.js";
6
+ export { createUploadsClient, type UploadsClient, type PutOptions, type ProvenanceInput, type ListOptions, type PutResult, type ListItem, type ListResult, type HeadResult, type DeleteResult, type GalleryItem, type Gallery, type GallerySummary, type GalleryListOptions, type GalleryListResult, type CreateGalleryOptions, type AddGalleryItemOptions, type DeleteGalleryOptions, type HealthResult, type UsageResult, type ReconcileResult, type PurgeExpiredResult, type PurgeExpiredResponse, } from "./client.js";
7
7
  export { buildCliProvenance } from "./provenance.js";
8
8
  export { ATTACHMENTS_MARKER, attachmentsCommentBody, ghAttachmentKey, ghKeyPrefix, isValidRepo, parseRepoFromRemoteUrl, type AttachmentItem, type GhTarget, type GhTargetKind, } from "./github.js";
9
9
  export { DEFAULT_OPTIMIZE_MAX_EDGE, DEFAULT_OPTIMIZE_QUALITY, optimizeImageForUpload, rewriteKeyExtension, withImageExtension, type OptimizeImageOptions, type OptimizeImageResult, type OptimizeOutputFormat, } from "./optimize.js";