@buildinternet/uploads 0.4.0 → 0.6.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.
@@ -1,8 +1,12 @@
1
1
  import { createEnrollment } from "../client.js";
2
- import { flagInt, flagString, parseCommandArgs, UsageError } from "../cli-args.js";
3
- const HELP = `uploads admin enrollment create [options]
2
+ import { flagBool, flagInt, flagString, parseCommandArgs, UsageError } from "../cli-args.js";
3
+ const HELP = `uploads admin invite create [options]
4
4
 
5
- Admin-only: create a short-lived, one-time enrollment code.
5
+ Admin-only: create a short-lived invitation for an existing workspace.
6
+ Prints one magic link whose URL fragment carries the single-use code — treat the
7
+ link like a password. Pass --separate-code for the legacy two-channel output (a
8
+ non-secret page URL plus a code you share separately). The legacy
9
+ "admin enrollment create" spelling is accepted.
6
10
 
7
11
  Options:
8
12
  --admin-token <token> Or ADMIN_TOKEN (UPLOADS_ADMIN_TOKEN is a legacy alias)
@@ -11,9 +15,39 @@ Options:
11
15
  --expires-in <seconds> Default: server policy
12
16
  --token-expires-in <seconds> Upload token lifetime (default: server policy)
13
17
  --scopes <list> Comma-separated files:read,files:write,files:delete
18
+ --email <address> Email the invite link to this recipient (from invites@uploads.sh)
19
+ --separate-code Two-channel output: non-secret page URL + separate code
14
20
  --api-url <url> Default: https://api.uploads.sh
21
+ --web-url <url> Invite-page origin (defaults from --api-url)
22
+
23
+ Examples:
24
+ uploads admin invite create --workspace acme --email user@example.com
25
+ uploads admin invite create --workspace acme --separate-code --json
26
+ uploads admin invite create --admin-token $ADMIN_TOKEN --workspace acme --label "onboarding"
15
27
  `;
16
28
  const FILE_SCOPES = new Set(["files:read", "files:write", "files:delete"]);
29
+ export function invitePageUrl(apiUrl, pageId, webUrl) {
30
+ let url;
31
+ try {
32
+ url = new URL(webUrl ?? apiUrl);
33
+ }
34
+ catch {
35
+ throw new UsageError("invalid invite web URL");
36
+ }
37
+ if (!webUrl && url.hostname.startsWith("api."))
38
+ url.hostname = url.hostname.slice(4);
39
+ url.pathname = "/invite";
40
+ url.search = "";
41
+ url.hash = "";
42
+ url.searchParams.set("id", pageId);
43
+ return url.toString();
44
+ }
45
+ // Compose the self-contained magic link. The one-time code rides in the URL
46
+ // fragment (#code=…), which browsers never send to the server, so opening the
47
+ // page neither leaks nor consumes it — only the CLI's exchange call redeems it.
48
+ export function inviteMagicLink(pageUrl, code) {
49
+ return `${pageUrl}#code=${encodeURIComponent(code)}`;
50
+ }
17
51
  export function parseScopes(raw) {
18
52
  if (raw === undefined)
19
53
  return undefined;
@@ -34,26 +68,44 @@ export async function runAdmin(args, opts, help = false) {
34
68
  process.stderr.write(HELP);
35
69
  return 0;
36
70
  }
37
- if (parsed.positionals[0] !== "enrollment" || parsed.positionals[1] !== "create")
38
- throw new UsageError("expected: uploads admin enrollment create");
71
+ if (!["invite", "enrollment"].includes(parsed.positionals[0] ?? "") ||
72
+ parsed.positionals[1] !== "create")
73
+ throw new UsageError("expected: uploads admin invite create");
39
74
  const adminToken = flagString(parsed.flags, "--admin-token") ??
40
75
  process.env.ADMIN_TOKEN ??
41
76
  process.env.UPLOADS_ADMIN_TOKEN;
42
77
  if (!adminToken)
43
78
  throw new UsageError("ADMIN_TOKEN is required for admin enrollment creation");
44
79
  const apiUrl = flagString(parsed.flags, "--api-url") ?? opts.apiUrl ?? "https://api.uploads.sh";
80
+ const webUrl = flagString(parsed.flags, "--web-url");
45
81
  const workspace = flagString(parsed.flags, "--workspace") ?? "default";
46
82
  const label = flagString(parsed.flags, "--label");
83
+ const email = flagString(parsed.flags, "--email");
47
84
  const result = await createEnrollment(apiUrl, adminToken, {
48
85
  workspace,
49
86
  label,
87
+ email,
50
88
  enrollmentSeconds: flagInt(parsed.flags, "--expires-in", "--expires-in"),
51
89
  tokenExpiresInSeconds: flagInt(parsed.flags, "--token-expires-in", "--token-expires-in"),
52
90
  scopes: parseScopes(flagString(parsed.flags, "--scopes")),
53
91
  });
54
- if (opts.json)
55
- process.stdout.write(JSON.stringify({ workspace, label: label ?? null, ...result }, null, 2) + "\n");
92
+ const separateCode = flagBool(parsed.flags, "--separate-code");
93
+ const pageUrl = invitePageUrl(apiUrl, result.pageId, webUrl);
94
+ const link = separateCode ? pageUrl : inviteMagicLink(pageUrl, result.code);
95
+ const footer = `workspace: ${workspace}\nexpires: ${result.expiresAt}\n`;
96
+ if (opts.json) {
97
+ process.stdout.write(JSON.stringify({ workspace, label: label ?? null, url: link, ...result }, null, 2) + "\n");
98
+ return 0;
99
+ }
100
+ if (email && result.emailed) {
101
+ process.stdout.write(`Invite emailed to ${email}\n${footer}`);
102
+ return 0;
103
+ }
104
+ if (email && result.emailed === false)
105
+ process.stderr.write("warning: email delivery failed; share this link instead\n");
106
+ if (separateCode)
107
+ process.stdout.write(`Invite page: ${pageUrl}\nOne-time code (share separately): ${result.code}\n${footer}`);
56
108
  else
57
- process.stdout.write(`Enrollment code (share once): ${result.code}\nworkspace: ${workspace}\nexpires: ${result.expiresAt}\n`);
109
+ process.stdout.write(`Invite link (contains the one-time code — treat like a password):\n${link}\n${footer}`);
58
110
  return 0;
59
111
  }
