@buildinternet/uploads 0.10.1 → 0.11.1
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 +18 -4
- package/dist/async.d.ts +5 -0
- package/dist/async.js +21 -0
- package/dist/cli-catalog.js +18 -4
- package/dist/cli-help.js +1 -1
- package/dist/cli.js +59 -7
- package/dist/commands/install.js +33 -15
- package/dist/commands/mcp.js +2 -0
- package/dist/commands/report.d.ts +4 -0
- package/dist/commands/report.js +164 -0
- package/dist/commands/telemetry.d.ts +4 -0
- package/dist/commands/telemetry.js +91 -0
- package/dist/commands.d.ts +92 -1
- package/dist/commands.js +364 -150
- package/dist/format-usage.d.ts +38 -0
- package/dist/format-usage.js +125 -0
- package/dist/mcp/batch-error.d.ts +16 -0
- package/dist/mcp/batch-error.js +24 -0
- package/dist/mcp/server.d.ts +3 -0
- package/dist/mcp/server.js +33 -1
- package/dist/mcp/tools.d.ts +1 -1
- package/dist/mcp/tools.js +254 -94
- package/dist/report.d.ts +68 -0
- package/dist/report.js +152 -0
- package/dist/telemetry.d.ts +60 -0
- package/dist/telemetry.js +290 -0
- package/package.json +1 -1
package/dist/commands.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { basename } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { mapBounded } from "./async.js";
|
|
4
|
+
import { createUploadsClient, } from "./client.js";
|
|
4
5
|
import { parseCommandArgs, flagString, flagBool, flagInt, flagValues, UsageError, } from "./cli-args.js";
|
|
5
6
|
import { resolvePutDefaults, workspaceMismatch, workspaceFromToken, } from "./config.js";
|
|
6
7
|
import { buildMarkdown } from "./embed.js";
|
|
@@ -15,8 +16,14 @@ import { optimizeImageForUpload, rewriteKeyExtension, } from "./optimize.js";
|
|
|
15
16
|
import { applyFrame, resolveFrameId } from "./frame.js";
|
|
16
17
|
import { buildCliProvenance } from "./provenance.js";
|
|
17
18
|
import { formatByteSize } from "./format-bytes.js";
|
|
19
|
+
import { formatUsageHuman } from "./format-usage.js";
|
|
18
20
|
import { packageVersion } from "./package-version.js";
|
|
19
|
-
import { writeCommandHelp } from "./cli-style.js";
|
|
21
|
+
import { colorEnabled, writeCommandHelp } from "./cli-style.js";
|
|
22
|
+
/** Parallel fan-out for multi-file put/attach (matches files-sdk bulk default). */
|
|
23
|
+
export const UPLOAD_BATCH_CONCURRENCY = 8;
|
|
24
|
+
/** @deprecated Use UPLOAD_BATCH_CONCURRENCY. */
|
|
25
|
+
export const ATTACH_CONCURRENCY = UPLOAD_BATCH_CONCURRENCY;
|
|
26
|
+
export { formatUsageHuman } from "./format-usage.js";
|
|
20
27
|
/** Read a local file (or `-` for stdin). Missing path → FILE_NOT_FOUND (exit 2). */
|
|
21
28
|
export function readFileArg(fileArg) {
|
|
22
29
|
try {
|
|
@@ -30,9 +37,13 @@ export function readFileArg(fileArg) {
|
|
|
30
37
|
}
|
|
31
38
|
}
|
|
32
39
|
// --- put ---
|
|
33
|
-
const PUT_HELP = `uploads put <file
|
|
40
|
+
const PUT_HELP = `uploads put <file...> [options]
|
|
34
41
|
|
|
35
|
-
Upload
|
|
42
|
+
Upload one or more images for GitHub embeds. Use "-" for stdin (single file only).
|
|
43
|
+
|
|
44
|
+
Multiple files upload in parallel (bounded concurrency). One bad file does not
|
|
45
|
+
block the rest; multi-file JSON is { uploads, failures } (exit 1 when any failed).
|
|
46
|
+
Single-file JSON stays a flat object (back-compat).
|
|
36
47
|
|
|
37
48
|
Still images (PNG/JPEG/…) are optimized to WebP by default (long edge capped,
|
|
38
49
|
high quality; EXIF stripped) so GitHub embeds stay lean. Original bytes are kept
|
|
@@ -55,13 +66,13 @@ Human/json output includes durable url and (when dual-host applies) embedUrl.
|
|
|
55
66
|
MARKDOWN prefers embedUrl for GitHub. Override: UPLOADS_EMBED_PUBLIC_BASE_URL.
|
|
56
67
|
|
|
57
68
|
Options:
|
|
58
|
-
--key <key> Object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.<ext>)
|
|
59
|
-
--name <leaf> Clean key leaf + default alt (no '/'); keeps --pr/default path. Not with --key
|
|
69
|
+
--key <key> Object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.<ext>). Single file only
|
|
70
|
+
--name <leaf> Clean key leaf + default alt (no '/'); keeps --pr/default path. Single file only. Not with --key
|
|
60
71
|
--destination <id> Typed root: screenshots | gh | f (sets --prefix)
|
|
61
72
|
--prefix <path> Key prefix (default: screenshots, or UPLOADS_DEFAULT_PREFIX)
|
|
62
73
|
--repo <owner/repo> Repo segment (default: git remote, or UPLOADS_DEFAULT_REPO)
|
|
63
74
|
--ref <id> PR/issue/branch segment (default: today, or UPLOADS_DEFAULT_REF)
|
|
64
|
-
--alt <text> Alt text (default:
|
|
75
|
+
--alt <text> Alt text (default: each file's name; with multiple files applies to all)
|
|
65
76
|
--width <px> <img width=…> markdown (or UPLOADS_DEFAULT_WIDTH)
|
|
66
77
|
--content-type <mime> Override Content-Type (ignored when optimize rewrites the body)
|
|
67
78
|
--frame <id> Device/browser frame before optimize (phone|browser|iphone-16-pro)
|
|
@@ -79,18 +90,19 @@ Options:
|
|
|
79
90
|
--pr <num> Attach to a pull request: key gh/<owner>/<repo>/pull/<num>/<name> (stable URL, no hash)
|
|
80
91
|
--issue <num> Attach to an issue: key gh/<owner>/<repo>/issues/<num>/<name>
|
|
81
92
|
--comment With --pr/--issue: update one managed comment with attachments and linked galleries via local gh auth
|
|
82
|
-
--gallery <id> Add the uploaded object to this public gallery
|
|
93
|
+
--gallery <id> Add the uploaded object(s) to this public gallery
|
|
83
94
|
--meta <k=v> Queryable custom metadata (repeatable; value may contain "="): key ^[a-z][a-z0-9._-]{0,63}$, value 1-512 printable ASCII, max 24 pairs
|
|
84
95
|
Re-uploading to an existing key WITH --meta replaces that file's
|
|
85
96
|
entire metadata set; without --meta the existing metadata is
|
|
86
97
|
preserved. Use "uploads meta set" to edit individual keys.
|
|
87
98
|
--dry-run Print key + public URL without uploading; reports if the key would replace an existing object. Not with --comment/--gallery
|
|
88
99
|
|
|
89
|
-
Exit codes: 0 ok · 2 usage/token/file · 3 auth/policy · 4 network · 1 other.
|
|
100
|
+
Exit codes: 0 ok · 2 usage/token/file · 3 auth/policy · 4 network · 1 other (incl. partial multi-file failure).
|
|
90
101
|
Scripted formats (json|url|markdown) also print failures on stdout.
|
|
91
102
|
|
|
92
103
|
Examples:
|
|
93
104
|
uploads put ./shot.png --repo myorg/myapp --ref 1722 --alt "New cards" --width 700
|
|
105
|
+
uploads put ./before.png ./after.png
|
|
94
106
|
uploads put ./mobile.png --frame phone
|
|
95
107
|
uploads put ./ui.png --frame browser --frame-url "https://app.example/settings"
|
|
96
108
|
uploads put ./shot.png --destination screenshots
|
|
@@ -269,6 +281,9 @@ const ATTACH_HELP = `uploads attach <file...> [options]
|
|
|
269
281
|
Upload one or more stable PR/issue attachments and maintain a single GitHub
|
|
270
282
|
comment. With no target, uses the pull request for the current branch.
|
|
271
283
|
|
|
284
|
+
Multiple files upload in parallel (bounded concurrency). One bad file does not
|
|
285
|
+
block the rest; JSON includes uploads + failures (exit 1 when any failed).
|
|
286
|
+
|
|
272
287
|
Attachments are public and their repo/number/filename keys are predictable.
|
|
273
288
|
Private/internal GitHub repository visibility does not restrict access; upload
|
|
274
289
|
only media that is safe at a public URL.
|
|
@@ -308,6 +323,163 @@ Examples:
|
|
|
308
323
|
uploads attach ./artifact.zip --issue 45 --no-comment
|
|
309
324
|
uploads attach ./shot.png --meta app=myapp --meta page=settings
|
|
310
325
|
`;
|
|
326
|
+
/**
|
|
327
|
+
* Prepare + put each path as a PR/issue attachment with bounded concurrency.
|
|
328
|
+
* Per-file errors collect in `failures` (does not throw). `firstError` is the
|
|
329
|
+
* original cause of the first failure — for rethrowing single-file CLI paths.
|
|
330
|
+
*/
|
|
331
|
+
export async function uploadAttachments(opts) {
|
|
332
|
+
if (opts.files.some((f) => f === "-")) {
|
|
333
|
+
throw new UsageError("attach does not support stdin; pass one or more file paths");
|
|
334
|
+
}
|
|
335
|
+
const slots = await mapBounded(opts.files, opts.concurrency ?? UPLOAD_BATCH_CONCURRENCY, async (file) => {
|
|
336
|
+
try {
|
|
337
|
+
const sourceName = basename(file);
|
|
338
|
+
const prepared = await prepareImageForUpload(readFileArg(file), sourceName, {
|
|
339
|
+
...opts.frame,
|
|
340
|
+
optimize: opts.optimize,
|
|
341
|
+
});
|
|
342
|
+
const result = await opts.client.put(prepared.bytes, {
|
|
343
|
+
filename: prepared.filename,
|
|
344
|
+
key: ghAttachmentKey(opts.target, prepared.filename),
|
|
345
|
+
contentType: prepared.optimized ? prepared.contentType : opts.contentType,
|
|
346
|
+
provenance: buildCliProvenance({
|
|
347
|
+
sourceName,
|
|
348
|
+
client: opts.provenanceClient,
|
|
349
|
+
optimized: prepared.optimized,
|
|
350
|
+
frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
|
|
351
|
+
keepExif: opts.optimize.keepExif === true,
|
|
352
|
+
}),
|
|
353
|
+
metadata: opts.metadata,
|
|
354
|
+
});
|
|
355
|
+
return {
|
|
356
|
+
ok: true,
|
|
357
|
+
upload: {
|
|
358
|
+
...result,
|
|
359
|
+
file,
|
|
360
|
+
markdown: buildMarkdown(urlForGithubEmbed(result.url, result.embedUrl), {
|
|
361
|
+
alt: sourceName,
|
|
362
|
+
}),
|
|
363
|
+
optimize: {
|
|
364
|
+
optimized: prepared.optimized,
|
|
365
|
+
skippedReason: prepared.skippedReason,
|
|
366
|
+
originalBytes: prepared.originalBytes,
|
|
367
|
+
outputBytes: prepared.outputBytes,
|
|
368
|
+
filename: prepared.filename,
|
|
369
|
+
},
|
|
370
|
+
frame: prepared.frame,
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
catch (err) {
|
|
375
|
+
return { ok: false, file, err };
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
const uploads = [];
|
|
379
|
+
const failures = [];
|
|
380
|
+
let firstError;
|
|
381
|
+
for (const slot of slots) {
|
|
382
|
+
if (slot.ok)
|
|
383
|
+
uploads.push(slot.upload);
|
|
384
|
+
else {
|
|
385
|
+
firstError ??= slot.err;
|
|
386
|
+
failures.push({ file: slot.file, error: errorDetail(slot.err) });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return { uploads, failures, firstError };
|
|
390
|
+
}
|
|
391
|
+
function errorDetail(err) {
|
|
392
|
+
if (err instanceof UploadsError)
|
|
393
|
+
return { message: err.message, code: err.code, status: err.status };
|
|
394
|
+
return { message: err instanceof Error ? err.message : String(err) };
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Prepare + put each path with put-style key resolution and bounded concurrency.
|
|
398
|
+
* Same partial-failure shape as uploadAttachments.
|
|
399
|
+
*/
|
|
400
|
+
export async function uploadPuts(opts) {
|
|
401
|
+
if (opts.files.length > 1 && opts.files.some((f) => f === "-")) {
|
|
402
|
+
throw new UsageError("stdin (-) cannot be combined with multiple file arguments");
|
|
403
|
+
}
|
|
404
|
+
if (opts.files.length > 1 && opts.explicitKey) {
|
|
405
|
+
throw new UsageError("--key cannot be combined with multiple files");
|
|
406
|
+
}
|
|
407
|
+
if (opts.files.length > 1 && opts.nameOverride) {
|
|
408
|
+
throw new UsageError("--name cannot be combined with multiple files");
|
|
409
|
+
}
|
|
410
|
+
const slots = await mapBounded(opts.files, opts.concurrency ?? UPLOAD_BATCH_CONCURRENCY, async (file) => {
|
|
411
|
+
try {
|
|
412
|
+
const sourceName = opts.nameOverride ??
|
|
413
|
+
(file === "-"
|
|
414
|
+
? opts.explicitKey
|
|
415
|
+
? basename(opts.explicitKey)
|
|
416
|
+
: "stdin.bin"
|
|
417
|
+
: basename(file));
|
|
418
|
+
const prepared = await prepareImageForUpload(readFileArg(file), sourceName, {
|
|
419
|
+
...opts.frame,
|
|
420
|
+
optimize: opts.optimize,
|
|
421
|
+
});
|
|
422
|
+
let key = opts.ghTarget
|
|
423
|
+
? ghAttachmentKey(opts.ghTarget, prepared.filename)
|
|
424
|
+
: opts.explicitKey;
|
|
425
|
+
if (key && prepared.optimized)
|
|
426
|
+
key = rewriteKeyExtension(key, prepared.filename);
|
|
427
|
+
const result = await opts.client.put(prepared.bytes, {
|
|
428
|
+
filename: prepared.filename,
|
|
429
|
+
key,
|
|
430
|
+
prefix: opts.prefix,
|
|
431
|
+
repo: opts.repo,
|
|
432
|
+
ref: opts.ref,
|
|
433
|
+
contentType: prepared.optimized ? prepared.contentType : opts.contentType,
|
|
434
|
+
deriveRepoFromGit: opts.deriveRepoFromGit,
|
|
435
|
+
dryRun: opts.dryRun,
|
|
436
|
+
provenance: buildCliProvenance({
|
|
437
|
+
sourceName,
|
|
438
|
+
client: opts.provenanceClient,
|
|
439
|
+
optimized: prepared.optimized,
|
|
440
|
+
frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
|
|
441
|
+
keepExif: opts.optimize.keepExif === true,
|
|
442
|
+
}),
|
|
443
|
+
metadata: opts.metadata,
|
|
444
|
+
});
|
|
445
|
+
const alt = opts.alt ?? basename(sourceName);
|
|
446
|
+
return {
|
|
447
|
+
ok: true,
|
|
448
|
+
upload: {
|
|
449
|
+
...result,
|
|
450
|
+
file,
|
|
451
|
+
markdown: buildMarkdown(urlForGithubEmbed(result.url, result.embedUrl), {
|
|
452
|
+
alt,
|
|
453
|
+
width: opts.width,
|
|
454
|
+
}),
|
|
455
|
+
optimize: {
|
|
456
|
+
optimized: prepared.optimized,
|
|
457
|
+
skippedReason: prepared.skippedReason,
|
|
458
|
+
originalBytes: prepared.originalBytes,
|
|
459
|
+
outputBytes: prepared.outputBytes,
|
|
460
|
+
filename: prepared.filename,
|
|
461
|
+
},
|
|
462
|
+
frame: prepared.frame,
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
return { ok: false, file, err };
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
const uploads = [];
|
|
471
|
+
const failures = [];
|
|
472
|
+
let firstError;
|
|
473
|
+
for (const slot of slots) {
|
|
474
|
+
if (slot.ok)
|
|
475
|
+
uploads.push(slot.upload);
|
|
476
|
+
else {
|
|
477
|
+
firstError ??= slot.err;
|
|
478
|
+
failures.push({ file: slot.file, error: errorDetail(slot.err) });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return { uploads, failures, firstError };
|
|
482
|
+
}
|
|
311
483
|
export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
312
484
|
const parsed = parseCommandArgs(args);
|
|
313
485
|
if (help || parsed.help) {
|
|
@@ -337,53 +509,28 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
337
509
|
const metadata = { ...metaExtras, ...ghMetadataFromTarget(target) };
|
|
338
510
|
if (Object.keys(metadata).length > 0)
|
|
339
511
|
validateMetaMap(metadata);
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
const result = await ctx.client.put(prepared.bytes, {
|
|
358
|
-
filename: prepared.filename,
|
|
359
|
-
key: ghAttachmentKey(target, prepared.filename),
|
|
360
|
-
contentType: prepared.optimized ? prepared.contentType : contentTypeOverride,
|
|
361
|
-
provenance: buildCliProvenance({
|
|
362
|
-
sourceName,
|
|
363
|
-
optimized: prepared.optimized,
|
|
364
|
-
frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
|
|
365
|
-
keepExif: optimizeOpts.keepExif === true,
|
|
366
|
-
}),
|
|
367
|
-
metadata,
|
|
368
|
-
});
|
|
369
|
-
writeReplacedNote(result.replaced, ctx.quiet || ctx.json);
|
|
370
|
-
const embedSrc = urlForGithubEmbed(result.url, result.embedUrl);
|
|
371
|
-
results.push({
|
|
372
|
-
...result,
|
|
373
|
-
markdown: buildMarkdown(embedSrc, { alt: sourceName }),
|
|
374
|
-
optimize: {
|
|
375
|
-
optimized: prepared.optimized,
|
|
376
|
-
skippedReason: prepared.skippedReason,
|
|
377
|
-
originalBytes: prepared.originalBytes,
|
|
378
|
-
outputBytes: prepared.outputBytes,
|
|
379
|
-
filename: prepared.filename,
|
|
380
|
-
},
|
|
381
|
-
frame: prepared.frame,
|
|
382
|
-
});
|
|
512
|
+
const logHuman = !ctx.quiet && !ctx.json;
|
|
513
|
+
if (logHuman) {
|
|
514
|
+
const n = parsed.positionals.length;
|
|
515
|
+
process.stderr.write(`>> uploading ${n} file${n === 1 ? "" : "s"}\n`);
|
|
516
|
+
}
|
|
517
|
+
const { uploads, failures, firstError } = await uploadAttachments({
|
|
518
|
+
client: ctx.client,
|
|
519
|
+
target,
|
|
520
|
+
files: parsed.positionals,
|
|
521
|
+
contentType: contentTypeOverride,
|
|
522
|
+
optimize: optimizeOpts,
|
|
523
|
+
frame: frameOpts,
|
|
524
|
+
metadata,
|
|
525
|
+
});
|
|
526
|
+
// Single-file total failure: rethrow so CLI exit codes stay auth/network-aware.
|
|
527
|
+
if (uploads.length === 0 && failures.length === 1 && parsed.positionals.length === 1) {
|
|
528
|
+
throw firstError instanceof Error ? firstError : new Error(String(firstError));
|
|
383
529
|
}
|
|
384
530
|
let comment;
|
|
385
531
|
let commentError;
|
|
386
|
-
|
|
532
|
+
// Skip comment refresh when every upload failed — nothing new from this batch.
|
|
533
|
+
if (!parsed.flags.has("--no-comment") && uploads.length > 0) {
|
|
387
534
|
try {
|
|
388
535
|
comment = await syncAttachmentsComment(ctx.client, target, run);
|
|
389
536
|
}
|
|
@@ -393,22 +540,33 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
393
540
|
}
|
|
394
541
|
}
|
|
395
542
|
if (ctx.json) {
|
|
396
|
-
await writeJson({ target, uploads
|
|
543
|
+
await writeJson({ target, uploads, failures, comment, commentError });
|
|
397
544
|
}
|
|
398
545
|
else {
|
|
399
|
-
for (const result of
|
|
546
|
+
for (const result of uploads) {
|
|
547
|
+
if (logHuman) {
|
|
548
|
+
if (result.frame?.framed) {
|
|
549
|
+
process.stderr.write(`>> ${basename(result.file)}: framed with ${result.frame.frameId}\n`);
|
|
550
|
+
}
|
|
551
|
+
const note = formatOptimizeNote(result.optimize);
|
|
552
|
+
if (note)
|
|
553
|
+
process.stderr.write(`>> ${basename(result.file)}: ${note}\n`);
|
|
554
|
+
writeReplacedNote(result.replaced, false);
|
|
555
|
+
}
|
|
400
556
|
const embedLine = result.embedUrl ? `EMBED: ${result.embedUrl}\n` : "";
|
|
401
557
|
await writeStdout(`URL: ${result.url}\n${embedLine}MARKDOWN: ${result.markdown}\n`);
|
|
402
558
|
}
|
|
559
|
+
for (const failure of failures) {
|
|
560
|
+
process.stderr.write(`warning: could not upload ${failure.file}: ${failure.error.message}\n`);
|
|
561
|
+
}
|
|
403
562
|
if (!ctx.quiet && comment)
|
|
404
563
|
process.stderr.write(`>> attachments comment ${comment.action}\n`);
|
|
405
|
-
|
|
406
|
-
if (!ctx.quiet) {
|
|
564
|
+
if (!ctx.quiet && uploads.length > 0) {
|
|
407
565
|
const ref = ghMetadataFromTarget(target)["gh.ref"];
|
|
408
566
|
process.stderr.write(`>> find these later: uploads find gh.ref=${ref}\n`);
|
|
409
567
|
}
|
|
410
568
|
}
|
|
411
|
-
return 0;
|
|
569
|
+
return failures.length === 0 ? 0 : 1;
|
|
412
570
|
}
|
|
413
571
|
export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
414
572
|
if (help) {
|
|
@@ -420,11 +578,12 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
420
578
|
writeCommandHelp(PUT_HELP);
|
|
421
579
|
return 0;
|
|
422
580
|
}
|
|
423
|
-
const
|
|
424
|
-
if (
|
|
581
|
+
const files = parsed.positionals;
|
|
582
|
+
if (files.length === 0) {
|
|
425
583
|
writeCommandHelp(PUT_HELP);
|
|
426
584
|
return 2;
|
|
427
585
|
}
|
|
586
|
+
const multi = files.length > 1;
|
|
428
587
|
const keyHint = flagString(parsed.flags, "--key");
|
|
429
588
|
const destFlag = flagString(parsed.flags, "--destination");
|
|
430
589
|
const prefixFlag = flagString(parsed.flags, "--prefix");
|
|
@@ -449,6 +608,15 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
449
608
|
}
|
|
450
609
|
if (wantComment && !ghTarget)
|
|
451
610
|
throw new UsageError("--comment requires --pr or --issue");
|
|
611
|
+
if (multi) {
|
|
612
|
+
if (keyHint)
|
|
613
|
+
throw new UsageError("--key cannot be combined with multiple files");
|
|
614
|
+
if (nameFlag !== undefined)
|
|
615
|
+
throw new UsageError("--name cannot be combined with multiple files");
|
|
616
|
+
if (files.some((f) => f === "-")) {
|
|
617
|
+
throw new UsageError("stdin (-) cannot be combined with multiple file arguments");
|
|
618
|
+
}
|
|
619
|
+
}
|
|
452
620
|
if (ghTarget) {
|
|
453
621
|
if (keyHint) {
|
|
454
622
|
throw new UsageError("--key cannot be combined with --pr/--issue; use --name <leaf> to set a clean filename on the stable path");
|
|
@@ -484,8 +652,6 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
484
652
|
catch (err) {
|
|
485
653
|
throw new UsageError(err instanceof Error ? err.message : String(err));
|
|
486
654
|
}
|
|
487
|
-
const bytes = readFileArg(fileArg);
|
|
488
|
-
const sourceName = nameFlag ?? (fileArg === "-" ? (keyHint ? basename(keyHint) : "stdin.bin") : basename(fileArg));
|
|
489
655
|
const format = ctx.json
|
|
490
656
|
? "json"
|
|
491
657
|
: (() => {
|
|
@@ -499,14 +665,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
499
665
|
const defaults = resolvePutDefaults({ envFile: ctx.envFile });
|
|
500
666
|
const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, defaults);
|
|
501
667
|
const frameOpts = frameOptionsFromFlags(parsed.flags);
|
|
502
|
-
// Optimize even on --dry-run so the preview key extension/hash match a real put.
|
|
503
|
-
const prepared = await prepareImageForUpload(bytes, sourceName, {
|
|
504
|
-
...frameOpts,
|
|
505
|
-
optimize: optimizeOpts,
|
|
506
|
-
});
|
|
507
|
-
const filename = prepared.filename;
|
|
508
668
|
const contentTypeOverride = flagString(parsed.flags, "--content-type");
|
|
509
|
-
const
|
|
669
|
+
const altFlag = flagString(parsed.flags, "--alt");
|
|
510
670
|
const widthRaw = flagString(parsed.flags, "--width");
|
|
511
671
|
const width = widthRaw && /^\d+$/.test(widthRaw) && Number(widthRaw) > 0
|
|
512
672
|
? Number.parseInt(widthRaw, 10)
|
|
@@ -515,14 +675,6 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
515
675
|
throw new UsageError(`invalid --width: ${widthRaw}`);
|
|
516
676
|
})()
|
|
517
677
|
: defaults.width;
|
|
518
|
-
if (!ctx.quiet && format === "human") {
|
|
519
|
-
process.stderr.write(`>> ${dryRun ? "dry run" : "uploading"} ${fileArg === "-" ? "stdin" : fileArg}\n`);
|
|
520
|
-
if (prepared.frame?.framed)
|
|
521
|
-
process.stderr.write(`>> framed with ${prepared.frame.frameId}\n`);
|
|
522
|
-
const note = formatOptimizeNote(prepared);
|
|
523
|
-
if (note)
|
|
524
|
-
process.stderr.write(`>> ${note}\n`);
|
|
525
|
-
}
|
|
526
678
|
const noGit = flagBool(parsed.flags, "--no-git") || defaults.noGit === true;
|
|
527
679
|
// gh.* metadata: explicit --pr/--issue target wins over --meta; otherwise
|
|
528
680
|
// best-effort auto resolution (on by default) where --meta wins. --no-git,
|
|
@@ -558,66 +710,144 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
558
710
|
}
|
|
559
711
|
}
|
|
560
712
|
}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
713
|
+
const logHuman = !ctx.quiet && format === "human";
|
|
714
|
+
if (logHuman) {
|
|
715
|
+
if (multi) {
|
|
716
|
+
process.stderr.write(`>> ${dryRun ? "dry run for" : "uploading"} ${files.length} files\n`);
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
const fileArg = files[0];
|
|
720
|
+
process.stderr.write(`>> ${dryRun ? "dry run" : "uploading"} ${fileArg === "-" ? "stdin" : fileArg}\n`);
|
|
721
|
+
}
|
|
722
|
+
if (attachedRef)
|
|
723
|
+
process.stderr.write(`>> attached to ${attachedRef}\n`);
|
|
724
|
+
}
|
|
725
|
+
const { uploads, failures, firstError } = await uploadPuts({
|
|
726
|
+
client: ctx.client,
|
|
727
|
+
files,
|
|
728
|
+
nameOverride: nameFlag,
|
|
729
|
+
explicitKey: keyHint,
|
|
730
|
+
ghTarget,
|
|
570
731
|
prefix: resolvedPrefix ?? defaults.prefix,
|
|
571
732
|
repo: flagString(parsed.flags, "--repo") ?? defaults.repo,
|
|
572
733
|
ref: flagString(parsed.flags, "--ref") ?? defaults.ref,
|
|
573
|
-
contentType: prepared.optimized ? prepared.contentType : contentTypeOverride,
|
|
574
734
|
deriveRepoFromGit: !noGit,
|
|
735
|
+
contentType: contentTypeOverride,
|
|
575
736
|
dryRun,
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
optimized: prepared.optimized,
|
|
579
|
-
frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
|
|
580
|
-
keepExif: optimizeOpts.keepExif === true,
|
|
581
|
-
}),
|
|
737
|
+
optimize: optimizeOpts,
|
|
738
|
+
frame: frameOpts,
|
|
582
739
|
metadata,
|
|
740
|
+
alt: altFlag,
|
|
741
|
+
width,
|
|
583
742
|
});
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
743
|
+
// Single-file total failure: rethrow so CLI exit codes stay auth/network-aware.
|
|
744
|
+
if (uploads.length === 0 && failures.length > 0 && !multi) {
|
|
745
|
+
throw firstError instanceof Error ? firstError : new Error(String(firstError));
|
|
746
|
+
}
|
|
747
|
+
const galleriesByKey = new Map();
|
|
748
|
+
let galleryHadError = false;
|
|
749
|
+
if (galleryId && uploads.length > 0) {
|
|
750
|
+
for (const upload of uploads) {
|
|
751
|
+
const alt = altFlag ?? basename(upload.file === "-" ? upload.optimize.filename : upload.file);
|
|
752
|
+
try {
|
|
753
|
+
// Gallery mutations use optimistic versions. Re-fetch before each add.
|
|
754
|
+
const current = await ctx.client.getGallery(galleryId);
|
|
755
|
+
const item = await ctx.client.addGalleryItem(galleryId, upload.key, {
|
|
756
|
+
expectedVersion: current.version,
|
|
757
|
+
altText: alt,
|
|
758
|
+
});
|
|
759
|
+
galleriesByKey.set(upload.key, { id: galleryId, url: current.url, item });
|
|
760
|
+
}
|
|
761
|
+
catch (err) {
|
|
762
|
+
galleryHadError = true;
|
|
763
|
+
galleriesByKey.set(upload.key, { id: galleryId, error: errorDetail(err) });
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
let comment;
|
|
768
|
+
let commentError;
|
|
769
|
+
if (wantComment && ghTarget && uploads.length > 0) {
|
|
590
770
|
try {
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
const item = await ctx.client.addGalleryItem(galleryId, result.key, {
|
|
595
|
-
expectedVersion: current.version,
|
|
596
|
-
altText: alt,
|
|
597
|
-
});
|
|
598
|
-
gallery = { id: galleryId, url: current.url, item };
|
|
771
|
+
comment = await syncAttachmentsComment(ctx.client, ghTarget, run);
|
|
772
|
+
if (logHuman)
|
|
773
|
+
process.stderr.write(`>> attachments comment ${comment.action}\n`);
|
|
599
774
|
}
|
|
600
775
|
catch (err) {
|
|
601
|
-
|
|
776
|
+
commentError = err instanceof Error ? err.message : String(err);
|
|
777
|
+
process.stderr.write(`warning: upload succeeded but the GitHub comment failed (is gh installed and authenticated?): ${commentError}\n`);
|
|
602
778
|
}
|
|
603
779
|
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
780
|
+
// --- multi-file output ---
|
|
781
|
+
if (multi) {
|
|
782
|
+
if (format === "json") {
|
|
783
|
+
await writeJson({
|
|
784
|
+
uploads: uploads.map((u) => ({
|
|
785
|
+
...u,
|
|
786
|
+
gallery: galleriesByKey.get(u.key),
|
|
787
|
+
...(dryRun ? { dryRun: true } : {}),
|
|
788
|
+
})),
|
|
789
|
+
failures,
|
|
790
|
+
comment,
|
|
791
|
+
commentError,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
else {
|
|
795
|
+
for (const result of uploads) {
|
|
796
|
+
if (logHuman) {
|
|
797
|
+
if (result.frame?.framed) {
|
|
798
|
+
process.stderr.write(`>> ${basename(result.file)}: framed with ${result.frame.frameId}\n`);
|
|
799
|
+
}
|
|
800
|
+
const note = formatOptimizeNote(result.optimize);
|
|
801
|
+
if (note)
|
|
802
|
+
process.stderr.write(`>> ${basename(result.file)}: ${note}\n`);
|
|
803
|
+
writeReplacedNote(result.replaced, false, dryRun);
|
|
804
|
+
process.stderr.write(`>> key: ${result.key}${dryRun ? " (dry run — not uploaded)" : ""}\n`);
|
|
805
|
+
}
|
|
806
|
+
const gallery = galleriesByKey.get(result.key);
|
|
807
|
+
if (format === "url")
|
|
808
|
+
await writeStdout(`${result.url}\n`);
|
|
809
|
+
else if (format === "markdown")
|
|
810
|
+
await writeStdout(`${result.markdown}\n`);
|
|
811
|
+
else {
|
|
812
|
+
const embedLine = result.embedUrl ? `EMBED: ${result.embedUrl}\n` : "";
|
|
813
|
+
await writeStdout(`URL: ${result.url}\n${embedLine}MARKDOWN: ${result.markdown}${gallery?.url ? `\nGALLERY: ${gallery.url}` : ""}\n`);
|
|
814
|
+
}
|
|
815
|
+
if (gallery?.error) {
|
|
816
|
+
process.stderr.write(`warning: upload succeeded but adding ${result.key} to gallery ${gallery.id} failed: ${gallery.error.message}\n`);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
for (const failure of failures) {
|
|
820
|
+
process.stderr.write(`warning: could not upload ${failure.file}: ${failure.error.message}\n`);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
return failures.length === 0 && !galleryHadError ? 0 : 1;
|
|
824
|
+
}
|
|
825
|
+
// --- single-file output (flat JSON shape, back-compat) ---
|
|
826
|
+
const result = uploads[0];
|
|
827
|
+
const gallery = galleriesByKey.get(result.key);
|
|
828
|
+
if (logHuman) {
|
|
829
|
+
if (result.frame?.framed) {
|
|
830
|
+
process.stderr.write(`>> framed with ${result.frame.frameId}\n`);
|
|
831
|
+
}
|
|
832
|
+
const note = formatOptimizeNote(result.optimize);
|
|
833
|
+
if (note)
|
|
834
|
+
process.stderr.write(`>> ${note}\n`);
|
|
835
|
+
writeReplacedNote(result.replaced, ctx.quiet, dryRun);
|
|
612
836
|
process.stderr.write(`>> key: ${result.key}${dryRun ? " (dry run — not uploaded)" : ""}\n\n`);
|
|
613
837
|
}
|
|
614
838
|
switch (format) {
|
|
615
839
|
case "json":
|
|
616
840
|
await writeJson({
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
841
|
+
workspace: result.workspace,
|
|
842
|
+
key: result.key,
|
|
843
|
+
url: result.url,
|
|
844
|
+
embedUrl: result.embedUrl,
|
|
845
|
+
size: result.size,
|
|
846
|
+
contentType: result.contentType,
|
|
847
|
+
replaced: result.replaced,
|
|
848
|
+
markdown: result.markdown,
|
|
849
|
+
optimize: result.optimize,
|
|
850
|
+
frame: result.frame,
|
|
621
851
|
gallery,
|
|
622
852
|
...(dryRun ? { dryRun: true } : {}),
|
|
623
853
|
});
|
|
@@ -626,11 +856,11 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
626
856
|
await writeStdout(`${result.url}\n`);
|
|
627
857
|
break;
|
|
628
858
|
case "markdown":
|
|
629
|
-
await writeStdout(`${markdown}\n`);
|
|
859
|
+
await writeStdout(`${result.markdown}\n`);
|
|
630
860
|
break;
|
|
631
861
|
default: {
|
|
632
862
|
const embedLine = result.embedUrl ? `EMBED: ${result.embedUrl}\n` : "";
|
|
633
|
-
await writeStdout(`URL: ${result.url}\n${embedLine}MARKDOWN: ${markdown}${gallery?.url ? `\nGALLERY: ${gallery.url}` : ""}\n`);
|
|
863
|
+
await writeStdout(`URL: ${result.url}\n${embedLine}MARKDOWN: ${result.markdown}${gallery?.url ? `\nGALLERY: ${gallery.url}` : ""}\n`);
|
|
634
864
|
}
|
|
635
865
|
}
|
|
636
866
|
if (gallery?.url && format !== "human") {
|
|
@@ -639,25 +869,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
639
869
|
if (gallery?.error) {
|
|
640
870
|
process.stderr.write(`warning: upload succeeded but adding it to gallery ${gallery.id} failed: ${gallery.error.message}\n`);
|
|
641
871
|
}
|
|
642
|
-
if (wantComment && ghTarget) {
|
|
643
|
-
try {
|
|
644
|
-
const sync = await syncAttachmentsComment(ctx.client, ghTarget, run);
|
|
645
|
-
if (!ctx.quiet && format === "human") {
|
|
646
|
-
process.stderr.write(`>> attachments comment ${sync.action}\n`);
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
catch (err) {
|
|
650
|
-
// Upload already succeeded; the comment is best-effort by design.
|
|
651
|
-
process.stderr.write(`warning: upload succeeded but the GitHub comment failed (is gh installed and authenticated?): ${err instanceof Error ? err.message : String(err)}\n`);
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
872
|
return gallery?.error ? 1 : 0;
|
|
655
873
|
}
|
|
656
|
-
function galleryError(err) {
|
|
657
|
-
if (err instanceof UploadsError)
|
|
658
|
-
return { message: err.message, code: err.code, status: err.status };
|
|
659
|
-
return { message: err instanceof Error ? err.message : String(err) };
|
|
660
|
-
}
|
|
661
874
|
// --- galleries ---
|
|
662
875
|
const GALLERY_HELP = `uploads gallery <command> [args]
|
|
663
876
|
|
|
@@ -848,7 +1061,7 @@ export async function runGallery(ctx, args, help = false) {
|
|
|
848
1061
|
}));
|
|
849
1062
|
}
|
|
850
1063
|
catch (err) {
|
|
851
|
-
failures.push({ objectKey, error:
|
|
1064
|
+
failures.push({ objectKey, error: errorDetail(err) });
|
|
852
1065
|
}
|
|
853
1066
|
}
|
|
854
1067
|
const output = { galleryId: id, galleryUrl: galleryUrl ?? null, added, failures };
|
|
@@ -1112,12 +1325,20 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
|
|
|
1112
1325
|
// --- usage / reconcile / purge ---
|
|
1113
1326
|
const USAGE_HELP = `uploads usage [--workspace <name>]
|
|
1114
1327
|
|
|
1115
|
-
Show workspace storage and monthly upload counters
|
|
1328
|
+
Show workspace storage and monthly upload counters.
|
|
1329
|
+
|
|
1330
|
+
When the API reports workspace quotas (typical on uploads.sh cloud /
|
|
1331
|
+
self-serve plans), human output includes progress bars toward those caps.
|
|
1332
|
+
Self-hosted or unlimited operator workspaces get usage totals only, plus a
|
|
1333
|
+
short unmetered note — no invented limits.
|
|
1116
1334
|
|
|
1117
1335
|
Examples:
|
|
1118
1336
|
uploads --env-file .env usage
|
|
1119
1337
|
uploads usage --json
|
|
1120
1338
|
`;
|
|
1339
|
+
function formatCount(n) {
|
|
1340
|
+
return Number.isFinite(n) ? Math.trunc(n).toLocaleString("en-US") : String(n);
|
|
1341
|
+
}
|
|
1121
1342
|
export async function runUsage(ctx, args, help = false) {
|
|
1122
1343
|
if (help || parseCommandArgs(args).help) {
|
|
1123
1344
|
writeCommandHelp(USAGE_HELP);
|
|
@@ -1128,14 +1349,7 @@ export async function runUsage(ctx, args, help = false) {
|
|
|
1128
1349
|
await writeJson(result);
|
|
1129
1350
|
return 0;
|
|
1130
1351
|
}
|
|
1131
|
-
|
|
1132
|
-
`workspace: ${result.workspace}`,
|
|
1133
|
-
`bytes: ${result.bytes}${result.maxStorageBytes != null ? ` / ${result.maxStorageBytes} (${result.storageRemainingBytes} remaining)` : ""}`,
|
|
1134
|
-
`objects: ${result.objects}`,
|
|
1135
|
-
`uploads: ${result.uploadsInPeriod} this period (${result.periodStart})${result.maxUploadsPerPeriod != null ? ` / ${result.maxUploadsPerPeriod} (${result.uploadsRemaining} remaining)` : ""}`,
|
|
1136
|
-
`updated: ${result.updatedAt}`,
|
|
1137
|
-
];
|
|
1138
|
-
await writeStdout(lines.join("\n") + "\n");
|
|
1352
|
+
await writeStdout(formatUsageHuman(result, { color: colorEnabled(process.stdout) }).join("\n") + "\n");
|
|
1139
1353
|
return 0;
|
|
1140
1354
|
}
|
|
1141
1355
|
const RECONCILE_HELP = `uploads reconcile [--workspace <name>]
|
|
@@ -1298,7 +1512,7 @@ export async function runDoctor(ctx, args, help = false) {
|
|
|
1298
1512
|
];
|
|
1299
1513
|
if (report.usage) {
|
|
1300
1514
|
lines.push(report.usage.ok
|
|
1301
|
-
? `usage: ${report.usage.bytes}
|
|
1515
|
+
? `usage: ${formatByteSize(report.usage.bytes ?? 0)}, ${formatCount(report.usage.objects ?? 0)} objects, ${formatCount(report.usage.uploadsInPeriod ?? 0)} uploads this period`
|
|
1302
1516
|
: `usage: failed — ${report.usage.error ?? "unknown"}`);
|
|
1303
1517
|
}
|
|
1304
1518
|
if (report.warning)
|