diff --git a/home/.chezmoitemplates/nushell/env.nu.tmpl b/home/.chezmoitemplates/nushell/env.nu.tmpl index f260714..399b3b6 100644 --- a/home/.chezmoitemplates/nushell/env.nu.tmpl +++ b/home/.chezmoitemplates/nushell/env.nu.tmpl @@ -70,6 +70,12 @@ $env.MICRO_TRUECOLOR = 1 # OpenAI API Key $env.OPENAI_API_KEY = "{{ dopplerProjectJson.OPENAI_CHATGPT_CLI }}" +# R2 configuration +$env.R2_ENDPOINT = "{{ dopplerProjectJson.R2_ENDPOINT }}" +$env.R2_ACCESS_KEY_ID = "{{ dopplerProjectJson.R2_ACCESS_KEY_ID }}" +$env.R2_SECRET_ACCESS_KEY = "{{ dopplerProjectJson.R2_SECRET_ACCESS_KEY }}" +$env.R2_BUCKET = "{{ dopplerProjectJson.R2_BUCKET }}" + # Initialize PATH as a list for easier manipulation $env.PATH = ($env.PATH | split row (char esep)) diff --git a/home/.chezmoitemplates/scripts/commonrc.fish.tmpl b/home/.chezmoitemplates/scripts/commonrc.fish.tmpl index c24473d..9521feb 100644 --- a/home/.chezmoitemplates/scripts/commonrc.fish.tmpl +++ b/home/.chezmoitemplates/scripts/commonrc.fish.tmpl @@ -10,6 +10,13 @@ set -gx MICRO_TRUECOLOR 1 set -gx TERM xterm-256color # fixes terminal colors when ssh'ing into laptop set -gx OPENAI_API_KEY "{{ dopplerProjectJson.OPENAI_CHATGPT_CLI }}" + +# R2 configuration +set -gx R2_ENDPOINT "{{ dopplerProjectJson.R2_ENDPOINT }}" +set -gx R2_ACCESS_KEY_ID "{{ dopplerProjectJson.R2_ACCESS_KEY_ID }}" +set -gx R2_SECRET_ACCESS_KEY "{{ dopplerProjectJson.R2_SECRET_ACCESS_KEY }}" +set -gx R2_BUCKET "{{ dopplerProjectJson.R2_BUCKET }}" + # Tools are organized in priority order (first = highest priority in PATH) # This order matches the Bash config for consistency # Batched for performance - reduces startup time by ~13ms diff --git a/home/.chezmoitemplates/scripts/commonrc.sh.tmpl b/home/.chezmoitemplates/scripts/commonrc.sh.tmpl index 5d01d79..f9b326b 100644 --- a/home/.chezmoitemplates/scripts/commonrc.sh.tmpl +++ b/home/.chezmoitemplates/scripts/commonrc.sh.tmpl @@ -11,6 +11,12 @@ export TERM=xterm-256color # fixes terminal colors when ssh'ing into laptop export OPENAI_API_KEY="{{ dopplerProjectJson.OPENAI_CHATGPT_CLI }}" +# R2 configuration +export R2_ENDPOINT="{{ dopplerProjectJson.R2_ENDPOINT }}" +export R2_ACCESS_KEY_ID="{{ dopplerProjectJson.R2_ACCESS_KEY_ID }}" +export R2_SECRET_ACCESS_KEY="{{ dopplerProjectJson.R2_SECRET_ACCESS_KEY }}" +export R2_BUCKET="{{ dopplerProjectJson.R2_BUCKET }}" + # hishtory configuration export HISHTORY_SERVER="https://hsh.{{ dopplerProjectJson.PRIVATE_DOMAIN }}" diff --git a/home/dot_config/fish/conf.d/abbr.fish.tmpl b/home/dot_config/fish/conf.d/abbr.fish.tmpl index 39fa3f6..fe8a536 100644 --- a/home/dot_config/fish/conf.d/abbr.fish.tmpl +++ b/home/dot_config/fish/conf.d/abbr.fish.tmpl @@ -30,7 +30,9 @@ abbr -a romanlog "ssh roman 'tail -F /var/log/syslog' --lines 100" # Other aliases abbr -a oc 'opencode' abbr -a cl 'claude' +abbr -a cope 'claude --model opus' abbr -a gpt 'chatgpt' +abbr -a share 'share.ts' abbr -a copilot 'gh copilot' abbr -a suggest 'gh copilot suggest -t shell' abbr -a spt 'spotify_player' diff --git a/home/dot_local/bin/executable_claude-usage b/home/dot_local/bin/executable_claude-usage index 9ef8a66..4a03951 100644 --- a/home/dot_local/bin/executable_claude-usage +++ b/home/dot_local/bin/executable_claude-usage @@ -164,7 +164,7 @@ async function readToken(): Promise { /** * Fetch usage data by making an API request and reading rate limit headers - * This request matches what Claude Code sends for checking quota + * This request matches what Claude Code sends for checking limit */ async function fetchUsage(accessToken: string): Promise { const userId = generateUserId(accessToken); @@ -175,7 +175,7 @@ async function fetchUsage(accessToken: string): Promise { messages: [ { role: 'user', - content: 'quota', + content: 'limit', }, ], metadata: { @@ -206,7 +206,7 @@ async function fetchUsage(accessToken: string): Promise { console.log(); } - // Make the exact same request Claude Code makes for checking quota + // Make the exact same request Claude Code makes for checking limit const response = await fetch('https://api.anthropic.com/v1/messages?beta=true', { method: 'POST', headers: { @@ -269,14 +269,14 @@ async function fetchUsage(accessToken: string): Promise { } /** - * Get pastel color for ahead/behind pace difference + * Get pastel color for ahead/under pace difference */ function getDiffColor(diffPct: number): string { if (diffPct > 15) return '#E89999'; // Soft red - way ahead if (diffPct > 5) return '#F4B8A4'; // Soft orange - ahead if (diffPct >= -5) return '#E8D4A2'; // Soft yellow - on track - if (diffPct >= -15) return '#B8D99A'; // Soft lime - behind (good) - return '#A5D8DD'; // Soft cyan - well under + if (diffPct >= -15) return '#B8D99A'; // Soft lime - below (good) + return '#A5D8DD'; // Soft cyan - well under limit } /** @@ -301,7 +301,7 @@ function getPaceStatus(diffPct: number): string { if (diffPct > 5) return 'slightly elevated'; if (diffPct >= -5) return 'on track'; if (diffPct >= -15) return 'comfortable margin'; - return 'well under quota'; + return 'well under limit'; } /** @@ -464,8 +464,8 @@ function formatOutput(usage: UsageData): void { const bulletColor = chalk.hex('#9CA3AF'); // Gray 400 - bullets console.log(headerColor('Usage:')); - console.log(` ${labelColor('Hourly (5h)')} ${bulletColor('·')} ${chalk.hex(fiveHourColor)(fiveHourPctStr)} ${bulletColor('•')} ${chalk.hex(fiveHourDiffColor)(fiveHourPace.status)} ${chalk.hex(fiveHourDiffColor)(`(${fiveHourDiffStr} ${fiveHourPace.diff >= 0 ? 'ahead' : 'behind'})`)} ${bulletColor('•')} ${chalk.hex(fiveHourResetColor)(fiveHourReset)}`); - console.log(` ${labelColor('Weekly (7d)')} ${bulletColor('·')} ${chalk.hex(sevenDayColor)(sevenDayPctStr)} ${bulletColor('•')} ${chalk.hex(sevenDayDiffColor)(sevenDayPace.status)} ${chalk.hex(sevenDayDiffColor)(`(${sevenDayDiffStr} ${sevenDayPace.diff >= 0 ? 'ahead' : 'behind'})`)} ${bulletColor('•')} ${chalk.hex(sevenDayResetColor)(sevenDayReset)}`); + console.log(` ${labelColor('Hourly (5h)')} ${bulletColor('·')} ${chalk.hex(fiveHourColor)(fiveHourPctStr)} ${bulletColor('•')} ${chalk.hex(fiveHourDiffColor)(fiveHourPace.status)} ${chalk.hex(fiveHourDiffColor)(`(${fiveHourDiffStr} ${fiveHourPace.diff >= 0 ? 'ahead' : 'under'})`)} ${bulletColor('•')} ${chalk.hex(fiveHourResetColor)(fiveHourReset)}`); + console.log(` ${labelColor('Weekly (7d)')} ${bulletColor('·')} ${chalk.hex(sevenDayColor)(sevenDayPctStr)} ${bulletColor('•')} ${chalk.hex(sevenDayDiffColor)(sevenDayPace.status)} ${chalk.hex(sevenDayDiffColor)(`(${sevenDayDiffStr} ${sevenDayPace.diff >= 0 ? 'ahead' : 'below'})`)} ${bulletColor('•')} ${chalk.hex(sevenDayResetColor)(sevenDayReset)}`); } /** diff --git a/home/dot_local/bin/executable_share.ts b/home/dot_local/bin/executable_share.ts new file mode 100755 index 0000000..447e95a --- /dev/null +++ b/home/dot_local/bin/executable_share.ts @@ -0,0 +1,822 @@ +#!/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 TimeoutHandle = ReturnType; +type IntervalHandle = ReturnType; + +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, +}; + +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 = { + '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', + '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 = { + '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', + '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 { + 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 { + 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 { + 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 { + 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 { + 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 { + process.stdout.write(`${chalk.hex(COLORS.label)('?')} ${message} ${chalk.hex(COLORS.dim)('(y/n)')}: `); + + const input = await new Promise((resolve) => { + process.stdin.resume(); + process.stdin.once('data', (data) => { + process.stdin.pause(); + resolve(data.toString().trim()); + }); + }); + + return TRUTHY.includes(input); +} + +async function promptExtension(): Promise { + while (true) { + process.stdout.write(`${chalk.hex(COLORS.label)('?')} File extension ${chalk.hex(COLORS.dim)('(default: .txt)')}: `); + + const input = await new Promise((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 { + for (const path of paths) { + try { + await $`rm -f ${path}`.quiet(); + } catch { + debug('Failed to cleanup temp file', path); + } + } +} + +async function convertImage(buffer: Buffer, fromMime: string, shouldConvert: boolean): Promise { + 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 { + 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 { + 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' }, + 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 (ImageMagick/ffmpeg) + -y, --yes Skip confirmation prompts + -n, --name Custom filename (without extension) + -h, --help Show this help + +${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 then upload + +${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}`; + } + + 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); + } + } + } + + if (isImageMime(mimeType)) { + buffer = await convertImage(buffer, mimeType, values.convert || false); + if (mimeType === 'image/bmp') { + mimeType = 'image/png'; + } else if (values.convert && (mimeType === 'image/heic' || mimeType === 'image/heif')) { + mimeType = 'image/jpeg'; + } + } else if (isVideoMime(mimeType) && values.convert) { + 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();