@buildinternet/uploads 0.15.0 → 0.16.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/README.md CHANGED
@@ -83,6 +83,17 @@ infers the pull request for the current branch via `gh`, uploads stable URLs, an
83
83
  or updates one marker-owned GitHub comment. It keeps loose `gh/...` attachments and linked public galleries in distinct sections, shows up to three available gallery images inline, and updates that same comment in place on every sync. Use `--pr`, `--issue`, and `--repo` to select
84
84
  the target explicitly, or `--no-comment` to upload without changing GitHub comments.
85
85
 
86
+ **Branch staging (pre-PR):** `attach <files> --branch [name]` (also on
87
+ `screenshot`) stages files against a git branch before any PR exists — the
88
+ working mode for coding agents capturing as they go. Staged files live under
89
+ `gh/<owner>/<repo>/branch/<branch>/…` and are promoted into the PR's
90
+ attachments when one opens: automatically via the
91
+ [GitHub App](https://uploads.sh/docs/github-app) webhook, or on the first
92
+ `attach` after the PR exists (`--promote` forces it with no new files,
93
+ `--no-promote` opts out). `uploads github link` inspects or claims the
94
+ workspace↔repo binding the webhook path uses. Promoted staging is cleaned up
95
+ server-side after ~7 days (~30 for branches that never got a PR).
96
+
86
97
  **Screenshot capture:** `uploads screenshot <url|file.html>` renders a page to a
87
98
  hosted image in one step — no separate browser tooling needed. `--via auto`
88
99
  (default) drives a Chrome/Chromium already on the machine (`playwright-core`
@@ -136,7 +136,10 @@ export const ROOT_COMMANDS = [
136
136
  {
137
137
  name: "github",
138
138
  summary: "Claim/inspect this workspace's binding to a GitHub repo",
139
- subcommands: [{ name: "link", summary: "Claim or inspect the repo binding" }],
139
+ subcommands: [
140
+ { name: "link", summary: "Claim or inspect the repo binding" },
141
+ { name: "doctor", summary: "Check the GitHub App's webhook event subscriptions" },
142
+ ],
140
143
  },
141
144
  {
142
145
  name: "list",
package/dist/client.d.ts CHANGED
@@ -172,8 +172,16 @@ export interface FindGalleriesByReferenceOptions {
172
172
  limit?: number;
173
173
  cursor?: string;
174
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";
175
+ /**
176
+ * Reasons the bot did not post. The CLI falls back to the local `gh` path
177
+ * for all of these except `not_authorized` (issue #297 baseline control):
178
+ * the target repo is bound to a different workspace (or is unbound and the
179
+ * caller is the communal `default` workspace, which can't claim new repos).
180
+ * Falling back to `gh` there would let the human's own credentials post
181
+ * anyway, defeating the point of the server-side gate, so the CLI surfaces
182
+ * the decline instead.
183
+ */
184
+ export type GithubCommentDeclineReason = "app_unconfigured" | "not_installed" | "forbidden" | "not_authorized" | "unavailable";
177
185
  export type GithubCommentResult = {
178
186
  posted: true;
179
187
  action: "created" | "updated" | "skipped";
@@ -212,9 +220,24 @@ export interface GithubLinkResult {
212
220
  export interface GithubLinkClaimResult extends GithubLinkResult {
213
221
  claimed: boolean;
214
222
  }
223
+ /** `DELETE /v1/:workspace/github/link` result (issue #318, self-serve unlink). */
224
+ export interface GithubLinkUnlinkResult {
225
+ repo: string;
226
+ unlinked: boolean;
227
+ reason?: "not_linked";
228
+ }
215
229
  export interface HealthResult {
216
230
  ok: boolean;
217
231
  }
232
+ /** `GET /v1/:workspace/github/health` result (issue #293 follow-up). */
233
+ export interface GithubHealthResult {
234
+ configured: boolean;
235
+ ok: boolean;
236
+ events: string[] | null;
237
+ missingEvents: string[];
238
+ requiredEvents: string[];
239
+ hint?: string;
240
+ }
218
241
  export interface UsageResult {
219
242
  workspace: string;
220
243
  bytes: number;
@@ -462,6 +485,20 @@ export declare function createUploadsClient(config: UploadsClientConfig): {
462
485
  * server without this route.
463
486
  */
464
487
  githubLinkClaim(repo: string): Promise<GithubLinkClaimResult>;
488
+ /**
489
+ * GitHub App configuration + webhook event subscription check. Throws
490
+ * `UploadsError` (status 404) on an older/self-hosted server without
491
+ * this route — callers treat that as "unknown", not "broken".
492
+ */
493
+ githubHealth(): Promise<GithubHealthResult>;
494
+ /**
495
+ * Self-serve unlink (issue #318): removes `repo`'s binding, but only if
496
+ * this workspace currently owns it. Throws `UploadsError` (status 403)
497
+ * when a different workspace owns the binding — never steals or
498
+ * overwrites another workspace's claim. Throws (status 404) on an
499
+ * older/self-hosted server without this route.
500
+ */
501
+ githubLinkUnlink(repo: string): Promise<GithubLinkUnlinkResult>;
465
502
  health(): Promise<HealthResult>;
466
503
  /** Workspace storage / upload counters (+ limits when configured). */
467
504
  usage(): Promise<UsageResult>;
package/dist/client.js CHANGED
@@ -489,6 +489,24 @@ export function createUploadsClient(config) {
489
489
  headers: { "Content-Type": "application/json" },
490
490
  });
491
491
  },
492
+ /**
493
+ * GitHub App configuration + webhook event subscription check. Throws
494
+ * `UploadsError` (status 404) on an older/self-hosted server without
495
+ * this route — callers treat that as "unknown", not "broken".
496
+ */
497
+ async githubHealth() {
498
+ return request("GET", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/health`);
499
+ },
500
+ /**
501
+ * Self-serve unlink (issue #318): removes `repo`'s binding, but only if
502
+ * this workspace currently owns it. Throws `UploadsError` (status 403)
503
+ * when a different workspace owns the binding — never steals or
504
+ * overwrites another workspace's claim. Throws (status 404) on an
505
+ * older/self-hosted server without this route.
506
+ */
507
+ async githubLinkUnlink(repo) {
508
+ return request("DELETE", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/link?repo=${encodeURIComponent(repo)}`);
509
+ },
492
510
  async health() {
493
511
  return request("GET", `${config.apiUrl}/health`, { auth: false });
494
512
  },
@@ -33,7 +33,10 @@ export declare function ghTargetFromFlags(flags: CommandFlags["flags"], run: Com
33
33
  * the current git branch (`resolveCurrentBranch`); `--branch feature/x` uses
34
34
  * the given name verbatim. Returns undefined when the flag is absent at all
35
35
  * (distinct from an empty/whitespace value, which is rejected). Throws
36
- * UsageError if `--branch` is given more than once.
36
+ * UsageError if `--branch` is given more than once, or if the value looks
37
+ * like a filename accidentally swallowed by the optional-value lookahead
38
+ * (e.g. `uploads attach --branch shot.png` with no other file args) — see
39
+ * `looksLikeFileNotBranch`.
37
40
  */
38
41
  export declare function branchFromFlags(flags: CommandFlags["flags"], run: CommandRunner): string | undefined;
39
42
  /** Shared put/attach optimize flags + UPLOADS_NO_OPTIMIZE default. */
@@ -112,6 +115,17 @@ export interface AttachmentsCommentResult {
112
115
  }
113
116
  /** Human-mode suffix noting who posted the managed comment. */
114
117
  export declare function commentViaSuffix(via: AttachmentsCommentResult["via"]): string;
118
+ /**
119
+ * Thrown by `syncAttachmentsComment` when the server declines with
120
+ * `not_authorized` (issue #297 baseline control) — this repo is bound to a
121
+ * different workspace, or unbound and unclaimable by the communal `default`
122
+ * workspace. Deliberately not caught by the generic "bot endpoint
123
+ * unreachable" fallback below: falling back to gh here would let the
124
+ * human's own credentials post anyway, defeating the point of the
125
+ * server-side gate.
126
+ */
127
+ export declare class GithubCommentAuthorizationError extends Error {
128
+ }
115
129
  export declare function syncAttachmentsComment(client: UploadsClient, target: GhTarget, run: CommandRunner, workspace?: string): Promise<AttachmentsCommentResult>;
116
130
  export type AttachUploadItem = PutResult & {
117
131
  file: string;
package/dist/commands.js CHANGED
@@ -1,5 +1,5 @@
1
- import { readFileSync } from "node:fs";
2
- import { basename } from "node:path";
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { basename, extname } from "node:path";
3
3
  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";
@@ -130,12 +130,53 @@ export function makeGhTarget(pr, issue, repoArg, run) {
130
130
  export function ghTargetFromFlags(flags, run) {
131
131
  return makeGhTarget(flagInt(flags, "--pr", "--pr"), flagInt(flags, "--issue", "--issue"), flagString(flags, "--repo"), run);
132
132
  }
133
+ /**
134
+ * Extensions that mark a `--branch` value as almost certainly a filename that
135
+ * got swallowed by the optional-value lookahead (e.g. `--branch shot.png`
136
+ * with no other file args). Branch names legitimately contain dots (e.g.
137
+ * `release/1.2`, `v1.2.3`), so this only matches known media/document
138
+ * extensions, never bare dotted segments.
139
+ */
140
+ const BRANCH_LIKE_FILE_EXTENSIONS = new Set([
141
+ ".png",
142
+ ".jpg",
143
+ ".jpeg",
144
+ ".gif",
145
+ ".webp",
146
+ ".bmp",
147
+ ".svg",
148
+ ".ico",
149
+ ".tif",
150
+ ".tiff",
151
+ ".heic",
152
+ ".avif",
153
+ ".mp4",
154
+ ".mov",
155
+ ".avi",
156
+ ".webm",
157
+ ".mkv",
158
+ ".pdf",
159
+ ]);
160
+ /**
161
+ * True when `value` looks like a filename that was mistakenly consumed as the
162
+ * `--branch` value: it names a file that exists on disk, or its extension is
163
+ * a known media/document type. Ordinary branch names (including dotted ones
164
+ * like `v1.2` or `release/1.2`) never match either check.
165
+ */
166
+ function looksLikeFileNotBranch(value) {
167
+ if (existsSync(value))
168
+ return true;
169
+ return BRANCH_LIKE_FILE_EXTENSIONS.has(extname(value).toLowerCase());
170
+ }
133
171
  /**
134
172
  * Reads `--branch [name]` — an optional-value flag: `--branch` alone resolves
135
173
  * the current git branch (`resolveCurrentBranch`); `--branch feature/x` uses
136
174
  * the given name verbatim. Returns undefined when the flag is absent at all
137
175
  * (distinct from an empty/whitespace value, which is rejected). Throws
138
- * UsageError if `--branch` is given more than once.
176
+ * UsageError if `--branch` is given more than once, or if the value looks
177
+ * like a filename accidentally swallowed by the optional-value lookahead
178
+ * (e.g. `uploads attach --branch shot.png` with no other file args) — see
179
+ * `looksLikeFileNotBranch`.
139
180
  */
140
181
  export function branchFromFlags(flags, run) {
141
182
  if (!flags.has("--branch"))
@@ -145,8 +186,14 @@ export function branchFromFlags(flags, run) {
145
186
  throw new UsageError("--branch may only be given once");
146
187
  if (raw === true)
147
188
  return resolveCurrentBranch(run);
148
- if (typeof raw === "string" && raw.trim().length > 0)
189
+ if (typeof raw === "string" && raw.trim().length > 0) {
190
+ if (looksLikeFileNotBranch(raw)) {
191
+ throw new UsageError(`"${raw}" looks like a file, not a branch name — did you mean ` +
192
+ `"uploads attach ${raw} --branch" (auto-detect the current branch), ` +
193
+ `or "uploads attach --branch <name> ${raw}" (explicit branch name)?`);
194
+ }
149
195
  return raw;
196
+ }
150
197
  throw new UsageError("--branch requires a non-empty branch name");
151
198
  }
152
199
  /**
@@ -297,15 +344,39 @@ export function frameOptionsFromFlags(flags) {
297
344
  export function commentViaSuffix(via) {
298
345
  return via === "bot" ? " (uploads-sh[bot])" : " (via gh)";
299
346
  }
347
+ /**
348
+ * Thrown by `syncAttachmentsComment` when the server declines with
349
+ * `not_authorized` (issue #297 baseline control) — this repo is bound to a
350
+ * different workspace, or unbound and unclaimable by the communal `default`
351
+ * workspace. Deliberately not caught by the generic "bot endpoint
352
+ * unreachable" fallback below: falling back to gh here would let the
353
+ * human's own credentials post anyway, defeating the point of the
354
+ * server-side gate.
355
+ */
356
+ export class GithubCommentAuthorizationError extends Error {
357
+ }
300
358
  export async function syncAttachmentsComment(client, target, run, workspace) {
359
+ let bot;
301
360
  try {
302
- const bot = await client.upsertGithubComment({
361
+ bot = await client.upsertGithubComment({
303
362
  repo: target.repo,
304
363
  num: target.num,
305
364
  kind: target.kind,
306
365
  });
366
+ }
367
+ catch {
368
+ // Endpoint absent/unreachable (self-hosted, network, older worker) — fall
369
+ // through to the gh path below.
370
+ bot = undefined;
371
+ }
372
+ if (bot) {
307
373
  if (bot.posted)
308
374
  return { action: bot.action, count: bot.count, via: "bot" };
375
+ if (bot.reason === "not_authorized") {
376
+ throw new GithubCommentAuthorizationError(`${bot.message ?? `${target.repo} is not authorized for this workspace.`}\n` +
377
+ `Run \`uploads github link --status --repo ${target.repo}\` to see who owns the ` +
378
+ `binding, use that workspace instead, or post the comment manually with gh.`);
379
+ }
309
380
  // Installed-but-unapproved is a fixable misconfiguration, not a silent
310
381
  // degrade: tell the user (and how to fix it) before falling back to gh.
311
382
  if (bot.reason === "forbidden" && bot.message) {
@@ -313,10 +384,6 @@ export async function syncAttachmentsComment(client, target, run, workspace) {
313
384
  `Posting via local gh in the meantime.\n`);
314
385
  }
315
386
  }
316
- catch {
317
- // Endpoint absent/unreachable (self-hosted, network, older worker) — fall
318
- // through to the gh path below.
319
- }
320
387
  // gh fallback: gather from this workspace's own data and post via local `gh`.
321
388
  // Note (issue #304): this CLI process has no server-side WorkspaceRecord in
322
389
  // scope, so it cannot honor a workspace's githubCommentLinkToFilePage=false
@@ -664,11 +731,15 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
664
731
  throw new UsageError("--promote cannot be combined with --no-promote");
665
732
  return runAttachPromoteOnly(ctx, parsed, run);
666
733
  }
734
+ // Validate --branch (including the filename-lookahead guard) before the
735
+ // zero-positionals bailout below — otherwise `uploads attach --branch
736
+ // shot.png` (where shot.png is swallowed as the branch value, leaving no
737
+ // file args) would silently print help instead of a clear UsageError.
738
+ const branchArg = branchFromFlags(parsed.flags, run);
667
739
  if (parsed.positionals.length === 0) {
668
740
  writeCommandHelp(ATTACH_HELP);
669
741
  return 2;
670
742
  }
671
- const branchArg = branchFromFlags(parsed.flags, run);
672
743
  if (branchArg !== undefined) {
673
744
  if (parsed.flags.has("--pr"))
674
745
  throw new UsageError("--branch cannot be combined with --pr");
@@ -1623,6 +1694,11 @@ App is installed on the repo; otherwise via your local gh auth. Finds its own
1623
1694
  prior comment via a hidden marker and edits it in place; never touches other
1624
1695
  comments or the description.
1625
1696
 
1697
+ If this repo is bound to a different workspace (or unbound and you're on the
1698
+ communal "default" workspace), the bot post is declined and this command
1699
+ fails rather than silently falling back to gh — see \`uploads github link
1700
+ --status\`.
1701
+
1626
1702
  Examples:
1627
1703
  uploads --env-file .env comment --pr 123
1628
1704
  uploads comment --issue 45 --repo buildinternet/uploads
@@ -1650,38 +1726,73 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
1650
1726
  }
1651
1727
  // --- github link ---
1652
1728
  const GITHUB_HELP = `uploads github link [--repo <owner/name>] [--status] [--workspace <name>]
1729
+ uploads github unlink [--repo <owner/name>] [--workspace <name>]
1730
+ uploads github doctor [--workspace <name>]
1653
1731
 
1654
- Claim or inspect this workspace's binding to a GitHub repo (see the managed
1655
- attachments comment / webhook auto-promotion, which use this binding).
1656
- First-claim-wins: claiming an already-bound repo never steals it from
1657
- whichever workspace claimed it first — the command reports who owns it
1658
- instead.
1732
+ Claim, inspect, or release this workspace's binding to a GitHub repo (see the
1733
+ managed attachments comment / webhook auto-promotion, which use this
1734
+ binding). First-claim-wins: claiming an already-bound repo never steals it
1735
+ from whichever workspace claimed it first — the command reports who owns it,
1736
+ and how to get it released, instead.
1659
1737
 
1660
1738
  --repo defaults the same way as --pr/--issue elsewhere (gh repo view, then
1661
1739
  the git remote). --status only inspects the current binding (files:read);
1662
- without it, the command claims the repo (files:write).
1740
+ without it, "link" claims the repo (files:write). "unlink" releases a
1741
+ binding this workspace owns — it 403s (via the server) if another workspace
1742
+ owns it; an operator can reassign or remove that binding instead.
1743
+
1744
+ \`doctor\` checks the GitHub App itself: whether it's configured on the
1745
+ server, and whether it's subscribed to the webhook events uploads.sh's
1746
+ handler needs (issues, pull_request — see docs/github-app). A missing
1747
+ subscription is the classic silent failure: the App's ping stays green
1748
+ while webhook auto-promotion and title-cache invalidation quietly do
1749
+ nothing.
1663
1750
 
1664
1751
  Examples:
1665
1752
  uploads github link
1666
1753
  uploads github link --repo buildinternet/uploads
1667
1754
  uploads github link --status
1755
+ uploads github unlink --repo buildinternet/uploads
1756
+ uploads github doctor
1668
1757
  `;
1758
+ function formatGithubDoctor(result) {
1759
+ if (!result.configured) {
1760
+ return `github app: not configured on this server${result.hint ? ` — ${result.hint}` : ""}\n`;
1761
+ }
1762
+ if (result.events === null) {
1763
+ return `github app: configured, but health check failed${result.hint ? ` — ${result.hint}` : ""}\n`;
1764
+ }
1765
+ if (result.ok) {
1766
+ return `github app: ok — subscribed to ${result.requiredEvents.join(", ")}\n`;
1767
+ }
1768
+ return (`github app: missing webhook event subscription(s): ${result.missingEvents.join(", ")}\n` +
1769
+ (result.hint ? ` ${result.hint}\n` : ""));
1770
+ }
1669
1771
  function formatGithubLink(repo, result) {
1670
1772
  return result.workspace
1671
1773
  ? `${repo} is bound to workspace "${result.workspace}"${result.source ? ` (${result.source})` : ""}\n`
1672
1774
  : `${repo} is not bound to any workspace\n`;
1673
1775
  }
1674
- export async function runGithub(ctx, args, help = false, run = execRunner) {
1675
- const parsed = parseCommandArgs(args);
1676
- const action = parsed.positionals[0];
1677
- if (help || parsed.help || !action) {
1678
- writeCommandHelp(GITHUB_HELP);
1679
- return help || parsed.help ? 0 : 2;
1776
+ async function runGithubDoctor(ctx) {
1777
+ let result;
1778
+ try {
1779
+ result = await ctx.client.githubHealth();
1680
1780
  }
1681
- if (action !== "link")
1682
- throw new UsageError(`unknown github subcommand: ${action}`);
1683
- const repo = resolveRepo(flagString(parsed.flags, "--repo"), run);
1684
- const statusOnly = flagBool(parsed.flags, "--status");
1781
+ catch (err) {
1782
+ if (err instanceof UploadsError && err.status === 404) {
1783
+ throw new UsageError("server does not support the GitHub App health check yet (404) — upgrade the uploads.sh API/self-hosted worker");
1784
+ }
1785
+ throw err;
1786
+ }
1787
+ if (ctx.json) {
1788
+ await writeJson(result);
1789
+ }
1790
+ else {
1791
+ await writeStdout(formatGithubDoctor(result));
1792
+ }
1793
+ return result.ok ? 0 : 1;
1794
+ }
1795
+ async function runGithubLink(ctx, repo, statusOnly) {
1685
1796
  let result;
1686
1797
  try {
1687
1798
  result = statusOnly
@@ -1699,11 +1810,52 @@ export async function runGithub(ctx, args, help = false, run = execRunner) {
1699
1810
  return 0;
1700
1811
  }
1701
1812
  if (!statusOnly && result.claimed === false) {
1702
- process.stderr.write(`note: ${repo} is already bound to a different workspace ("${result.workspace}") — first-claim-wins, not overwritten\n`);
1813
+ process.stderr.write(`note: ${repo} is already bound to a different workspace ("${result.workspace}") — first-claim-wins, not overwritten. Run "uploads github unlink --repo ${repo}" from that workspace, or ask an operator to reassign it.\n`);
1703
1814
  }
1704
1815
  await writeStdout(formatGithubLink(repo, result));
1705
1816
  return 0;
1706
1817
  }
1818
+ async function runGithubUnlink(ctx, repo) {
1819
+ let result;
1820
+ try {
1821
+ result = await ctx.client.githubLinkUnlink(repo);
1822
+ }
1823
+ catch (err) {
1824
+ if (err instanceof UploadsError && err.status === 404) {
1825
+ throw new UsageError("server does not support repo bindings yet (404) — upgrade the uploads.sh API/self-hosted worker");
1826
+ }
1827
+ if (err instanceof UploadsError && err.status === 403) {
1828
+ throw new UsageError(`${repo} is bound to a different workspace — ask an operator to reassign or remove it (${err.message})`);
1829
+ }
1830
+ throw err;
1831
+ }
1832
+ if (ctx.json) {
1833
+ await writeJson(result);
1834
+ return 0;
1835
+ }
1836
+ await writeStdout(result.unlinked
1837
+ ? `unlinked ${repo}\n`
1838
+ : `${repo} was not bound to any workspace — nothing to unlink\n`);
1839
+ return 0;
1840
+ }
1841
+ export async function runGithub(ctx, args, help = false, run = execRunner) {
1842
+ const parsed = parseCommandArgs(args);
1843
+ const action = parsed.positionals[0];
1844
+ if (help || parsed.help || !action) {
1845
+ writeCommandHelp(GITHUB_HELP);
1846
+ return help || parsed.help ? 0 : 2;
1847
+ }
1848
+ if (action !== "link" && action !== "unlink" && action !== "doctor") {
1849
+ throw new UsageError(`unknown github subcommand: ${action}`);
1850
+ }
1851
+ if (action === "doctor")
1852
+ return runGithubDoctor(ctx);
1853
+ const repo = resolveRepo(flagString(parsed.flags, "--repo"), run);
1854
+ if (action === "unlink")
1855
+ return runGithubUnlink(ctx, repo);
1856
+ const statusOnly = flagBool(parsed.flags, "--status");
1857
+ return runGithubLink(ctx, repo, statusOnly);
1858
+ }
1707
1859
  // --- usage / reconcile / purge ---
1708
1860
  const USAGE_HELP = `uploads usage [--workspace <name>]
1709
1861
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.15.0",
3
+ "version": "0.16.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,