@buildinternet/uploads 0.49.0 → 0.50.1

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.
@@ -26,6 +26,7 @@ export const PUT_LIKE_FLAGS = [
26
26
  "--pr",
27
27
  "--issue",
28
28
  "--branch",
29
+ "--from-branch",
29
30
  "--comment",
30
31
  "--no-comment",
31
32
  "--format",
package/dist/client.d.ts CHANGED
@@ -441,6 +441,8 @@ export interface UsageResult {
441
441
  uploadsRemaining?: number;
442
442
  /** Bytes still on hosted storage (shared-lane residue). */
443
443
  sharedBytes?: number;
444
+ /** Objects still on hosted storage (shared-lane residue). */
445
+ sharedObjects?: number;
444
446
  /** "shared" = BYO bucket active: the storage cap meters only hosted
445
447
  * residue; the customer's own bucket is unmetered. */
446
448
  storageBudgetBasis?: "total" | "shared";
package/dist/commands.js CHANGED
@@ -649,6 +649,13 @@ export async function syncAttachmentsComment(client, target, run, workspace, opt
649
649
  // byte-identical.
650
650
  const path = metadata?.path;
651
651
  const state = metadata?.state;
652
+ // Server-derived image dimensions (parity with the bot path's
653
+ // COMMENT_META_KEYS hydration): finite positive numbers only, field
654
+ // omitted entirely when neither parses.
655
+ const imgWidth = Number(metadata?.["image.width"]);
656
+ const imgHeight = Number(metadata?.["image.height"]);
657
+ const hasImgWidth = Number.isFinite(imgWidth) && imgWidth > 0;
658
+ const hasImgHeight = Number.isFinite(imgHeight) && imgHeight > 0;
652
659
  return {
653
660
  key,
654
661
  url,
@@ -657,6 +664,14 @@ export async function syncAttachmentsComment(client, target, run, workspace, opt
657
664
  ...(path || state
658
665
  ? { meta: { ...(path ? { path } : {}), ...(state ? { state } : {}) } }
659
666
  : {}),
667
+ ...(hasImgWidth || hasImgHeight
668
+ ? {
669
+ imageMeta: {
670
+ ...(hasImgWidth ? { width: imgWidth } : {}),
671
+ ...(hasImgHeight ? { height: imgHeight } : {}),
672
+ },
673
+ }
674
+ : {}),
660
675
  };
661
676
  }));
662
677
  const galleries = [];
@@ -794,6 +809,12 @@ takes effect with zero files and cannot combine with --branch/--issue/
794
809
  --no-promote. Promotion never applies to issues. Staged files stay findable
795
810
  with "uploads find gh.branch=<branch>" either way.
796
811
 
812
+ If the branch was renamed or deleted before the PR opened, pass
813
+ "--from-branch <old-name>" with "--pr <num>". With no file arguments, this
814
+ promotes the stale branch prefix and refreshes the managed comment. With file
815
+ or existing-key arguments, it promotes the stale prefix before the normal
816
+ attach flow.
817
+
797
818
  Options:
798
819
  --pr <num> Attach to this pull request
799
820
  --issue <num> Attach to this issue
@@ -802,6 +823,8 @@ Options:
802
823
  --promote No files: promote branch-staged attachments into the
803
824
  resolved PR and refresh the comment; not with
804
825
  --branch/--issue/--no-promote
826
+ --from-branch <name> Promote staged attachments from this branch instead of
827
+ the current branch; requires a pull-request target
805
828
  --no-promote Skip auto-promoting branch-staged attachments (default path only)
806
829
  --move With an already-uploaded key/URL argument: delete the source
807
830
  object after a successful server-side copy (default: copy)
@@ -833,6 +856,7 @@ Examples:
833
856
  uploads attach ./artifact.zip --issue 45 --no-comment
834
857
  uploads attach ./shot.png --meta path=/settings --state after
835
858
  uploads attach ./shot.png --branch
859
+ uploads attach --pr 123 --from-branch old/branch
836
860
  uploads attach ./shot.png --branch feature/new-settings
837
861
  uploads attach --promote
838
862
  `;
@@ -1189,7 +1213,12 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
1189
1213
  if (parsed.flags.has("--move") && typeof parsed.flags.get("--move") === "string") {
1190
1214
  throw new UsageError("--move takes no value — place it after the file arguments");
1191
1215
  }
1192
- if (parsed.flags.has("--promote")) {
1216
+ if (parsed.flags.has("--from-branch") && !flagString(parsed.flags, "--from-branch")) {
1217
+ throw new UsageError("--from-branch requires a branch name");
1218
+ }
1219
+ const fromBranch = flagString(parsed.flags, "--from-branch");
1220
+ if (parsed.flags.has("--promote") ||
1221
+ (fromBranch !== undefined && parsed.positionals.length === 0)) {
1193
1222
  if (parsed.positionals.length > 0) {
1194
1223
  throw new UsageError("--promote takes no file arguments — attaching a file to a PR already auto-promotes " +
1195
1224
  "staged files; use `uploads attach <file> --pr <num>` instead");
@@ -1221,6 +1250,12 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
1221
1250
  throw new UsageError("--branch cannot be combined with --comment");
1222
1251
  return runAttachBranch(ctx, parsed, branchArg, run);
1223
1252
  }
1253
+ if (fromBranch !== undefined && parsed.flags.has("--issue")) {
1254
+ throw new UsageError("--from-branch cannot be combined with --issue");
1255
+ }
1256
+ if (fromBranch !== undefined && parsed.flags.has("--no-promote")) {
1257
+ throw new UsageError("--from-branch cannot be combined with --no-promote");
1258
+ }
1224
1259
  const explicitTarget = ghTargetFromFlags(parsed.flags, run);
1225
1260
  const target = explicitTarget ??
1226
1261
  resolveCurrentPullRequest(resolveRepo(flagString(parsed.flags, "--repo"), run), run);
@@ -1309,11 +1344,14 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
1309
1344
  let promotion;
1310
1345
  let promotedBranch;
1311
1346
  if (target.kind === "pull" && !parsed.flags.has("--no-promote")) {
1312
- try {
1313
- promotedBranch = resolveCurrentBranch(run);
1314
- }
1315
- catch {
1316
- promotedBranch = undefined;
1347
+ promotedBranch = fromBranch;
1348
+ if (promotedBranch === undefined) {
1349
+ try {
1350
+ promotedBranch = resolveCurrentBranch(run);
1351
+ }
1352
+ catch {
1353
+ promotedBranch = undefined;
1354
+ }
1317
1355
  }
1318
1356
  if (promotedBranch !== undefined) {
1319
1357
  promotion = await attemptPromoteBranch(ctx.client, target, promotedBranch);
@@ -1321,8 +1359,10 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
1321
1359
  }
1322
1360
  let comment;
1323
1361
  let commentError;
1324
- // Skip comment refresh when every upload failed nothing new from this batch.
1325
- if (!parsed.flags.has("--no-comment") && uploads.length > 0) {
1362
+ // Existing-key attach syncs server-side, but a stale-branch promotion runs
1363
+ // afterward. Refresh once more so that copy is visible in the same command.
1364
+ const shouldRefreshComment = uploads.length > 0 || (fromBranch !== undefined && attachedExisting.length > 0);
1365
+ if (!parsed.flags.has("--no-comment") && shouldRefreshComment) {
1326
1366
  try {
1327
1367
  comment = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace);
1328
1368
  }
@@ -1524,7 +1564,10 @@ async function runAttachPromoteOnly(ctx, parsed, run) {
1524
1564
  const explicitTarget = ghTargetFromFlags(parsed.flags, run);
1525
1565
  const target = explicitTarget ??
1526
1566
  resolveCurrentPullRequest(resolveRepo(flagString(parsed.flags, "--repo"), run), run);
1527
- const branch = resolveCurrentBranch(run);
1567
+ if (target.kind !== "pull") {
1568
+ throw new UsageError("--from-branch only promotes into a pull request");
1569
+ }
1570
+ const branch = flagString(parsed.flags, "--from-branch") ?? resolveCurrentBranch(run);
1528
1571
  const promotion = await attemptPromoteBranch(ctx.client, target, branch);
1529
1572
  let comment;
1530
1573
  let commentError;
@@ -107,6 +107,11 @@ export interface AttachmentItem {
107
107
  width?: number;
108
108
  height?: number;
109
109
  };
110
+ /** Server-derived pixel dimensions for an image (never client-settable). */
111
+ imageMeta?: {
112
+ width?: number;
113
+ height?: number;
114
+ };
110
115
  }
111
116
  /** A public gallery linked to the PR or issue whose managed comment is syncing. */
112
117
  export interface GalleryCommentItem {
@@ -144,6 +149,11 @@ export declare function attachmentPairWidth(density?: AttachmentDensity): number
144
149
  * only affects managed-comment auto layout; other callers leave it `"dense"`.
145
150
  */
146
151
  export declare function attachmentImageWidth(filename: string, density?: AttachmentDensity): number;
152
+ /**
153
+ * Icons and other small assets: both dimensions known and no edge above this
154
+ * flow inline at natural size rather than each becoming a full row.
155
+ */
156
+ export declare const SMALL_ASSET_MAX_EDGE = 200;
147
157
  /**
148
158
  * Render the one marker-owned GitHub comment. When there are no galleries this
149
159
  * intentionally preserves the legacy attachment-only body byte-for-byte.
@@ -183,19 +183,32 @@ function formatDuration(seconds) {
183
183
  return `${h}:${String(m).padStart(2, "0")}:${ss}`;
184
184
  }
185
185
  /**
186
- * Display width for a video poster. Real dimensions only *select* among the
187
- * density table's tiers — a raw 1920 would blow out the comment column — and
188
- * the result is capped at the real width so a small clip is never upscaled.
186
+ * Display width for an image or video-poster embed. Real dimensions only
187
+ * *select* among the density table's tiers — a raw 1920 would blow out the
188
+ * comment column — and the result is capped at the real width so a small
189
+ * asset is never upscaled into a blurry tile. Without dims (either missing),
190
+ * falls back to the filename heuristic exactly, keeping dimension-less
191
+ * renders byte-identical to before dims existed.
189
192
  */
190
- function posterImageWidth(videoMeta, filename, density = "dense") {
191
- const w = videoMeta?.width ?? 0;
192
- const h = videoMeta?.height ?? 0;
193
+ function naturalMediaWidth(dims, filename, density = "dense") {
194
+ const w = dims?.width ?? 0;
195
+ const h = dims?.height ?? 0;
193
196
  if (w <= 0 || h <= 0)
194
197
  return attachmentImageWidth(filename, density);
195
198
  const table = WIDTH_BY_DENSITY[density];
196
199
  const chosen = h > w ? table.portrait : w / h >= 16 / 9 ? table.wide : table.default;
197
200
  return Math.min(chosen, w);
198
201
  }
202
+ /**
203
+ * Icons and other small assets: both dimensions known and no edge above this
204
+ * flow inline at natural size rather than each becoming a full row.
205
+ */
206
+ export const SMALL_ASSET_MAX_EDGE = 200;
207
+ function isSmallAsset(dims) {
208
+ const w = dims?.width ?? 0;
209
+ const h = dims?.height ?? 0;
210
+ return w > 0 && h > 0 && Math.max(w, h) <= SMALL_ASSET_MAX_EDGE;
211
+ }
199
212
  function escapeHtmlAttr(s) {
200
213
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
201
214
  }
@@ -360,7 +373,7 @@ function renderPairCell(item, label, options, density) {
360
373
  const name = item.key.slice(item.key.lastIndexOf("/") + 1);
361
374
  const src = item.embedUrl ?? item.url;
362
375
  const link = item.pageUrl ?? item.url;
363
- const autoPx = Math.min(attachmentImageWidth(name, density), attachmentPairWidth(density));
376
+ const autoPx = Math.min(naturalMediaWidth(item.imageMeta, name, density), attachmentPairWidth(density));
364
377
  const w = resolvedWidth(autoPx, options);
365
378
  const alt = escapeHtmlAttr(name);
366
379
  const href = escapeHtmlAttr((link ?? src));
@@ -480,7 +493,7 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
480
493
  }
481
494
  if (isPosterVideo) {
482
495
  inlinedImages++;
483
- const autoPx = posterImageWidth(item.videoMeta, name, density);
496
+ const autoPx = naturalMediaWidth(item.videoMeta, name, density);
484
497
  const w = resolvedWidth(autoPx, options);
485
498
  const href = escapeHtmlAttr(link ?? item.posterUrl);
486
499
  lines.push(`<a href="${href}">${imgTag(w, escapeHtmlAttr(name), escapeHtmlAttr(item.posterUrl))}</a>`);
@@ -496,10 +509,53 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
496
509
  lines.push(parts.join(" · "), "");
497
510
  }
498
511
  else if (isImage) {
512
+ // Small-asset flow layout: consecutive captionless small images (icons)
513
+ // join onto one line so GitHub flows them horizontally at natural size
514
+ // instead of stacking a giant column per icon. Auto mode only — a
515
+ // numeric imageWidth override or "full" keeps today's one-per-row form.
516
+ const smallGroupable = (candidate, i) => {
517
+ if (options.imageWidth !== "auto")
518
+ return false;
519
+ if (consumedByPair.has(i) || partnerOf.has(i))
520
+ return false;
521
+ if (!isSmallAsset(candidate.imageMeta))
522
+ return false;
523
+ if (metaCaptionValues(candidate.meta, options).length > 0)
524
+ return false;
525
+ const n = candidate.key.slice(candidate.key.lastIndexOf("/") + 1);
526
+ const s = candidate.embedUrl ?? candidate.url;
527
+ return Boolean(s) && inferContentType(n).startsWith("image/");
528
+ };
529
+ if (smallGroupable(item, idx)) {
530
+ const group = [item];
531
+ let j = idx + 1;
532
+ // Each grouped image still counts toward maxInlineImages.
533
+ while (j < sorted.length &&
534
+ inlinedImages + group.length < options.maxInlineImages &&
535
+ smallGroupable(sorted[j], j)) {
536
+ group.push(sorted[j]);
537
+ j++;
538
+ }
539
+ if (group.length > 1) {
540
+ inlinedImages += group.length;
541
+ lines.push(group
542
+ .map((g) => {
543
+ const gName = g.key.slice(g.key.lastIndexOf("/") + 1);
544
+ const gSrc = (g.embedUrl ?? g.url);
545
+ const gHref = escapeHtmlAttr((g.pageUrl ?? g.url ?? gSrc));
546
+ // Natural width — a small asset is never upscaled.
547
+ const gW = g.imageMeta?.width;
548
+ return `<a href="${gHref}">${imgTag(gW, escapeHtmlAttr(gName), escapeHtmlAttr(gSrc))}</a>`;
549
+ })
550
+ .join(" "), "");
551
+ idx = j - 1;
552
+ continue;
553
+ }
554
+ }
499
555
  inlinedImages++;
500
556
  // Markdown ![]() has no width control — phone frames become full-column giants.
501
557
  // img src uses embed host when available (Camo revalidates); click-through prefers the file page.
502
- const autoPx = attachmentImageWidth(name, density);
558
+ const autoPx = naturalMediaWidth(item.imageMeta, name, density);
503
559
  const w = resolvedWidth(autoPx, options);
504
560
  const alt = escapeHtmlAttr(name);
505
561
  const href = escapeHtmlAttr(link ?? src);
@@ -191,13 +191,30 @@ export const usageResultSchema = objectSchema({
191
191
  workspace: { type: "string" },
192
192
  bytes: { type: "number" },
193
193
  objects: { type: "number" },
194
+ sharedBytes: { type: "number" },
195
+ sharedObjects: { type: "number" },
194
196
  uploadsInPeriod: { type: "number" },
195
197
  periodStart: { type: "string" },
196
198
  updatedAt: { type: "string" },
199
+ storageBudgetBasis: { type: "string", enum: ["total", "shared"] },
197
200
  maxStorageBytes: { type: "number" },
198
201
  storageRemainingBytes: { type: "number" },
199
202
  maxUploadsPerPeriod: { type: "number" },
200
203
  uploadsRemaining: { type: "number" },
204
+ // GET /:workspace/usage (routes/workspace-usage.ts) also stamps these on
205
+ // every response — not part of `usageWithLimits`'s return, so easy to miss.
206
+ scopes: { type: "array", items: { type: "string" } },
207
+ plan: { type: "string" },
208
+ storage: objectSchema({
209
+ mode: { type: "string", enum: ["shared", "byo"] },
210
+ fallbackLanes: { type: "number" },
211
+ health: objectSchema({
212
+ ok: { type: "boolean" },
213
+ code: { type: "string" },
214
+ message: { type: "string" },
215
+ since: { type: "string" },
216
+ }, ["ok"]),
217
+ }, ["mode", "fallbackLanes", "health"]),
201
218
  });
202
219
  export const reconcileResultSchema = objectSchema({
203
220
  workspace: { type: "string" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.49.0",
3
+ "version": "0.50.1",
4
4
  "mcpName": "sh.uploads/mcp",
5
5
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
6
6
  "type": "module",