@buildinternet/uploads 0.51.0 → 0.52.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/commands.js +19 -9
- package/dist/comment-render.generated.d.ts +8 -0
- package/dist/comment-render.generated.js +46 -18
- package/dist/embed.d.ts +14 -1
- package/dist/embed.js +14 -22
- package/dist/mcp/tools.js +1 -1
- package/dist/optimize.js +22 -26
- package/package.json +1 -1
package/dist/commands.js
CHANGED
|
@@ -4,7 +4,7 @@ import { mapBounded } from "./async.js";
|
|
|
4
4
|
import { createUploadsClient, } from "./client.js";
|
|
5
5
|
import { parseCommandArgs, flagString, flagBool, flagInt, flagValues, UsageError, } from "./cli-args.js";
|
|
6
6
|
import { resolvePutDefaults, workspaceMismatch, workspaceFromToken, } from "./config.js";
|
|
7
|
-
import { buildUploadMarkdown } from "./embed.js";
|
|
7
|
+
import { buildUploadMarkdown, fileKindFromName } from "./embed.js";
|
|
8
8
|
import { readLocalRepoCommentConfig, resolveCommentOptions } from "./comment-config.js";
|
|
9
9
|
import { urlForGithubEmbed } from "./public-urls.js";
|
|
10
10
|
import { UploadsError } from "./errors.js";
|
|
@@ -110,6 +110,10 @@ Still images (PNG/JPEG/…) are optimized to WebP by default (long edge capped,
|
|
|
110
110
|
high quality; EXIF stripped) so GitHub embeds stay lean. Original bytes are kept
|
|
111
111
|
when they are already smaller, animated, or not an image. Use --no-optimize to
|
|
112
112
|
upload as-is, or --keep-exif when image metadata matters for the discussion.
|
|
113
|
+
Non-media files (PDF, zip, gzip, logs, JSON, CSV, markdown) upload as-is and
|
|
114
|
+
show up in the managed comment as links. HTML and SVG are rejected. Uploads are
|
|
115
|
+
public, so scrub secrets and tokens out of logs, JSON, and other text before
|
|
116
|
+
uploading.
|
|
113
117
|
|
|
114
118
|
Optional --frame wraps the image in a device/browser chrome before optimize
|
|
115
119
|
(default off). See: uploads put --help frames
|
|
@@ -421,13 +425,23 @@ export async function prepareImageForUpload(bytes, filename, opts) {
|
|
|
421
425
|
return { ...optimized, frame: frameMeta };
|
|
422
426
|
}
|
|
423
427
|
/**
|
|
424
|
-
* Merge an image's own EXIF-derived facts under any explicit metadata
|
|
428
|
+
* Merge an image's own EXIF-derived facts under any explicit metadata, when
|
|
429
|
+
* the caller asked for them and the filename could plausibly be an image —
|
|
430
|
+
* only an `image/*` extension, or none at all (an extension-less
|
|
431
|
+
* screenshot), is worth an `imageFactsFromBytes` probe
|
|
432
|
+
* (`sharp(bytes).metadata()` under the hood); a known non-image extension (a
|
|
433
|
+
* 25 MB zip, a `.log`, a PDF…) skips it rather than paying a sharp call that
|
|
434
|
+
* can only come back empty.
|
|
435
|
+
*
|
|
425
436
|
* Best-effort by contract: `imageFactsFromBytes` never rejects, and a full key
|
|
426
437
|
* budget drops the derived pairs rather than failing the upload. Returns the
|
|
427
438
|
* input untouched (including `undefined`) when there is nothing to add, so a
|
|
428
439
|
* metadata-free upload stays metadata-free.
|
|
429
440
|
*/
|
|
430
|
-
async function
|
|
441
|
+
async function maybeDeriveImageFacts(enabled, sourceName, bytes, metadata) {
|
|
442
|
+
const kind = fileKindFromName(sourceName);
|
|
443
|
+
if (!enabled || (kind !== "image" && kind !== "unknown"))
|
|
444
|
+
return metadata;
|
|
431
445
|
const facts = await imageFactsFromBytes(bytes);
|
|
432
446
|
if (Object.keys(facts).length === 0)
|
|
433
447
|
return metadata;
|
|
@@ -444,9 +458,7 @@ async function mergeImageFacts(bytes, metadata) {
|
|
|
444
458
|
*/
|
|
445
459
|
export async function uploadPreparedImage(client, bytes, sourceName, opts) {
|
|
446
460
|
// Read EXIF from the original bytes before the optimizer strips it.
|
|
447
|
-
const metadata = opts.deriveImageFacts
|
|
448
|
-
? await mergeImageFacts(bytes, opts.metadata)
|
|
449
|
-
: opts.metadata;
|
|
461
|
+
const metadata = await maybeDeriveImageFacts(opts.deriveImageFacts, sourceName, bytes, opts.metadata);
|
|
450
462
|
const prepared = await prepareImageForUpload(bytes, sourceName, {
|
|
451
463
|
frameId: opts.frame.frameId,
|
|
452
464
|
frameUrl: opts.frame.frameUrl,
|
|
@@ -955,9 +967,7 @@ async function uploadAttachmentBatch(opts) {
|
|
|
955
967
|
const baseMetadata = mergeSidecarMeta(file, bytes, opts.metadata);
|
|
956
968
|
// Same EXIF promotion uploadPreparedImage does; attach keeps its own
|
|
957
969
|
// per-file tail (it builds keys differently), so it opts in here too.
|
|
958
|
-
const metadata = opts.deriveImageFacts
|
|
959
|
-
? await mergeImageFacts(bytes, baseMetadata)
|
|
960
|
-
: baseMetadata;
|
|
970
|
+
const metadata = await maybeDeriveImageFacts(opts.deriveImageFacts, sourceName, bytes, baseMetadata);
|
|
961
971
|
const prepared = await prepareImageForUpload(bytes, sourceName, {
|
|
962
972
|
...opts.frame,
|
|
963
973
|
optimize: opts.optimize,
|
|
@@ -47,6 +47,14 @@ export declare function parseGhPrivateKey(key: string): {
|
|
|
47
47
|
kind: GhTargetKind;
|
|
48
48
|
num: number;
|
|
49
49
|
} | undefined;
|
|
50
|
+
export declare function inferContentType(filename: string): string;
|
|
51
|
+
/**
|
|
52
|
+
* Coarse family a filename belongs to, for the callers that only branch on
|
|
53
|
+
* "is this an image / a video / something else" — `unknown` when the
|
|
54
|
+
* extension maps to nothing (an extension-less screenshot, say), which those
|
|
55
|
+
* callers treat as "might be an image, probe it".
|
|
56
|
+
*/
|
|
57
|
+
export declare function fileKindFromName(filename: string): "image" | "video" | "file" | "unknown";
|
|
50
58
|
/** Hidden marker identifying the one comment this CLI manages. Never change it — existing comments are found by exact match. */
|
|
51
59
|
export declare const ATTACHMENTS_MARKER = "<!-- uploads.sh:attachments -->";
|
|
52
60
|
/**
|
|
@@ -69,27 +69,55 @@ export function parseGhPrivateKey(key) {
|
|
|
69
69
|
return { prefixId, kind: kind, num: Number(num) };
|
|
70
70
|
}
|
|
71
71
|
/** GitHub-embed helper (content type). Copied from packages/uploads/src/embed.ts. */
|
|
72
|
-
|
|
72
|
+
const CONTENT_TYPE_BY_EXTENSION = {
|
|
73
|
+
png: "image/png",
|
|
74
|
+
jpg: "image/jpeg",
|
|
75
|
+
jpeg: "image/jpeg",
|
|
76
|
+
gif: "image/gif",
|
|
77
|
+
webp: "image/webp",
|
|
78
|
+
avif: "image/avif",
|
|
79
|
+
svg: "image/svg+xml",
|
|
80
|
+
mp4: "video/mp4",
|
|
81
|
+
webm: "video/webm",
|
|
82
|
+
mov: "video/quicktime",
|
|
83
|
+
pdf: "application/pdf",
|
|
84
|
+
zip: "application/zip",
|
|
85
|
+
gz: "application/gzip",
|
|
86
|
+
tgz: "application/gzip",
|
|
87
|
+
txt: "text/plain",
|
|
88
|
+
text: "text/plain",
|
|
89
|
+
log: "text/plain",
|
|
90
|
+
jsonl: "text/plain",
|
|
91
|
+
ndjson: "text/plain",
|
|
92
|
+
yaml: "text/plain",
|
|
93
|
+
yml: "text/plain",
|
|
94
|
+
md: "text/markdown",
|
|
95
|
+
markdown: "text/markdown",
|
|
96
|
+
csv: "text/csv",
|
|
97
|
+
json: "application/json",
|
|
98
|
+
xml: "application/xml",
|
|
99
|
+
};
|
|
100
|
+
export function inferContentType(filename) {
|
|
73
101
|
const ext = filename.includes(".")
|
|
74
102
|
? filename.slice(filename.lastIndexOf(".") + 1).toLowerCase()
|
|
75
103
|
: "";
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
104
|
+
return CONTENT_TYPE_BY_EXTENSION[ext] ?? "application/octet-stream";
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Coarse family a filename belongs to, for the callers that only branch on
|
|
108
|
+
* "is this an image / a video / something else" — `unknown` when the
|
|
109
|
+
* extension maps to nothing (an extension-less screenshot, say), which those
|
|
110
|
+
* callers treat as "might be an image, probe it".
|
|
111
|
+
*/
|
|
112
|
+
export function fileKindFromName(filename) {
|
|
113
|
+
const type = inferContentType(filename);
|
|
114
|
+
if (type === "application/octet-stream")
|
|
115
|
+
return "unknown";
|
|
116
|
+
if (type.startsWith("image/"))
|
|
117
|
+
return "image";
|
|
118
|
+
if (type.startsWith("video/"))
|
|
119
|
+
return "video";
|
|
120
|
+
return "file";
|
|
93
121
|
}
|
|
94
122
|
/** Hidden marker identifying the one comment this CLI manages. Never change it — existing comments are found by exact match. */
|
|
95
123
|
export const ATTACHMENTS_MARKER = "<!-- uploads.sh:attachments -->";
|
package/dist/embed.d.ts
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
/** GitHub-embed helpers (content type + markdown). */
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* The filename→type map and its two readers live in
|
|
4
|
+
* packages/comment-render/src/index.ts (inlined here as
|
|
5
|
+
* `comment-render.generated.ts`), so the CLI and the managed-comment renderer
|
|
6
|
+
* cannot drift apart. Re-exported from this module because every existing
|
|
7
|
+
* caller imports them from `./embed.js`. That map mirrors the upload table in
|
|
8
|
+
* apps/api/src/guards.ts; keep the two in step. `svg` maps to
|
|
9
|
+
* `image/svg+xml` here same as any other type (issue #929): the server no
|
|
10
|
+
* longer rejects it outright — it accepts SVG only on a storage lane
|
|
11
|
+
* verified to serve it behind a sandboxing CSP (`apps/api/src/active-content.ts`),
|
|
12
|
+
* so whether a given upload actually lands depends on that lane's state,
|
|
13
|
+
* not on anything this map decides.
|
|
14
|
+
*/
|
|
15
|
+
export { fileKindFromName, inferContentType } from "./comment-render.generated.js";
|
|
3
16
|
export declare function buildMarkdown(url: string, opts: {
|
|
4
17
|
alt: string;
|
|
5
18
|
width?: number;
|
package/dist/embed.js
CHANGED
|
@@ -1,26 +1,18 @@
|
|
|
1
1
|
/** GitHub-embed helpers (content type + markdown). */
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
case "svg":
|
|
17
|
-
return "image/svg+xml";
|
|
18
|
-
case "mp4":
|
|
19
|
-
return "video/mp4";
|
|
20
|
-
default:
|
|
21
|
-
return "application/octet-stream";
|
|
22
|
-
}
|
|
23
|
-
}
|
|
2
|
+
/**
|
|
3
|
+
* The filename→type map and its two readers live in
|
|
4
|
+
* packages/comment-render/src/index.ts (inlined here as
|
|
5
|
+
* `comment-render.generated.ts`), so the CLI and the managed-comment renderer
|
|
6
|
+
* cannot drift apart. Re-exported from this module because every existing
|
|
7
|
+
* caller imports them from `./embed.js`. That map mirrors the upload table in
|
|
8
|
+
* apps/api/src/guards.ts; keep the two in step. `svg` maps to
|
|
9
|
+
* `image/svg+xml` here same as any other type (issue #929): the server no
|
|
10
|
+
* longer rejects it outright — it accepts SVG only on a storage lane
|
|
11
|
+
* verified to serve it behind a sandboxing CSP (`apps/api/src/active-content.ts`),
|
|
12
|
+
* so whether a given upload actually lands depends on that lane's state,
|
|
13
|
+
* not on anything this map decides.
|
|
14
|
+
*/
|
|
15
|
+
export { fileKindFromName, inferContentType } from "./comment-render.generated.js";
|
|
24
16
|
export function buildMarkdown(url, opts) {
|
|
25
17
|
if (opts.width) {
|
|
26
18
|
const alt = opts.alt.replace(/"/g, """);
|
package/dist/mcp/tools.js
CHANGED
|
@@ -292,7 +292,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
292
292
|
title: "Upload file",
|
|
293
293
|
annotations: mcpDestroyPublic,
|
|
294
294
|
securitySchemes: mcpOAuthWrite,
|
|
295
|
-
description: "Upload one or more files and get a public URL plus GitHub-ready markdown. Prefer `embedUrl` in GitHub markdown. Pass `contentUrl` for a public HTTPS file, or http://localhost on this machine, instead of a local path. With `pr`/`issue`, keys are stable and the managed comment is synced. All uploads are public.",
|
|
295
|
+
description: "Upload one or more files and get a public URL plus GitHub-ready markdown. Prefer `embedUrl` in GitHub markdown. Pass `contentUrl` for a public HTTPS file, or http://localhost on this machine, instead of a local path. With `pr`/`issue`, keys are stable and the managed comment is synced. All uploads are public. Accepts images (PNG, JPEG, GIF, WebP, AVIF), video (MP4, WebM, MOV), PDF, zip, gzip, and text (plain, markdown, CSV, JSON). SVG and XML are accepted only on storage lanes verified to serve them sandboxed (see the workspace's storage settings); HTML is rejected.",
|
|
296
296
|
inputSchema: {
|
|
297
297
|
type: "object",
|
|
298
298
|
properties: {
|
package/dist/optimize.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* SVG, video, and non-images are left unchanged.
|
|
7
7
|
*/
|
|
8
8
|
import sharp from "sharp";
|
|
9
|
+
import { fileKindFromName, inferContentType } from "./embed.js";
|
|
9
10
|
/** Longest edge in pixels (screenshots beyond this rarely help PR review). */
|
|
10
11
|
export const DEFAULT_OPTIMIZE_MAX_EDGE = 2400;
|
|
11
12
|
/** WebP quality tuned for UI screenshots (text/chrome stay sharp enough). */
|
|
@@ -68,11 +69,25 @@ function passthrough(bytes, filename, contentType, skippedReason) {
|
|
|
68
69
|
*/
|
|
69
70
|
export async function optimizeImageForUpload(bytes, filename, opts = {}) {
|
|
70
71
|
const originalBytes = bytes.byteLength;
|
|
72
|
+
// The filename's own claim, used by every passthrough exit below.
|
|
73
|
+
const guessed = inferContentType(filename);
|
|
71
74
|
if (opts.enabled === false) {
|
|
72
|
-
return passthrough(bytes, filename,
|
|
75
|
+
return passthrough(bytes, filename, guessed, "disabled");
|
|
73
76
|
}
|
|
74
77
|
if (originalBytes === 0) {
|
|
75
|
-
return passthrough(bytes, filename,
|
|
78
|
+
return passthrough(bytes, filename, guessed, "empty");
|
|
79
|
+
}
|
|
80
|
+
// A known non-image extension (log, pdf, zip, video…) never needs sharp —
|
|
81
|
+
// and must be checked before `looksLikeSvg` below, since that check sniffs
|
|
82
|
+
// the leading bytes for an XML/SVG prologue with no regard for the actual
|
|
83
|
+
// extension: a `.log` or `.json` file that happens to start with `<?xml`
|
|
84
|
+
// (a JUnit report, say) would otherwise misreport as `skippedReason: "svg"`
|
|
85
|
+
// / `image/svg+xml`. An unknown extension is still "might be an image": it
|
|
86
|
+
// falls through to the SVG sniff and the sharp probe below, so an
|
|
87
|
+
// extension-less screenshot is optimized as before.
|
|
88
|
+
const kind = fileKindFromName(filename);
|
|
89
|
+
if (kind === "file" || kind === "video") {
|
|
90
|
+
return passthrough(bytes, filename, guessed, "not_image");
|
|
76
91
|
}
|
|
77
92
|
if (looksLikeSvg(bytes, filename)) {
|
|
78
93
|
return passthrough(bytes, filename, "image/svg+xml", "svg");
|
|
@@ -92,14 +107,14 @@ export async function optimizeImageForUpload(bytes, filename, opts = {}) {
|
|
|
92
107
|
meta = await image.metadata();
|
|
93
108
|
}
|
|
94
109
|
catch {
|
|
95
|
-
return passthrough(bytes, filename,
|
|
110
|
+
return passthrough(bytes, filename, guessed, "not_image");
|
|
96
111
|
}
|
|
97
112
|
if (!meta.format) {
|
|
98
|
-
return passthrough(bytes, filename,
|
|
113
|
+
return passthrough(bytes, filename, guessed, "not_image");
|
|
99
114
|
}
|
|
100
115
|
// Animated GIF/WebP: keep as-is (re-encoding often breaks or balloons size).
|
|
101
116
|
if ((meta.pages ?? 1) > 1) {
|
|
102
|
-
return passthrough(bytes, filename, meta.format === "gif" ? "image/gif" :
|
|
117
|
+
return passthrough(bytes, filename, meta.format === "gif" ? "image/gif" : guessed, "animated");
|
|
103
118
|
}
|
|
104
119
|
if (meta.format === "gif") {
|
|
105
120
|
// Single-frame GIF can convert; multi-page already returned above.
|
|
@@ -134,10 +149,10 @@ export async function optimizeImageForUpload(bytes, filename, opts = {}) {
|
|
|
134
149
|
}
|
|
135
150
|
}
|
|
136
151
|
catch {
|
|
137
|
-
return passthrough(bytes, filename,
|
|
152
|
+
return passthrough(bytes, filename, guessed, "encode_failed");
|
|
138
153
|
}
|
|
139
154
|
if (encoded.byteLength >= originalBytes) {
|
|
140
|
-
return passthrough(bytes, filename,
|
|
155
|
+
return passthrough(bytes, filename, guessed, "not_smaller");
|
|
141
156
|
}
|
|
142
157
|
const outFilename = withImageExtension(filename, format === "jpeg" ? "jpg" : "webp");
|
|
143
158
|
return {
|
|
@@ -149,25 +164,6 @@ export async function optimizeImageForUpload(bytes, filename, opts = {}) {
|
|
|
149
164
|
outputBytes: encoded.byteLength,
|
|
150
165
|
};
|
|
151
166
|
}
|
|
152
|
-
function guessContentType(filename) {
|
|
153
|
-
switch (extensionOf(filename)) {
|
|
154
|
-
case "png":
|
|
155
|
-
return "image/png";
|
|
156
|
-
case "jpg":
|
|
157
|
-
case "jpeg":
|
|
158
|
-
return "image/jpeg";
|
|
159
|
-
case "gif":
|
|
160
|
-
return "image/gif";
|
|
161
|
-
case "webp":
|
|
162
|
-
return "image/webp";
|
|
163
|
-
case "avif":
|
|
164
|
-
return "image/avif";
|
|
165
|
-
case "svg":
|
|
166
|
-
return "image/svg+xml";
|
|
167
|
-
default:
|
|
168
|
-
return "application/octet-stream";
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
167
|
/** Rewrite an object key's trailing image extension to match optimized output. */
|
|
172
168
|
export function rewriteKeyExtension(key, filename) {
|
|
173
169
|
const ext = extensionOf(filename);
|