mirror of
https://github.com/Xevion/dotfiles.git
synced 2026-01-31 14:24:09 -06:00
Add prominent Question tool guidance to AGENTS.md and expand coverage in CLAUDE.md with chezmoi-specific examples. Refactor share.ts error handling to show last 10 lines of stderr instead of full output, remove hardcoded env var docs now that credentials are templated.
1392 lines
38 KiB
Cheetah
Executable File
1392 lines
38 KiB
Cheetah
Executable File
#!/usr/bin/env -S bun --install=fallback
|
|
|
|
/**
|
|
* share - Upload files to R2 and copy the URL to clipboard
|
|
*
|
|
* Usage:
|
|
* share # Upload clipboard content
|
|
* share file.png # Upload specific file
|
|
* cat file.txt | share # Upload from stdin
|
|
* share -c video.mov # Convert then upload
|
|
*/
|
|
|
|
import { existsSync, fstatSync } from "fs";
|
|
import { tmpdir, platform } from "os";
|
|
import { join, basename, extname } from "path";
|
|
import { parseArgs } from "util";
|
|
import chalk from "chalk";
|
|
import { S3Client } from "@aws-sdk/client-s3";
|
|
import { Upload } from "@aws-sdk/lib-storage";
|
|
import { nanoid } from "nanoid";
|
|
import { fileTypeFromBuffer } from "file-type";
|
|
import { $ } from "bun";
|
|
|
|
interface UploadSource {
|
|
buffer: Buffer;
|
|
filename?: string;
|
|
mimeType?: string;
|
|
}
|
|
|
|
interface UploadResult {
|
|
url: string;
|
|
key: string;
|
|
size: number;
|
|
}
|
|
|
|
type FixMode = "never" | "prompt" | "always";
|
|
|
|
interface MediaIssue {
|
|
id: string;
|
|
description: string;
|
|
severity: "error" | "warning";
|
|
autoFix: boolean; // true = instant/lossless, false = slow/lossy
|
|
fix: (buffer: Buffer) => Promise<Buffer>;
|
|
}
|
|
|
|
interface ProbeResult {
|
|
issues: MediaIssue[];
|
|
metadata: Record<string, unknown>;
|
|
}
|
|
|
|
interface VideoProbe {
|
|
duration: number | null;
|
|
codec: string | null;
|
|
hasFaststart: boolean;
|
|
}
|
|
|
|
type TimeoutHandle = ReturnType<typeof setTimeout>;
|
|
type IntervalHandle = ReturnType<typeof setInterval>;
|
|
|
|
const DOMAIN = "https://i.xevion.dev";
|
|
|
|
const ENV = {
|
|
endpoint: "{{ dopplerProjectJson.R2_ENDPOINT }}",
|
|
accessKeyId: "{{ dopplerProjectJson.R2_ACCESS_KEY_ID }}",
|
|
secretAccessKey: "{{ dopplerProjectJson.R2_SECRET_ACCESS_KEY }}",
|
|
bucket: "{{ dopplerProjectJson.R2_BUCKET }}",
|
|
};
|
|
|
|
const TIMING = {
|
|
spinnerFrame: 80,
|
|
requestTimeout: 30_000,
|
|
uploadTimeout: 60_000,
|
|
};
|
|
|
|
const SIZE_THRESHOLDS = {
|
|
image: 10 * 1024 * 1024,
|
|
video: 50 * 1024 * 1024,
|
|
binary: 100 * 1024 * 1024,
|
|
reencodeAuto: 10 * 1024 * 1024, // Auto re-encode incompatible codecs below this size
|
|
};
|
|
|
|
const INCOMPATIBLE_VIDEO_CODECS = ["hevc", "h265", "av1"];
|
|
|
|
const COLORS = {
|
|
spinner: ["#A5D8DD", "#9DCCB4", "#B8D99A", "#E8D4A2", "#F4B8A4", "#F5A6A6"],
|
|
success: "#9DCCB4",
|
|
error: "#E89999",
|
|
label: "#6B7280",
|
|
dim: "#9CA3AF",
|
|
progressFilled: "#A5D8DD",
|
|
progressEmpty: "#374151",
|
|
};
|
|
|
|
const MIME_EXTENSIONS: Record<string, string> = {
|
|
"image/png": "png",
|
|
"image/jpeg": "jpg",
|
|
"image/jpg": "jpg",
|
|
"image/gif": "gif",
|
|
"image/webp": "webp",
|
|
"image/bmp": "bmp",
|
|
"image/tiff": "tiff",
|
|
"image/heic": "heic",
|
|
"image/heif": "heif",
|
|
"image/avif": "avif",
|
|
"video/mp4": "mp4",
|
|
"video/webm": "webm",
|
|
"video/quicktime": "mov",
|
|
"video/x-matroska": "mkv",
|
|
"text/plain": "txt",
|
|
"application/json": "json",
|
|
"application/pdf": "pdf",
|
|
};
|
|
|
|
const EXTENSION_MIMES: Record<string, string> = {
|
|
png: "image/png",
|
|
jpg: "image/jpeg",
|
|
jpeg: "image/jpeg",
|
|
gif: "image/gif",
|
|
webp: "image/webp",
|
|
bmp: "image/bmp",
|
|
tiff: "image/tiff",
|
|
heic: "image/heic",
|
|
heif: "image/heif",
|
|
avif: "image/avif",
|
|
mp4: "video/mp4",
|
|
webm: "video/webm",
|
|
mov: "video/quicktime",
|
|
mkv: "video/x-matroska",
|
|
txt: "text/plain",
|
|
json: "application/json",
|
|
pdf: "application/pdf",
|
|
js: "application/javascript",
|
|
ts: "application/typescript",
|
|
rs: "text/x-rust",
|
|
py: "text/x-python",
|
|
md: "text/markdown",
|
|
html: "text/html",
|
|
css: "text/css",
|
|
};
|
|
|
|
const TRUTHY = ["y", "yes", "true", "t", "Y", "YES", "TRUE", "T"];
|
|
const FALSY = ["n", "no", "false", "f", "N", "NO", "FALSE", "F", "deny"];
|
|
|
|
let VERBOSE = false;
|
|
let usedStdin = false;
|
|
|
|
const IS_WSL = await (async () => {
|
|
try {
|
|
const text = await Bun.file("/proc/version").text();
|
|
return text.toLowerCase().includes("microsoft");
|
|
} catch {
|
|
return false;
|
|
}
|
|
})();
|
|
|
|
const IS_WINDOWS = platform() === "win32";
|
|
|
|
class Spinner {
|
|
private frames = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
|
|
private colors = COLORS.spinner;
|
|
private index = 0;
|
|
private interval: IntervalHandle | null = null;
|
|
|
|
start(message: string) {
|
|
process.stdout.write("\x1B[?25l");
|
|
|
|
this.interval = setInterval(() => {
|
|
const colorIndex = this.index % this.colors.length;
|
|
const frame = chalk.hex(this.colors[colorIndex])(this.frames[this.index]);
|
|
process.stdout.write(`\r${frame} ${chalk.hex(COLORS.dim)(message)}`);
|
|
this.index = (this.index + 1) % this.frames.length;
|
|
}, TIMING.spinnerFrame);
|
|
}
|
|
|
|
stop(clearLine = true) {
|
|
if (this.interval) {
|
|
clearInterval(this.interval);
|
|
if (clearLine) {
|
|
process.stdout.write("\r\x1B[K");
|
|
}
|
|
process.stdout.write("\x1B[?25h");
|
|
}
|
|
}
|
|
}
|
|
|
|
function restoreCursor() {
|
|
process.stdout.write("\x1B[?25h");
|
|
}
|
|
|
|
process.on("SIGINT", () => {
|
|
restoreCursor();
|
|
process.exit(130);
|
|
});
|
|
|
|
process.on("SIGTERM", () => {
|
|
restoreCursor();
|
|
process.exit(143);
|
|
});
|
|
|
|
process.on("uncaughtException", (err) => {
|
|
restoreCursor();
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|
|
|
|
class ProgressBar {
|
|
private total: number;
|
|
private barWidth = 30;
|
|
|
|
constructor(total: number) {
|
|
this.total = Math.max(1, total);
|
|
}
|
|
|
|
update(loaded: number) {
|
|
const percentage = Math.min(100, Math.floor((loaded / this.total) * 100));
|
|
const filled = Math.floor((loaded / this.total) * this.barWidth);
|
|
const empty = this.barWidth - filled;
|
|
|
|
const bar =
|
|
chalk.hex(COLORS.progressFilled)("█".repeat(filled)) +
|
|
chalk.hex(COLORS.progressEmpty)("░".repeat(empty));
|
|
|
|
const stats = `${formatBytes(loaded)}/${formatBytes(this.total)}`;
|
|
|
|
process.stdout.write(`\r${bar} ${percentage}% · ${stats}`);
|
|
}
|
|
|
|
finish() {
|
|
process.stdout.write("\r\x1B[K");
|
|
}
|
|
}
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes === 0) return "0 B";
|
|
const k = 1024;
|
|
const sizes = ["B", "KB", "MB", "GB"];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
|
}
|
|
|
|
function log(
|
|
message: string,
|
|
type: "success" | "error" | "info" | "dim" = "info",
|
|
) {
|
|
const icons = {
|
|
success: chalk.hex(COLORS.success)("✓"),
|
|
error: chalk.hex(COLORS.error)("✗"),
|
|
info: chalk.hex(COLORS.dim)("→"),
|
|
dim: chalk.hex(COLORS.dim)("·"),
|
|
};
|
|
console.log(`${icons[type]} ${message}`);
|
|
}
|
|
|
|
function debug(message: string, data?: unknown) {
|
|
if (!VERBOSE) return;
|
|
console.error(chalk.hex(COLORS.label)("[debug]"), message);
|
|
if (data !== undefined) {
|
|
console.error(chalk.hex(COLORS.dim)(JSON.stringify(data, null, 2)));
|
|
}
|
|
}
|
|
|
|
async function detectMimeType(
|
|
buffer: Buffer,
|
|
filename?: string,
|
|
): Promise<string> {
|
|
const result = await fileTypeFromBuffer(buffer);
|
|
if (result) {
|
|
debug("Detected MIME via file-type", { mime: result.mime });
|
|
return result.mime;
|
|
}
|
|
|
|
if (filename) {
|
|
const ext = extname(filename).slice(1).toLowerCase();
|
|
if (EXTENSION_MIMES[ext]) {
|
|
debug("Detected MIME via extension", { ext, mime: EXTENSION_MIMES[ext] });
|
|
return EXTENSION_MIMES[ext];
|
|
}
|
|
}
|
|
|
|
debug("Defaulting to application/octet-stream");
|
|
return "application/octet-stream";
|
|
}
|
|
|
|
function getExtensionForMime(mime: string): string {
|
|
return MIME_EXTENSIONS[mime] || "bin";
|
|
}
|
|
|
|
function getMimeForExtension(ext: string): string {
|
|
const normalized = ext.replace(/^\.+/, "");
|
|
return EXTENSION_MIMES[normalized] || "application/octet-stream";
|
|
}
|
|
|
|
function isTextMime(mime: string): boolean {
|
|
return (
|
|
mime.startsWith("text/") ||
|
|
mime === "application/json" ||
|
|
mime === "application/javascript" ||
|
|
mime === "application/typescript"
|
|
);
|
|
}
|
|
|
|
function isImageMime(mime: string): boolean {
|
|
return mime.startsWith("image/");
|
|
}
|
|
|
|
function isVideoMime(mime: string): boolean {
|
|
return mime.startsWith("video/");
|
|
}
|
|
|
|
async function readFromFile(path: string): Promise<UploadSource> {
|
|
const spinner = new Spinner();
|
|
spinner.start("Reading file...");
|
|
|
|
try {
|
|
if (!existsSync(path)) {
|
|
throw new Error(`File not found: ${path}`);
|
|
}
|
|
|
|
const buffer = Buffer.from(await Bun.file(path).arrayBuffer());
|
|
const filename = basename(path);
|
|
const mimeType = await detectMimeType(buffer, filename);
|
|
|
|
spinner.stop();
|
|
debug("Read file", { path, size: buffer.length, mimeType });
|
|
|
|
return { buffer, filename, mimeType };
|
|
} catch (e) {
|
|
spinner.stop();
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
function hasStdinData(): boolean {
|
|
if (Bun.stdin.isTTY === true) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const stats = fstatSync(0);
|
|
return stats.isFIFO() || stats.isSocket();
|
|
} catch (error) {
|
|
debug(
|
|
"fstat stdin detection failed",
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function readFromStdin(): Promise<UploadSource> {
|
|
usedStdin = true;
|
|
const spinner = new Spinner();
|
|
spinner.start("Reading from stdin...");
|
|
|
|
try {
|
|
const chunks: Buffer[] = [];
|
|
|
|
for await (const chunk of Bun.stdin.stream()) {
|
|
chunks.push(Buffer.from(chunk));
|
|
}
|
|
|
|
const buffer = Buffer.concat(chunks);
|
|
|
|
if (buffer.length === 0) {
|
|
throw new Error("No data received from stdin");
|
|
}
|
|
|
|
const mimeType = await detectMimeType(buffer);
|
|
spinner.stop();
|
|
debug("Read stdin", { size: buffer.length, mimeType });
|
|
|
|
return { buffer, mimeType };
|
|
} catch (e) {
|
|
spinner.stop();
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async function copyToClipboard(text: string): Promise<void> {
|
|
if (IS_WSL || IS_WINDOWS) {
|
|
const proc = Bun.spawn(["clip.exe"], { stdin: "pipe" });
|
|
proc.stdin.write(text);
|
|
proc.stdin.end();
|
|
await proc.exited;
|
|
} else {
|
|
const proc = Bun.spawn(["xclip", "-selection", "clipboard"], {
|
|
stdin: "pipe",
|
|
});
|
|
proc.stdin.write(text);
|
|
proc.stdin.end();
|
|
await proc.exited;
|
|
}
|
|
}
|
|
|
|
async function readFromClipboard(): Promise<UploadSource> {
|
|
const spinner = new Spinner();
|
|
spinner.start("Reading clipboard...");
|
|
|
|
try {
|
|
if (IS_WSL || IS_WINDOWS) {
|
|
const result =
|
|
await $`powershell.exe -NoProfile -Command "Get-Clipboard -Raw"`.text();
|
|
const text = result.trim();
|
|
|
|
if (!text) {
|
|
throw new Error("Clipboard is empty");
|
|
}
|
|
|
|
if (text.startsWith("/") && existsSync(text)) {
|
|
spinner.stop();
|
|
const shouldUpload = await confirm(
|
|
`Clipboard contains a file path: ${text}\nUpload this file?`,
|
|
);
|
|
if (shouldUpload) {
|
|
return readFromFile(text);
|
|
}
|
|
throw new Error("Upload cancelled");
|
|
}
|
|
|
|
spinner.stop();
|
|
const buffer = Buffer.from(text, "utf-8");
|
|
debug("Read clipboard text (WSL)", { size: buffer.length });
|
|
|
|
return { buffer, mimeType: "text/plain" };
|
|
}
|
|
|
|
const targets = await $`xclip -selection clipboard -t TARGETS -o`.text();
|
|
const targetList = targets.trim().split("\n");
|
|
debug("Clipboard targets", targetList);
|
|
|
|
if (
|
|
targetList.includes("text/uri-list") ||
|
|
targetList.includes("x-special/gnome-copied-files")
|
|
) {
|
|
const uris =
|
|
await $`xclip -selection clipboard -t text/uri-list -o`.text();
|
|
const filePath = uris
|
|
.split("\n")[0]
|
|
.replace(/^file:\/\//, "")
|
|
.trim();
|
|
|
|
if (filePath && existsSync(filePath)) {
|
|
spinner.stop();
|
|
|
|
const shouldUpload = await confirm(
|
|
`Clipboard contains a file path: ${filePath}\nUpload this file?`,
|
|
);
|
|
if (shouldUpload) {
|
|
return readFromFile(filePath);
|
|
} else {
|
|
throw new Error("Upload cancelled");
|
|
}
|
|
}
|
|
}
|
|
|
|
const imageTarget = targetList.find((t) => t.startsWith("image/"));
|
|
if (imageTarget) {
|
|
const isBmp = imageTarget === "image/bmp";
|
|
const buffer = Buffer.from(
|
|
await $`xclip -selection clipboard -t ${imageTarget} -o`.arrayBuffer(),
|
|
);
|
|
|
|
spinner.stop();
|
|
debug("Read clipboard image", {
|
|
target: imageTarget,
|
|
size: buffer.length,
|
|
});
|
|
|
|
if (isBmp) {
|
|
log("Converting BMP to PNG...", "info");
|
|
return { buffer, mimeType: "image/bmp", filename: "paste.bmp" };
|
|
}
|
|
|
|
return {
|
|
buffer,
|
|
mimeType: imageTarget,
|
|
filename: `paste.${getExtensionForMime(imageTarget)}`,
|
|
};
|
|
}
|
|
|
|
const text = await $`xclip -selection clipboard -o`.text();
|
|
if (!text.trim()) {
|
|
throw new Error("Clipboard is empty or contains unsupported data");
|
|
}
|
|
|
|
const trimmedText = text.trim();
|
|
if (trimmedText.startsWith("/") && existsSync(trimmedText)) {
|
|
spinner.stop();
|
|
const shouldUpload = await confirm(
|
|
`Clipboard contains a file path: ${trimmedText}\nUpload this file?`,
|
|
);
|
|
if (shouldUpload) {
|
|
return readFromFile(trimmedText);
|
|
}
|
|
}
|
|
|
|
spinner.stop();
|
|
const buffer = Buffer.from(text, "utf-8");
|
|
debug("Read clipboard text", { size: buffer.length });
|
|
|
|
return { buffer, mimeType: "text/plain" };
|
|
} catch (e) {
|
|
spinner.stop();
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async function confirm(message: string): Promise<boolean> {
|
|
process.stdout.write(
|
|
`${chalk.hex(COLORS.label)("?")} ${message} ${chalk.hex(COLORS.dim)("(y/n)")}: `,
|
|
);
|
|
|
|
const input = await new Promise<string>((resolve) => {
|
|
process.stdin.resume();
|
|
process.stdin.once("data", (data) => {
|
|
process.stdin.pause();
|
|
resolve(data.toString().trim());
|
|
});
|
|
});
|
|
|
|
return TRUTHY.includes(input);
|
|
}
|
|
|
|
async function promptExtension(): Promise<string | null> {
|
|
while (true) {
|
|
process.stdout.write(
|
|
`${chalk.hex(COLORS.label)("?")} File extension ${chalk.hex(COLORS.dim)("(default: .txt)")}: `,
|
|
);
|
|
|
|
const input = await new Promise<string>((resolve) => {
|
|
process.stdin.resume();
|
|
process.stdin.once("data", (data) => {
|
|
process.stdin.pause();
|
|
resolve(data.toString().trim());
|
|
});
|
|
});
|
|
|
|
if (!input) {
|
|
const useTxt = await confirm("Upload as .txt?");
|
|
if (useTxt) return "txt";
|
|
return null;
|
|
}
|
|
|
|
if (!input.startsWith(".")) {
|
|
if (TRUTHY.includes(input)) return "txt";
|
|
if (FALSY.includes(input)) return null;
|
|
}
|
|
|
|
let ext = input.startsWith(".") ? input.slice(1) : input;
|
|
|
|
if (ext.startsWith(".") || ext.includes("..")) {
|
|
log(
|
|
'Invalid extension format. Use format like "txt", ".txt", or ".ts.map"',
|
|
"error",
|
|
);
|
|
continue;
|
|
}
|
|
|
|
return ext;
|
|
}
|
|
}
|
|
|
|
async function cleanupTempFiles(...paths: string[]): Promise<void> {
|
|
for (const path of paths) {
|
|
try {
|
|
await $`rm -f ${path}`.quiet();
|
|
} catch {
|
|
debug("Failed to cleanup temp file", path);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function hasCommand(cmd: string): Promise<boolean> {
|
|
try {
|
|
await $`which ${cmd}`.quiet();
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function runMediaCommand(cmd: string[]): Promise<void> {
|
|
try {
|
|
const proc = Bun.spawn(cmd, {
|
|
stdout: "ignore",
|
|
stderr: "pipe",
|
|
});
|
|
|
|
const exitCode = await proc.exited;
|
|
|
|
if (exitCode !== 0) {
|
|
const stderr = await new Response(proc.stderr).text();
|
|
const errorLines = stderr
|
|
.split('\n')
|
|
.filter(line => line.trim())
|
|
.slice(-10) // Last 10 non-empty lines
|
|
.join('\n');
|
|
|
|
throw new Error(`Command failed with exit code ${exitCode}\n${errorLines}`);
|
|
}
|
|
} catch (e) {
|
|
if (e instanceof Error) {
|
|
throw e;
|
|
}
|
|
throw new Error(`Command failed: ${String(e)}`);
|
|
}
|
|
}
|
|
|
|
async function writeTempFile(buffer: Buffer, ext = ""): Promise<string> {
|
|
const path = join(tmpdir(), `share-probe-${nanoid(8)}${ext}`);
|
|
await Bun.write(path, buffer);
|
|
return path;
|
|
}
|
|
|
|
function parseFixMode(value: string | undefined): FixMode {
|
|
if (!value) return "prompt";
|
|
const v = value.toLowerCase();
|
|
if (v.startsWith("n")) return "never";
|
|
if (v.startsWith("a")) return "always";
|
|
return "prompt";
|
|
}
|
|
|
|
function isAudioMime(mime: string): boolean {
|
|
return mime.startsWith("audio/");
|
|
}
|
|
|
|
async function probeVideoMetadata(buffer: Buffer): Promise<VideoProbe | null> {
|
|
if (!(await hasCommand("ffprobe"))) {
|
|
debug("ffprobe not found, skipping video validation");
|
|
return null;
|
|
}
|
|
|
|
const tmpPath = await writeTempFile(buffer);
|
|
try {
|
|
const result =
|
|
await $`ffprobe -v error -show_format -show_streams -of json ${tmpPath}`.json();
|
|
const format = result.format || {};
|
|
const videoStream = result.streams?.find(
|
|
(s: { codec_type: string }) => s.codec_type === "video",
|
|
);
|
|
|
|
// Check for faststart by looking at format tags or probing atom order
|
|
// A proper check would require parsing the file, but we can infer from tags
|
|
const hasFaststart =
|
|
format.tags?.major_brand === "isom" ||
|
|
(format.format_name?.includes("mov") &&
|
|
format.tags?.compatible_brands?.includes("isom"));
|
|
|
|
return {
|
|
duration: format.duration ? parseFloat(format.duration) : null,
|
|
codec: videoStream?.codec_name?.toLowerCase() || null,
|
|
hasFaststart: !!hasFaststart,
|
|
};
|
|
} catch (e) {
|
|
debug("ffprobe failed", e instanceof Error ? e.message : String(e));
|
|
return null;
|
|
} finally {
|
|
await cleanupTempFiles(tmpPath);
|
|
}
|
|
}
|
|
|
|
async function probeVideo(buffer: Buffer): Promise<ProbeResult | null> {
|
|
const probe = await probeVideoMetadata(buffer);
|
|
if (!probe) return null;
|
|
|
|
const issues: MediaIssue[] = [];
|
|
|
|
// Missing duration metadata - remux fixes this and also adds faststart
|
|
if (probe.duration === null) {
|
|
issues.push({
|
|
id: "missing-duration",
|
|
description: "Missing duration metadata",
|
|
severity: "error",
|
|
autoFix: true,
|
|
fix: remuxVideo,
|
|
});
|
|
// Skip faststart check since remux handles it
|
|
} else if (!probe.hasFaststart) {
|
|
// Only check faststart if duration exists (otherwise remux already handles it)
|
|
issues.push({
|
|
id: "missing-faststart",
|
|
description: "MP4 not optimized for streaming",
|
|
severity: "warning",
|
|
autoFix: true,
|
|
fix: addFaststart,
|
|
});
|
|
}
|
|
|
|
// Incompatible codec - this is separate since it requires re-encoding
|
|
if (probe.codec && INCOMPATIBLE_VIDEO_CODECS.includes(probe.codec)) {
|
|
const isSmall = buffer.length < SIZE_THRESHOLDS.reencodeAuto;
|
|
issues.push({
|
|
id: "incompatible-codec",
|
|
description: `Codec '${probe.codec}' has limited browser/Discord support`,
|
|
severity: "warning",
|
|
autoFix: isSmall,
|
|
fix: reencodeVideo,
|
|
});
|
|
}
|
|
|
|
return { issues, metadata: probe };
|
|
}
|
|
|
|
async function probeImage(
|
|
_buffer: Buffer,
|
|
mime: string,
|
|
): Promise<ProbeResult | null> {
|
|
const issues: MediaIssue[] = [];
|
|
|
|
if (mime === "image/heic" || mime === "image/heif") {
|
|
issues.push({
|
|
id: "heic-compat",
|
|
description: "HEIC not supported in browsers",
|
|
severity: "error",
|
|
autoFix: true,
|
|
fix: convertImageToJpeg,
|
|
});
|
|
}
|
|
|
|
if (mime === "image/avif") {
|
|
issues.push({
|
|
id: "avif-compat",
|
|
description: "AVIF has limited browser support",
|
|
severity: "warning",
|
|
autoFix: true,
|
|
fix: convertImageToWebp,
|
|
});
|
|
}
|
|
|
|
return issues.length > 0
|
|
? { issues, metadata: { originalMime: mime } }
|
|
: null;
|
|
}
|
|
|
|
async function probeAudio(buffer: Buffer): Promise<ProbeResult | null> {
|
|
if (!(await hasCommand("ffprobe"))) {
|
|
debug("ffprobe not found, skipping audio validation");
|
|
return null;
|
|
}
|
|
|
|
const tmpPath = await writeTempFile(buffer);
|
|
try {
|
|
const result =
|
|
await $`ffprobe -v error -show_format -of json ${tmpPath}`.json();
|
|
const format = result.format || {};
|
|
const issues: MediaIssue[] = [];
|
|
|
|
if (!format.duration || format.duration === "N/A") {
|
|
issues.push({
|
|
id: "missing-duration",
|
|
description: "Missing duration metadata",
|
|
severity: "error",
|
|
autoFix: true,
|
|
fix: remuxAudio,
|
|
});
|
|
}
|
|
|
|
return issues.length > 0 ? { issues, metadata: format } : null;
|
|
} catch (e) {
|
|
debug(
|
|
"ffprobe failed for audio",
|
|
e instanceof Error ? e.message : String(e),
|
|
);
|
|
return null;
|
|
} finally {
|
|
await cleanupTempFiles(tmpPath);
|
|
}
|
|
}
|
|
|
|
async function validateMedia(
|
|
buffer: Buffer,
|
|
mimeType: string,
|
|
): Promise<ProbeResult | null> {
|
|
if (isVideoMime(mimeType)) {
|
|
return probeVideo(buffer);
|
|
} else if (isImageMime(mimeType)) {
|
|
return probeImage(buffer, mimeType);
|
|
} else if (isAudioMime(mimeType)) {
|
|
return probeAudio(buffer);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function applyFixes(
|
|
buffer: Buffer,
|
|
mimeType: string,
|
|
issues: MediaIssue[],
|
|
mode: FixMode,
|
|
skipPrompts: boolean,
|
|
): Promise<{ buffer: Buffer; mimeType: string; applied: string[] }> {
|
|
if (mode === "never" || issues.length === 0) {
|
|
for (const issue of issues) {
|
|
log(`Skipped: ${issue.description}`, "dim");
|
|
}
|
|
return { buffer, mimeType, applied: [] };
|
|
}
|
|
|
|
const applied: string[] = [];
|
|
|
|
for (const issue of issues) {
|
|
const shouldAuto =
|
|
mode === "always" || (mode === "prompt" && issue.autoFix);
|
|
|
|
if (shouldAuto) {
|
|
log(`Fixing: ${issue.description}`, "info");
|
|
buffer = await issue.fix(buffer);
|
|
applied.push(issue.id);
|
|
} else if (mode === "prompt") {
|
|
if (skipPrompts) {
|
|
log(`Skipped: ${issue.description}`, "dim");
|
|
continue;
|
|
}
|
|
|
|
const proceed = await confirm(`Fix: ${issue.description}?`);
|
|
if (proceed) {
|
|
buffer = await issue.fix(buffer);
|
|
applied.push(issue.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update mimeType based on applied fixes
|
|
if (applied.includes("heic-compat")) {
|
|
mimeType = "image/jpeg";
|
|
} else if (applied.includes("avif-compat")) {
|
|
mimeType = "image/webp";
|
|
} else if (applied.includes("incompatible-codec")) {
|
|
mimeType = "video/mp4";
|
|
}
|
|
|
|
return { buffer, mimeType, applied };
|
|
}
|
|
|
|
async function remuxVideo(buffer: Buffer): Promise<Buffer> {
|
|
const spinner = new Spinner();
|
|
spinner.start("Remuxing video...");
|
|
|
|
const inputPath = await writeTempFile(buffer);
|
|
const outputPath = join(tmpdir(), `share-remux-${nanoid(8)}.webm`);
|
|
|
|
try {
|
|
await runMediaCommand([
|
|
"ffmpeg", "-y", "-i", inputPath,
|
|
"-c", "copy", "-fflags", "+genpts",
|
|
outputPath
|
|
]);
|
|
|
|
const result = Buffer.from(await Bun.file(outputPath).arrayBuffer());
|
|
spinner.stop();
|
|
log("Fixed video metadata", "success");
|
|
return result;
|
|
} catch (e) {
|
|
spinner.stop();
|
|
console.error(chalk.hex(COLORS.error)("\nRemux error:"));
|
|
console.error(chalk.hex(COLORS.dim)(e instanceof Error ? e.message : String(e)));
|
|
throw new Error("Video remux failed");
|
|
} finally {
|
|
await cleanupTempFiles(inputPath, outputPath);
|
|
}
|
|
}
|
|
|
|
async function addFaststart(buffer: Buffer): Promise<Buffer> {
|
|
const spinner = new Spinner();
|
|
spinner.start("Optimizing for streaming...");
|
|
|
|
const inputPath = await writeTempFile(buffer);
|
|
const outputPath = join(tmpdir(), `share-faststart-${nanoid(8)}.mp4`);
|
|
|
|
try {
|
|
await runMediaCommand([
|
|
"ffmpeg", "-y", "-i", inputPath,
|
|
"-c", "copy", "-movflags", "+faststart",
|
|
outputPath
|
|
]);
|
|
|
|
const result = Buffer.from(await Bun.file(outputPath).arrayBuffer());
|
|
spinner.stop();
|
|
log("Optimized for streaming", "success");
|
|
return result;
|
|
} catch (e) {
|
|
spinner.stop();
|
|
console.error(chalk.hex(COLORS.error)("\nFaststart optimization error:"));
|
|
console.error(chalk.hex(COLORS.dim)(e instanceof Error ? e.message : String(e)));
|
|
throw new Error("Faststart optimization failed");
|
|
} finally {
|
|
await cleanupTempFiles(inputPath, outputPath);
|
|
}
|
|
}
|
|
|
|
async function reencodeVideo(buffer: Buffer): Promise<Buffer> {
|
|
const spinner = new Spinner();
|
|
spinner.start("Re-encoding video for compatibility...");
|
|
|
|
const inputPath = await writeTempFile(buffer);
|
|
const outputPath = join(tmpdir(), `share-reencode-${nanoid(8)}.mp4`);
|
|
|
|
try {
|
|
await runMediaCommand([
|
|
"ffmpeg", "-y", "-i", inputPath,
|
|
"-c:v", "libx264", "-crf", "23", "-preset", "medium",
|
|
"-c:a", "aac", "-b:a", "128k",
|
|
"-movflags", "+faststart",
|
|
outputPath
|
|
]);
|
|
|
|
const result = Buffer.from(await Bun.file(outputPath).arrayBuffer());
|
|
spinner.stop();
|
|
log("Re-encoded to H.264", "success");
|
|
return result;
|
|
} catch (e) {
|
|
spinner.stop();
|
|
console.error(chalk.hex(COLORS.error)("\nRe-encode error:"));
|
|
console.error(chalk.hex(COLORS.dim)(e instanceof Error ? e.message : String(e)));
|
|
throw new Error("Video re-encode failed");
|
|
} finally {
|
|
await cleanupTempFiles(inputPath, outputPath);
|
|
}
|
|
}
|
|
|
|
async function remuxAudio(buffer: Buffer): Promise<Buffer> {
|
|
const spinner = new Spinner();
|
|
spinner.start("Fixing audio metadata...");
|
|
|
|
const inputPath = await writeTempFile(buffer);
|
|
const outputPath = join(tmpdir(), `share-audio-${nanoid(8)}.mka`);
|
|
|
|
try {
|
|
await runMediaCommand([
|
|
"ffmpeg", "-y", "-i", inputPath,
|
|
"-c", "copy",
|
|
outputPath
|
|
]);
|
|
|
|
const result = Buffer.from(await Bun.file(outputPath).arrayBuffer());
|
|
spinner.stop();
|
|
log("Fixed audio metadata", "success");
|
|
return result;
|
|
} catch (e) {
|
|
spinner.stop();
|
|
console.error(chalk.hex(COLORS.error)("\nAudio remux error:"));
|
|
console.error(chalk.hex(COLORS.dim)(e instanceof Error ? e.message : String(e)));
|
|
throw new Error("Audio remux failed");
|
|
} finally {
|
|
await cleanupTempFiles(inputPath, outputPath);
|
|
}
|
|
}
|
|
|
|
async function convertImageToJpeg(buffer: Buffer): Promise<Buffer> {
|
|
const spinner = new Spinner();
|
|
spinner.start("Converting to JPEG...");
|
|
|
|
const inputPath = await writeTempFile(buffer);
|
|
const outputPath = join(tmpdir(), `share-convert-${nanoid(8)}.jpg`);
|
|
|
|
try {
|
|
await runMediaCommand([
|
|
"convert", inputPath, "-quality", "90", outputPath
|
|
]);
|
|
|
|
const result = Buffer.from(await Bun.file(outputPath).arrayBuffer());
|
|
spinner.stop();
|
|
log("Converted to JPEG", "success");
|
|
return result;
|
|
} catch (e) {
|
|
spinner.stop();
|
|
console.error(chalk.hex(COLORS.error)("\nJPEG conversion error:"));
|
|
console.error(chalk.hex(COLORS.dim)(e instanceof Error ? e.message : String(e)));
|
|
throw new Error("JPEG conversion failed");
|
|
} finally {
|
|
await cleanupTempFiles(inputPath, outputPath);
|
|
}
|
|
}
|
|
|
|
async function convertImageToWebp(buffer: Buffer): Promise<Buffer> {
|
|
const spinner = new Spinner();
|
|
spinner.start("Converting to WebP...");
|
|
|
|
const inputPath = await writeTempFile(buffer);
|
|
const outputPath = join(tmpdir(), `share-convert-${nanoid(8)}.webp`);
|
|
|
|
try {
|
|
await runMediaCommand([
|
|
"convert", inputPath, "-quality", "85", outputPath
|
|
]);
|
|
|
|
const result = Buffer.from(await Bun.file(outputPath).arrayBuffer());
|
|
spinner.stop();
|
|
log("Converted to WebP", "success");
|
|
return result;
|
|
} catch (e) {
|
|
spinner.stop();
|
|
console.error(chalk.hex(COLORS.error)("\nWebP conversion error:"));
|
|
console.error(chalk.hex(COLORS.dim)(e instanceof Error ? e.message : String(e)));
|
|
throw new Error("WebP conversion failed");
|
|
} finally {
|
|
await cleanupTempFiles(inputPath, outputPath);
|
|
}
|
|
}
|
|
|
|
async function convertImage(
|
|
buffer: Buffer,
|
|
fromMime: string,
|
|
shouldConvert: boolean,
|
|
): Promise<Buffer> {
|
|
const needsConversion =
|
|
fromMime === "image/bmp" ||
|
|
(shouldConvert &&
|
|
(fromMime === "image/heic" ||
|
|
fromMime === "image/heif" ||
|
|
fromMime === "image/tiff"));
|
|
|
|
if (!needsConversion) return buffer;
|
|
|
|
const spinner = new Spinner();
|
|
spinner.start("Converting image...");
|
|
|
|
const inputPath = join(tmpdir(), `share-input-${nanoid(8)}`);
|
|
const outputPath = join(tmpdir(), `share-output-${nanoid(8)}.png`);
|
|
|
|
try {
|
|
await Bun.write(inputPath, buffer);
|
|
|
|
if (fromMime === "image/heic" || fromMime === "image/heif") {
|
|
await runMediaCommand(["convert", inputPath, "-quality", "90", outputPath]);
|
|
} else {
|
|
await runMediaCommand(["convert", inputPath, outputPath]);
|
|
}
|
|
|
|
const converted = Buffer.from(await Bun.file(outputPath).arrayBuffer());
|
|
|
|
spinner.stop();
|
|
log(`Converted to ${fromMime === "image/bmp" ? "PNG" : "JPEG"}`, "success");
|
|
|
|
return converted;
|
|
} catch (e) {
|
|
spinner.stop();
|
|
console.error(chalk.hex(COLORS.error)("\nImage conversion error:"));
|
|
console.error(chalk.hex(COLORS.dim)(e instanceof Error ? e.message : String(e)));
|
|
throw new Error("Image conversion failed");
|
|
} finally {
|
|
await cleanupTempFiles(inputPath, outputPath);
|
|
}
|
|
}
|
|
|
|
async function convertVideo(buffer: Buffer): Promise<Buffer> {
|
|
const spinner = new Spinner();
|
|
spinner.start("Converting video (this may take a while)...");
|
|
|
|
const inputPath = join(tmpdir(), `share-input-${nanoid(8)}`);
|
|
const outputPath = join(tmpdir(), `share-output-${nanoid(8)}.mp4`);
|
|
|
|
try {
|
|
await Bun.write(inputPath, buffer);
|
|
|
|
await runMediaCommand([
|
|
"ffmpeg", "-y", "-i", inputPath,
|
|
"-c:v", "libx264", "-crf", "28", "-preset", "slow",
|
|
"-vf", "scale=-2:'min(720,ih)'",
|
|
"-c:a", "aac", "-b:a", "128k",
|
|
"-movflags", "+faststart",
|
|
outputPath
|
|
]);
|
|
|
|
const converted = Buffer.from(await Bun.file(outputPath).arrayBuffer());
|
|
|
|
spinner.stop();
|
|
log("Converted to web-optimized MP4", "success");
|
|
|
|
return converted;
|
|
} catch (e) {
|
|
spinner.stop();
|
|
console.error(chalk.hex(COLORS.error)("\nConversion error:"));
|
|
console.error(chalk.hex(COLORS.dim)(e instanceof Error ? e.message : String(e)));
|
|
throw new Error("Video conversion failed");
|
|
} finally {
|
|
await cleanupTempFiles(inputPath, outputPath);
|
|
}
|
|
}
|
|
|
|
async function uploadToR2(
|
|
buffer: Buffer,
|
|
filename: string,
|
|
mimeType: string,
|
|
): Promise<UploadResult> {
|
|
if (
|
|
!ENV.endpoint ||
|
|
!ENV.accessKeyId ||
|
|
!ENV.secretAccessKey ||
|
|
!ENV.bucket
|
|
) {
|
|
throw new Error(
|
|
"Missing R2 credentials - ensure all R2_* environment variables are set",
|
|
);
|
|
}
|
|
|
|
debug("S3 Client Config", {
|
|
endpoint: ENV.endpoint,
|
|
bucket: ENV.bucket,
|
|
accessKeyIdLength: ENV.accessKeyId.length,
|
|
});
|
|
|
|
const client = new S3Client({
|
|
region: "auto",
|
|
endpoint: ENV.endpoint,
|
|
credentials: {
|
|
accessKeyId: ENV.accessKeyId,
|
|
secretAccessKey: ENV.secretAccessKey,
|
|
},
|
|
requestHandler: {
|
|
requestTimeout: TIMING.requestTimeout,
|
|
},
|
|
});
|
|
|
|
const now = new Date();
|
|
const year = now.getFullYear();
|
|
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
const key = `${year}/${month}/${filename}`;
|
|
|
|
debug("Uploading to R2", { key, size: buffer.length, mimeType });
|
|
|
|
const progressBar = new ProgressBar(buffer.length);
|
|
|
|
try {
|
|
const upload = new Upload({
|
|
client,
|
|
params: {
|
|
Bucket: ENV.bucket,
|
|
Key: key,
|
|
Body: buffer,
|
|
ContentType: mimeType,
|
|
},
|
|
});
|
|
|
|
upload.on("httpUploadProgress", (progress) => {
|
|
if (progress.loaded) {
|
|
progressBar.update(progress.loaded);
|
|
}
|
|
});
|
|
|
|
console.log(`Uploading ${chalk.hex(COLORS.dim)(filename)}`);
|
|
|
|
let timeoutId: TimeoutHandle;
|
|
const uploadPromise = upload.done();
|
|
const timeoutPromise = new Promise((_, reject) => {
|
|
timeoutId = setTimeout(
|
|
() => reject(new Error("Upload timeout after 60 seconds")),
|
|
TIMING.uploadTimeout,
|
|
);
|
|
});
|
|
|
|
debug("Waiting for upload to complete...");
|
|
try {
|
|
await Promise.race([uploadPromise, timeoutPromise]);
|
|
debug("Upload promise resolved");
|
|
} finally {
|
|
clearTimeout(timeoutId!);
|
|
}
|
|
progressBar.finish();
|
|
debug("Progress bar finished");
|
|
|
|
upload.removeAllListeners();
|
|
debug("Event listeners removed");
|
|
|
|
const url = `${DOMAIN}/${key}`;
|
|
debug("Returning result");
|
|
return { url, key, size: buffer.length };
|
|
} catch (e) {
|
|
progressBar.finish();
|
|
if (VERBOSE && e instanceof Error) {
|
|
debug("Upload error details", { error: e.message, stack: e.stack });
|
|
}
|
|
throw new Error(
|
|
`Upload failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
);
|
|
} finally {
|
|
client.destroy();
|
|
}
|
|
}
|
|
|
|
function hasNanoidSuffix(filename: string): boolean {
|
|
// nanoid default alphabet: A-Za-z0-9_-
|
|
return /-[A-Za-z0-9_-]{8}\.[^.]+$/.test(filename);
|
|
}
|
|
|
|
async function main() {
|
|
const { values, positionals } = parseArgs({
|
|
args: Bun.argv.slice(2),
|
|
options: {
|
|
verbose: { type: "boolean", short: "v" },
|
|
convert: { type: "boolean", short: "c" },
|
|
fix: { type: "string", short: "F" },
|
|
yes: { type: "boolean", short: "y" },
|
|
name: { type: "string", short: "n" },
|
|
help: { type: "boolean", short: "h" },
|
|
},
|
|
allowPositionals: true,
|
|
});
|
|
|
|
if (values.help) {
|
|
console.log(`
|
|
${chalk.hex(COLORS.success)("share")} - Upload files to R2 and copy the URL to clipboard
|
|
|
|
${chalk.hex(COLORS.label)("Usage:")}
|
|
share [options] [file]
|
|
|
|
${chalk.hex(COLORS.label)("Arguments:")}
|
|
file Optional file path to upload
|
|
|
|
${chalk.hex(COLORS.label)("Options:")}
|
|
-v, --verbose Enable debug output
|
|
-c, --convert Convert media before upload (re-encode video, etc.)
|
|
-F, --fix <mode> Fix media issues: n=never, p=prompt (default), a=always
|
|
-y, --yes Skip confirmation prompts
|
|
-n, --name <name> Custom filename (without extension)
|
|
-h, --help Show this help
|
|
|
|
${chalk.hex(COLORS.label)("Fix Modes:")}
|
|
-Fn, --fix=never Skip all fixes, upload as-is
|
|
-Fp, --fix=prompt Auto-fix quick/lossless, prompt for slow/lossy (default)
|
|
-Fa, --fix=always Apply all fixes automatically
|
|
|
|
${chalk.hex(COLORS.label)("Media Fixes (auto-applied by default):")}
|
|
• Missing video duration/metadata (remux)
|
|
• MP4 streaming optimization (faststart)
|
|
• HEIC/AVIF browser compatibility (convert to JPEG/WebP)
|
|
|
|
${chalk.hex(COLORS.label)("Media Fixes (prompted or with -Fa):")}
|
|
• Video re-encoding for codec compatibility (H.265/AV1 → H.264)
|
|
• Large file optimizations
|
|
|
|
${chalk.hex(COLORS.label)("Examples:")}
|
|
share # Upload clipboard content
|
|
share screenshot.png # Upload specific file
|
|
cat file.txt | share # Upload from stdin
|
|
share -c large-video.mov # Convert/re-encode then upload
|
|
share -Fa video.webm # Fix all issues automatically
|
|
|
|
${chalk.hex(COLORS.label)("Note:")}
|
|
R2 credentials are embedded via chezmoi template from Doppler.
|
|
Use 'chezmoi apply' to update the deployed script with new credentials.
|
|
`);
|
|
return;
|
|
}
|
|
|
|
VERBOSE = values.verbose || false;
|
|
|
|
// Credentials are embedded via chezmoi template - check ENV object instead
|
|
if (!ENV.endpoint || !ENV.accessKeyId || !ENV.secretAccessKey || !ENV.bucket) {
|
|
log("Missing R2 credentials - chezmoi template may not have been processed correctly", "error");
|
|
process.exit(1);
|
|
}
|
|
|
|
try {
|
|
let source: UploadSource;
|
|
|
|
if (positionals[0]) {
|
|
source = await readFromFile(positionals[0]);
|
|
} else if (hasStdinData()) {
|
|
source = await readFromStdin();
|
|
} else {
|
|
source = await readFromClipboard();
|
|
}
|
|
|
|
let { buffer, filename, mimeType } = source;
|
|
|
|
if (!mimeType) {
|
|
mimeType = await detectMimeType(buffer, filename);
|
|
}
|
|
|
|
log(`Detected: ${mimeType} (${formatBytes(buffer.length)})`, "success");
|
|
|
|
if (isTextMime(mimeType) && !values.yes) {
|
|
const ext = await promptExtension();
|
|
if (!ext) {
|
|
log("Upload cancelled", "error");
|
|
process.exit(0);
|
|
}
|
|
|
|
mimeType = getMimeForExtension(ext);
|
|
const baseName =
|
|
values.name ||
|
|
(filename ? basename(filename, extname(filename)) : "text");
|
|
filename = `${baseName}-${nanoid(8)}.${ext}`;
|
|
}
|
|
|
|
// Determine fix mode: -c implies -Fa (always fix), otherwise parse -F flag
|
|
const fixMode = values.convert ? "always" : parseFixMode(values.fix);
|
|
|
|
// Validate and fix media issues
|
|
if (fixMode !== "never") {
|
|
const probeResult = await validateMedia(buffer, mimeType);
|
|
|
|
if (probeResult && probeResult.issues.length > 0) {
|
|
debug("Media issues detected", probeResult.metadata);
|
|
|
|
const fixResult = await applyFixes(
|
|
buffer,
|
|
mimeType,
|
|
probeResult.issues,
|
|
fixMode,
|
|
values.yes || false,
|
|
);
|
|
|
|
buffer = fixResult.buffer;
|
|
mimeType = fixResult.mimeType;
|
|
|
|
if (fixResult.applied.length > 0) {
|
|
debug("Applied fixes", fixResult.applied);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!values.yes) {
|
|
let needsConfirm = false;
|
|
let reason = "";
|
|
|
|
if (isImageMime(mimeType) && buffer.length > SIZE_THRESHOLDS.image) {
|
|
needsConfirm = true;
|
|
reason = `image is larger than ${formatBytes(SIZE_THRESHOLDS.image)}`;
|
|
} else if (
|
|
isVideoMime(mimeType) &&
|
|
buffer.length > SIZE_THRESHOLDS.video
|
|
) {
|
|
needsConfirm = true;
|
|
reason = `video is larger than ${formatBytes(SIZE_THRESHOLDS.video)}`;
|
|
} else if (
|
|
!isImageMime(mimeType) &&
|
|
!isVideoMime(mimeType) &&
|
|
buffer.length > SIZE_THRESHOLDS.binary
|
|
) {
|
|
needsConfirm = true;
|
|
reason = `file is larger than ${formatBytes(SIZE_THRESHOLDS.binary)}`;
|
|
}
|
|
|
|
if (needsConfirm) {
|
|
const shouldContinue = await confirm(`Upload large file? (${reason})`);
|
|
if (!shouldContinue) {
|
|
log("Upload cancelled", "error");
|
|
process.exit(0);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Legacy conversion path: BMP always converts, -c triggers full re-encode
|
|
if (isImageMime(mimeType) && mimeType === "image/bmp") {
|
|
buffer = await convertImage(buffer, mimeType, false);
|
|
mimeType = "image/png";
|
|
} else if (isVideoMime(mimeType) && values.convert) {
|
|
// Full video conversion (re-encode with scaling, etc.) only with -c
|
|
buffer = await convertVideo(buffer);
|
|
mimeType = "video/mp4";
|
|
}
|
|
|
|
if (!filename) {
|
|
const ext = getExtensionForMime(mimeType);
|
|
const baseName = values.name || "upload";
|
|
filename = `${baseName}-${nanoid(8)}.${ext}`;
|
|
} else if (!hasNanoidSuffix(filename)) {
|
|
const ext = extname(filename);
|
|
const base = basename(filename, ext);
|
|
const finalName = values.name || base;
|
|
filename = `${finalName}-${nanoid(8)}${ext}`;
|
|
}
|
|
|
|
debug("Calling uploadToR2...");
|
|
const result = await uploadToR2(buffer, filename, mimeType);
|
|
debug("uploadToR2 returned", { url: result.url });
|
|
|
|
debug("Copying to clipboard...");
|
|
await copyToClipboard(result.url);
|
|
debug("Clipboard copy complete");
|
|
|
|
log(result.url, "success");
|
|
log("Copied to clipboard", "success");
|
|
|
|
debug("About to exit...");
|
|
if (usedStdin) {
|
|
process.stdin.destroy();
|
|
}
|
|
process.exit(0);
|
|
} catch (e) {
|
|
if (e instanceof Error) {
|
|
log(e.message, "error");
|
|
} else {
|
|
log("Unknown error occurred", "error");
|
|
}
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
main();
|