@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.
- package/dist/cli-catalog.js +7 -0
- package/dist/cli.js +6 -2
- package/dist/client.d.ts +66 -0
- package/dist/client.js +37 -0
- package/dist/commands/screenshot.js +39 -9
- package/dist/commands.d.ts +53 -4
- package/dist/commands.js +387 -33
- package/dist/github-gh.d.ts +28 -1
- package/dist/github-gh.js +89 -6
- 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 +15 -15
- package/dist/metadata.d.ts +8 -0
- package/dist/metadata.js +10 -0
- package/package.json +1 -1
package/dist/commands.js
CHANGED
|
@@ -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, 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";
|
|
@@ -89,7 +89,9 @@ Options:
|
|
|
89
89
|
--format human|url|markdown|json
|
|
90
90
|
--pr <num> Attach to a pull request: key gh/<owner>/<repo>/pull/<num>/<name> (stable URL, no hash)
|
|
91
91
|
--issue <num> Attach to an issue: key gh/<owner>/<repo>/issues/<num>/<name>
|
|
92
|
-
--comment With --pr/--issue: update one managed comment with
|
|
92
|
+
--comment With --pr/--issue: update one managed comment with
|
|
93
|
+
attachments and linked galleries. Posts as uploads-sh[bot]
|
|
94
|
+
when the GitHub App is installed; otherwise via local gh.
|
|
93
95
|
--gallery <id> Add the uploaded object(s) to this public gallery
|
|
94
96
|
--meta <k=v> Queryable custom metadata (repeatable; value may contain "="): key ^[a-z][a-z0-9._-]{0,63}$, value 1-512 printable ASCII, max 24 pairs
|
|
95
97
|
Re-uploading to an existing key WITH --meta replaces that file's
|
|
@@ -128,6 +130,25 @@ export function makeGhTarget(pr, issue, repoArg, run) {
|
|
|
128
130
|
export function ghTargetFromFlags(flags, run) {
|
|
129
131
|
return makeGhTarget(flagInt(flags, "--pr", "--pr"), flagInt(flags, "--issue", "--issue"), flagString(flags, "--repo"), run);
|
|
130
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Reads `--branch [name]` — an optional-value flag: `--branch` alone resolves
|
|
135
|
+
* the current git branch (`resolveCurrentBranch`); `--branch feature/x` uses
|
|
136
|
+
* the given name verbatim. Returns undefined when the flag is absent at all
|
|
137
|
+
* (distinct from an empty/whitespace value, which is rejected). Throws
|
|
138
|
+
* UsageError if `--branch` is given more than once.
|
|
139
|
+
*/
|
|
140
|
+
export function branchFromFlags(flags, run) {
|
|
141
|
+
if (!flags.has("--branch"))
|
|
142
|
+
return undefined;
|
|
143
|
+
const raw = flags.get("--branch");
|
|
144
|
+
if (Array.isArray(raw))
|
|
145
|
+
throw new UsageError("--branch may only be given once");
|
|
146
|
+
if (raw === true)
|
|
147
|
+
return resolveCurrentBranch(run);
|
|
148
|
+
if (typeof raw === "string" && raw.trim().length > 0)
|
|
149
|
+
return raw;
|
|
150
|
+
throw new UsageError("--branch requires a non-empty branch name");
|
|
151
|
+
}
|
|
131
152
|
/**
|
|
132
153
|
* Best-effort GitHub target for the default put path (no --pr/--issue). A
|
|
133
154
|
* numeric --ref is classified as pull vs issue; otherwise the current branch's
|
|
@@ -272,13 +293,37 @@ export function frameOptionsFromFlags(flags) {
|
|
|
272
293
|
throw new UsageError("--frame-url requires --frame");
|
|
273
294
|
return { frameId, frameUrl, frameFit };
|
|
274
295
|
}
|
|
275
|
-
/**
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
296
|
+
/** Human-mode suffix noting who posted the managed comment. */
|
|
297
|
+
export function commentViaSuffix(via) {
|
|
298
|
+
return via === "bot" ? " (uploads-sh[bot])" : " (via gh)";
|
|
299
|
+
}
|
|
300
|
+
export async function syncAttachmentsComment(client, target, run, workspace) {
|
|
301
|
+
try {
|
|
302
|
+
const bot = await client.upsertGithubComment({
|
|
303
|
+
repo: target.repo,
|
|
304
|
+
num: target.num,
|
|
305
|
+
kind: target.kind,
|
|
306
|
+
});
|
|
307
|
+
if (bot.posted)
|
|
308
|
+
return { action: bot.action, count: bot.count, via: "bot" };
|
|
309
|
+
// Installed-but-unapproved is a fixable misconfiguration, not a silent
|
|
310
|
+
// degrade: tell the user (and how to fix it) before falling back to gh.
|
|
311
|
+
if (bot.reason === "forbidden" && bot.message) {
|
|
312
|
+
process.stderr.write(`note: ${bot.message}${bot.fixUrl ? `\n ${bot.fixUrl}` : ""}\n` +
|
|
313
|
+
`Posting via local gh in the meantime.\n`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
// Endpoint absent/unreachable (self-hosted, network, older worker) — fall
|
|
318
|
+
// through to the gh path below.
|
|
319
|
+
}
|
|
320
|
+
// gh fallback: gather from this workspace's own data and post via local `gh`.
|
|
321
|
+
// Note (issue #304): this CLI process has no server-side WorkspaceRecord in
|
|
322
|
+
// scope, so it cannot honor a workspace's githubCommentLinkToFilePage=false
|
|
323
|
+
// — it always links to the file page here, matching the default. This only
|
|
324
|
+
// diverges from the bot-posted comment for a workspace that both sets the
|
|
325
|
+
// flag false and falls through to this gh-fallback path.
|
|
326
|
+
const items = (await client.listAll({ prefix: ghKeyPrefix(target) })).map(({ key, url, embedUrl, pageUrl }) => ({ key, url, embedUrl, pageUrl }));
|
|
282
327
|
const galleries = [];
|
|
283
328
|
let cursor;
|
|
284
329
|
do {
|
|
@@ -313,10 +358,15 @@ export async function syncAttachmentsComment(client, target, run) {
|
|
|
313
358
|
}
|
|
314
359
|
}));
|
|
315
360
|
if (items.length === 0 && previewGalleries.length === 0)
|
|
316
|
-
return { action: "skipped", count: 0 };
|
|
317
|
-
const
|
|
318
|
-
const
|
|
319
|
-
|
|
361
|
+
return { action: "skipped", count: 0, via: "gh" };
|
|
362
|
+
const marker = attachmentsMarker(workspace);
|
|
363
|
+
const body = attachmentsCommentBody(items, previewGalleries, marker);
|
|
364
|
+
const { created } = upsertAttachmentsComment(target, body, run, marker);
|
|
365
|
+
return {
|
|
366
|
+
action: created ? "created" : "updated",
|
|
367
|
+
count: items.length + previewGalleries.length,
|
|
368
|
+
via: "gh",
|
|
369
|
+
};
|
|
320
370
|
}
|
|
321
371
|
// --- attach ---
|
|
322
372
|
const ATTACH_HELP = `uploads attach <file...> [options]
|
|
@@ -338,9 +388,37 @@ URL and every embed hot-swap. Human mode prints ">> replaced existing object
|
|
|
338
388
|
Still images are optimized to WebP by default (same as put). Use --no-optimize
|
|
339
389
|
to upload originals. Optional --frame wraps images in device/browser chrome.
|
|
340
390
|
|
|
391
|
+
Branch staging (pre-PR): --branch [name] stages files against a git branch
|
|
392
|
+
before a pull request exists, e.g. for a coding agent working a branch that
|
|
393
|
+
hasn't opened a PR yet. Key: gh/<owner>/<repo>/branch/<branch>/<filename>
|
|
394
|
+
("/" in the branch name sanitizes to "-", e.g. feature/x -> feature-x).
|
|
395
|
+
With no value, --branch resolves the current git branch. Staged files are
|
|
396
|
+
public like every other attachment — same public-URL caveat applies. There is
|
|
397
|
+
no managed comment for a branch (no PR/issue to comment on yet); --branch
|
|
398
|
+
never runs the comment sync and cannot combine with --pr/--issue/--comment.
|
|
399
|
+
|
|
400
|
+
Promotion: once a PR exists, staged files for the current branch are picked
|
|
401
|
+
up automatically the first time you attach to that PR (a plain "uploads
|
|
402
|
+
attach <file> --pr <num>", or the inferred-PR default with no target flags) —
|
|
403
|
+
they're copied into the PR's attachment prefix before the managed comment is
|
|
404
|
+
built, so they show up in the same run. Pass --no-promote to skip that. If
|
|
405
|
+
you'd rather promote without attaching a new file (e.g. right after
|
|
406
|
+
"gh pr create" with nothing new to upload), run "uploads attach --promote"
|
|
407
|
+
with no file arguments — it resolves the PR the same way, promotes, and
|
|
408
|
+
refreshes the comment; it exits 0 even if nothing was staged. --promote only
|
|
409
|
+
takes effect with zero files and cannot combine with --branch/--issue/
|
|
410
|
+
--no-promote. Promotion never applies to issues. Staged files stay findable
|
|
411
|
+
with "uploads find gh.branch=<branch>" either way.
|
|
412
|
+
|
|
341
413
|
Options:
|
|
342
414
|
--pr <num> Attach to this pull request
|
|
343
415
|
--issue <num> Attach to this issue
|
|
416
|
+
--branch [name] Stage against a branch, pre-PR (default: current git branch);
|
|
417
|
+
not with --pr/--issue/--comment
|
|
418
|
+
--promote No files: promote branch-staged attachments into the
|
|
419
|
+
resolved PR and refresh the comment; not with
|
|
420
|
+
--branch/--issue/--no-promote
|
|
421
|
+
--no-promote Skip auto-promoting branch-staged attachments (default path only)
|
|
344
422
|
--repo <owner/repo> Repository (default: gh/git inference)
|
|
345
423
|
--no-comment Upload only; don't create/update the managed comment
|
|
346
424
|
--content-type <mime> Override Content-Type (applied to every file; ignored when optimize rewrites)
|
|
@@ -354,7 +432,8 @@ Options:
|
|
|
354
432
|
--workspace, -w <name> Override workspace
|
|
355
433
|
--meta <k=v> Extra queryable metadata (repeatable; value may contain "=").
|
|
356
434
|
gh.repo/gh.kind/gh.number/gh.ref are always set from the resolved
|
|
357
|
-
target
|
|
435
|
+
target (or gh.repo/gh.kind/gh.branch/gh.staged-at with --branch) —
|
|
436
|
+
a --meta pair with the same key is overridden by it.
|
|
358
437
|
Because attach always sends its own gh.* pairs, re-attaching to
|
|
359
438
|
the same key always replaces that file's entire metadata set
|
|
360
439
|
(never preserves) — use "uploads meta set" to add to it instead.
|
|
@@ -365,13 +444,17 @@ Examples:
|
|
|
365
444
|
uploads attach ./shot.png --pr 123 --repo myorg/myapp
|
|
366
445
|
uploads attach ./artifact.zip --issue 45 --no-comment
|
|
367
446
|
uploads attach ./shot.png --meta app=myapp --meta page=settings
|
|
447
|
+
uploads attach ./shot.png --branch
|
|
448
|
+
uploads attach ./shot.png --branch feature/new-settings
|
|
449
|
+
uploads attach --promote
|
|
368
450
|
`;
|
|
369
451
|
/**
|
|
370
|
-
*
|
|
371
|
-
*
|
|
452
|
+
* Shared prepare + put loop for both PR/issue attach (`uploadAttachments`)
|
|
453
|
+
* and branch-staged attach (`uploadBranchAttachments`) — bounded concurrency,
|
|
454
|
+
* per-file errors collect in `failures` (does not throw). `firstError` is the
|
|
372
455
|
* original cause of the first failure — for rethrowing single-file CLI paths.
|
|
373
456
|
*/
|
|
374
|
-
|
|
457
|
+
async function uploadAttachmentBatch(opts) {
|
|
375
458
|
if (opts.files.some((f) => f === "-")) {
|
|
376
459
|
throw new UsageError("attach does not support stdin; pass one or more file paths");
|
|
377
460
|
}
|
|
@@ -384,7 +467,7 @@ export async function uploadAttachments(opts) {
|
|
|
384
467
|
});
|
|
385
468
|
const result = await opts.client.put(prepared.bytes, {
|
|
386
469
|
filename: prepared.filename,
|
|
387
|
-
key:
|
|
470
|
+
key: opts.keyFor(prepared.filename),
|
|
388
471
|
contentType: prepared.optimized ? prepared.contentType : opts.contentType,
|
|
389
472
|
provenance: buildCliProvenance({
|
|
390
473
|
sourceName,
|
|
@@ -431,6 +514,30 @@ export async function uploadAttachments(opts) {
|
|
|
431
514
|
}
|
|
432
515
|
return { uploads, failures, firstError };
|
|
433
516
|
}
|
|
517
|
+
/**
|
|
518
|
+
* Prepare + put each path as a PR/issue attachment with bounded concurrency.
|
|
519
|
+
* Per-file errors collect in `failures` (does not throw). `firstError` is the
|
|
520
|
+
* original cause of the first failure — for rethrowing single-file CLI paths.
|
|
521
|
+
*/
|
|
522
|
+
export async function uploadAttachments(opts) {
|
|
523
|
+
return uploadAttachmentBatch({
|
|
524
|
+
...opts,
|
|
525
|
+
keyFor: (filename) => ghAttachmentKey(opts.target, filename),
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Prepare + put each path as a branch-staged attachment (pre-PR) with
|
|
530
|
+
* bounded concurrency. Same shape as `uploadAttachments`, keyed under
|
|
531
|
+
* `gh/<owner>/<repo>/branch/<branch>/<filename>` instead of a PR/issue
|
|
532
|
+
* number. Never syncs the managed comment — callers must not call
|
|
533
|
+
* `syncAttachmentsComment` for a branch target.
|
|
534
|
+
*/
|
|
535
|
+
export async function uploadBranchAttachments(opts) {
|
|
536
|
+
return uploadAttachmentBatch({
|
|
537
|
+
...opts,
|
|
538
|
+
keyFor: (filename) => ghBranchAttachmentKey(opts.target.repo, opts.target.branch, filename),
|
|
539
|
+
});
|
|
540
|
+
}
|
|
434
541
|
function errorDetail(err) {
|
|
435
542
|
if (err instanceof UploadsError)
|
|
436
543
|
return { message: err.message, code: err.code, status: err.status };
|
|
@@ -508,18 +615,68 @@ export async function uploadPuts(opts) {
|
|
|
508
615
|
}
|
|
509
616
|
return { uploads, failures, firstError };
|
|
510
617
|
}
|
|
618
|
+
/**
|
|
619
|
+
* Best-effort call to `POST /v1/:workspace/github/promote` (server contract,
|
|
620
|
+
* PR #310). Degrade-safe like `syncAttachmentsComment`'s bot path: an older
|
|
621
|
+
* or self-hosted worker without this route (404), a forbidden token (403),
|
|
622
|
+
* or a network error all collapse to "nothing promoted" — the caller must
|
|
623
|
+
* never let this fail the attach. Returns undefined on any failure.
|
|
624
|
+
*/
|
|
625
|
+
async function attemptPromoteBranch(client, target, branch) {
|
|
626
|
+
try {
|
|
627
|
+
return await client.promoteBranchAttachments({ repo: target.repo, num: target.num, branch });
|
|
628
|
+
}
|
|
629
|
+
catch {
|
|
630
|
+
return undefined;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
/** Human-mode note for a promotion that actually promoted something. */
|
|
634
|
+
function promotionNote(promotion, branch) {
|
|
635
|
+
const n = promotion.promoted.length;
|
|
636
|
+
const branchSuffix = branch ? ` from branch ${branch}` : "";
|
|
637
|
+
return `>> promoted ${n} staged attachment${n === 1 ? "" : "s"}${branchSuffix}\n`;
|
|
638
|
+
}
|
|
511
639
|
export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
512
640
|
const parsed = parseCommandArgs(args);
|
|
513
641
|
if (help || parsed.help) {
|
|
514
642
|
writeCommandHelp(ATTACH_HELP);
|
|
515
643
|
return 0;
|
|
516
644
|
}
|
|
645
|
+
if (parsed.flags.has("--no-comment") && typeof parsed.flags.get("--no-comment") === "string") {
|
|
646
|
+
throw new UsageError("--no-comment takes no value — place it after the file arguments");
|
|
647
|
+
}
|
|
648
|
+
if (parsed.flags.has("--promote") && typeof parsed.flags.get("--promote") === "string") {
|
|
649
|
+
throw new UsageError("--promote takes no value — place it after the file arguments");
|
|
650
|
+
}
|
|
651
|
+
if (parsed.flags.has("--no-promote") && typeof parsed.flags.get("--no-promote") === "string") {
|
|
652
|
+
throw new UsageError("--no-promote takes no value — place it after the file arguments");
|
|
653
|
+
}
|
|
654
|
+
if (parsed.flags.has("--promote")) {
|
|
655
|
+
if (parsed.positionals.length > 0) {
|
|
656
|
+
throw new UsageError("--promote takes no file arguments — attaching a file to a PR already auto-promotes " +
|
|
657
|
+
"staged files; use `uploads attach <file> --pr <num>` instead");
|
|
658
|
+
}
|
|
659
|
+
if (parsed.flags.has("--branch"))
|
|
660
|
+
throw new UsageError("--promote cannot be combined with --branch");
|
|
661
|
+
if (parsed.flags.has("--issue"))
|
|
662
|
+
throw new UsageError("--promote cannot be combined with --issue");
|
|
663
|
+
if (parsed.flags.has("--no-promote"))
|
|
664
|
+
throw new UsageError("--promote cannot be combined with --no-promote");
|
|
665
|
+
return runAttachPromoteOnly(ctx, parsed, run);
|
|
666
|
+
}
|
|
517
667
|
if (parsed.positionals.length === 0) {
|
|
518
668
|
writeCommandHelp(ATTACH_HELP);
|
|
519
669
|
return 2;
|
|
520
670
|
}
|
|
521
|
-
|
|
522
|
-
|
|
671
|
+
const branchArg = branchFromFlags(parsed.flags, run);
|
|
672
|
+
if (branchArg !== undefined) {
|
|
673
|
+
if (parsed.flags.has("--pr"))
|
|
674
|
+
throw new UsageError("--branch cannot be combined with --pr");
|
|
675
|
+
if (parsed.flags.has("--issue"))
|
|
676
|
+
throw new UsageError("--branch cannot be combined with --issue");
|
|
677
|
+
if (parsed.flags.has("--comment"))
|
|
678
|
+
throw new UsageError("--branch cannot be combined with --comment");
|
|
679
|
+
return runAttachBranch(ctx, parsed, branchArg, run);
|
|
523
680
|
}
|
|
524
681
|
const explicitTarget = ghTargetFromFlags(parsed.flags, run);
|
|
525
682
|
const target = explicitTarget ??
|
|
@@ -532,9 +689,9 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
532
689
|
// target pairs always win over a same-named --meta extra (documented above).
|
|
533
690
|
// Validate the merged map (not just the extras) so the 24-key/8KB caps are
|
|
534
691
|
// enforced client-side even when extras alone are under the cap but extras
|
|
535
|
-
// + the
|
|
692
|
+
// + the gh.* pairs push the merged map over it.
|
|
536
693
|
const metaExtras = parseMetaFlags(flagValues(parsed.flags, "--meta"));
|
|
537
|
-
const metadata = { ...metaExtras, ...
|
|
694
|
+
const metadata = { ...metaExtras, ...ghMetadataFromTargetWithTitle(target, run) };
|
|
538
695
|
if (Object.keys(metadata).length > 0)
|
|
539
696
|
validateMetaMap(metadata);
|
|
540
697
|
const logHuman = !ctx.quiet && !ctx.json;
|
|
@@ -555,12 +712,33 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
555
712
|
if (uploads.length === 0 && failures.length === 1 && parsed.positionals.length === 1) {
|
|
556
713
|
throw firstError instanceof Error ? firstError : new Error(String(firstError));
|
|
557
714
|
}
|
|
715
|
+
// Auto-promote: before the comment sync, best-effort promote this
|
|
716
|
+
// workspace's own branch-staged attachments (from an earlier `attach
|
|
717
|
+
// --branch` while the PR didn't exist yet) into this PR's attachment
|
|
718
|
+
// prefix, so the comment gather below sees them in the same invocation.
|
|
719
|
+
// Never for issues (branch staging only ever targets a future PR), never
|
|
720
|
+
// with --no-promote, and silently skipped (no client call at all) when the
|
|
721
|
+
// current git branch can't be resolved (detached HEAD, not a repo) — this
|
|
722
|
+
// must never fail the attach itself.
|
|
723
|
+
let promotion;
|
|
724
|
+
let promotedBranch;
|
|
725
|
+
if (target.kind === "pull" && !parsed.flags.has("--no-promote")) {
|
|
726
|
+
try {
|
|
727
|
+
promotedBranch = resolveCurrentBranch(run);
|
|
728
|
+
}
|
|
729
|
+
catch {
|
|
730
|
+
promotedBranch = undefined;
|
|
731
|
+
}
|
|
732
|
+
if (promotedBranch !== undefined) {
|
|
733
|
+
promotion = await attemptPromoteBranch(ctx.client, target, promotedBranch);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
558
736
|
let comment;
|
|
559
737
|
let commentError;
|
|
560
738
|
// Skip comment refresh when every upload failed — nothing new from this batch.
|
|
561
739
|
if (!parsed.flags.has("--no-comment") && uploads.length > 0) {
|
|
562
740
|
try {
|
|
563
|
-
comment = await syncAttachmentsComment(ctx.client, target, run);
|
|
741
|
+
comment = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace);
|
|
564
742
|
}
|
|
565
743
|
catch (err) {
|
|
566
744
|
commentError = err instanceof Error ? err.message : String(err);
|
|
@@ -568,7 +746,14 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
568
746
|
}
|
|
569
747
|
}
|
|
570
748
|
if (ctx.json) {
|
|
571
|
-
await writeJson({
|
|
749
|
+
await writeJson({
|
|
750
|
+
target,
|
|
751
|
+
uploads,
|
|
752
|
+
failures,
|
|
753
|
+
comment,
|
|
754
|
+
commentError,
|
|
755
|
+
promotion: promotion ?? null,
|
|
756
|
+
});
|
|
572
757
|
}
|
|
573
758
|
else {
|
|
574
759
|
for (const result of uploads) {
|
|
@@ -587,8 +772,11 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
587
772
|
for (const failure of failures) {
|
|
588
773
|
process.stderr.write(`warning: could not upload ${failure.file}: ${failure.error.message}\n`);
|
|
589
774
|
}
|
|
775
|
+
if (!ctx.quiet && promotion && promotion.promoted.length > 0) {
|
|
776
|
+
process.stderr.write(promotionNote(promotion, promotedBranch));
|
|
777
|
+
}
|
|
590
778
|
if (!ctx.quiet && comment)
|
|
591
|
-
process.stderr.write(`>> attachments comment ${comment.action}\n`);
|
|
779
|
+
process.stderr.write(`>> attachments comment ${comment.action}${commentViaSuffix(comment.via)}\n`);
|
|
592
780
|
if (!ctx.quiet && uploads.length > 0) {
|
|
593
781
|
const ref = ghMetadataFromTarget(target)["gh.ref"];
|
|
594
782
|
process.stderr.write(`>> find these later: uploads find gh.ref=${ref}\n`);
|
|
@@ -596,6 +784,114 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
596
784
|
}
|
|
597
785
|
return failures.length === 0 ? 0 : 1;
|
|
598
786
|
}
|
|
787
|
+
/**
|
|
788
|
+
* `attach --branch` path: stages files under
|
|
789
|
+
* `gh/<owner>/<repo>/branch/<branch>/<filename>` instead of a PR/issue
|
|
790
|
+
* number. Never syncs the managed comment (there is no PR/issue to comment
|
|
791
|
+
* on yet, and the comment-gatherer only lists PR/issue prefixes anyway —
|
|
792
|
+
* branch-staged keys are invisible to it by construction).
|
|
793
|
+
*/
|
|
794
|
+
async function runAttachBranch(ctx, parsed, branch, run) {
|
|
795
|
+
const repo = resolveRepo(flagString(parsed.flags, "--repo"), run);
|
|
796
|
+
const defaults = resolvePutDefaults({ envFile: ctx.envFile });
|
|
797
|
+
const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, defaults);
|
|
798
|
+
const frameOpts = frameOptionsFromFlags(parsed.flags);
|
|
799
|
+
const contentTypeOverride = flagString(parsed.flags, "--content-type");
|
|
800
|
+
const metaExtras = parseMetaFlags(flagValues(parsed.flags, "--meta"));
|
|
801
|
+
const metadata = { ...metaExtras, ...ghMetadataForBranch(repo, branch) };
|
|
802
|
+
validateMetaMap(metadata);
|
|
803
|
+
const logHuman = !ctx.quiet && !ctx.json;
|
|
804
|
+
if (logHuman) {
|
|
805
|
+
const n = parsed.positionals.length;
|
|
806
|
+
process.stderr.write(`>> uploading ${n} file${n === 1 ? "" : "s"} (staged for branch ${branch})\n`);
|
|
807
|
+
}
|
|
808
|
+
const target = { repo, branch };
|
|
809
|
+
const { uploads, failures, firstError } = await uploadBranchAttachments({
|
|
810
|
+
client: ctx.client,
|
|
811
|
+
target,
|
|
812
|
+
files: parsed.positionals,
|
|
813
|
+
contentType: contentTypeOverride,
|
|
814
|
+
optimize: optimizeOpts,
|
|
815
|
+
frame: frameOpts,
|
|
816
|
+
metadata,
|
|
817
|
+
});
|
|
818
|
+
// Single-file total failure: rethrow so CLI exit codes stay auth/network-aware.
|
|
819
|
+
if (uploads.length === 0 && failures.length === 1 && parsed.positionals.length === 1) {
|
|
820
|
+
throw firstError instanceof Error ? firstError : new Error(String(firstError));
|
|
821
|
+
}
|
|
822
|
+
if (ctx.json) {
|
|
823
|
+
await writeJson({ target, uploads, failures });
|
|
824
|
+
}
|
|
825
|
+
else {
|
|
826
|
+
for (const result of uploads) {
|
|
827
|
+
if (logHuman) {
|
|
828
|
+
if (result.frame?.framed) {
|
|
829
|
+
process.stderr.write(`>> ${basename(result.file)}: framed with ${result.frame.frameId}\n`);
|
|
830
|
+
}
|
|
831
|
+
const note = formatOptimizeNote(result.optimize);
|
|
832
|
+
if (note)
|
|
833
|
+
process.stderr.write(`>> ${basename(result.file)}: ${note}\n`);
|
|
834
|
+
writeReplacedNote(result.replaced, false);
|
|
835
|
+
}
|
|
836
|
+
const embedLine = result.embedUrl ? `EMBED: ${result.embedUrl}\n` : "";
|
|
837
|
+
await writeStdout(`URL: ${result.url}\n${embedLine}MARKDOWN: ${result.markdown}\n`);
|
|
838
|
+
}
|
|
839
|
+
for (const failure of failures) {
|
|
840
|
+
process.stderr.write(`warning: could not upload ${failure.file}: ${failure.error.message}\n`);
|
|
841
|
+
}
|
|
842
|
+
if (!ctx.quiet && uploads.length > 0) {
|
|
843
|
+
process.stderr.write(`>> find these later: uploads find gh.branch=${branch.toLowerCase()}\n`);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
return failures.length === 0 ? 0 : 1;
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* `attach --promote` with zero file arguments: resolve the PR target (same
|
|
850
|
+
* resolution as the default `runAttach` path), promote this workspace's
|
|
851
|
+
* branch-staged attachments into it, then run the comment sync — useful
|
|
852
|
+
* right after `gh pr create` when the PR was opened without a fresh attach
|
|
853
|
+
* (auto-promotion on the default path only fires when you attach a file).
|
|
854
|
+
* Unlike the default path's best-effort branch resolution, this is an
|
|
855
|
+
* explicit user action: `resolveCurrentBranch` throwing (detached HEAD, not
|
|
856
|
+
* a repo) propagates as a UsageError instead of silently skipping. Always
|
|
857
|
+
* exits 0 — an empty staging prefix is success, not a failure.
|
|
858
|
+
*/
|
|
859
|
+
async function runAttachPromoteOnly(ctx, parsed, run) {
|
|
860
|
+
const explicitTarget = ghTargetFromFlags(parsed.flags, run);
|
|
861
|
+
const target = explicitTarget ??
|
|
862
|
+
resolveCurrentPullRequest(resolveRepo(flagString(parsed.flags, "--repo"), run), run);
|
|
863
|
+
const branch = resolveCurrentBranch(run);
|
|
864
|
+
const promotion = await attemptPromoteBranch(ctx.client, target, branch);
|
|
865
|
+
let comment;
|
|
866
|
+
let commentError;
|
|
867
|
+
if (!parsed.flags.has("--no-comment")) {
|
|
868
|
+
try {
|
|
869
|
+
comment = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace);
|
|
870
|
+
}
|
|
871
|
+
catch (err) {
|
|
872
|
+
commentError = err instanceof Error ? err.message : String(err);
|
|
873
|
+
process.stderr.write(`warning: promotion succeeded but the GitHub comment failed (is gh installed and authenticated?): ${commentError}\n`);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
if (ctx.json) {
|
|
877
|
+
await writeJson({
|
|
878
|
+
target,
|
|
879
|
+
uploads: [],
|
|
880
|
+
failures: [],
|
|
881
|
+
comment,
|
|
882
|
+
commentError,
|
|
883
|
+
promotion: promotion ?? null,
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
else {
|
|
887
|
+
if (!ctx.quiet && promotion && promotion.promoted.length > 0) {
|
|
888
|
+
process.stderr.write(promotionNote(promotion, branch));
|
|
889
|
+
}
|
|
890
|
+
if (!ctx.quiet && comment)
|
|
891
|
+
process.stderr.write(`>> attachments comment ${comment.action}${commentViaSuffix(comment.via)}\n`);
|
|
892
|
+
}
|
|
893
|
+
return 0;
|
|
894
|
+
}
|
|
599
895
|
export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
600
896
|
if (help) {
|
|
601
897
|
writeCommandHelp(PUT_HELP);
|
|
@@ -711,7 +1007,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
711
1007
|
let metadata = userMeta;
|
|
712
1008
|
let attachedRef;
|
|
713
1009
|
if (ghTarget) {
|
|
714
|
-
const merged = { ...userMeta, ...
|
|
1010
|
+
const merged = { ...userMeta, ...ghMetadataFromTargetWithTitle(ghTarget, run) };
|
|
715
1011
|
validateMetaMap(merged); // enforce 24-key/8KB caps on the merged map (matches attach)
|
|
716
1012
|
metadata = merged;
|
|
717
1013
|
attachedRef = merged["gh.ref"];
|
|
@@ -723,7 +1019,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
723
1019
|
if (autoEnabled) {
|
|
724
1020
|
const autoTarget = resolveAutoGhTarget(flagString(parsed.flags, "--repo") ?? defaults.repo, flagString(parsed.flags, "--ref") ?? defaults.ref, run);
|
|
725
1021
|
if (autoTarget) {
|
|
726
|
-
const autoMeta =
|
|
1022
|
+
const autoMeta = ghMetadataFromTargetWithTitle(autoTarget, run);
|
|
727
1023
|
const merged = { ...autoMeta, ...userMeta };
|
|
728
1024
|
// Auto resolution must never fail the upload: if merging the gh.* pairs
|
|
729
1025
|
// would exceed the metadata caps, drop them and upload with --meta only.
|
|
@@ -796,9 +1092,9 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
796
1092
|
let commentError;
|
|
797
1093
|
if (wantComment && ghTarget && uploads.length > 0) {
|
|
798
1094
|
try {
|
|
799
|
-
comment = await syncAttachmentsComment(ctx.client, ghTarget, run);
|
|
1095
|
+
comment = await syncAttachmentsComment(ctx.client, ghTarget, run, ctx.config.workspace);
|
|
800
1096
|
if (logHuman)
|
|
801
|
-
process.stderr.write(`>> attachments comment ${comment.action}\n`);
|
|
1097
|
+
process.stderr.write(`>> attachments comment ${comment.action}${commentViaSuffix(comment.via)}\n`);
|
|
802
1098
|
}
|
|
803
1099
|
catch (err) {
|
|
804
1100
|
commentError = err instanceof Error ? err.message : String(err);
|
|
@@ -1322,7 +1618,8 @@ export async function runDelete(ctx, args, help = false) {
|
|
|
1322
1618
|
const COMMENT_HELP = `uploads comment (--pr <num> | --issue <num>) [--repo <owner/name>] [--workspace <name>]
|
|
1323
1619
|
|
|
1324
1620
|
Create or update the managed attachments comment on a GitHub PR or issue,
|
|
1325
|
-
listing everything uploaded for it.
|
|
1621
|
+
listing everything uploaded for it. Posts as uploads-sh[bot] when the GitHub
|
|
1622
|
+
App is installed on the repo; otherwise via your local gh auth. Finds its own
|
|
1326
1623
|
prior comment via a hidden marker and edits it in place; never touches other
|
|
1327
1624
|
comments or the description.
|
|
1328
1625
|
|
|
@@ -1339,15 +1636,72 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
|
|
|
1339
1636
|
const target = ghTargetFromFlags(parsed.flags, run);
|
|
1340
1637
|
if (!target)
|
|
1341
1638
|
throw new UsageError("comment requires --pr or --issue");
|
|
1342
|
-
const result = await syncAttachmentsComment(ctx.client, target, run);
|
|
1639
|
+
const result = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace);
|
|
1343
1640
|
if (ctx.json) {
|
|
1344
1641
|
await writeJson({ ...target, ...result });
|
|
1345
1642
|
}
|
|
1346
1643
|
else if (!ctx.quiet) {
|
|
1644
|
+
const via = commentViaSuffix(result.via);
|
|
1347
1645
|
process.stderr.write(result.action === "skipped"
|
|
1348
1646
|
? `no attachments under ${ghKeyPrefix(target)} — nothing to do\n`
|
|
1349
|
-
: `${result.action} attachments comment on ${target.repo}#${target.num} (${result.count} file${result.count === 1 ? "" : "s"})\n`);
|
|
1647
|
+
: `${result.action} attachments comment on ${target.repo}#${target.num} (${result.count} file${result.count === 1 ? "" : "s"})${via}\n`);
|
|
1648
|
+
}
|
|
1649
|
+
return 0;
|
|
1650
|
+
}
|
|
1651
|
+
// --- github link ---
|
|
1652
|
+
const GITHUB_HELP = `uploads github link [--repo <owner/name>] [--status] [--workspace <name>]
|
|
1653
|
+
|
|
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.
|
|
1659
|
+
|
|
1660
|
+
--repo defaults the same way as --pr/--issue elsewhere (gh repo view, then
|
|
1661
|
+
the git remote). --status only inspects the current binding (files:read);
|
|
1662
|
+
without it, the command claims the repo (files:write).
|
|
1663
|
+
|
|
1664
|
+
Examples:
|
|
1665
|
+
uploads github link
|
|
1666
|
+
uploads github link --repo buildinternet/uploads
|
|
1667
|
+
uploads github link --status
|
|
1668
|
+
`;
|
|
1669
|
+
function formatGithubLink(repo, result) {
|
|
1670
|
+
return result.workspace
|
|
1671
|
+
? `${repo} is bound to workspace "${result.workspace}"${result.source ? ` (${result.source})` : ""}\n`
|
|
1672
|
+
: `${repo} is not bound to any workspace\n`;
|
|
1673
|
+
}
|
|
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;
|
|
1680
|
+
}
|
|
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");
|
|
1685
|
+
let result;
|
|
1686
|
+
try {
|
|
1687
|
+
result = statusOnly
|
|
1688
|
+
? await ctx.client.githubLinkStatus(repo)
|
|
1689
|
+
: await ctx.client.githubLinkClaim(repo);
|
|
1690
|
+
}
|
|
1691
|
+
catch (err) {
|
|
1692
|
+
if (err instanceof UploadsError && err.status === 404) {
|
|
1693
|
+
throw new UsageError("server does not support repo bindings yet (404) — upgrade the uploads.sh API/self-hosted worker");
|
|
1694
|
+
}
|
|
1695
|
+
throw err;
|
|
1696
|
+
}
|
|
1697
|
+
if (ctx.json) {
|
|
1698
|
+
await writeJson(result);
|
|
1699
|
+
return 0;
|
|
1700
|
+
}
|
|
1701
|
+
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`);
|
|
1350
1703
|
}
|
|
1704
|
+
await writeStdout(formatGithubLink(repo, result));
|
|
1351
1705
|
return 0;
|
|
1352
1706
|
}
|
|
1353
1707
|
// --- usage / reconcile / purge ---
|
package/dist/github-gh.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export declare const execRunner: CommandRunner;
|
|
|
9
9
|
export declare function resolveRepo(explicit: string | undefined, run?: CommandRunner): string;
|
|
10
10
|
/** Resolve the pull request associated with the current branch. */
|
|
11
11
|
export declare function resolveCurrentPullRequest(repo: string, run?: CommandRunner): GhTarget;
|
|
12
|
+
/** Resolve the current git branch (`--branch` with no value). Throws UsageError on detached HEAD or outside a git repo. */
|
|
13
|
+
export declare function resolveCurrentBranch(run?: CommandRunner): string;
|
|
12
14
|
/**
|
|
13
15
|
* Classify a bare PR/issue number via the GitHub API so the default `put`
|
|
14
16
|
* path can stamp the right `gh.kind`. Returns undefined on any failure (gh
|
|
@@ -16,11 +18,36 @@ export declare function resolveCurrentPullRequest(repo: string, run?: CommandRun
|
|
|
16
18
|
* uploads without metadata.
|
|
17
19
|
*/
|
|
18
20
|
export declare function classifyGhNumber(repo: string, num: number, run?: CommandRunner): GhTarget | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Best-effort PR/issue title lookup via local `gh`. Returns undefined on any
|
|
23
|
+
* failure (gh missing, unauthenticated, network, 404) — mirrors
|
|
24
|
+
* `resolveCurrentPullRequest`/`classifyGhNumber`'s degrade-don't-throw
|
|
25
|
+
* pattern. A title is a nice-to-have annotation, never a blocker: callers
|
|
26
|
+
* must never let this failure abort an upload.
|
|
27
|
+
*/
|
|
28
|
+
export declare function resolveGhTitle(target: GhTarget, run?: CommandRunner): string | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* `ghMetadataFromTarget`'s 4 pairs, plus a best-effort `gh.title` (issue #267)
|
|
31
|
+
* when `resolveGhTitle` yields one that also satisfies the metadata-value
|
|
32
|
+
* rule every other pair follows (1-512 printable ASCII — `metadata.ts`'s
|
|
33
|
+
* `META_VALUE_MAX`/`isMetaValueSafe`). Truncated to `META_VALUE_MAX` first;
|
|
34
|
+
* a title left empty or unsafe by truncation (e.g. non-ASCII — real titles
|
|
35
|
+
* often contain emoji or curly quotes) is silently omitted rather than
|
|
36
|
+
* sanitized, matching `resolveGhTitle`'s own "degrade, don't fail the
|
|
37
|
+
* upload" contract.
|
|
38
|
+
*/
|
|
39
|
+
export declare function ghMetadataFromTargetWithTitle(target: GhTarget, run?: CommandRunner): Record<string, string>;
|
|
19
40
|
/**
|
|
20
41
|
* Create the managed attachments comment, or edit it in place if it already
|
|
21
42
|
* exists. Never touches any other comment. Body is passed via stdin
|
|
22
43
|
* (`-F body=@-`) so it is never shell-interpolated.
|
|
44
|
+
*
|
|
45
|
+
* `marker` identifies which comment to hunt for (see `findManagedComment`);
|
|
46
|
+
* `body` is expected to already carry that same marker as its first line
|
|
47
|
+
* (built via `attachmentsCommentBody(items, galleries, marker)`), so patching
|
|
48
|
+
* an adopted legacy comment migrates it to the namespaced marker in place.
|
|
49
|
+
* Defaults to the shared legacy marker for backward compatibility.
|
|
23
50
|
*/
|
|
24
|
-
export declare function upsertAttachmentsComment(target: GhTarget, body: string, run?: CommandRunner): {
|
|
51
|
+
export declare function upsertAttachmentsComment(target: GhTarget, body: string, run?: CommandRunner, marker?: string): {
|
|
25
52
|
created: boolean;
|
|
26
53
|
};
|