@buildinternet/uploads 0.26.1 → 0.28.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 +5 -0
- package/dist/cli-brand.js +4 -1
- package/dist/cli-catalog.js +6 -0
- package/dist/cli-help.js +1 -0
- package/dist/cli.js +4 -0
- package/dist/client.d.ts +8 -0
- package/dist/client.js +7 -0
- package/dist/commands/install.d.ts +8 -0
- package/dist/commands/install.js +1 -1
- package/dist/commands/screenshot.js +85 -12
- package/dist/commands/update.d.ts +14 -0
- package/dist/commands/update.js +125 -0
- package/dist/commands.d.ts +33 -1
- package/dist/commands.js +121 -15
- package/dist/github-gh.d.ts +23 -1
- package/dist/github-gh.js +59 -8
- package/dist/github.d.ts +6 -0
- package/dist/github.js +12 -0
- package/dist/install-source.d.ts +14 -0
- package/dist/install-source.js +42 -0
- package/dist/mcp/tools.js +42 -23
- package/dist/sidecar.d.ts +31 -0
- package/dist/sidecar.js +111 -0
- package/dist/update-check.js +1 -1
- package/package.json +1 -1
package/dist/commands.js
CHANGED
|
@@ -11,7 +11,8 @@ import { writeJson, writeStdout } from "./io.js";
|
|
|
11
11
|
import { imageFactsFromBytes } from "./image-facts.js";
|
|
12
12
|
import { parseMetaFlags, validateMetaMap } from "./metadata.js";
|
|
13
13
|
import { mergeDerivedMeta, nearMissMetaWarnings, validateStateValue } from "./metadata-vocab.js";
|
|
14
|
-
import {
|
|
14
|
+
import { mergeSidecarMeta } from "./sidecar.js";
|
|
15
|
+
import { ghAttachmentKey, ghBranchAttachmentKey, ghBranchKeyPrefix, ghKeyPrefix, ghMetadataFromTarget, parseGhKey, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, normalizeGithubCoordinate, } from "./github.js";
|
|
15
16
|
import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, resolveDefaultBranch, classifyGhNumber, execRunner, timedExecRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, } from "./github-gh.js";
|
|
16
17
|
import { deriveRepoFromGit } from "./keys.js";
|
|
17
18
|
import { resolvePutPrefix } from "./destinations.js";
|
|
@@ -56,6 +57,12 @@ upload as-is, or --keep-exif when image metadata matters for the discussion.
|
|
|
56
57
|
Optional --frame wraps the image in a device/browser chrome before optimize
|
|
57
58
|
(default off). See: uploads put --help frames
|
|
58
59
|
|
|
60
|
+
If the file has a sidecar manifest (<file>.uploads.json, written by
|
|
61
|
+
"screenshot --out") and its content hash still matches this file, that
|
|
62
|
+
capture's derived metadata (path/url/env/viewport/state) is merged in
|
|
63
|
+
automatically — explicit --meta/--state always win. A regenerated or edited
|
|
64
|
+
file loses its sidecar silently (hash no longer matches).
|
|
65
|
+
|
|
59
66
|
Uploads are public. --pr/--issue keys include the repo, number, and filename and
|
|
60
67
|
remain public even for private/internal GitHub repositories. Upload only media
|
|
61
68
|
that is safe at a predictable public URL.
|
|
@@ -443,13 +450,21 @@ export function commentViaSuffix(via) {
|
|
|
443
450
|
*/
|
|
444
451
|
export class GithubCommentAuthorizationError extends Error {
|
|
445
452
|
}
|
|
446
|
-
|
|
453
|
+
/**
|
|
454
|
+
* `opts.resync` marks an explicit `uploads comment` invocation rather than a
|
|
455
|
+
* background sync (attach, screenshot, put --comment). It costs the server one
|
|
456
|
+
* extra comment listing and in exchange collapses any duplicate managed
|
|
457
|
+
* comment (issue #480) — worth it on the rare, explicitly-asked-for resync,
|
|
458
|
+
* not on every attach.
|
|
459
|
+
*/
|
|
460
|
+
export async function syncAttachmentsComment(client, target, run, workspace, opts = {}) {
|
|
447
461
|
let bot;
|
|
448
462
|
try {
|
|
449
463
|
bot = await client.upsertGithubComment({
|
|
450
464
|
repo: target.repo,
|
|
451
465
|
num: target.num,
|
|
452
466
|
kind: target.kind,
|
|
467
|
+
...(opts.resync ? { resync: true } : {}),
|
|
453
468
|
});
|
|
454
469
|
}
|
|
455
470
|
catch {
|
|
@@ -558,6 +573,12 @@ URL and every embed hot-swap. Human mode prints ">> replaced existing object
|
|
|
558
573
|
Still images are optimized to WebP by default (same as put). Use --no-optimize
|
|
559
574
|
to upload originals. Optional --frame wraps images in device/browser chrome.
|
|
560
575
|
|
|
576
|
+
If a file has a sidecar manifest (<file>.uploads.json, written by
|
|
577
|
+
"screenshot --out") and its content hash still matches, that capture's
|
|
578
|
+
derived metadata (path/url/env/viewport/state) is merged in automatically —
|
|
579
|
+
explicit --meta/--state always win. A regenerated or edited file loses its
|
|
580
|
+
sidecar silently (hash no longer matches).
|
|
581
|
+
|
|
561
582
|
Branch staging (pre-PR): --branch [name] stages files against a git branch
|
|
562
583
|
before a pull request exists, e.g. for a coding agent working a branch that
|
|
563
584
|
hasn't opened a PR yet. Key: gh/<owner>/<repo>/branch/<branch>/<filename>
|
|
@@ -620,6 +641,21 @@ Examples:
|
|
|
620
641
|
uploads attach ./shot.png --branch feature/new-settings
|
|
621
642
|
uploads attach --promote
|
|
622
643
|
`;
|
|
644
|
+
/**
|
|
645
|
+
* Lever 3 (issue #469): a nudge for when an image lands on a PR/issue with
|
|
646
|
+
* no `path` metadata — `path` is one of the highest-value queryable tags
|
|
647
|
+
* (same tier as `state=`), and unlike `uploads screenshot` (which derives it
|
|
648
|
+
* from the captured URL), a plain `attach`/`put --pr`/`put --issue` of an
|
|
649
|
+
* already-existing image has nothing to derive it from, so it's easy to
|
|
650
|
+
* forget. Fires once per batch (not per file) — checks the metadata the
|
|
651
|
+
* server actually stored (`PutResult.metadata`), not what was requested, so
|
|
652
|
+
* a merge/validation drop still surfaces the gap. Non-image uploads (zips,
|
|
653
|
+
* PDFs, etc.) are exempt — "findable by page" doesn't apply to them.
|
|
654
|
+
*/
|
|
655
|
+
export function pathMetaHintFor(uploads) {
|
|
656
|
+
const missingPath = uploads.some((u) => u.contentType.startsWith("image/") && !u.metadata?.path);
|
|
657
|
+
return missingPath ? "tip: add --meta path=/route so this shot is findable by page" : undefined;
|
|
658
|
+
}
|
|
623
659
|
/**
|
|
624
660
|
* Shared prepare + put loop for both PR/issue attach (`uploadAttachments`)
|
|
625
661
|
* and branch-staged attach (`uploadBranchAttachments`) — bounded concurrency,
|
|
@@ -634,11 +670,14 @@ async function uploadAttachmentBatch(opts) {
|
|
|
634
670
|
try {
|
|
635
671
|
const sourceName = basename(file);
|
|
636
672
|
const bytes = readFileArg(file);
|
|
673
|
+
// Sidecar manifest from a prior `screenshot --out` of this exact file
|
|
674
|
+
// (issue #469 lever 2) — see mergeSidecarMeta.
|
|
675
|
+
const baseMetadata = mergeSidecarMeta(file, bytes, opts.metadata);
|
|
637
676
|
// Same EXIF promotion uploadPreparedImage does; attach keeps its own
|
|
638
677
|
// per-file tail (it builds keys differently), so it opts in here too.
|
|
639
678
|
const metadata = opts.deriveImageFacts
|
|
640
|
-
? await mergeImageFacts(bytes,
|
|
641
|
-
:
|
|
679
|
+
? await mergeImageFacts(bytes, baseMetadata)
|
|
680
|
+
: baseMetadata;
|
|
642
681
|
const prepared = await prepareImageForUpload(bytes, sourceName, {
|
|
643
682
|
...opts.frame,
|
|
644
683
|
optimize: opts.optimize,
|
|
@@ -743,7 +782,11 @@ export async function uploadPuts(opts) {
|
|
|
743
782
|
? basename(opts.explicitKey)
|
|
744
783
|
: "stdin.bin"
|
|
745
784
|
: basename(file));
|
|
746
|
-
const
|
|
785
|
+
const bytes = readFileArg(file);
|
|
786
|
+
// Sidecar manifest from a prior `screenshot --out` of this exact file
|
|
787
|
+
// (issue #469 lever 2) — see mergeSidecarMeta. Not applicable to stdin.
|
|
788
|
+
const metadata = file !== "-" ? mergeSidecarMeta(file, bytes, opts.metadata) : opts.metadata;
|
|
789
|
+
const { result, prepared, markdown } = await uploadPreparedImage(opts.client, bytes, sourceName, {
|
|
747
790
|
frame: opts.frame,
|
|
748
791
|
optimize: opts.optimize,
|
|
749
792
|
ghTarget: opts.ghTarget,
|
|
@@ -756,7 +799,7 @@ export async function uploadPuts(opts) {
|
|
|
756
799
|
contentType: opts.contentType,
|
|
757
800
|
dryRun: opts.dryRun,
|
|
758
801
|
replace: opts.replace,
|
|
759
|
-
metadata
|
|
802
|
+
metadata,
|
|
760
803
|
deriveImageFacts: opts.deriveImageFacts,
|
|
761
804
|
provenanceClient: opts.provenanceClient,
|
|
762
805
|
alt: () => opts.alt ?? basename(sourceName),
|
|
@@ -935,6 +978,8 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
935
978
|
process.stderr.write(`warning: uploads succeeded but the GitHub comment failed (is gh installed and authenticated?): ${commentError}\n`);
|
|
936
979
|
}
|
|
937
980
|
}
|
|
981
|
+
// Lever 3 (issue #469): tip when an image lands here with no `path` meta.
|
|
982
|
+
const pathHint = uploads.length > 0 && !ctx.quiet ? pathMetaHintFor(uploads) : undefined;
|
|
938
983
|
if (ctx.json) {
|
|
939
984
|
await writeJson({
|
|
940
985
|
target,
|
|
@@ -943,6 +988,7 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
943
988
|
comment,
|
|
944
989
|
commentError,
|
|
945
990
|
promotion: promotion ?? null,
|
|
991
|
+
...(pathHint ? { hint: pathHint } : {}),
|
|
946
992
|
});
|
|
947
993
|
}
|
|
948
994
|
else {
|
|
@@ -971,6 +1017,8 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
971
1017
|
const ref = ghMetadataFromTarget(target)["gh.ref"];
|
|
972
1018
|
process.stderr.write(`>> find these later: uploads find gh.ref=${ref}\n`);
|
|
973
1019
|
}
|
|
1020
|
+
if (pathHint)
|
|
1021
|
+
process.stderr.write(`${pathHint}\n`);
|
|
974
1022
|
}
|
|
975
1023
|
return failures.length === 0 ? 0 : 1;
|
|
976
1024
|
}
|
|
@@ -1269,6 +1317,18 @@ export function resolvePutStagingTarget(opts) {
|
|
|
1269
1317
|
return undefined; // gh/git unavailable, or repo unresolvable — dated layout
|
|
1270
1318
|
}
|
|
1271
1319
|
}
|
|
1320
|
+
/**
|
|
1321
|
+
* Merges a staging target's `gh.*` branch metadata over `base` and validates
|
|
1322
|
+
* the result (same builder, same contract as `attach --branch`) — the one
|
|
1323
|
+
* merge+validate step shared by every staging call site: `runPut`,
|
|
1324
|
+
* `runScreenshot`, and both the local stdio MCP `put` and `screenshot`
|
|
1325
|
+
* tools.
|
|
1326
|
+
*/
|
|
1327
|
+
export function mergeStagingMeta(base, target) {
|
|
1328
|
+
const merged = { ...base, ...ghMetadataForBranch(target.repo, target.branch) };
|
|
1329
|
+
validateMetaMap(merged);
|
|
1330
|
+
return merged;
|
|
1331
|
+
}
|
|
1272
1332
|
/**
|
|
1273
1333
|
* The bare-put staging note's wording (issue #403): replaces the #393 nudge
|
|
1274
1334
|
* for the (now default) case where a bare put on a non-default branch stages
|
|
@@ -1553,12 +1613,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
1553
1613
|
attachedRef = merged["gh.ref"];
|
|
1554
1614
|
}
|
|
1555
1615
|
else if (stagingTarget) {
|
|
1556
|
-
|
|
1557
|
-
...userMeta,
|
|
1558
|
-
...ghMetadataForBranch(stagingTarget.repo, stagingTarget.branch),
|
|
1559
|
-
};
|
|
1560
|
-
validateMetaMap(merged); // matches attach --branch's unwrapped call — same builder, same contract
|
|
1561
|
-
metadata = merged;
|
|
1616
|
+
metadata = mergeStagingMeta(userMeta, stagingTarget);
|
|
1562
1617
|
}
|
|
1563
1618
|
else {
|
|
1564
1619
|
// gh.* additionally needs git, which the shared derived gate ignores.
|
|
@@ -1648,13 +1703,19 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
1648
1703
|
const bindingWarning = stagingTarget && uploads.length > 0
|
|
1649
1704
|
? await resolveStageBindingWarning({ ctx, defaults, repo: stagingTarget.repo })
|
|
1650
1705
|
: undefined;
|
|
1706
|
+
// Lever 3 (issue #469): tip when a --pr/--issue put lands an image with no
|
|
1707
|
+
// `path` meta. Only relevant on the ghTarget path — the bare-put paths
|
|
1708
|
+
// above (staging/auto/dated) aren't attached to a PR/issue yet, so there's
|
|
1709
|
+
// nothing to look up from a page later.
|
|
1710
|
+
const pathHint = ghTarget && uploads.length > 0 && !ctx.quiet ? pathMetaHintFor(uploads) : undefined;
|
|
1651
1711
|
// One JSON `hint` slot, shared with the #393 nudge (mutually exclusive with
|
|
1652
1712
|
// it — nudge is undefined whenever staging took over). When staging fires,
|
|
1653
1713
|
// prefer the more actionable binding warning over the generic staging note
|
|
1654
1714
|
// (mirrors attach --branch, whose only JSON hint content IS the binding
|
|
1655
1715
|
// warning); stderr prints the nudge/staging-note and binding-warning lines
|
|
1656
|
-
// independently, below.
|
|
1657
|
-
|
|
1716
|
+
// independently, below. pathHint only ever fires on the ghTarget path, so
|
|
1717
|
+
// it never competes with the other three.
|
|
1718
|
+
const jsonHint = nudge ?? bindingWarning ?? stagingNote ?? pathHint;
|
|
1658
1719
|
const galleriesByKey = new Map();
|
|
1659
1720
|
let galleryHadError = false;
|
|
1660
1721
|
if (galleryId && uploads.length > 0) {
|
|
@@ -1737,6 +1798,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
1737
1798
|
process.stderr.write(`${stagingNote}\n`);
|
|
1738
1799
|
if (bindingWarning)
|
|
1739
1800
|
process.stderr.write(`${bindingWarning}\n`);
|
|
1801
|
+
if (pathHint)
|
|
1802
|
+
process.stderr.write(`${pathHint}\n`);
|
|
1740
1803
|
}
|
|
1741
1804
|
return failures.length === 0 && !galleryHadError ? 0 : 1;
|
|
1742
1805
|
}
|
|
@@ -1794,6 +1857,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
1794
1857
|
process.stderr.write(`${stagingNote}\n`);
|
|
1795
1858
|
if (bindingWarning && format !== "json")
|
|
1796
1859
|
process.stderr.write(`${bindingWarning}\n`);
|
|
1860
|
+
if (pathHint && format !== "json")
|
|
1861
|
+
process.stderr.write(`${pathHint}\n`);
|
|
1797
1862
|
return gallery?.error ? 1 : 0;
|
|
1798
1863
|
}
|
|
1799
1864
|
// --- galleries ---
|
|
@@ -2173,12 +2238,51 @@ export async function runMeta(ctx, args, help = false) {
|
|
|
2173
2238
|
else
|
|
2174
2239
|
for (const [k, v] of Object.entries(result.metadata))
|
|
2175
2240
|
await writeStdout(`${k}=${v}\n`);
|
|
2241
|
+
await resyncCommentAfterMetaSet(ctx, key, [...Object.keys(set ?? {}), ...del]);
|
|
2176
2242
|
return 0;
|
|
2177
2243
|
}
|
|
2178
2244
|
default:
|
|
2179
2245
|
throw new UsageError(`unknown meta command: ${action}`);
|
|
2180
2246
|
}
|
|
2181
2247
|
}
|
|
2248
|
+
/** The metadata keys the managed comment renders (path/state, PR #370). */
|
|
2249
|
+
const COMMENT_RENDERED_META_KEYS = ["path", "state"];
|
|
2250
|
+
/**
|
|
2251
|
+
* Best-effort managed-comment refresh after `meta set` touches a
|
|
2252
|
+
* display-relevant key on a PR/issue-keyed object (issue #470) — without
|
|
2253
|
+
* this, backfilled `path=`/`state=` never reaches the rendered comment until
|
|
2254
|
+
* an unrelated attach fires. Bot endpoint only (no gh fallback — this is a
|
|
2255
|
+
* metadata tweak, not an explicit comment command); any failure degrades to
|
|
2256
|
+
* a stderr hint instead of failing the metadata write that already landed.
|
|
2257
|
+
*/
|
|
2258
|
+
async function resyncCommentAfterMetaSet(ctx, key, touchedKeys) {
|
|
2259
|
+
if (!touchedKeys.some((k) => COMMENT_RENDERED_META_KEYS.includes(k)))
|
|
2260
|
+
return;
|
|
2261
|
+
const target = parseGhKey(key);
|
|
2262
|
+
if (!target)
|
|
2263
|
+
return;
|
|
2264
|
+
try {
|
|
2265
|
+
const bot = await ctx.client.upsertGithubComment({
|
|
2266
|
+
repo: target.repo,
|
|
2267
|
+
num: target.num,
|
|
2268
|
+
kind: target.kind,
|
|
2269
|
+
resync: true,
|
|
2270
|
+
});
|
|
2271
|
+
if (bot.posted) {
|
|
2272
|
+
if (!ctx.quiet && !ctx.json) {
|
|
2273
|
+
process.stderr.write(`refreshed the managed comment on ${target.repo}#${target.num}\n`);
|
|
2274
|
+
}
|
|
2275
|
+
return;
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
catch {
|
|
2279
|
+
// Fall through to the hint.
|
|
2280
|
+
}
|
|
2281
|
+
if (!ctx.quiet && !ctx.json) {
|
|
2282
|
+
const flag = target.kind === "pull" ? "--pr" : "--issue";
|
|
2283
|
+
process.stderr.write(`tip: run \`uploads comment ${flag} ${target.num}\` to refresh the PR comment\n`);
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2182
2286
|
// --- delete ---
|
|
2183
2287
|
const DELETE_HELP = `uploads delete <key> [--dry-run] [--workspace <name>]
|
|
2184
2288
|
|
|
@@ -2241,7 +2345,9 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
|
|
|
2241
2345
|
const target = ghTargetFromFlags(parsed.flags, run);
|
|
2242
2346
|
if (!target)
|
|
2243
2347
|
throw new UsageError("comment requires --pr or --issue");
|
|
2244
|
-
const result = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace
|
|
2348
|
+
const result = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace, {
|
|
2349
|
+
resync: true,
|
|
2350
|
+
});
|
|
2245
2351
|
if (ctx.json) {
|
|
2246
2352
|
await writeJson({ ...target, ...result });
|
|
2247
2353
|
}
|
package/dist/github-gh.d.ts
CHANGED
|
@@ -55,7 +55,8 @@ export declare function resolveGhTitle(target: GhTarget, run?: CommandRunner): s
|
|
|
55
55
|
export declare function ghMetadataFromTargetWithTitle(target: GhTarget, run?: CommandRunner): Record<string, string>;
|
|
56
56
|
/**
|
|
57
57
|
* Create the managed attachments comment, or edit it in place if it already
|
|
58
|
-
* exists. Never touches any other comment
|
|
58
|
+
* exists. Never touches any other comment except best-effort deletes of
|
|
59
|
+
* duplicate marker comments (see below). Body is passed via stdin
|
|
59
60
|
* (`-F body=@-`) so it is never shell-interpolated.
|
|
60
61
|
*
|
|
61
62
|
* `marker` identifies which comment to hunt for (see `findManagedComment`);
|
|
@@ -63,6 +64,27 @@ export declare function ghMetadataFromTargetWithTitle(target: GhTarget, run?: Co
|
|
|
63
64
|
* (built via `attachmentsCommentBody(items, galleries, marker)`), so patching
|
|
64
65
|
* an adopted legacy comment migrates it to the namespaced marker in place.
|
|
65
66
|
* Defaults to the shared legacy marker for backward compatibility.
|
|
67
|
+
*
|
|
68
|
+
* Self-healing dedupe (issue #486, mirroring the bot path's #470/#484 fix):
|
|
69
|
+
* a create race (two concurrent `uploads attach` runs, neither finding an
|
|
70
|
+
* existing comment) can leave more than one marker comment on the thread.
|
|
71
|
+
* This path has no id cache, so unlike the bot path a duplicate here never
|
|
72
|
+
* heals on its own — every sync just patches the oldest and leaves the rest
|
|
73
|
+
* stale. After patching (or creating), any extra exact-`marker` hits are
|
|
74
|
+
* deleted best-effort via `gh api -X DELETE`; a failed delete is swallowed
|
|
75
|
+
* and never fails the caller's command, and the next sync retries anyway.
|
|
76
|
+
*
|
|
77
|
+
* On why this duplicates the bot path rather than deferring to it: the gh
|
|
78
|
+
* fallback is a supported path, not a stopgap, so it is held at behavioral
|
|
79
|
+
* parity deliberately. This file already reimplements the hunt, the legacy
|
|
80
|
+
* adoption and the create-vs-patch gate against a different transport (the
|
|
81
|
+
* `gh` subprocess, not the App's token), and #486 existed precisely because
|
|
82
|
+
* the two drifted. Treat any behavior change to `upsertBotComment`
|
|
83
|
+
* (apps/api/src/github-comment.ts) as owing a matching change here. Note
|
|
84
|
+
* this is the one place the CLI deletes a GitHub resource under the
|
|
85
|
+
* invoking human's own credentials — bounded to comments carrying this
|
|
86
|
+
* workspace's exact namespaced marker, whose content is always
|
|
87
|
+
* regenerable.
|
|
66
88
|
*/
|
|
67
89
|
export declare function upsertAttachmentsComment(target: GhTarget, body: string, run?: CommandRunner, marker?: string, opts?: {
|
|
68
90
|
createIfMissing?: boolean;
|
package/dist/github-gh.js
CHANGED
|
@@ -187,7 +187,9 @@ export function ghMetadataFromTargetWithTitle(target, run = execRunner) {
|
|
|
187
187
|
/**
|
|
188
188
|
* PR comments live on the issues endpoint, so one path covers PRs and issues.
|
|
189
189
|
* `--paginate` follows Link headers and merges every page into one array, so the
|
|
190
|
-
* marker comment is found even on threads past 100 comments.
|
|
190
|
+
* marker comment is found even on threads past 100 comments. GitHub returns
|
|
191
|
+
* comments oldest-first, so `hits[0]` (after merging paginated pages, which
|
|
192
|
+
* preserve that order) is the oldest exact-`marker` hit.
|
|
191
193
|
*
|
|
192
194
|
* Hunts for `marker` (the namespaced, per-workspace marker) first; when none
|
|
193
195
|
* is found, falls back to a comment carrying the shared legacy
|
|
@@ -195,6 +197,12 @@ export function ghMetadataFromTargetWithTitle(target, run = execRunner) {
|
|
|
195
197
|
* migrated in place. When `marker` IS the legacy marker (no workspace to
|
|
196
198
|
* namespace with) this collapses to a single hunt, unchanged from pre-4b
|
|
197
199
|
* behavior.
|
|
200
|
+
*
|
|
201
|
+
* Collects EVERY comment carrying `marker` (a create race can leave more
|
|
202
|
+
* than one — issue #486, mirroring the bot path's #470 fix): the oldest is
|
|
203
|
+
* `comment`, the rest come back as `extras` for the caller to delete. Only
|
|
204
|
+
* exact-`marker` hits are ever extras — a legacy (unnamespaced) comment may
|
|
205
|
+
* belong to a different workspace, so it is adopted at most, never deleted.
|
|
198
206
|
*/
|
|
199
207
|
function findManagedComment(target, run, marker) {
|
|
200
208
|
const raw = run("gh", [
|
|
@@ -203,16 +211,25 @@ function findManagedComment(target, run, marker) {
|
|
|
203
211
|
"--paginate",
|
|
204
212
|
]);
|
|
205
213
|
const comments = JSON.parse(raw);
|
|
206
|
-
const
|
|
207
|
-
if (
|
|
208
|
-
|
|
214
|
+
const hits = comments.filter((c) => typeof c.body === "string" && c.body.includes(marker));
|
|
215
|
+
if (hits.length > 0) {
|
|
216
|
+
// In legacy mode (no workspace to namespace with) our "exact" marker IS
|
|
217
|
+
// the shared one, so a second hit is not our own duplicate — it may be
|
|
218
|
+
// another workspace's comment. Adopt the oldest and never delete: the
|
|
219
|
+
// adopt-only contract is about the marker being ambiguous, which is just
|
|
220
|
+
// as true when it is the marker we are hunting on.
|
|
221
|
+
const extras = marker === ATTACHMENTS_MARKER ? undefined : hits.slice(1);
|
|
222
|
+
return { comment: hits[0], extras };
|
|
223
|
+
}
|
|
209
224
|
if (marker === ATTACHMENTS_MARKER)
|
|
210
|
-
return
|
|
211
|
-
|
|
225
|
+
return {};
|
|
226
|
+
const legacyHit = comments.find((c) => typeof c.body === "string" && c.body.includes(ATTACHMENTS_MARKER));
|
|
227
|
+
return { comment: legacyHit };
|
|
212
228
|
}
|
|
213
229
|
/**
|
|
214
230
|
* Create the managed attachments comment, or edit it in place if it already
|
|
215
|
-
* exists. Never touches any other comment
|
|
231
|
+
* exists. Never touches any other comment except best-effort deletes of
|
|
232
|
+
* duplicate marker comments (see below). Body is passed via stdin
|
|
216
233
|
* (`-F body=@-`) so it is never shell-interpolated.
|
|
217
234
|
*
|
|
218
235
|
* `marker` identifies which comment to hunt for (see `findManagedComment`);
|
|
@@ -220,10 +237,41 @@ function findManagedComment(target, run, marker) {
|
|
|
220
237
|
* (built via `attachmentsCommentBody(items, galleries, marker)`), so patching
|
|
221
238
|
* an adopted legacy comment migrates it to the namespaced marker in place.
|
|
222
239
|
* Defaults to the shared legacy marker for backward compatibility.
|
|
240
|
+
*
|
|
241
|
+
* Self-healing dedupe (issue #486, mirroring the bot path's #470/#484 fix):
|
|
242
|
+
* a create race (two concurrent `uploads attach` runs, neither finding an
|
|
243
|
+
* existing comment) can leave more than one marker comment on the thread.
|
|
244
|
+
* This path has no id cache, so unlike the bot path a duplicate here never
|
|
245
|
+
* heals on its own — every sync just patches the oldest and leaves the rest
|
|
246
|
+
* stale. After patching (or creating), any extra exact-`marker` hits are
|
|
247
|
+
* deleted best-effort via `gh api -X DELETE`; a failed delete is swallowed
|
|
248
|
+
* and never fails the caller's command, and the next sync retries anyway.
|
|
249
|
+
*
|
|
250
|
+
* On why this duplicates the bot path rather than deferring to it: the gh
|
|
251
|
+
* fallback is a supported path, not a stopgap, so it is held at behavioral
|
|
252
|
+
* parity deliberately. This file already reimplements the hunt, the legacy
|
|
253
|
+
* adoption and the create-vs-patch gate against a different transport (the
|
|
254
|
+
* `gh` subprocess, not the App's token), and #486 existed precisely because
|
|
255
|
+
* the two drifted. Treat any behavior change to `upsertBotComment`
|
|
256
|
+
* (apps/api/src/github-comment.ts) as owing a matching change here. Note
|
|
257
|
+
* this is the one place the CLI deletes a GitHub resource under the
|
|
258
|
+
* invoking human's own credentials — bounded to comments carrying this
|
|
259
|
+
* workspace's exact namespaced marker, whose content is always
|
|
260
|
+
* regenerable.
|
|
223
261
|
*/
|
|
224
262
|
export function upsertAttachmentsComment(target, body, run = execRunner, marker = ATTACHMENTS_MARKER, opts = {}) {
|
|
225
263
|
const createIfMissing = opts.createIfMissing ?? true;
|
|
226
|
-
const existing = findManagedComment(target, run, marker);
|
|
264
|
+
const { comment: existing, extras } = findManagedComment(target, run, marker);
|
|
265
|
+
const deleteExtras = () => {
|
|
266
|
+
for (const extra of extras ?? []) {
|
|
267
|
+
try {
|
|
268
|
+
run("gh", ["api", `repos/${target.repo}/issues/comments/${extra.id}`, "-X", "DELETE"]);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
// Best effort only — a failed delete must never fail the caller's command.
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
};
|
|
227
275
|
if (existing) {
|
|
228
276
|
run("gh", [
|
|
229
277
|
"api",
|
|
@@ -233,12 +281,15 @@ export function upsertAttachmentsComment(target, body, run = execRunner, marker
|
|
|
233
281
|
"-F",
|
|
234
282
|
"body=@-",
|
|
235
283
|
], body);
|
|
284
|
+
deleteExtras();
|
|
236
285
|
return { action: "updated" };
|
|
237
286
|
}
|
|
238
287
|
// Patch-only (createIfMissing false, i.e. an empty body) with no existing
|
|
239
288
|
// comment: nothing to do — never create one just to say it's empty.
|
|
240
289
|
if (!createIfMissing)
|
|
241
290
|
return { action: "skipped" };
|
|
291
|
+
// No existing marker hit means `extras` is necessarily empty here (see
|
|
292
|
+
// `findManagedComment`) — nothing to delete after a create.
|
|
242
293
|
run("gh", ["api", `repos/${target.repo}/issues/${target.num}/comments`, "-F", "body=@-"], body);
|
|
243
294
|
return { action: "created" };
|
|
244
295
|
}
|
package/dist/github.d.ts
CHANGED
|
@@ -15,6 +15,12 @@ export declare function isValidRepo(repo: string): boolean;
|
|
|
15
15
|
export declare function parseRepoFromRemoteUrl(url: string): string | undefined;
|
|
16
16
|
/** Normalize a GitHub issue or pull-request coordinate for gallery linking. */
|
|
17
17
|
export declare function normalizeGithubCoordinate(value: string): GithubCoordinate | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Inverse of `ghKeyPrefix`: parse the PR/issue coordinate back out of a
|
|
20
|
+
* stable attachment key (`gh/<owner>/<name>/<kind>/<num>/<filename>`), or
|
|
21
|
+
* undefined for any other key shape.
|
|
22
|
+
*/
|
|
23
|
+
export declare function parseGhKey(key: string): GhTarget | undefined;
|
|
18
24
|
export declare function ghKeyPrefix(target: GhTarget): string;
|
|
19
25
|
/**
|
|
20
26
|
* Stable attachment key: same filename → same key → same public URL, so
|
package/dist/github.js
CHANGED
|
@@ -53,6 +53,18 @@ export function normalizeGithubCoordinate(value) {
|
|
|
53
53
|
number,
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Inverse of `ghKeyPrefix`: parse the PR/issue coordinate back out of a
|
|
58
|
+
* stable attachment key (`gh/<owner>/<name>/<kind>/<num>/<filename>`), or
|
|
59
|
+
* undefined for any other key shape.
|
|
60
|
+
*/
|
|
61
|
+
export function parseGhKey(key) {
|
|
62
|
+
const match = /^gh\/([^/]+)\/([^/]+)\/(pull|issues)\/([1-9][0-9]*)\/./.exec(key);
|
|
63
|
+
if (!match)
|
|
64
|
+
return undefined;
|
|
65
|
+
const [, owner, name, kind, num] = match;
|
|
66
|
+
return { repo: `${owner}/${name}`, kind: kind, num: Number(num) };
|
|
67
|
+
}
|
|
56
68
|
export function ghKeyPrefix(target) {
|
|
57
69
|
const [owner, name] = target.repo.split("/");
|
|
58
70
|
return `gh/${sanitizeKeySegment(owner)}/${sanitizeKeySegment(name)}/${target.kind}/${target.num}/`;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type InstallKind = "global" | "workspace" | "npx" | "unknown";
|
|
2
|
+
export type PackageManager = "npm" | "pnpm" | "bun";
|
|
3
|
+
export interface InstallSource {
|
|
4
|
+
kind: InstallKind;
|
|
5
|
+
/** Falls back to npm for every non-global kind. */
|
|
6
|
+
manager: PackageManager;
|
|
7
|
+
/** Upgrades the global install. Only meaningful when kind is "global". */
|
|
8
|
+
upgradeCommand: string[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* @param modulePath Absolute path of a file inside the installed package,
|
|
12
|
+
* normally `realpathSync(fileURLToPath(import.meta.url))`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function detectInstallSource(modulePath: string): InstallSource;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classify where the running CLI was installed from.
|
|
3
|
+
*
|
|
4
|
+
* `uploads update` upgrades the global npm package. That is only safe when the
|
|
5
|
+
* CLI actually came from a global install — upgrading a workspace checkout
|
|
6
|
+
* would overwrite a developer's build with the published version.
|
|
7
|
+
*
|
|
8
|
+
* Pure and path-only: no filesystem or process access, so it is fully testable.
|
|
9
|
+
*/
|
|
10
|
+
import { PACKAGE_NAME } from "./update-check.js";
|
|
11
|
+
const UPGRADE_COMMANDS = {
|
|
12
|
+
npm: ["npm", "install", "-g", `${PACKAGE_NAME}@latest`],
|
|
13
|
+
pnpm: ["pnpm", "add", "-g", `${PACKAGE_NAME}@latest`],
|
|
14
|
+
bun: ["bun", "add", "-g", `${PACKAGE_NAME}@latest`],
|
|
15
|
+
};
|
|
16
|
+
function classify(path) {
|
|
17
|
+
// npx is checked first: a cache entry can also contain a global-looking marker.
|
|
18
|
+
if (path.includes("/_npx/"))
|
|
19
|
+
return { kind: "npx", manager: "npm" };
|
|
20
|
+
if (path.includes("/.bun/install/global/"))
|
|
21
|
+
return { kind: "global", manager: "bun" };
|
|
22
|
+
if (path.includes("/pnpm/global/"))
|
|
23
|
+
return { kind: "global", manager: "pnpm" };
|
|
24
|
+
if (path.includes("/lib/node_modules/"))
|
|
25
|
+
return { kind: "global", manager: "npm" };
|
|
26
|
+
// Windows npm globals have no `lib` segment: `<prefix>\npm\node_modules\<pkg>`.
|
|
27
|
+
if (path.includes("/npm/node_modules/"))
|
|
28
|
+
return { kind: "global", manager: "npm" };
|
|
29
|
+
// No node_modules segment at all means we are running out of a source checkout.
|
|
30
|
+
if (!path.includes("/node_modules/"))
|
|
31
|
+
return { kind: "workspace", manager: "npm" };
|
|
32
|
+
return { kind: "unknown", manager: "npm" };
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* @param modulePath Absolute path of a file inside the installed package,
|
|
36
|
+
* normally `realpathSync(fileURLToPath(import.meta.url))`.
|
|
37
|
+
*/
|
|
38
|
+
export function detectInstallSource(modulePath) {
|
|
39
|
+
const normalized = modulePath.split("\\").join("/");
|
|
40
|
+
const { kind, manager } = classify(normalized);
|
|
41
|
+
return { kind, manager, upgradeCommand: UPGRADE_COMMANDS[manager] };
|
|
42
|
+
}
|
package/dist/mcp/tools.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createUploadsClient } from "../client.js";
|
|
2
|
-
import { buildDoctorReport, makeGhTarget, resolvePutStagingTarget, resolveStaged, syncAttachmentsComment, uploadAttachments, uploadPreparedImage, uploadPuts, } from "../commands.js";
|
|
2
|
+
import { buildDoctorReport, makeGhTarget, mergeStagingMeta, resolvePutStagingTarget, resolveStaged, syncAttachmentsComment, uploadAttachments, uploadPreparedImage, uploadPuts, } from "../commands.js";
|
|
3
3
|
import { resolveFrameId } from "../frame.js";
|
|
4
4
|
import { resolveConfig, resolvePutDefaults, } from "../config.js";
|
|
5
5
|
import { resolvePutPrefix } from "../destinations.js";
|
|
6
|
-
import {
|
|
6
|
+
import { ghBranchAttachmentKey, ghKeyPrefix } from "../github.js";
|
|
7
7
|
import { safeCaptureFacts } from "../capture-facts.js";
|
|
8
8
|
import { validateMetaMap } from "../metadata.js";
|
|
9
9
|
import { mergeDerivedMeta } from "../metadata-vocab.js";
|
|
@@ -430,16 +430,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
430
430
|
repoArg: optString(args, "repo") ?? defaults.repo,
|
|
431
431
|
run,
|
|
432
432
|
});
|
|
433
|
-
const putMetadata = stagingTarget
|
|
434
|
-
? (() => {
|
|
435
|
-
const merged = {
|
|
436
|
-
...metadata,
|
|
437
|
-
...ghMetadataForBranch(stagingTarget.repo, stagingTarget.branch),
|
|
438
|
-
};
|
|
439
|
-
validateMetaMap(merged); // same builder, same contract as attach --branch
|
|
440
|
-
return merged;
|
|
441
|
-
})()
|
|
442
|
-
: metadata;
|
|
433
|
+
const putMetadata = stagingTarget ? mergeStagingMeta(metadata, stagingTarget) : metadata;
|
|
443
434
|
const putShared = {
|
|
444
435
|
client,
|
|
445
436
|
ghTarget: target,
|
|
@@ -695,25 +686,41 @@ export function createUploadsMcpTools(opts) {
|
|
|
695
686
|
const metadata = metadataArgWithCanonical(args);
|
|
696
687
|
if (metadata)
|
|
697
688
|
validateMetaMap(metadata);
|
|
689
|
+
const { config, client } = clientFor(args);
|
|
690
|
+
const defaults = resolvePutDefaults({ envFile: globals.envFile });
|
|
691
|
+
const frameOpts = mcpFrameOptions(args);
|
|
692
|
+
const optimizeOpts = mcpOptimizeOptions(args, defaults);
|
|
693
|
+
const noGit = optBool(args, "noGit") || defaults.noGit === true;
|
|
694
|
+
const alt = optString(args, "alt");
|
|
695
|
+
const width = optPosInt(args, "width") ?? defaults.width;
|
|
696
|
+
// Auto branch staging (issue #469 lever 1): mirrors the CLI screenshot
|
|
697
|
+
// command and the put tool above (issue #403) — no pr/issue/key/ref/
|
|
698
|
+
// prefix/destination, not noGit, on a non-default git branch stages
|
|
699
|
+
// to the branch prefix (identical key/metadata to `attach --branch`)
|
|
700
|
+
// instead of the dated `screenshots/<repo>/<date>/...` layout. Never
|
|
701
|
+
// throws — see resolvePutStagingTarget.
|
|
702
|
+
const stagingTarget = resolvePutStagingTarget({
|
|
703
|
+
ghTarget: target,
|
|
704
|
+
keyHint: keyArg,
|
|
705
|
+
refArg,
|
|
706
|
+
prefixArg,
|
|
707
|
+
destinationArg: destArg,
|
|
708
|
+
noGit,
|
|
709
|
+
repoArg: optString(args, "repo") ?? defaults.repo,
|
|
710
|
+
run,
|
|
711
|
+
});
|
|
698
712
|
let resolvedPrefix;
|
|
699
713
|
try {
|
|
700
714
|
resolvedPrefix = resolvePutPrefix({
|
|
701
715
|
destination: destArg,
|
|
702
716
|
prefix: prefixArg,
|
|
703
717
|
key: keyArg,
|
|
704
|
-
ghAttachment: Boolean(target),
|
|
718
|
+
ghAttachment: Boolean(target) || stagingTarget !== undefined,
|
|
705
719
|
});
|
|
706
720
|
}
|
|
707
721
|
catch (err) {
|
|
708
722
|
usage(err instanceof Error ? err.message : String(err));
|
|
709
723
|
}
|
|
710
|
-
const { config, client } = clientFor(args);
|
|
711
|
-
const defaults = resolvePutDefaults({ envFile: globals.envFile });
|
|
712
|
-
const frameOpts = mcpFrameOptions(args);
|
|
713
|
-
const optimizeOpts = mcpOptimizeOptions(args, defaults);
|
|
714
|
-
const noGit = optBool(args, "noGit") || defaults.noGit === true;
|
|
715
|
-
const alt = optString(args, "alt");
|
|
716
|
-
const width = optPosInt(args, "width") ?? defaults.width;
|
|
717
724
|
// Dynamic import only: keeps mcp/tools.ts (and therefore anything
|
|
718
725
|
// that statically imports it) free of a static reference to the
|
|
719
726
|
// local-backend chain. If this fails, the runtime can't do Node-side
|
|
@@ -730,9 +737,15 @@ export function createUploadsMcpTools(opts) {
|
|
|
730
737
|
// Keep undefined when nothing at all was supplied or derived, so the
|
|
731
738
|
// "omit to leave stored metadata untouched" contract still holds.
|
|
732
739
|
const captureDerived = safeCaptureFacts(targetArg, viewport, colorSchemeArg);
|
|
733
|
-
const
|
|
740
|
+
const metadataBase = metadata === undefined && Object.keys(captureDerived).length === 0
|
|
734
741
|
? undefined
|
|
735
742
|
: mergeDerivedMeta(metadata ?? {}, captureDerived);
|
|
743
|
+
// gh.* metadata: explicit pr/issue target wins; staging wins the same
|
|
744
|
+
// way (matches attach --branch/bare put); otherwise capture-derived +
|
|
745
|
+
// explicit only.
|
|
746
|
+
const metadataWithCaptureFacts = stagingTarget
|
|
747
|
+
? mergeStagingMeta(metadataBase, stagingTarget)
|
|
748
|
+
: metadataBase;
|
|
736
749
|
let captured;
|
|
737
750
|
try {
|
|
738
751
|
captured = await screenshotModule.captureScreenshot({
|
|
@@ -761,11 +774,14 @@ export function createUploadsMcpTools(opts) {
|
|
|
761
774
|
}
|
|
762
775
|
throw err;
|
|
763
776
|
}
|
|
777
|
+
const branchKey = stagingTarget
|
|
778
|
+
? ghBranchAttachmentKey(stagingTarget.repo, stagingTarget.branch, captured.filename)
|
|
779
|
+
: undefined;
|
|
764
780
|
const { result, prepared, markdown } = await uploadPreparedImage(client, captured.png, captured.filename, {
|
|
765
781
|
frame: frameOpts,
|
|
766
782
|
optimize: optimizeOpts,
|
|
767
783
|
ghTarget: target,
|
|
768
|
-
key: keyArg,
|
|
784
|
+
key: keyArg ?? branchKey,
|
|
769
785
|
prefix: resolvedPrefix ?? defaults.prefix,
|
|
770
786
|
repo: optString(args, "repo") ?? defaults.repo,
|
|
771
787
|
ref: refArg ?? defaults.ref,
|
|
@@ -1142,7 +1158,10 @@ export function createUploadsMcpTools(opts) {
|
|
|
1142
1158
|
if (!target)
|
|
1143
1159
|
usage("comment requires pr or issue");
|
|
1144
1160
|
const { config, client } = clientFor(args);
|
|
1145
|
-
|
|
1161
|
+
// Explicit resync, same as `uploads comment` (issue #480).
|
|
1162
|
+
const result = await syncAttachmentsComment(client, target, run, config.workspace, {
|
|
1163
|
+
resync: true,
|
|
1164
|
+
});
|
|
1146
1165
|
return { ...target, ...result };
|
|
1147
1166
|
},
|
|
1148
1167
|
},
|