@@ -15,6 +15,12 @@ Options:
15
15
  --path <file> Config destination
16
16
  --force Replace existing saved credentials
17
17
  --no-check Skip doctor verification
18
+
19
+ Examples:
20
+ uploads login --code upe_…
21
+ uploads login --code-stdin --non-interactive < code.txt
22
+ printf '%s' upe_… | uploads login --code-stdin --non-interactive
23
+ uploads login --code upe_… --force --no-check
18
24
  `;
19
25
  export function validateEnrollmentCode(raw) {
20
26
  const code = raw.trim();
@@ -1,8 +1,8 @@
1
- import { createRequire } from "node:module";
2
1
  import { parseCommandArgs } from "../cli-args.js";
3
2
  import { createMcpServer } from "../mcp/server.js";
4
3
  import { serveStdio } from "../mcp/stdio.js";
5
4
  import { createUploadsMcpTools } from "../mcp/tools.js";
5
+ import { packageVersion } from "../package-version.js";
6
6
  const MCP_HELP = `uploads [globals] mcp
7
7
 
8
8
  Serve the Model Context Protocol (MCP) over stdio for agent clients. Tools
@@ -22,16 +22,13 @@ Examples:
22
22
  uploads --env-file .env mcp
23
23
  uploads --token up_default_… mcp
