@buildinternet/uploads 0.37.2 → 0.38.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.
@@ -2,7 +2,7 @@ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { basename } from "node:path";
3
3
  import { extractDashValue, flagBool, flagInt, flagString, flagValues, parseCommandArgs, UsageError, } from "../cli-args.js";
4
4
  import { writeCommandHelp } from "../cli-style.js";
5
- import { branchFromFlags, derivedMetaEnabled, frameOptionsFromFlags, ghTargetFromFlags, optimizeOptionsFromFlags, stateAppMetaFromFlags, warnNearMissMeta, syncAttachmentsComment, commentViaSuffix, uploadPreparedImage, resolvePutStagingTarget, putStagingNoteText, resolveStageBindingWarning, mergeStagingMeta, } from "../commands.js";
5
+ import { branchFromFlags, derivedMetaEnabled, frameOptionsFromFlags, ghTargetFromFlags, optimizeOptionsFromFlags, stateAppMetaFromFlags, warnNearMissMeta, syncAttachmentsComment, commentViaSuffix, uploadPreparedImage, resolvePutStagingTarget, putStagingNoteText, resolveStageBindingWarning, mergeStagingMeta, writeReplacedNote, } from "../commands.js";
6
6
  import { resolvePutDefaults } from "../config.js";
7
7
  import { loadDefaultsRaw, resolveScreenshotDefaults } from "../config-file.js";
8
8
  import { resolvePutPrefix } from "../destinations.js";
@@ -31,6 +31,15 @@ localhost/private-network URLs are reachable only by the
31
31
  local backend — with --via remote (or auto falling back to remote) these
32
32
  fail fast with a clear error instead of sending a doomed request.
33
33
 
34
+ The object name is derived from the target URL (host + path), not chosen by
35
+ you — e.g. https://app.example/settings becomes app.example-settings.png.
36
+ --state folds into that derived name (a -before/-after/... suffix), so
37
+ capturing the same URL with --state before then --state after produces two
38
+ distinct objects instead of the second silently overwriting the first.
39
+ Re-capturing the same URL + --state replaces that object in place — the
40
+ intended idempotency for repeat captures. --key bypasses all of this and
41
+ sets the whole object key verbatim (no folding).
42
+
34
43
  After capture, screenshots share the put upload pipeline: optional --frame,
35
44
  optimize-by-default, --pr/--issue attachment + --comment, --gallery, --meta.
36
45
 
@@ -381,6 +390,9 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
381
390
  reducedMotion,
382
391
  evalJs,
383
392
  initScript,
393
+ // Skip folding when an explicit --key was given — --key sets the whole
394
+ // key, so there's no auto-derived name to fold state into.
395
+ state: keyHint ? undefined : explicitMeta.state,
384
396
  measureSelectors: annotateSelectors.length > 0 ? annotateSelectors : undefined,
385
397
  apiUrl: ctx.config.apiUrl,
386
398
  token: ctx.config.token,
@@ -497,6 +509,7 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
497
509
  if (prepared.optimized) {
498
510
  process.stderr.write(`>> optimized ${prepared.originalBytes} → ${prepared.outputBytes} bytes\n`);
499
511
  }
512
+ writeReplacedNote(result.replaced, ctx.quiet, dryRun, result.wouldRefuse);
500
513
  process.stderr.write(`>> key: ${result.key}${dryRun ? " (dry run — not uploaded)" : ""}\n`);
501
514
  if (stagingTarget !== undefined) {
502
515
  process.stderr.write(`>> find these later: uploads find gh.branch=${stagingTarget.branch.toLowerCase()}\n`);
@@ -514,8 +527,16 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
514
527
  process.stderr.write("\n");
515
528
  }
516
529
  // One JSON `hint` slot (mirrors bare put): the binding warning is more
