@buildinternet/uploads 0.37.3 → 0.40.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 +5 -0
- package/dist/cli.js +6 -2
- package/dist/client.d.ts +38 -5
- package/dist/client.js +30 -9
- package/dist/commands/screenshot.js +30 -4
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +77 -6
- package/dist/comment-config.d.ts +3 -0
- package/dist/comment-config.js +13 -0
- package/dist/config-file.d.ts +3 -1
- package/dist/keys.d.ts +6 -0
- package/dist/keys.js +17 -0
- package/dist/mcp/args.d.ts +1 -1
- package/dist/mcp/tools.js +30 -3
- package/dist/metadata-vocab.d.ts +3 -1
- package/dist/metadata-vocab.js +5 -0
- package/dist/screenshot.d.ts +28 -0
- package/dist/screenshot.js +31 -1
- package/package.json +4 -4
package/dist/cli-catalog.js
CHANGED
|
@@ -166,6 +166,11 @@ export const ROOT_COMMANDS = [
|
|
|
166
166
|
{ name: "doctor", summary: "Check the GitHub App's webhook event subscriptions" },
|
|
167
167
|
],
|
|
168
168
|
},
|
|
169
|
+
{
|
|
170
|
+
name: "ingest",
|
|
171
|
+
usage: "ingest (--pr <n> | --issue <n>)",
|
|
172
|
+
summary: "Mirror GitHub-native user-attachments media from a PR/issue into the workspace",
|
|
173
|
+
},
|
|
169
174
|
{
|
|
170
175
|
name: "list",
|
|
171
176
|
summary: "List objects (--meta k=v filters by queryable metadata)",
|
package/dist/cli.js
CHANGED
|
@@ -6,7 +6,7 @@ import { formatRootHelp, formatUnknownCommand, wantsFullHelp } from "./cli-help.
|
|
|
6
6
|
import { commandSummary, suggestCommand } from "./cli-suggest.js";
|
|
7
7
|
import { writeJson } from "./io.js";
|
|
8
8
|
import { colorEnabled, createStyle } from "./cli-style.js";
|
|
9
|
-
import { runPut, runAttach, runStaged, runList, runFind, runMeta, runDelete, runHealth, runDoctor, runComment, runGithub, runUsage, runReconcile, runPurgeExpired, runGallery, } from "./commands.js";
|
|
9
|
+
import { runPut, runAttach, runStaged, runList, runFind, runMeta, runDelete, runHealth, runDoctor, runComment, runGithub, runIngest, runUsage, runReconcile, runPurgeExpired, runGallery, } from "./commands.js";
|
|
10
10
|
import { runConfig } from "./commands/config.js";
|
|
11
11
|
import { runSetup } from "./commands/setup.js";
|
|
12
12
|
import { runLogin } from "./commands/login.js";
|
|
@@ -308,7 +308,8 @@ export async function runCli(argv) {
|
|
|
308
308
|
case "purge-expired":
|
|
309
309
|
case "doctor":
|
|
310
310
|
case "comment":
|
|
311
|
-
case "github":
|
|
311
|
+
case "github":
|
|
312
|
+
case "ingest": {
|
|
312
313
|
const ctx = createContext(parsed.globals, !showHelp, cmdArgs);
|
|
313
314
|
switch (parsed.command) {
|
|
314
315
|
case "attach":
|
|
@@ -332,6 +333,9 @@ export async function runCli(argv) {
|
|
|
332
333
|
case "github":
|
|
333
334
|
code = await runGithub(ctx, cmdArgs, showHelp);
|
|
334
335
|
break;
|
|
336
|
+
case "ingest":
|
|
337
|
+
code = await runIngest(ctx, cmdArgs, showHelp);
|
|
338
|
+
break;
|
|
335
339
|
case "list":
|
|
336
340
|
code = await runList(ctx, cmdArgs, showHelp);
|
|
337
341
|
break;
|
package/dist/client.d.ts
CHANGED
|
@@ -256,7 +256,7 @@ export type GithubCommentResult = {
|
|
|
256
256
|
fixUrl?: string;
|
|
257
257
|
required?: string[];
|
|
258
258
|
};
|
|
259
|
-
/** `POST /v1/:workspace/github/promote` request/response (server contract, PR #310). */
|
|
259
|
+
/** `POST /v1/workspaces/:workspace/github/promote` request/response (server contract, PR #310). */
|
|
260
260
|
export interface PromoteBranchAttachmentsOptions {
|
|
261
261
|
repo: string;
|
|
262
262
|
num: number;
|
|
@@ -270,7 +270,7 @@ export interface PromoteBranchAttachmentsResult {
|
|
|
270
270
|
promoted: string[];
|
|
271
271
|
skipped: PromoteSkip[];
|
|
272
272
|
}
|
|
273
|
-
/** `GET`/`POST /v1/:workspace/github/link` result (server contract, phase 4b). */
|
|
273
|
+
/** `GET`/`POST /v1/workspaces/:workspace/github/link` result (server contract, phase 4b). */
|
|
274
274
|
export interface GithubLinkResult {
|
|
275
275
|
repo: string;
|
|
276
276
|
linked: boolean;
|
|
@@ -289,14 +289,14 @@ export interface GithubLinkClaimResult extends GithubLinkResult {
|
|
|
289
289
|
*/
|
|
290
290
|
reason?: "not_authorized";
|
|
291
291
|
}
|
|
292
|
-
/** `DELETE /v1/:workspace/github/link` result (issue #318, self-serve unlink). */
|
|
292
|
+
/** `DELETE /v1/workspaces/:workspace/github/link` result (issue #318, self-serve unlink). */
|
|
293
293
|
export interface GithubLinkUnlinkResult {
|
|
294
294
|
repo: string;
|
|
295
295
|
unlinked: boolean;
|
|
296
296
|
reason?: "not_linked";
|
|
297
297
|
}
|
|
298
298
|
/**
|
|
299
|
-
* `GET /v1/:workspace/github/repo-link` result (issue #398). Deliberately
|
|
299
|
+
* `GET /v1/workspaces/:workspace/github/repo-link` result (issue #398). Deliberately
|
|
300
300
|
* minimal relative to `GithubLinkResult`: never names the owning workspace
|
|
301
301
|
* when it isn't this one — "self"/"other"/"none" is all the stage-time
|
|
302
302
|
* warning needs, and anything richer would leak cross-tenant info to a
|
|
@@ -305,10 +305,31 @@ export interface GithubLinkUnlinkResult {
|
|
|
305
305
|
export interface GithubRepoLinkResult {
|
|
306
306
|
binding: "self" | "other" | "none";
|
|
307
307
|
}
|
|
308
|
+
/**
|
|
309
|
+
* `POST /v1/workspaces/:workspace/github/ingest` result (Task 6, manual/
|
|
310
|
+
* backfill entry point for GitHub-native `user-attachments` media). `repo`
|
|
311
|
+
* comes back lowercased by the server.
|
|
312
|
+
*/
|
|
313
|
+
export interface IngestGithubResult {
|
|
314
|
+
repo: string;
|
|
315
|
+
kind: "pull" | "issues";
|
|
316
|
+
num: number;
|
|
317
|
+
/** Object keys newly mirrored into the workspace this call. */
|
|
318
|
+
ingested: string[];
|
|
319
|
+
/** Object keys whose previously-detached ledger row was un-detached. */
|
|
320
|
+
reattached: string[];
|
|
321
|
+
/** Object keys detached because they're no longer referenced. */
|
|
322
|
+
detached: string[];
|
|
323
|
+
/** Attachment URLs skipped, with the reason. */
|
|
324
|
+
skipped: {
|
|
325
|
+
url: string;
|
|
326
|
+
reason: string;
|
|
327
|
+
}[];
|
|
328
|
+
}
|
|
308
329
|
export interface HealthResult {
|
|
309
330
|
ok: boolean;
|
|
310
331
|
}
|
|
311
|
-
/** `GET /v1/:workspace/github/health` result (issue #293 follow-up). */
|
|
332
|
+
/** `GET /v1/workspaces/:workspace/github/health` result (issue #293 follow-up). */
|
|
312
333
|
export interface GithubHealthResult {
|
|
313
334
|
configured: boolean;
|
|
314
335
|
ok: boolean;
|
|
@@ -617,6 +638,18 @@ export declare function createUploadsClient(config: UploadsClientConfig): {
|
|
|
617
638
|
* `UploadsError` (status 404) on an older/self-hosted server without
|
|
618
639
|
* this route — callers treat that as "unknown", not "broken".
|
|
619
640
|
*/
|
|
641
|
+
/**
|
|
642
|
+
* `POST /v1/workspaces/:workspace/github/ingest` (Task 6) — manual/
|
|
643
|
+
* backfill mirror of a PR/issue's `github.com/user-attachments` media
|
|
644
|
+
* into the workspace. Only ever had the canonical `/v1/workspaces`
|
|
645
|
+
* route (no old bearer-only alias) — unlike the other `github/*` client
|
|
646
|
+
* methods above, which moved onto it in issue #613.
|
|
647
|
+
*/
|
|
648
|
+
ingestGithub(input: {
|
|
649
|
+
repo: string;
|
|
650
|
+
kind: "pull" | "issues";
|
|
651
|
+
num: number;
|
|
652
|
+
}): Promise<IngestGithubResult>;
|
|
620
653
|
githubHealth(): Promise<GithubHealthResult>;
|
|
621
654
|
/**
|
|
622
655
|
* Self-serve unlink (issue #318): removes `repo`'s binding, but only if
|
package/dist/client.js
CHANGED
|
@@ -186,14 +186,19 @@ export function mintWorkspaceToken(apiUrl, accessToken, input) {
|
|
|
186
186
|
function encodeKeyPath(key) {
|
|
187
187
|
return key.split("/").map(encodeURIComponent).join("/");
|
|
188
188
|
}
|
|
189
|
+
// Stays on the legacy `/v1/:workspace/files` wildcard for now (issue #613):
|
|
190
|
+
// the canonical files vertical has no upload PUT, POST /sign, or GET/PATCH
|
|
191
|
+
// /:key metadata yet, and its list/search adopted the session response shape
|
|
192
|
+
// rather than the bearer one. Move this once the canonical vertical grows
|
|
193
|
+
// those routes and the bearer list shape is reconciled.
|
|
189
194
|
function filesBase(config) {
|
|
190
195
|
return `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/files`;
|
|
191
196
|
}
|
|
192
197
|
function usageBase(config) {
|
|
193
|
-
return `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/usage`;
|
|
198
|
+
return `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/usage`;
|
|
194
199
|
}
|
|
195
200
|
function galleriesBase(config) {
|
|
196
|
-
return `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/galleries`;
|
|
201
|
+
return `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/galleries`;
|
|
197
202
|
}
|
|
198
203
|
function mapApiError(status, error, code, requiredScope, existingUrl) {
|
|
199
204
|
const normalized = error.toLowerCase();
|
|
@@ -499,7 +504,7 @@ export function createUploadsClient(config) {
|
|
|
499
504
|
* the field.
|
|
500
505
|
*/
|
|
501
506
|
async upsertGithubComment(opts) {
|
|
502
|
-
return request("POST", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/comment`, {
|
|
507
|
+
return request("POST", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/comment`, {
|
|
503
508
|
body: new TextEncoder().encode(JSON.stringify(opts)),
|
|
504
509
|
headers: { "Content-Type": "application/json" },
|
|
505
510
|
});
|
|
@@ -511,7 +516,7 @@ export function createUploadsClient(config) {
|
|
|
511
516
|
* that doesn't have this route yet, as "nothing promoted").
|
|
512
517
|
*/
|
|
513
518
|
async promoteBranchAttachments(opts) {
|
|
514
|
-
return request("POST", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/promote`, {
|
|
519
|
+
return request("POST", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/promote`, {
|
|
515
520
|
body: new TextEncoder().encode(JSON.stringify(opts)),
|
|
516
521
|
headers: { "Content-Type": "application/json" },
|
|
517
522
|
});
|
|
@@ -520,7 +525,7 @@ export function createUploadsClient(config) {
|
|
|
520
525
|
* `UploadsError` (status 404) on an older/self-hosted server without this
|
|
521
526
|
* route — callers treat that as "bindings unsupported". */
|
|
522
527
|
async githubLinkStatus(repo) {
|
|
523
|
-
return request("GET", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/link?repo=${encodeURIComponent(repo)}`);
|
|
528
|
+
return request("GET", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/link?repo=${encodeURIComponent(repo)}`);
|
|
524
529
|
},
|
|
525
530
|
/**
|
|
526
531
|
* Explicitly claim `repo` for this workspace (first-claim-wins — see
|
|
@@ -530,7 +535,7 @@ export function createUploadsClient(config) {
|
|
|
530
535
|
* server without this route.
|
|
531
536
|
*/
|
|
532
537
|
async githubLinkClaim(repo) {
|
|
533
|
-
return request("POST", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/link`, {
|
|
538
|
+
return request("POST", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/link`, {
|
|
534
539
|
body: new TextEncoder().encode(JSON.stringify({ repo })),
|
|
535
540
|
headers: { "Content-Type": "application/json" },
|
|
536
541
|
});
|
|
@@ -543,15 +548,31 @@ export function createUploadsClient(config) {
|
|
|
543
548
|
* caller (the stage warning) treats ANY failure here as "stay silent".
|
|
544
549
|
*/
|
|
545
550
|
async githubRepoLinkStatus(repo) {
|
|
546
|
-
return request("GET", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/repo-link?repo=${encodeURIComponent(repo)}`);
|
|
551
|
+
return request("GET", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/repo-link?repo=${encodeURIComponent(repo)}`);
|
|
547
552
|
},
|
|
548
553
|
/**
|
|
549
554
|
* GitHub App configuration + webhook event subscription check. Throws
|
|
550
555
|
* `UploadsError` (status 404) on an older/self-hosted server without
|
|
551
556
|
* this route — callers treat that as "unknown", not "broken".
|
|
552
557
|
*/
|
|
558
|
+
/**
|
|
559
|
+
* `POST /v1/workspaces/:workspace/github/ingest` (Task 6) — manual/
|
|
560
|
+
* backfill mirror of a PR/issue's `github.com/user-attachments` media
|
|
561
|
+
* into the workspace. Only ever had the canonical `/v1/workspaces`
|
|
562
|
+
* route (no old bearer-only alias) — unlike the other `github/*` client
|
|
563
|
+
* methods above, which moved onto it in issue #613.
|
|
564
|
+
*/
|
|
565
|
+
async ingestGithub(input) {
|
|
566
|
+
const body = input.kind === "pull"
|
|
567
|
+
? { repo: input.repo, pr: input.num }
|
|
568
|
+
: { repo: input.repo, issue: input.num };
|
|
569
|
+
return request("POST", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/ingest`, {
|
|
570
|
+
body: new TextEncoder().encode(JSON.stringify(body)),
|
|
571
|
+
headers: { "Content-Type": "application/json" },
|
|
572
|
+
});
|
|
573
|
+
},
|
|
553
574
|
async githubHealth() {
|
|
554
|
-
return request("GET", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/health`);
|
|
575
|
+
return request("GET", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/health`);
|
|
555
576
|
},
|
|
556
577
|
/**
|
|
557
578
|
* Self-serve unlink (issue #318): removes `repo`'s binding, but only if
|
|
@@ -561,7 +582,7 @@ export function createUploadsClient(config) {
|
|
|
561
582
|
* older/self-hosted server without this route.
|
|
562
583
|
*/
|
|
563
584
|
async githubLinkUnlink(repo) {
|
|
564
|
-
return request("DELETE", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/link?repo=${encodeURIComponent(repo)}`);
|
|
585
|
+
return request("DELETE", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/link?repo=${encodeURIComponent(repo)}`);
|
|
565
586
|
},
|
|
566
587
|
async health() {
|
|
567
588
|
return request("GET", `${config.apiUrl}/health`, { auth: false });
|
|
@@ -2,12 +2,13 @@ import { readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { basename } from "node:path";
|
|
3
3
|
import { extractDashValue, flagBool, flagInt, flagString, flagValues, parseCommandArgs, UsageError, } from "../cli-args.js";
|
|
4
4
|
import { writeCommandHelp } from "../cli-style.js";
|
|
5
|
-
import { branchFromFlags, derivedMetaEnabled, frameOptionsFromFlags, ghTargetFromFlags, optimizeOptionsFromFlags, stateAppMetaFromFlags, warnNearMissMeta, syncAttachmentsComment, commentViaSuffix, uploadPreparedImage, resolvePutStagingTarget, putStagingNoteText, resolveStageBindingWarning, mergeStagingMeta, } from "../commands.js";
|
|
5
|
+
import { branchFromFlags, derivedMetaEnabled, frameOptionsFromFlags, ghTargetFromFlags, optimizeOptionsFromFlags, stateAppMetaFromFlags, warnNearMissMeta, syncAttachmentsComment, commentViaSuffix, uploadPreparedImage, resolvePutStagingTarget, putStagingNoteText, resolveStageBindingWarning, mergeStagingMeta, writeReplacedNote, } from "../commands.js";
|
|
6
6
|
import { resolvePutDefaults } from "../config.js";
|
|
7
7
|
import { loadDefaultsRaw, resolveScreenshotDefaults } from "../config-file.js";
|
|
8
8
|
import { resolvePutPrefix } from "../destinations.js";
|
|
9
9
|
import { execRunner, ghMetadataFromTargetWithTitle, resolveRepo, } from "../github-gh.js";
|
|
10
10
|
import { ghBranchAttachmentKey } from "../github.js";
|
|
11
|
+
import { deriveRepoSlugFromGit } from "../keys.js";
|
|
11
12
|
import { safeCaptureFacts } from "../capture-facts.js";
|
|
12
13
|
import { parseMetaFlags, validateMetaMap } from "../metadata.js";
|
|
13
14
|
import { mergeDerivedMeta } from "../metadata-vocab.js";
|
|
@@ -31,6 +32,15 @@ localhost/private-network URLs are reachable only by the
|
|
|
31
32
|
local backend — with --via remote (or auto falling back to remote) these
|
|
32
33
|
fail fast with a clear error instead of sending a doomed request.
|
|
33
34
|
|
|
35
|
+
The object name is derived from the target URL (host + path), not chosen by
|
|
36
|
+
you — e.g. https://app.example/settings becomes app.example-settings.png.
|
|
37
|
+
--state folds into that derived name (a -before/-after/... suffix), so
|
|
38
|
+
capturing the same URL with --state before then --state after produces two
|
|
39
|
+
distinct objects instead of the second silently overwriting the first.
|
|
40
|
+
Re-capturing the same URL + --state replaces that object in place — the
|
|
41
|
+
intended idempotency for repeat captures. --key bypasses all of this and
|
|
42
|
+
sets the whole object key verbatim (no folding).
|
|
43
|
+
|
|
34
44
|
After capture, screenshots share the put upload pipeline: optional --frame,
|
|
35
45
|
optimize-by-default, --pr/--issue attachment + --comment, --gallery, --meta.
|
|
36
46
|
|
|
@@ -351,7 +361,11 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
|
|
|
351
361
|
// Explicit input (--meta plus the dedicated flags) wins over capture facts.
|
|
352
362
|
const explicitMeta = { ...metaExtras, ...stateAppMetaFromFlags(parsed.flags) };
|
|
353
363
|
const deriveMeta = derivedMetaEnabled(parsed.flags, putDefaults);
|
|
354
|
-
const
|
|
364
|
+
const repoSlug = deriveMeta && !noGit ? deriveRepoSlugFromGit(run) : undefined;
|
|
365
|
+
const withFacts = mergeDerivedMeta(explicitMeta, {
|
|
366
|
+
...(deriveMeta ? safeCaptureFacts(target, viewport, colorScheme) : {}),
|
|
367
|
+
...(repoSlug ? { repo: repoSlug } : {}),
|
|
368
|
+
});
|
|
355
369
|
let metadata = withFacts;
|
|
356
370
|
if (ghTarget) {
|
|
357
371
|
metadata = { ...withFacts, ...ghMetadataFromTargetWithTitle(ghTarget, run) };
|
|
@@ -381,6 +395,9 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
|
|
|
381
395
|
reducedMotion,
|
|
382
396
|
evalJs,
|
|
383
397
|
initScript,
|
|
398
|
+
// Skip folding when an explicit --key was given — --key sets the whole
|
|
399
|
+
// key, so there's no auto-derived name to fold state into.
|
|
400
|
+
state: keyHint ? undefined : explicitMeta.state,
|
|
384
401
|
measureSelectors: annotateSelectors.length > 0 ? annotateSelectors : undefined,
|
|
385
402
|
apiUrl: ctx.config.apiUrl,
|
|
386
403
|
token: ctx.config.token,
|
|
@@ -497,6 +514,7 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
|
|
|
497
514
|
if (prepared.optimized) {
|
|
498
515
|
process.stderr.write(`>> optimized ${prepared.originalBytes} → ${prepared.outputBytes} bytes\n`);
|
|
499
516
|
}
|
|
517
|
+
writeReplacedNote(result.replaced, ctx.quiet, dryRun, result.wouldRefuse);
|
|
500
518
|
process.stderr.write(`>> key: ${result.key}${dryRun ? " (dry run — not uploaded)" : ""}\n`);
|
|
501
519
|
if (stagingTarget !== undefined) {
|
|
502
520
|
process.stderr.write(`>> find these later: uploads find gh.branch=${stagingTarget.branch.toLowerCase()}\n`);
|
|
@@ -514,8 +532,16 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
|
|
|
514
532
|
process.stderr.write("\n");
|
|
515
533
|
}
|
|
516
534
|
// One JSON `hint` slot (mirrors bare put): the binding warning is more
|
|
517
|
-
// actionable than the generic staging note, so it wins when both fire
|
|
518
|
-
|
|
535
|
+
// actionable than the generic staging note, so it wins when both fire; a
|
|
536
|
+
// replaced-object note (issue #618) is the lowest priority of the three —
|
|
537
|
+
// it only surfaces when nothing else already claimed the slot. Since state
|
|
538
|
+
// folds into the derived key, replaced + state means a same-side re-capture,
|
|
539
|
+
// which is the intended replace-in-place flow — word it as informational,
|
|
540
|
+
// not as a problem.
|
|
541
|
+
const replacedHint = result.replaced && explicitMeta.state
|
|
542
|
+
? `re-capture replaced the previous state=${explicitMeta.state} object at ${result.key} — expected for repeat captures of the same URL + state`
|
|
543
|
+
: undefined;
|
|
544
|
+
const jsonHint = bindingWarning ?? stagingNote ?? replacedHint;
|
|
519
545
|
switch (format) {
|
|
520
546
|
case "json":
|
|
521
547
|
await writeJson({
|
package/dist/commands.d.ts
CHANGED
|
@@ -63,6 +63,7 @@ export declare function warnNearMissMeta(ctx: CliContext, meta: Record<string, s
|
|
|
63
63
|
* point is `--help` discoverability and `--state` validation.
|
|
64
64
|
*/
|
|
65
65
|
export declare function stateAppMetaFromFlags(flags: CommandFlags["flags"]): Record<string, string>;
|
|
66
|
+
export declare function writeReplacedNote(replaced: boolean | undefined, quiet: boolean, dryRun?: boolean, wouldRefuse?: boolean): void;
|
|
66
67
|
export type PreparedUpload = OptimizeImageResult & {
|
|
67
68
|
frame?: Pick<FrameResult, "framed" | "frameId" | "skippedReason">;
|
|
68
69
|
};
|
|
@@ -456,6 +457,7 @@ export declare function runFind(ctx: CliContext, args: string[], help?: boolean)
|
|
|
456
457
|
export declare function runMeta(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
|
|
457
458
|
export declare function runDelete(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
|
|
458
459
|
export declare function runComment(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
|
|
460
|
+
export declare function runIngest(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
|
|
459
461
|
export declare function runGithub(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
|
|
460
462
|
export declare function runUsage(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
|
|
461
463
|
export declare function runReconcile(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
|
package/dist/commands.js
CHANGED
|
@@ -15,7 +15,7 @@ import { mergeDerivedMeta, nearMissMetaWarnings, validateStateValue } from "./me
|
|
|
15
15
|
import { mergeSidecarMeta } from "./sidecar.js";
|
|
16
16
|
import { ghAttachmentKey, ghBranchAttachmentKey, ghBranchKeyPrefix, ghKeyPrefix, ghMetadataFromTarget, parseGhKey, 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";
|
|
@@ -315,7 +315,7 @@ function formatOptimizeNote(opt) {
|
|
|
315
315
|
}
|
|
316
316
|
return undefined;
|
|
317
317
|
}
|
|
318
|
-
function writeReplacedNote(replaced, quiet, dryRun = false, wouldRefuse = false) {
|
|
318
|
+
export function writeReplacedNote(replaced, quiet, dryRun = false, wouldRefuse = false) {
|
|
319
319
|
if (quiet)
|
|
320
320
|
return;
|
|
321
321
|
if (dryRun && wouldRefuse) {
|
|
@@ -899,7 +899,7 @@ export async function uploadPuts(opts) {
|
|
|
899
899
|
return { uploads, failures, firstError, sentMetadata };
|
|
900
900
|
}
|
|
901
901
|
/**
|
|
902
|
-
* Best-effort call to `POST /v1/:workspace/github/promote` (server contract,
|
|
902
|
+
* Best-effort call to `POST /v1/workspaces/:workspace/github/promote` (server contract,
|
|
903
903
|
* PR #310). Degrade-safe like `syncAttachmentsComment`'s bot path: an older
|
|
904
904
|
* or self-hosted worker without this route (404), a forbidden token (403),
|
|
905
905
|
* or a network error all collapse to "nothing promoted" — the caller must
|
|
@@ -1699,6 +1699,23 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
1699
1699
|
}
|
|
1700
1700
|
}
|
|
1701
1701
|
}
|
|
1702
|
+
// Derived `repo` (spec: 2026-08-11-screenshots-project-grouping-design.md):
|
|
1703
|
+
// the capturing repo, on every layout including gh/staging. mergeDerivedMeta
|
|
1704
|
+
// keeps explicit --meta repo= wins and never breaks the caps.
|
|
1705
|
+
//
|
|
1706
|
+
// `metadata === undefined` means "leave whatever's already stored for this
|
|
1707
|
+
// key untouched" (client.ts only sends X-Uploads-Meta-* when the map is
|
|
1708
|
+
// defined; a defined map is a full replace server-side). A bare `uploads
|
|
1709
|
+
// put` with no --meta/gh/staging context leaves `metadata` undefined by
|
|
1710
|
+
// design — never synthesize `{ repo }` there just because a repo happens
|
|
1711
|
+
// to be derivable, or a plain re-upload of an existing key would silently
|
|
1712
|
+
// wipe everything already stored on it. Only fold repo in when `metadata`
|
|
1713
|
+
// is already a defined object for another reason.
|
|
1714
|
+
if (!noGit && derivedMetaEnabled(parsed.flags, defaults) && metadata !== undefined) {
|
|
1715
|
+
const slug = deriveRepoSlugFromGit(run);
|
|
1716
|
+
if (slug)
|
|
1717
|
+
metadata = mergeDerivedMeta(metadata, { repo: slug });
|
|
1718
|
+
}
|
|
1702
1719
|
// Bare-put nudge (issue #393): only relevant when staging didn't take over
|
|
1703
1720
|
// — once `stagingTarget` resolves, staging IS the upgrade the nudge used to
|
|
1704
1721
|
// point at, so this is skipped entirely rather than firing redundantly.
|
|
@@ -2551,6 +2568,48 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
|
|
|
2551
2568
|
}
|
|
2552
2569
|
return 0;
|
|
2553
2570
|
}
|
|
2571
|
+
// --- ingest ---
|
|
2572
|
+
const INGEST_HELP = `uploads ingest — mirror GitHub-native attachments from a PR/issue into the workspace
|
|
2573
|
+
|
|
2574
|
+
Usage:
|
|
2575
|
+
uploads ingest --pr <n> [--repo owner/name]
|
|
2576
|
+
uploads ingest --issue <n> [--repo owner/name]
|
|
2577
|
+
|
|
2578
|
+
Scans the PR/issue description and comments for github.com/user-attachments
|
|
2579
|
+
media, mirrors new ones into the workspace (indexed, not added to the managed
|
|
2580
|
+
comment), and detaches ones no longer referenced. Works on any repo linked to
|
|
2581
|
+
the workspace; the .uploads.yml ingestGithubAttachments knob only gates the
|
|
2582
|
+
automatic webhook path.
|
|
2583
|
+
|
|
2584
|
+
Examples:
|
|
2585
|
+
uploads ingest --pr 123
|
|
2586
|
+
uploads ingest --issue 45 --repo acme/app --format json
|
|
2587
|
+
`;
|
|
2588
|
+
export async function runIngest(ctx, args, help = false, run = execRunner) {
|
|
2589
|
+
const parsed = parseCommandArgs(args);
|
|
2590
|
+
if (help || parsed.help) {
|
|
2591
|
+
writeCommandHelp(INGEST_HELP);
|
|
2592
|
+
return 0;
|
|
2593
|
+
}
|
|
2594
|
+
const target = ghTargetFromFlags(parsed.flags, run);
|
|
2595
|
+
if (!target) {
|
|
2596
|
+
throw new UsageError("--pr or --issue required");
|
|
2597
|
+
}
|
|
2598
|
+
const result = await ctx.client.ingestGithub(target);
|
|
2599
|
+
const jsonMode = ctx.json || flagString(parsed.flags, "--format") === "json";
|
|
2600
|
+
if (jsonMode) {
|
|
2601
|
+
await writeJson(result);
|
|
2602
|
+
return 0;
|
|
2603
|
+
}
|
|
2604
|
+
if (!ctx.quiet) {
|
|
2605
|
+
let line = `Ingested ${result.ingested.length}, re-attached ${result.reattached.length}, detached ${result.detached.length}, skipped ${result.skipped.length}\n`;
|
|
2606
|
+
for (const skip of result.skipped) {
|
|
2607
|
+
line += ` skipped: ${skip.url} (${skip.reason})\n`;
|
|
2608
|
+
}
|
|
2609
|
+
process.stderr.write(line);
|
|
2610
|
+
}
|
|
2611
|
+
return 0;
|
|
2612
|
+
}
|
|
2554
2613
|
// --- github link ---
|
|
2555
2614
|
const GITHUB_HELP = `uploads github link [--repo <owner/name>] [--status] [--workspace <name>]
|
|
2556
2615
|
uploads github unlink [--repo <owner/name>] [--workspace <name>]
|
|
@@ -2570,7 +2629,8 @@ owns it; an operator can reassign or remove that binding instead.
|
|
|
2570
2629
|
|
|
2571
2630
|
\`doctor\` checks the GitHub App itself: whether it's configured on the
|
|
2572
2631
|
server, and whether it's subscribed to the webhook events uploads.sh's
|
|
2573
|
-
handler needs (issues, pull_request
|
|
2632
|
+
handler needs (required: issues, pull_request; recommended: issue_comment —
|
|
2633
|
+
see docs/github-app). A missing
|
|
2574
2634
|
subscription is the classic silent failure: the App's ping stays green
|
|
2575
2635
|
while webhook auto-promotion and title-cache invalidation quietly do
|
|
2576
2636
|
nothing.
|
|
@@ -2590,7 +2650,7 @@ function recommendedNoteLine(result) {
|
|
|
2590
2650
|
const missing = missingRecommendedEventsOf(result);
|
|
2591
2651
|
if (missing.length === 0)
|
|
2592
2652
|
return "";
|
|
2593
|
-
return `note: not subscribed to ${missing.join(", ")} (recommended) — enables bot-comment self-healing; subscribe under the App's Permissions & events\n`;
|
|
2653
|
+
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
2654
|
}
|
|
2595
2655
|
function formatGithubDoctor(result) {
|
|
2596
2656
|
if (!result.configured) {
|
|
@@ -2600,7 +2660,18 @@ function formatGithubDoctor(result) {
|
|
|
2600
2660
|
return `github app: configured, but health check failed${result.hint ? ` — ${result.hint}` : ""}\n`;
|
|
2601
2661
|
}
|
|
2602
2662
|
if (result.ok) {
|
|
2603
|
-
|
|
2663
|
+
// The ACTUAL subscribed list, not just the required subset — printing
|
|
2664
|
+
// requiredEvents here once misdiagnosed a live App as "not subscribed to
|
|
2665
|
+
// issue_comment" when it was (2026-08-11). `events` is non-null on every
|
|
2666
|
+
// ok result (the null case returns above); the requiredEvents fallback
|
|
2667
|
+
// only guards a malformed payload from an older server.
|
|
2668
|
+
const subscribed = Array.isArray(result.events)
|
|
2669
|
+
? [...result.events].sort()
|
|
2670
|
+
: [...result.requiredEvents];
|
|
2671
|
+
const allPresent = missingRecommendedEventsOf(result).length === 0;
|
|
2672
|
+
return (`github app: ok — subscribed to ${subscribed.join(", ")}` +
|
|
2673
|
+
(allPresent ? " (all required + recommended events)" : "") +
|
|
2674
|
+
"\n" +
|
|
2604
2675
|
recommendedNoteLine(result));
|
|
2605
2676
|
}
|
|
2606
2677
|
return (`github app: missing webhook event subscription(s): ${result.missingEvents.join(", ")}\n` +
|
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/config-file.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { UploadsClientConfig } from "./config.js";
|
|
2
|
-
export declare const UPLOADS_CONFIG_KEYS: readonly ["UPLOADS_API_URL", "UPLOADS_WORKSPACE", "UPLOADS_TOKEN",
|
|
2
|
+
export declare const UPLOADS_CONFIG_KEYS: readonly ["UPLOADS_API_URL", "UPLOADS_WORKSPACE", "UPLOADS_TOKEN",
|
|
3
|
+
/** Better Auth device-flow session bearer — keeps session.cliVersion fresh. */
|
|
4
|
+
"UPLOADS_SESSION_TOKEN", "UPLOADS_DEFAULT_PREFIX", "UPLOADS_DEFAULT_REPO", "UPLOADS_DEFAULT_REF", "UPLOADS_DEFAULT_WIDTH", "UPLOADS_NO_GIT", "UPLOADS_NO_OPTIMIZE", "UPLOADS_KEEP_EXIF", "UPLOADS_NO_AUTO_META", "UPLOADS_SCREENSHOT_VIA", "UPLOADS_NO_NUDGE"];
|
|
3
5
|
export type UploadsConfigKey = (typeof UPLOADS_CONFIG_KEYS)[number];
|
|
4
6
|
export type UploadsConfigValues = Partial<Record<UploadsConfigKey, string>>;
|
|
5
7
|
export interface PutDefaults {
|
package/dist/keys.d.ts
CHANGED
|
@@ -8,6 +8,12 @@ export declare function sha256Short(bytes: Uint8Array): Promise<string>;
|
|
|
8
8
|
* every existing caller.
|
|
9
9
|
*/
|
|
10
10
|
export declare function deriveRepoFromGit(run?: (cmd: string, args: string[], input?: string) => string): string | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Full `owner/name` slug (lowercase, matching the `gh.repo` convention) from
|
|
13
|
+
* the cwd's git remote — the derived `repo` metadata value. Unlike
|
|
14
|
+
* `deriveRepoFromGit` above (key-segment name only), this keeps the owner.
|
|
15
|
+
*/
|
|
16
|
+
export declare function deriveRepoSlugFromGit(run?: (cmd: string, args: string[], input?: string) => string): string | undefined;
|
|
11
17
|
export declare function buildScreenshotKey(opts: {
|
|
12
18
|
filename: string;
|
|
13
19
|
fileBytes: Uint8Array;
|
package/dist/keys.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execSync } from "node:child_process";
|
|
2
|
+
import { parseRepoFromRemoteUrl } from "./github.js";
|
|
2
3
|
export function sanitizeKeySegment(s) {
|
|
3
4
|
return s.replace(/[^A-Za-z0-9._-]/g, "-");
|
|
4
5
|
}
|
|
@@ -28,6 +29,22 @@ export function deriveRepoFromGit(run) {
|
|
|
28
29
|
return undefined;
|
|
29
30
|
}
|
|
30
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Full `owner/name` slug (lowercase, matching the `gh.repo` convention) from
|
|
34
|
+
* the cwd's git remote — the derived `repo` metadata value. Unlike
|
|
35
|
+
* `deriveRepoFromGit` above (key-segment name only), this keeps the owner.
|
|
36
|
+
*/
|
|
37
|
+
export function deriveRepoSlugFromGit(run) {
|
|
38
|
+
try {
|
|
39
|
+
const url = run
|
|
40
|
+
? run("git", ["config", "--get", "remote.origin.url"])
|
|
41
|
+
: execSync("git config --get remote.origin.url", { encoding: "utf8" });
|
|
42
|
+
return parseRepoFromRemoteUrl(url)?.toLowerCase();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
31
48
|
export async function buildScreenshotKey(opts) {
|
|
32
49
|
const dot = opts.filename.lastIndexOf(".");
|
|
33
50
|
const ext = dot >= 0 ? opts.filename.slice(dot + 1) : "";
|
package/dist/mcp/args.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ export declare const metadataProp: {
|
|
|
23
23
|
};
|
|
24
24
|
export declare const stateProp: {
|
|
25
25
|
type: string;
|
|
26
|
-
enum: ("
|
|
26
|
+
enum: ("after" | "before" | "empty" | "error" | "loading")[];
|
|
27
27
|
description: string;
|
|
28
28
|
};
|
|
29
29
|
export declare const appProp: {
|
package/dist/mcp/tools.js
CHANGED
|
@@ -5,6 +5,7 @@ import { resolveConfig, resolvePutDefaults, } from "../config.js";
|
|
|
5
5
|
import { resolvePutPrefix } from "../destinations.js";
|
|
6
6
|
import { ghBranchAttachmentKey, ghKeyPrefix } from "../github.js";
|
|
7
7
|
import { safeCaptureFacts } from "../capture-facts.js";
|
|
8
|
+
import { deriveRepoSlugFromGit } from "../keys.js";
|
|
8
9
|
import { validateMetaMap } from "../metadata.js";
|
|
9
10
|
import { mergeDerivedMeta } from "../metadata-vocab.js";
|
|
10
11
|
import { execRunner, ghMetadataFromTargetWithTitle, resolveCurrentBranch, resolveCurrentPullRequest, resolveRepo, } from "../github-gh.js";
|
|
@@ -430,7 +431,23 @@ export function createUploadsMcpTools(opts) {
|
|
|
430
431
|
repoArg: optString(args, "repo") ?? defaults.repo,
|
|
431
432
|
run,
|
|
432
433
|
});
|
|
433
|
-
|
|
434
|
+
// Derived `repo` metadata (spec: 2026-08-11-screenshots-project-grouping-design.md).
|
|
435
|
+
// Same derivation the CLI does; MCP always derives (no --no-auto), so
|
|
436
|
+
// this is only suppressed by noGit. metadataProp's contract: omitting
|
|
437
|
+
// `metadata` means "leave what's already stored for this key
|
|
438
|
+
// untouched" — an object (even {}) triggers a full replace. So only
|
|
439
|
+
// fold the derived repo into a defined object when the caller already
|
|
440
|
+
// supplied metadata, or branch staging is building one below anyway
|
|
441
|
+
// (mergeStagingMeta always returns a defined object); never
|
|
442
|
+
// synthesize metadata on a bare re-upload just to add repo, or a
|
|
443
|
+
// no-metadata put would silently wipe everything already stored.
|
|
444
|
+
const repoSlug = !noGit ? deriveRepoSlugFromGit(run) : undefined;
|
|
445
|
+
const metadataWithRepo = repoSlug !== undefined && (metadata !== undefined || stagingTarget !== undefined)
|
|
446
|
+
? mergeDerivedMeta(metadata ?? {}, { repo: repoSlug })
|
|
447
|
+
: metadata;
|
|
448
|
+
const putMetadata = stagingTarget
|
|
449
|
+
? mergeStagingMeta(metadataWithRepo, stagingTarget)
|
|
450
|
+
: metadataWithRepo;
|
|
434
451
|
const putShared = {
|
|
435
452
|
client,
|
|
436
453
|
ghTarget: target,
|
|
@@ -737,9 +754,15 @@ export function createUploadsMcpTools(opts) {
|
|
|
737
754
|
// Keep undefined when nothing at all was supplied or derived, so the
|
|
738
755
|
// "omit to leave stored metadata untouched" contract still holds.
|
|
739
756
|
const captureDerived = safeCaptureFacts(targetArg, viewport, colorSchemeArg);
|
|
740
|
-
|
|
757
|
+
// Derived `repo` metadata (spec: 2026-08-11-screenshots-project-grouping-design.md).
|
|
758
|
+
// Same derivation the CLI does, suppressed by noGit.
|
|
759
|
+
const repoSlug = !noGit ? deriveRepoSlugFromGit(run) : undefined;
|
|
760
|
+
const captureDerivedWithRepo = repoSlug
|
|
761
|
+
? { ...captureDerived, repo: repoSlug }
|
|
762
|
+
: captureDerived;
|
|
763
|
+
const metadataBase = metadata === undefined && Object.keys(captureDerivedWithRepo).length === 0
|
|
741
764
|
? undefined
|
|
742
|
-
: mergeDerivedMeta(metadata ?? {},
|
|
765
|
+
: mergeDerivedMeta(metadata ?? {}, captureDerivedWithRepo);
|
|
743
766
|
// gh.* metadata: explicit pr/issue target wins; staging wins the same
|
|
744
767
|
// way (matches attach --branch/bare put); otherwise capture-derived +
|
|
745
768
|
// explicit only.
|
|
@@ -761,6 +784,10 @@ export function createUploadsMcpTools(opts) {
|
|
|
761
784
|
hide: optStringArray(args, "hide"),
|
|
762
785
|
hideDevTools: optBool(args, "noHideDevTools") ? false : undefined,
|
|
763
786
|
reducedMotion: optBool(args, "reducedMotion"),
|
|
787
|
+
// Skip folding when an explicit key was given — key sets the
|
|
788
|
+
// whole object key, so there's no auto-derived name to fold
|
|
789
|
+
// state into.
|
|
790
|
+
state: keyArg ? undefined : metadataWithCaptureFacts?.state,
|
|
764
791
|
apiUrl: config.apiUrl,
|
|
765
792
|
token: config.token,
|
|
766
793
|
});
|
package/dist/metadata-vocab.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
export declare const CANONICAL_META_KEYS: readonly ["url", "path", "env", "theme", "viewport", "device", "software", "captured", "state", "app"];
|
|
1
|
+
export declare const CANONICAL_META_KEYS: readonly ["url", "path", "env", "theme", "viewport", "device", "software", "captured", "state", "app", "repo"];
|
|
2
2
|
export type CanonicalMetaKey = (typeof CANONICAL_META_KEYS)[number];
|
|
3
3
|
/** Closed enum for `state` — the highest-value hand-supplied search facet. */
|
|
4
4
|
export declare const META_STATE_VALUES: readonly ["before", "after", "empty", "error", "loading"];
|
|
5
5
|
export type MetaStateValue = (typeof META_STATE_VALUES)[number];
|
|
6
|
+
/** True when `value` is exactly one of the canonical state values. */
|
|
7
|
+
export declare function isMetaStateValue(value: string): value is MetaStateValue;
|
|
6
8
|
/**
|
|
7
9
|
* Validate a `--state` value. Fails fast with a suggestion when the value is a
|
|
8
10
|
* recognized near-miss, otherwise lists the valid set.
|
package/dist/metadata-vocab.js
CHANGED
|
@@ -18,11 +18,16 @@ export const CANONICAL_META_KEYS = [
|
|
|
18
18
|
"captured",
|
|
19
19
|
"state",
|
|
20
20
|
"app",
|
|
21
|
+
"repo",
|
|
21
22
|
];
|
|
22
23
|
const CANONICAL_KEY_SET = new Set(CANONICAL_META_KEYS);
|
|
23
24
|
/** Closed enum for `state` — the highest-value hand-supplied search facet. */
|
|
24
25
|
export const META_STATE_VALUES = ["before", "after", "empty", "error", "loading"];
|
|
25
26
|
const STATE_VALUE_SET = new Set(META_STATE_VALUES);
|
|
27
|
+
/** True when `value` is exactly one of the canonical state values. */
|
|
28
|
+
export function isMetaStateValue(value) {
|
|
29
|
+
return STATE_VALUE_SET.has(value);
|
|
30
|
+
}
|
|
26
31
|
/** Common spellings agents reach for, mapped to the canonical `state` value. */
|
|
27
32
|
const STATE_ALIASES = {
|
|
28
33
|
pre: "before",
|
package/dist/screenshot.d.ts
CHANGED
|
@@ -65,6 +65,14 @@ export interface CaptureScreenshotOptions {
|
|
|
65
65
|
fullPage?: boolean;
|
|
66
66
|
colorScheme?: "dark" | "light";
|
|
67
67
|
waitUntil?: WaitUntil;
|
|
68
|
+
/**
|
|
69
|
+
* Folded into the auto-derived filename's stem (e.g. `-before`) so a
|
|
70
|
+
* before/after pair of the same URL yields two distinct object names
|
|
71
|
+
* instead of silently overwriting each other. Callers must omit this when
|
|
72
|
+
* the caller also gave an explicit object key/name — folding only applies
|
|
73
|
+
* to the auto-derived name.
|
|
74
|
+
*/
|
|
75
|
+
state?: string;
|
|
68
76
|
/** Extra CSS selectors to hide (display:none) before capture. */
|
|
69
77
|
hide?: string[];
|
|
70
78
|
/**
|
|
@@ -120,6 +128,26 @@ export interface CaptureScreenshotResult {
|
|
|
120
128
|
/** Present when `measureSelectors` was given and the local backend ran. */
|
|
121
129
|
measures?: Record<string, MeasuredBox>;
|
|
122
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Folds a `--state` value into an auto-derived filename's stem, e.g.
|
|
133
|
+
* `localhost-docs-mcp.png` + "before" → `localhost-docs-mcp-before.png`. This
|
|
134
|
+
* is what keeps a before/after pair (same URL, two states) from colliding on
|
|
135
|
+
* the same object key (issue #618) — without it, the second capture silently
|
|
136
|
+
* overwrote the first.
|
|
137
|
+
*
|
|
138
|
+
* No-op when `state` is undefined, and idempotent: re-folding a filename that
|
|
139
|
+
* already ends with `-<state>` (e.g. re-deriving from a stored filename)
|
|
140
|
+
* doesn't double-append.
|
|
141
|
+
*
|
|
142
|
+
* Only the canonical state values fold. Callers pass the merged metadata bag's
|
|
143
|
+
* `state`, which a free-form `--meta state=…` can populate with any printable
|
|
144
|
+
* ASCII (validateMetaMap allows `/` and spaces) — that stays metadata-only
|
|
145
|
+
* rather than entering the object key.
|
|
146
|
+
*
|
|
147
|
+
* Callers must skip this entirely when the caller gave an explicit key/name
|
|
148
|
+
* (e.g. `--key`) — this only applies to the auto-derived filename.
|
|
149
|
+
*/
|
|
150
|
+
export declare function foldStateIntoFilename(filename: string, state: string | undefined): string;
|
|
123
151
|
/**
|
|
124
152
|
* Resolve target + options into PNG bytes via the local or remote backend.
|
|
125
153
|
* Shared by the CLI `screenshot` command and the MCP `screenshot` tool.
|
package/dist/screenshot.js
CHANGED
|
@@ -12,6 +12,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
|
|
|
12
12
|
import { basename, resolve as resolvePath } from "node:path";
|
|
13
13
|
import { pathToFileURL } from "node:url";
|
|
14
14
|
import { UploadsError } from "./errors.js";
|
|
15
|
+
import { isMetaStateValue } from "./metadata-vocab.js";
|
|
15
16
|
import { captureRemote, MAX_REMOTE_HTML_BYTES } from "./screenshot-remote.js";
|
|
16
17
|
/**
|
|
17
18
|
* Host selectors for framework dev toolbars/overlays that otherwise pollute a
|
|
@@ -159,6 +160,35 @@ function deriveFilename(target) {
|
|
|
159
160
|
const stem = [url.hostname, pathPart].filter(Boolean).join("-");
|
|
160
161
|
return `${stem || "screenshot"}.png`;
|
|
161
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* Folds a `--state` value into an auto-derived filename's stem, e.g.
|
|
165
|
+
* `localhost-docs-mcp.png` + "before" → `localhost-docs-mcp-before.png`. This
|
|
166
|
+
* is what keeps a before/after pair (same URL, two states) from colliding on
|
|
167
|
+
* the same object key (issue #618) — without it, the second capture silently
|
|
168
|
+
* overwrote the first.
|
|
169
|
+
*
|
|
170
|
+
* No-op when `state` is undefined, and idempotent: re-folding a filename that
|
|
171
|
+
* already ends with `-<state>` (e.g. re-deriving from a stored filename)
|
|
172
|
+
* doesn't double-append.
|
|
173
|
+
*
|
|
174
|
+
* Only the canonical state values fold. Callers pass the merged metadata bag's
|
|
175
|
+
* `state`, which a free-form `--meta state=…` can populate with any printable
|
|
176
|
+
* ASCII (validateMetaMap allows `/` and spaces) — that stays metadata-only
|
|
177
|
+
* rather than entering the object key.
|
|
178
|
+
*
|
|
179
|
+
* Callers must skip this entirely when the caller gave an explicit key/name
|
|
180
|
+
* (e.g. `--key`) — this only applies to the auto-derived filename.
|
|
181
|
+
*/
|
|
182
|
+
export function foldStateIntoFilename(filename, state) {
|
|
183
|
+
if (!state || !isMetaStateValue(state))
|
|
184
|
+
return filename;
|
|
185
|
+
const match = /^(.*?)(\.[^./]+)?$/.exec(filename);
|
|
186
|
+
const stem = match?.[1] ?? filename;
|
|
187
|
+
const ext = match?.[2] ?? "";
|
|
188
|
+
if (stem.endsWith(`-${state}`))
|
|
189
|
+
return filename;
|
|
190
|
+
return `${stem}-${state}${ext}`;
|
|
191
|
+
}
|
|
162
192
|
/**
|
|
163
193
|
* Best-effort local-browser probe used only to decide `auto` routing. Never
|
|
164
194
|
* throws — any failure (e.g. optional playwright-core not installed) means
|
|
@@ -183,7 +213,7 @@ export async function captureScreenshot(opts) {
|
|
|
183
213
|
const target = classifyTarget(opts.target);
|
|
184
214
|
const viewport = opts.viewport ?? DEFAULT_SCREENSHOT_VIEWPORT;
|
|
185
215
|
const waitUntil = opts.waitUntil ?? "load";
|
|
186
|
-
const filename = deriveFilename(target);
|
|
216
|
+
const filename = foldStateIntoFilename(deriveFilename(target), opts.state);
|
|
187
217
|
// Only private-network URLs are truly unreachable remotely; an .html file
|
|
188
218
|
// is sent to the remote backend as an inline `html` body (though anything
|
|
189
219
|
// it references via file:// or relative paths won't resolve there).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@buildinternet/uploads",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
4
|
"description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"node": ">=22"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
|
-
"files-sdk": "^2.
|
|
41
|
+
"files-sdk": "^2.2.3"
|
|
42
42
|
},
|
|
43
43
|
"peerDependenciesMeta": {
|
|
44
44
|
"files-sdk": {
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"@types/node": "^26.1.0",
|
|
50
50
|
"ai": "^6.0.0",
|
|
51
|
-
"files-sdk": "^2.
|
|
52
|
-
"typescript": "^
|
|
51
|
+
"files-sdk": "^2.2.3",
|
|
52
|
+
"typescript": "^7.0.2",
|
|
53
53
|
"vitest": "^4.1.10"
|
|
54
54
|
},
|
|
55
55
|
"publishConfig": {
|