@buildinternet/uploads 0.38.0 → 0.41.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 +9 -0
- package/dist/cli.js +6 -2
- package/dist/client.d.ts +99 -9
- package/dist/client.js +112 -30
- package/dist/commands/screenshot.js +24 -7
- package/dist/commands.d.ts +46 -1
- package/dist/commands.js +298 -31
- package/dist/comment-config.d.ts +3 -0
- package/dist/comment-config.js +13 -0
- package/dist/github.d.ts +62 -0
- package/dist/github.js +84 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -1
- package/dist/keys.d.ts +6 -0
- package/dist/keys.js +17 -0
- package/dist/mcp/tools.js +65 -11
- package/dist/metadata-vocab.d.ts +1 -1
- package/dist/metadata-vocab.js +1 -0
- package/package.json +3 -3
package/dist/commands.js
CHANGED
|
@@ -13,9 +13,9 @@ import { imageFactsFromBytes } from "./image-facts.js";
|
|
|
13
13
|
import { parseMetaFlags, validateMetaMap } from "./metadata.js";
|
|
14
14
|
import { mergeDerivedMeta, nearMissMetaWarnings, validateStateValue } from "./metadata-vocab.js";
|
|
15
15
|
import { mergeSidecarMeta } from "./sidecar.js";
|
|
16
|
-
import {
|
|
16
|
+
import { ghAttachmentKeyForMode, ghBranchAttachmentKeyForMode, ghBranchKeyPrefix, ghKeyPrefix, ghPrivateKeyPrefix, ghPrivateBranchKeyPrefix, ghMetadataFromTarget, parseGhKey, parseGhPrivateKey, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, AUTO_RENDER_OPTIONS, GH_FALLBACK_AUTHOR_NOTE, normalizeGithubCoordinate, } from "./github.js";
|
|
17
17
|
import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, resolveDefaultBranch, classifyGhNumber, execRunner, timedExecRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, } from "./github-gh.js";
|
|
18
|
-
import { deriveRepoFromGit } from "./keys.js";
|
|
18
|
+
import { deriveRepoFromGit, deriveRepoSlugFromGit } from "./keys.js";
|
|
19
19
|
import { resolvePutPrefix } from "./destinations.js";
|
|
20
20
|
import { optimizeImageForUpload, rewriteKeyExtension, } from "./optimize.js";
|
|
21
21
|
import { applyFrame, resolveFrameId } from "./frame.js";
|
|
@@ -28,6 +28,56 @@ import { colorEnabled, writeCommandHelp } from "./cli-style.js";
|
|
|
28
28
|
export const UPLOAD_BATCH_CONCURRENCY = 8;
|
|
29
29
|
/** @deprecated Use UPLOAD_BATCH_CONCURRENCY. */
|
|
30
30
|
export const ATTACH_CONCURRENCY = UPLOAD_BATCH_CONCURRENCY;
|
|
31
|
+
/**
|
|
32
|
+
* Fail-open wrapper around `client.resolveGhPrefix` (issue #631): resolves to
|
|
33
|
+
* `{ mode: "plain" }` on ANY failure — an HTTP/network error (already handled
|
|
34
|
+
* inside `resolveGhPrefix` itself), or a self-hosted/older server or test
|
|
35
|
+
* double that lacks the method entirely (the outer try/catch here). Never
|
|
36
|
+
* blocks an upload or a read-back, and never logs — this is not an error.
|
|
37
|
+
* Call once per command invocation and thread the resolved mode through
|
|
38
|
+
* (uploadPuts/uploadAttachments/uploadBranchAttachments each do this once
|
|
39
|
+
* internally, ahead of their per-file loop); `resolveGhPrefix` itself also
|
|
40
|
+
* caches per-process by repo+branch+target, so repeat callers in the same
|
|
41
|
+
* process (e.g. attach's promote + comment-sync + upload, all for the same
|
|
42
|
+
* target) cost one request total.
|
|
43
|
+
*/
|
|
44
|
+
export async function resolveGhPrefixSafe(client, opts) {
|
|
45
|
+
try {
|
|
46
|
+
return await client.resolveGhPrefix(opts);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return { mode: "plain" };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The list of prefixes to fan a multi-prefix list/gather across (issue
|
|
54
|
+
* #631): the plain prefix plus every active private prefix, if any — a
|
|
55
|
+
* repo's history can be split across the plain shape and MULTIPLE private
|
|
56
|
+
* prefixes (e.g. a prefix rotation, or the repo went private after some
|
|
57
|
+
* files were uploaded), not just the currently-resolved one. Falls back to
|
|
58
|
+
* `[prefixId]` when the server omits `activePrefixIds` (optional field — an
|
|
59
|
+
* older/self-hosted worker), so a private repo is never listed as zero
|
|
60
|
+
* private prefixes. Collapses to `[plainPrefix]` in plain mode, so callers
|
|
61
|
+
* that special-case a single-prefix array stay byte-identical to pre-#631.
|
|
62
|
+
*/
|
|
63
|
+
export function ghListPrefixes(plainPrefix, ghPrefix, privatePrefixFor) {
|
|
64
|
+
if (ghPrefix.mode !== "private")
|
|
65
|
+
return [plainPrefix];
|
|
66
|
+
return [plainPrefix, ...(ghPrefix.activePrefixIds ?? [ghPrefix.prefixId]).map(privatePrefixFor)];
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Merge-list helper for a multi-prefix fan-out: runs `fetchItems` per prefix
|
|
70
|
+
* concurrently and concatenates in prefix order. Encodes the first-prefix-
|
|
71
|
+
* only cursor rule once — a cursor is opaque and scoped to the prefix it was
|
|
72
|
+
* minted against, so a multi-prefix merge only ever hands it to the FIRST
|
|
73
|
+
* prefix; every other prefix always starts from its own beginning (undefined
|
|
74
|
+
* cursor), or a cursor minted for one prefix's keyspace would get replayed
|
|
75
|
+
* against a different one.
|
|
76
|
+
*/
|
|
77
|
+
export async function ghMergedList(prefixes, cursor, fetchItems) {
|
|
78
|
+
const pages = await Promise.all(prefixes.map((prefix, i) => fetchItems(prefix, i === 0 ? cursor : undefined)));
|
|
79
|
+
return pages.flat();
|
|
80
|
+
}
|
|
31
81
|
export { formatUsageHuman } from "./format-usage.js";
|
|
32
82
|
/** Read a local file (or `-` for stdin). Missing path → FILE_NOT_FOUND (exit 2). */
|
|
33
83
|
export function readFileArg(fileArg) {
|
|
@@ -385,10 +435,11 @@ export async function uploadPreparedImage(client, bytes, sourceName, opts) {
|
|
|
385
435
|
frameFit: opts.frame.frameFit,
|
|
386
436
|
optimize: opts.optimize,
|
|
387
437
|
});
|
|
438
|
+
const ghMode = opts.ghPrefix ?? { mode: "plain" };
|
|
388
439
|
let key = opts.ghTarget
|
|
389
|
-
?
|
|
440
|
+
? ghAttachmentKeyForMode(ghMode, opts.ghTarget, prepared.filename)
|
|
390
441
|
: opts.ghBranchTarget
|
|
391
|
-
?
|
|
442
|
+
? ghBranchAttachmentKeyForMode(ghMode, opts.ghBranchTarget.repo, opts.ghBranchTarget.branch, prepared.filename)
|
|
392
443
|
: opts.key;
|
|
393
444
|
if (key && prepared.optimized)
|
|
394
445
|
key = rewriteKeyExtension(key, prepared.filename);
|
|
@@ -510,9 +561,21 @@ export async function syncAttachmentsComment(client, target, run, workspace, opt
|
|
|
510
561
|
// always links to the file page and always shows metadata here, matching the
|
|
511
562
|
// defaults. This only diverges from the bot-posted comment for a workspace
|
|
512
563
|
// that both sets one of those flags false and falls through to this path.
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
564
|
+
//
|
|
565
|
+
// Also list every active private prefix for this repo (issue #631): a
|
|
566
|
+
// private repo's attachments can live under a randomized prefix instead of
|
|
567
|
+
// the plain one. `resolveGhPrefixSafe` is fail-open (any resolve failure,
|
|
568
|
+
// including a client that lacks the method entirely) — plain-only listing,
|
|
569
|
+
// silently, matching pre-#631 behavior exactly.
|
|
570
|
+
const ghPrefix = await resolveGhPrefixSafe(client, {
|
|
571
|
+
repo: target.repo,
|
|
572
|
+
target: { kind: target.kind, num: target.num },
|
|
573
|
+
});
|
|
574
|
+
const prefixes = ghListPrefixes(ghKeyPrefix(target), ghPrefix, (id) => ghPrivateKeyPrefix(id, target));
|
|
575
|
+
const items = await ghMergedList(prefixes, undefined, async (prefix) => (await client.listAll({ prefix, metadata: true })).map(({ key, url, embedUrl, pageUrl, metadata }) => {
|
|
576
|
+
// The list endpoint returns every metadata key; the comment
|
|
577
|
+
// renders only these two. Narrowing here keeps both render paths
|
|
578
|
+
// byte-identical.
|
|
516
579
|
const path = metadata?.path;
|
|
517
580
|
const state = metadata?.state;
|
|
518
581
|
return {
|
|
@@ -524,7 +587,7 @@ export async function syncAttachmentsComment(client, target, run, workspace, opt
|
|
|
524
587
|
? { meta: { ...(path ? { path } : {}), ...(state ? { state } : {}) } }
|
|
525
588
|
: {}),
|
|
526
589
|
};
|
|
527
|
-
});
|
|
590
|
+
}));
|
|
528
591
|
const galleries = [];
|
|
529
592
|
let cursor;
|
|
530
593
|
do {
|
|
@@ -791,9 +854,14 @@ async function uploadAttachmentBatch(opts) {
|
|
|
791
854
|
* original cause of the first failure — for rethrowing single-file CLI paths.
|
|
792
855
|
*/
|
|
793
856
|
export async function uploadAttachments(opts) {
|
|
857
|
+
// Resolved once for the whole batch (issue #631) — never per file.
|
|
858
|
+
const ghPrefix = await resolveGhPrefixSafe(opts.client, {
|
|
859
|
+
repo: opts.target.repo,
|
|
860
|
+
target: { kind: opts.target.kind, num: opts.target.num },
|
|
861
|
+
});
|
|
794
862
|
return uploadAttachmentBatch({
|
|
795
863
|
...opts,
|
|
796
|
-
keyFor: (filename) =>
|
|
864
|
+
keyFor: (filename) => ghAttachmentKeyForMode(ghPrefix, opts.target, filename),
|
|
797
865
|
});
|
|
798
866
|
}
|
|
799
867
|
/**
|
|
@@ -804,9 +872,14 @@ export async function uploadAttachments(opts) {
|
|
|
804
872
|
* `syncAttachmentsComment` for a branch target.
|
|
805
873
|
*/
|
|
806
874
|
export async function uploadBranchAttachments(opts) {
|
|
875
|
+
// Resolved once for the whole batch (issue #631) — never per file.
|
|
876
|
+
const ghPrefix = await resolveGhPrefixSafe(opts.client, {
|
|
877
|
+
repo: opts.target.repo,
|
|
878
|
+
branch: opts.target.branch,
|
|
879
|
+
});
|
|
807
880
|
return uploadAttachmentBatch({
|
|
808
881
|
...opts,
|
|
809
|
-
keyFor: (filename) =>
|
|
882
|
+
keyFor: (filename) => ghBranchAttachmentKeyForMode(ghPrefix, opts.target.repo, opts.target.branch, filename),
|
|
810
883
|
});
|
|
811
884
|
}
|
|
812
885
|
function errorDetail(err) {
|
|
@@ -828,6 +901,18 @@ export async function uploadPuts(opts) {
|
|
|
828
901
|
if (opts.files.length > 1 && opts.nameOverride) {
|
|
829
902
|
throw new UsageError("--name cannot be combined with multiple files");
|
|
830
903
|
}
|
|
904
|
+
// Resolved once for the whole batch (issue #631) — never per file.
|
|
905
|
+
const ghPrefix = opts.ghTarget
|
|
906
|
+
? await resolveGhPrefixSafe(opts.client, {
|
|
907
|
+
repo: opts.ghTarget.repo,
|
|
908
|
+
target: { kind: opts.ghTarget.kind, num: opts.ghTarget.num },
|
|
909
|
+
})
|
|
910
|
+
: opts.ghBranchTarget
|
|
911
|
+
? await resolveGhPrefixSafe(opts.client, {
|
|
912
|
+
repo: opts.ghBranchTarget.repo,
|
|
913
|
+
branch: opts.ghBranchTarget.branch,
|
|
914
|
+
})
|
|
915
|
+
: undefined;
|
|
831
916
|
const slots = await mapBounded(opts.files, opts.concurrency ?? UPLOAD_BATCH_CONCURRENCY, async (file) => {
|
|
832
917
|
try {
|
|
833
918
|
const sourceName = opts.nameOverride ??
|
|
@@ -845,6 +930,7 @@ export async function uploadPuts(opts) {
|
|
|
845
930
|
optimize: opts.optimize,
|
|
846
931
|
ghTarget: opts.ghTarget,
|
|
847
932
|
ghBranchTarget: opts.ghBranchTarget,
|
|
933
|
+
ghPrefix,
|
|
848
934
|
key: opts.explicitKey,
|
|
849
935
|
prefix: opts.prefix,
|
|
850
936
|
repo: opts.repo,
|
|
@@ -899,7 +985,7 @@ export async function uploadPuts(opts) {
|
|
|
899
985
|
return { uploads, failures, firstError, sentMetadata };
|
|
900
986
|
}
|
|
901
987
|
/**
|
|
902
|
-
* Best-effort call to `POST /v1/:workspace/github/promote` (server contract,
|
|
988
|
+
* Best-effort call to `POST /v1/workspaces/:workspace/github/promote` (server contract,
|
|
903
989
|
* PR #310). Degrade-safe like `syncAttachmentsComment`'s bot path: an older
|
|
904
990
|
* or self-hosted worker without this route (404), a forbidden token (403),
|
|
905
991
|
* or a network error all collapse to "nothing promoted" — the caller must
|
|
@@ -1447,18 +1533,28 @@ async function resolveStagedBinding(client, repo) {
|
|
|
1447
1533
|
*/
|
|
1448
1534
|
export async function resolveStaged(opts) {
|
|
1449
1535
|
const { client, repo, branch } = opts;
|
|
1450
|
-
const
|
|
1451
|
-
|
|
1452
|
-
|
|
1536
|
+
const plainPrefix = ghBranchKeyPrefix(repo, branch);
|
|
1537
|
+
// Also list every active private prefix, if any (issue #631) — mirrors
|
|
1538
|
+
// syncAttachmentsComment's gh-fallback gather above: a repo's staged
|
|
1539
|
+
// history can be split across the plain shape and MULTIPLE private
|
|
1540
|
+
// prefixes (e.g. a prefix rotation, or the repo went private after some
|
|
1541
|
+
// files were staged), not just the currently-resolved one. Fail-open: any
|
|
1542
|
+
// resolve failure degrades to plain-only, byte-identical to pre-#631.
|
|
1543
|
+
const ghPrefix = await resolveGhPrefixSafe(client, { repo, branch });
|
|
1544
|
+
const prefixes = ghListPrefixes(plainPrefix, ghPrefix, (id) => ghPrivateBranchKeyPrefix(id));
|
|
1545
|
+
const [files, binding] = await Promise.all([
|
|
1546
|
+
ghMergedList(prefixes, undefined, async (prefix) => {
|
|
1547
|
+
const list = await client.list({ prefix, metadata: true });
|
|
1548
|
+
return list.items.map((item) => ({
|
|
1549
|
+
key: item.key,
|
|
1550
|
+
filename: item.key.slice(prefix.length),
|
|
1551
|
+
size: item.size,
|
|
1552
|
+
stagedAt: item.metadata?.["gh.staged-at"],
|
|
1553
|
+
url: item.url,
|
|
1554
|
+
}));
|
|
1555
|
+
}),
|
|
1453
1556
|
resolveStagedBinding(client, repo),
|
|
1454
1557
|
]);
|
|
1455
|
-
const files = list.items.map((item) => ({
|
|
1456
|
-
key: item.key,
|
|
1457
|
-
filename: item.key.slice(prefix.length),
|
|
1458
|
-
size: item.size,
|
|
1459
|
-
stagedAt: item.metadata?.["gh.staged-at"],
|
|
1460
|
-
url: item.url,
|
|
1461
|
-
}));
|
|
1462
1558
|
return { repo, branch, files, binding };
|
|
1463
1559
|
}
|
|
1464
1560
|
const STAGED_HELP = `uploads staged [--branch <name>] [--repo <owner/name>] [--format json] [--workspace <name>]
|
|
@@ -1699,6 +1795,23 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
1699
1795
|
}
|
|
1700
1796
|
}
|
|
1701
1797
|
}
|
|
1798
|
+
// Derived `repo` (spec: 2026-08-11-screenshots-project-grouping-design.md):
|
|
1799
|
+
// the capturing repo, on every layout including gh/staging. mergeDerivedMeta
|
|
1800
|
+
// keeps explicit --meta repo= wins and never breaks the caps.
|
|
1801
|
+
//
|
|
1802
|
+
// `metadata === undefined` means "leave whatever's already stored for this
|
|
1803
|
+
// key untouched" (client.ts only sends X-Uploads-Meta-* when the map is
|
|
1804
|
+
// defined; a defined map is a full replace server-side). A bare `uploads
|
|
1805
|
+
// put` with no --meta/gh/staging context leaves `metadata` undefined by
|
|
1806
|
+
// design — never synthesize `{ repo }` there just because a repo happens
|
|
1807
|
+
// to be derivable, or a plain re-upload of an existing key would silently
|
|
1808
|
+
// wipe everything already stored on it. Only fold repo in when `metadata`
|
|
1809
|
+
// is already a defined object for another reason.
|
|
1810
|
+
if (!noGit && derivedMetaEnabled(parsed.flags, defaults) && metadata !== undefined) {
|
|
1811
|
+
const slug = deriveRepoSlugFromGit(run);
|
|
1812
|
+
if (slug)
|
|
1813
|
+
metadata = mergeDerivedMeta(metadata, { repo: slug });
|
|
1814
|
+
}
|
|
1702
1815
|
// Bare-put nudge (issue #393): only relevant when staging didn't take over
|
|
1703
1816
|
// — once `stagingTarget` resolves, staging IS the upgrade the nudge used to
|
|
1704
1817
|
// point at, so this is skipped entirely rather than firing redundantly.
|
|
@@ -2219,16 +2332,31 @@ export async function runList(ctx, args, help = false, run = execRunner) {
|
|
|
2219
2332
|
const prefixFlag = flagString(parsed.flags, "--prefix");
|
|
2220
2333
|
let prefix = prefixFlag ?? (defaults.prefix ? `${defaults.prefix}/` : undefined);
|
|
2221
2334
|
const ghTarget = ghTargetFromFlags(parsed.flags, run);
|
|
2335
|
+
// Also list every active private prefix, if any (issue #631) — mirrors
|
|
2336
|
+
// syncAttachmentsComment's gh-fallback gather: a repo's attachment
|
|
2337
|
+
// history can be split across the plain shape and MULTIPLE private
|
|
2338
|
+
// prefixes, not just the currently-resolved one. `prefixes` stays
|
|
2339
|
+
// undefined outside --pr/--issue (unchanged behavior); when defined it
|
|
2340
|
+
// collapses to just `[prefix]` in plain mode, so the single-request path
|
|
2341
|
+
// below is byte-identical to pre-#631 output there.
|
|
2342
|
+
let prefixes;
|
|
2222
2343
|
if (ghTarget) {
|
|
2223
2344
|
if (prefixFlag)
|
|
2224
2345
|
throw new UsageError("--prefix cannot be combined with --pr/--issue");
|
|
2225
2346
|
prefix = ghKeyPrefix(ghTarget);
|
|
2347
|
+
const ghPrefix = await resolveGhPrefixSafe(ctx.client, {
|
|
2348
|
+
repo: ghTarget.repo,
|
|
2349
|
+
target: { kind: ghTarget.kind, num: ghTarget.num },
|
|
2350
|
+
});
|
|
2351
|
+
prefixes = ghListPrefixes(prefix, ghPrefix, (id) => ghPrivateKeyPrefix(id, ghTarget));
|
|
2226
2352
|
}
|
|
2227
2353
|
const limit = flagInt(parsed.flags, "--limit", "--limit");
|
|
2228
2354
|
const cursor = flagString(parsed.flags, "--cursor");
|
|
2229
2355
|
if (flagBool(parsed.flags, "--all")) {
|
|
2230
2356
|
// --all may start from a caller-provided --cursor and drains from there.
|
|
2231
|
-
const items =
|
|
2357
|
+
const items = prefixes && prefixes.length > 1
|
|
2358
|
+
? await ghMergedList(prefixes, cursor, (p, c) => ctx.client.listAll({ prefix: p, limit, cursor: c }))
|
|
2359
|
+
: await ctx.client.listAll({ prefix, limit, cursor });
|
|
2232
2360
|
if (ctx.json)
|
|
2233
2361
|
await writeJson({ items, cursor: null });
|
|
2234
2362
|
else
|
|
@@ -2236,7 +2364,16 @@ export async function runList(ctx, args, help = false, run = execRunner) {
|
|
|
2236
2364
|
await writeStdout(`${item.key}${item.url ? ` ${item.url}` : ""}\n`);
|
|
2237
2365
|
return 0;
|
|
2238
2366
|
}
|
|
2239
|
-
|
|
2367
|
+
// Merged multi-prefix pages don't have a meaningful combined cursor —
|
|
2368
|
+
// dropped (null) only when there's more than one prefix to merge; the
|
|
2369
|
+
// single-prefix path (every non-private call, plus every call before
|
|
2370
|
+
// #631) is untouched. Same first-prefix-only cursor guard as --all above.
|
|
2371
|
+
const result = prefixes && prefixes.length > 1
|
|
2372
|
+
? {
|
|
2373
|
+
items: await ghMergedList(prefixes, cursor, async (p, c) => (await ctx.client.list({ prefix: p, limit, cursor: c })).items),
|
|
2374
|
+
cursor: null,
|
|
2375
|
+
}
|
|
2376
|
+
: await ctx.client.list({ prefix, limit, cursor });
|
|
2240
2377
|
if (ctx.json)
|
|
2241
2378
|
await writeJson(result);
|
|
2242
2379
|
else {
|
|
@@ -2434,10 +2571,37 @@ const COMMENT_RENDERED_META_KEYS = ["path", "state"];
|
|
|
2434
2571
|
* metadata tweak, not an explicit comment command); any failure degrades to
|
|
2435
2572
|
* a stderr hint instead of failing the metadata write that already landed.
|
|
2436
2573
|
*/
|
|
2574
|
+
/**
|
|
2575
|
+
* Resolve the `{repo, kind, num}` target for a key, whether plain
|
|
2576
|
+
* (`parseGhKey`) or private-prefixed (issue #631, `parseGhPrivateKey` —
|
|
2577
|
+
* cannot recover the repo from the key alone, since the randomized prefix
|
|
2578
|
+
* deliberately omits it). For a private key, reads `gh.repo` metadata — the
|
|
2579
|
+
* attach/put that created this key already wrote it — via the same metadata
|
|
2580
|
+
* client call `meta get` uses. Fail-open: any read failure, or a key that
|
|
2581
|
+
* isn't gh-managed at all, resolves to undefined (nothing to resync).
|
|
2582
|
+
*/
|
|
2583
|
+
async function resolveGhTargetForResync(client, key) {
|
|
2584
|
+
const plain = parseGhKey(key);
|
|
2585
|
+
if (plain)
|
|
2586
|
+
return plain;
|
|
2587
|
+
const priv = parseGhPrivateKey(key);
|
|
2588
|
+
if (!priv)
|
|
2589
|
+
return undefined;
|
|
2590
|
+
try {
|
|
2591
|
+
const { metadata } = await client.getMetadata(key);
|
|
2592
|
+
const repo = metadata["gh.repo"];
|
|
2593
|
+
if (!repo)
|
|
2594
|
+
return undefined;
|
|
2595
|
+
return { repo, kind: priv.kind, num: priv.num };
|
|
2596
|
+
}
|
|
2597
|
+
catch {
|
|
2598
|
+
return undefined;
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2437
2601
|
async function resyncCommentAfterMetaSet(ctx, key, touchedKeys) {
|
|
2438
2602
|
if (!touchedKeys.some((k) => COMMENT_RENDERED_META_KEYS.includes(k)))
|
|
2439
2603
|
return;
|
|
2440
|
-
const target =
|
|
2604
|
+
const target = await resolveGhTargetForResync(ctx.client, key);
|
|
2441
2605
|
if (!target)
|
|
2442
2606
|
return;
|
|
2443
2607
|
try {
|
|
@@ -2551,10 +2715,53 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
|
|
|
2551
2715
|
}
|
|
2552
2716
|
return 0;
|
|
2553
2717
|
}
|
|
2718
|
+
// --- ingest ---
|
|
2719
|
+
const INGEST_HELP = `uploads ingest — mirror GitHub-native attachments from a PR/issue into the workspace
|
|
2720
|
+
|
|
2721
|
+
Usage:
|
|
2722
|
+
uploads ingest --pr <n> [--repo owner/name]
|
|
2723
|
+
uploads ingest --issue <n> [--repo owner/name]
|
|
2724
|
+
|
|
2725
|
+
Scans the PR/issue description and comments for github.com/user-attachments
|
|
2726
|
+
media, mirrors new ones into the workspace (indexed, not added to the managed
|
|
2727
|
+
comment), and detaches ones no longer referenced. Works on any repo linked to
|
|
2728
|
+
the workspace; the .uploads.yml ingestGithubAttachments knob only gates the
|
|
2729
|
+
automatic webhook path.
|
|
2730
|
+
|
|
2731
|
+
Examples:
|
|
2732
|
+
uploads ingest --pr 123
|
|
2733
|
+
uploads ingest --issue 45 --repo acme/app --format json
|
|
2734
|
+
`;
|
|
2735
|
+
export async function runIngest(ctx, args, help = false, run = execRunner) {
|
|
2736
|
+
const parsed = parseCommandArgs(args);
|
|
2737
|
+
if (help || parsed.help) {
|
|
2738
|
+
writeCommandHelp(INGEST_HELP);
|
|
2739
|
+
return 0;
|
|
2740
|
+
}
|
|
2741
|
+
const target = ghTargetFromFlags(parsed.flags, run);
|
|
2742
|
+
if (!target) {
|
|
2743
|
+
throw new UsageError("--pr or --issue required");
|
|
2744
|
+
}
|
|
2745
|
+
const result = await ctx.client.ingestGithub(target);
|
|
2746
|
+
const jsonMode = ctx.json || flagString(parsed.flags, "--format") === "json";
|
|
2747
|
+
if (jsonMode) {
|
|
2748
|
+
await writeJson(result);
|
|
2749
|
+
return 0;
|
|
2750
|
+
}
|
|
2751
|
+
if (!ctx.quiet) {
|
|
2752
|
+
let line = `Ingested ${result.ingested.length}, re-attached ${result.reattached.length}, detached ${result.detached.length}, skipped ${result.skipped.length}\n`;
|
|
2753
|
+
for (const skip of result.skipped) {
|
|
2754
|
+
line += ` skipped: ${skip.url} (${skip.reason})\n`;
|
|
2755
|
+
}
|
|
2756
|
+
process.stderr.write(line);
|
|
2757
|
+
}
|
|
2758
|
+
return 0;
|
|
2759
|
+
}
|
|
2554
2760
|
// --- github link ---
|
|
2555
2761
|
const GITHUB_HELP = `uploads github link [--repo <owner/name>] [--status] [--workspace <name>]
|
|
2556
2762
|
uploads github unlink [--repo <owner/name>] [--workspace <name>]
|
|
2557
2763
|
uploads github doctor [--workspace <name>]
|
|
2764
|
+
uploads github rotate-prefix [--repo <owner/name>] [--branch <name> | --repo-level] [--workspace <name>]
|
|
2558
2765
|
|
|
2559
2766
|
Claim, inspect, or release this workspace's binding to a GitHub repo (see the
|
|
2560
2767
|
managed attachments comment / webhook auto-promotion, which use this
|
|
@@ -2570,17 +2777,28 @@ owns it; an operator can reassign or remove that binding instead.
|
|
|
2570
2777
|
|
|
2571
2778
|
\`doctor\` checks the GitHub App itself: whether it's configured on the
|
|
2572
2779
|
server, and whether it's subscribed to the webhook events uploads.sh's
|
|
2573
|
-
handler needs (issues, pull_request
|
|
2780
|
+
handler needs (required: issues, pull_request; recommended: issue_comment —
|
|
2781
|
+
see docs/github-app). A missing
|
|
2574
2782
|
subscription is the classic silent failure: the App's ping stays green
|
|
2575
2783
|
while webhook auto-promotion and title-cache invalidation quietly do
|
|
2576
2784
|
nothing.
|
|
2577
2785
|
|
|
2786
|
+
\`rotate-prefix\` mints a fresh randomized URL prefix for a private repo's
|
|
2787
|
+
attachments and moves everything under the old one to it, so the old URLs
|
|
2788
|
+
404 at origin immediately (see docs/private-attachments.md). --branch
|
|
2789
|
+
defaults to the current git branch; --repo-level rotates the id shared by
|
|
2790
|
+
issue attachments and ingested assets instead of a branch's id. Rotation
|
|
2791
|
+
is an explicit action — an unauthorized caller gets an error, not a silent
|
|
2792
|
+
no-op.
|
|
2793
|
+
|
|
2578
2794
|
Examples:
|
|
2579
2795
|
uploads github link
|
|
2580
2796
|
uploads github link --repo buildinternet/uploads
|
|
2581
2797
|
uploads github link --status
|
|
2582
2798
|
uploads github unlink --repo buildinternet/uploads
|
|
2583
2799
|
uploads github doctor
|
|
2800
|
+
uploads github rotate-prefix --branch feature-x
|
|
2801
|
+
uploads github rotate-prefix --repo-level
|
|
2584
2802
|
`;
|
|
2585
2803
|
/** Older servers' health payload predates recommendedEvents/missingRecommendedEvents — treat as no recommendations rather than crashing. */
|
|
2586
2804
|
function missingRecommendedEventsOf(result) {
|
|
@@ -2590,7 +2808,7 @@ function recommendedNoteLine(result) {
|
|
|
2590
2808
|
const missing = missingRecommendedEventsOf(result);
|
|
2591
2809
|
if (missing.length === 0)
|
|
2592
2810
|
return "";
|
|
2593
|
-
return `note: not subscribed to ${missing.join(", ")} (recommended) — enables bot-comment self-healing; subscribe under the App's Permissions & events\n`;
|
|
2811
|
+
return `note: not subscribed to ${missing.join(", ")} (recommended) — enables bot-comment self-healing and comment-attachment ingestion; subscribe under the App's Permissions & events\n`;
|
|
2594
2812
|
}
|
|
2595
2813
|
function formatGithubDoctor(result) {
|
|
2596
2814
|
if (!result.configured) {
|
|
@@ -2600,7 +2818,18 @@ function formatGithubDoctor(result) {
|
|
|
2600
2818
|
return `github app: configured, but health check failed${result.hint ? ` — ${result.hint}` : ""}\n`;
|
|
2601
2819
|
}
|
|
2602
2820
|
if (result.ok) {
|
|
2603
|
-
|
|
2821
|
+
// The ACTUAL subscribed list, not just the required subset — printing
|
|
2822
|
+
// requiredEvents here once misdiagnosed a live App as "not subscribed to
|
|
2823
|
+
// issue_comment" when it was (2026-08-11). `events` is non-null on every
|
|
2824
|
+
// ok result (the null case returns above); the requiredEvents fallback
|
|
2825
|
+
// only guards a malformed payload from an older server.
|
|
2826
|
+
const subscribed = Array.isArray(result.events)
|
|
2827
|
+
? [...result.events].sort()
|
|
2828
|
+
: [...result.requiredEvents];
|
|
2829
|
+
const allPresent = missingRecommendedEventsOf(result).length === 0;
|
|
2830
|
+
return (`github app: ok — subscribed to ${subscribed.join(", ")}` +
|
|
2831
|
+
(allPresent ? " (all required + recommended events)" : "") +
|
|
2832
|
+
"\n" +
|
|
2604
2833
|
recommendedNoteLine(result));
|
|
2605
2834
|
}
|
|
2606
2835
|
return (`github app: missing webhook event subscription(s): ${result.missingEvents.join(", ")}\n` +
|
|
@@ -2689,6 +2918,34 @@ async function runGithubUnlink(ctx, repo) {
|
|
|
2689
2918
|
: `${repo} was not bound to any workspace — nothing to unlink\n`);
|
|
2690
2919
|
return 0;
|
|
2691
2920
|
}
|
|
2921
|
+
function formatGithubRotatePrefix(repo, branchLabel, result) {
|
|
2922
|
+
if (!result.rotated) {
|
|
2923
|
+
return `nothing to rotate for ${repo} (${branchLabel}): ${result.reason}\n`;
|
|
2924
|
+
}
|
|
2925
|
+
return `rotated ${repo} (${branchLabel}): moved ${result.moved} object${result.moved === 1 ? "" : "s"} to a new prefix (${result.prefixId})\n`;
|
|
2926
|
+
}
|
|
2927
|
+
async function runGithubRotatePrefix(ctx, repo, branch, repoLevel) {
|
|
2928
|
+
let result;
|
|
2929
|
+
try {
|
|
2930
|
+
result = await ctx.client.rotateGhPrefix(repoLevel ? { repo, repoLevel: true } : { repo, branch });
|
|
2931
|
+
}
|
|
2932
|
+
catch (err) {
|
|
2933
|
+
if (err instanceof UploadsError && err.status === 404) {
|
|
2934
|
+
throw new UsageError("server does not support private-prefix rotation yet (404) — upgrade the uploads.sh API/self-hosted worker");
|
|
2935
|
+
}
|
|
2936
|
+
if (err instanceof UploadsError && err.status === 403) {
|
|
2937
|
+
throw new UsageError(`not authorized to rotate ${repo}'s attachment prefix (${err.message})`);
|
|
2938
|
+
}
|
|
2939
|
+
throw err;
|
|
2940
|
+
}
|
|
2941
|
+
if (ctx.json) {
|
|
2942
|
+
await writeJson(result);
|
|
2943
|
+
return result.rotated ? 0 : 1;
|
|
2944
|
+
}
|
|
2945
|
+
const branchLabel = repoLevel ? "repo-level" : (branch ?? "");
|
|
2946
|
+
await writeStdout(formatGithubRotatePrefix(repo, branchLabel, result));
|
|
2947
|
+
return result.rotated ? 0 : 1;
|
|
2948
|
+
}
|
|
2692
2949
|
export async function runGithub(ctx, args, help = false, run = execRunner) {
|
|
2693
2950
|
const parsed = parseCommandArgs(args);
|
|
2694
2951
|
const action = parsed.positionals[0];
|
|
@@ -2696,14 +2953,24 @@ export async function runGithub(ctx, args, help = false, run = execRunner) {
|
|
|
2696
2953
|
writeCommandHelp(GITHUB_HELP);
|
|
2697
2954
|
return help || parsed.help ? 0 : 2;
|
|
2698
2955
|
}
|
|
2699
|
-
if (action !== "link" &&
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2956
|
+
if (action !== "link" &&
|
|
2957
|
+
action !== "unlink" &&
|
|
2958
|
+
action !== "doctor" &&
|
|
2959
|
+
action !== "rotate-prefix") {
|
|
2960
|
+
throw new UsageError(`unknown github subcommand: ${action} (expected link, unlink, doctor, or rotate-prefix)`, { example: "uploads github link" });
|
|
2703
2961
|
}
|
|
2704
2962
|
if (action === "doctor")
|
|
2705
2963
|
return runGithubDoctor(ctx);
|
|
2706
2964
|
const repo = resolveRepo(flagString(parsed.flags, "--repo"), run);
|
|
2965
|
+
if (action === "rotate-prefix") {
|
|
2966
|
+
const repoLevel = flagBool(parsed.flags, "--repo-level");
|
|
2967
|
+
const branchFlag = flagString(parsed.flags, "--branch");
|
|
2968
|
+
if (repoLevel && branchFlag !== undefined) {
|
|
2969
|
+
throw new UsageError("pass either --branch or --repo-level, not both");
|
|
2970
|
+
}
|
|
2971
|
+
const branch = repoLevel ? undefined : (branchFlag ?? resolveCurrentBranch(run));
|
|
2972
|
+
return runGithubRotatePrefix(ctx, repo, branch, repoLevel);
|
|
2973
|
+
}
|
|
2707
2974
|
if (action === "unlink")
|
|
2708
2975
|
return runGithubUnlink(ctx, repo);
|
|
2709
2976
|
const statusOnly = flagBool(parsed.flags, "--status");
|
package/dist/comment-config.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export interface RepoCommentConfig {
|
|
|
17
17
|
metaState?: boolean;
|
|
18
18
|
linkToFilePage?: boolean;
|
|
19
19
|
note?: string;
|
|
20
|
+
ingestGithubAttachments?: boolean;
|
|
20
21
|
}
|
|
21
22
|
export interface WorkspaceCommentDefaults {
|
|
22
23
|
imageWidth?: "full" | number;
|
|
@@ -24,6 +25,7 @@ export interface WorkspaceCommentDefaults {
|
|
|
24
25
|
showMetadata?: boolean;
|
|
25
26
|
linkToFilePage?: boolean;
|
|
26
27
|
note?: string;
|
|
28
|
+
ingestGithubAttachments?: boolean;
|
|
27
29
|
}
|
|
28
30
|
export interface ResolvedCommentOptions {
|
|
29
31
|
imageWidth: "auto" | "full" | number;
|
|
@@ -32,6 +34,7 @@ export interface ResolvedCommentOptions {
|
|
|
32
34
|
metaState: boolean;
|
|
33
35
|
linkToFilePage: boolean;
|
|
34
36
|
note: string | null;
|
|
37
|
+
ingestGithubAttachments: boolean;
|
|
35
38
|
}
|
|
36
39
|
export type OptionSource = "repo" | "workspace" | "auto";
|
|
37
40
|
export declare const AUTO_COMMENT_OPTIONS: ResolvedCommentOptions;
|
package/dist/comment-config.js
CHANGED
|
@@ -20,6 +20,7 @@ export const AUTO_COMMENT_OPTIONS = {
|
|
|
20
20
|
metaState: true,
|
|
21
21
|
linkToFilePage: true,
|
|
22
22
|
note: null,
|
|
23
|
+
ingestGithubAttachments: false,
|
|
23
24
|
};
|
|
24
25
|
export const NOTE_MAX_CHARS = 500;
|
|
25
26
|
const WIDTH_MIN = 160;
|
|
@@ -71,6 +72,14 @@ export function parseRepoCommentConfig(text, format) {
|
|
|
71
72
|
else
|
|
72
73
|
warnings.push(`linkToFilePage: expected a boolean; dropped`);
|
|
73
74
|
}
|
|
75
|
+
// ingestGithubAttachments: boolean
|
|
76
|
+
if ("ingestGithubAttachments" in c) {
|
|
77
|
+
const v = c.ingestGithubAttachments;
|
|
78
|
+
if (typeof v === "boolean")
|
|
79
|
+
config.ingestGithubAttachments = v;
|
|
80
|
+
else
|
|
81
|
+
warnings.push(`ingestGithubAttachments: expected a boolean; dropped`);
|
|
82
|
+
}
|
|
74
83
|
// meta.path / meta.state: booleans nested under `meta`
|
|
75
84
|
if ("meta" in c) {
|
|
76
85
|
const v = c.meta;
|
|
@@ -123,6 +132,9 @@ export function resolveCommentOptions(repo, ws) {
|
|
|
123
132
|
: {}),
|
|
124
133
|
...(ws?.linkToFilePage !== undefined ? { linkToFilePage: ws.linkToFilePage } : {}),
|
|
125
134
|
...(ws?.note ? { note: ws.note } : {}),
|
|
135
|
+
...(ws?.ingestGithubAttachments !== undefined
|
|
136
|
+
? { ingestGithubAttachments: ws.ingestGithubAttachments }
|
|
137
|
+
: {}),
|
|
126
138
|
};
|
|
127
139
|
const options = { ...AUTO_COMMENT_OPTIONS };
|
|
128
140
|
const source = Object.fromEntries(Object.keys(AUTO_COMMENT_OPTIONS).map((k) => [k, "auto"]));
|
|
@@ -133,6 +145,7 @@ export function resolveCommentOptions(repo, ws) {
|
|
|
133
145
|
"metaPath",
|
|
134
146
|
"metaState",
|
|
135
147
|
"linkToFilePage",
|
|
148
|
+
"ingestGithubAttachments",
|
|
136
149
|
]) {
|
|
137
150
|
if (cfg[key] !== undefined && source[key] === "auto") {
|
|
138
151
|
options[key] = cfg[key];
|
package/dist/github.d.ts
CHANGED
|
@@ -19,8 +19,47 @@ export declare function normalizeGithubCoordinate(value: string): GithubCoordina
|
|
|
19
19
|
* Inverse of `ghKeyPrefix`: parse the PR/issue coordinate back out of a
|
|
20
20
|
* stable attachment key (`gh/<owner>/<name>/<kind>/<num>/<filename>`), or
|
|
21
21
|
* undefined for any other key shape.
|
|
22
|
+
*
|
|
23
|
+
* A real GitHub owner CAN be named `private`, so the strict private-repo
|
|
24
|
+
* shape (`gh/private/<32-hex-id>/...`, see `parseGhPrivateKey`) is checked
|
|
25
|
+
* first and rejected here — otherwise a private-prefixed key would
|
|
26
|
+
* misparse as an ordinary key with owner "private". The accepted ambiguity:
|
|
27
|
+
* a key whose second segment is NOT 32-lowercase-hex (e.g.
|
|
28
|
+
* `gh/private/realrepo/pull/5/x.png`) still parses here as owner "private",
|
|
29
|
+
* repo "private/realrepo" — that's an ordinary public-repo key for a repo
|
|
30
|
+
* actually named "private", not a private-prefix key.
|
|
22
31
|
*/
|
|
23
32
|
export declare function parseGhKey(key: string): GhTarget | undefined;
|
|
33
|
+
/** Literal root under which every private-repo attachment key lives. */
|
|
34
|
+
export declare const GH_PRIVATE_ROOT = "gh/private/";
|
|
35
|
+
/**
|
|
36
|
+
* Private-repo key prefix: `gh/private/<32-hex-id>/<kind>/<num>/`.
|
|
37
|
+
* Deliberately omits the repo (unlike `ghKeyPrefix`) — the id is a random,
|
|
38
|
+
* unguessable per-repo prefix rather than an owner/name path, so callers
|
|
39
|
+
* that need the repo back must read `gh.repo` metadata (see
|
|
40
|
+
* `parseGhPrivateKey`, which cannot recover it from the key alone).
|
|
41
|
+
*/
|
|
42
|
+
export declare function ghPrivateKeyPrefix(prefixId: string, target: GhTarget): string;
|
|
43
|
+
/** Private-repo attachment key: `ghPrivateKeyPrefix` + the sanitized filename. */
|
|
44
|
+
export declare function ghPrivateAttachmentKey(prefixId: string, target: GhTarget, filename: string): string;
|
|
45
|
+
/**
|
|
46
|
+
* Private-repo branch-staged key prefix: `gh/private/<32-hex-id>/branch/`.
|
|
47
|
+
* Unlike `ghBranchKeyPrefix`, there is deliberately NO branch-name segment —
|
|
48
|
+
* the branch name itself is not embedded in a private-repo key.
|
|
49
|
+
*/
|
|
50
|
+
export declare function ghPrivateBranchKeyPrefix(prefixId: string): string;
|
|
51
|
+
/** Private-repo branch-staged attachment key: `ghPrivateBranchKeyPrefix` + the sanitized filename. */
|
|
52
|
+
export declare function ghPrivateBranchAttachmentKey(prefixId: string, filename: string): string;
|
|
53
|
+
/**
|
|
54
|
+
* Inverse of `ghPrivateKeyPrefix`: parse the prefix id/kind/number back out
|
|
55
|
+
* of a private-repo attachment key, or undefined for any other key shape.
|
|
56
|
+
* Cannot recover the repo — callers that need it read `gh.repo` metadata.
|
|
57
|
+
*/
|
|
58
|
+
export declare function parseGhPrivateKey(key: string): {
|
|
59
|
+
prefixId: string;
|
|
60
|
+
kind: GhTargetKind;
|
|
61
|
+
num: number;
|
|
62
|
+
} | undefined;
|
|
24
63
|
export declare function ghKeyPrefix(target: GhTarget): string;
|
|
25
64
|
/**
|
|
26
65
|
* Stable attachment key: same filename → same key → same public URL, so
|
|
@@ -37,6 +76,29 @@ export declare function ghAttachmentKey(target: GhTarget, filename: string): str
|
|
|
37
76
|
export declare function ghBranchKeyPrefix(repo: string, branch: string): string;
|
|
38
77
|
/** Branch-staged attachment key: `ghBranchKeyPrefix` + the sanitized filename. */
|
|
39
78
|
export declare function ghBranchAttachmentKey(repo: string, branch: string, filename: string): string;
|
|
79
|
+
/**
|
|
80
|
+
* Structural stand-in for `ResolveGhPrefixResult` (defined in client.ts) —
|
|
81
|
+
* kept local so these key builders don't need to import client types just
|
|
82
|
+
* to own the plain-vs-private branch.
|
|
83
|
+
*/
|
|
84
|
+
export type GhKeyMode = {
|
|
85
|
+
mode: "plain";
|
|
86
|
+
} | {
|
|
87
|
+
mode: "private";
|
|
88
|
+
prefixId: string;
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Mode-owning attachment key builder: collapses the
|
|
92
|
+
* `mode === "private" ? ghPrivateAttachmentKey(...) : ghAttachmentKey(...)`
|
|
93
|
+
* ternary repeated across call sites into one place.
|
|
94
|
+
*/
|
|
95
|
+
export declare function ghAttachmentKeyForMode(mode: GhKeyMode, target: GhTarget, filename: string): string;
|
|
96
|
+
/**
|
|
97
|
+
* Mode-owning branch-staged attachment key builder. The private form
|
|
98
|
+
* ignores `repo`/`branch` (a private-repo key has no branch-name segment,
|
|
99
|
+
* see `ghPrivateBranchKeyPrefix`) and uses the prefix id instead.
|
|
100
|
+
*/
|
|
101
|
+
export declare function ghBranchAttachmentKeyForMode(mode: GhKeyMode, repo: string, branch: string, filename: string): string;
|
|
40
102
|
/**
|
|
41
103
|
* `gh.*` metadata for a branch-staged attach: `gh.repo`, `gh.kind=branch`,
|
|
42
104
|
* `gh.branch` (lowercased), and `gh.staged-at` (ISO 8601 UTC, no fractional
|