517
- // actionable than the generic staging note, so it wins when both fire.
518
- const jsonHint = bindingWarning ?? stagingNote;
530
+ // actionable than the generic staging note, so it wins when both fire; a
531
+ // replaced-object note (issue #618) is the lowest priority of the three —
532
+ // it only surfaces when nothing else already claimed the slot. Since state
533
+ // folds into the derived key, replaced + state means a same-side re-capture,
534
+ // which is the intended replace-in-place flow — word it as informational,
535
+ // not as a problem.
536
+ const replacedHint = result.replaced && explicitMeta.state
537
+ ? `re-capture replaced the previous state=${explicitMeta.state} object at ${result.key} — expected for repeat captures of the same URL + state`
538
+ : undefined;
539
+ const jsonHint = bindingWarning ?? stagingNote ?? replacedHint;
519
540
  switch (format) {
520
541
  case "json":
521
542
  await writeJson({
@@ -63,6 +63,7 @@ export declare function warnNearMissMeta(ctx: CliContext, meta: Record<string, s
63
63
  * point is `--help` discoverability and `--state` validation.
64
64
  */
65
65
  export declare function stateAppMetaFromFlags(flags: CommandFlags["flags"]): Record<string, string>;
66
+ export declare function writeReplacedNote(replaced: boolean | undefined, quiet: boolean, dryRun?: boolean, wouldRefuse?: boolean): void;
66
67
  export type PreparedUpload = OptimizeImageResult & {
67
68
  frame?: Pick<FrameResult, "framed" | "frameId" | "skippedReason">;
68
69
  };
package/dist/commands.js CHANGED
@@ -4,7 +4,7 @@ import { mapBounded } from "./async.js";
4
4
  import { createUploadsClient, } from "./client.js";
5
5
  import { parseCommandArgs, flagString, flagBool, flagInt, flagValues, UsageError, } from "./cli-args.js";
6
6
  import { resolvePutDefaults, workspaceMismatch, workspaceFromToken, } from "./config.js";
7
- import { buildMarkdown } from "./embed.js";
7
+ import { buildUploadMarkdown } from "./embed.js";
8
8
  import { readLocalRepoCommentConfig, resolveCommentOptions } from "./comment-config.js";
9
9
  import { urlForGithubEmbed } from "./public-urls.js";
10
10
  import { UploadsError } from "./errors.js";
@@ -315,7 +315,7 @@ function formatOptimizeNote(opt) {
315
315
  }
316
316
  return undefined;
317
317
  }
318
- function writeReplacedNote(replaced, quiet, dryRun = false, wouldRefuse = false) {
318
+ export function writeReplacedNote(replaced, quiet, dryRun = false, wouldRefuse = false) {
319
319
  if (quiet)
320
320
  return;
321
321
  if (dryRun && wouldRefuse) {
@@ -411,9 +411,10 @@ export async function uploadPreparedImage(client, bytes, sourceName, opts) {
411
411
  }),
412
412
  metadata,
413
413
  });
414
- const markdown = buildMarkdown(urlForGithubEmbed(result.url, result.embedUrl), {
414
+ const markdown = buildUploadMarkdown(urlForGithubEmbed(result.url, result.embedUrl), {
415
415
  alt: opts.alt(prepared),
416
416
  width: opts.width,
417
+ key: result.key,
417
418
  });
418
419
  return { result, prepared, markdown, sentMetadata: metadata };
419
420
  }
@@ -748,8 +749,9 @@ async function uploadAttachmentBatch(opts) {
748
749
  upload: {
749
750
  ...result,
750
751
  file,
751
- markdown: buildMarkdown(urlForGithubEmbed(result.url, result.embedUrl), {
752
+ markdown: buildUploadMarkdown(urlForGithubEmbed(result.url, result.embedUrl), {
752
753
  alt: sourceName,
754
+ key: result.key,
753
755
  }),
754
756
  optimize: {
755
757
  optimized: prepared.optimized,
@@ -1,5 +1,7 @@
1
1
  import type { UploadsClientConfig } from "./config.js";
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"];
2
+ export declare const UPLOADS_CONFIG_KEYS: readonly ["UPLOADS_API_URL", "UPLOADS_WORKSPACE", "UPLOADS_TOKEN",
3
+ /** Better Auth device-flow session bearer — keeps session.cliVersion fresh. */
4
+ "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
5
  export type UploadsConfigKey = (typeof UPLOADS_CONFIG_KEYS)[number];
4
6
  export type UploadsConfigValues = Partial<Record<UploadsConfigKey, string>>;
5
7
  export interface PutDefaults {
package/dist/embed.d.ts CHANGED
@@ -4,3 +4,13 @@ export declare function buildMarkdown(url: string, opts: {
4
4
  alt: string;
5
5
  width?: number;
6
6
  }): string;
7
+ /**
8
+ * Null-safe wrapper for upload flows: workspaces with no public base URL
9
+ * (BYO signed-URLs-only) upload fine but have no embeddable URL, and the
10
+ * markdown must degrade to honest plain text instead of `![alt](null)`.
11
+ */
12
+ export declare function buildUploadMarkdown(url: string | null | undefined, opts: {
13
+ alt: string;
14
+ width?: number;
15
+ key: string;
16
+ }): string;
package/dist/embed.js CHANGED
@@ -28,3 +28,14 @@ export function buildMarkdown(url, opts) {
28
28
  }
29
29
  return `![${opts.alt}](${url})`;
30
30
  }
31
+ /**
32
+ * Null-safe wrapper for upload flows: workspaces with no public base URL
33
+ * (BYO signed-URLs-only) upload fine but have no embeddable URL, and the
34
+ * markdown must degrade to honest plain text instead of `![alt](null)`.
35
+ */
36
+ export function buildUploadMarkdown(url, opts) {
37
+ if (!url) {
38
+ return `\`${opts.key}\` uploaded (no public URL — this workspace serves signed URLs only)`;
39
+ }
40
+ return buildMarkdown(url, opts);
41
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { inferContentType, buildMarkdown } from "./embed.js";
1
+ export { inferContentType, buildMarkdown, buildUploadMarkdown } from "./embed.js";
2
2
  export { DEFAULT_EMBED_PUBLIC_BASE_URL, embedBaseUrlFromEnv, embedUrlFromPublic, resolveEmbedBaseUrl, resolveEmbedUrl, urlForGithubEmbed, } from "./public-urls.js";
3
3
  export { sanitizeKeySegment, sha256Short, deriveRepoFromGit, buildScreenshotKey } from "./keys.js";
4
4
  export { BUILTIN_DESTINATIONS, isBuiltinDestination, keyMatchesDestination, resolveDestinationRoot, resolvePutPrefix, type BuiltinDestinationId, } from "./destinations.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { inferContentType, buildMarkdown } from "./embed.js";
1
+ export { inferContentType, buildMarkdown, buildUploadMarkdown } from "./embed.js";
2
2
  export { DEFAULT_EMBED_PUBLIC_BASE_URL, embedBaseUrlFromEnv, embedUrlFromPublic, resolveEmbedBaseUrl, resolveEmbedUrl, urlForGithubEmbed, } from "./public-urls.js";
3
3
  export { sanitizeKeySegment, sha256Short, deriveRepoFromGit, buildScreenshotKey } from "./keys.js";
4
4
  export { BUILTIN_DESTINATIONS, isBuiltinDestination, keyMatchesDestination, resolveDestinationRoot, resolvePutPrefix, } from "./destinations.js";
@@ -23,7 +23,7 @@ export declare const metadataProp: {
23
23
  };
24
24
  export declare const stateProp: {
25
25
  type: string;
26
- enum: ("before" | "after" | "empty" | "error" | "loading")[];
26
+ enum: ("after" | "before" | "empty" | "error" | "loading")[];
27
27
  description: string;
28
28
  };
29
29
  export declare const appProp: {
package/dist/mcp/tools.js CHANGED
@@ -761,6 +761,10 @@ export function createUploadsMcpTools(opts) {
761
761
  hide: optStringArray(args, "hide"),
762
762
  hideDevTools: optBool(args, "noHideDevTools") ? false : undefined,
763
763
  reducedMotion: optBool(args, "reducedMotion"),
764
+ // Skip folding when an explicit key was given — key sets the
765
+ // whole object key, so there's no auto-derived name to fold
766
+ // state into.
767
+ state: keyArg ? undefined : metadataWithCaptureFacts?.state,
764
768
  apiUrl: config.apiUrl,
765
769
  token: config.token,
766
770
  });
@@ -3,6 +3,8 @@ export type CanonicalMetaKey = (typeof CANONICAL_META_KEYS)[number];
3
3
  /** Closed enum for `state` — the highest-value hand-supplied search facet. */
4
4
  export declare const META_STATE_VALUES: readonly ["before", "after", "empty", "error", "loading"];
5
5
  export type MetaStateValue = (typeof META_STATE_VALUES)[number];
6
+ /** True when `value` is exactly one of the canonical state values. */
7
+ export declare function isMetaStateValue(value: string): value is MetaStateValue;
6
8
  /**
7
9
  * Validate a `--state` value. Fails fast with a suggestion when the value is a
8
10
  * recognized near-miss, otherwise lists the valid set.
@@ -23,6 +23,10 @@ const CANONICAL_KEY_SET = new Set(CANONICAL_META_KEYS);
23
23
  /** Closed enum for `state` — the highest-value hand-supplied search facet. */
24
24
  export const META_STATE_VALUES = ["before", "after", "empty", "error", "loading"];
25
25
  const STATE_VALUE_SET = new Set(META_STATE_VALUES);
26
+ /** True when `value` is exactly one of the canonical state values. */
27
+ export function isMetaStateValue(value) {
28
+ return STATE_VALUE_SET.has(value);
29
+ }
26
30
  /** Common spellings agents reach for, mapped to the canonical `state` value. */
27
31
  const STATE_ALIASES = {
28
32
  pre: "before",
@@ -65,6 +65,14 @@ export interface CaptureScreenshotOptions {
65
65
  fullPage?: boolean;
66
66
  colorScheme?: "dark" | "light";
67
67
  waitUntil?: WaitUntil;
68
+ /**
69
+ * Folded into the auto-derived filename's stem (e.g. `-before`) so a
70
+ * before/after pair of the same URL yields two distinct object names
71
+ * instead of silently overwriting each other. Callers must omit this when
72
+ * the caller also gave an explicit object key/name — folding only applies
73
+ * to the auto-derived name.
74
+ */
75
+ state?: string;
68
76
  /** Extra CSS selectors to hide (display:none) before capture. */
69
77
  hide?: string[];
70
78
  /**
@@ -120,6 +128,26 @@ export interface CaptureScreenshotResult {
120
128
  /** Present when `measureSelectors` was given and the local backend ran. */
121
129
  measures?: Record<string, MeasuredBox>;
122
130
  }
131
+ /**
132
+ * Folds a `--state` value into an auto-derived filename's stem, e.g.
133
+ * `localhost-docs-mcp.png` + "before" → `localhost-docs-mcp-before.png`. This
134
+ * is what keeps a before/after pair (same URL, two states) from colliding on
135
+ * the same object key (issue #618) — without it, the second capture silently
136
+ * overwrote the first.
137
+ *
138
+ * No-op when `state` is undefined, and idempotent: re-folding a filename that
139
+ * already ends with `-<state>` (e.g. re-deriving from a stored filename)
140
+ * doesn't double-append.
141
+ *
142
+ * Only the canonical state values fold. Callers pass the merged metadata bag's
143
+ * `state`, which a free-form `--meta state=…` can populate with any printable
144
+ * ASCII (validateMetaMap allows `/` and spaces) — that stays metadata-only
145
+ * rather than entering the object key.
146
+ *
147
+ * Callers must skip this entirely when the caller gave an explicit key/name
148
+ * (e.g. `--key`) — this only applies to the auto-derived filename.
149
+ */
150
+ export declare function foldStateIntoFilename(filename: string, state: string | undefined): string;
123
151
  /**
124
152
  * Resolve target + options into PNG bytes via the local or remote backend.
125
153
  * Shared by the CLI `screenshot` command and the MCP `screenshot` tool.
@@ -12,6 +12,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
12
12
  import { basename, resolve as resolvePath } from "node:path";
13
13
  import { pathToFileURL } from "node:url";
14
14
  import { UploadsError } from "./errors.js";
15
+ import { isMetaStateValue } from "./metadata-vocab.js";
15
16
  import { captureRemote, MAX_REMOTE_HTML_BYTES } from "./screenshot-remote.js";
16
17
  /**
17
18
  * Host selectors for framework dev toolbars/overlays that otherwise pollute a
@@ -159,6 +160,35 @@ function deriveFilename(target) {
159
160
  const stem = [url.hostname, pathPart].filter(Boolean).join("-");
160
161
  return `${stem || "screenshot"}.png`;
161
162
  }
163
+ /**
164
+ * Folds a `--state` value into an auto-derived filename's stem, e.g.
165
+ * `localhost-docs-mcp.png` + "before" → `localhost-docs-mcp-before.png`. This
166
+ * is what keeps a before/after pair (same URL, two states) from colliding on
167
+ * the same object key (issue #618) — without it, the second capture silently
168
+ * overwrote the first.
169
+ *
170
+ * No-op when `state` is undefined, and idempotent: re-folding a filename that
171
+ * already ends with `-<state>` (e.g. re-deriving from a stored filename)
172
+ * doesn't double-append.
173
+ *
174
+ * Only the canonical state values fold. Callers pass the merged metadata bag's
175
+ * `state`, which a free-form `--meta state=…` can populate with any printable
176
+ * ASCII (validateMetaMap allows `/` and spaces) — that stays metadata-only
177
+ * rather than entering the object key.
178
+ *
179
+ * Callers must skip this entirely when the caller gave an explicit key/name
180
+ * (e.g. `--key`) — this only applies to the auto-derived filename.
181
+ */
182
+ export function foldStateIntoFilename(filename, state) {
183
+ if (!state || !isMetaStateValue(state))
184
+ return filename;
185
+ const match = /^(.*?)(\.[^./]+)?$/.exec(filename);
186
+ const stem = match?.[1] ?? filename;
187
+ const ext = match?.[2] ?? "";
188
+ if (stem.endsWith(`-${state}`))
189
+ return filename;
190
+ return `${stem}-${state}${ext}`;
191
+ }
162
192
  /**
163
193
  * Best-effort local-browser probe used only to decide `auto` routing. Never
164
194
  * throws — any failure (e.g. optional playwright-core not installed) means
@@ -183,7 +213,7 @@ export async function captureScreenshot(opts) {
183
213
  const target = classifyTarget(opts.target);
184
214
  const viewport = opts.viewport ?? DEFAULT_SCREENSHOT_VIEWPORT;
185
215
  const waitUntil = opts.waitUntil ?? "load";
186
- const filename = deriveFilename(target);
216
+ const filename = foldStateIntoFilename(deriveFilename(target), opts.state);
187
217
  // Only private-network URLs are truly unreachable remotely; an .html file
188
218
  // is sent to the remote backend as an inline `html` body (though anything
189
219
  // it references via file:// or relative paths won't resolve there).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.37.2",
3
+ "version": "0.38.0",
4
4
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -49,7 +49,7 @@
49
49
  "@types/node": "^26.1.0",
50
50
  "ai": "^6.0.0",
51
51
  "files-sdk": "^2.1.0",
52
- "typescript": "^6.0.3",
52
+ "typescript": "^7.0.2",
53
53
  "vitest": "^4.1.10"
54
54
  },
55
55
  "publishConfig": {