@buildinternet/uploads 0.26.0 → 0.27.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
@@ -11,7 +11,8 @@ import { writeJson, writeStdout } from "./io.js";
11
11
  import { imageFactsFromBytes } from "./image-facts.js";
12
12
  import { parseMetaFlags, validateMetaMap } from "./metadata.js";
13
13
  import { mergeDerivedMeta, nearMissMetaWarnings, validateStateValue } from "./metadata-vocab.js";
14
- import { ghAttachmentKey, ghBranchAttachmentKey, ghBranchKeyPrefix, ghKeyPrefix, ghMetadataFromTarget, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, normalizeGithubCoordinate, } from "./github.js";
14
+ import { mergeSidecarMeta } from "./sidecar.js";
15
+ import { ghAttachmentKey, ghBranchAttachmentKey, ghBranchKeyPrefix, ghKeyPrefix, ghMetadataFromTarget, parseGhKey, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, normalizeGithubCoordinate, } from "./github.js";
15
16
  import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, resolveDefaultBranch, classifyGhNumber, execRunner, timedExecRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, } from "./github-gh.js";
16
17
  import { deriveRepoFromGit } from "./keys.js";
17
18
  import { resolvePutPrefix } from "./destinations.js";
@@ -56,6 +57,12 @@ upload as-is, or --keep-exif when image metadata matters for the discussion.
56
57
  Optional --frame wraps the image in a device/browser chrome before optimize
57
58
  (default off). See: uploads put --help frames
58
59
 
60
+ If the file has a sidecar manifest (<file>.uploads.json, written by
61
+ "screenshot --out") and its content hash still matches this file, that
62
+ capture's derived metadata (path/url/env/viewport/state) is merged in
63
+ automatically — explicit --meta/--state always win. A regenerated or edited
64
+ file loses its sidecar silently (hash no longer matches).
65
+
59
66
  Uploads are public. --pr/--issue keys include the repo, number, and filename and
60
67
  remain public even for private/internal GitHub repositories. Upload only media
61
68
  that is safe at a predictable public URL.
@@ -558,6 +565,12 @@ URL and every embed hot-swap. Human mode prints ">> replaced existing object
558
565
  Still images are optimized to WebP by default (same as put). Use --no-optimize
559
566
  to upload originals. Optional --frame wraps images in device/browser chrome.
560
567
 
568
+ If a file has a sidecar manifest (<file>.uploads.json, written by
569
+ "screenshot --out") and its content hash still matches, that capture's
570
+ derived metadata (path/url/env/viewport/state) is merged in automatically —
571
+ explicit --meta/--state always win. A regenerated or edited file loses its
572
+ sidecar silently (hash no longer matches).
573
+
561
574
  Branch staging (pre-PR): --branch [name] stages files against a git branch
562
575
  before a pull request exists, e.g. for a coding agent working a branch that
563
576
  hasn't opened a PR yet. Key: gh/<owner>/<repo>/branch/<branch>/<filename>
@@ -620,6 +633,21 @@ Examples:
620
633
  uploads attach ./shot.png --branch feature/new-settings
621
634
  uploads attach --promote