24
24
  `;
25
- // Same relative depth from src/commands/ and dist/commands/, so this works
26
- // both under vitest (src) and at runtime (dist).
27
- const { version } = createRequire(import.meta.url)("../../package.json");
28
25
  export async function runMcp(args, opts, help = false) {
29
26
  if (help || parseCommandArgs(args).help) {
30
27
  process.stderr.write(MCP_HELP);
31
28
  return 0;
32
29
  }
33
30
  const server = createMcpServer({
34
- serverInfo: { name: "uploads", version },
31
+ serverInfo: { name: "uploads", version: packageVersion() },
35
32
  tools: createUploadsMcpTools({ globals: opts.globals }),
36
33
  });
37
34
  await serveStdio(server);
@@ -41,6 +41,7 @@ export declare function syncAttachmentsComment(client: UploadsClient, target: Gh
41
41
  }>;
42
42
  export declare function runAttach(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
43
43
  export declare function runPut(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
44
+ export declare function runGallery(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
44
45
  export declare function runList(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
45
46
  export declare function runDelete(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
46
47
  export declare function runComment(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
@@ -52,6 +53,8 @@ export declare function runHealth(ctx: Pick<CliContext, "json"> & {
52
53
  }, args: string[], help?: boolean): Promise<number>;
53
54
  export interface DoctorReport {
54
55
  ok: boolean;
56
+ /** Installed @buildinternet/uploads package version. */
57
+ cliVersion: string;
55
58
  apiUrl: string;
56
59
  workspace: string;
57
60
  workspaceSource: ResolvedConfig["workspaceSource"];
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
 
@@ -25,6 +26,10 @@ upload as-is, or --keep-exif when image metadata matters for the discussion.
25
26
  Optional --frame wraps the image in a device/browser chrome before optimize
26
27
  (default off). See: uploads put --help frames
27
28
 
29
+ Uploads are public. --pr/--issue keys include the repo, number, and filename and
30
+ remain public even for private/internal GitHub repositories. Upload only media
31
+ that is safe at a predictable public URL.
32
+
28
33
  Options:
29
34
  --key <key> Object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.<ext>)
30
35
  --destination <id> Typed root: screenshots | gh | f (sets --prefix)
@@ -46,13 +51,15 @@ Options:
46
51
  --format human|url|markdown|json
47
52
  --pr <num> Attach to a pull request: key gh/<owner>/<repo>/pull/<num>/<name> (stable URL, no hash)
48
53
  --issue <num> Attach to an issue: key gh/<owner>/<repo>/issues/<num>/<name>
49
- --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
50
56
 
51
57
  Examples:
52
58
  uploads put ./shot.png --repo myorg/myapp --ref 1722 --alt "New cards" --width 700
53
59
  uploads put ./mobile.png --frame phone
54
60
  uploads put ./ui.png --frame browser --frame-url "https://app.example/settings"
55
61
  uploads put ./shot.png --destination screenshots
62
+ uploads put ./after.png --gallery gal_example
56
63
  `;
57
64
  /**
58
65
  * Turns a pr/issue pair (+ optional repo) into a GhTarget; undefined when
@@ -154,11 +161,39 @@ function frameOptionsFromFlags(flags) {
154
161
  */
155
162
  export async function syncAttachmentsComment(client, target, run) {
156
163
  const items = (await client.listAll({ prefix: ghKeyPrefix(target) })).map(({ key, url }) => ({ key, url }));
157
- 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) => ({ url: item.url, alt: item.altText ?? item.objectKey })),
185
+ };
186
+ }
187
+ catch {
188
+ // A deleted or temporarily unavailable gallery still gets a safe title link.
189
+ return gallery;
190
+ }
191
+ }));
192
+ if (items.length === 0 && previewGalleries.length === 0)
158
193
  return { action: "skipped", count: 0 };
159
- const body = attachmentsCommentBody(items);
194
+ const body = attachmentsCommentBody(items, previewGalleries);
160
195
  const { created } = upsertAttachmentsComment(target, body, run);
161
- return { action: created ? "created" : "updated", count: items.length };
196
+ return { action: created ? "created" : "updated", count: items.length + previewGalleries.length };
162
197
  }
163
198
  // --- attach ---
164
199
  const ATTACH_HELP = `uploads attach <file...> [options]
@@ -166,6 +201,10 @@ const ATTACH_HELP = `uploads attach <file...> [options]
166
201
  Upload one or more stable PR/issue attachments and maintain a single GitHub
167
202
  comment. With no target, uses the pull request for the current branch.
168
203
 
