@buildinternet/uploads 0.13.1 → 0.15.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.
@@ -25,6 +25,7 @@ export const PUT_LIKE_FLAGS = [
25
25
  "--ref",
26
26
  "--pr",
27
27
  "--issue",
28
+ "--branch",
28
29
  "--comment",
29
30
  "--no-comment",
30
31
  "--format",
@@ -75,6 +76,7 @@ export const SCREENSHOT_FLAGS = [
75
76
  "--no-git",
76
77
  "--pr",
77
78
  "--issue",
79
+ "--branch",
78
80
  "--comment",
79
81
  "--gallery",
80
82
  "--meta",
@@ -131,6 +133,11 @@ export const ROOT_COMMANDS = [
131
133
  name: "comment",
132
134
  summary: "Create/update a PR/issue attachments comment (via gh)",
133
135
  },
136
+ {
137
+ name: "github",
138
+ summary: "Claim/inspect this workspace's binding to a GitHub repo",
139
+ subcommands: [{ name: "link", summary: "Claim or inspect the repo binding" }],
140
+ },
134
141
  {
135
142
  name: "list",
136
143
  summary: "List objects (--meta k=v filters by queryable metadata)",
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import { UploadsError } from "./errors.js";
4
4
  import { commandWorkspace, flagString, isHelpFlag, parseArgv, parseCommandArgs, UsageError, } from "./cli-args.js";
5
5
  import { formatRootHelp, wantsFullHelp } from "./cli-help.js";
6
6
  import { colorEnabled, createStyle } from "./cli-style.js";
7
- import { runPut, runAttach, runList, runFind, runMeta, runDelete, runHealth, runDoctor, runComment, runUsage, runReconcile, runPurgeExpired, runGallery, } from "./commands.js";
7
+ import { runPut, runAttach, runList, runFind, runMeta, runDelete, runHealth, runDoctor, runComment, runGithub, runUsage, runReconcile, runPurgeExpired, runGallery, } from "./commands.js";
8
8
  import { runConfig } from "./commands/config.js";
9
9
  import { runSetup } from "./commands/setup.js";
10
10
  import { runLogin } from "./commands/login.js";
@@ -278,7 +278,8 @@ export async function runCli(argv) {
278
278
  case "reconcile":
279
279
  case "purge-expired":
280
280
  case "doctor":
281
- case "comment": {
281
+ case "comment":
282
+ case "github": {
282
283
  const ctx = createContext(parsed.globals, !showHelp, cmdArgs);
283
284
  switch (parsed.command) {
284
285
  case "attach":
@@ -296,6 +297,9 @@ export async function runCli(argv) {
296
297
  case "comment":
297
298
  code = await runComment(ctx, cmdArgs, showHelp);
298
299
  break;
300
+ case "github":
301
+ code = await runGithub(ctx, cmdArgs, showHelp);
302
+ break;
299
303
  case "list":
300
304
  code = await runList(ctx, cmdArgs, showHelp);
301
305
  break;
package/dist/client.d.ts CHANGED
@@ -73,6 +73,8 @@ export interface ListItem {
73
73
  key: string;
74
74
  url: string | null;
75
75
  embedUrl?: string | null;
76
+ /** Canonical `/f/` page URL when the API provides it. Absent on older API deployments. */
77
+ pageUrl?: string;
76
78
  size?: number;
77
79
  uploaded?: string;
78
80
  }
@@ -170,6 +172,46 @@ export interface FindGalleriesByReferenceOptions {
170
172
  limit?: number;
171
173
  cursor?: string;
172
174
  }
175
+ /** Reasons the bot did not post; the CLI treats any of them as "fall back to gh". */
176
+ export type GithubCommentDeclineReason = "app_unconfigured" | "not_installed" | "forbidden" | "unavailable";
177
+ export type GithubCommentResult = {
178
+ posted: true;
179
+ action: "created" | "updated" | "skipped";
180
+ count: number;
181
+ commentUrl?: string;
182
+ } | {
183
+ posted: false;
184
+ reason: GithubCommentDeclineReason;
185
+ message?: string;
186
+ fixUrl?: string;
187
+ required?: string[];
188
+ };
189
+ /** `POST /v1/:workspace/github/promote` request/response (server contract, PR #310). */
190
+ export interface PromoteBranchAttachmentsOptions {
191
+ repo: string;
192
+ num: number;
193
+ branch: string;
194
+ }
195
+ export interface PromoteSkip {
196
+ key: string;
197
+ reason: string;
198
+ }
199
+ export interface PromoteBranchAttachmentsResult {
200
+ promoted: string[];
201
+ skipped: PromoteSkip[];
202
+ }
203
+ /** `GET`/`POST /v1/:workspace/github/link` result (server contract, phase 4b). */
204
+ export interface GithubLinkResult {
205
+ repo: string;
206
+ linked: boolean;
207
+ workspace: string | null;
208
+ source: string | null;
209
+ createdAt: string | null;
210
+ }
211
+ /** POST-only: whether THIS call's workspace ended up owning the binding. */
212
+ export interface GithubLinkClaimResult extends GithubLinkResult {
213
+ claimed: boolean;
214
+ }
173
215
  export interface HealthResult {
174
216
  ok: boolean;
175
217
  }
@@ -396,6 +438,30 @@ export declare function createUploadsClient(config: UploadsClientConfig): {
396
438
  id: string;
397
439
  }>;
398
440
  findGalleriesByReference(opts: FindGalleriesByReferenceOptions): Promise<GalleryListResult>;
441
+ upsertGithubComment(opts: {
442
+ repo: string;
443
+ num: number;
444
+ kind: "pull" | "issues";
445
+ }): Promise<GithubCommentResult>;
446
+ /**
447
+ * Promote a workspace's branch-staged attachments into a PR's stable
448
+ * attachment prefix (server contract, PR #310 — degrade-safe callers
449
+ * treat any failure, including a 404 from an older/self-hosted worker
450
+ * that doesn't have this route yet, as "nothing promoted").
451
+ */
452
+ promoteBranchAttachments(opts: PromoteBranchAttachmentsOptions): Promise<PromoteBranchAttachmentsResult>;
453
+ /** Current binding for `repo`, or `{ linked: false }` if unclaimed. Throws
454
+ * `UploadsError` (status 404) on an older/self-hosted server without this
455
+ * route — callers treat that as "bindings unsupported". */
456
+ githubLinkStatus(repo: string): Promise<GithubLinkResult>;
457
+ /**
458
+ * Explicitly claim `repo` for this workspace (first-claim-wins — see
459
+ * github-repo-links.ts server-side). `claimed: false` in the result means
460
+ * the repo is already bound to a DIFFERENT workspace; this call never
461
+ * steals it. Throws `UploadsError` (status 404) on an older/self-hosted
462
+ * server without this route.
463
+ */
464
+ githubLinkClaim(repo: string): Promise<GithubLinkClaimResult>;
399
465
  health(): Promise<HealthResult>;
400
466
  /** Workspace storage / upload counters (+ limits when configured). */
401
467
  usage(): Promise<UsageResult>;
package/dist/client.js CHANGED
@@ -452,6 +452,43 @@ export function createUploadsClient(config) {
452
452
  params.set("cursor", opts.cursor);
453
453
  return request("GET", galleriesBase(config) + "/by-reference?" + params);
454
454
  },
455
+ async upsertGithubComment(opts) {
456
+ return request("POST", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/comment`, {
457
+ body: new TextEncoder().encode(JSON.stringify(opts)),
458
+ headers: { "Content-Type": "application/json" },
459
+ });
460
+ },
461
+ /**
462
+ * Promote a workspace's branch-staged attachments into a PR's stable
463
+ * attachment prefix (server contract, PR #310 — degrade-safe callers
464
+ * treat any failure, including a 404 from an older/self-hosted worker
465
+ * that doesn't have this route yet, as "nothing promoted").
466
+ */
467
+ async promoteBranchAttachments(opts) {
468
+ return request("POST", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/promote`, {
469
+ body: new TextEncoder().encode(JSON.stringify(opts)),
470
+ headers: { "Content-Type": "application/json" },
471
+ });
472
+ },
473
+ /** Current binding for `repo`, or `{ linked: false }` if unclaimed. Throws
474
+ * `UploadsError` (status 404) on an older/self-hosted server without this
475
+ * route — callers treat that as "bindings unsupported". */
476
+ async githubLinkStatus(repo) {
477
+ return request("GET", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/link?repo=${encodeURIComponent(repo)}`);
478
+ },
479
+ /**
480
+ * Explicitly claim `repo` for this workspace (first-claim-wins — see
481
+ * github-repo-links.ts server-side). `claimed: false` in the result means
482
+ * the repo is already bound to a DIFFERENT workspace; this call never
483
+ * steals it. Throws `UploadsError` (status 404) on an older/self-hosted
484
+ * server without this route.
485
+ */
486
+ async githubLinkClaim(repo) {
487
+ return request("POST", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/link`, {
488
+ body: new TextEncoder().encode(JSON.stringify({ repo })),
489
+ headers: { "Content-Type": "application/json" },
490
+ });
491
+ },
455
492
  async health() {
456
493
  return request("GET", `${config.apiUrl}/health`, { auth: false });
457
494
  },
@@ -2,12 +2,12 @@ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { basename } from "node:path";
3
3
  import { flagBool, flagInt, flagString, flagValues, parseCommandArgs, UsageError, } from "../cli-args.js";
4
4
  import { writeCommandHelp } from "../cli-style.js";
5
- import { frameOptionsFromFlags, ghTargetFromFlags, optimizeOptionsFromFlags, syncAttachmentsComment, uploadPreparedImage, } from "../commands.js";
5
+ import { branchFromFlags, frameOptionsFromFlags, ghTargetFromFlags, optimizeOptionsFromFlags, syncAttachmentsComment, commentViaSuffix, uploadPreparedImage, } 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";
9
- import { ghMetadataFromTarget } from "../github.js";
10
- import { execRunner } from "../github-gh.js";
9
+ import { execRunner, ghMetadataFromTargetWithTitle, resolveRepo, } from "../github-gh.js";
10
+ import { ghBranchAttachmentKey, ghMetadataForBranch } from "../github.js";
11
11
  import { parseMetaFlags, validateMetaMap } from "../metadata.js";
12
12
  import { writeJson, writeStdout } from "../io.js";
13
13
  import { assertHideSelector, captureScreenshot, parseViewport, parseWaitUntil, } from "../screenshot.js";
@@ -67,7 +67,14 @@ Options:
67
67
  --keep-exif Keep EXIF/XMP/ICC when optimizing
68
68
  --pr <num> Attach to a pull request (stable URL, no hash)
69
69
  --issue <num> Attach to an issue
70
- --comment With --pr/--issue: update the managed attachments comment
70
+ --branch [name] Stage against a branch, pre-PR (default: current git branch):
71
+ key gh/<owner>/<repo>/branch/<branch>/<name>; not with
72
+ --pr/--issue/--comment/--key/--ref/--prefix. No managed
73
+ comment exists yet — promoting into the PR's comment once
74
+ one opens ships in a later phase.
75
+ --comment With --pr/--issue: update the managed attachments comment.
76
+ Posts as uploads-sh[bot] when the GitHub App is installed;
77
+ otherwise via local gh.
71
78
  --gallery <id> Add the uploaded object to this public gallery
72
79
  --meta <k=v> Queryable custom metadata (repeatable)
73
80
  --workspace, -w <name> Override workspace
@@ -83,6 +90,7 @@ Examples:
83
90
  uploads screenshot http://localhost:3000 --via local --full-page
84
91
  uploads screenshot https://uploads.sh --pr 128 --comment
85
92
  uploads screenshot ./card.html --no-upload --out ./card.png
93
+ uploads screenshot https://app.example/settings --branch
86
94
  `;
87
95
  function colorSchemeFromFlags(flags) {
88
96
  const dark = flagBool(flags, "--dark");
@@ -163,9 +171,13 @@ captureImpl = captureScreenshot) {
163
171
  const destFlag = flagString(parsed.flags, "--destination");
164
172
  const prefixFlag = flagString(parsed.flags, "--prefix");
165
173
  const ghTarget = ghTargetFromFlags(parsed.flags, run);
174
+ const branchArg = branchFromFlags(parsed.flags, run);
166
175
  const wantComment = parsed.flags.has("--comment");
167
176
  const galleryId = flagString(parsed.flags, "--gallery");
168
177
  const dryRun = flagBool(parsed.flags, "--dry-run");
178
+ if (branchArg !== undefined && ghTarget) {
179
+ throw new UsageError("--branch cannot be combined with --pr/--issue");
180
+ }
169
181
  if (wantComment && !ghTarget)
170
182
  throw new UsageError("--comment requires --pr or --issue");
171
183
  if (ghTarget) {
@@ -176,6 +188,16 @@ captureImpl = captureScreenshot) {
176
188
  if (prefixFlag)
177
189
  throw new UsageError("--prefix cannot be combined with --pr/--issue");
178
190
  }
191
+ if (branchArg !== undefined) {
192
+ if (wantComment)
193
+ throw new UsageError("--branch cannot be combined with --comment");
194
+ if (keyHint)
195
+ throw new UsageError("--key cannot be combined with --branch");
196
+ if (flagString(parsed.flags, "--ref"))
197
+ throw new UsageError("--ref cannot be combined with --branch");
198
+ if (prefixFlag)
199
+ throw new UsageError("--prefix cannot be combined with --branch");
200
+ }
179
201
  if (dryRun) {
180
202
  if (wantComment)
181
203
  throw new UsageError("--dry-run cannot be combined with --comment");
@@ -184,13 +206,14 @@ captureImpl = captureScreenshot) {
184
206
  if (noUpload)
185
207
  throw new UsageError("--dry-run cannot be combined with --no-upload");
186
208
  }
209
+ const branchRepo = branchArg !== undefined ? resolveRepo(flagString(parsed.flags, "--repo"), run) : undefined;
187
210
  let resolvedPrefix;
188
211
  try {
189
212
  resolvedPrefix = resolvePutPrefix({
190
213
  destination: destFlag,
191
214
  prefix: prefixFlag,
192
215
  key: keyHint,
193
- ghAttachment: Boolean(ghTarget),
216
+ ghAttachment: Boolean(ghTarget) || branchArg !== undefined,
194
217
  });
195
218
  }
196
219
  catch (err) {
@@ -214,7 +237,11 @@ captureImpl = captureScreenshot) {
214
237
  const metaExtras = parseMetaFlags(flagValues(parsed.flags, "--meta"));
215
238
  let metadata = metaExtras;
216
239
  if (ghTarget) {
217
- metadata = { ...metaExtras, ...ghMetadataFromTarget(ghTarget) };
240
+ metadata = { ...metaExtras, ...ghMetadataFromTargetWithTitle(ghTarget, run) };
241
+ validateMetaMap(metadata);
242
+ }
243
+ else if (branchArg !== undefined) {
244
+ metadata = { ...metaExtras, ...ghMetadataForBranch(branchRepo, branchArg) };
218
245
  validateMetaMap(metadata);
219
246
  }
220
247
  else if (Object.keys(metaExtras).length > 0) {
@@ -259,12 +286,15 @@ captureImpl = captureScreenshot) {
259
286
  }
260
287
  const repo = flagString(parsed.flags, "--repo") ?? putDefaults.repo;
261
288
  const ref = flagString(parsed.flags, "--ref") ?? putDefaults.ref;
289
+ const branchKey = branchArg !== undefined
290
+ ? ghBranchAttachmentKey(branchRepo, branchArg, captured.filename)
291
+ : undefined;
262
292
  const alt = altFlag ?? basename(captured.filename);
263
293
  const { result, prepared, markdown } = await uploadPreparedImage(ctx.client, captured.png, captured.filename, {
264
294
  frame: frameOpts,
265
295
  optimize: optimizeOpts,
266
296
  ghTarget,
267
- key: keyHint,
297
+ key: keyHint ?? branchKey,
268
298
  prefix: resolvedPrefix ?? putDefaults.prefix,
269
299
  repo,
270
300
  ref,
@@ -294,9 +324,9 @@ captureImpl = captureScreenshot) {
294
324
  let commentError;
295
325
  if (wantComment && ghTarget) {
296
326
  try {
297
- comment = await syncAttachmentsComment(ctx.client, ghTarget, run);
327
+ comment = await syncAttachmentsComment(ctx.client, ghTarget, run, ctx.config.workspace);
298
328
  if (logHuman)
299
- process.stderr.write(`>> attachments comment ${comment.action}\n`);
329
+ process.stderr.write(`>> attachments comment ${comment.action}${commentViaSuffix(comment.via)}\n`);
300
330
  }
301
331
  catch (err) {
302
332
  commentError = err instanceof Error ? err.message : String(err);
@@ -28,6 +28,14 @@ export declare function readFileArg(fileArg: string): Uint8Array;
28
28
  export declare function makeGhTarget(pr: number | undefined, issue: number | undefined, repoArg: string | undefined, run: CommandRunner): GhTarget | undefined;
29
29
  /** Reads --pr/--issue (+ --repo) into a GhTarget; undefined when neither flag is present. */
30
30
  export declare function ghTargetFromFlags(flags: CommandFlags["flags"], run: CommandRunner): GhTarget | undefined;
31
+ /**
32
+ * Reads `--branch [name]` — an optional-value flag: `--branch` alone resolves
33
+ * the current git branch (`resolveCurrentBranch`); `--branch feature/x` uses
34
+ * the given name verbatim. Returns undefined when the flag is absent at all
35
+ * (distinct from an empty/whitespace value, which is rejected). Throws
36
+ * UsageError if `--branch` is given more than once.
37
+ */
38
+ export declare function branchFromFlags(flags: CommandFlags["flags"], run: CommandRunner): string | undefined;
31
39
  /** Shared put/attach optimize flags + UPLOADS_NO_OPTIMIZE default. */
32
40
  export declare function optimizeOptionsFromFlags(flags: CommandFlags["flags"], defaults: PutDefaults): OptimizeImageOptions;
33
41
  export type PreparedUpload = OptimizeImageResult & {
@@ -89,13 +97,22 @@ export declare function frameOptionsFromFlags(flags: CommandFlags["flags"]): {
89
97
  };
90
98
  /**
91
99
  * List every attachment under the target's prefix and create/update the
92
- * managed comment. Throws on gh failure callers decide whether that is
93
- * fatal (`comment` command) or a warning (`put --comment`).
100
+ * managed comment. Prefers the server-side bot endpoint (`uploads-sh[bot]`,
101
+ * rendered from this workspace's own data); any failure to post that way —
102
+ * not installed, declined, self-hosted 404, network error — falls through to
103
+ * the local-`gh` path so self-hosters keep working unchanged. Throws on gh
104
+ * failure — callers decide whether that is fatal (`comment` command) or a
105
+ * warning (`put --comment`).
94
106
  */
95
- export declare function syncAttachmentsComment(client: UploadsClient, target: GhTarget, run: CommandRunner): Promise<{
107
+ export interface AttachmentsCommentResult {
96
108
  action: "created" | "updated" | "skipped";
97
109
  count: number;
98
- }>;
110
+ /** Who posted the comment: the GitHub App bot, or the local `gh` fallback. */
111
+ via: "bot" | "gh";
112
+ }
113
+ /** Human-mode suffix noting who posted the managed comment. */
114
+ export declare function commentViaSuffix(via: AttachmentsCommentResult["via"]): string;
115
+ export declare function syncAttachmentsComment(client: UploadsClient, target: GhTarget, run: CommandRunner, workspace?: string): Promise<AttachmentsCommentResult>;
99
116
  export type AttachUploadItem = PutResult & {
100
117
  file: string;
101
118
  markdown: string;
@@ -141,6 +158,37 @@ export declare function uploadAttachments(opts: {
141
158
  failures: AttachFailure[];
142
159
  firstError?: unknown;
143
160
  }>;
161
+ /** A branch to stage attachments against pre-PR (`uploads attach --branch`). */
162
+ export interface BranchTarget {
163
+ repo: string;
164
+ branch: string;
165
+ }
166
+ /**
167
+ * Prepare + put each path as a branch-staged attachment (pre-PR) with
168
+ * bounded concurrency. Same shape as `uploadAttachments`, keyed under
169
+ * `gh/<owner>/<repo>/branch/<branch>/<filename>` instead of a PR/issue
170
+ * number. Never syncs the managed comment — callers must not call
171
+ * `syncAttachmentsComment` for a branch target.
172
+ */
173
+ export declare function uploadBranchAttachments(opts: {
174
+ client: UploadsClient;
175
+ target: BranchTarget;
176
+ files: readonly string[];
177
+ contentType?: string;
178
+ optimize: OptimizeImageOptions;
179
+ frame: {
180
+ frameId?: string;
181
+ frameUrl?: string;
182
+ frameFit?: "cover" | "contain";
183
+ };
184
+ metadata?: Record<string, string>;
185
+ provenanceClient?: string;
186
+ concurrency?: number;
187
+ }): Promise<{
188
+ uploads: AttachUploadItem[];
189
+ failures: AttachFailure[];
190
+ firstError?: unknown;
191
+ }>;
144
192
  export type PutUploadItem = PutResult & {
145
193
  file: string;
146
194
  markdown: string;
@@ -190,6 +238,7 @@ export declare function runFind(ctx: CliContext, args: string[], help?: boolean)
190
238
  export declare function runMeta(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
191
239
  export declare function runDelete(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
192
240
  export declare function runComment(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
241
+ export declare function runGithub(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
193
242
  export declare function runUsage(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
194
243
  export declare function runReconcile(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
195
244
  export declare function runPurgeExpired(ctx: CliContext, args: string[], help?: boolean): Promise<number>;