@buildinternet/uploads 0.14.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 +11 -0
- package/dist/cli-catalog.js +10 -0
- package/dist/cli.js +6 -2
- package/dist/client.d.ts +89 -2
- package/dist/client.js +49 -0
- package/dist/commands/screenshot.js +34 -5
- package/dist/commands.d.ts +55 -1
- package/dist/commands.js +507 -22
- package/dist/github-gh.d.ts +9 -1
- package/dist/github-gh.js +35 -3
- package/dist/github.d.ts +35 -1
- package/dist/github.js +88 -7
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mcp/tools.js +11 -11
- package/package.json +1 -1
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";
|
|
@@ -9,8 +9,8 @@ import { urlForGithubEmbed } from "./public-urls.js";
|
|
|
9
9
|
import { UploadsError } from "./errors.js";
|
|
10
10
|
import { writeJson, writeStdout } from "./io.js";
|
|
11
11
|
import { parseMetaFlags, validateMetaMap } from "./metadata.js";
|
|
12
|
-
import { ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, attachmentsCommentBody, normalizeGithubCoordinate, } from "./github.js";
|
|
13
|
-
import { resolveRepo, resolveCurrentPullRequest, classifyGhNumber, execRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, } from "./github-gh.js";
|
|
12
|
+
import { ghAttachmentKey, ghBranchAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, normalizeGithubCoordinate, } from "./github.js";
|
|
13
|
+
import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, classifyGhNumber, execRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, } from "./github-gh.js";
|
|
14
14
|
import { resolvePutPrefix } from "./destinations.js";
|
|
15
15
|
import { optimizeImageForUpload, rewriteKeyExtension, } from "./optimize.js";
|
|
16
16
|
import { applyFrame, resolveFrameId } from "./frame.js";
|
|
@@ -130,6 +130,72 @@ 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
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Reads `--branch [name]` — an optional-value flag: `--branch` alone resolves
|
|
173
|
+
* the current git branch (`resolveCurrentBranch`); `--branch feature/x` uses
|
|
174
|
+
* the given name verbatim. Returns undefined when the flag is absent at all
|
|
175
|
+
* (distinct from an empty/whitespace value, which is rejected). Throws
|
|
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`.
|
|
180
|
+
*/
|
|
181
|
+
export function branchFromFlags(flags, run) {
|
|
182
|
+
if (!flags.has("--branch"))
|
|
183
|
+
return undefined;
|
|
184
|
+
const raw = flags.get("--branch");
|
|
185
|
+
if (Array.isArray(raw))
|
|
186
|
+
throw new UsageError("--branch may only be given once");
|
|
187
|
+
if (raw === true)
|
|
188
|
+
return resolveCurrentBranch(run);
|
|
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
|
+
}
|
|
195
|
+
return raw;
|
|
196
|
+
}
|
|
197
|
+
throw new UsageError("--branch requires a non-empty branch name");
|
|
198
|
+
}
|
|
133
199
|
/**
|
|
134
200
|
* Best-effort GitHub target for the default put path (no --pr/--issue). A
|
|
135
201
|
* numeric --ref is classified as pull vs issue; otherwise the current branch's
|
|
@@ -278,22 +344,53 @@ export function frameOptionsFromFlags(flags) {
|
|
|
278
344
|
export function commentViaSuffix(via) {
|
|
279
345
|
return via === "bot" ? " (uploads-sh[bot])" : " (via gh)";
|
|
280
346
|
}
|
|
281
|
-
|
|
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
|
+
}
|
|
358
|
+
export async function syncAttachmentsComment(client, target, run, workspace) {
|
|
359
|
+
let bot;
|
|
282
360
|
try {
|
|
283
|
-
|
|
361
|
+
bot = await client.upsertGithubComment({
|
|
284
362
|
repo: target.repo,
|
|
285
363
|
num: target.num,
|
|
286
364
|
kind: target.kind,
|
|
287
365
|
});
|
|
288
|
-
if (bot.posted)
|
|
289
|
-
return { action: bot.action, count: bot.count, via: "bot" };
|
|
290
366
|
}
|
|
291
367
|
catch {
|
|
292
368
|
// Endpoint absent/unreachable (self-hosted, network, older worker) — fall
|
|
293
369
|
// through to the gh path below.
|
|
370
|
+
bot = undefined;
|
|
371
|
+
}
|
|
372
|
+
if (bot) {
|
|
373
|
+
if (bot.posted)
|
|
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
|
+
}
|
|
380
|
+
// Installed-but-unapproved is a fixable misconfiguration, not a silent
|
|
381
|
+
// degrade: tell the user (and how to fix it) before falling back to gh.
|
|
382
|
+
if (bot.reason === "forbidden" && bot.message) {
|
|
383
|
+
process.stderr.write(`note: ${bot.message}${bot.fixUrl ? `\n ${bot.fixUrl}` : ""}\n` +
|
|
384
|
+
`Posting via local gh in the meantime.\n`);
|
|
385
|
+
}
|
|
294
386
|
}
|
|
295
387
|
// gh fallback: gather from this workspace's own data and post via local `gh`.
|
|
296
|
-
|
|
388
|
+
// Note (issue #304): this CLI process has no server-side WorkspaceRecord in
|
|
389
|
+
// scope, so it cannot honor a workspace's githubCommentLinkToFilePage=false
|
|
390
|
+
// — it always links to the file page here, matching the default. This only
|
|
391
|
+
// diverges from the bot-posted comment for a workspace that both sets the
|
|
392
|
+
// flag false and falls through to this gh-fallback path.
|
|
393
|
+
const items = (await client.listAll({ prefix: ghKeyPrefix(target) })).map(({ key, url, embedUrl, pageUrl }) => ({ key, url, embedUrl, pageUrl }));
|
|
297
394
|
const galleries = [];
|
|
298
395
|
let cursor;
|
|
299
396
|
do {
|
|
@@ -329,8 +426,9 @@ export async function syncAttachmentsComment(client, target, run) {
|
|
|
329
426
|
}));
|
|
330
427
|
if (items.length === 0 && previewGalleries.length === 0)
|
|
331
428
|
return { action: "skipped", count: 0, via: "gh" };
|
|
332
|
-
const
|
|
333
|
-
const
|
|
429
|
+
const marker = attachmentsMarker(workspace);
|
|
430
|
+
const body = attachmentsCommentBody(items, previewGalleries, marker);
|
|
431
|
+
const { created } = upsertAttachmentsComment(target, body, run, marker);
|
|
334
432
|
return {
|
|
335
433
|
action: created ? "created" : "updated",
|
|
336
434
|
count: items.length + previewGalleries.length,
|
|
@@ -357,9 +455,37 @@ URL and every embed hot-swap. Human mode prints ">> replaced existing object
|
|
|
357
455
|
Still images are optimized to WebP by default (same as put). Use --no-optimize
|
|
358
456
|
to upload originals. Optional --frame wraps images in device/browser chrome.
|
|
359
457
|
|
|
458
|
+
Branch staging (pre-PR): --branch [name] stages files against a git branch
|
|
459
|
+
before a pull request exists, e.g. for a coding agent working a branch that
|
|
460
|
+
hasn't opened a PR yet. Key: gh/<owner>/<repo>/branch/<branch>/<filename>
|
|
461
|
+
("/" in the branch name sanitizes to "-", e.g. feature/x -> feature-x).
|
|
462
|
+
With no value, --branch resolves the current git branch. Staged files are
|
|
463
|
+
public like every other attachment — same public-URL caveat applies. There is
|
|
464
|
+
no managed comment for a branch (no PR/issue to comment on yet); --branch
|
|
465
|
+
never runs the comment sync and cannot combine with --pr/--issue/--comment.
|
|
466
|
+
|
|
467
|
+
Promotion: once a PR exists, staged files for the current branch are picked
|
|
468
|
+
up automatically the first time you attach to that PR (a plain "uploads
|
|
469
|
+
attach <file> --pr <num>", or the inferred-PR default with no target flags) —
|
|
470
|
+
they're copied into the PR's attachment prefix before the managed comment is
|
|
471
|
+
built, so they show up in the same run. Pass --no-promote to skip that. If
|
|
472
|
+
you'd rather promote without attaching a new file (e.g. right after
|
|
473
|
+
"gh pr create" with nothing new to upload), run "uploads attach --promote"
|
|
474
|
+
with no file arguments — it resolves the PR the same way, promotes, and
|
|
475
|
+
refreshes the comment; it exits 0 even if nothing was staged. --promote only
|
|
476
|
+
takes effect with zero files and cannot combine with --branch/--issue/
|
|
477
|
+
--no-promote. Promotion never applies to issues. Staged files stay findable
|
|
478
|
+
with "uploads find gh.branch=<branch>" either way.
|
|
479
|
+
|
|
360
480
|
Options:
|
|
361
481
|
--pr <num> Attach to this pull request
|
|
362
482
|
--issue <num> Attach to this issue
|
|
483
|
+
--branch [name] Stage against a branch, pre-PR (default: current git branch);
|
|
484
|
+
not with --pr/--issue/--comment
|
|
485
|
+
--promote No files: promote branch-staged attachments into the
|
|
486
|
+
resolved PR and refresh the comment; not with
|
|
487
|
+
--branch/--issue/--no-promote
|
|
488
|
+
--no-promote Skip auto-promoting branch-staged attachments (default path only)
|
|
363
489
|
--repo <owner/repo> Repository (default: gh/git inference)
|
|
364
490
|
--no-comment Upload only; don't create/update the managed comment
|
|
365
491
|
--content-type <mime> Override Content-Type (applied to every file; ignored when optimize rewrites)
|
|
@@ -373,7 +499,8 @@ Options:
|
|
|
373
499
|
--workspace, -w <name> Override workspace
|
|
374
500
|
--meta <k=v> Extra queryable metadata (repeatable; value may contain "=").
|
|
375
501
|
gh.repo/gh.kind/gh.number/gh.ref are always set from the resolved
|
|
376
|
-
target
|
|
502
|
+
target (or gh.repo/gh.kind/gh.branch/gh.staged-at with --branch) —
|
|
503
|
+
a --meta pair with the same key is overridden by it.
|
|
377
504
|
Because attach always sends its own gh.* pairs, re-attaching to
|
|
378
505
|
the same key always replaces that file's entire metadata set
|
|
379
506
|
(never preserves) — use "uploads meta set" to add to it instead.
|
|
@@ -384,13 +511,17 @@ Examples:
|
|
|
384
511
|
uploads attach ./shot.png --pr 123 --repo myorg/myapp
|
|
385
512
|
uploads attach ./artifact.zip --issue 45 --no-comment
|
|
386
513
|
uploads attach ./shot.png --meta app=myapp --meta page=settings
|
|
514
|
+
uploads attach ./shot.png --branch
|
|
515
|
+
uploads attach ./shot.png --branch feature/new-settings
|
|
516
|
+
uploads attach --promote
|
|
387
517
|
`;
|
|
388
518
|
/**
|
|
389
|
-
*
|
|
390
|
-
*
|
|
519
|
+
* Shared prepare + put loop for both PR/issue attach (`uploadAttachments`)
|
|
520
|
+
* and branch-staged attach (`uploadBranchAttachments`) — bounded concurrency,
|
|
521
|
+
* per-file errors collect in `failures` (does not throw). `firstError` is the
|
|
391
522
|
* original cause of the first failure — for rethrowing single-file CLI paths.
|
|
392
523
|
*/
|
|
393
|
-
|
|
524
|
+
async function uploadAttachmentBatch(opts) {
|
|
394
525
|
if (opts.files.some((f) => f === "-")) {
|
|
395
526
|
throw new UsageError("attach does not support stdin; pass one or more file paths");
|
|
396
527
|
}
|
|
@@ -403,7 +534,7 @@ export async function uploadAttachments(opts) {
|
|
|
403
534
|
});
|
|
404
535
|
const result = await opts.client.put(prepared.bytes, {
|
|
405
536
|
filename: prepared.filename,
|
|
406
|
-
key:
|
|
537
|
+
key: opts.keyFor(prepared.filename),
|
|
407
538
|
contentType: prepared.optimized ? prepared.contentType : opts.contentType,
|
|
408
539
|
provenance: buildCliProvenance({
|
|
409
540
|
sourceName,
|
|
@@ -450,6 +581,30 @@ export async function uploadAttachments(opts) {
|
|
|
450
581
|
}
|
|
451
582
|
return { uploads, failures, firstError };
|
|
452
583
|
}
|
|
584
|
+
/**
|
|
585
|
+
* Prepare + put each path as a PR/issue attachment with bounded concurrency.
|
|
586
|
+
* Per-file errors collect in `failures` (does not throw). `firstError` is the
|
|
587
|
+
* original cause of the first failure — for rethrowing single-file CLI paths.
|
|
588
|
+
*/
|
|
589
|
+
export async function uploadAttachments(opts) {
|
|
590
|
+
return uploadAttachmentBatch({
|
|
591
|
+
...opts,
|
|
592
|
+
keyFor: (filename) => ghAttachmentKey(opts.target, filename),
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Prepare + put each path as a branch-staged attachment (pre-PR) with
|
|
597
|
+
* bounded concurrency. Same shape as `uploadAttachments`, keyed under
|
|
598
|
+
* `gh/<owner>/<repo>/branch/<branch>/<filename>` instead of a PR/issue
|
|
599
|
+
* number. Never syncs the managed comment — callers must not call
|
|
600
|
+
* `syncAttachmentsComment` for a branch target.
|
|
601
|
+
*/
|
|
602
|
+
export async function uploadBranchAttachments(opts) {
|
|
603
|
+
return uploadAttachmentBatch({
|
|
604
|
+
...opts,
|
|
605
|
+
keyFor: (filename) => ghBranchAttachmentKey(opts.target.repo, opts.target.branch, filename),
|
|
606
|
+
});
|
|
607
|
+
}
|
|
453
608
|
function errorDetail(err) {
|
|
454
609
|
if (err instanceof UploadsError)
|
|
455
610
|
return { message: err.message, code: err.code, status: err.status };
|
|
@@ -527,18 +682,72 @@ export async function uploadPuts(opts) {
|
|
|
527
682
|
}
|
|
528
683
|
return { uploads, failures, firstError };
|
|
529
684
|
}
|
|
685
|
+
/**
|
|
686
|
+
* Best-effort call to `POST /v1/:workspace/github/promote` (server contract,
|
|
687
|
+
* PR #310). Degrade-safe like `syncAttachmentsComment`'s bot path: an older
|
|
688
|
+
* or self-hosted worker without this route (404), a forbidden token (403),
|
|
689
|
+
* or a network error all collapse to "nothing promoted" — the caller must
|
|
690
|
+
* never let this fail the attach. Returns undefined on any failure.
|
|
691
|
+
*/
|
|
692
|
+
async function attemptPromoteBranch(client, target, branch) {
|
|
693
|
+
try {
|
|
694
|
+
return await client.promoteBranchAttachments({ repo: target.repo, num: target.num, branch });
|
|
695
|
+
}
|
|
696
|
+
catch {
|
|
697
|
+
return undefined;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
/** Human-mode note for a promotion that actually promoted something. */
|
|
701
|
+
function promotionNote(promotion, branch) {
|
|
702
|
+
const n = promotion.promoted.length;
|
|
703
|
+
const branchSuffix = branch ? ` from branch ${branch}` : "";
|
|
704
|
+
return `>> promoted ${n} staged attachment${n === 1 ? "" : "s"}${branchSuffix}\n`;
|
|
705
|
+
}
|
|
530
706
|
export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
531
707
|
const parsed = parseCommandArgs(args);
|
|
532
708
|
if (help || parsed.help) {
|
|
533
709
|
writeCommandHelp(ATTACH_HELP);
|
|
534
710
|
return 0;
|
|
535
711
|
}
|
|
712
|
+
if (parsed.flags.has("--no-comment") && typeof parsed.flags.get("--no-comment") === "string") {
|
|
713
|
+
throw new UsageError("--no-comment takes no value — place it after the file arguments");
|
|
714
|
+
}
|
|
715
|
+
if (parsed.flags.has("--promote") && typeof parsed.flags.get("--promote") === "string") {
|
|
716
|
+
throw new UsageError("--promote takes no value — place it after the file arguments");
|
|
717
|
+
}
|
|
718
|
+
if (parsed.flags.has("--no-promote") && typeof parsed.flags.get("--no-promote") === "string") {
|
|
719
|
+
throw new UsageError("--no-promote takes no value — place it after the file arguments");
|
|
720
|
+
}
|
|
721
|
+
if (parsed.flags.has("--promote")) {
|
|
722
|
+
if (parsed.positionals.length > 0) {
|
|
723
|
+
throw new UsageError("--promote takes no file arguments — attaching a file to a PR already auto-promotes " +
|
|
724
|
+
"staged files; use `uploads attach <file> --pr <num>` instead");
|
|
725
|
+
}
|
|
726
|
+
if (parsed.flags.has("--branch"))
|
|
727
|
+
throw new UsageError("--promote cannot be combined with --branch");
|
|
728
|
+
if (parsed.flags.has("--issue"))
|
|
729
|
+
throw new UsageError("--promote cannot be combined with --issue");
|
|
730
|
+
if (parsed.flags.has("--no-promote"))
|
|
731
|
+
throw new UsageError("--promote cannot be combined with --no-promote");
|
|
732
|
+
return runAttachPromoteOnly(ctx, parsed, run);
|
|
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);
|
|
536
739
|
if (parsed.positionals.length === 0) {
|
|
537
740
|
writeCommandHelp(ATTACH_HELP);
|
|
538
741
|
return 2;
|
|
539
742
|
}
|
|
540
|
-
if (
|
|
541
|
-
|
|
743
|
+
if (branchArg !== undefined) {
|
|
744
|
+
if (parsed.flags.has("--pr"))
|
|
745
|
+
throw new UsageError("--branch cannot be combined with --pr");
|
|
746
|
+
if (parsed.flags.has("--issue"))
|
|
747
|
+
throw new UsageError("--branch cannot be combined with --issue");
|
|
748
|
+
if (parsed.flags.has("--comment"))
|
|
749
|
+
throw new UsageError("--branch cannot be combined with --comment");
|
|
750
|
+
return runAttachBranch(ctx, parsed, branchArg, run);
|
|
542
751
|
}
|
|
543
752
|
const explicitTarget = ghTargetFromFlags(parsed.flags, run);
|
|
544
753
|
const target = explicitTarget ??
|
|
@@ -574,12 +783,33 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
574
783
|
if (uploads.length === 0 && failures.length === 1 && parsed.positionals.length === 1) {
|
|
575
784
|
throw firstError instanceof Error ? firstError : new Error(String(firstError));
|
|
576
785
|
}
|
|
786
|
+
// Auto-promote: before the comment sync, best-effort promote this
|
|
787
|
+
// workspace's own branch-staged attachments (from an earlier `attach
|
|
788
|
+
// --branch` while the PR didn't exist yet) into this PR's attachment
|
|
789
|
+
// prefix, so the comment gather below sees them in the same invocation.
|
|
790
|
+
// Never for issues (branch staging only ever targets a future PR), never
|
|
791
|
+
// with --no-promote, and silently skipped (no client call at all) when the
|
|
792
|
+
// current git branch can't be resolved (detached HEAD, not a repo) — this
|
|
793
|
+
// must never fail the attach itself.
|
|
794
|
+
let promotion;
|
|
795
|
+
let promotedBranch;
|
|
796
|
+
if (target.kind === "pull" && !parsed.flags.has("--no-promote")) {
|
|
797
|
+
try {
|
|
798
|
+
promotedBranch = resolveCurrentBranch(run);
|
|
799
|
+
}
|
|
800
|
+
catch {
|
|
801
|
+
promotedBranch = undefined;
|
|
802
|
+
}
|
|
803
|
+
if (promotedBranch !== undefined) {
|
|
804
|
+
promotion = await attemptPromoteBranch(ctx.client, target, promotedBranch);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
577
807
|
let comment;
|
|
578
808
|
let commentError;
|
|
579
809
|
// Skip comment refresh when every upload failed — nothing new from this batch.
|
|
580
810
|
if (!parsed.flags.has("--no-comment") && uploads.length > 0) {
|
|
581
811
|
try {
|
|
582
|
-
comment = await syncAttachmentsComment(ctx.client, target, run);
|
|
812
|
+
comment = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace);
|
|
583
813
|
}
|
|
584
814
|
catch (err) {
|
|
585
815
|
commentError = err instanceof Error ? err.message : String(err);
|
|
@@ -587,7 +817,14 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
587
817
|
}
|
|
588
818
|
}
|
|
589
819
|
if (ctx.json) {
|
|
590
|
-
await writeJson({
|
|
820
|
+
await writeJson({
|
|
821
|
+
target,
|
|
822
|
+
uploads,
|
|
823
|
+
failures,
|
|
824
|
+
comment,
|
|
825
|
+
commentError,
|
|
826
|
+
promotion: promotion ?? null,
|
|
827
|
+
});
|
|
591
828
|
}
|
|
592
829
|
else {
|
|
593
830
|
for (const result of uploads) {
|
|
@@ -606,6 +843,9 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
606
843
|
for (const failure of failures) {
|
|
607
844
|
process.stderr.write(`warning: could not upload ${failure.file}: ${failure.error.message}\n`);
|
|
608
845
|
}
|
|
846
|
+
if (!ctx.quiet && promotion && promotion.promoted.length > 0) {
|
|
847
|
+
process.stderr.write(promotionNote(promotion, promotedBranch));
|
|
848
|
+
}
|
|
609
849
|
if (!ctx.quiet && comment)
|
|
610
850
|
process.stderr.write(`>> attachments comment ${comment.action}${commentViaSuffix(comment.via)}\n`);
|
|
611
851
|
if (!ctx.quiet && uploads.length > 0) {
|
|
@@ -615,6 +855,114 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
615
855
|
}
|
|
616
856
|
return failures.length === 0 ? 0 : 1;
|
|
617
857
|
}
|
|
858
|
+
/**
|
|
859
|
+
* `attach --branch` path: stages files under
|
|
860
|
+
* `gh/<owner>/<repo>/branch/<branch>/<filename>` instead of a PR/issue
|
|
861
|
+
* number. Never syncs the managed comment (there is no PR/issue to comment
|
|
862
|
+
* on yet, and the comment-gatherer only lists PR/issue prefixes anyway —
|
|
863
|
+
* branch-staged keys are invisible to it by construction).
|
|
864
|
+
*/
|
|
865
|
+
async function runAttachBranch(ctx, parsed, branch, run) {
|
|
866
|
+
const repo = resolveRepo(flagString(parsed.flags, "--repo"), run);
|
|
867
|
+
const defaults = resolvePutDefaults({ envFile: ctx.envFile });
|
|
868
|
+
const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, defaults);
|
|
869
|
+
const frameOpts = frameOptionsFromFlags(parsed.flags);
|
|
870
|
+
const contentTypeOverride = flagString(parsed.flags, "--content-type");
|
|
871
|
+
const metaExtras = parseMetaFlags(flagValues(parsed.flags, "--meta"));
|
|
872
|
+
const metadata = { ...metaExtras, ...ghMetadataForBranch(repo, branch) };
|
|
873
|
+
validateMetaMap(metadata);
|
|
874
|
+
const logHuman = !ctx.quiet && !ctx.json;
|
|
875
|
+
if (logHuman) {
|
|
876
|
+
const n = parsed.positionals.length;
|
|
877
|
+
process.stderr.write(`>> uploading ${n} file${n === 1 ? "" : "s"} (staged for branch ${branch})\n`);
|
|
878
|
+
}
|
|
879
|
+
const target = { repo, branch };
|
|
880
|
+
const { uploads, failures, firstError } = await uploadBranchAttachments({
|
|
881
|
+
client: ctx.client,
|
|
882
|
+
target,
|
|
883
|
+
files: parsed.positionals,
|
|
884
|
+
contentType: contentTypeOverride,
|
|
885
|
+
optimize: optimizeOpts,
|
|
886
|
+
frame: frameOpts,
|
|
887
|
+
metadata,
|
|
888
|
+
});
|
|
889
|
+
// Single-file total failure: rethrow so CLI exit codes stay auth/network-aware.
|
|
890
|
+
if (uploads.length === 0 && failures.length === 1 && parsed.positionals.length === 1) {
|
|
891
|
+
throw firstError instanceof Error ? firstError : new Error(String(firstError));
|
|
892
|
+
}
|
|
893
|
+
if (ctx.json) {
|
|
894
|
+
await writeJson({ target, uploads, failures });
|
|
895
|
+
}
|
|
896
|
+
else {
|
|
897
|
+
for (const result of uploads) {
|
|
898
|
+
if (logHuman) {
|
|
899
|
+
if (result.frame?.framed) {
|
|
900
|
+
process.stderr.write(`>> ${basename(result.file)}: framed with ${result.frame.frameId}\n`);
|
|
901
|
+
}
|
|
902
|
+
const note = formatOptimizeNote(result.optimize);
|
|
903
|
+
if (note)
|
|
904
|
+
process.stderr.write(`>> ${basename(result.file)}: ${note}\n`);
|
|
905
|
+
writeReplacedNote(result.replaced, false);
|
|
906
|
+
}
|
|
907
|
+
const embedLine = result.embedUrl ? `EMBED: ${result.embedUrl}\n` : "";
|
|
908
|
+
await writeStdout(`URL: ${result.url}\n${embedLine}MARKDOWN: ${result.markdown}\n`);
|
|
909
|
+
}
|
|
910
|
+
for (const failure of failures) {
|
|
911
|
+
process.stderr.write(`warning: could not upload ${failure.file}: ${failure.error.message}\n`);
|
|
912
|
+
}
|
|
913
|
+
if (!ctx.quiet && uploads.length > 0) {
|
|
914
|
+
process.stderr.write(`>> find these later: uploads find gh.branch=${branch.toLowerCase()}\n`);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
return failures.length === 0 ? 0 : 1;
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* `attach --promote` with zero file arguments: resolve the PR target (same
|
|
921
|
+
* resolution as the default `runAttach` path), promote this workspace's
|
|
922
|
+
* branch-staged attachments into it, then run the comment sync — useful
|
|
923
|
+
* right after `gh pr create` when the PR was opened without a fresh attach
|
|
924
|
+
* (auto-promotion on the default path only fires when you attach a file).
|
|
925
|
+
* Unlike the default path's best-effort branch resolution, this is an
|
|
926
|
+
* explicit user action: `resolveCurrentBranch` throwing (detached HEAD, not
|
|
927
|
+
* a repo) propagates as a UsageError instead of silently skipping. Always
|
|
928
|
+
* exits 0 — an empty staging prefix is success, not a failure.
|
|
929
|
+
*/
|
|
930
|
+
async function runAttachPromoteOnly(ctx, parsed, run) {
|
|
931
|
+
const explicitTarget = ghTargetFromFlags(parsed.flags, run);
|
|
932
|
+
const target = explicitTarget ??
|
|
933
|
+
resolveCurrentPullRequest(resolveRepo(flagString(parsed.flags, "--repo"), run), run);
|
|
934
|
+
const branch = resolveCurrentBranch(run);
|
|
935
|
+
const promotion = await attemptPromoteBranch(ctx.client, target, branch);
|
|
936
|
+
let comment;
|
|
937
|
+
let commentError;
|
|
938
|
+
if (!parsed.flags.has("--no-comment")) {
|
|
939
|
+
try {
|
|
940
|
+
comment = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace);
|
|
941
|
+
}
|
|
942
|
+
catch (err) {
|
|
943
|
+
commentError = err instanceof Error ? err.message : String(err);
|
|
944
|
+
process.stderr.write(`warning: promotion succeeded but the GitHub comment failed (is gh installed and authenticated?): ${commentError}\n`);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
if (ctx.json) {
|
|
948
|
+
await writeJson({
|
|
949
|
+
target,
|
|
950
|
+
uploads: [],
|
|
951
|
+
failures: [],
|
|
952
|
+
comment,
|
|
953
|
+
commentError,
|
|
954
|
+
promotion: promotion ?? null,
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
else {
|
|
958
|
+
if (!ctx.quiet && promotion && promotion.promoted.length > 0) {
|
|
959
|
+
process.stderr.write(promotionNote(promotion, branch));
|
|
960
|
+
}
|
|
961
|
+
if (!ctx.quiet && comment)
|
|
962
|
+
process.stderr.write(`>> attachments comment ${comment.action}${commentViaSuffix(comment.via)}\n`);
|
|
963
|
+
}
|
|
964
|
+
return 0;
|
|
965
|
+
}
|
|
618
966
|
export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
619
967
|
if (help) {
|
|
620
968
|
writeCommandHelp(PUT_HELP);
|
|
@@ -815,7 +1163,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
815
1163
|
let commentError;
|
|
816
1164
|
if (wantComment && ghTarget && uploads.length > 0) {
|
|
817
1165
|
try {
|
|
818
|
-
comment = await syncAttachmentsComment(ctx.client, ghTarget, run);
|
|
1166
|
+
comment = await syncAttachmentsComment(ctx.client, ghTarget, run, ctx.config.workspace);
|
|
819
1167
|
if (logHuman)
|
|
820
1168
|
process.stderr.write(`>> attachments comment ${comment.action}${commentViaSuffix(comment.via)}\n`);
|
|
821
1169
|
}
|
|
@@ -1346,6 +1694,11 @@ App is installed on the repo; otherwise via your local gh auth. Finds its own
|
|
|
1346
1694
|
prior comment via a hidden marker and edits it in place; never touches other
|
|
1347
1695
|
comments or the description.
|
|
1348
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
|
+
|
|
1349
1702
|
Examples:
|
|
1350
1703
|
uploads --env-file .env comment --pr 123
|
|
1351
1704
|
uploads comment --issue 45 --repo buildinternet/uploads
|
|
@@ -1359,7 +1712,7 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
|
|
|
1359
1712
|
const target = ghTargetFromFlags(parsed.flags, run);
|
|
1360
1713
|
if (!target)
|
|
1361
1714
|
throw new UsageError("comment requires --pr or --issue");
|
|
1362
|
-
const result = await syncAttachmentsComment(ctx.client, target, run);
|
|
1715
|
+
const result = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace);
|
|
1363
1716
|
if (ctx.json) {
|
|
1364
1717
|
await writeJson({ ...target, ...result });
|
|
1365
1718
|
}
|
|
@@ -1371,6 +1724,138 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
|
|
|
1371
1724
|
}
|
|
1372
1725
|
return 0;
|
|
1373
1726
|
}
|
|
1727
|
+
// --- github link ---
|
|
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>]
|
|
1731
|
+
|
|
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.
|
|
1737
|
+
|
|
1738
|
+
--repo defaults the same way as --pr/--issue elsewhere (gh repo view, then
|
|
1739
|
+
the git remote). --status only inspects the current binding (files:read);
|
|
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.
|
|
1750
|
+
|
|
1751
|
+
Examples:
|
|
1752
|
+
uploads github link
|
|
1753
|
+
uploads github link --repo buildinternet/uploads
|
|
1754
|
+
uploads github link --status
|
|
1755
|
+
uploads github unlink --repo buildinternet/uploads
|
|
1756
|
+
uploads github doctor
|
|
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
|
+
}
|
|
1771
|
+
function formatGithubLink(repo, result) {
|
|
1772
|
+
return result.workspace
|
|
1773
|
+
? `${repo} is bound to workspace "${result.workspace}"${result.source ? ` (${result.source})` : ""}\n`
|
|
1774
|
+
: `${repo} is not bound to any workspace\n`;
|
|
1775
|
+
}
|
|
1776
|
+
async function runGithubDoctor(ctx) {
|
|
1777
|
+
let result;
|
|
1778
|
+
try {
|
|
1779
|
+
result = await ctx.client.githubHealth();
|
|
1780
|
+
}
|
|
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) {
|
|
1796
|
+
let result;
|
|
1797
|
+
try {
|
|
1798
|
+
result = statusOnly
|
|
1799
|
+
? await ctx.client.githubLinkStatus(repo)
|
|
1800
|
+
: await ctx.client.githubLinkClaim(repo);
|
|
1801
|
+
}
|
|
1802
|
+
catch (err) {
|
|
1803
|
+
if (err instanceof UploadsError && err.status === 404) {
|
|
1804
|
+
throw new UsageError("server does not support repo bindings yet (404) — upgrade the uploads.sh API/self-hosted worker");
|
|
1805
|
+
}
|
|
1806
|
+
throw err;
|
|
1807
|
+
}
|
|
1808
|
+
if (ctx.json) {
|
|
1809
|
+
await writeJson(result);
|
|
1810
|
+
return 0;
|
|
1811
|
+
}
|
|
1812
|
+
if (!statusOnly && result.claimed === false) {
|
|
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`);
|
|
1814
|
+
}
|
|
1815
|
+
await writeStdout(formatGithubLink(repo, result));
|
|
1816
|
+
return 0;
|
|
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
|
+
}
|
|
1374
1859
|
// --- usage / reconcile / purge ---
|
|
1375
1860
|
const USAGE_HELP = `uploads usage [--workspace <name>]
|
|
1376
1861
|
|