204
+ Attachments are public and their repo/number/filename keys are predictable.
205
+ Private/internal GitHub repository visibility does not restrict access; upload
206
+ only media that is safe at a public URL.
207
+
169
208
  Still images are optimized to WebP by default (same as put). Use --no-optimize
170
209
  to upload originals. Optional --frame wraps images in device/browser chrome.
171
210
 
@@ -294,6 +333,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
294
333
  const prefixFlag = flagString(parsed.flags, "--prefix");
295
334
  const ghTarget = ghTargetFromFlags(parsed.flags, run);
296
335
  const wantComment = parsed.flags.has("--comment");
336
+ const galleryId = flagString(parsed.flags, "--gallery");
297
337
  if (wantComment && typeof parsed.flags.get("--comment") === "string") {
298
338
  throw new UsageError("--comment takes no value — place it after the file argument");
299
339
  }
@@ -378,6 +418,22 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
378
418
  }),
379
419
  });
380
420
  const markdown = buildMarkdown(result.url, { alt, width });
421
+ let gallery;
422
+ if (galleryId) {
423
+ try {
424
+ // Gallery mutations use optimistic versions. Fetch immediately before this
425
+ // mutation so `put --gallery` composes safely with other CLI writers.
426
+ const current = await ctx.client.getGallery(galleryId);
427
+ const item = await ctx.client.addGalleryItem(galleryId, result.key, {
428
+ expectedVersion: current.version,
429
+ altText: alt,
430
+ });
431
+ gallery = { id: galleryId, url: current.url, item };
432
+ }
433
+ catch (err) {
434
+ gallery = { id: galleryId, error: galleryError(err) };
435
+ }
436
+ }
381
437
  const optimizeMeta = {
382
438
  optimized: prepared.optimized,
383
439
  skippedReason: prepared.skippedReason,
@@ -390,7 +446,13 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
390
446
  }
391
447
  switch (format) {
392
448
  case "json":
393
- await writeJson({ ...result, markdown, optimize: optimizeMeta, frame: prepared.frame });
449
+ await writeJson({
450
+ ...result,
451
+ markdown,
452
+ optimize: optimizeMeta,
453
+ frame: prepared.frame,
454
+ gallery,
455
+ });
394
456
  break;
395
457
  case "url":
396
458
  await writeStdout(`${result.url}\n`);
@@ -399,7 +461,13 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
399
461
  await writeStdout(`${markdown}\n`);
400
462
  break;
401
463
  default:
402
- await writeStdout(`URL: ${result.url}\nMARKDOWN: ${markdown}\n`);
464
+ await writeStdout(`URL: ${result.url}\nMARKDOWN: ${markdown}${gallery?.url ? `\nGALLERY: ${gallery.url}` : ""}\n`);
465
+ }
466
+ if (gallery?.url && format !== "human") {
467
+ process.stderr.write(`gallery: ${gallery.url}\n`);
468
+ }
469
+ if (gallery?.error) {
470
+ process.stderr.write(`warning: upload succeeded but adding it to gallery ${gallery.id} failed: ${gallery.error.message}\n`);
403
471
  }
404
472
  if (wantComment && ghTarget) {
405
473
  try {
@@ -413,7 +481,222 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
413
481
  process.stderr.write(`warning: upload succeeded but the GitHub comment failed (is gh installed and authenticated?): ${err instanceof Error ? err.message : String(err)}\n`);
414
482
  }
415
483
  }
