@buildinternet/uploads 0.2.0 → 0.3.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 +29 -8
- package/dist/cli.js +12 -0
- package/dist/client.js +3 -0
- package/dist/commands.d.ts +16 -0
- package/dist/commands.js +187 -19
- package/dist/config-file.d.ts +5 -1
- package/dist/config-file.js +27 -2
- package/dist/destinations.d.ts +26 -0
- package/dist/destinations.js +47 -0
- package/dist/errors.d.ts +1 -1
- package/dist/frame.d.ts +36 -0
- package/dist/frame.js +245 -0
- package/dist/github.d.ts +11 -0
- package/dist/github.js +32 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/mcp/tools.js +155 -19
- package/dist/optimize.d.ts +38 -0
- package/dist/optimize.js +177 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -4,9 +4,7 @@ CLI and client for **uploads.sh** — upload files, get public URLs, and produce
|
|
|
4
4
|
|
|
5
5
|
## CLI
|
|
6
6
|
|
|
7
|
-
Binary:
|
|
8
|
-
|
|
9
|
-
Install globally or run a pinned version without installing:
|
|
7
|
+
Binary: **`uploads`**. Install globally (or use a pinned `npx` one-shot):
|
|
10
8
|
|
|
11
9
|
```bash
|
|
12
10
|
npm install --global @buildinternet/uploads
|
|
@@ -14,13 +12,21 @@ npx @buildinternet/uploads@0.1.0 --help
|
|
|
14
12
|
```
|
|
15
13
|
|
|
16
14
|
```bash
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
uploads setup
|
|
16
|
+
uploads attach ./before.png ./after.png
|
|
17
|
+
uploads put ./shot.png
|
|
18
|
+
uploads put ./shot.png --destination screenshots
|
|
19
|
+
uploads put ./shot.png --no-optimize
|
|
20
|
+
uploads put ./mobile.png --frame phone
|
|
21
|
+
uploads put ./ui.png --frame browser --frame-url "https://app.example"
|
|
22
|
+
uploads put ./after.png --pr 123 --comment
|
|
23
|
+
uploads doctor
|
|
22
24
|
```
|
|
23
25
|
|
|
26
|
+
Inside this monorepo only, `pnpm uploads …` builds the package first so you pick
|
|
27
|
+
up local source; product docs and PR “how to try it” examples should use the
|
|
28
|
+
global `uploads` form above.
|
|
29
|
+
|
|
24
30
|
Commands: `attach`, `put`, `comment`, `list`, `delete`, `usage`, `reconcile`,
|
|
25
31
|
`purge-expired`, `setup`, `install`, `config`, `doctor`, `health`, `mcp`.
|
|
26
32
|
|
|
@@ -29,6 +35,21 @@ infers the pull request for the current branch via `gh`, uploads stable URLs, an
|
|
|
29
35
|
or updates one managed attachments comment. Use `--pr`, `--issue`, and `--repo` to select
|
|
30
36
|
the target explicitly, or `--no-comment` to upload without changing GitHub comments.
|
|
31
37
|
|
|
38
|
+
**Keys / destinations:** default put uses the `screenshots` layout. Typed destinations
|
|
39
|
+
(`--destination screenshots|gh|f`, MCP `destination`) set the root; `--pr`/`--issue`
|
|
40
|
+
use `gh/…`. Workspaces may restrict put/sign to those roots via
|
|
41
|
+
`allowedKeyPrefixes` (see [workspaces](../../docs/workspaces.md)).
|
|
42
|
+
|
|
43
|
+
**Image optimization:** by default, still images are re-encoded to WebP (long edge
|
|
44
|
+
capped, high quality) before upload so GitHub embeds stay small, and **EXIF is
|
|
45
|
+
stripped**. Pass `--keep-exif` / `UPLOADS_KEEP_EXIF=1` to preserve image metadata, or
|
|
46
|
+
`--no-optimize` / `UPLOADS_NO_OPTIMIZE=1` to upload originals unchanged.
|
|
47
|
+
|
|
48
|
+
**Frames (opt-in):** `--frame phone|browser|iphone-16-pro` composites chrome
|
|
49
|
+
**before** optimize. `phone`/`browser` are procedural; `iphone-16-pro` fetches
|
|
50
|
+
community art from [device-frames-media](https://github.com/jonnyjackson26/device-frames-media)
|
|
51
|
+
into `~/.cache/uploads/frames` (not bundled).
|
|
52
|
+
|
|
32
53
|
Config layers (first match wins): CLI flags → env vars → `--env-file` → `~/.config/buildinternet/config`. See `config.example` for keys.
|
|
33
54
|
|
|
34
55
|
## MCP server
|
package/dist/cli.js
CHANGED
|
@@ -94,6 +94,7 @@ function exitCode(err) {
|
|
|
94
94
|
return 2;
|
|
95
95
|
case "UNAUTHORIZED":
|
|
96
96
|
case "NOT_FOUND":
|
|
97
|
+
case "KEY_POLICY":
|
|
97
98
|
case "STORAGE_QUOTA":
|
|
98
99
|
case "UPLOAD_BUDGET":
|
|
99
100
|
return 3;
|
|
@@ -119,6 +120,17 @@ function errorOut(err, json) {
|
|
|
119
120
|
process.stderr.write(`${msg}\n`);
|
|
120
121
|
else
|
|
121
122
|
process.stderr.write(`error: ${msg}\n`);
|
|
123
|
+
if (err instanceof UploadsError) {
|
|
124
|
+
if (err.code === "STORAGE_QUOTA" || err.code === "UPLOAD_BUDGET") {
|
|
125
|
+
process.stderr.write("hint: run `uploads usage` then delete objects or raise limits (`pnpm workspace:limits`)\n");
|
|
126
|
+
}
|
|
127
|
+
else if (err.code === "KEY_POLICY") {
|
|
128
|
+
process.stderr.write("hint: use a typed destination (`--destination screenshots|gh`) or an allowed prefix; operators set allowlists with `pnpm workspace:limits --allowed-prefixes`\n");
|
|
129
|
+
}
|
|
130
|
+
else if (err.status === 413 || err.message.toLowerCase().includes("too large")) {
|
|
131
|
+
process.stderr.write("hint: file exceeds workspace size policy (images vs video may differ); compress or raise --max-upload-bytes / --max-video-bytes\n");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
122
134
|
}
|
|
123
135
|
}
|
|
124
136
|
export async function runCli(argv) {
|
package/dist/client.js
CHANGED
|
@@ -47,6 +47,9 @@ function mapApiError(status, error, code) {
|
|
|
47
47
|
if (status === 400 && normalized === "invalid key") {
|
|
48
48
|
return new UploadsError(error, "INVALID_KEY", status);
|
|
49
49
|
}
|
|
50
|
+
if (code === "key_prefix_not_allowed" || code === "key_too_deep") {
|
|
51
|
+
return new UploadsError(error, "KEY_POLICY", status);
|
|
52
|
+
}
|
|
50
53
|
// Prefer stable body.code — bare 429 is also used for write rate limits.
|
|
51
54
|
if (status === 507 || code === "storage_quota_exceeded") {
|
|
52
55
|
return new UploadsError(error, "STORAGE_QUOTA", status);
|
package/dist/commands.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { type UploadsClient } from "./client.js";
|
|
2
|
+
import { type CommandFlags } from "./cli-args.js";
|
|
2
3
|
import { type ResolvedConfig } from "./config.js";
|
|
3
4
|
import { type GhTarget } from "./github.js";
|
|
4
5
|
import { type CommandRunner } from "./github-gh.js";
|
|
6
|
+
import { type OptimizeImageOptions, type OptimizeImageResult } from "./optimize.js";
|
|
7
|
+
import { type FrameResult } from "./frame.js";
|
|
8
|
+
import type { PutDefaults } from "./config-file.js";
|
|
5
9
|
export interface CliContext {
|
|
6
10
|
config: ResolvedConfig;
|
|
7
11
|
client: UploadsClient;
|
|
@@ -14,6 +18,18 @@ export interface CliContext {
|
|
|
14
18
|
* neither is present. Shared by the CLI flags and the MCP tool arguments.
|
|
15
19
|
*/
|
|
16
20
|
export declare function makeGhTarget(pr: number | undefined, issue: number | undefined, repoArg: string | undefined, run: CommandRunner): GhTarget | undefined;
|
|
21
|
+
/** Shared put/attach optimize flags + UPLOADS_NO_OPTIMIZE default. */
|
|
22
|
+
export declare function optimizeOptionsFromFlags(flags: CommandFlags["flags"], defaults: PutDefaults): OptimizeImageOptions;
|
|
23
|
+
export type PreparedUpload = OptimizeImageResult & {
|
|
24
|
+
frame?: Pick<FrameResult, "framed" | "frameId" | "skippedReason">;
|
|
25
|
+
};
|
|
26
|
+
/** Frame (optional) then optimize — shared by put/attach/MCP. */
|
|
27
|
+
export declare function prepareImageForUpload(bytes: Uint8Array, filename: string, opts: {
|
|
28
|
+
frameId?: string;
|
|
29
|
+
frameUrl?: string;
|
|
30
|
+
frameFit?: "cover" | "contain";
|
|
31
|
+
optimize: OptimizeImageOptions;
|
|
32
|
+
}): Promise<PreparedUpload>;
|
|
17
33
|
/**
|
|
18
34
|
* List every attachment under the target's prefix and create/update the
|
|
19
35
|
* managed comment. Throws on gh failure — callers decide whether that is
|
package/dist/commands.js
CHANGED
|
@@ -8,19 +8,38 @@ import { UploadsError } from "./errors.js";
|
|
|
8
8
|
import { writeJson, writeStdout } from "./io.js";
|
|
9
9
|
import { ghAttachmentKey, ghKeyPrefix, attachmentsCommentBody, } from "./github.js";
|
|
10
10
|
import { resolveRepo, resolveCurrentPullRequest, execRunner, upsertAttachmentsComment, } from "./github-gh.js";
|
|
11
|
+
import { resolvePutPrefix } from "./destinations.js";
|
|
12
|
+
import { optimizeImageForUpload, rewriteKeyExtension, } from "./optimize.js";
|
|
13
|
+
import { applyFrame, resolveFrameId } from "./frame.js";
|
|
11
14
|
// --- put ---
|
|
12
15
|
const PUT_HELP = `uploads put <file> [options]
|
|
13
16
|
|
|
14
17
|
Upload an image for GitHub embeds. Use "-" for stdin.
|
|
15
18
|
|
|
19
|
+
Still images (PNG/JPEG/…) are optimized to WebP by default (long edge capped,
|
|
20
|
+
high quality; EXIF stripped) so GitHub embeds stay lean. Original bytes are kept
|
|
21
|
+
when they are already smaller, animated, or not an image. Use --no-optimize to
|
|
22
|
+
upload as-is, or --keep-exif when image metadata matters for the discussion.
|
|
23
|
+
|
|
24
|
+
Optional --frame wraps the image in a device/browser chrome before optimize
|
|
25
|
+
(default off). See: uploads put --help frames
|
|
26
|
+
|
|
16
27
|
Options:
|
|
17
28
|
--key <key> Object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.<ext>)
|
|
29
|
+
--destination <id> Typed root: screenshots | gh | f (sets --prefix)
|
|
18
30
|
--prefix <path> Key prefix (default: screenshots, or UPLOADS_DEFAULT_PREFIX)
|
|
19
31
|
--repo <owner/repo> Repo segment (default: git remote, or UPLOADS_DEFAULT_REPO)
|
|
20
32
|
--ref <id> PR/issue/branch segment (default: today, or UPLOADS_DEFAULT_REF)
|
|
21
33
|
--alt <text> Alt text (default: filename)
|
|
22
34
|
--width <px> <img width=…> markdown (or UPLOADS_DEFAULT_WIDTH)
|
|
23
|
-
--content-type <mime> Override Content-Type
|
|
35
|
+
--content-type <mime> Override Content-Type (ignored when optimize rewrites the body)
|
|
36
|
+
--frame <id> Device/browser frame before optimize (phone|browser|iphone-16-pro)
|
|
37
|
+
--frame-url <url> Address bar text for --frame browser
|
|
38
|
+
--frame-fit cover|contain How the shot fills the screen (default: cover)
|
|
39
|
+
--no-optimize Skip client-side image optimization (or UPLOADS_NO_OPTIMIZE=1)
|
|
40
|
+
--optimize-max-edge <px> Max long edge when optimizing (default: 2400)
|
|
41
|
+
--optimize-quality <1-100> WebP quality (default: 85)
|
|
42
|
+
--keep-exif Keep EXIF/XMP/ICC when optimizing (default: strip for privacy)
|
|
24
43
|
--no-git Don't derive --repo from git (or UPLOADS_NO_GIT=1)
|
|
25
44
|
--workspace, -w <name> Override workspace (wins over UPLOADS_WORKSPACE and token inference)
|
|
26
45
|
--format human|url|markdown|json
|
|
@@ -30,8 +49,9 @@ Options:
|
|
|
30
49
|
|
|
31
50
|
Examples:
|
|
32
51
|
uploads put ./shot.png --repo myorg/myapp --ref 1722 --alt "New cards" --width 700
|
|
33
|
-
uploads
|
|
34
|
-
uploads
|
|
52
|
+
uploads put ./mobile.png --frame phone
|
|
53
|
+
uploads put ./ui.png --frame browser --frame-url "https://app.example/settings"
|
|
54
|
+
uploads put ./shot.png --destination screenshots
|
|
35
55
|
`;
|
|
36
56
|
/**
|
|
37
57
|
* Turns a pr/issue pair (+ optional repo) into a GhTarget; undefined when
|
|
@@ -50,6 +70,82 @@ export function makeGhTarget(pr, issue, repoArg, run) {
|
|
|
50
70
|
function ghTargetFromFlags(flags, run) {
|
|
51
71
|
return makeGhTarget(flagInt(flags, "--pr", "--pr"), flagInt(flags, "--issue", "--issue"), flagString(flags, "--repo"), run);
|
|
52
72
|
}
|
|
73
|
+
/** Shared put/attach optimize flags + UPLOADS_NO_OPTIMIZE default. */
|
|
74
|
+
export function optimizeOptionsFromFlags(flags, defaults) {
|
|
75
|
+
if (flags.has("--no-optimize") && typeof flags.get("--no-optimize") === "string") {
|
|
76
|
+
throw new UsageError("--no-optimize takes no value");
|
|
77
|
+
}
|
|
78
|
+
if (flags.has("--keep-exif") && typeof flags.get("--keep-exif") === "string") {
|
|
79
|
+
throw new UsageError("--keep-exif takes no value");
|
|
80
|
+
}
|
|
81
|
+
const quality = flagInt(flags, "--optimize-quality", "--optimize-quality");
|
|
82
|
+
if (quality !== undefined && quality > 100) {
|
|
83
|
+
throw new UsageError("invalid --optimize-quality: must be 1–100");
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
enabled: !(flagBool(flags, "--no-optimize") || defaults.noOptimize === true),
|
|
87
|
+
maxEdge: flagInt(flags, "--optimize-max-edge", "--optimize-max-edge"),
|
|
88
|
+
quality,
|
|
89
|
+
keepExif: flagBool(flags, "--keep-exif") || defaults.keepExif === true,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function formatOptimizeNote(opt) {
|
|
93
|
+
if (opt.optimized) {
|
|
94
|
+
return `optimized ${opt.originalBytes} → ${opt.outputBytes} bytes (${opt.filename})`;
|
|
95
|
+
}
|
|
96
|
+
if (opt.skippedReason && opt.skippedReason !== "disabled") {
|
|
97
|
+
return `optimize skipped (${opt.skippedReason})`;
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
/** Frame (optional) then optimize — shared by put/attach/MCP. */
|
|
102
|
+
export async function prepareImageForUpload(bytes, filename, opts) {
|
|
103
|
+
let currentBytes = bytes;
|
|
104
|
+
let currentName = filename;
|
|
105
|
+
let frameMeta;
|
|
106
|
+
if (opts.frameId) {
|
|
107
|
+
const framed = await applyFrame(currentBytes, currentName, {
|
|
108
|
+
id: opts.frameId,
|
|
109
|
+
browserUrl: opts.frameUrl,
|
|
110
|
+
fit: opts.frameFit,
|
|
111
|
+
});
|
|
112
|
+
frameMeta = {
|
|
113
|
+
framed: framed.framed,
|
|
114
|
+
frameId: framed.frameId,
|
|
115
|
+
skippedReason: framed.skippedReason,
|
|
116
|
+
};
|
|
117
|
+
if (framed.framed) {
|
|
118
|
+
currentBytes = framed.bytes;
|
|
119
|
+
currentName = framed.filename;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const optimized = await optimizeImageForUpload(currentBytes, currentName, opts.optimize);
|
|
123
|
+
return { ...optimized, frame: frameMeta };
|
|
124
|
+
}
|
|
125
|
+
function frameOptionsFromFlags(flags) {
|
|
126
|
+
const raw = flagString(flags, "--frame");
|
|
127
|
+
let frameId;
|
|
128
|
+
try {
|
|
129
|
+
frameId = resolveFrameId(raw);
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
throw new UsageError(err instanceof Error ? err.message : String(err));
|
|
133
|
+
}
|
|
134
|
+
const fitRaw = flagString(flags, "--frame-fit");
|
|
135
|
+
let frameFit;
|
|
136
|
+
if (fitRaw) {
|
|
137
|
+
if (fitRaw !== "cover" && fitRaw !== "contain") {
|
|
138
|
+
throw new UsageError(`invalid --frame-fit: ${fitRaw} (use cover or contain)`);
|
|
139
|
+
}
|
|
140
|
+
frameFit = fitRaw;
|
|
141
|
+
}
|
|
142
|
+
if (frameFit && !frameId)
|
|
143
|
+
throw new UsageError("--frame-fit requires --frame");
|
|
144
|
+
const frameUrl = flagString(flags, "--frame-url");
|
|
145
|
+
if (frameUrl && !frameId)
|
|
146
|
+
throw new UsageError("--frame-url requires --frame");
|
|
147
|
+
return { frameId, frameUrl, frameFit };
|
|
148
|
+
}
|
|
53
149
|
/**
|
|
54
150
|
* List every attachment under the target's prefix and create/update the
|
|
55
151
|
* managed comment. Throws on gh failure — callers decide whether that is
|
|
@@ -69,16 +165,27 @@ const ATTACH_HELP = `uploads attach <file...> [options]
|
|
|
69
165
|
Upload one or more stable PR/issue attachments and maintain a single GitHub
|
|
70
166
|
comment. With no target, uses the pull request for the current branch.
|
|
71
167
|
|
|
168
|
+
Still images are optimized to WebP by default (same as put). Use --no-optimize
|
|
169
|
+
to upload originals. Optional --frame wraps images in device/browser chrome.
|
|
170
|
+
|
|
72
171
|
Options:
|
|
73
172
|
--pr <num> Attach to this pull request
|
|
74
173
|
--issue <num> Attach to this issue
|
|
75
174
|
--repo <owner/repo> Repository (default: gh/git inference)
|
|
76
175
|
--no-comment Upload only; don't create/update the managed comment
|
|
77
|
-
--content-type <mime> Override Content-Type (applied to every file)
|
|
176
|
+
--content-type <mime> Override Content-Type (applied to every file; ignored when optimize rewrites)
|
|
177
|
+
--frame <id> Device/browser frame before optimize (phone|browser|iphone-16-pro)
|
|
178
|
+
--frame-url <url> Address bar text for --frame browser
|
|
179
|
+
--frame-fit cover|contain How the shot fills the screen (default: cover)
|
|
180
|
+
--no-optimize Skip client-side image optimization (or UPLOADS_NO_OPTIMIZE=1)
|
|
181
|
+
--optimize-max-edge <px> Max long edge when optimizing (default: 2400)
|
|
182
|
+
--optimize-quality <1-100> WebP quality (default: 85)
|
|
183
|
+
--keep-exif Keep EXIF/XMP/ICC when optimizing (default: strip for privacy)
|
|
78
184
|
--workspace, -w <name> Override workspace
|
|
79
185
|
|
|
80
186
|
Examples:
|
|
81
187
|
uploads attach ./before.png ./after.png
|
|
188
|
+
uploads attach ./mobile.png --frame phone
|
|
82
189
|
uploads attach ./shot.png --pr 123 --repo myorg/myapp
|
|
83
190
|
uploads attach ./artifact.zip --issue 45 --no-comment
|
|
84
191
|
`;
|
|
@@ -98,19 +205,44 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
98
205
|
const explicitTarget = ghTargetFromFlags(parsed.flags, run);
|
|
99
206
|
const target = explicitTarget ??
|
|
100
207
|
resolveCurrentPullRequest(resolveRepo(flagString(parsed.flags, "--repo"), run), run);
|
|
208
|
+
const defaults = resolvePutDefaults({ envFile: ctx.envFile });
|
|
209
|
+
const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, defaults);
|
|
210
|
+
const frameOpts = frameOptionsFromFlags(parsed.flags);
|
|
211
|
+
const contentTypeOverride = flagString(parsed.flags, "--content-type");
|
|
101
212
|
const results = [];
|
|
102
213
|
for (const file of parsed.positionals) {
|
|
103
214
|
if (file === "-")
|
|
104
215
|
throw new UsageError("attach does not support stdin; pass one or more file paths");
|
|
105
|
-
const
|
|
216
|
+
const sourceName = basename(file);
|
|
106
217
|
if (!ctx.quiet && !ctx.json)
|
|
107
218
|
process.stderr.write(`>> uploading ${file}\n`);
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
219
|
+
const prepared = await prepareImageForUpload(new Uint8Array(readFileSync(file)), sourceName, {
|
|
220
|
+
...frameOpts,
|
|
221
|
+
optimize: optimizeOpts,
|
|
222
|
+
});
|
|
223
|
+
if (prepared.frame?.framed && !ctx.quiet && !ctx.json) {
|
|
224
|
+
process.stderr.write(`>> framed with ${prepared.frame.frameId}\n`);
|
|
225
|
+
}
|
|
226
|
+
const note = formatOptimizeNote(prepared);
|
|
227
|
+
if (note && !ctx.quiet && !ctx.json)
|
|
228
|
+
process.stderr.write(`>> ${note}\n`);
|
|
229
|
+
const result = await ctx.client.put(prepared.bytes, {
|
|
230
|
+
filename: prepared.filename,
|
|
231
|
+
key: ghAttachmentKey(target, prepared.filename),
|
|
232
|
+
contentType: prepared.optimized ? prepared.contentType : contentTypeOverride,
|
|
233
|
+
});
|
|
234
|
+
results.push({
|
|
235
|
+
...result,
|
|
236
|
+
markdown: buildMarkdown(result.url, { alt: sourceName }),
|
|
237
|
+
optimize: {
|
|
238
|
+
optimized: prepared.optimized,
|
|
239
|
+
skippedReason: prepared.skippedReason,
|
|
240
|
+
originalBytes: prepared.originalBytes,
|
|
241
|
+
outputBytes: prepared.outputBytes,
|
|
242
|
+
filename: prepared.filename,
|
|
243
|
+
},
|
|
244
|
+
frame: prepared.frame,
|
|
112
245
|
});
|
|
113
|
-
results.push({ ...result, markdown: buildMarkdown(result.url, { alt: filename }) });
|
|
114
246
|
}
|
|
115
247
|
let comment;
|
|
116
248
|
let commentError;
|
|
@@ -151,6 +283,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
151
283
|
return 2;
|
|
152
284
|
}
|
|
153
285
|
const keyHint = flagString(parsed.flags, "--key");
|
|
286
|
+
const destFlag = flagString(parsed.flags, "--destination");
|
|
287
|
+
const prefixFlag = flagString(parsed.flags, "--prefix");
|
|
154
288
|
const ghTarget = ghTargetFromFlags(parsed.flags, run);
|
|
155
289
|
const wantComment = parsed.flags.has("--comment");
|
|
156
290
|
if (wantComment && typeof parsed.flags.get("--comment") === "string") {
|
|
@@ -164,12 +298,23 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
164
298
|
if (flagString(parsed.flags, "--ref")) {
|
|
165
299
|
throw new UsageError("--ref cannot be combined with --pr/--issue");
|
|
166
300
|
}
|
|
167
|
-
if (
|
|
301
|
+
if (prefixFlag)
|
|
168
302
|
throw new UsageError("--prefix cannot be combined with --pr/--issue");
|
|
169
|
-
|
|
303
|
+
}
|
|
304
|
+
let resolvedPrefix;
|
|
305
|
+
try {
|
|
306
|
+
resolvedPrefix = resolvePutPrefix({
|
|
307
|
+
destination: destFlag,
|
|
308
|
+
prefix: prefixFlag,
|
|
309
|
+
key: keyHint,
|
|
310
|
+
ghAttachment: Boolean(ghTarget),
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
catch (err) {
|
|
314
|
+
throw new UsageError(err instanceof Error ? err.message : String(err));
|
|
170
315
|
}
|
|
171
316
|
const bytes = fileArg === "-" ? new Uint8Array(readFileSync(0)) : new Uint8Array(readFileSync(fileArg));
|
|
172
|
-
const
|
|
317
|
+
const sourceName = fileArg === "-" ? (keyHint ? basename(keyHint) : "stdin.bin") : basename(fileArg);
|
|
173
318
|
const format = ctx.json
|
|
174
319
|
? "json"
|
|
175
320
|
: (() => {
|
|
@@ -181,7 +326,15 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
181
326
|
throw new UsageError(`invalid --format: ${raw}`);
|
|
182
327
|
})();
|
|
183
328
|
const defaults = resolvePutDefaults({ envFile: ctx.envFile });
|
|
184
|
-
const
|
|
329
|
+
const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, defaults);
|
|
330
|
+
const frameOpts = frameOptionsFromFlags(parsed.flags);
|
|
331
|
+
const prepared = await prepareImageForUpload(bytes, sourceName, {
|
|
332
|
+
...frameOpts,
|
|
333
|
+
optimize: optimizeOpts,
|
|
334
|
+
});
|
|
335
|
+
const filename = prepared.filename;
|
|
336
|
+
const contentTypeOverride = flagString(parsed.flags, "--content-type");
|
|
337
|
+
const alt = flagString(parsed.flags, "--alt") ?? basename(sourceName);
|
|
185
338
|
const widthRaw = flagString(parsed.flags, "--width");
|
|
186
339
|
const width = widthRaw && /^\d+$/.test(widthRaw) && Number(widthRaw) > 0
|
|
187
340
|
? Number.parseInt(widthRaw, 10)
|
|
@@ -192,24 +345,39 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
192
345
|
: defaults.width;
|
|
193
346
|
if (!ctx.quiet && format === "human") {
|
|
194
347
|
process.stderr.write(`>> uploading ${fileArg === "-" ? "stdin" : fileArg}\n`);
|
|
348
|
+
if (prepared.frame?.framed)
|
|
349
|
+
process.stderr.write(`>> framed with ${prepared.frame.frameId}\n`);
|
|
350
|
+
const note = formatOptimizeNote(prepared);
|
|
351
|
+
if (note)
|
|
352
|
+
process.stderr.write(`>> ${note}\n`);
|
|
195
353
|
}
|
|
196
354
|
const noGit = flagBool(parsed.flags, "--no-git") || defaults.noGit === true;
|
|
197
|
-
|
|
355
|
+
let key = ghTarget ? ghAttachmentKey(ghTarget, filename) : keyHint;
|
|
356
|
+
if (key && prepared.optimized)
|
|
357
|
+
key = rewriteKeyExtension(key, filename);
|
|
358
|
+
const result = await ctx.client.put(prepared.bytes, {
|
|
198
359
|
filename,
|
|
199
|
-
key
|
|
200
|
-
prefix:
|
|
360
|
+
key,
|
|
361
|
+
prefix: resolvedPrefix ?? defaults.prefix,
|
|
201
362
|
repo: flagString(parsed.flags, "--repo") ?? defaults.repo,
|
|
202
363
|
ref: flagString(parsed.flags, "--ref") ?? defaults.ref,
|
|
203
|
-
contentType:
|
|
364
|
+
contentType: prepared.optimized ? prepared.contentType : contentTypeOverride,
|
|
204
365
|
deriveRepoFromGit: !noGit,
|
|
205
366
|
});
|
|
206
367
|
const markdown = buildMarkdown(result.url, { alt, width });
|
|
368
|
+
const optimizeMeta = {
|
|
369
|
+
optimized: prepared.optimized,
|
|
370
|
+
skippedReason: prepared.skippedReason,
|
|
371
|
+
originalBytes: prepared.originalBytes,
|
|
372
|
+
outputBytes: prepared.outputBytes,
|
|
373
|
+
filename: prepared.filename,
|
|
374
|
+
};
|
|
207
375
|
if (!ctx.quiet && format === "human") {
|
|
208
376
|
process.stderr.write(`>> key: ${result.key}\n\n`);
|
|
209
377
|
}
|
|
210
378
|
switch (format) {
|
|
211
379
|
case "json":
|
|
212
|
-
await writeJson({ ...result, markdown });
|
|
380
|
+
await writeJson({ ...result, markdown, optimize: optimizeMeta, frame: prepared.frame });
|
|
213
381
|
break;
|
|
214
382
|
case "url":
|
|
215
383
|
await writeStdout(`${result.url}\n`);
|
package/dist/config-file.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { UploadsClientConfig } from "./config.js";
|
|
2
|
-
export declare const UPLOADS_CONFIG_KEYS: readonly ["UPLOADS_API_URL", "UPLOADS_WORKSPACE", "UPLOADS_TOKEN", "UPLOADS_DEFAULT_PREFIX", "UPLOADS_DEFAULT_REPO", "UPLOADS_DEFAULT_REF", "UPLOADS_DEFAULT_WIDTH", "UPLOADS_NO_GIT"];
|
|
2
|
+
export declare const UPLOADS_CONFIG_KEYS: readonly ["UPLOADS_API_URL", "UPLOADS_WORKSPACE", "UPLOADS_TOKEN", "UPLOADS_DEFAULT_PREFIX", "UPLOADS_DEFAULT_REPO", "UPLOADS_DEFAULT_REF", "UPLOADS_DEFAULT_WIDTH", "UPLOADS_NO_GIT", "UPLOADS_NO_OPTIMIZE", "UPLOADS_KEEP_EXIF"];
|
|
3
3
|
export type UploadsConfigKey = (typeof UPLOADS_CONFIG_KEYS)[number];
|
|
4
4
|
export type UploadsConfigValues = Partial<Record<UploadsConfigKey, string>>;
|
|
5
5
|
export interface PutDefaults {
|
|
@@ -8,6 +8,10 @@ export interface PutDefaults {
|
|
|
8
8
|
ref?: string;
|
|
9
9
|
width?: number;
|
|
10
10
|
noGit?: boolean;
|
|
11
|
+
/** When true, put/attach skip client-side image optimization. */
|
|
12
|
+
noOptimize?: boolean;
|
|
13
|
+
/** When true, optimize keeps EXIF/XMP/ICC (default strips). */
|
|
14
|
+
keepExif?: boolean;
|
|
11
15
|
}
|
|
12
16
|
declare const PUT_DEFAULT_KEY_MAP: Record<keyof PutDefaults, UploadsConfigKey>;
|
|
13
17
|
export declare function putDefaultsToConfigValues(defaults: PutDefaults): UploadsConfigValues;
|
package/dist/config-file.js
CHANGED
|
@@ -10,6 +10,8 @@ export const UPLOADS_CONFIG_KEYS = [
|
|
|
10
10
|
"UPLOADS_DEFAULT_REF",
|
|
11
11
|
"UPLOADS_DEFAULT_WIDTH",
|
|
12
12
|
"UPLOADS_NO_GIT",
|
|
13
|
+
"UPLOADS_NO_OPTIMIZE",
|
|
14
|
+
"UPLOADS_KEEP_EXIF",
|
|
13
15
|
];
|
|
14
16
|
const PUT_DEFAULT_KEY_MAP = {
|
|
15
17
|
prefix: "UPLOADS_DEFAULT_PREFIX",
|
|
@@ -17,7 +19,15 @@ const PUT_DEFAULT_KEY_MAP = {
|
|
|
17
19
|
ref: "UPLOADS_DEFAULT_REF",
|
|
18
20
|
width: "UPLOADS_DEFAULT_WIDTH",
|
|
19
21
|
noGit: "UPLOADS_NO_GIT",
|
|
22
|
+
noOptimize: "UPLOADS_NO_OPTIMIZE",
|
|
23
|
+
keepExif: "UPLOADS_KEEP_EXIF",
|
|
20
24
|
};
|
|
25
|
+
function isTruthyConfigFlag(value) {
|
|
26
|
+
if (!value)
|
|
27
|
+
return false;
|
|
28
|
+
const v = value.toLowerCase();
|
|
29
|
+
return v === "1" || v === "true" || v === "yes";
|
|
30
|
+
}
|
|
21
31
|
export function putDefaultsToConfigValues(defaults) {
|
|
22
32
|
const out = {};
|
|
23
33
|
if (defaults.prefix)
|
|
@@ -30,6 +40,10 @@ export function putDefaultsToConfigValues(defaults) {
|
|
|
30
40
|
out.UPLOADS_DEFAULT_WIDTH = String(defaults.width);
|
|
31
41
|
if (defaults.noGit)
|
|
32
42
|
out.UPLOADS_NO_GIT = "1";
|
|
43
|
+
if (defaults.noOptimize)
|
|
44
|
+
out.UPLOADS_NO_OPTIMIZE = "1";
|
|
45
|
+
if (defaults.keepExif)
|
|
46
|
+
out.UPLOADS_KEEP_EXIF = "1";
|
|
33
47
|
return out;
|
|
34
48
|
}
|
|
35
49
|
function parsePutDefaultsFromRaw(raw) {
|
|
@@ -45,9 +59,12 @@ function parsePutDefaultsFromRaw(raw) {
|
|
|
45
59
|
if (Number.isFinite(n) && n > 0)
|
|
46
60
|
out.width = n;
|
|
47
61
|
}
|
|
48
|
-
if (raw.UPLOADS_NO_GIT
|
|
62
|
+
if (isTruthyConfigFlag(raw.UPLOADS_NO_GIT))
|
|
49
63
|
out.noGit = true;
|
|
50
|
-
|
|
64
|
+
if (isTruthyConfigFlag(raw.UPLOADS_NO_OPTIMIZE))
|
|
65
|
+
out.noOptimize = true;
|
|
66
|
+
if (isTruthyConfigFlag(raw.UPLOADS_KEEP_EXIF))
|
|
67
|
+
out.keepExif = true;
|
|
51
68
|
return out;
|
|
52
69
|
}
|
|
53
70
|
function parsePutDefaultsFromEnv() {
|
|
@@ -62,6 +79,10 @@ function parsePutDefaultsFromEnv() {
|
|
|
62
79
|
raw.UPLOADS_DEFAULT_WIDTH = process.env.UPLOADS_DEFAULT_WIDTH;
|
|
63
80
|
if (process.env.UPLOADS_NO_GIT)
|
|
64
81
|
raw.UPLOADS_NO_GIT = process.env.UPLOADS_NO_GIT;
|
|
82
|
+
if (process.env.UPLOADS_NO_OPTIMIZE)
|
|
83
|
+
raw.UPLOADS_NO_OPTIMIZE = process.env.UPLOADS_NO_OPTIMIZE;
|
|
84
|
+
if (process.env.UPLOADS_KEEP_EXIF)
|
|
85
|
+
raw.UPLOADS_KEEP_EXIF = process.env.UPLOADS_KEEP_EXIF;
|
|
65
86
|
return parsePutDefaultsFromRaw(raw);
|
|
66
87
|
}
|
|
67
88
|
/** XDG default shared across buildinternet skills (github-screenshots, uploads, …). */
|
|
@@ -127,6 +148,10 @@ export function mergePutDefaults(...layers) {
|
|
|
127
148
|
out.width = layer.width;
|
|
128
149
|
if (layer.noGit != null)
|
|
129
150
|
out.noGit = layer.noGit;
|
|
151
|
+
if (layer.noOptimize != null)
|
|
152
|
+
out.noOptimize = layer.noOptimize;
|
|
153
|
+
if (layer.keepExif != null)
|
|
154
|
+
out.keepExif = layer.keepExif;
|
|
130
155
|
}
|
|
131
156
|
return out;
|
|
132
157
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed destination roots for put/attach. Matches the API allowlist defaults
|
|
3
|
+
* (`f/`, `screenshots/`, `gh/`) — see apps/api `key-policy.ts`.
|
|
4
|
+
*/
|
|
5
|
+
export declare const BUILTIN_DESTINATIONS: {
|
|
6
|
+
readonly f: "f";
|
|
7
|
+
readonly screenshots: "screenshots";
|
|
8
|
+
readonly gh: "gh";
|
|
9
|
+
};
|
|
10
|
+
export type BuiltinDestinationId = keyof typeof BUILTIN_DESTINATIONS;
|
|
11
|
+
export declare function isBuiltinDestination(id: string): id is BuiltinDestinationId;
|
|
12
|
+
/** Root segment for a known destination, or throws with a usage-friendly message. */
|
|
13
|
+
export declare function resolveDestinationRoot(id: string): string;
|
|
14
|
+
/** True when `key` is under the destination root. */
|
|
15
|
+
export declare function keyMatchesDestination(key: string, destinationId: string): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Resolve CLI/MCP destination flags into a put `prefix`. Throws plain Errors
|
|
18
|
+
* (callers wrap as UsageError / MCP usage errors).
|
|
19
|
+
*/
|
|
20
|
+
export declare function resolvePutPrefix(opts: {
|
|
21
|
+
destination?: string;
|
|
22
|
+
prefix?: string;
|
|
23
|
+
key?: string;
|
|
24
|
+
/** When true (PR/issue attach), destination must be `gh` or omitted. */
|
|
25
|
+
ghAttachment?: boolean;
|
|
26
|
+
}): string | undefined;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed destination roots for put/attach. Matches the API allowlist defaults
|
|
3
|
+
* (`f/`, `screenshots/`, `gh/`) — see apps/api `key-policy.ts`.
|
|
4
|
+
*/
|
|
5
|
+
export const BUILTIN_DESTINATIONS = {
|
|
6
|
+
f: "f",
|
|
7
|
+
screenshots: "screenshots",
|
|
8
|
+
gh: "gh",
|
|
9
|
+
};
|
|
10
|
+
export function isBuiltinDestination(id) {
|
|
11
|
+
return Object.hasOwn(BUILTIN_DESTINATIONS, id);
|
|
12
|
+
}
|
|
13
|
+
/** Root segment for a known destination, or throws with a usage-friendly message. */
|
|
14
|
+
export function resolveDestinationRoot(id) {
|
|
15
|
+
if (!isBuiltinDestination(id)) {
|
|
16
|
+
const known = Object.keys(BUILTIN_DESTINATIONS).join(", ");
|
|
17
|
+
throw new Error(`unknown destination: ${id} (known: ${known})`);
|
|
18
|
+
}
|
|
19
|
+
return BUILTIN_DESTINATIONS[id];
|
|
20
|
+
}
|
|
21
|
+
/** True when `key` is under the destination root. */
|
|
22
|
+
export function keyMatchesDestination(key, destinationId) {
|
|
23
|
+
if (!isBuiltinDestination(destinationId))
|
|
24
|
+
return false;
|
|
25
|
+
const root = BUILTIN_DESTINATIONS[destinationId];
|
|
26
|
+
return key === root || key.startsWith(`${root}/`);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Resolve CLI/MCP destination flags into a put `prefix`. Throws plain Errors
|
|
30
|
+
* (callers wrap as UsageError / MCP usage errors).
|
|
31
|
+
*/
|
|
32
|
+
export function resolvePutPrefix(opts) {
|
|
33
|
+
const { destination, prefix, key, ghAttachment } = opts;
|
|
34
|
+
if (!destination)
|
|
35
|
+
return prefix;
|
|
36
|
+
const root = resolveDestinationRoot(destination);
|
|
37
|
+
if (ghAttachment && destination !== "gh") {
|
|
38
|
+
throw new Error("destination with pr/issue must be gh (or omit it)");
|
|
39
|
+
}
|
|
40
|
+
if (key && !keyMatchesDestination(key, destination)) {
|
|
41
|
+
throw new Error(`key must start with destination root "${root}/"`);
|
|
42
|
+
}
|
|
43
|
+
if (prefix && prefix.replace(/\/+$/, "") !== root) {
|
|
44
|
+
throw new Error(`prefix (${prefix}) conflicts with destination ${destination} (root ${root})`);
|
|
45
|
+
}
|
|
46
|
+
return root;
|
|
47
|
+
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type UploadsErrorCode = "MISSING_TOKEN" | "NO_PUBLIC_URL" | "NOT_FOUND" | "UNAUTHORIZED" | "INVALID_KEY" | "STORAGE_QUOTA" | "UPLOAD_BUDGET" | "API_ERROR" | "NETWORK" | "USAGE";
|
|
1
|
+
export type UploadsErrorCode = "MISSING_TOKEN" | "NO_PUBLIC_URL" | "NOT_FOUND" | "UNAUTHORIZED" | "INVALID_KEY" | "KEY_POLICY" | "STORAGE_QUOTA" | "UPLOAD_BUDGET" | "API_ERROR" | "NETWORK" | "USAGE";
|
|
2
2
|
export declare class UploadsError extends Error {
|
|
3
3
|
readonly code: UploadsErrorCode;
|
|
4
4
|
readonly status?: number;
|
package/dist/frame.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type FrameFit = "cover" | "contain";
|
|
2
|
+
export interface FrameOptions {
|
|
3
|
+
id: string;
|
|
4
|
+
fit?: FrameFit;
|
|
5
|
+
/** Address bar text for procedural `browser`. */
|
|
6
|
+
browserUrl?: string;
|
|
7
|
+
fetchImpl?: typeof fetch;
|
|
8
|
+
cacheDir?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface FrameResult {
|
|
11
|
+
bytes: Uint8Array;
|
|
12
|
+
filename: string;
|
|
13
|
+
contentType: string;
|
|
14
|
+
framed: boolean;
|
|
15
|
+
frameId: string;
|
|
16
|
+
skippedReason?: string;
|
|
17
|
+
}
|
|
18
|
+
type FramePreset = {
|
|
19
|
+
kind: "procedural";
|
|
20
|
+
label: string;
|
|
21
|
+
} | {
|
|
22
|
+
kind: "remote";
|
|
23
|
+
label: string;
|
|
24
|
+
/** Directory URL with frame.png, mask.png, template.json */
|
|
25
|
+
assetBase: string;
|
|
26
|
+
};
|
|
27
|
+
export declare const FRAME_PRESETS: Record<string, FramePreset>;
|
|
28
|
+
export declare function listFramePresets(): Array<{
|
|
29
|
+
id: string;
|
|
30
|
+
label: string;
|
|
31
|
+
kind: string;
|
|
32
|
+
}>;
|
|
33
|
+
export declare function resolveFrameId(raw: string | undefined): string | undefined;
|
|
34
|
+
/** Apply a named frame. Non-images pass through. Output PNG for the optimize step. */
|
|
35
|
+
export declare function applyFrame(bytes: Uint8Array, filename: string, opts: FrameOptions): Promise<FrameResult>;
|
|
36
|
+
export {};
|