622
635
  `;
636
+ /**
637
+ * Lever 3 (issue #469): a nudge for when an image lands on a PR/issue with
638
+ * no `path` metadata — `path` is one of the highest-value queryable tags
639
+ * (same tier as `state=`), and unlike `uploads screenshot` (which derives it
640
+ * from the captured URL), a plain `attach`/`put --pr`/`put --issue` of an
641
+ * already-existing image has nothing to derive it from, so it's easy to
642
+ * forget. Fires once per batch (not per file) — checks the metadata the
643
+ * server actually stored (`PutResult.metadata`), not what was requested, so
644
+ * a merge/validation drop still surfaces the gap. Non-image uploads (zips,
645
+ * PDFs, etc.) are exempt — "findable by page" doesn't apply to them.
646
+ */
647
+ export function pathMetaHintFor(uploads) {
648
+ const missingPath = uploads.some((u) => u.contentType.startsWith("image/") && !u.metadata?.path);
649
+ return missingPath ? "tip: add --meta path=/route so this shot is findable by page" : undefined;
650
+ }
623
651
  /**
624
652
  * Shared prepare + put loop for both PR/issue attach (`uploadAttachments`)
625
653
  * and branch-staged attach (`uploadBranchAttachments`) — bounded concurrency,
@@ -634,11 +662,14 @@ async function uploadAttachmentBatch(opts) {
634
662
  try {
635
663
  const sourceName = basename(file);
636
664
  const bytes = readFileArg(file);
665
+ // Sidecar manifest from a prior `screenshot --out` of this exact file
666
+ // (issue #469 lever 2) — see mergeSidecarMeta.
667
+ const baseMetadata = mergeSidecarMeta(file, bytes, opts.metadata);
637
668
  // Same EXIF promotion uploadPreparedImage does; attach keeps its own
638
669
  // per-file tail (it builds keys differently), so it opts in here too.
639
670
  const metadata = opts.deriveImageFacts
640
- ? await mergeImageFacts(bytes, opts.metadata)
641
- : opts.metadata;
671
+ ? await mergeImageFacts(bytes, baseMetadata)
672
+ : baseMetadata;
642
673
  const prepared = await prepareImageForUpload(bytes, sourceName, {
643
674
  ...opts.frame,
644
675
  optimize: opts.optimize,
@@ -743,7 +774,11 @@ export async function uploadPuts(opts) {
743
774
  ? basename(opts.explicitKey)
744
775
  : "stdin.bin"
745
776
  : basename(file));
746
- const { result, prepared, markdown } = await uploadPreparedImage(opts.client, readFileArg(file), sourceName, {
777
+ const bytes = readFileArg(file);
778
+ // Sidecar manifest from a prior `screenshot --out` of this exact file
779
+ // (issue #469 lever 2) — see mergeSidecarMeta. Not applicable to stdin.
780
+ const metadata = file !== "-" ? mergeSidecarMeta(file, bytes, opts.metadata) : opts.metadata;
781
+ const { result, prepared, markdown } = await uploadPreparedImage(opts.client, bytes, sourceName, {
747
782
  frame: opts.frame,
748
783
  optimize: opts.optimize,
749
784
  ghTarget: opts.ghTarget,
@@ -756,7 +791,7 @@ export async function uploadPuts(opts) {
756
791
  contentType: opts.contentType,
757
792
  dryRun: opts.dryRun,
758
793
  replace: opts.replace,
759
- metadata: opts.metadata,
794
+ metadata,
760
795
  deriveImageFacts: opts.deriveImageFacts,
761
796
  provenanceClient: opts.provenanceClient,
762
797
  alt: () => opts.alt ?? basename(sourceName),
@@ -935,6 +970,8 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
935
970
  process.stderr.write(`warning: uploads succeeded but the GitHub comment failed (is gh installed and authenticated?): ${commentError}\n`);
936
971
  }
937
972
  }
973
+ // Lever 3 (issue #469): tip when an image lands here with no `path` meta.
974
+ const pathHint = uploads.length > 0 && !ctx.quiet ? pathMetaHintFor(uploads) : undefined;
938
975
  if (ctx.json) {
939
976
  await writeJson({
940
977
  target,
@@ -943,6 +980,7 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
943
980
  comment,
944
981
  commentError,
945
982
  promotion: promotion ?? null,
983
+ ...(pathHint ? { hint: pathHint } : {}),
946
984
  });
947
985
  }
948
986
  else {
@@ -971,6 +1009,8 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
971
1009
  const ref = ghMetadataFromTarget(target)["gh.ref"];
972
1010
  process.stderr.write(`>> find these later: uploads find gh.ref=${ref}\n`);
973
1011
  }
1012
+ if (pathHint)
1013
+ process.stderr.write(`${pathHint}\n`);
974
1014
  }
975
1015
  return failures.length === 0 ? 0 : 1;
976
1016
  }
@@ -1269,6 +1309,18 @@ export function resolvePutStagingTarget(opts) {
1269
1309
  return undefined; // gh/git unavailable, or repo unresolvable — dated layout
1270
1310
  }
1271
1311
  }
1312
+ /**
1313
+ * Merges a staging target's `gh.*` branch metadata over `base` and validates
1314
+ * the result (same builder, same contract as `attach --branch`) — the one
1315
+ * merge+validate step shared by every staging call site: `runPut`,
1316
+ * `runScreenshot`, and both the local stdio MCP `put` and `screenshot`
1317
+ * tools.
1318
+ */
1319
+ export function mergeStagingMeta(base, target) {
1320
+ const merged = { ...base, ...ghMetadataForBranch(target.repo, target.branch) };
1321
+ validateMetaMap(merged);
1322
+ return merged;
1323
+ }
1272
1324
  /**
1273
1325
  * The bare-put staging note's wording (issue #403): replaces the #393 nudge
1274
1326
  * for the (now default) case where a bare put on a non-default branch stages
@@ -1553,12 +1605,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1553
1605
  attachedRef = merged["gh.ref"];
1554
1606
  }
1555
1607
  else if (stagingTarget) {
1556
- const merged = {
1557
- ...userMeta,
1558
- ...ghMetadataForBranch(stagingTarget.repo, stagingTarget.branch),
1559
- };
1560
- validateMetaMap(merged); // matches attach --branch's unwrapped call — same builder, same contract
1561
- metadata = merged;
1608
+ metadata = mergeStagingMeta(userMeta, stagingTarget);
1562
1609
  }
1563
1610
  else {
1564
1611
  // gh.* additionally needs git, which the shared derived gate ignores.
@@ -1648,13 +1695,19 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1648
1695
  const bindingWarning = stagingTarget && uploads.length > 0
1649
1696
  ? await resolveStageBindingWarning({ ctx, defaults, repo: stagingTarget.repo })
1650
1697
  : undefined;
1698
+ // Lever 3 (issue #469): tip when a --pr/--issue put lands an image with no
1699
+ // `path` meta. Only relevant on the ghTarget path — the bare-put paths
1700
+ // above (staging/auto/dated) aren't attached to a PR/issue yet, so there's
1701
+ // nothing to look up from a page later.
1702
+ const pathHint = ghTarget && uploads.length > 0 && !ctx.quiet ? pathMetaHintFor(uploads) : undefined;
1651
1703
  // One JSON `hint` slot, shared with the #393 nudge (mutually exclusive with
1652
1704
  // it — nudge is undefined whenever staging took over). When staging fires,
1653
1705
  // prefer the more actionable binding warning over the generic staging note
1654
1706
  // (mirrors attach --branch, whose only JSON hint content IS the binding
1655
1707
  // warning); stderr prints the nudge/staging-note and binding-warning lines
1656
- // independently, below.
1657
- const jsonHint = nudge ?? bindingWarning ?? stagingNote;
1708
+ // independently, below. pathHint only ever fires on the ghTarget path, so
1709
+ // it never competes with the other three.
1710
+ const jsonHint = nudge ?? bindingWarning ?? stagingNote ?? pathHint;
1658
1711
  const galleriesByKey = new Map();
1659
1712
  let galleryHadError = false;
1660
1713
  if (galleryId && uploads.length > 0) {
@@ -1737,6 +1790,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1737
1790
  process.stderr.write(`${stagingNote}\n`);
1738
1791
  if (bindingWarning)
1739
1792
  process.stderr.write(`${bindingWarning}\n`);
1793
+ if (pathHint)
1794
+ process.stderr.write(`${pathHint}\n`);
1740
1795
  }
1741
1796
  return failures.length === 0 && !galleryHadError ? 0 : 1;
1742
1797
  }
@@ -1794,6 +1849,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1794
1849
  process.stderr.write(`${stagingNote}\n`);
1795
1850
  if (bindingWarning && format !== "json")
1796
1851
  process.stderr.write(`${bindingWarning}\n`);
1852
+ if (pathHint && format !== "json")
1853
+ process.stderr.write(`${pathHint}\n`);
1797
1854
  return gallery?.error ? 1 : 0;
1798
1855
  }
1799
1856
  // --- galleries ---
@@ -2173,12 +2230,50 @@ export async function runMeta(ctx, args, help = false) {
2173
2230
  else
2174
2231
  for (const [k, v] of Object.entries(result.metadata))
2175
2232
  await writeStdout(`${k}=${v}\n`);
2233
+ await resyncCommentAfterMetaSet(ctx, key, [...Object.keys(set ?? {}), ...del]);
2176
2234
  return 0;
2177
2235
  }
2178
2236
  default:
2179
2237
  throw new UsageError(`unknown meta command: ${action}`);
2180
2238
  }
2181
2239
  }
2240
+ /** The metadata keys the managed comment renders (path/state, PR #370). */
2241
+ const COMMENT_RENDERED_META_KEYS = ["path", "state"];
2242
+ /**
2243
+ * Best-effort managed-comment refresh after `meta set` touches a
2244
+ * display-relevant key on a PR/issue-keyed object (issue #470) — without
2245
+ * this, backfilled `path=`/`state=` never reaches the rendered comment until
2246
+ * an unrelated attach fires. Bot endpoint only (no gh fallback — this is a
2247
+ * metadata tweak, not an explicit comment command); any failure degrades to
2248
+ * a stderr hint instead of failing the metadata write that already landed.
2249
+ */
2250
+ async function resyncCommentAfterMetaSet(ctx, key, touchedKeys) {
2251
+ if (!touchedKeys.some((k) => COMMENT_RENDERED_META_KEYS.includes(k)))
2252
+ return;
2253
+ const target = parseGhKey(key);
2254
+ if (!target)
2255
+ return;
2256
+ try {
2257
+ const bot = await ctx.client.upsertGithubComment({
2258
+ repo: target.repo,
2259
+ num: target.num,
2260
+ kind: target.kind,
2261
+ });
2262
+ if (bot.posted) {
2263
+ if (!ctx.quiet && !ctx.json) {
2264
+ process.stderr.write(`refreshed the managed comment on ${target.repo}#${target.num}\n`);
2265
+ }
2266
+ return;
2267
+ }
2268
+ }
2269
+ catch {
2270
+ // Fall through to the hint.
2271
+ }
2272
+ if (!ctx.quiet && !ctx.json) {
2273
+ const flag = target.kind === "pull" ? "--pr" : "--issue";
2274
+ process.stderr.write(`tip: run \`uploads comment ${flag} ${target.num}\` to refresh the PR comment\n`);
2275
+ }
2276
+ }
2182
2277
  // --- delete ---