416
- return 0;
484
+ return gallery?.error ? 1 : 0;
485
+ }
486
+ function galleryError(err) {
487
+ if (err instanceof UploadsError)
488
+ return { message: err.message, code: err.code, status: err.status };
489
+ return { message: err instanceof Error ? err.message : String(err) };
490
+ }
491
+ // --- galleries ---
492
+ const GALLERY_HELP = `uploads gallery <command> [args]
493
+
494
+ Public galleries can be viewed by anyone who knows the URL. Do not add sensitive media.
495
+ Deleting a gallery only removes the gallery record; it never deletes its uploaded objects.
496
+
497
+ Commands:
498
+ create --title <title> [--description <text>]
499
+ show <gallery-id>
500
+ list [--limit <n>] [--cursor <c>] [--all]
501
+ delete <gallery-id>
502
+ add <gallery-id> <object-key...> [--caption <text>] [--alt <text>]
503
+ link <gallery-id> --github <owner/repo#number|github-url>
504
+ unlink <gallery-id> --github <owner/repo#number|github-url>
505
+ list --github <owner/repo#number|github-url> [--limit <n>] [--cursor <c>] [--all]
506
+
507
+ Examples:
508
+ uploads gallery create --title "Settings redesign"
509
+ uploads gallery add gal_example screenshots/app/after.webp --alt "Updated settings page"
510
+ uploads gallery show gal_example
511
+ uploads gallery link gal_example --github buildinternet/uploads#58
512
+ uploads gallery list --github https://github.com/buildinternet/uploads/pull/58
513
+ `;
514
+ function githubCoordinateFromFlags(flags) {
515
+ const value = flagString(flags, "--github");
516
+ if (!value)
517
+ throw new UsageError("--github requires an owner/repo#number coordinate or GitHub issue/PR URL");
518
+ const normalized = normalizeGithubCoordinate(value);
519
+ if (!normalized)
520
+ throw new UsageError("--github must be owner/repo#number or an https://github.com/.../issues|pull/number URL");
521
+ return normalized.coordinate;
522
+ }
523
+ export async function runGallery(ctx, args, help = false) {
524
+ const parsed = parseCommandArgs(args);
525
+ const action = parsed.positionals[0];
526
+ if (help || parsed.help || !action) {
527
+ process.stderr.write(GALLERY_HELP);
528
+ return help || parsed.help ? 0 : 2;
529
+ }
530
+ switch (action) {
531
+ case "create": {
532
+ const title = flagString(parsed.flags, "--title");
533
+ if (!title)
534
+ throw new UsageError("gallery create requires --title");
535
+ const gallery = await ctx.client.createGallery({
536
+ title,
537
+ description: flagString(parsed.flags, "--description"),
538
+ });
539
+ if (ctx.json)
540
+ await writeJson(gallery);
541
+ else
542
+ await writeStdout(`${gallery.url}\n`);
543
+ if (!ctx.quiet && !ctx.json)
544
+ process.stderr.write("warning: galleries are public to anyone with the URL\n");
545
+ return 0;
546
+ }
547
+ case "show": {
548
+ const id = parsed.positionals[1];
549
+ if (!id)
550
+ throw new UsageError("gallery show requires a gallery ID");
551
+ const gallery = await ctx.client.getGallery(id);
552
+ if (ctx.json)
553
+ await writeJson(gallery);
554
+ else
555
+ await writeStdout(`${gallery.url}\n`);
556
+ return 0;
557
+ }
558
+ case "list": {
559
+ const limit = flagInt(parsed.flags, "--limit", "--limit");
560
+ const cursor = flagString(parsed.flags, "--cursor");
561
+ const github = parsed.flags.has("--github")
562
+ ? githubCoordinateFromFlags(parsed.flags)
563
+ : undefined;
564
+ if (flagBool(parsed.flags, "--all")) {
565
+ const galleries = [];
566
+ let nextCursor = cursor;
567
+ do {
568
+ const page = github
569
+ ? await ctx.client.findGalleriesByReference({
570
+ provider: "github",
571
+ coordinate: github,
572
+ limit,
573
+ cursor: nextCursor,
574
+ })
575
+ : await ctx.client.listGalleries({ limit, cursor: nextCursor });
576
+ galleries.push(...page.galleries);
577
+ nextCursor = page.nextCursor ?? undefined;
578
+ } while (nextCursor);
579
+ if (ctx.json)
580
+ await writeJson({ galleries, nextCursor: null });
581
+ else
582
+ for (const gallery of galleries)
583
+ await writeStdout(`${gallery.id} ${gallery.url} ${gallery.title}\n`);
584
+ return 0;
585
+ }
586
+ const page = github
587
+ ? await ctx.client.findGalleriesByReference({
588
+ provider: "github",
589
+ coordinate: github,
590
+ limit,
591
+ cursor,
592
+ })
593
+ : await ctx.client.listGalleries({ limit, cursor });
594
+ if (ctx.json)
595
+ await writeJson(page);
596
+ else {
597
+ for (const gallery of page.galleries)
598
+ await writeStdout(`${gallery.id} ${gallery.url} ${gallery.title}\n`);
599
+ if (page.nextCursor)
600
+ process.stderr.write(`cursor: ${page.nextCursor}\n`);
601
+ }
602
+ return 0;
603
+ }
604
+ case "link": {
605
+ const id = parsed.positionals[1];
606
+ if (!id)
607
+ throw new UsageError("gallery link requires a gallery ID");
608
+ const coordinate = githubCoordinateFromFlags(parsed.flags);
609
+ const current = await ctx.client.getGallery(id);
610
+ const reference = await ctx.client.linkGalleryExternalReference(id, {
611
+ expectedVersion: current.version,
612
+ provider: "github",
613
+ coordinate,
614
+ });
615
+ if (ctx.json)
616
+ await writeJson({ galleryId: id, reference });
617
+ else
618
+ await writeStdout((reference.canonicalUrl ?? reference.coordinate) + "\n");
619
+ return 0;
620
+ }
621
+ case "unlink": {
622
+ const id = parsed.positionals[1];
623
+ if (!id)
624
+ throw new UsageError("gallery unlink requires a gallery ID");
625
+ const coordinate = githubCoordinateFromFlags(parsed.flags);
626
+ const references = await ctx.client.listGalleryExternalReferences(id);
627
+ const reference = references.references.find((entry) => entry.provider === "github" && entry.coordinate === coordinate);
628
+ if (!reference) {
629
+ const output = { galleryId: id, coordinate, deleted: false };
630
+ if (ctx.json)
631
+ await writeJson(output);
632
+ else if (!ctx.quiet)
633
+ process.stderr.write("GitHub reference was already absent\n");
634
+ return 0;
635
+ }
636
+ const current = await ctx.client.getGallery(id);
637
+ const result = await ctx.client.unlinkGalleryExternalReference(id, reference.id, {
638
+ expectedVersion: current.version,
639
+ });
640
+ if (ctx.json)
641
+ await writeJson({ galleryId: id, coordinate, ...result });
642
+ else if (!ctx.quiet)
643
+ process.stderr.write("unlinked " + coordinate + "\n");
644
+ return 0;
645
+ }
646
+ case "delete": {
647
+ const id = parsed.positionals[1];
648
+ if (!id)
649
+ throw new UsageError("gallery delete requires a gallery ID");
650
+ const current = await ctx.client.getGallery(id);
651
+ const result = await ctx.client.deleteGallery(id, { expectedVersion: current.version });
652
+ if (ctx.json)
653
+ await writeJson(result);
654
+ else if (!ctx.quiet)
655
+ process.stderr.write(`deleted gallery ${result.id} (objects kept)\n`);
656
+ return 0;
657
+ }
658
+ case "add": {
659
+ const id = parsed.positionals[1];
660
+ const keys = parsed.positionals.slice(2);
661
+ if (!id || keys.length === 0)
662
+ throw new UsageError("gallery add requires a gallery ID and one or more object keys");
663
+ const caption = flagString(parsed.flags, "--caption");
664
+ const altText = flagString(parsed.flags, "--alt");
665
+ const added = [];
666
+ let galleryUrl;
667
+ const failures = [];
668
+ for (const objectKey of keys) {
669
+ try {
670
+ // Always re-read before the next write: each add increments the version,
671
+ // and this also avoids stale versions after an independent writer.
672
+ const current = await ctx.client.getGallery(id);
673
+ galleryUrl = current.url;
674
+ added.push(await ctx.client.addGalleryItem(id, objectKey, {
675
+ expectedVersion: current.version,
676
+ caption,
677
+ altText,
678
+ }));
679
+ }
680
+ catch (err) {
681
+ failures.push({ objectKey, error: galleryError(err) });
682
+ }
683
+ }
684
+ const output = { galleryId: id, galleryUrl: galleryUrl ?? null, added, failures };
685
+ if (ctx.json)
686
+ await writeJson(output);
687
+ else {
688
+ if (galleryUrl)
689
+ await writeStdout(`GALLERY: ${galleryUrl}\n`);
690
+ for (const item of added)
691
+ await writeStdout(`${item.objectKey}\n`);
692
+ for (const failure of failures)
693
+ process.stderr.write(`warning: could not add ${failure.objectKey}: ${failure.error.message}\n`);
694
+ }
695
+ return failures.length === 0 ? 0 : 1;
696
+ }
697
+ default:
698
+ throw new UsageError(`unknown gallery command: ${action}`);
699
+ }
417
700
  }
