@buildinternet/uploads 0.19.0 → 0.22.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 +35 -3
- package/dist/client.js +23 -6
- package/dist/commands/login.d.ts +23 -1
- package/dist/commands/login.js +78 -10
- package/dist/commands/screenshot.js +15 -7
- package/dist/commands.d.ts +47 -2
- package/dist/commands.js +174 -39
- package/dist/errors.d.ts +10 -2
- package/dist/errors.js +8 -1
- package/dist/github.d.ts +11 -0
- package/dist/github.js +52 -3
- 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.
|
|
@@ -385,12 +464,27 @@ export async function syncAttachmentsComment(client, target, run, workspace) {
|
|
|
385
464
|
}
|
|
386
465
|
}
|
|
387
466
|
// gh fallback: gather from this workspace's own data and post via local `gh`.
|
|
388
|
-
// Note (
|
|
389
|
-
// scope, so it cannot honor a workspace's
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
|
|
467
|
+
// Note (issues #304, #365): this CLI process has no server-side
|
|
468
|
+
// WorkspaceRecord in scope, so it cannot honor a workspace's
|
|
469
|
+
// githubCommentLinkToFilePage=false or githubCommentShowMetadata=false — it
|
|
470
|
+
// always links to the file page and always shows metadata here, matching the
|
|
471
|
+
// defaults. This only diverges from the bot-posted comment for a workspace
|
|
472
|
+
// that both sets one of those flags false and falls through to this path.
|
|
473
|
+
const items = (await client.listAll({ prefix: ghKeyPrefix(target), metadata: true })).map(({ key, url, embedUrl, pageUrl, metadata }) => {
|
|
474
|
+
// The list endpoint returns every metadata key; the comment renders only
|
|
475
|
+
// these two. Narrowing here keeps both render paths byte-identical.
|
|
476
|
+
const path = metadata?.path;
|
|
477
|
+
const state = metadata?.state;
|
|
478
|
+
return {
|
|
479
|
+
key,
|
|
480
|
+
url,
|
|
481
|
+
embedUrl,
|
|
482
|
+
pageUrl,
|
|
483
|
+
...(path || state
|
|
484
|
+
? { meta: { ...(path ? { path } : {}), ...(state ? { state } : {}) } }
|
|
485
|
+
: {}),
|
|
486
|
+
};
|
|
487
|
+
});
|
|
394
488
|
const galleries = [];
|
|
395
489
|
let cursor;
|
|
396
490
|
do {
|
|
@@ -504,13 +598,15 @@ Options:
|
|
|
504
598
|
Because attach always sends its own gh.* pairs, re-attaching to
|
|
505
599
|
the same key always replaces that file's entire metadata set
|
|
506
600
|
(never preserves) — use "uploads meta set" to add to it instead.
|
|
601
|
+
--state <s> before|after|empty|error|loading — the UI state shown (sets meta state=)
|
|
602
|
+
--app <name> Surface shown: web, ios, android, cli (sets meta app=)
|
|
507
603
|
|
|
508
604
|
Examples:
|
|
509
605
|
uploads attach ./before.png ./after.png
|
|
510
606
|
uploads attach ./mobile.png --frame phone
|
|
511
607
|
uploads attach ./shot.png --pr 123 --repo myorg/myapp
|
|
512
608
|
uploads attach ./artifact.zip --issue 45 --no-comment
|
|
513
|
-
uploads attach ./shot.png --meta
|
|
609
|
+
uploads attach ./shot.png --meta path=/settings --state after
|
|
514
610
|
uploads attach ./shot.png --branch
|
|
515
611
|
uploads attach ./shot.png --branch feature/new-settings
|
|
516
612
|
uploads attach --promote
|
|
@@ -528,7 +624,13 @@ async function uploadAttachmentBatch(opts) {
|
|
|
528
624
|
const slots = await mapBounded(opts.files, opts.concurrency ?? UPLOAD_BATCH_CONCURRENCY, async (file) => {
|
|
529
625
|
try {
|
|
530
626
|
const sourceName = basename(file);
|
|
531
|
-
const
|
|
627
|
+
const bytes = readFileArg(file);
|
|
628
|
+
// Same EXIF promotion uploadPreparedImage does; attach keeps its own
|
|
629
|
+
// per-file tail (it builds keys differently), so it opts in here too.
|
|
630
|
+
const metadata = opts.deriveImageFacts
|
|
631
|
+
? await mergeImageFacts(bytes, opts.metadata)
|
|
632
|
+
: opts.metadata;
|
|
633
|
+
const prepared = await prepareImageForUpload(bytes, sourceName, {
|
|
532
634
|
...opts.frame,
|
|
533
635
|
optimize: opts.optimize,
|
|
534
636
|
});
|
|
@@ -543,7 +645,7 @@ async function uploadAttachmentBatch(opts) {
|
|
|
543
645
|
frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
|
|
544
646
|
keepExif: opts.optimize.keepExif === true,
|
|
545
647
|
}),
|
|
546
|
-
metadata
|
|
648
|
+
metadata,
|
|
547
649
|
});
|
|
548
650
|
return {
|
|
549
651
|
ok: true,
|
|
@@ -643,7 +745,9 @@ export async function uploadPuts(opts) {
|
|
|
643
745
|
deriveRepoFromGit: opts.deriveRepoFromGit,
|
|
644
746
|
contentType: opts.contentType,
|
|
645
747
|
dryRun: opts.dryRun,
|
|
748
|
+
replace: opts.replace,
|
|
646
749
|
metadata: opts.metadata,
|
|
750
|
+
deriveImageFacts: opts.deriveImageFacts,
|
|
647
751
|
provenanceClient: opts.provenanceClient,
|
|
648
752
|
alt: () => opts.alt ?? basename(sourceName),
|
|
649
753
|
width: opts.width,
|
|
@@ -761,8 +865,12 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
761
865
|
// Validate the merged map (not just the extras) so the 24-key/8KB caps are
|
|
762
866
|
// enforced client-side even when extras alone are under the cap but extras
|
|
763
867
|
// + the gh.* pairs push the merged map over it.
|
|
764
|
-
const metaExtras = parseMetaFlags(flagValues(parsed.flags, "--meta"));
|
|
765
|
-
const metadata = {
|
|
868
|
+
const metaExtras = warnNearMissMeta(ctx, parseMetaFlags(flagValues(parsed.flags, "--meta")));
|
|
869
|
+
const metadata = {
|
|
870
|
+
...metaExtras,
|
|
871
|
+
...stateAppMetaFromFlags(parsed.flags),
|
|
872
|
+
...ghMetadataFromTargetWithTitle(target, run),
|
|
873
|
+
};
|
|
766
874
|
if (Object.keys(metadata).length > 0)
|
|
767
875
|
validateMetaMap(metadata);
|
|
768
876
|
const logHuman = !ctx.quiet && !ctx.json;
|
|
@@ -778,6 +886,7 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
778
886
|
optimize: optimizeOpts,
|
|
779
887
|
frame: frameOpts,
|
|
780
888
|
metadata,
|
|
889
|
+
deriveImageFacts: derivedMetaEnabled(parsed.flags, defaults),
|
|
781
890
|
});
|
|
782
891
|
// Single-file total failure: rethrow so CLI exit codes stay auth/network-aware.
|
|
783
892
|
if (uploads.length === 0 && failures.length === 1 && parsed.positionals.length === 1) {
|
|
@@ -868,8 +977,12 @@ async function runAttachBranch(ctx, parsed, branch, run) {
|
|
|
868
977
|
const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, defaults);
|
|
869
978
|
const frameOpts = frameOptionsFromFlags(parsed.flags);
|
|
870
979
|
const contentTypeOverride = flagString(parsed.flags, "--content-type");
|
|
871
|
-
const metaExtras = parseMetaFlags(flagValues(parsed.flags, "--meta"));
|
|
872
|
-
const metadata = {
|
|
980
|
+
const metaExtras = warnNearMissMeta(ctx, parseMetaFlags(flagValues(parsed.flags, "--meta")));
|
|
981
|
+
const metadata = {
|
|
982
|
+
...metaExtras,
|
|
983
|
+
...stateAppMetaFromFlags(parsed.flags),
|
|
984
|
+
...ghMetadataForBranch(repo, branch),
|
|
985
|
+
};
|
|
873
986
|
validateMetaMap(metadata);
|
|
874
987
|
const logHuman = !ctx.quiet && !ctx.json;
|
|
875
988
|
if (logHuman) {
|
|
@@ -885,6 +998,7 @@ async function runAttachBranch(ctx, parsed, branch, run) {
|
|
|
885
998
|
optimize: optimizeOpts,
|
|
886
999
|
frame: frameOpts,
|
|
887
1000
|
metadata,
|
|
1001
|
+
deriveImageFacts: derivedMetaEnabled(parsed.flags, defaults),
|
|
888
1002
|
});
|
|
889
1003
|
// Single-file total failure: rethrow so CLI exit codes stay auth/network-aware.
|
|
890
1004
|
if (uploads.length === 0 && failures.length === 1 && parsed.positionals.length === 1) {
|
|
@@ -987,10 +1101,20 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
987
1101
|
const galleryId = flagString(parsed.flags, "--gallery");
|
|
988
1102
|
const nameFlag = flagString(parsed.flags, "--name");
|
|
989
1103
|
const dryRun = flagBool(parsed.flags, "--dry-run");
|
|
1104
|
+
// Strict-overwrite escape hatch (issue #174): only matters on non-gh/ keys
|
|
1105
|
+
// (--key or the default put path) — the server ignores `replace` on
|
|
1106
|
+
// managed gh/ paths (--pr/--issue), which always hot-swap regardless.
|
|
1107
|
+
const replaceFlag = flagBool(parsed.flags, "--replace") || process.env.UPLOADS_OVERWRITE === "1";
|
|
990
1108
|
// Validate --meta up front (fail fast, before reading/optimizing the file).
|
|
991
1109
|
const userMeta = (() => {
|
|
992
1110
|
const pairs = flagValues(parsed.flags, "--meta");
|
|
993
|
-
|
|
1111
|
+
const fromMeta = warnNearMissMeta(ctx, pairs.length > 0 ? parseMetaFlags(pairs) : {});
|
|
1112
|
+
// Dedicated flags are explicit input and win over a same-named --meta pair.
|
|
1113
|
+
const merged = { ...fromMeta, ...stateAppMetaFromFlags(parsed.flags) };
|
|
1114
|
+
if (Object.keys(merged).length === 0)
|
|
1115
|
+
return undefined;
|
|
1116
|
+
validateMetaMap(merged);
|
|
1117
|
+
return merged;
|
|
994
1118
|
})();
|
|
995
1119
|
if (wantComment && typeof parsed.flags.get("--comment") === "string") {
|
|
996
1120
|
throw new UsageError("--comment takes no value — place it after the file argument");
|
|
@@ -1084,10 +1208,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
1084
1208
|
attachedRef = merged["gh.ref"];
|
|
1085
1209
|
}
|
|
1086
1210
|
else {
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
(flagBool(parsed.flags, "--auto") || defaults.noAutoMeta !== true);
|
|
1090
|
-
if (autoEnabled) {
|
|
1211
|
+
// gh.* additionally needs git, which the shared derived gate ignores.
|
|
1212
|
+
if (!noGit && derivedMetaEnabled(parsed.flags, defaults)) {
|
|
1091
1213
|
const autoTarget = resolveAutoGhTarget(flagString(parsed.flags, "--repo") ?? defaults.repo, flagString(parsed.flags, "--ref") ?? defaults.ref, run);
|
|
1092
1214
|
if (autoTarget) {
|
|
1093
1215
|
const autoMeta = ghMetadataFromTargetWithTitle(autoTarget, run);
|
|
@@ -1129,9 +1251,11 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
1129
1251
|
deriveRepoFromGit: !noGit,
|
|
1130
1252
|
contentType: contentTypeOverride,
|
|
1131
1253
|
dryRun,
|
|
1254
|
+
replace: replaceFlag,
|
|
1132
1255
|
optimize: optimizeOpts,
|
|
1133
1256
|
frame: frameOpts,
|
|
1134
1257
|
metadata,
|
|
1258
|
+
deriveImageFacts: derivedMetaEnabled(parsed.flags, defaults),
|
|
1135
1259
|
alt: altFlag,
|
|
1136
1260
|
width,
|
|
1137
1261
|
});
|
|
@@ -1195,7 +1319,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
1195
1319
|
const note = formatOptimizeNote(result.optimize);
|
|
1196
1320
|
if (note)
|
|
1197
1321
|
process.stderr.write(`>> ${basename(result.file)}: ${note}\n`);
|
|
1198
|
-
writeReplacedNote(result.replaced, false, dryRun);
|
|
1322
|
+
writeReplacedNote(result.replaced, false, dryRun, result.wouldRefuse);
|
|
1199
1323
|
process.stderr.write(`>> key: ${result.key}${dryRun ? " (dry run — not uploaded)" : ""}\n`);
|
|
1200
1324
|
}
|
|
1201
1325
|
const gallery = galleriesByKey.get(result.key);
|
|
@@ -1227,7 +1351,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
1227
1351
|
const note = formatOptimizeNote(result.optimize);
|
|
1228
1352
|
if (note)
|
|
1229
1353
|
process.stderr.write(`>> ${note}\n`);
|
|
1230
|
-
writeReplacedNote(result.replaced, ctx.quiet, dryRun);
|
|
1354
|
+
writeReplacedNote(result.replaced, ctx.quiet, dryRun, result.wouldRefuse);
|
|
1231
1355
|
process.stderr.write(`>> key: ${result.key}${dryRun ? " (dry run — not uploaded)" : ""}\n\n`);
|
|
1232
1356
|
}
|
|
1233
1357
|
switch (format) {
|
|
@@ -1569,7 +1693,7 @@ Human-friendly alias for \`uploads list --meta k=v...\` — same metadata filter
|
|
|
1569
1693
|
|
|
1570
1694
|
Examples:
|
|
1571
1695
|
uploads find gh.repo=buildinternet/uploads gh.number=123
|
|
1572
|
-
uploads find
|
|
1696
|
+
uploads find path=/settings state=after --prefix screenshots/
|
|
1573
1697
|
`;
|
|
1574
1698
|
export async function runFind(ctx, args, help = false) {
|
|
1575
1699
|
const parsed = parseCommandArgs(args);
|
|
@@ -1596,8 +1720,8 @@ Commands:
|
|
|
1596
1720
|
|
|
1597
1721
|
Examples:
|
|
1598
1722
|
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
|
|
1723
|
+
uploads meta set screenshots/myapp/42/shot.png path=/settings state=after
|
|
1724
|
+
uploads meta set screenshots/myapp/42/shot.png --delete path --delete state
|
|
1601
1725
|
`;
|
|
1602
1726
|
export async function runMeta(ctx, args, help = false) {
|
|
1603
1727
|
const parsed = parseCommandArgs(args);
|
|
@@ -1694,10 +1818,9 @@ App is installed on the repo; otherwise via your local gh auth. Finds its own
|
|
|
1694
1818
|
prior comment via a hidden marker and edits it in place; never touches other
|
|
1695
1819
|
comments or the description.
|
|
1696
1820
|
|
|
1697
|
-
If this repo is bound to a different workspace
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
--status\`.
|
|
1821
|
+
If this repo is bound to a different workspace, the bot post is declined and
|
|
1822
|
+
this command fails rather than silently falling back to gh — see
|
|
1823
|
+
\`uploads github link --status\`.
|
|
1701
1824
|
|
|
1702
1825
|
Examples:
|
|
1703
1826
|
uploads --env-file .env comment --pr 123
|
|
@@ -1822,7 +1945,19 @@ async function runGithubLink(ctx, repo, statusOnly) {
|
|
|
1822
1945
|
return 0;
|
|
1823
1946
|
}
|
|
1824
1947
|
if (!statusOnly && result.claimed === false) {
|
|
1825
|
-
|
|
1948
|
+
// Cross-tenant authorization (issue #297): `reason: "not_authorized"`
|
|
1949
|
+
// means the repo is unbound but this workspace couldn't be verified as
|
|
1950
|
+
// entitled to claim it (no linked GitHub account, or that account lacks
|
|
1951
|
+
// push access) — distinct from the older "someone else already owns it"
|
|
1952
|
+
// case, which still reports `result.workspace`.
|
|
1953
|
+
if (result.reason === "not_authorized") {
|
|
1954
|
+
process.stderr.write(`note: ${repo} isn't linked to any workspace yet, and this workspace couldn't be ` +
|
|
1955
|
+
`verified as entitled to claim it. Link a GitHub account with push access to ` +
|
|
1956
|
+
`${repo}, or ask an operator to bind it explicitly.\n`);
|
|
1957
|
+
}
|
|
1958
|
+
else {
|
|
1959
|
+
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`);
|
|
1960
|
+
}
|
|
1826
1961
|
}
|
|
1827
1962
|
await writeStdout(formatGithubLink(repo, result));
|
|
1828
1963
|
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
|
}
|
package/dist/github.d.ts
CHANGED
|
@@ -75,6 +75,17 @@ export interface AttachmentItem {
|
|
|
75
75
|
embedUrl?: string | null;
|
|
76
76
|
/** Canonical `/f/` file-page URL (server-computed). Preferred click-through target; falls back to `url`. */
|
|
77
77
|
pageUrl?: string | null;
|
|
78
|
+
/**
|
|
79
|
+
* The only canonical metadata the managed comment renders (issue #365).
|
|
80
|
+
* Deliberately two named fields rather than `Record<string, string>`: the
|
|
81
|
+
* comment is posted publicly, and keeping the set narrow at the type level
|
|
82
|
+
* mirrors the server-side query filter that never fetches EXIF-derived
|
|
83
|
+
* keys like `device`/`software` for this path.
|
|
84
|
+
*/
|
|
85
|
+
meta?: {
|
|
86
|
+
path?: string;
|
|
87
|
+
state?: string;
|
|
88
|
+
};
|
|
78
89
|
}
|
|
79
90
|
/** A public gallery linked to the PR or issue whose managed comment is syncing. */
|
|
80
91
|
export interface GalleryCommentItem {
|
package/dist/github.js
CHANGED
|
@@ -177,6 +177,51 @@ function escapeHtmlAttr(s) {
|
|
|
177
177
|
function escapeHtmlText(s) {
|
|
178
178
|
return escapeHtmlAttr(s).replace(/'/g, "'").replace(/>/g, ">");
|
|
179
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Backslash-escape the markdown metacharacters that can appear in a metadata
|
|
182
|
+
* value. `~` is in the set because GitHub's strikethrough extension treats a
|
|
183
|
+
* matching pair of ONE or two tildes as markup, so an unescaped `/a~b~c` would
|
|
184
|
+
* render with `b` struck through.
|
|
185
|
+
*/
|
|
186
|
+
function escapeMarkdownText(s) {
|
|
187
|
+
return s.replace(/([\\`*_[\]~])/g, "\\$1");
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* An attachment's caption parts — `path`, then `state` (issue #365). Empty
|
|
191
|
+
* when neither is usable, so callers emit nothing at all and a body with no
|
|
192
|
+
* metadata stays byte-identical to the pre-#365 render.
|
|
193
|
+
*
|
|
194
|
+
* Neither value is pre-sanitized: metadata values are printable ASCII up to
|
|
195
|
+
* 512 chars, and while the CLI validates `--state` against a closed enum,
|
|
196
|
+
* `PATCH /v1/:workspace/files/:key` can set any valid metadata value. A
|
|
197
|
+
* whitespace-only value passes that validation (length-1 printable ASCII), so
|
|
198
|
+
* treat it as absent rather than rendering a dangling separator.
|
|
199
|
+
*/
|
|
200
|
+
function metaCaptionParts(meta) {
|
|
201
|
+
const parts = [];
|
|
202
|
+
for (const value of [meta?.path, meta?.state]) {
|
|
203
|
+
const trimmed = value?.trim();
|
|
204
|
+
if (trimmed)
|
|
205
|
+
parts.push(trimmed);
|
|
206
|
+
}
|
|
207
|
+
return parts;
|
|
208
|
+
}
|
|
209
|
+
/** `<sub>` caption body for an inline image, or null when there is nothing to say. */
|
|
210
|
+
function metaCaptionHtml(meta) {
|
|
211
|
+
const parts = metaCaptionParts(meta);
|
|
212
|
+
return parts.length > 0 ? parts.map(escapeHtmlText).join(" · ") : null;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* ` · …` suffix for a markdown list row, or `""` when there is nothing to add.
|
|
216
|
+
* HTML-escapes first, then markdown-escapes: HTML escaping introduces no
|
|
217
|
+
* backslashes or brackets, so the markdown pass cannot corrupt its entities.
|
|
218
|
+
*/
|
|
219
|
+
function metaCaptionMarkdown(meta) {
|
|
220
|
+
const parts = metaCaptionParts(meta);
|
|
221
|
+
if (parts.length === 0)
|
|
222
|
+
return "";
|
|
223
|
+
return ` · ${parts.map((p) => escapeMarkdownText(escapeHtmlText(p))).join(" · ")}`;
|
|
224
|
+
}
|
|
180
225
|
/**
|
|
181
226
|
* Render the one marker-owned GitHub comment. When there are no galleries this
|
|
182
227
|
* intentionally preserves the legacy attachment-only body byte-for-byte.
|
|
@@ -224,13 +269,16 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
|
|
|
224
269
|
const href = escapeHtmlAttr(link ?? src);
|
|
225
270
|
const imgSrc = escapeHtmlAttr(src);
|
|
226
271
|
lines.push(`<a href="${href}"><img width="${w}" alt="${alt}" src="${imgSrc}"></a>`);
|
|
272
|
+
const caption = metaCaptionHtml(item.meta);
|
|
273
|
+
if (caption)
|
|
274
|
+
lines.push(`<sub>${caption}</sub>`);
|
|
227
275
|
lines.push("");
|
|
228
276
|
}
|
|
229
277
|
else if (link) {
|
|
230
|
-
lines.push(`- [${name}](${link})`);
|
|
278
|
+
lines.push(`- [${name}](${link})${metaCaptionMarkdown(item.meta)}`);
|
|
231
279
|
}
|
|
232
280
|
else {
|
|
233
|
-
lines.push(`- ${name}`);
|
|
281
|
+
lines.push(`- ${name}${metaCaptionMarkdown(item.meta)}`);
|
|
234
282
|
}
|
|
235
283
|
}
|
|
236
284
|
if (overflowImages.length > 0) {
|
|
@@ -239,7 +287,8 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
|
|
|
239
287
|
for (const item of overflowImages) {
|
|
240
288
|
const name = item.key.slice(item.key.lastIndexOf("/") + 1);
|
|
241
289
|
const link = item.pageUrl ?? item.url;
|
|
242
|
-
|
|
290
|
+
const suffix = metaCaptionMarkdown(item.meta);
|
|
291
|
+
lines.push(link ? `- [${name}](${link})${suffix}` : `- ${name}${suffix}`);
|
|
243
292
|
}
|
|
244
293
|
lines.push("", "</details>", "");
|
|
245
294
|
}
|
|
@@ -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>>;
|