@buildinternet/uploads 0.26.1 → 0.27.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-catalog.js +1 -0
- package/dist/commands/screenshot.js +85 -12
- package/dist/commands.d.ts +23 -0
- package/dist/commands.js +108 -13
- package/dist/github.d.ts +6 -0
- package/dist/github.js +12 -0
- package/dist/mcp/tools.js +38 -22
- package/dist/sidecar.d.ts +31 -0
- package/dist/sidecar.js +111 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -98,6 +98,11 @@ explicit deletes. Promotion (auto or `--promote`) does skip files staged
|
|
|
98
98
|
more than 30 days before the PR opens, though; they're still there, just no
|
|
99
99
|
longer auto-promoted.
|
|
100
100
|
|
|
101
|
+
**Metadata edits re-sync the comment too:** `uploads meta set` on a `gh/…`-keyed
|
|
102
|
+
object refreshes the managed comment automatically when it touches `path` or
|
|
103
|
+
`state` — best-effort, so backfilled metadata shows up without waiting on the
|
|
104
|
+
next `attach`.
|
|
105
|
+
|
|
101
106
|
**Bare `put` stages too, by default (issue #403):** on a non-default git
|
|
102
107
|
branch, a `put` with none of
|
|
103
108
|
`--pr`/`--issue`/`--key`/`--ref`/`--prefix`/`--destination` set
|
package/dist/cli-catalog.js
CHANGED
|
@@ -2,15 +2,16 @@ import { readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { basename } from "node:path";
|
|
3
3
|
import { 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, } from "../commands.js";
|
|
5
|
+
import { branchFromFlags, derivedMetaEnabled, frameOptionsFromFlags, ghTargetFromFlags, optimizeOptionsFromFlags, stateAppMetaFromFlags, warnNearMissMeta, syncAttachmentsComment, commentViaSuffix, uploadPreparedImage, resolvePutStagingTarget, putStagingNoteText, resolveStageBindingWarning, mergeStagingMeta, } 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
|
-
import { ghBranchAttachmentKey
|
|
10
|
+
import { ghBranchAttachmentKey } from "../github.js";
|
|
11
11
|
import { safeCaptureFacts } from "../capture-facts.js";
|
|
12
12
|
import { parseMetaFlags, validateMetaMap } from "../metadata.js";
|
|
13
13
|
import { mergeDerivedMeta } from "../metadata-vocab.js";
|
|
14
|
+
import { writeSidecarMeta } from "../sidecar.js";
|
|
14
15
|
import { writeJson, writeStdout } from "../io.js";
|
|
15
16
|
import { assertHideSelector, captureScreenshot, parseViewport, parseWaitUntil, } from "../screenshot.js";
|
|
16
17
|
const SCREENSHOT_HELP = `uploads screenshot <target> [options]
|
|
@@ -33,6 +34,15 @@ fail fast with a clear error instead of sending a doomed request.
|
|
|
33
34
|
After capture, screenshots share the put upload pipeline: optional --frame,
|
|
34
35
|
optimize-by-default, --pr/--issue attachment + --comment, --gallery, --meta.
|
|
35
36
|
|
|
37
|
+
Branch staging by default (pre-PR): with no --pr/--issue/--branch/--key/--ref/
|
|
38
|
+
--prefix/--destination, a screenshot taken on a non-default git branch stages
|
|
39
|
+
under gh/<owner>/<repo>/branch/<branch>/<name> instead of the dated
|
|
40
|
+
screenshots/<repo>/<date>/... layout — same key/metadata as an explicit
|
|
41
|
+
--branch, carrying every derived fact (path/url/env/viewport, --state) along.
|
|
42
|
+
Staged files auto-attach with full metadata the first time you attach to that
|
|
43
|
+
branch's PR once it opens (or run "uploads attach --promote"). Use --no-git,
|
|
44
|
+
or an explicit --ref/--prefix, to opt back into the dated layout.
|
|
45
|
+
|
|
36
46
|
Options:
|
|
37
47
|
--via auto|local|remote Capture backend (default: auto, or UPLOADS_SCREENSHOT_VIA)
|
|
38
48
|
--browser <path> Explicit local browser executable (or UPLOADS_CHROME_PATH / CHROME_PATH)
|
|
@@ -51,7 +61,12 @@ Options:
|
|
|
51
61
|
on --via remote — neutralizes animations via injected CSS)
|
|
52
62
|
--eval <js> Run JS in the page after settle, before capture (--via local only)
|
|
53
63
|
--init-script <file> Inject a JS file before navigation (--via local only)
|
|
54
|
-
--out <file> Also write the PNG to a local file
|
|
64
|
+
--out <file> Also write the PNG to a local file. Also writes a sidecar manifest,
|
|
65
|
+
<file>.uploads.json, recording this capture's derived metadata
|
|
66
|
+
(path/url/env/viewport, plus --state if given) with a content hash; a
|
|
67
|
+
later \`put\`/\`attach\` of this exact file picks the metadata back up
|
|
68
|
+
automatically (explicit --meta/--state still win). See --no-sidecar.
|
|
69
|
+
--no-sidecar Don't write the <file>.uploads.json sidecar alongside --out
|
|
55
70
|
--no-upload Skip hosting; requires --out (local file only)
|
|
56
71
|
--destination <id> Typed root: screenshots | gh | f
|
|
57
72
|
--prefix <path> Key prefix (default: screenshots, or UPLOADS_DEFAULT_PREFIX)
|
|
@@ -171,6 +186,9 @@ captureImpl = captureScreenshot) {
|
|
|
171
186
|
const noUpload = flagBool(parsed.flags, "--no-upload");
|
|
172
187
|
if (noUpload && !outFile)
|
|
173
188
|
throw new UsageError("--no-upload requires --out");
|
|
189
|
+
const noSidecar = flagBool(parsed.flags, "--no-sidecar");
|
|
190
|
+
if (noSidecar && !outFile)
|
|
191
|
+
throw new UsageError("--no-sidecar requires --out");
|
|
174
192
|
const keyHint = flagString(parsed.flags, "--key");
|
|
175
193
|
const destFlag = flagString(parsed.flags, "--destination");
|
|
176
194
|
const prefixFlag = flagString(parsed.flags, "--prefix");
|
|
@@ -210,14 +228,38 @@ captureImpl = captureScreenshot) {
|
|
|
210
228
|
if (noUpload)
|
|
211
229
|
throw new UsageError("--dry-run cannot be combined with --no-upload");
|
|
212
230
|
}
|
|
231
|
+
const putDefaults = resolvePutDefaults({ envFile: ctx.envFile }, rawDefaults);
|
|
232
|
+
const noGit = flagBool(parsed.flags, "--no-git") || putDefaults.noGit === true;
|
|
213
233
|
const branchRepo = branchArg !== undefined ? resolveRepo(flagString(parsed.flags, "--repo"), run) : undefined;
|
|
234
|
+
// Auto branch staging (issue #469 lever 1): mirrors bare `put`'s auto-staging
|
|
235
|
+
// (issue #403). When no --branch/--pr/--issue/--key/--ref/--prefix/--destination
|
|
236
|
+
// is given and git use isn't disabled, a screenshot taken on a non-default
|
|
237
|
+
// git branch stages the same way explicit `--branch`/bare `put` do — same
|
|
238
|
+
// key shape, same gh.* metadata — instead of landing on the dated
|
|
239
|
+
// `screenshots/<repo>/<date>/...` layout. This is what lets derived
|
|
240
|
+
// metadata (path/url/env/viewport, --state) ride through to PR-open
|
|
241
|
+
// promotion when the capture happens before the PR exists. Skipped
|
|
242
|
+
// entirely when --branch was given explicitly (already handled above).
|
|
243
|
+
const autoStagingTarget = branchArg === undefined
|
|
244
|
+
? resolvePutStagingTarget({
|
|
245
|
+
ghTarget,
|
|
246
|
+
keyHint,
|
|
247
|
+
refArg: flagString(parsed.flags, "--ref"),
|
|
248
|
+
prefixArg: prefixFlag,
|
|
249
|
+
destinationArg: destFlag,
|
|
250
|
+
noGit,
|
|
251
|
+
repoArg: flagString(parsed.flags, "--repo") ?? putDefaults.repo,
|
|
252
|
+
run,
|
|
253
|
+
})
|
|
254
|
+
: undefined;
|
|
255
|
+
const stagingTarget = branchArg !== undefined ? { repo: branchRepo, branch: branchArg } : autoStagingTarget;
|
|
214
256
|
let resolvedPrefix;
|
|
215
257
|
try {
|
|
216
258
|
resolvedPrefix = resolvePutPrefix({
|
|
217
259
|
destination: destFlag,
|
|
218
260
|
prefix: prefixFlag,
|
|
219
261
|
key: keyHint,
|
|
220
|
-
ghAttachment: Boolean(ghTarget) ||
|
|
262
|
+
ghAttachment: Boolean(ghTarget) || stagingTarget !== undefined,
|
|
221
263
|
});
|
|
222
264
|
}
|
|
223
265
|
catch (err) {
|
|
@@ -233,7 +275,6 @@ captureImpl = captureScreenshot) {
|
|
|
233
275
|
return raw;
|
|
234
276
|
throw new UsageError(`invalid --format: ${raw}`);
|
|
235
277
|
})();
|
|
236
|
-
const putDefaults = resolvePutDefaults({ envFile: ctx.envFile }, rawDefaults);
|
|
237
278
|
const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, putDefaults);
|
|
238
279
|
const frameOpts = frameOptionsFromFlags(parsed.flags);
|
|
239
280
|
const altFlag = flagString(parsed.flags, "--alt");
|
|
@@ -248,9 +289,8 @@ captureImpl = captureScreenshot) {
|
|
|
248
289
|
metadata = { ...withFacts, ...ghMetadataFromTargetWithTitle(ghTarget, run) };
|
|
249
290
|
validateMetaMap(metadata);
|
|
250
291
|
}
|
|
251
|
-
else if (
|
|
252
|
-
metadata =
|
|
253
|
-
validateMetaMap(metadata);
|
|
292
|
+
else if (stagingTarget !== undefined) {
|
|
293
|
+
metadata = mergeStagingMeta(withFacts, stagingTarget);
|
|
254
294
|
}
|
|
255
295
|
else if (Object.keys(withFacts).length > 0) {
|
|
256
296
|
validateMetaMap(withFacts);
|
|
@@ -282,6 +322,8 @@ captureImpl = captureScreenshot) {
|
|
|
282
322
|
writeFileSync(outFile, captured.png);
|
|
283
323
|
if (logHuman)
|
|
284
324
|
process.stderr.write(`>> wrote ${outFile}\n`);
|
|
325
|
+
if (!noSidecar)
|
|
326
|
+
writeSidecarMeta(outFile, captured.png, withFacts);
|
|
285
327
|
}
|
|
286
328
|
if (noUpload) {
|
|
287
329
|
if (ctx.json) {
|
|
@@ -294,8 +336,8 @@ captureImpl = captureScreenshot) {
|
|
|
294
336
|
}
|
|
295
337
|
const repo = flagString(parsed.flags, "--repo") ?? putDefaults.repo;
|
|
296
338
|
const ref = flagString(parsed.flags, "--ref") ?? putDefaults.ref;
|
|
297
|
-
const branchKey =
|
|
298
|
-
? ghBranchAttachmentKey(
|
|
339
|
+
const branchKey = stagingTarget !== undefined
|
|
340
|
+
? ghBranchAttachmentKey(stagingTarget.repo, stagingTarget.branch, captured.filename)
|
|
299
341
|
: undefined;
|
|
300
342
|
const alt = altFlag ?? basename(captured.filename);
|
|
301
343
|
const { result, prepared, markdown } = await uploadPreparedImage(ctx.client, captured.png, captured.filename, {
|
|
@@ -306,13 +348,26 @@ captureImpl = captureScreenshot) {
|
|
|
306
348
|
prefix: resolvedPrefix ?? putDefaults.prefix,
|
|
307
349
|
repo,
|
|
308
350
|
ref,
|
|
309
|
-
deriveRepoFromGit: !
|
|
351
|
+
deriveRepoFromGit: !noGit,
|
|
310
352
|
dryRun,
|
|
311
353
|
metadata,
|
|
312
354
|
provenanceClient: "uploads-cli-screenshot",
|
|
313
355
|
alt: () => alt,
|
|
314
356
|
width,
|
|
315
357
|
});
|
|
358
|
+
// Staging note (issue #469 lever 1, mirrors #403's bare-put note): only for
|
|
359
|
+
// the auto-staged case — explicit `--branch` keeps its own "staged: these
|
|
360
|
+
// auto-attach..." wording below. Same suppression as put's note (--quiet,
|
|
361
|
+
// UPLOADS_NO_NUDGE=1).
|
|
362
|
+
const stagingNote = autoStagingTarget && !ctx.quiet && !putDefaults.noNudge
|
|
363
|
+
? putStagingNoteText(autoStagingTarget.branch)
|
|
364
|
+
: undefined;
|
|
365
|
+
// Stage-time binding warning (issue #398), same check bare put/attach
|
|
366
|
+
// --branch run, now also reachable from screenshot's staging paths
|
|
367
|
+
// (explicit --branch and auto-staging alike).
|
|
368
|
+
const bindingWarning = stagingTarget !== undefined
|
|
369
|
+
? await resolveStageBindingWarning({ ctx, defaults: putDefaults, repo: stagingTarget.repo })
|
|
370
|
+
: undefined;
|
|
316
371
|
let gallery;
|
|
317
372
|
if (galleryId) {
|
|
318
373
|
try {
|
|
@@ -347,8 +402,25 @@ captureImpl = captureScreenshot) {
|
|
|
347
402
|
if (prepared.optimized) {
|
|
348
403
|
process.stderr.write(`>> optimized ${prepared.originalBytes} → ${prepared.outputBytes} bytes\n`);
|
|
349
404
|
}
|
|
350
|
-
process.stderr.write(`>> key: ${result.key}${dryRun ? " (dry run — not uploaded)" : ""}\n
|
|
405
|
+
process.stderr.write(`>> key: ${result.key}${dryRun ? " (dry run — not uploaded)" : ""}\n`);
|
|
406
|
+
if (stagingTarget !== undefined) {
|
|
407
|
+
process.stderr.write(`>> find these later: uploads find gh.branch=${stagingTarget.branch.toLowerCase()}\n`);
|
|
408
|
+
if (autoStagingTarget) {
|
|
409
|
+
if (stagingNote)
|
|
410
|
+
process.stderr.write(`${stagingNote}\n`);
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
process.stderr.write(`>> staged: these auto-attach to this branch's PR when it opens ` +
|
|
414
|
+
`(or run \`uploads attach --promote\` after opening)\n`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (bindingWarning)
|
|
418
|
+
process.stderr.write(`${bindingWarning}\n`);
|
|
419
|
+
process.stderr.write("\n");
|
|
351
420
|
}
|
|
421
|
+
// One JSON `hint` slot (mirrors bare put): the binding warning is more
|
|
422
|
+
// actionable than the generic staging note, so it wins when both fire.
|
|
423
|
+
const jsonHint = bindingWarning ?? stagingNote;
|
|
352
424
|
switch (format) {
|
|
353
425
|
case "json":
|
|
354
426
|
await writeJson({
|
|
@@ -363,6 +435,7 @@ captureImpl = captureScreenshot) {
|
|
|
363
435
|
backend: captured.backend,
|
|
364
436
|
gallery,
|
|
365
437
|
...(dryRun ? { dryRun: true } : {}),
|
|
438
|
+
...(jsonHint ? { hint: jsonHint } : {}),
|
|
366
439
|
});
|
|
367
440
|
break;
|
|
368
441
|
case "url":
|
package/dist/commands.d.ts
CHANGED
|
@@ -167,6 +167,21 @@ export declare function commentViaSuffix(via: AttachmentsCommentResult["via"]):
|
|
|
167
167
|
export declare class GithubCommentAuthorizationError extends Error {
|
|
168
168
|
}
|
|
169
169
|
export declare function syncAttachmentsComment(client: UploadsClient, target: GhTarget, run: CommandRunner, workspace?: string): Promise<AttachmentsCommentResult>;
|
|
170
|
+
/**
|
|
171
|
+
* Lever 3 (issue #469): a nudge for when an image lands on a PR/issue with
|
|
172
|
+
* no `path` metadata — `path` is one of the highest-value queryable tags
|
|
173
|
+
* (same tier as `state=`), and unlike `uploads screenshot` (which derives it
|
|
174
|
+
* from the captured URL), a plain `attach`/`put --pr`/`put --issue` of an
|
|
175
|
+
* already-existing image has nothing to derive it from, so it's easy to
|
|
176
|
+
* forget. Fires once per batch (not per file) — checks the metadata the
|
|
177
|
+
* server actually stored (`PutResult.metadata`), not what was requested, so
|
|
178
|
+
* a merge/validation drop still surfaces the gap. Non-image uploads (zips,
|
|
179
|
+
* PDFs, etc.) are exempt — "findable by page" doesn't apply to them.
|
|
180
|
+
*/
|
|
181
|
+
export declare function pathMetaHintFor(uploads: {
|
|
182
|
+
contentType: string;
|
|
183
|
+
metadata?: Record<string, string>;
|
|
184
|
+
}[]): string | undefined;
|
|
170
185
|
export type AttachUploadItem = PutResult & {
|
|
171
186
|
file: string;
|
|
172
187
|
markdown: string;
|
|
@@ -355,6 +370,14 @@ export declare function resolvePutStagingTarget(opts: {
|
|
|
355
370
|
repoArg: string | undefined;
|
|
356
371
|
run: CommandRunner;
|
|
357
372
|
}): BranchTarget | undefined;
|
|
373
|
+
/**
|
|
374
|
+
* Merges a staging target's `gh.*` branch metadata over `base` and validates
|
|
375
|
+
* the result (same builder, same contract as `attach --branch`) — the one
|
|
376
|
+
* merge+validate step shared by every staging call site: `runPut`,
|
|
377
|
+
* `runScreenshot`, and both the local stdio MCP `put` and `screenshot`
|
|
378
|
+
* tools.
|
|
379
|
+
*/
|
|
380
|
+
export declare function mergeStagingMeta(base: Record<string, string> | undefined, target: BranchTarget): Record<string, string>;
|
|
358
381
|
/**
|
|
359
382
|
* The bare-put staging note's wording (issue #403): replaces the #393 nudge
|
|
360
383
|
* for the (now default) case where a bare put on a non-default branch stages
|
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.
|
|
@@ -558,6 +565,12 @@ URL and every embed hot-swap. Human mode prints ">> replaced existing object
|
|
|
558
565
|
Still images are optimized to WebP by default (same as put). Use --no-optimize
|
|
559
566
|
to upload originals. Optional --frame wraps images in device/browser chrome.
|
|
560
567
|
|
|
568
|
+
If a file has a sidecar manifest (<file>.uploads.json, written by
|
|
569
|
+
"screenshot --out") and its content hash still matches, that capture's
|
|
570
|
+
derived metadata (path/url/env/viewport/state) is merged in automatically —
|
|
571
|
+
explicit --meta/--state always win. A regenerated or edited file loses its
|
|
572
|
+
sidecar silently (hash no longer matches).
|
|
573
|
+
|
|
561
574
|
Branch staging (pre-PR): --branch [name] stages files against a git branch
|
|
562
575
|
before a pull request exists, e.g. for a coding agent working a branch that
|
|
563
576
|
hasn't opened a PR yet. Key: gh/<owner>/<repo>/branch/<branch>/<filename>
|
|
@@ -620,6 +633,21 @@ Examples:
|
|
|
620
633
|
uploads attach ./shot.png --branch feature/new-settings
|
|
621
634
|
uploads attach --promote
|
|
622
635
|
`;
|
|
636
|
+
/**
|
|
637
|
+
* Lever 3 (issue #469): a nudge for when an image lands on a PR/issue with
|
|
638
|
+
* no `path` metadata — `path` is one of the highest-value queryable tags
|
|
639
|
+
* (same tier as `state=`), and unlike `uploads screenshot` (which derives it
|
|
640
|
+
* from the captured URL), a plain `attach`/`put --pr`/`put --issue` of an
|
|
641
|
+
* already-existing image has nothing to derive it from, so it's easy to
|
|
642
|
+
* forget. Fires once per batch (not per file) — checks the metadata the
|
|
643
|
+
* server actually stored (`PutResult.metadata`), not what was requested, so
|
|
644
|
+
* a merge/validation drop still surfaces the gap. Non-image uploads (zips,
|
|
645
|
+
* PDFs, etc.) are exempt — "findable by page" doesn't apply to them.
|
|
646
|
+
*/
|
|
647
|
+
export function pathMetaHintFor(uploads) {
|
|
648
|
+
const missingPath = uploads.some((u) => u.contentType.startsWith("image/") && !u.metadata?.path);
|
|
649
|
+
return missingPath ? "tip: add --meta path=/route so this shot is findable by page" : undefined;
|
|
650
|
+
}
|
|
623
651
|
/**
|
|
624
652
|
* Shared prepare + put loop for both PR/issue attach (`uploadAttachments`)
|
|
625
653
|
* and branch-staged attach (`uploadBranchAttachments`) — bounded concurrency,
|
|
@@ -634,11 +662,14 @@ async function uploadAttachmentBatch(opts) {
|
|
|
634
662
|
try {
|
|
635
663
|
const sourceName = basename(file);
|
|
636
664
|
const bytes = readFileArg(file);
|
|
665
|
+
// Sidecar manifest from a prior `screenshot --out` of this exact file
|
|
666
|
+
// (issue #469 lever 2) — see mergeSidecarMeta.
|
|
667
|
+
const baseMetadata = mergeSidecarMeta(file, bytes, opts.metadata);
|
|
637
668
|
// Same EXIF promotion uploadPreparedImage does; attach keeps its own
|
|
638
669
|
// per-file tail (it builds keys differently), so it opts in here too.
|
|
639
670
|
const metadata = opts.deriveImageFacts
|
|
640
|
-
? await mergeImageFacts(bytes,
|
|
641
|
-
:
|
|
671
|
+
? await mergeImageFacts(bytes, baseMetadata)
|
|
672
|
+
: baseMetadata;
|
|
642
673
|
const prepared = await prepareImageForUpload(bytes, sourceName, {
|
|
643
674
|
...opts.frame,
|
|
644
675
|
optimize: opts.optimize,
|
|
@@ -743,7 +774,11 @@ export async function uploadPuts(opts) {
|
|
|
743
774
|
? basename(opts.explicitKey)
|
|
744
775
|
: "stdin.bin"
|
|
745
776
|
: basename(file));
|
|
746
|
-
const
|
|
777
|
+
const bytes = readFileArg(file);
|
|
778
|
+
// Sidecar manifest from a prior `screenshot --out` of this exact file
|
|
779
|
+
// (issue #469 lever 2) — see mergeSidecarMeta. Not applicable to stdin.
|
|
780
|
+
const metadata = file !== "-" ? mergeSidecarMeta(file, bytes, opts.metadata) : opts.metadata;
|
|
781
|
+
const { result, prepared, markdown } = await uploadPreparedImage(opts.client, bytes, sourceName, {
|
|
747
782
|
frame: opts.frame,
|
|
748
783
|
optimize: opts.optimize,
|
|
749
784
|
ghTarget: opts.ghTarget,
|
|
@@ -756,7 +791,7 @@ export async function uploadPuts(opts) {
|
|
|
756
791
|
contentType: opts.contentType,
|
|
757
792
|
dryRun: opts.dryRun,
|
|
758
793
|
replace: opts.replace,
|
|
759
|
-
metadata
|
|
794
|
+
metadata,
|
|
760
795
|
deriveImageFacts: opts.deriveImageFacts,
|
|
761
796
|
provenanceClient: opts.provenanceClient,
|
|
762
797
|
alt: () => opts.alt ?? basename(sourceName),
|
|
@@ -935,6 +970,8 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
935
970
|
process.stderr.write(`warning: uploads succeeded but the GitHub comment failed (is gh installed and authenticated?): ${commentError}\n`);
|
|
936
971
|
}
|
|
937
972
|
}
|
|
973
|
+
// Lever 3 (issue #469): tip when an image lands here with no `path` meta.
|
|
974
|
+
const pathHint = uploads.length > 0 && !ctx.quiet ? pathMetaHintFor(uploads) : undefined;
|
|
938
975
|
if (ctx.json) {
|
|
939
976
|
await writeJson({
|
|
940
977
|
target,
|
|
@@ -943,6 +980,7 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
943
980
|
comment,
|
|
944
981
|
commentError,
|
|
945
982
|
promotion: promotion ?? null,
|
|
983
|
+
...(pathHint ? { hint: pathHint } : {}),
|
|
946
984
|
});
|
|
947
985
|
}
|
|
948
986
|
else {
|
|
@@ -971,6 +1009,8 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
971
1009
|
const ref = ghMetadataFromTarget(target)["gh.ref"];
|
|
972
1010
|
process.stderr.write(`>> find these later: uploads find gh.ref=${ref}\n`);
|
|
973
1011
|
}
|
|
1012
|
+
if (pathHint)
|
|
1013
|
+
process.stderr.write(`${pathHint}\n`);
|
|
974
1014
|
}
|
|
975
1015
|
return failures.length === 0 ? 0 : 1;
|
|
976
1016
|
}
|
|
@@ -1269,6 +1309,18 @@ export function resolvePutStagingTarget(opts) {
|
|
|
1269
1309
|
return undefined; // gh/git unavailable, or repo unresolvable — dated layout
|
|
1270
1310
|
}
|
|
1271
1311
|
}
|
|
1312
|
+
/**
|
|
1313
|
+
* Merges a staging target's `gh.*` branch metadata over `base` and validates
|
|
1314
|
+
* the result (same builder, same contract as `attach --branch`) — the one
|
|
1315
|
+
* merge+validate step shared by every staging call site: `runPut`,
|
|
1316
|
+
* `runScreenshot`, and both the local stdio MCP `put` and `screenshot`
|
|
1317
|
+
* tools.
|
|
1318
|
+
*/
|
|
1319
|
+
export function mergeStagingMeta(base, target) {
|
|
1320
|
+
const merged = { ...base, ...ghMetadataForBranch(target.repo, target.branch) };
|
|
1321
|
+
validateMetaMap(merged);
|
|
1322
|
+
return merged;
|
|
1323
|
+
}
|
|
1272
1324
|
/**
|
|
1273
1325
|
* The bare-put staging note's wording (issue #403): replaces the #393 nudge
|
|
1274
1326
|
* for the (now default) case where a bare put on a non-default branch stages
|
|
@@ -1553,12 +1605,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
1553
1605
|
attachedRef = merged["gh.ref"];
|
|
1554
1606
|
}
|
|
1555
1607
|
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;
|
|
1608
|
+
metadata = mergeStagingMeta(userMeta, stagingTarget);
|
|
1562
1609
|
}
|
|
1563
1610
|
else {
|
|
1564
1611
|
// gh.* additionally needs git, which the shared derived gate ignores.
|
|
@@ -1648,13 +1695,19 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
1648
1695
|
const bindingWarning = stagingTarget && uploads.length > 0
|
|
1649
1696
|
? await resolveStageBindingWarning({ ctx, defaults, repo: stagingTarget.repo })
|
|
1650
1697
|
: undefined;
|
|
1698
|
+
// Lever 3 (issue #469): tip when a --pr/--issue put lands an image with no
|
|
1699
|
+
// `path` meta. Only relevant on the ghTarget path — the bare-put paths
|
|
1700
|
+
// above (staging/auto/dated) aren't attached to a PR/issue yet, so there's
|
|
1701
|
+
// nothing to look up from a page later.
|
|
1702
|
+
const pathHint = ghTarget && uploads.length > 0 && !ctx.quiet ? pathMetaHintFor(uploads) : undefined;
|
|
1651
1703
|
// One JSON `hint` slot, shared with the #393 nudge (mutually exclusive with
|
|
1652
1704
|
// it — nudge is undefined whenever staging took over). When staging fires,
|
|
1653
1705
|
// prefer the more actionable binding warning over the generic staging note
|
|
1654
1706
|
// (mirrors attach --branch, whose only JSON hint content IS the binding
|
|
1655
1707
|
// warning); stderr prints the nudge/staging-note and binding-warning lines
|
|
1656
|
-
// independently, below.
|
|
1657
|
-
|
|
1708
|
+
// independently, below. pathHint only ever fires on the ghTarget path, so
|
|
1709
|
+
// it never competes with the other three.
|
|
1710
|
+
const jsonHint = nudge ?? bindingWarning ?? stagingNote ?? pathHint;
|
|
1658
1711
|
const galleriesByKey = new Map();
|
|
1659
1712
|
let galleryHadError = false;
|
|
1660
1713
|
if (galleryId && uploads.length > 0) {
|
|
@@ -1737,6 +1790,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
1737
1790
|
process.stderr.write(`${stagingNote}\n`);
|
|
1738
1791
|
if (bindingWarning)
|
|
1739
1792
|
process.stderr.write(`${bindingWarning}\n`);
|
|
1793
|
+
if (pathHint)
|
|
1794
|
+
process.stderr.write(`${pathHint}\n`);
|
|
1740
1795
|
}
|
|
1741
1796
|
return failures.length === 0 && !galleryHadError ? 0 : 1;
|
|
1742
1797
|
}
|
|
@@ -1794,6 +1849,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
1794
1849
|
process.stderr.write(`${stagingNote}\n`);
|
|
1795
1850
|
if (bindingWarning && format !== "json")
|
|
1796
1851
|
process.stderr.write(`${bindingWarning}\n`);
|
|
1852
|
+
if (pathHint && format !== "json")
|
|
1853
|
+
process.stderr.write(`${pathHint}\n`);
|
|
1797
1854
|
return gallery?.error ? 1 : 0;
|
|
1798
1855
|
}
|
|
1799
1856
|
// --- galleries ---
|
|
@@ -2173,12 +2230,50 @@ export async function runMeta(ctx, args, help = false) {
|
|
|
2173
2230
|
else
|
|
2174
2231
|
for (const [k, v] of Object.entries(result.metadata))
|
|
2175
2232
|
await writeStdout(`${k}=${v}\n`);
|
|
2233
|
+
await resyncCommentAfterMetaSet(ctx, key, [...Object.keys(set ?? {}), ...del]);
|
|
2176
2234
|
return 0;
|
|
2177
2235
|
}
|
|
2178
2236
|
default:
|
|
2179
2237
|
throw new UsageError(`unknown meta command: ${action}`);
|
|
2180
2238
|
}
|
|
2181
2239
|
}
|
|
2240
|
+
/** The metadata keys the managed comment renders (path/state, PR #370). */
|
|
2241
|
+
const COMMENT_RENDERED_META_KEYS = ["path", "state"];
|
|
2242
|
+
/**
|
|
2243
|
+
* Best-effort managed-comment refresh after `meta set` touches a
|
|
2244
|
+
* display-relevant key on a PR/issue-keyed object (issue #470) — without
|
|
2245
|
+
* this, backfilled `path=`/`state=` never reaches the rendered comment until
|
|
2246
|
+
* an unrelated attach fires. Bot endpoint only (no gh fallback — this is a
|
|
2247
|
+
* metadata tweak, not an explicit comment command); any failure degrades to
|
|
2248
|
+
* a stderr hint instead of failing the metadata write that already landed.
|
|
2249
|
+
*/
|
|
2250
|
+
async function resyncCommentAfterMetaSet(ctx, key, touchedKeys) {
|
|
2251
|
+
if (!touchedKeys.some((k) => COMMENT_RENDERED_META_KEYS.includes(k)))
|
|
2252
|
+
return;
|
|
2253
|
+
const target = parseGhKey(key);
|
|
2254
|
+
if (!target)
|
|
2255
|
+
return;
|
|
2256
|
+
try {
|
|
2257
|
+
const bot = await ctx.client.upsertGithubComment({
|
|
2258
|
+
repo: target.repo,
|
|
2259
|
+
num: target.num,
|
|
2260
|
+
kind: target.kind,
|
|
2261
|
+
});
|
|
2262
|
+
if (bot.posted) {
|
|
2263
|
+
if (!ctx.quiet && !ctx.json) {
|
|
2264
|
+
process.stderr.write(`refreshed the managed comment on ${target.repo}#${target.num}\n`);
|
|
2265
|
+
}
|
|
2266
|
+
return;
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
catch {
|
|
2270
|
+
// Fall through to the hint.
|
|
2271
|
+
}
|
|
2272
|
+
if (!ctx.quiet && !ctx.json) {
|
|
2273
|
+
const flag = target.kind === "pull" ? "--pr" : "--issue";
|
|
2274
|
+
process.stderr.write(`tip: run \`uploads comment ${flag} ${target.num}\` to refresh the PR comment\n`);
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2182
2277
|
// --- delete ---
|
|
2183
2278
|
const DELETE_HELP = `uploads delete <key> [--dry-run] [--workspace <name>]
|
|
2184
2279
|
|
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}/`;
|
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,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** The sidecar path for a given local file — `<file>.uploads.json`. */
|
|
2
|
+
export declare function sidecarPath(filePath: string): string;
|
|
3
|
+
/** Hex-encoded SHA-256 of `bytes`. */
|
|
4
|
+
export declare function sha256Hex(bytes: Uint8Array): string;
|
|
5
|
+
/** Keep only entries whose key is in the closed canonical metadata vocabulary. */
|
|
6
|
+
export declare function restrictToCanonicalMeta(meta: Record<string, string>): Record<string, string>;
|
|
7
|
+
/**
|
|
8
|
+
* Write a sidecar manifest next to `filePath`, recording `meta` (restricted
|
|
9
|
+
* to the canonical vocabulary) and the SHA-256 of `bytes` (the exact bytes
|
|
10
|
+
* being written to `filePath`). No-ops when `meta` is empty — an image with
|
|
11
|
+
* no derived metadata gets no sidecar. Best-effort: a write failure (e.g. a
|
|
12
|
+
* read-only directory) is swallowed, matching the rest of the derived-
|
|
13
|
+
* metadata pipeline's "never fail the primary operation" contract.
|
|
14
|
+
*/
|
|
15
|
+
export declare function writeSidecarMeta(filePath: string, bytes: Uint8Array, meta: Record<string, string>): void;
|
|
16
|
+
/**
|
|
17
|
+
* Read back a sidecar manifest for `filePath`, only when it is present,
|
|
18
|
+
* well-formed, and its recorded hash matches `bytes` (the file's current
|
|
19
|
+
* content, as read for the upload in progress). Returns `undefined` on any
|
|
20
|
+
* absence, parse failure, malformed shape, or hash mismatch — a sidecar is a
|
|
21
|
+
* best-effort convenience and must never fail or noise an upload. Returned
|
|
22
|
+
* keys are always a subset of `CANONICAL_META_KEYS`, so a hand-edited
|
|
23
|
+
* manifest can never inject arbitrary metadata.
|
|
24
|
+
*/
|
|
25
|
+
export declare function readSidecarMeta(filePath: string, bytes: Uint8Array): Record<string, string> | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Merge `filePath`'s sidecar metadata (if any, per {@link readSidecarMeta})
|
|
28
|
+
* under `baseMeta` — explicit metadata always wins. Shared by the `put` and
|
|
29
|
+
* `attach` upload loops (issue #469 lever 2).
|
|
30
|
+
*/
|
|
31
|
+
export declare function mergeSidecarMeta(filePath: string, bytes: Uint8Array, baseMeta: Record<string, string> | undefined): Record<string, string> | undefined;
|
package/dist/sidecar.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidecar manifest for `uploads screenshot --out`: derived metadata written
|
|
3
|
+
* next to a local screenshot file so a later `put`/`attach` of that exact
|
|
4
|
+
* file can recover the metadata the hosted copy would have gotten at capture
|
|
5
|
+
* time. See issue #469 lever 2 (the "sidecar manifest" variant — the
|
|
6
|
+
* alternative, content-hash-keyed server-side inheritance, is out of scope
|
|
7
|
+
* here).
|
|
8
|
+
*
|
|
9
|
+
* File: `<file>.uploads.json` next to `<file>`, e.g. `shot.png.uploads.json`.
|
|
10
|
+
* Shape: `{ version, sha256, meta }`. `sha256` is the SHA-256 of the exact
|
|
11
|
+
* bytes written to `<file>` at capture time — read-back compares it against
|
|
12
|
+
* the file's *current* bytes, so a file that was regenerated or hand-edited
|
|
13
|
+
* since capture silently loses its sidecar instead of attaching stale
|
|
14
|
+
* metadata to a different image.
|
|
15
|
+
*
|
|
16
|
+
* `meta` is filtered to the closed `CANONICAL_META_KEYS` vocabulary
|
|
17
|
+
* (metadata-vocab.ts) on both write and read: the sidecar is a plain JSON
|
|
18
|
+
* file sitting next to the image, so it must never be a channel for
|
|
19
|
+
* arbitrary metadata even if hand-edited.
|
|
20
|
+
*/
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { CANONICAL_META_KEYS, mergeDerivedMeta } from "./metadata-vocab.js";
|
|
24
|
+
const SIDECAR_VERSION = 1;
|
|
25
|
+
/** The sidecar path for a given local file — `<file>.uploads.json`. */
|
|
26
|
+
export function sidecarPath(filePath) {
|
|
27
|
+
return `${filePath}.uploads.json`;
|
|
28
|
+
}
|
|
29
|
+
/** Hex-encoded SHA-256 of `bytes`. */
|
|
30
|
+
export function sha256Hex(bytes) {
|
|
31
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
32
|
+
}
|
|
33
|
+
/** Keep only entries whose key is in the closed canonical metadata vocabulary. */
|
|
34
|
+
export function restrictToCanonicalMeta(meta) {
|
|
35
|
+
const out = {};
|
|
36
|
+
for (const key of CANONICAL_META_KEYS) {
|
|
37
|
+
if (Object.prototype.hasOwnProperty.call(meta, key))
|
|
38
|
+
out[key] = meta[key];
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
function isPlainStringRecord(value) {
|
|
43
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
44
|
+
return false;
|
|
45
|
+
return Object.values(value).every((v) => typeof v === "string");
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Write a sidecar manifest next to `filePath`, recording `meta` (restricted
|
|
49
|
+
* to the canonical vocabulary) and the SHA-256 of `bytes` (the exact bytes
|
|
50
|
+
* being written to `filePath`). No-ops when `meta` is empty — an image with
|
|
51
|
+
* no derived metadata gets no sidecar. Best-effort: a write failure (e.g. a
|
|
52
|
+
* read-only directory) is swallowed, matching the rest of the derived-
|
|
53
|
+
* metadata pipeline's "never fail the primary operation" contract.
|
|
54
|
+
*/
|
|
55
|
+
export function writeSidecarMeta(filePath, bytes, meta) {
|
|
56
|
+
const restricted = restrictToCanonicalMeta(meta);
|
|
57
|
+
if (Object.keys(restricted).length === 0)
|
|
58
|
+
return;
|
|
59
|
+
try {
|
|
60
|
+
const manifest = {
|
|
61
|
+
version: SIDECAR_VERSION,
|
|
62
|
+
sha256: sha256Hex(bytes),
|
|
63
|
+
meta: restricted,
|
|
64
|
+
};
|
|
65
|
+
writeFileSync(sidecarPath(filePath), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// best-effort — never fail the screenshot over a sidecar write
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Read back a sidecar manifest for `filePath`, only when it is present,
|
|
73
|
+
* well-formed, and its recorded hash matches `bytes` (the file's current
|
|
74
|
+
* content, as read for the upload in progress). Returns `undefined` on any
|
|
75
|
+
* absence, parse failure, malformed shape, or hash mismatch — a sidecar is a
|
|
76
|
+
* best-effort convenience and must never fail or noise an upload. Returned
|
|
77
|
+
* keys are always a subset of `CANONICAL_META_KEYS`, so a hand-edited
|
|
78
|
+
* manifest can never inject arbitrary metadata.
|
|
79
|
+
*/
|
|
80
|
+
export function readSidecarMeta(filePath, bytes) {
|
|
81
|
+
const path = sidecarPath(filePath);
|
|
82
|
+
try {
|
|
83
|
+
if (!existsSync(path))
|
|
84
|
+
return undefined;
|
|
85
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
86
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
87
|
+
return undefined;
|
|
88
|
+
const candidate = parsed;
|
|
89
|
+
if (candidate.version !== SIDECAR_VERSION ||
|
|
90
|
+
typeof candidate.sha256 !== "string" ||
|
|
91
|
+
!isPlainStringRecord(candidate.meta)) {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
if (candidate.sha256 !== sha256Hex(bytes))
|
|
95
|
+
return undefined; // stale/regenerated file
|
|
96
|
+
const restricted = restrictToCanonicalMeta(candidate.meta);
|
|
97
|
+
return Object.keys(restricted).length > 0 ? restricted : undefined;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Merge `filePath`'s sidecar metadata (if any, per {@link readSidecarMeta})
|
|
105
|
+
* under `baseMeta` — explicit metadata always wins. Shared by the `put` and
|
|
106
|
+
* `attach` upload loops (issue #469 lever 2).
|
|
107
|
+
*/
|
|
108
|
+
export function mergeSidecarMeta(filePath, bytes, baseMeta) {
|
|
109
|
+
const sidecarMeta = readSidecarMeta(filePath, bytes);
|
|
110
|
+
return sidecarMeta ? mergeDerivedMeta(baseMeta ?? {}, sidecarMeta) : baseMeta;
|
|
111
|
+
}
|