mirror of
https://github.com/Xevion/dotfiles.git
synced 2026-01-31 02:24:11 -06:00
- Extract delta configuration to dedicated managed config file - Update lazygit with full Dracula theme and quality-of-life improvements - Remove banner comments from config files for cleaner format - Update git-related tool scripts (install-fonts, share utility)
1352 lines
36 KiB
TypeScript
Executable File
1352 lines
36 KiB
TypeScript
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
|
|
*
|
|
* Environment Variables:
|
|
* R2_ENDPOINT S3-compatible endpoint URL
|
|
* R2_ACCESS_KEY_ID Access key ID
|
|
* R2_SECRET_ACCESS_KEY Secret access key
|
|
* R2_BUCKET Bucket name
|
|
*/
|
|
|
|
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 REQUIRED_ENV = [
|
|
"R2_ENDPOINT",
|
|
"R2_ACCESS_KEY_ID",
|
|
"R2_SECRET_ACCESS_KEY",
|
|
"R2_BUCKET",
|
|
];
|
|
const ENV = {
|
|
endpoint: process.env.R2_ENDPOINT || "",
|
|
accessKeyId: process.env.R2_ACCESS_KEY_ID || "",
|
|
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY || "",
|
|
bucket: process.env.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 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 {
|
|
// Remux without re-encoding, adding faststart for MP4
|
|
await $`ffmpeg -y -i ${inputPath} -c copy -fflags +genpts ${outputPath}`.quiet();
|
|
|
|
const result = Buffer.from(await Bun.file(outputPath).arrayBuffer());
|
|
spinner.stop();
|
|
log("Fixed video metadata", "success");
|
|
return result;
|
|
} catch (e) {
|
|
spinner.stop();
|
|
throw new Error(
|
|
`Video remux failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
);
|
|
} 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 $`ffmpeg -y -i ${inputPath} -c copy -movflags +faststart ${outputPath}`.quiet();
|
|
|
|
const result = Buffer.from(await Bun.file(outputPath).arrayBuffer());
|
|
spinner.stop();
|
|
log("Optimized for streaming", "success");
|
|
return result;
|
|
} catch (e) {
|
|
spinner.stop();
|
|
throw new Error(
|
|
`Faststart optimization failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
);
|
|
} 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 $`ffmpeg -y -i ${inputPath} -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 128k -movflags +faststart ${outputPath}`.quiet();
|
|
|
|
const result = Buffer.from(await Bun.file(outputPath).arrayBuffer());
|
|
spinner.stop();
|
|
log("Re-encoded to H.264", "success");
|
|
return result;
|
|
} catch (e) {
|
|
spinner.stop();
|
|
throw new Error(
|
|
`Video re-encode failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
);
|
|
} 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);
|
|
// Detect format and use same extension
|
|
const outputPath = join(tmpdir(), `share-audio-${nanoid(8)}.mka`);
|
|
|
|
try {
|
|
await $`ffmpeg -y -i ${inputPath} -c copy ${outputPath}`.quiet();
|
|
|
|
const result = Buffer.from(await Bun.file(outputPath).arrayBuffer());
|
|
spinner.stop();
|
|
log("Fixed audio metadata", "success");
|
|
return result;
|
|
} catch (e) {
|
|
spinner.stop();
|
|
throw new Error(
|
|
`Audio remux failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
);
|
|
} 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 $`convert ${inputPath} -quality 90 ${outputPath}`.quiet();
|
|
|
|
const result = Buffer.from(await Bun.file(outputPath).arrayBuffer());
|
|
spinner.stop();
|
|
log("Converted to JPEG", "success");
|
|
return result;
|
|
} catch (e) {
|
|
spinner.stop();
|
|
throw new Error(
|
|
`JPEG conversion failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
);
|
|
} 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 $`convert ${inputPath} -quality 85 ${outputPath}`.quiet();
|
|
|
|
const result = Buffer.from(await Bun.file(outputPath).arrayBuffer());
|
|
spinner.stop();
|
|
log("Converted to WebP", "success");
|
|
return result;
|
|
} catch (e) {
|
|
spinner.stop();
|
|
throw new Error(
|
|
`WebP conversion failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
);
|
|
} 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 $`convert ${inputPath} -quality 90 ${outputPath}`.quiet();
|
|
} else {
|
|
await $`convert ${inputPath} ${outputPath}`.quiet();
|
|
}
|
|
|
|
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();
|
|
throw new Error(
|
|
`Image conversion failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
);
|
|
} 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 $`ffmpeg -i ${inputPath} -c:v libx264 -crf 28 -preset slow -vf "scale=-2:min(720,ih)" -c:a aac -b:a 128k -movflags +faststart ${outputPath}`.quiet();
|
|
|
|
const converted = Buffer.from(await Bun.file(outputPath).arrayBuffer());
|
|
|
|
spinner.stop();
|
|
log("Converted to web-optimized MP4", "success");
|
|
|
|
return converted;
|
|
} catch (e) {
|
|
spinner.stop();
|
|
throw new Error(
|
|
`Video conversion failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
);
|
|
} 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)("Environment Variables:")}
|
|
R2_ENDPOINT S3-compatible endpoint URL
|
|
R2_ACCESS_KEY_ID Access key ID
|
|
R2_SECRET_ACCESS_KEY Secret access key
|
|
R2_BUCKET Bucket name
|
|
`);
|
|
return;
|
|
}
|
|
|
|
VERBOSE = values.verbose || false;
|
|
|
|
const missing = REQUIRED_ENV.filter((key) => !process.env[key]);
|
|
if (missing.length > 0) {
|
|
log(`Missing environment variables: ${missing.join(", ")}`, "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();
|