@buildinternet/uploads 0.1.1 → 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/LICENSE +21 -0
- package/README.md +46 -10
- package/dist/cli.js +39 -2
- package/dist/client.d.ts +48 -1
- package/dist/client.js +52 -12
- package/dist/commands/install.d.ts +8 -0
- package/dist/commands/install.js +133 -0
- package/dist/commands/mcp.d.ts +4 -0
- package/dist/commands/mcp.js +39 -0
- package/dist/commands.d.ts +63 -0
- package/dist/commands.js +337 -76
- 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 +4 -1
- package/dist/index.js +3 -0
- package/dist/io.d.ts +3 -0
- package/dist/io.js +9 -0
- package/dist/mcp/args.d.ts +4 -0
- package/dist/mcp/args.js +26 -0
- package/dist/mcp/server.d.ts +19 -0
- package/dist/mcp/server.js +109 -0
- package/dist/mcp/stdio.d.ts +3 -0
- package/dist/mcp/stdio.js +14 -0
- package/dist/mcp/tools.d.ts +10 -0
- package/dist/mcp/tools.js +522 -0
- package/dist/optimize.d.ts +38 -0
- package/dist/optimize.js +177 -0
- package/package.json +15 -9
package/dist/commands.js
CHANGED
|
@@ -5,29 +5,41 @@ import { parseCommandArgs, flagString, flagBool, flagInt, UsageError, } from "./
|
|
|
5
5
|
import { resolvePutDefaults, workspaceMismatch, workspaceFromToken, } from "./config.js";
|
|
6
6
|
import { buildMarkdown } from "./embed.js";
|
|
7
7
|
import { UploadsError } from "./errors.js";
|
|
8
|
+
import { writeJson, writeStdout } from "./io.js";
|
|
8
9
|
import { ghAttachmentKey, ghKeyPrefix, attachmentsCommentBody, } from "./github.js";
|
|
9
10
|
import { resolveRepo, resolveCurrentPullRequest, execRunner, upsertAttachmentsComment, } from "./github-gh.js";
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
async function writeJson(value) {
|
|
16
|
-
await writeStdout(JSON.stringify(value, null, 2) + "\n");
|
|
17
|
-
}
|
|
11
|
+
import { resolvePutPrefix } from "./destinations.js";
|
|
12
|
+
import { optimizeImageForUpload, rewriteKeyExtension, } from "./optimize.js";
|
|
13
|
+
import { applyFrame, resolveFrameId } from "./frame.js";
|
|
18
14
|
// --- put ---
|
|
19
15
|
const PUT_HELP = `uploads put <file> [options]
|
|
20
16
|
|
|
21
17
|
Upload an image for GitHub embeds. Use "-" for stdin.
|
|
22
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
|
+
|
|
23
27
|
Options:
|
|
24
28
|
--key <key> Object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.<ext>)
|
|
29
|
+
--destination <id> Typed root: screenshots | gh | f (sets --prefix)
|
|
25
30
|
--prefix <path> Key prefix (default: screenshots, or UPLOADS_DEFAULT_PREFIX)
|
|
26
31
|
--repo <owner/repo> Repo segment (default: git remote, or UPLOADS_DEFAULT_REPO)
|
|
27
32
|
--ref <id> PR/issue/branch segment (default: today, or UPLOADS_DEFAULT_REF)
|
|
28
33
|
--alt <text> Alt text (default: filename)
|
|
29
34
|
--width <px> <img width=…> markdown (or UPLOADS_DEFAULT_WIDTH)
|
|
30
|
-
--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)
|
|
31
43
|
--no-git Don't derive --repo from git (or UPLOADS_NO_GIT=1)
|
|
32
44
|
--workspace, -w <name> Override workspace (wins over UPLOADS_WORKSPACE and token inference)
|
|
33
45
|
--format human|url|markdown|json
|
|
@@ -37,34 +49,110 @@ Options:
|
|
|
37
49
|
|
|
38
50
|
Examples:
|
|
39
51
|
uploads put ./shot.png --repo myorg/myapp --ref 1722 --alt "New cards" --width 700
|
|
40
|
-
uploads
|
|
41
|
-
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
|
|
42
55
|
`;
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Turns a pr/issue pair (+ optional repo) into a GhTarget; undefined when
|
|
58
|
+
* neither is present. Shared by the CLI flags and the MCP tool arguments.
|
|
59
|
+
*/
|
|
60
|
+
export function makeGhTarget(pr, issue, repoArg, run) {
|
|
47
61
|
if (pr === undefined && issue === undefined)
|
|
48
62
|
return undefined;
|
|
49
63
|
if (pr !== undefined && issue !== undefined) {
|
|
50
64
|
throw new UsageError("--pr and --issue are mutually exclusive");
|
|
51
65
|
}
|
|
52
|
-
const repo = resolveRepo(
|
|
66
|
+
const repo = resolveRepo(repoArg, run);
|
|
53
67
|
return { repo, kind: pr !== undefined ? "pull" : "issues", num: (pr ?? issue) };
|
|
54
68
|
}
|
|
69
|
+
/** Reads --pr/--issue (+ --repo) into a GhTarget; undefined when neither flag is present. */
|
|
70
|
+
function ghTargetFromFlags(flags, run) {
|
|
71
|
+
return makeGhTarget(flagInt(flags, "--pr", "--pr"), flagInt(flags, "--issue", "--issue"), flagString(flags, "--repo"), run);
|
|
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
|
+
}
|
|
55
149
|
/**
|
|
56
150
|
* List every attachment under the target's prefix and create/update the
|
|
57
151
|
* managed comment. Throws on gh failure — callers decide whether that is
|
|
58
152
|
* fatal (`comment` command) or a warning (`put --comment`).
|
|
59
153
|
*/
|
|
60
|
-
async function syncAttachmentsComment(
|
|
61
|
-
const items =
|
|
62
|
-
let cursor;
|
|
63
|
-
do {
|
|
64
|
-
const page = await ctx.client.list({ prefix: ghKeyPrefix(target), cursor });
|
|
65
|
-
items.push(...page.items.map(({ key, url }) => ({ key, url })));
|
|
66
|
-
cursor = page.cursor ?? undefined;
|
|
67
|
-
} while (cursor);
|
|
154
|
+
export async function syncAttachmentsComment(client, target, run) {
|
|
155
|
+
const items = (await client.listAll({ prefix: ghKeyPrefix(target) })).map(({ key, url }) => ({ key, url }));
|
|
68
156
|
if (items.length === 0)
|
|
69
157
|
return { action: "skipped", count: 0 };
|
|
70
158
|
const body = attachmentsCommentBody(items);
|
|
@@ -77,16 +165,27 @@ const ATTACH_HELP = `uploads attach <file...> [options]
|
|
|
77
165
|
Upload one or more stable PR/issue attachments and maintain a single GitHub
|
|
78
166
|
comment. With no target, uses the pull request for the current branch.
|
|
79
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
|
+
|
|
80
171
|
Options:
|
|
81
172
|
--pr <num> Attach to this pull request
|
|
82
173
|
--issue <num> Attach to this issue
|
|
83
174
|
--repo <owner/repo> Repository (default: gh/git inference)
|
|
84
175
|
--no-comment Upload only; don't create/update the managed comment
|
|
85
|
-
--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)
|
|
86
184
|
--workspace, -w <name> Override workspace
|
|
87
185
|
|
|
88
186
|
Examples:
|
|
89
187
|
uploads attach ./before.png ./after.png
|
|
188
|
+
uploads attach ./mobile.png --frame phone
|
|
90
189
|
uploads attach ./shot.png --pr 123 --repo myorg/myapp
|
|
91
190
|
uploads attach ./artifact.zip --issue 45 --no-comment
|
|
92
191
|
`;
|
|
@@ -106,25 +205,50 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
106
205
|
const explicitTarget = ghTargetFromFlags(parsed.flags, run);
|
|
107
206
|
const target = explicitTarget ??
|
|
108
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");
|
|
109
212
|
const results = [];
|
|
110
213
|
for (const file of parsed.positionals) {
|
|
111
214
|
if (file === "-")
|
|
112
215
|
throw new UsageError("attach does not support stdin; pass one or more file paths");
|
|
113
|
-
const
|
|
216
|
+
const sourceName = basename(file);
|
|
114
217
|
if (!ctx.quiet && !ctx.json)
|
|
115
218
|
process.stderr.write(`>> uploading ${file}\n`);
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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,
|
|
120
245
|
});
|
|
121
|
-
results.push({ ...result, markdown: buildMarkdown(result.url, { alt: filename }) });
|
|
122
246
|
}
|
|
123
247
|
let comment;
|
|
124
248
|
let commentError;
|
|
125
249
|
if (!parsed.flags.has("--no-comment")) {
|
|
126
250
|
try {
|
|
127
|
-
comment = await syncAttachmentsComment(ctx, target, run);
|
|
251
|
+
comment = await syncAttachmentsComment(ctx.client, target, run);
|
|
128
252
|
}
|
|
129
253
|
catch (err) {
|
|
130
254
|
commentError = err instanceof Error ? err.message : String(err);
|
|
@@ -159,6 +283,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
159
283
|
return 2;
|
|
160
284
|
}
|
|
161
285
|
const keyHint = flagString(parsed.flags, "--key");
|
|
286
|
+
const destFlag = flagString(parsed.flags, "--destination");
|
|
287
|
+
const prefixFlag = flagString(parsed.flags, "--prefix");
|
|
162
288
|
const ghTarget = ghTargetFromFlags(parsed.flags, run);
|
|
163
289
|
const wantComment = parsed.flags.has("--comment");
|
|
164
290
|
if (wantComment && typeof parsed.flags.get("--comment") === "string") {
|
|
@@ -172,12 +298,23 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
172
298
|
if (flagString(parsed.flags, "--ref")) {
|
|
173
299
|
throw new UsageError("--ref cannot be combined with --pr/--issue");
|
|
174
300
|
}
|
|
175
|
-
if (
|
|
301
|
+
if (prefixFlag)
|
|
176
302
|
throw new UsageError("--prefix cannot be combined with --pr/--issue");
|
|
177
|
-
|
|
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));
|
|
178
315
|
}
|
|
179
316
|
const bytes = fileArg === "-" ? new Uint8Array(readFileSync(0)) : new Uint8Array(readFileSync(fileArg));
|
|
180
|
-
const
|
|
317
|
+
const sourceName = fileArg === "-" ? (keyHint ? basename(keyHint) : "stdin.bin") : basename(fileArg);
|
|
181
318
|
const format = ctx.json
|
|
182
319
|
? "json"
|
|
183
320
|
: (() => {
|
|
@@ -189,7 +326,15 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
189
326
|
throw new UsageError(`invalid --format: ${raw}`);
|
|
190
327
|
})();
|
|
191
328
|
const defaults = resolvePutDefaults({ envFile: ctx.envFile });
|
|
192
|
-
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);
|
|
193
338
|
const widthRaw = flagString(parsed.flags, "--width");
|
|
194
339
|
const width = widthRaw && /^\d+$/.test(widthRaw) && Number(widthRaw) > 0
|
|
195
340
|
? Number.parseInt(widthRaw, 10)
|
|
@@ -200,24 +345,39 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
200
345
|
: defaults.width;
|
|
201
346
|
if (!ctx.quiet && format === "human") {
|
|
202
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`);
|
|
203
353
|
}
|
|
204
354
|
const noGit = flagBool(parsed.flags, "--no-git") || defaults.noGit === true;
|
|
205
|
-
|
|
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, {
|
|
206
359
|
filename,
|
|
207
|
-
key
|
|
208
|
-
prefix:
|
|
360
|
+
key,
|
|
361
|
+
prefix: resolvedPrefix ?? defaults.prefix,
|
|
209
362
|
repo: flagString(parsed.flags, "--repo") ?? defaults.repo,
|
|
210
363
|
ref: flagString(parsed.flags, "--ref") ?? defaults.ref,
|
|
211
|
-
contentType:
|
|
364
|
+
contentType: prepared.optimized ? prepared.contentType : contentTypeOverride,
|
|
212
365
|
deriveRepoFromGit: !noGit,
|
|
213
366
|
});
|
|
214
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
|
+
};
|
|
215
375
|
if (!ctx.quiet && format === "human") {
|
|
216
376
|
process.stderr.write(`>> key: ${result.key}\n\n`);
|
|
217
377
|
}
|
|
218
378
|
switch (format) {
|
|
219
379
|
case "json":
|
|
220
|
-
await writeJson({ ...result, markdown });
|
|
380
|
+
await writeJson({ ...result, markdown, optimize: optimizeMeta, frame: prepared.frame });
|
|
221
381
|
break;
|
|
222
382
|
case "url":
|
|
223
383
|
await writeStdout(`${result.url}\n`);
|
|
@@ -230,7 +390,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
230
390
|
}
|
|
231
391
|
if (wantComment && ghTarget) {
|
|
232
392
|
try {
|
|
233
|
-
const sync = await syncAttachmentsComment(ctx, ghTarget, run);
|
|
393
|
+
const sync = await syncAttachmentsComment(ctx.client, ghTarget, run);
|
|
234
394
|
if (!ctx.quiet && format === "human") {
|
|
235
395
|
process.stderr.write(`>> attachments comment ${sync.action}\n`);
|
|
236
396
|
}
|
|
@@ -270,13 +430,8 @@ export async function runList(ctx, args, help = false, run = execRunner) {
|
|
|
270
430
|
const limit = flagInt(parsed.flags, "--limit", "--limit");
|
|
271
431
|
const cursor = flagString(parsed.flags, "--cursor");
|
|
272
432
|
if (flagBool(parsed.flags, "--all")) {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
do {
|
|
276
|
-
const page = await ctx.client.list({ prefix, limit, cursor: next ?? undefined });
|
|
277
|
-
items.push(...page.items);
|
|
278
|
-
next = page.cursor;
|
|
279
|
-
} while (next);
|
|
433
|
+
// --all may start from a caller-provided --cursor and drains from there.
|
|
434
|
+
const items = await ctx.client.listAll({ prefix, limit, cursor });
|
|
280
435
|
if (ctx.json)
|
|
281
436
|
await writeJson({ items, cursor: null });
|
|
282
437
|
else
|
|
@@ -347,7 +502,7 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
|
|
|
347
502
|
const target = ghTargetFromFlags(parsed.flags, run);
|
|
348
503
|
if (!target)
|
|
349
504
|
throw new UsageError("comment requires --pr or --issue");
|
|
350
|
-
const result = await syncAttachmentsComment(ctx, target, run);
|
|
505
|
+
const result = await syncAttachmentsComment(ctx.client, target, run);
|
|
351
506
|
if (ctx.json) {
|
|
352
507
|
await writeJson({ ...target, ...result });
|
|
353
508
|
}
|
|
@@ -358,6 +513,83 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
|
|
|
358
513
|
}
|
|
359
514
|
return 0;
|
|
360
515
|
}
|
|
516
|
+
// --- usage / reconcile / purge ---
|
|
517
|
+
const USAGE_HELP = `uploads usage [--workspace <name>]
|
|
518
|
+
|
|
519
|
+
Show workspace storage and monthly upload counters (and limits when set).
|
|
520
|
+
|
|
521
|
+
Examples:
|
|
522
|
+
uploads --env-file .env usage
|
|
523
|
+
uploads usage --json
|
|
524
|
+
`;
|
|
525
|
+
export async function runUsage(ctx, args, help = false) {
|
|
526
|
+
if (help || parseCommandArgs(args).help) {
|
|
527
|
+
process.stderr.write(USAGE_HELP);
|
|
528
|
+
return 0;
|
|
529
|
+
}
|
|
530
|
+
const result = await ctx.client.usage();
|
|
531
|
+
if (ctx.json) {
|
|
532
|
+
await writeJson(result);
|
|
533
|
+
return 0;
|
|
534
|
+
}
|
|
535
|
+
const lines = [
|
|
536
|
+
`workspace: ${result.workspace}`,
|
|
537
|
+
`bytes: ${result.bytes}${result.maxStorageBytes != null ? ` / ${result.maxStorageBytes} (${result.storageRemainingBytes} remaining)` : ""}`,
|
|
538
|
+
`objects: ${result.objects}`,
|
|
539
|
+
`uploads: ${result.uploadsInPeriod} this period (${result.periodStart})${result.maxUploadsPerPeriod != null ? ` / ${result.maxUploadsPerPeriod} (${result.uploadsRemaining} remaining)` : ""}`,
|
|
540
|
+
`updated: ${result.updatedAt}`,
|
|
541
|
+
];
|
|
542
|
+
await writeStdout(lines.join("\n") + "\n");
|
|
543
|
+
return 0;
|
|
544
|
+
}
|
|
545
|
+
const RECONCILE_HELP = `uploads reconcile [--workspace <name>]
|
|
546
|
+
|
|
547
|
+
Rebuild ledger bytes/objects from storage (source of truth). Preserves the
|
|
548
|
+
monthly upload counter. Requires files:write.
|
|
549
|
+
|
|
550
|
+
Examples:
|
|
551
|
+
uploads --env-file .env reconcile
|
|
552
|
+
`;
|
|
553
|
+
export async function runReconcile(ctx, args, help = false) {
|
|
554
|
+
if (help || parseCommandArgs(args).help) {
|
|
555
|
+
process.stderr.write(RECONCILE_HELP);
|
|
556
|
+
return 0;
|
|
557
|
+
}
|
|
558
|
+
const result = await ctx.client.reconcile();
|
|
559
|
+
if (ctx.json) {
|
|
560
|
+
await writeJson(result);
|
|
561
|
+
return 0;
|
|
562
|
+
}
|
|
563
|
+
await writeStdout(result.changed
|
|
564
|
+
? `reconciled ${result.workspace}: ${result.previous.bytes}→${result.bytes} bytes, ${result.previous.objects}→${result.objects} objects\n`
|
|
565
|
+
: `reconciled ${result.workspace}: unchanged (${result.bytes} bytes, ${result.objects} objects)\n`);
|
|
566
|
+
return 0;
|
|
567
|
+
}
|
|
568
|
+
const PURGE_HELP = `uploads purge-expired [--workspace <name>]
|
|
569
|
+
|
|
570
|
+
Delete objects older than the workspace retentionDays setting, then reconcile.
|
|
571
|
+
Skips if retention is unset. Requires files:delete.
|
|
572
|
+
|
|
573
|
+
Examples:
|
|
574
|
+
uploads --env-file .env purge-expired
|
|
575
|
+
`;
|
|
576
|
+
export async function runPurgeExpired(ctx, args, help = false) {
|
|
577
|
+
if (help || parseCommandArgs(args).help) {
|
|
578
|
+
process.stderr.write(PURGE_HELP);
|
|
579
|
+
return 0;
|
|
580
|
+
}
|
|
581
|
+
const result = await ctx.client.purgeExpired();
|
|
582
|
+
if (ctx.json) {
|
|
583
|
+
await writeJson(result);
|
|
584
|
+
return 0;
|
|
585
|
+
}
|
|
586
|
+
if ("skipped" in result) {
|
|
587
|
+
await writeStdout(`skipped: ${result.reason}\n`);
|
|
588
|
+
return 0;
|
|
589
|
+
}
|
|
590
|
+
await writeStdout(`purged ${result.deleted} object(s), freed ${result.freedBytes} bytes (retention ${result.retentionDays}d)\n`);
|
|
591
|
+
return 0;
|
|
592
|
+
}
|
|
361
593
|
// --- health & doctor ---
|
|
362
594
|
const HEALTH_HELP = `uploads health
|
|
363
595
|
|
|
@@ -391,23 +623,20 @@ Examples:
|
|
|
391
623
|
uploads --env-file .env doctor
|
|
392
624
|
uploads --workspace acme --env-file .env doctor
|
|
393
625
|
`;
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
return 0;
|
|
398
|
-
}
|
|
399
|
-
const mismatch = workspaceMismatch(ctx.config);
|
|
626
|
+
/** Doctor's health + auth + workspace checks, shared by the CLI and the MCP tool. */
|
|
627
|
+
export async function buildDoctorReport(config, client) {
|
|
628
|
+
const mismatch = workspaceMismatch(config);
|
|
400
629
|
const hints = [];
|
|
401
630
|
if (mismatch)
|
|
402
631
|
hints.push(mismatch);
|
|
403
|
-
if (
|
|
632
|
+
if (config.apiUrl.includes("localhost") || config.apiUrl.includes("127.0.0.1")) {
|
|
404
633
|
hints.push("local API uses dev KV — prod tokens won't work unless minted with --local");
|
|
405
634
|
}
|
|
406
|
-
const health = await
|
|
635
|
+
const health = await client.health();
|
|
407
636
|
let authOk = false;
|
|
408
637
|
let authError;
|
|
409
638
|
try {
|
|
410
|
-
await
|
|
639
|
+
await client.list({ limit: 1 });
|
|
411
640
|
authOk = true;
|
|
412
641
|
}
|
|
413
642
|
catch (err) {
|
|
@@ -416,35 +645,67 @@ export async function runDoctor(ctx, args, help = false) {
|
|
|
416
645
|
hints.push("if this token works on api.uploads.sh, set UPLOADS_API_URL=https://api.uploads.sh");
|
|
417
646
|
}
|
|
418
647
|
}
|
|
419
|
-
|
|
420
|
-
|
|
648
|
+
let usage;
|
|
649
|
+
if (authOk) {
|
|
650
|
+
try {
|
|
651
|
+
const snap = await client.usage();
|
|
652
|
+
usage = {
|
|
653
|
+
ok: true,
|
|
654
|
+
bytes: snap.bytes,
|
|
655
|
+
objects: snap.objects,
|
|
656
|
+
uploadsInPeriod: snap.uploadsInPeriod,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
catch (err) {
|
|
660
|
+
usage = {
|
|
661
|
+
ok: false,
|
|
662
|
+
error: err instanceof UploadsError ? err.message : String(err),
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (!config.configExists && !config.token) {
|
|
667
|
+
hints.push(`run uploads setup to configure ${config.configPath}`);
|
|
421
668
|
}
|
|
422
|
-
|
|
669
|
+
return {
|
|
423
670
|
ok: health.ok && authOk,
|
|
424
|
-
apiUrl:
|
|
425
|
-
workspace:
|
|
426
|
-
workspaceSource:
|
|
427
|
-
workspaceFromToken: workspaceFromToken(
|
|
428
|
-
configPath:
|
|
429
|
-
configExists:
|
|
671
|
+
apiUrl: config.apiUrl,
|
|
672
|
+
workspace: config.workspace,
|
|
673
|
+
workspaceSource: config.workspaceSource,
|
|
674
|
+
workspaceFromToken: workspaceFromToken(config.token),
|
|
675
|
+
configPath: config.configPath,
|
|
676
|
+
configExists: config.configExists,
|
|
430
677
|
health,
|
|
431
678
|
auth: { ok: authOk, error: authError },
|
|
679
|
+
usage,
|
|
680
|
+
warning: mismatch,
|
|
432
681
|
hints,
|
|
433
682
|
};
|
|
683
|
+
}
|
|
684
|
+
export async function runDoctor(ctx, args, help = false) {
|
|
685
|
+
if (help || parseCommandArgs(args).help) {
|
|
686
|
+
process.stderr.write(DOCTOR_HELP);
|
|
687
|
+
return 0;
|
|
688
|
+
}
|
|
689
|
+
const report = await buildDoctorReport(ctx.config, ctx.client);
|
|
434
690
|
if (ctx.json) {
|
|
435
691
|
await writeJson(report);
|
|
436
692
|
return report.ok ? 0 : 1;
|
|
437
693
|
}
|
|
438
694
|
const lines = [
|
|
439
|
-
`config: ${
|
|
440
|
-
`api: ${
|
|
441
|
-
`workspace: ${
|
|
442
|
-
`auth: ${
|
|
695
|
+
`config: ${report.configPath}${report.configExists ? "" : " (missing)"}`,
|
|
696
|
+
`api: ${report.apiUrl} (${report.health.ok ? "ok" : "failed"})`,
|
|
697
|
+
`workspace: ${report.workspace}`,
|
|
698
|
+
`auth: ${report.auth.ok ? "ok" : `failed — ${report.auth.error ?? "no token"}`}`,
|
|
443
699
|
];
|
|
444
|
-
if (
|
|
445
|
-
lines.push(
|
|
446
|
-
|
|
447
|
-
|
|
700
|
+
if (report.usage) {
|
|
701
|
+
lines.push(report.usage.ok
|
|
702
|
+
? `usage: ${report.usage.bytes} bytes, ${report.usage.objects} objects, ${report.usage.uploadsInPeriod} uploads this period`
|
|
703
|
+
: `usage: failed — ${report.usage.error ?? "unknown"}`);
|
|
704
|
+
}
|
|
705
|
+
if (report.warning)
|
|
706
|
+
lines.push(`warning: ${report.warning}`);
|
|
707
|
+
for (const h of report.hints)
|
|
708
|
+
if (h !== report.warning)
|
|
448
709
|
lines.push(`hint: ${h}`);
|
|
449
710
|
await writeStdout(lines.join("\n") + "\n");
|
|
450
711
|
return report.ok ? 0 : 1;
|
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;
|