2183
2278
  const DELETE_HELP = `uploads delete <key> [--dry-run] [--workspace <name>]
2184
2279
 
@@ -2424,9 +2519,10 @@ const USAGE_HELP = `uploads usage [--workspace <name>]
2424
2519
  Show workspace storage and monthly upload counters.
2425
2520
 
2426
2521
  When the API reports workspace quotas (typical on uploads.sh cloud /
2427
- self-serve plans), human output includes progress bars toward those caps.
2428
- Self-hosted or unlimited operator workspaces get usage totals only, plus a
2429
- short unmetered note no invented limits.
2522
+ self-serve Free and Pro), human output includes the plan name and progress
2523
+ bars toward those caps. Free is not unlimited storage and monthly upload
2524
+ limits show on the meters. Self-hosted or unlimited operator workspaces get
2525
+ usage totals only, plus a short unmetered note — no invented limits.
2430
2526
 
2431
2527
  Examples:
2432
2528
  uploads --env-file .env usage
@@ -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", "UPLOADS_NO_OPTIMIZE", "UPLOADS_KEEP_EXIF", "UPLOADS_NO_AUTO_META", "UPLOADS_SCREENSHOT_VIA", "UPLOADS_NO_NUDGE"];
2
+ export declare const UPLOADS_CONFIG_KEYS: readonly ["UPLOADS_API_URL", "UPLOADS_WORKSPACE", "UPLOADS_TOKEN", "UPLOADS_SESSION_TOKEN", "UPLOADS_DEFAULT_PREFIX", "UPLOADS_DEFAULT_REPO", "UPLOADS_DEFAULT_REF", "UPLOADS_DEFAULT_WIDTH", "UPLOADS_NO_GIT", "UPLOADS_NO_OPTIMIZE", "UPLOADS_KEEP_EXIF", "UPLOADS_NO_AUTO_META", "UPLOADS_SCREENSHOT_VIA", "UPLOADS_NO_NUDGE"];
3
3
  export type UploadsConfigKey = (typeof UPLOADS_CONFIG_KEYS)[number];