418
701
  // --- list ---
419
702
  const LIST_HELP = `uploads list [--prefix <p>] [--pr <num> | --issue <num>] [--repo <owner/name>] [--limit <n>] [--cursor <c>] [--all] [--workspace <name>]
@@ -466,8 +749,13 @@ export async function runList(ctx, args, help = false, run = execRunner) {
466
749
  // --- delete ---
467
750
  const DELETE_HELP = `uploads delete <key> [--dry-run] [--workspace <name>]
468
751
 
752
+ Options:
753
+ --dry-run Preview without deleting
754
+ --workspace, -w <name>
755
+
469
756
  Examples:
470
757
  uploads delete screenshots/myapp/42/shot-a1b2c3.png
758
+ uploads delete screenshots/myapp/42/shot-a1b2c3.png --dry-run
471
759
  `;
472
760
  export async function runDelete(ctx, args, help = false) {
473
761
  const parsed = parseCommandArgs(args);
@@ -635,6 +923,7 @@ Checks API health, token auth, and workspace/token alignment.
635
923
  Examples:
636
924
  uploads --env-file .env doctor
637
925
  uploads --workspace acme --env-file .env doctor
926
+ uploads doctor --json
638
927
  `;
639
928
  /** Doctor's health + auth + workspace checks, shared by the CLI and the MCP tool. */
