@buildinternet/uploads 0.19.0 → 0.21.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/capture-facts.d.ts +19 -0
- package/dist/capture-facts.js +49 -0
- package/dist/cli-catalog.js +4 -0
- package/dist/cli-help.js +4 -4
- package/dist/client.d.ts +23 -2
- package/dist/client.js +12 -4
- package/dist/commands/screenshot.js +15 -7
- package/dist/commands.d.ts +47 -2
- package/dist/commands.js +153 -33
- package/dist/errors.d.ts +10 -2
- package/dist/errors.js +8 -1
- package/dist/image-facts.d.ts +12 -0
- package/dist/image-facts.js +108 -0
- package/dist/mcp/args.d.ts +24 -1
- package/dist/mcp/args.js +61 -1
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/mcp/tools.js +42 -15
- package/dist/metadata-vocab.d.ts +24 -0
- package/dist/metadata-vocab.js +127 -0
- package/dist/metadata.d.ts +7 -0
- package/dist/metadata.js +13 -0
- package/package.json +2 -1
package/dist/commands.js
CHANGED
|
@@ -8,7 +8,9 @@ import { buildMarkdown } from "./embed.js";
|
|
|
8
8
|
import { urlForGithubEmbed } from "./public-urls.js";
|
|
9
9
|
import { UploadsError } from "./errors.js";
|
|
10
10
|
import { writeJson, writeStdout } from "./io.js";
|
|
11
|
+
import { imageFactsFromBytes } from "./image-facts.js";
|
|
11
12
|
import { parseMetaFlags, validateMetaMap } from "./metadata.js";
|
|
13
|
+
import { mergeDerivedMeta, nearMissMetaWarnings, validateStateValue } from "./metadata-vocab.js";
|
|
12
14
|
import { ghAttachmentKey, ghBranchAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, normalizeGithubCoordinate, } from "./github.js";
|
|
13
15
|
import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, classifyGhNumber, execRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, } from "./github-gh.js";
|
|
14
16
|
import { resolvePutPrefix } from "./destinations.js";
|
|
@@ -57,10 +59,15 @@ Uploads are public. --pr/--issue keys include the repo, number, and filename and
|
|
|
57
59
|
remain public even for private/internal GitHub repositories. Upload only media
|
|
58
60
|
that is safe at a predictable public URL.
|
|
59
61
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
Overwrite semantics depend on the key (issue #174): --pr/--issue always
|
|
63
|
+
hot-swap in place (no prompt) so embeds stay stable — human mode prints
|
|
64
|
+
">> replaced existing object (same URL)" after a real put, or ">> would
|
|
65
|
+
replace existing object (same URL)" on --dry-run. Every other key (--key, or
|
|
66
|
+
the default put path) is strict: re-uploading to an existing key REFUSES with
|
|
67
|
+
a "key_exists" error (JSON includes the existing object's url) unless you
|
|
68
|
+
pass --replace, or set UPLOADS_OVERWRITE=1 to restore old always-overwrite
|
|
69
|
+
behavior for those paths. --dry-run reports ">> would refuse: key already
|
|
70
|
+
exists" instead of writing.
|
|
64
71
|
|
|
65
72
|
Human/json output includes durable url and (when dual-host applies) embedUrl.
|
|
66
73
|
MARKDOWN prefers embedUrl for GitHub. Override: UPLOADS_EMBED_PUBLIC_BASE_URL.
|
|
@@ -97,7 +104,12 @@ Options:
|
|
|
97
104
|
Re-uploading to an existing key WITH --meta replaces that file's
|
|
98
105
|
entire metadata set; without --meta the existing metadata is
|
|
99
106
|
preserved. Use "uploads meta set" to edit individual keys.
|
|
100
|
-
--
|
|
107
|
+
--state <s> before|after|empty|error|loading — the UI state shown (sets meta state=)
|
|
108
|
+
--app <name> Surface shown: web, ios, android, cli (sets meta app=)
|
|
109
|
+
--replace Allow overwriting an existing object on a strict (--key/default) key
|
|
110
|
+
(or UPLOADS_OVERWRITE=1). No effect on --pr/--issue, which always overwrite.
|
|
111
|
+
--dry-run Print key + public URL without uploading; reports if the key would replace
|
|
112
|
+
(or, on a strict key, be refused). Not with --comment/--gallery
|
|
101
113
|
|
|
102
114
|
Exit codes: 0 ok · 2 usage/token/file · 3 auth/policy · 4 network · 1 other (incl. partial multi-file failure).
|
|
103
115
|
Scripted formats (json|url|markdown) also print failures on stdout.
|
|
@@ -111,7 +123,7 @@ Examples:
|
|
|
111
123
|
uploads put ./capture-….webp --pr 128 --name hero.webp
|
|
112
124
|
uploads put ./shot.png --pr 128 --name hero.webp --dry-run --format url
|
|
113
125
|
uploads put ./after.png --gallery gal_example
|
|
114
|
-
uploads put ./shot.png --meta
|
|
126
|
+
uploads put ./shot.png --meta path=/settings --state after --app web
|
|
115
127
|
`;
|
|
116
128
|
/**
|
|
117
129
|
* Turns a pr/issue pair (+ optional repo) into a GhTarget; undefined when
|
|
@@ -233,6 +245,50 @@ export function optimizeOptionsFromFlags(flags, defaults) {
|
|
|
233
245
|
keepExif: flagBool(flags, "--keep-exif") || defaults.keepExif === true,
|
|
234
246
|
};
|
|
235
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* Whether the derived-metadata tier is on — screenshot capture facts and EXIF
|
|
250
|
+
* promotion. `--no-auto` and `UPLOADS_NO_AUTO_META=1` turn it off; `--auto`
|
|
251
|
+
* forces past the config default.
|
|
252
|
+
*
|
|
253
|
+
* Deliberately *not* gated on `--no-git`. That flag means "don't shell out to
|
|
254
|
+
* git", which says nothing about a viewport or a URL path — a capture of a
|
|
255
|
+
* local .html file outside any repo should still record what it captured.
|
|
256
|
+
* `--no-git` still disables gh.* below, which genuinely needs a repo.
|
|
257
|
+
*/
|
|
258
|
+
export function derivedMetaEnabled(flags, defaults) {
|
|
259
|
+
return (!flagBool(flags, "--no-auto") && (flagBool(flags, "--auto") || defaults.noAutoMeta !== true));
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Warn about metadata keys that look like misspellings of canonical ones, then
|
|
263
|
+
* return the map unchanged — we nag, we never rewrite a caller's key.
|
|
264
|
+
*/
|
|
265
|
+
export function warnNearMissMeta(ctx, meta) {
|
|
266
|
+
if (!ctx.quiet) {
|
|
267
|
+
for (const warning of nearMissMetaWarnings(Object.keys(meta))) {
|
|
268
|
+
process.stderr.write(`!! ${warning}\n`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return meta;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Canonical `state`/`app` pairs from their dedicated flags. Shared by put,
|
|
275
|
+
* attach and screenshot. These are sugar for the matching `--meta` keys; the
|
|
276
|
+
* point is `--help` discoverability and `--state` validation.
|
|
277
|
+
*/
|
|
278
|
+
export function stateAppMetaFromFlags(flags) {
|
|
279
|
+
const meta = {};
|
|
280
|
+
const state = flagString(flags, "--state");
|
|
281
|
+
if (state !== undefined)
|
|
282
|
+
meta.state = validateStateValue(state);
|
|
283
|
+
const app = flagString(flags, "--app");
|
|
284
|
+
if (app !== undefined) {
|
|
285
|
+
const normalized = app.trim().toLowerCase();
|
|
286
|
+
if (normalized.length === 0)
|
|
287
|
+
throw new UsageError("--app requires a value");
|
|
288
|
+
meta.app = normalized;
|
|
289
|
+
}
|
|
290
|
+
return meta;
|
|
291
|
+
}
|
|
236
292
|
function formatOptimizeNote(opt) {
|
|
237
293
|
if (opt.optimized) {
|
|
238
294
|
return `optimized ${formatByteSize(opt.originalBytes)} → ${formatByteSize(opt.outputBytes)} (${opt.filename})`;
|
|
@@ -242,8 +298,14 @@ function formatOptimizeNote(opt) {
|
|
|
242
298
|
}
|
|
243
299
|
return undefined;
|
|
244
300
|
}
|
|
245
|
-
function writeReplacedNote(replaced, quiet, dryRun = false) {
|
|
246
|
-
if (
|
|
301
|
+
function writeReplacedNote(replaced, quiet, dryRun = false, wouldRefuse = false) {
|
|
302
|
+
if (quiet)
|
|
303
|
+
return;
|
|
304
|
+
if (dryRun && wouldRefuse) {
|
|
305
|
+
process.stderr.write(`>> would refuse: key already exists (pass --replace to overwrite; or set UPLOADS_OVERWRITE=1)\n`);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (replaced) {
|
|
247
309
|
process.stderr.write(dryRun
|
|
248
310
|
? `>> would replace existing object (same URL)\n`
|
|
249
311
|
: `>> replaced existing object (same URL)\n`);
|
|
@@ -273,6 +335,19 @@ export async function prepareImageForUpload(bytes, filename, opts) {
|
|
|
273
335
|
const optimized = await optimizeImageForUpload(currentBytes, currentName, opts.optimize);
|
|
274
336
|
return { ...optimized, frame: frameMeta };
|
|
275
337
|
}
|
|
338
|
+
/**
|
|
339
|
+
* Merge an image's own EXIF-derived facts under any explicit metadata.
|
|
340
|
+
* Best-effort by contract: `imageFactsFromBytes` never rejects, and a full key
|
|
341
|
+
* budget drops the derived pairs rather than failing the upload. Returns the
|
|
342
|
+
* input untouched (including `undefined`) when there is nothing to add, so a
|
|
343
|
+
* metadata-free upload stays metadata-free.
|
|
344
|
+
*/
|
|
345
|
+
async function mergeImageFacts(bytes, metadata) {
|
|
346
|
+
const facts = await imageFactsFromBytes(bytes);
|
|
347
|
+
if (Object.keys(facts).length === 0)
|
|
348
|
+
return metadata;
|
|
349
|
+
return mergeDerivedMeta(metadata ?? {}, facts);
|
|
350
|
+
}
|
|
276
351
|
/**
|
|
277
352
|
* Shared bytes-oriented upload tail: frame + optimize the bytes, resolve the
|
|
278
353
|
* object key (gh attachment key wins over an explicit key; extension
|
|
@@ -283,6 +358,10 @@ export async function prepareImageForUpload(bytes, filename, opts) {
|
|
|
283
358
|
* concurrency and delegate here per item.
|
|
284
359
|
*/
|
|
285
360
|
export async function uploadPreparedImage(client, bytes, sourceName, opts) {
|
|
361
|
+
// Read EXIF from the original bytes before the optimizer strips it.
|
|
362
|
+
const metadata = opts.deriveImageFacts
|
|
363
|
+
? await mergeImageFacts(bytes, opts.metadata)
|
|
364
|
+
: opts.metadata;
|
|
286
365
|
const prepared = await prepareImageForUpload(bytes, sourceName, {
|
|
287
366
|
frameId: opts.frame.frameId,
|
|
288
367
|
frameUrl: opts.frame.frameUrl,
|
|
@@ -301,6 +380,7 @@ export async function uploadPreparedImage(client, bytes, sourceName, opts) {
|
|
|
301
380
|
contentType: prepared.optimized ? prepared.contentType : opts.contentType,
|
|
302
381
|
deriveRepoFromGit: opts.deriveRepoFromGit,
|
|
303
382
|
dryRun: opts.dryRun,
|
|
383
|
+
replace: opts.replace,
|
|
304
384
|
provenance: buildCliProvenance({
|
|
305
385
|
sourceName,
|
|
306
386
|
client: opts.provenanceClient,
|
|
@@ -308,7 +388,7 @@ export async function uploadPreparedImage(client, bytes, sourceName, opts) {
|
|
|
308
388
|
frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
|
|
309
389
|
keepExif: opts.optimize.keepExif === true,
|
|
310
390
|
}),
|
|
311
|
-
metadata
|
|
391
|
+
metadata,
|
|
312
392
|
});
|
|
313
393
|
const markdown = buildMarkdown(urlForGithubEmbed(result.url, result.embedUrl), {
|
|
314
394
|
alt: opts.alt(prepared),
|
|
@@ -347,8 +427,7 @@ export function commentViaSuffix(via) {
|
|
|
347
427
|
/**
|
|
348
428
|
* Thrown by `syncAttachmentsComment` when the server declines with
|
|
349
429
|
* `not_authorized` (issue #297 baseline control) — this repo is bound to a
|
|
350
|
-
* different workspace
|
|
351
|
-
* workspace. Deliberately not caught by the generic "bot endpoint
|
|
430
|
+
* different workspace. Deliberately not caught by the generic "bot endpoint
|
|
352
431
|
* unreachable" fallback below: falling back to gh here would let the
|
|
353
432
|
* human's own credentials post anyway, defeating the point of the
|
|
354
433
|
* server-side gate.
|
|
@@ -504,13 +583,15 @@ Options:
|
|
|
504
583
|
Because attach always sends its own gh.* pairs, re-attaching to
|
|
505
584
|
the same key always replaces that file's entire metadata set
|
|
506
585
|
(never preserves) — use "uploads meta set" to add to it instead.
|
|
586
|
+
--state <s> before|after|empty|error|loading — the UI state shown (sets meta state=)
|
|
587
|
+
--app <name> Surface shown: web, ios, android, cli (sets meta app=)
|
|
507
588
|
|
|
508
589
|
Examples:
|
|
509
590
|
uploads attach ./before.png ./after.png
|
|
510
591
|
uploads attach ./mobile.png --frame phone
|
|
511
592
|
uploads attach ./shot.png --pr 123 --repo myorg/myapp
|
|
512
593
|
uploads attach ./artifact.zip --issue 45 --no-comment
|
|
513
|
-
uploads attach ./shot.png --meta
|
|
594
|
+
uploads attach ./shot.png --meta path=/settings --state after
|
|
514
595
|
uploads attach ./shot.png --branch
|
|
515
596
|
uploads attach ./shot.png --branch feature/new-settings
|
|
516
597
|
uploads attach --promote
|
|
@@ -528,7 +609,13 @@ async function uploadAttachmentBatch(opts) {
|
|
|
528
609
|
const slots = await mapBounded(opts.files, opts.concurrency ?? UPLOAD_BATCH_CONCURRENCY, async (file) => {
|
|
529
610
|
try {
|
|
530
611
|
const sourceName = basename(file);
|
|
531
|
-
const
|
|
612
|
+
const bytes = readFileArg(file);
|
|
613
|
+
// Same EXIF promotion uploadPreparedImage does; attach keeps its own
|
|
614
|
+
// per-file tail (it builds keys differently), so it opts in here too.
|
|
615
|
+
const metadata = opts.deriveImageFacts
|
|
616
|
+
? await mergeImageFacts(bytes, opts.metadata)
|
|
617
|
+
: opts.metadata;
|
|
618
|
+
const prepared = await prepareImageForUpload(bytes, sourceName, {
|
|
532
619
|
...opts.frame,
|
|
533
620
|
optimize: opts.optimize,
|
|
534
621
|
});
|
|
@@ -543,7 +630,7 @@ async function uploadAttachmentBatch(opts) {
|
|
|
543
630
|
frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
|
|
544
631
|
keepExif: opts.optimize.keepExif === true,
|
|
545
632
|
}),
|
|
546
|
-
metadata
|
|
633
|
+
metadata,
|
|
547
634
|
});
|
|
548
635
|
return {
|
|
549
636
|
ok: true,
|
|
@@ -643,7 +730,9 @@ export async function uploadPuts(opts) {
|
|
|
643
730
|
deriveRepoFromGit: opts.deriveRepoFromGit,
|
|
644
731
|
contentType: opts.contentType,
|
|
645
732
|
dryRun: opts.dryRun,
|
|
733
|
+
replace: opts.replace,
|
|
646
734
|
metadata: opts.metadata,
|
|
735
|
+
deriveImageFacts: opts.deriveImageFacts,
|
|
647
736
|
provenanceClient: opts.provenanceClient,
|
|
648
737
|
alt: () => opts.alt ?? basename(sourceName),
|
|
649
738
|
width: opts.width,
|
|
@@ -761,8 +850,12 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
761
850
|
// Validate the merged map (not just the extras) so the 24-key/8KB caps are
|
|
762
851
|
// enforced client-side even when extras alone are under the cap but extras
|
|
763
852
|
// + the gh.* pairs push the merged map over it.
|
|
764
|
-
const metaExtras = parseMetaFlags(flagValues(parsed.flags, "--meta"));
|
|
765
|
-
const metadata = {
|
|
853
|
+
const metaExtras = warnNearMissMeta(ctx, parseMetaFlags(flagValues(parsed.flags, "--meta")));
|
|
854
|
+
const metadata = {
|
|
855
|
+
...metaExtras,
|
|
856
|
+
...stateAppMetaFromFlags(parsed.flags),
|
|
857
|
+
...ghMetadataFromTargetWithTitle(target, run),
|
|
858
|
+
};
|
|
766
859
|
if (Object.keys(metadata).length > 0)
|
|
767
860
|
validateMetaMap(metadata);
|
|
768
861
|
const logHuman = !ctx.quiet && !ctx.json;
|
|
@@ -778,6 +871,7 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
778
871
|
optimize: optimizeOpts,
|
|
779
872
|
frame: frameOpts,
|
|
780
873
|
metadata,
|
|
874
|
+
deriveImageFacts: derivedMetaEnabled(parsed.flags, defaults),
|
|
781
875
|
});
|
|
782
876
|
// Single-file total failure: rethrow so CLI exit codes stay auth/network-aware.
|
|
783
877
|
if (uploads.length === 0 && failures.length === 1 && parsed.positionals.length === 1) {
|
|
@@ -868,8 +962,12 @@ async function runAttachBranch(ctx, parsed, branch, run) {
|
|
|
868
962
|
const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, defaults);
|
|
869
963
|
const frameOpts = frameOptionsFromFlags(parsed.flags);
|
|
870
964
|
const contentTypeOverride = flagString(parsed.flags, "--content-type");
|
|
871
|
-
const metaExtras = parseMetaFlags(flagValues(parsed.flags, "--meta"));
|
|
872
|
-
const metadata = {
|
|
965
|
+
const metaExtras = warnNearMissMeta(ctx, parseMetaFlags(flagValues(parsed.flags, "--meta")));
|
|
966
|
+
const metadata = {
|
|
967
|
+
...metaExtras,
|
|
968
|
+
...stateAppMetaFromFlags(parsed.flags),
|
|
969
|
+
...ghMetadataForBranch(repo, branch),
|
|
970
|
+
};
|
|
873
971
|
validateMetaMap(metadata);
|
|
874
972
|
const logHuman = !ctx.quiet && !ctx.json;
|
|
875
973
|
if (logHuman) {
|
|
@@ -885,6 +983,7 @@ async function runAttachBranch(ctx, parsed, branch, run) {
|
|
|
885
983
|
optimize: optimizeOpts,
|
|
886
984
|
frame: frameOpts,
|
|
887
985
|
metadata,
|
|
986
|
+
deriveImageFacts: derivedMetaEnabled(parsed.flags, defaults),
|
|
888
987
|
});
|
|
889
988
|
// Single-file total failure: rethrow so CLI exit codes stay auth/network-aware.
|
|
890
989
|
if (uploads.length === 0 && failures.length === 1 && parsed.positionals.length === 1) {
|
|
@@ -987,10 +1086,20 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
987
1086
|
const galleryId = flagString(parsed.flags, "--gallery");
|
|
988
1087
|
const nameFlag = flagString(parsed.flags, "--name");
|
|
989
1088
|
const dryRun = flagBool(parsed.flags, "--dry-run");
|
|
1089
|
+
// Strict-overwrite escape hatch (issue #174): only matters on non-gh/ keys
|
|
1090
|
+
// (--key or the default put path) — the server ignores `replace` on
|
|
1091
|
+
// managed gh/ paths (--pr/--issue), which always hot-swap regardless.
|
|
1092
|
+
const replaceFlag = flagBool(parsed.flags, "--replace") || process.env.UPLOADS_OVERWRITE === "1";
|
|
990
1093
|
// Validate --meta up front (fail fast, before reading/optimizing the file).
|
|
991
1094
|
const userMeta = (() => {
|
|
992
1095
|
const pairs = flagValues(parsed.flags, "--meta");
|
|
993
|
-
|
|
1096
|
+
const fromMeta = warnNearMissMeta(ctx, pairs.length > 0 ? parseMetaFlags(pairs) : {});
|
|
1097
|
+
// Dedicated flags are explicit input and win over a same-named --meta pair.
|
|
1098
|
+
const merged = { ...fromMeta, ...stateAppMetaFromFlags(parsed.flags) };
|
|
1099
|
+
if (Object.keys(merged).length === 0)
|
|
1100
|
+
return undefined;
|
|
1101
|
+
validateMetaMap(merged);
|
|
1102
|
+
return merged;
|
|
994
1103
|
})();
|
|
995
1104
|
if (wantComment && typeof parsed.flags.get("--comment") === "string") {
|
|
996
1105
|
throw new UsageError("--comment takes no value — place it after the file argument");
|
|
@@ -1084,10 +1193,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
1084
1193
|
attachedRef = merged["gh.ref"];
|
|
1085
1194
|
}
|
|
1086
1195
|
else {
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
(flagBool(parsed.flags, "--auto") || defaults.noAutoMeta !== true);
|
|
1090
|
-
if (autoEnabled) {
|
|
1196
|
+
// gh.* additionally needs git, which the shared derived gate ignores.
|
|
1197
|
+
if (!noGit && derivedMetaEnabled(parsed.flags, defaults)) {
|
|
1091
1198
|
const autoTarget = resolveAutoGhTarget(flagString(parsed.flags, "--repo") ?? defaults.repo, flagString(parsed.flags, "--ref") ?? defaults.ref, run);
|
|
1092
1199
|
if (autoTarget) {
|
|
1093
1200
|
const autoMeta = ghMetadataFromTargetWithTitle(autoTarget, run);
|
|
@@ -1129,9 +1236,11 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
1129
1236
|
deriveRepoFromGit: !noGit,
|
|
1130
1237
|
contentType: contentTypeOverride,
|
|
1131
1238
|
dryRun,
|
|
1239
|
+
replace: replaceFlag,
|
|
1132
1240
|
optimize: optimizeOpts,
|
|
1133
1241
|
frame: frameOpts,
|
|
1134
1242
|
metadata,
|
|
1243
|
+
deriveImageFacts: derivedMetaEnabled(parsed.flags, defaults),
|
|
1135
1244
|
alt: altFlag,
|
|
1136
1245
|
width,
|
|
1137
1246
|
});
|
|
@@ -1195,7 +1304,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
1195
1304
|
const note = formatOptimizeNote(result.optimize);
|
|
1196
1305
|
if (note)
|
|
1197
1306
|
process.stderr.write(`>> ${basename(result.file)}: ${note}\n`);
|
|
1198
|
-
writeReplacedNote(result.replaced, false, dryRun);
|
|
1307
|
+
writeReplacedNote(result.replaced, false, dryRun, result.wouldRefuse);
|
|
1199
1308
|
process.stderr.write(`>> key: ${result.key}${dryRun ? " (dry run — not uploaded)" : ""}\n`);
|
|
1200
1309
|
}
|
|
1201
1310
|
const gallery = galleriesByKey.get(result.key);
|
|
@@ -1227,7 +1336,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
1227
1336
|
const note = formatOptimizeNote(result.optimize);
|
|
1228
1337
|
if (note)
|
|
1229
1338
|
process.stderr.write(`>> ${note}\n`);
|
|
1230
|
-
writeReplacedNote(result.replaced, ctx.quiet, dryRun);
|
|
1339
|
+
writeReplacedNote(result.replaced, ctx.quiet, dryRun, result.wouldRefuse);
|
|
1231
1340
|
process.stderr.write(`>> key: ${result.key}${dryRun ? " (dry run — not uploaded)" : ""}\n\n`);
|
|
1232
1341
|
}
|
|
1233
1342
|
switch (format) {
|
|
@@ -1569,7 +1678,7 @@ Human-friendly alias for \`uploads list --meta k=v...\` — same metadata filter
|
|
|
1569
1678
|
|
|
1570
1679
|
Examples:
|
|
1571
1680
|
uploads find gh.repo=buildinternet/uploads gh.number=123
|
|
1572
|
-
uploads find
|
|
1681
|
+
uploads find path=/settings state=after --prefix screenshots/
|
|
1573
1682
|
`;
|
|
1574
1683
|
export async function runFind(ctx, args, help = false) {
|
|
1575
1684
|
const parsed = parseCommandArgs(args);
|
|
@@ -1596,8 +1705,8 @@ Commands:
|
|
|
1596
1705
|
|
|
1597
1706
|
Examples:
|
|
1598
1707
|
uploads meta get screenshots/myapp/42/shot.png
|
|
1599
|
-
uploads meta set screenshots/myapp/42/shot.png
|
|
1600
|
-
uploads meta set screenshots/myapp/42/shot.png --delete
|
|
1708
|
+
uploads meta set screenshots/myapp/42/shot.png path=/settings state=after
|
|
1709
|
+
uploads meta set screenshots/myapp/42/shot.png --delete path --delete state
|
|
1601
1710
|
`;
|
|
1602
1711
|
export async function runMeta(ctx, args, help = false) {
|
|
1603
1712
|
const parsed = parseCommandArgs(args);
|
|
@@ -1694,10 +1803,9 @@ App is installed on the repo; otherwise via your local gh auth. Finds its own
|
|
|
1694
1803
|
prior comment via a hidden marker and edits it in place; never touches other
|
|
1695
1804
|
comments or the description.
|
|
1696
1805
|
|
|
1697
|
-
If this repo is bound to a different workspace
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
--status\`.
|
|
1806
|
+
If this repo is bound to a different workspace, the bot post is declined and
|
|
1807
|
+
this command fails rather than silently falling back to gh — see
|
|
1808
|
+
\`uploads github link --status\`.
|
|
1701
1809
|
|
|
1702
1810
|
Examples:
|
|
1703
1811
|
uploads --env-file .env comment --pr 123
|
|
@@ -1822,7 +1930,19 @@ async function runGithubLink(ctx, repo, statusOnly) {
|
|
|
1822
1930
|
return 0;
|
|
1823
1931
|
}
|
|
1824
1932
|
if (!statusOnly && result.claimed === false) {
|
|
1825
|
-
|
|
1933
|
+
// Cross-tenant authorization (issue #297): `reason: "not_authorized"`
|
|
1934
|
+
// means the repo is unbound but this workspace couldn't be verified as
|
|
1935
|
+
// entitled to claim it (no linked GitHub account, or that account lacks
|
|
1936
|
+
// push access) — distinct from the older "someone else already owns it"
|
|
1937
|
+
// case, which still reports `result.workspace`.
|
|
1938
|
+
if (result.reason === "not_authorized") {
|
|
1939
|
+
process.stderr.write(`note: ${repo} isn't linked to any workspace yet, and this workspace couldn't be ` +
|
|
1940
|
+
`verified as entitled to claim it. Link a GitHub account with push access to ` +
|
|
1941
|
+
`${repo}, or ask an operator to bind it explicitly.\n`);
|
|
1942
|
+
}
|
|
1943
|
+
else {
|
|
1944
|
+
process.stderr.write(`note: ${repo} is already bound to a different workspace ("${result.workspace}") — first-claim-wins, not overwritten. Run "uploads github unlink --repo ${repo}" from that workspace, or ask an operator to reassign it.\n`);
|
|
1945
|
+
}
|
|
1826
1946
|
}
|
|
1827
1947
|
await writeStdout(formatGithubLink(repo, result));
|
|
1828
1948
|
return 0;
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
export type UploadsErrorCode = "MISSING_TOKEN" | "NO_PUBLIC_URL" | "FILE_NOT_FOUND" | "NOT_FOUND" | "UNAUTHORIZED" | "INSUFFICIENT_SCOPE" | "INVALID_KEY" | "KEY_POLICY" | "STORAGE_QUOTA" | "UPLOAD_BUDGET" | "GITHUB_REQUIRED" | "API_ERROR" | "NETWORK" | "USAGE" | "BROWSER_NOT_FOUND" | "RENDER_FAILED" | "RATE_LIMITED";
|
|
1
|
+
export type UploadsErrorCode = "MISSING_TOKEN" | "NO_PUBLIC_URL" | "FILE_NOT_FOUND" | "NOT_FOUND" | "UNAUTHORIZED" | "INSUFFICIENT_SCOPE" | "INVALID_KEY" | "KEY_POLICY" | "STORAGE_QUOTA" | "UPLOAD_BUDGET" | "GITHUB_REQUIRED" | "KEY_EXISTS" | "API_ERROR" | "NETWORK" | "USAGE" | "BROWSER_NOT_FOUND" | "RENDER_FAILED" | "RATE_LIMITED";
|
|
2
2
|
export declare class UploadsError extends Error {
|
|
3
3
|
readonly code: UploadsErrorCode;
|
|
4
4
|
readonly status?: number;
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* The existing object's public URL, set only for `KEY_EXISTS` (strict
|
|
7
|
+
* overwrite refusal, issue #174) — lets a catch site point the caller at
|
|
8
|
+
* what's already there without a follow-up lookup.
|
|
9
|
+
*/
|
|
10
|
+
readonly existingUrl?: string;
|
|
11
|
+
constructor(message: string, code: UploadsErrorCode, status?: number, opts?: {
|
|
12
|
+
existingUrl?: string;
|
|
13
|
+
});
|
|
6
14
|
}
|
package/dist/errors.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
export class UploadsError extends Error {
|
|
2
2
|
code;
|
|
3
3
|
status;
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* The existing object's public URL, set only for `KEY_EXISTS` (strict
|
|
6
|
+
* overwrite refusal, issue #174) — lets a catch site point the caller at
|
|
7
|
+
* what's already there without a follow-up lookup.
|
|
8
|
+
*/
|
|
9
|
+
existingUrl;
|
|
10
|
+
constructor(message, code, status, opts) {
|
|
5
11
|
super(message);
|
|
6
12
|
this.name = "UploadsError";
|
|
7
13
|
this.code = code;
|
|
8
14
|
this.status = status;
|
|
15
|
+
this.existingUrl = opts?.existingUrl;
|
|
9
16
|
}
|
|
10
17
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map exif-reader's parsed tags onto canonical keys. Pure and total: junk
|
|
3
|
+
* input yields `{}`. Only `device`, `software` and `captured` are ever read —
|
|
4
|
+
* every other tag, including all of GPSInfo, is ignored by construction.
|
|
5
|
+
*/
|
|
6
|
+
export declare function factsFromExifTags(tags: unknown): Record<string, string>;
|
|
7
|
+
/**
|
|
8
|
+
* Read canonical facts from image bytes. Best-effort by contract: any failure
|
|
9
|
+
* (not an image, corrupt EXIF, unsupported format) yields `{}` and must never
|
|
10
|
+
* fail the upload.
|
|
11
|
+
*/
|
|
12
|
+
export declare function imageFactsFromBytes(bytes: Uint8Array): Promise<Record<string, string>>;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical metadata promoted from an image's own EXIF, read *before* the
|
|
3
|
+
* optimizer strips it from the bytes. `--keep-exif` is orthogonal: it governs
|
|
4
|
+
* whether the uploaded bytes retain EXIF, not whether we promote these keys.
|
|
5
|
+
*
|
|
6
|
+
* Promotion is allowlist-only. Anything not named here is discarded, and the
|
|
7
|
+
* denials (GPS, serials, personal names, free-form comments) are load-bearing:
|
|
8
|
+
* promoted values render on the public /f/ page.
|
|
9
|
+
*
|
|
10
|
+
* Design: .context/2026-07-21-upload-metadata-vocabulary-design.md
|
|
11
|
+
*/
|
|
12
|
+
import exifReader from "exif-reader";
|
|
13
|
+
import sharp from "sharp";
|
|
14
|
+
import { dropUnsafeMetaValues } from "./metadata.js";
|
|
15
|
+
import { formatViewport } from "./metadata-vocab.js";
|
|
16
|
+
/** Below this, the image is a 1:1 photo rather than a scaled screen capture. */
|
|
17
|
+
const SCREEN_CAPTURE_MIN_DENSITY = 72;
|
|
18
|
+
function asString(value) {
|
|
19
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
20
|
+
}
|
|
21
|
+
/** EXIF's `YYYY:MM:DD HH:MM:SS` (or a parsed Date) → ISO 8601, zone-honest. */
|
|
22
|
+
function formatCaptured(raw, offset) {
|
|
23
|
+
let stamp;
|
|
24
|
+
if (raw instanceof Date && !Number.isNaN(raw.getTime())) {
|
|
25
|
+
// exif-reader builds this Date from the EXIF wall-clock digits as if UTC,
|
|
26
|
+
// so the ISO prefix reproduces those digits exactly.
|
|
27
|
+
stamp = raw.toISOString().slice(0, 19);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
const text = asString(raw);
|
|
31
|
+
const match = text && /^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}:\d{2}:\d{2})$/.exec(text);
|
|
32
|
+
if (match)
|
|
33
|
+
stamp = `${match[1]}-${match[2]}-${match[3]}T${match[4]}`;
|
|
34
|
+
}
|
|
35
|
+
if (!stamp)
|
|
36
|
+
return undefined;
|
|
37
|
+
// Only claim a zone when EXIF actually carried one. Never append a bare "Z".
|
|
38
|
+
const zone = offset && /^[+-]\d{2}:\d{2}$/.test(offset) ? offset : "";
|
|
39
|
+
return `${stamp}${zone}`;
|
|
40
|
+
}
|
|
41
|
+
/** Combine Make + Model without repeating the make (`Canon` + `Canon EOS R5`). */
|
|
42
|
+
function formatDevice(make, model) {
|
|
43
|
+
if (!model)
|
|
44
|
+
return make;
|
|
45
|
+
if (!make)
|
|
46
|
+
return model;
|
|
47
|
+
return model.toLowerCase().startsWith(make.toLowerCase()) ? model : `${make} ${model}`;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Map exif-reader's parsed tags onto canonical keys. Pure and total: junk
|
|
51
|
+
* input yields `{}`. Only `device`, `software` and `captured` are ever read —
|
|
52
|
+
* every other tag, including all of GPSInfo, is ignored by construction.
|
|
53
|
+
*/
|
|
54
|
+
export function factsFromExifTags(tags) {
|
|
55
|
+
const facts = {};
|
|
56
|
+
if (!tags || typeof tags !== "object")
|
|
57
|
+
return facts;
|
|
58
|
+
const root = tags;
|
|
59
|
+
const image = (root.Image ?? {});
|
|
60
|
+
const photo = (root.Photo ?? {});
|
|
61
|
+
const device = formatDevice(asString(image.Make), asString(image.Model));
|
|
62
|
+
if (device)
|
|
63
|
+
facts.device = device;
|
|
64
|
+
const software = asString(image.Software);
|
|
65
|
+
if (software)
|
|
66
|
+
facts.software = software;
|
|
67
|
+
const captured = formatCaptured(photo.DateTimeOriginal, asString(photo.OffsetTimeOriginal));
|
|
68
|
+
if (captured)
|
|
69
|
+
facts.captured = captured;
|
|
70
|
+
// Derived values must satisfy the metadata contract or be dropped silently —
|
|
71
|
+
// same posture as the existing best-effort gh.title.
|
|
72
|
+
return dropUnsafeMetaValues(facts);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Read canonical facts from image bytes. Best-effort by contract: any failure
|
|
76
|
+
* (not an image, corrupt EXIF, unsupported format) yields `{}` and must never
|
|
77
|
+
* fail the upload.
|
|
78
|
+
*/
|
|
79
|
+
export async function imageFactsFromBytes(bytes) {
|
|
80
|
+
if (bytes.byteLength === 0)
|
|
81
|
+
return {};
|
|
82
|
+
let meta;
|
|
83
|
+
try {
|
|
84
|
+
meta = await sharp(bytes, { failOn: "none" }).metadata();
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return {};
|
|
88
|
+
}
|
|
89
|
+
if (!meta.format)
|
|
90
|
+
return {};
|
|
91
|
+
const facts = {};
|
|
92
|
+
// A density above 72dpi means a scaled screen capture: recover the logical
|
|
93
|
+
// size the user actually saw. Camera photos report 72 and are skipped.
|
|
94
|
+
const { width, height, density } = meta;
|
|
95
|
+
if (width && height && density && density > SCREEN_CAPTURE_MIN_DENSITY) {
|
|
96
|
+
const scale = density / SCREEN_CAPTURE_MIN_DENSITY;
|
|
97
|
+
facts.viewport = formatViewport(width / scale, height / scale, scale);
|
|
98
|
+
}
|
|
99
|
+
if (meta.exif) {
|
|
100
|
+
try {
|
|
101
|
+
Object.assign(facts, factsFromExifTags(exifReader(meta.exif)));
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// Unparseable EXIF is not an error — keep whatever we already derived.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return facts;
|
|
108
|
+
}
|
package/dist/mcp/args.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export type ToolArgs = Record<string, unknown>;
|
|
2
2
|
export declare function usage(msg: string): never;
|
|
3
3
|
export declare function optString(args: ToolArgs, name: string): string | undefined;
|
|
4
|
+
/** A boolean flag argument; missing/null reads as `false`. */
|
|
5
|
+
export declare function optBool(args: ToolArgs, name: string): boolean;
|
|
4
6
|
export declare function optPosInt(args: ToolArgs, name: string): number | undefined;
|
|
5
7
|
/** A JSON-object argument of string→string pairs (e.g. a `metadata` or `filters` param). */
|
|
6
8
|
export declare function optStringRecord(args: ToolArgs, name: string): Record<string, string> | undefined;
|
|
@@ -11,7 +13,7 @@ export declare function optStringArray(args: ToolArgs, name: string): string[] |
|
|
|
11
13
|
* `filters` params across the CLI/local MCP (put/attach/set_metadata/
|
|
12
14
|
* find_files) and the remote MCP worker (set_metadata/find_files).
|
|
13
15
|
*/
|
|
14
|
-
export declare const METADATA_DESCRIPTION = "Queryable custom metadata (key\u2192value), separate from provenance. Omit to leave any metadata already stored for this key untouched; pass an object (even {}) to fully replace it. Keys: lowercase, ^[a-z][a-z0-9._-]{0,63}$. Values: 1-512 printable ASCII characters. Caps: at most 24 keys, at most 8192 total key+value bytes.
|
|
16
|
+
export declare const METADATA_DESCRIPTION = "Queryable custom metadata (key\u2192value), separate from provenance. Omit to leave any metadata already stored for this key untouched; pass an object (even {}) to fully replace it. Keys: lowercase, ^[a-z][a-z0-9._-]{0,63}$. Values: 1-512 printable ASCII characters. Caps: at most 24 keys, at most 8192 total key+value bytes. Canonical keys, which uploads.sh derives automatically where it can: url, path, env, theme, viewport, device, software, captured. Use `path` for the route (e.g. /settings) \u2014 that is the key `find_files` searches by, so spell it `path` and not route/page/screen. `gh.*` is reserved by convention for GitHub PR/issue attachment context (repo/kind/number/ref).";
|
|
15
17
|
export declare const metadataProp: {
|
|
16
18
|
type: string;
|
|
17
19
|
additionalProperties: {
|
|
@@ -19,3 +21,24 @@ export declare const metadataProp: {
|
|
|
19
21
|
};
|
|
20
22
|
description: string;
|
|
21
23
|
};
|
|
24
|
+
export declare const stateProp: {
|
|
25
|
+
type: string;
|
|
26
|
+
enum: ("before" | "after" | "empty" | "error" | "loading")[];
|
|
27
|
+
description: string;
|
|
28
|
+
};
|
|
29
|
+
export declare const appProp: {
|
|
30
|
+
type: string;
|
|
31
|
+
description: string;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Canonical `state`/`app` pairs from their dedicated tool params. The schema
|
|
35
|
+
* enum already constrains `state` for well-behaved clients; re-validate here
|
|
36
|
+
* because a schema is a hint, not an enforcement boundary.
|
|
37
|
+
*/
|
|
38
|
+
export declare function canonicalMetaFromArgs(args: ToolArgs): Record<string, string>;
|
|
39
|
+
/**
|
|
40
|
+
* The `metadata` tool arg merged with the canonical `state`/`app` params.
|
|
41
|
+
* `undefined` (leave stored metadata untouched) is preserved only when the
|
|
42
|
+
* caller supplied none of the three.
|
|
43
|
+
*/
|
|
44
|
+
export declare function metadataArgWithCanonical(args: ToolArgs): Record<string, string> | undefined;
|