4
4
  export type UploadsConfigValues = Partial<Record<UploadsConfigKey, string>>;
5
5
  export interface PutDefaults {
@@ -5,6 +5,8 @@ export const UPLOADS_CONFIG_KEYS = [
5
5
  "UPLOADS_API_URL",
6
6
  "UPLOADS_WORKSPACE",
7
7
  "UPLOADS_TOKEN",
8
+ /** Better Auth device-flow session bearer — keeps session.cliVersion fresh. */
9
+ "UPLOADS_SESSION_TOKEN",
8
10
  "UPLOADS_DEFAULT_PREFIX",
9
11
  "UPLOADS_DEFAULT_REPO",
10
12
  "UPLOADS_DEFAULT_REF",
package/dist/config.d.ts CHANGED
@@ -4,6 +4,8 @@ export interface UploadsClientConfig {
4
4
  workspace: string;
5
5
  token: string;
6
6
  }
7
+ /** Derive auth origin from an API base (`api.` → `auth.`), else production default. */
8
+ export declare function authUrlFromApi(apiUrl: string): string;
7
9
  export declare const DEFAULT_API_URL = "https://api.uploads.sh";
8
10
  export declare const DEFAULT_WORKSPACE = "default";
9
11
  /** Workspace encoded in minted tokens: `up_<workspace>_…` */
package/dist/config.js CHANGED
@@ -2,6 +2,20 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { loadConfigFile, resolveConfigPath } from "./config-file.js";
3
3
  import { UploadsError } from "./errors.js";
4
4
  export { defaultConfigPath, resolveConfigPath, loadConfigFile, redactToken, writeConfigKeys, removeConfigKeys, configValuesFromClient, putDefaultsToConfigValues, resolvePutDefaults, mergePutDefaults, resolveScreenshotDefaults, UPLOADS_CONFIG_KEYS, } from "./config-file.js";
5
+ /** Derive auth origin from an API base (`api.` → `auth.`), else production default. */
6
+ export function authUrlFromApi(apiUrl) {
7
+ try {
8
+ const url = new URL(apiUrl);
9
+ if (url.hostname.startsWith("api.")) {
10
+ url.hostname = `auth.${url.hostname.slice(4)}`;
11
+ return url.origin;
12
+ }
13
+ }
14
+ catch {
15
+ // fall through
16
+ }
17
+ return "https://auth.uploads.sh";
18
+ }
5
19
  export const DEFAULT_API_URL = "https://api.uploads.sh";
6
20
  export const DEFAULT_WORKSPACE = "default";
7
21
  const TOKEN_WORKSPACE_RE = /^up_([a-z0-9][a-z0-9-]{1,62})_/;
@@ -1,5 +1,9 @@
1
1
  /**
2
- * Human-readable size for CLI notes (1024-based).
3
- * files-sdk has no size formatter only raw `size` on head/upload results.
2
+ * Decimal (SI) human sizes for CLI output. Plan catalog caps are round decimal
3
+ * numbers (250 MB free, 10 GB pro); binary units made Free look like 238.4 MB.
4
+ * Used for usage meters, list sizes, optimize notes, and doctor — same base
5
+ * as apps/web `formatBytes` / `formatMarketedBytes`.
4
6
  */
5
7
  export declare function formatByteSize(bytes: number): string;
8
+ /** Alias for call sites that want plan-cap wording; same SI formatter. */
9
+ export declare function formatMarketedBytes(bytes: number): string;
@@ -1,11 +1,25 @@
1
1
  /**
2
- * Human-readable size for CLI notes (1024-based).
3
- * files-sdk has no size formatter only raw `size` on head/upload results.
2
+ * Decimal (SI) human sizes for CLI output. Plan catalog caps are round decimal
3
+ * numbers (250 MB free, 10 GB pro); binary units made Free look like 238.4 MB.
4
+ * Used for usage meters, list sizes, optimize notes, and doctor — same base
5
+ * as apps/web `formatBytes` / `formatMarketedBytes`.
4
6
  */
5
7
  export function formatByteSize(bytes) {
6
8
  if (!Number.isFinite(bytes) || bytes <= 0)
7
9
  return "0 B";
8
- const units = ["B", "KB", "MB", "GB", "TB"];
9
- const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
10
- return `${(bytes / 1024 ** exponent).toFixed(exponent === 0 ? 0 : 1)} ${units[exponent]}`;
10
+ if (bytes < 1000)
11
+ return `${Math.round(bytes)} B`;
12
+ const units = ["KB", "MB", "GB", "TB"];
13
+ let value = bytes / 1000;
14
+ let unit = 0;
15
+ while (value >= 1000 && unit < units.length - 1) {
16
+ value /= 1000;
17
+ unit += 1;
18
+ }
19
+ const rounded = value >= 10 ? Math.round(value) : Math.round(value * 10) / 10;
20
+ return `${rounded} ${units[unit]}`;
21
+ }
22
+ /** Alias for call sites that want plan-cap wording; same SI formatter. */
23
+ export function formatMarketedBytes(bytes) {
24
+ return formatByteSize(bytes);
11
25
  }
@@ -9,6 +9,8 @@ export type UsageSnapshotLike = {
9
9
  storageRemainingBytes?: number;
10
10
  maxUploadsPerPeriod?: number;
11
11
  uploadsRemaining?: number;
12
+ /** Catalog plan id when the API reports it (`free` | `pro`). */
13
+ plan?: string;
12
14
  };
13
15
  export type FormatUsageOptions = {
14
16
  /** IANA zone or undefined for the host local zone. */
@@ -34,5 +36,12 @@ export declare function formatProgressBar(pct: number, opts?: {
34
36
  }): string;
35
37
  /** Host-local time by default; pass `timeZone` for stable tests. */
36
38
  export declare function formatUsageTimestamp(iso: string, timeZone?: string): string;
39
+ /**
40
+ * Display label for a plan catalog id. Free is shown too — free workspaces
41
+ * still have quotas (storage / monthly uploads) that usage meters report.
42
+ */
43
+ export declare function planLabel(plan: string | undefined): string | null;
44
+ /** @deprecated use planLabel — kept as an alias for any external importers. */
45
+ export declare const paidPlanLabel: typeof planLabel;
37
46
  /** Human-readable lines for `uploads usage` (not JSON). */
38
47
  export declare function formatUsageHuman(result: UsageSnapshotLike, opts?: FormatUsageOptions): string[];
@@ -7,7 +7,7 @@
7
7
  * and operator workspaces usually omit them (unlimited). Progress bars only
8
8
  * appear for fields that have a positive cap — never invent a budget.
9
9
  */
10
- import { formatByteSize } from "./format-bytes.js";
10
+ import { formatByteSize, formatMarketedBytes } from "./format-bytes.js";
11
11
  import { BRAND } from "./cli-brand.js";
12
12
  /** True when the API reported any cumulative workspace quota. */
13
13
  export function isUsageMetered(result) {
@@ -87,17 +87,39 @@ export function formatUsageTimestamp(iso, timeZone) {
87
87
  return iso;
88
88
  }
89
89
  }
90
+ /**
91
+ * Display label for a plan catalog id. Free is shown too — free workspaces
92
+ * still have quotas (storage / monthly uploads) that usage meters report.
93
+ */
94
+ export function planLabel(plan) {
95
+ if (!plan)
96
+ return null;
97
+ if (plan === "free")
98
+ return "Free";
99
+ if (plan === "pro")
100
+ return "Pro";
101
+ // Future tiers: title-case the id rather than hiding them.
102
+ return plan.charAt(0).toUpperCase() + plan.slice(1);
103
+ }
104
+ /** @deprecated use planLabel — kept as an alias for any external importers. */
105
+ export const paidPlanLabel = planLabel;
90
106
  /** Human-readable lines for `uploads usage` (not JSON). */
91
107
  export function formatUsageHuman(result, opts = {}) {
92
108
  const width = opts.barWidth ?? 20;
93
109
  const color = opts.color === true;
94
110
  const metered = isUsageMetered(result);
95
111
  const lines = [`workspace: ${result.workspace}`];
112
+ const label = planLabel(result.plan);
113
+ if (label)
114
+ lines.push(`plan: ${label}`);
96
115
  const storagePct = usagePct(result.bytes, result.maxStorageBytes);
97
116
  if (storagePct !== null && result.maxStorageBytes != null) {
98
- const detail = `${formatByteSize(result.bytes)} / ${formatByteSize(result.maxStorageBytes)}` +
117
+ // Caps (and remaining-against-cap) use SI marketed formatting so Free's
118
+ // 250_000_000 reads as "250 MB", not binary "238.4 MB". Used bytes share
119
+ // the same base on this line so the three numbers stay coherent.
120
+ const detail = `${formatMarketedBytes(result.bytes)} / ${formatMarketedBytes(result.maxStorageBytes)}` +
99
121
  (result.storageRemainingBytes != null
100
- ? ` (${formatByteSize(result.storageRemainingBytes)} free)`
122
+ ? ` (${formatMarketedBytes(result.storageRemainingBytes)} free)`
101
123
  : "");
102
124
  const bar = formatProgressBar(storagePct, { width, color });
103
125
  lines.push(`storage: ${bar} ${detail}`);
@@ -118,8 +140,15 @@ export function formatUsageHuman(result, opts = {}) {
118
140
  }
119
141
  lines.push(`updated: ${formatUsageTimestamp(result.updatedAt, opts.timeZone)}`);
120
142
  if (!metered) {
121
- // Self-host / operator unlimited: report usage without implying a plan.
122
- lines.push("note: unmetered no storage or upload quotas on this workspace");
143
+ // Self-host / operator unlimited, or a plan without reported caps.
144
+ // Free self-serve normally reports maxStorageBytes / maxUploadsPerPeriod;
145
+ // when those are missing, say so explicitly rather than implying unlimited free.
146
+ if (result.plan === "free") {
147
+ lines.push("note: free plan — no quotas reported for this workspace (limits may still apply server-side)");
148
+ }
149
+ else {
150
+ lines.push("note: unmetered — no storage or upload quotas on this workspace");
151
+ }
123
152
  }
124
153
  return lines;
125
154
  }
package/dist/github.d.ts CHANGED
@@ -15,6 +15,12 @@ export declare function isValidRepo(repo: string): boolean;
15
15
  export declare function parseRepoFromRemoteUrl(url: string): string | undefined;
16
16
  /** Normalize a GitHub issue or pull-request coordinate for gallery linking. */
17
17
  export declare function normalizeGithubCoordinate(value: string): GithubCoordinate | undefined;
18
+ /**
19
+ * Inverse of `ghKeyPrefix`: parse the PR/issue coordinate back out of a
20
+ * stable attachment key (`gh/<owner>/<name>/<kind>/<num>/<filename>`), or
21
+ * undefined for any other key shape.
22
+ */
23
+ export declare function parseGhKey(key: string): GhTarget | undefined;
18
24
  export declare function ghKeyPrefix(target: GhTarget): string;
19
25
  /**
20
26
  * Stable attachment key: same filename → same key → same public URL, so
package/dist/github.js CHANGED
@@ -53,6 +53,18 @@ export function normalizeGithubCoordinate(value) {
53
53
  number,
54
54
  };
55
55
  }
56
+ /**
57
+ * Inverse of `ghKeyPrefix`: parse the PR/issue coordinate back out of a
58
+ * stable attachment key (`gh/<owner>/<name>/<kind>/<num>/<filename>`), or
59
+ * undefined for any other key shape.
60
+ */
61
+ export function parseGhKey(key) {
62
+ const match = /^gh\/([^/]+)\/([^/]+)\/(pull|issues)\/([1-9][0-9]*)\/./.exec(key);
63
+ if (!match)
64
+ return undefined;
65
+ const [, owner, name, kind, num] = match;
66
+ return { repo: `${owner}/${name}`, kind: kind, num: Number(num) };
67
+ }
56
68
  export function ghKeyPrefix(target) {
57
69
  const [owner, name] = target.repo.split("/");
58
70
  return `gh/${sanitizeKeySegment(owner)}/${sanitizeKeySegment(name)}/${target.kind}/${target.num}/`;
package/dist/mcp/tools.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { createUploadsClient } from "../client.js";
2
- import { buildDoctorReport, makeGhTarget, resolvePutStagingTarget, resolveStaged, syncAttachmentsComment, uploadAttachments, uploadPreparedImage, uploadPuts, } from "../commands.js";
2
+ import { buildDoctorReport, makeGhTarget, mergeStagingMeta, resolvePutStagingTarget, resolveStaged, syncAttachmentsComment, uploadAttachments, uploadPreparedImage, uploadPuts, } from "../commands.js";
3
3
  import { resolveFrameId } from "../frame.js";
4
4
  import { resolveConfig, resolvePutDefaults, } from "../config.js";
5
5
  import { resolvePutPrefix } from "../destinations.js";
6
- import { ghKeyPrefix, ghMetadataForBranch } from "../github.js";
6
+ import { ghBranchAttachmentKey, ghKeyPrefix } from "../github.js";
7
7
  import { safeCaptureFacts } from "../capture-facts.js";
8
8
  import { validateMetaMap } from "../metadata.js";
9
9
  import { mergeDerivedMeta } from "../metadata-vocab.js";
@@ -430,16 +430,7 @@ export function createUploadsMcpTools(opts) {
430
430
  repoArg: optString(args, "repo") ?? defaults.repo,
431
431
  run,
432
432
  });
433
- const putMetadata = stagingTarget
434
- ? (() => {
435
- const merged = {
436
- ...metadata,
437
- ...ghMetadataForBranch(stagingTarget.repo, stagingTarget.branch),
438
- };
439
- validateMetaMap(merged); // same builder, same contract as attach --branch
440
- return merged;
441
- })()
442
- : metadata;
433
+ const putMetadata = stagingTarget ? mergeStagingMeta(metadata, stagingTarget) : metadata;
443
434
  const putShared = {
444
435
  client,
445
436
  ghTarget: target,
@@ -695,25 +686,41 @@ export function createUploadsMcpTools(opts) {
695
686
  const metadata = metadataArgWithCanonical(args);
696
687
  if (metadata)
697
688
  validateMetaMap(metadata);
689
+ const { config, client } = clientFor(args);
690
+ const defaults = resolvePutDefaults({ envFile: globals.envFile });
691
+ const frameOpts = mcpFrameOptions(args);
692
+ const optimizeOpts = mcpOptimizeOptions(args, defaults);
693
+ const noGit = optBool(args, "noGit") || defaults.noGit === true;
694
+ const alt = optString(args, "alt");
695
+ const width = optPosInt(args, "width") ?? defaults.width;
696
+ // Auto branch staging (issue #469 lever 1): mirrors the CLI screenshot
697
+ // command and the put tool above (issue #403) — no pr/issue/key/ref/
698
+ // prefix/destination, not noGit, on a non-default git branch stages
699
+ // to the branch prefix (identical key/metadata to `attach --branch`)
700
+ // instead of the dated `screenshots/<repo>/<date>/...` layout. Never
701
+ // throws — see resolvePutStagingTarget.
702
+ const stagingTarget = resolvePutStagingTarget({
703
+ ghTarget: target,
704
+ keyHint: keyArg,
705
+ refArg,
706
+ prefixArg,
707
+ destinationArg: destArg,
708
+ noGit,
709
+ repoArg: optString(args, "repo") ?? defaults.repo,
710
+ run,
711
+ });
698
712
  let resolvedPrefix;
699
713
  try {
700
714
  resolvedPrefix = resolvePutPrefix({
701
715
  destination: destArg,
702
716
  prefix: prefixArg,
703
717
  key: keyArg,
704
- ghAttachment: Boolean(target),
718
+ ghAttachment: Boolean(target) || stagingTarget !== undefined,
705
719
  });
706
720
  }
707
721
  catch (err) {
708
722
  usage(err instanceof Error ? err.message : String(err));
709
723
  }
710
- const { config, client } = clientFor(args);
711
- const defaults = resolvePutDefaults({ envFile: globals.envFile });
712
- const frameOpts = mcpFrameOptions(args);
713
- const optimizeOpts = mcpOptimizeOptions(args, defaults);
714
- const noGit = optBool(args, "noGit") || defaults.noGit === true;
715
- const alt = optString(args, "alt");
716
- const width = optPosInt(args, "width") ?? defaults.width;
717
724
  // Dynamic import only: keeps mcp/tools.ts (and therefore anything
718
725
  // that statically imports it) free of a static reference to the
719
726
  // local-backend chain. If this fails, the runtime can't do Node-side
@@ -730,9 +737,15 @@ export function createUploadsMcpTools(opts) {
730
737
  // Keep undefined when nothing at all was supplied or derived, so the
731
738
  // "omit to leave stored metadata untouched" contract still holds.
732
739
  const captureDerived = safeCaptureFacts(targetArg, viewport, colorSchemeArg);
733
- const metadataWithCaptureFacts = metadata === undefined && Object.keys(captureDerived).length === 0
740
+ const metadataBase = metadata === undefined && Object.keys(captureDerived).length === 0
734
741
  ? undefined
735
742
  : mergeDerivedMeta(metadata ?? {}, captureDerived);
743
+ // gh.* metadata: explicit pr/issue target wins; staging wins the same
744
+ // way (matches attach --branch/bare put); otherwise capture-derived +
745
+ // explicit only.
746
+ const metadataWithCaptureFacts = stagingTarget
747
+ ? mergeStagingMeta(metadataBase, stagingTarget)
748
+ : metadataBase;
736
749
  let captured;
737
750
  try {
738
751
  captured = await screenshotModule.captureScreenshot({
@@ -761,11 +774,14 @@ export function createUploadsMcpTools(opts) {
761
774
  }
762
775
  throw err;
763
776
  }
777
+ const branchKey = stagingTarget
778
+ ? ghBranchAttachmentKey(stagingTarget.repo, stagingTarget.branch, captured.filename)
779
+ : undefined;
764
780
  const { result, prepared, markdown } = await uploadPreparedImage(client, captured.png, captured.filename, {
765
781
  frame: frameOpts,
766
782
  optimize: optimizeOpts,
767
783
  ghTarget: target,
768
- key: keyArg,
784
+ key: keyArg ?? branchKey,
769
785
  prefix: resolvedPrefix ?? defaults.prefix,
770
786
  repo: optString(args, "repo") ?? defaults.repo,
771
787
  ref: refArg ?? defaults.ref,
@@ -0,0 +1,15 @@
1
+ export interface SyncCliVersionOptions {
2
+ sessionToken?: string;
3
+ authUrl?: string;
4
+ apiUrl?: string;
5
+ envFile?: string;
6
+ version?: string;
7
+ /** Skip the local "already synced this version" short-circuit. */
8
+ force?: boolean;
9
+ cachePath?: string;
10
+ fetchImpl?: typeof fetch;
11
+ }
12
+ /** Best-effort POST; never throws. */
13
+ export declare function syncSessionCliVersion(opts?: SyncCliVersionOptions): Promise<boolean>;
14
+ /** Fire-and-forget for CLI command starts. */
15
+ export declare function maybeSyncSessionCliVersion(opts?: SyncCliVersionOptions): void;