640
929
  export async function buildDoctorReport(config, client) {
@@ -681,6 +970,7 @@ export async function buildDoctorReport(config, client) {
681
970
  }
682
971
  return {
683
972
  ok: health.ok && authOk,
973
+ cliVersion: packageVersion(),
684
974
  apiUrl: config.apiUrl,
685
975
  workspace: config.workspace,
686
976
  workspaceSource: config.workspaceSource,
@@ -705,6 +995,7 @@ export async function runDoctor(ctx, args, help = false) {
705
995
  return report.ok ? 0 : 1;
706
996
  }
707
997
  const lines = [
998
+ `cli: @buildinternet/uploads@${report.cliVersion}`,
708
999
  `config: ${report.configPath}${report.configExists ? "" : " (missing)"}`,
709
1000
  `api: ${report.apiUrl} (${report.health.ok ? "ok" : "failed"})`,
710
1001
  `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,17 @@ 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, all of which link back to the gallery. */
37
+ previews?: {
38
+ url: string;
39
+ alt: string;
40
+ }[];
41
+ }
24
42
  /** Default max width for images in the managed attachments comment (HTML img). */
25
43
  export declare const ATTACHMENT_IMAGE_WIDTH_DEFAULT = 400;
26
44
  /** Portrait / device mockups — keep phones readable, not full-column. */
@@ -32,4 +50,8 @@ export declare const ATTACHMENT_IMAGE_WIDTH_WIDE = 640;
32
50
  * practical signal (we don't re-fetch dimensions when rebuilding the comment).
33
51
  */
34
52
  export declare function attachmentImageWidth(filename: string): number;
35
- export declare function attachmentsCommentBody(items: AttachmentItem[]): string;
53
+ /**
54
+ * Render the one marker-owned GitHub comment. When there are no galleries this
55
+ * intentionally preserves the legacy attachment-only body byte-for-byte.
56
+ */
57
+ export declare function attachmentsCommentBody(items: AttachmentItem[], galleries?: GalleryCommentItem[]): string;