quickly upload files to a remote server via rsync
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Initial commit

eti 80c5b130

+1402
+56
README.md
··· 1 + # hop 2 + 3 + Quick file upload to remote server via rsync. 4 + 5 + ## Installation 6 + 7 + ```bash 8 + bun install 9 + bun run build 10 + # Move the binary to your PATH 11 + mv hop /usr/local/bin/ 12 + ``` 13 + 14 + ## Configuration 15 + 16 + On first run, hop will create a config file at `~/.config/hop/config.json`: 17 + 18 + ```json 19 + { 20 + "server": "user@your-server.com", 21 + "remotePath": "/home/user/serve/x", 22 + "baseUrl": "https://x.eti.tf" 23 + } 24 + ``` 25 + 26 + Edit this file with your server details. 27 + 28 + ## Usage 29 + 30 + ```bash 31 + hop [-v|--verbose] path/to/file.png 32 + ``` 33 + 34 + Options: 35 + - `-v`, `--verbose` - Show rsync command and stderr output for debugging 36 + 37 + Output: 38 + ``` 39 + ✓ uploaded file.png to https://x.eti.tf/a1b2c3d.png 40 + ``` 41 + 42 + The URL is also copied to your clipboard automatically. 43 + 44 + ## Features 45 + 46 + - **SHA256 hash-based filenames** - Content-addressable storage with 7-character hashes 47 + - **Rsync upload with progress** - Visual spinner showing upload progress 48 + - **Detailed error messages** - Human-readable rsync error codes (permission denied, connection failed, etc.) 49 + - **Automatic clipboard copy** - URL copied to clipboard on macOS (via pbcopy) 50 + - **Verbose mode** - Debug rsync issues with `-v` flag 51 + 52 + ## How It Works 53 + 54 + 1. Generates SHA256 hash of file content (truncated to 7 chars) 55 + 2. Uploads via rsync with progress display 56 + 3. Outputs and copies the public URL
+64
bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "workspaces": { 4 + "": { 5 + "name": "hop", 6 + "dependencies": { 7 + "ora": "^8.0.0", 8 + }, 9 + "devDependencies": { 10 + "@types/bun": "latest", 11 + }, 12 + "peerDependencies": { 13 + "typescript": "^5.0.0", 14 + }, 15 + }, 16 + }, 17 + "packages": { 18 + "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], 19 + 20 + "@types/node": ["@types/node@25.2.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="], 21 + 22 + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], 23 + 24 + "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], 25 + 26 + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], 27 + 28 + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], 29 + 30 + "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], 31 + 32 + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], 33 + 34 + "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], 35 + 36 + "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], 37 + 38 + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], 39 + 40 + "log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], 41 + 42 + "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], 43 + 44 + "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], 45 + 46 + "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], 47 + 48 + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], 49 + 50 + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], 51 + 52 + "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], 53 + 54 + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], 55 + 56 + "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], 57 + 58 + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 59 + 60 + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 61 + 62 + "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], 63 + } 64 + }
+21
package.json
··· 1 + { 2 + "name": "hop", 3 + "version": "1.0.0", 4 + "description": "Quick file upload to remote server via rsync", 5 + "module": "src/index.ts", 6 + "type": "module", 7 + "scripts": { 8 + "dev": "bun run src/index.ts", 9 + "build": "bun build --compile --outfile hop src/index.ts", 10 + "test": "bun test" 11 + }, 12 + "dependencies": { 13 + "ora": "^8.0.0" 14 + }, 15 + "devDependencies": { 16 + "@types/bun": "latest" 17 + }, 18 + "peerDependencies": { 19 + "typescript": "^5.0.0" 20 + } 21 + }
+38
src/clipboard.ts
··· 1 + import { spawn } from "child_process"; 2 + import { PBCOPY_TIMEOUT_MS } from "./constants"; 3 + 4 + /** 5 + * Copies text to the system clipboard using pbcopy (macOS) 6 + * @param text The text to copy to clipboard 7 + * @returns Promise that resolves when copy is complete 8 + * @throws Error if pbcopy fails or times out 9 + */ 10 + export async function copyToClipboard(text: string): Promise<void> { 11 + return new Promise((resolve, reject) => { 12 + const pbcopy = spawn("pbcopy"); 13 + let timeoutId: Timer | null = null; 14 + 15 + pbcopy.stdin.write(text); 16 + pbcopy.stdin.end(); 17 + 18 + pbcopy.on("close", (code) => { 19 + if (timeoutId) clearTimeout(timeoutId); 20 + if (code === 0) { 21 + resolve(); 22 + } else { 23 + reject(new Error(`pbcopy exited with code ${code}`)); 24 + } 25 + }); 26 + 27 + pbcopy.on("error", (err) => { 28 + if (timeoutId) clearTimeout(timeoutId); 29 + reject(err); 30 + }); 31 + 32 + // Add timeout for clipboard operation 33 + timeoutId = setTimeout(() => { 34 + pbcopy.kill("SIGTERM"); 35 + reject(new Error("pbcopy timed out")); 36 + }, PBCOPY_TIMEOUT_MS); 37 + }); 38 + }
+57
src/config.test.ts
··· 1 + import { describe, test, expect } from "bun:test"; 2 + import { type Config } from "./config"; 3 + 4 + describe("loadConfig", () => { 5 + test("creates default config when file doesn't exist", async () => { 6 + // This test would need to mock the actual config path 7 + // For now, we test the structure 8 + const defaultConfig: Config = { 9 + server: "user@your-server.com", 10 + remotePath: "/home/user/serve/x", 11 + baseUrl: "https://x.eti.tf", 12 + }; 13 + 14 + expect(defaultConfig.server).toBe("user@your-server.com"); 15 + expect(defaultConfig.remotePath).toBe("/home/user/serve/x"); 16 + expect(defaultConfig.baseUrl).toBe("https://x.eti.tf"); 17 + }); 18 + 19 + test("valid config structure has required fields", () => { 20 + const validConfig: Config = { 21 + server: "test@example.com", 22 + remotePath: "/path/to/files", 23 + baseUrl: "https://files.example.com", 24 + }; 25 + 26 + expect(validConfig.server).toBeDefined(); 27 + expect(validConfig.remotePath).toBeDefined(); 28 + expect(validConfig.baseUrl).toBeDefined(); 29 + }); 30 + 31 + test("rejects config missing server", () => { 32 + const invalidConfig = { 33 + remotePath: "/path", 34 + baseUrl: "https://example.com", 35 + }; 36 + 37 + expect((invalidConfig as Config).server).toBeUndefined(); 38 + }); 39 + 40 + test("rejects config missing remotePath", () => { 41 + const invalidConfig = { 42 + server: "user@example.com", 43 + baseUrl: "https://example.com", 44 + }; 45 + 46 + expect((invalidConfig as Config).remotePath).toBeUndefined(); 47 + }); 48 + 49 + test("rejects config missing baseUrl", () => { 50 + const invalidConfig = { 51 + server: "user@example.com", 52 + remotePath: "/path", 53 + }; 54 + 55 + expect((invalidConfig as Config).baseUrl).toBeUndefined(); 56 + }); 57 + });
+60
src/config.ts
··· 1 + import { mkdir } from "fs/promises"; 2 + import { existsSync } from "fs"; 3 + import { homedir } from "os"; 4 + import { join } from "path"; 5 + 6 + /** 7 + * Configuration interface for hop 8 + */ 9 + export interface Config { 10 + /** SSH server in format "user@host" */ 11 + server: string; 12 + /** Remote path where files will be uploaded */ 13 + remotePath: string; 14 + /** Base URL for accessing uploaded files */ 15 + baseUrl: string; 16 + } 17 + 18 + const CONFIG_DIR = join(homedir(), ".config", "hop"); 19 + const CONFIG_PATH = join(CONFIG_DIR, "config.json"); 20 + 21 + const DEFAULT_CONFIG: Config = { 22 + server: "user@your-server.com", 23 + remotePath: "/home/user/serve/x", 24 + baseUrl: "https://x.eti.tf", 25 + }; 26 + 27 + const CONFIG_TEMPLATE = JSON.stringify(DEFAULT_CONFIG, null, 2); 28 + 29 + /** 30 + * Loads configuration from ~/.config/hop/config.json 31 + * Creates a default config file if it doesn't exist 32 + * @returns The loaded configuration 33 + * @throws Exits process if config is missing or invalid 34 + */ 35 + export async function loadConfig(): Promise<Config> { 36 + if (!existsSync(CONFIG_PATH)) { 37 + await mkdir(CONFIG_DIR, { recursive: true }); 38 + await Bun.write(CONFIG_PATH, CONFIG_TEMPLATE); 39 + console.error("Created default config at:", CONFIG_PATH); 40 + console.error("Please edit it with your server details."); 41 + process.exit(1); 42 + } 43 + 44 + try { 45 + const content = await Bun.file(CONFIG_PATH).text(); 46 + const config = JSON.parse(content) as Config; 47 + 48 + if (!config.server || !config.remotePath || !config.baseUrl) { 49 + console.error("Invalid config: missing required fields"); 50 + console.error("Config path:", CONFIG_PATH); 51 + process.exit(1); 52 + } 53 + 54 + return config; 55 + } catch (error) { 56 + console.error("Failed to load config:", error); 57 + console.error("Config path:", CONFIG_PATH); 58 + process.exit(1); 59 + } 60 + }
+25
src/constants.ts
··· 1 + /** Application constants */ 2 + 3 + /** Length of hash to use for filenames (truncated from SHA256 hex) */ 4 + export const HASH_LENGTH = 7; 5 + 6 + /** Video quality CRF (Constant Rate Factor) for ffmpeg VP9 encoding 7 + * Lower values = higher quality, larger file size 8 + * 30 is a good balance for screen recordings 9 + */ 10 + export const VIDEO_QUALITY_CRF = 30; 11 + 12 + /** Maximum filename length (filesystem limit for most systems) */ 13 + export const MAX_FILENAME_LENGTH = 255; 14 + 15 + /** Timeout for pbcopy command (clipboard operations) in milliseconds */ 16 + export const PBCOPY_TIMEOUT_MS = 5000; 17 + 18 + /** Timeout for defaults command (reading macOS preferences) in milliseconds */ 19 + export const DEFAULTS_TIMEOUT_MS = 5000; 20 + 21 + /** Timeout for screencapture screenshot command in milliseconds */ 22 + export const SCREENSHOT_TIMEOUT_MS = 60000; 23 + 24 + /** Timeout for ffprobe command (getting video metadata) in milliseconds */ 25 + export const FFPROBE_TIMEOUT_MS = 30000;
+93
src/hash.test.ts
··· 1 + import { describe, test, expect, beforeAll, afterAll } from "bun:test"; 2 + import { generateHash, getFilename } from "./hash"; 3 + import { mkdtempSync, writeFileSync, rmSync } from "fs"; 4 + import { tmpdir } from "os"; 5 + import { join } from "path"; 6 + 7 + describe("generateHash", () => { 8 + let tempDir: string; 9 + let testFile: string; 10 + 11 + beforeAll(() => { 12 + tempDir = mkdtempSync(join(tmpdir(), "hop-test-")); 13 + testFile = join(tempDir, "test.txt"); 14 + writeFileSync(testFile, "Hello, World!"); 15 + }); 16 + 17 + afterAll(() => { 18 + rmSync(tempDir, { recursive: true }); 19 + }); 20 + 21 + test("generates consistent hash for same content", async () => { 22 + const hash1 = await generateHash(testFile); 23 + const hash2 = await generateHash(testFile); 24 + expect(hash1).toBe(hash2); 25 + }); 26 + 27 + test("generates different hash for different content", async () => { 28 + const hash1 = await generateHash(testFile); 29 + 30 + const testFile2 = join(tempDir, "test2.txt"); 31 + writeFileSync(testFile2, "Different content"); 32 + const hash2 = await generateHash(testFile2); 33 + 34 + expect(hash1).not.toBe(hash2); 35 + rmSync(testFile2); 36 + }); 37 + 38 + test("returns 7 character hash", async () => { 39 + const hash = await generateHash(testFile); 40 + expect(hash.length).toBe(7); 41 + expect(hash).toMatch(/^[a-f0-9]{7}$/); 42 + }); 43 + 44 + test("generates correct SHA256 hash (truncated)", async () => { 45 + const hash = await generateHash(testFile); 46 + // SHA256 of "Hello, World!" is dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f 47 + // First 7 chars: dffd602 48 + expect(hash).toBe("dffd602"); 49 + }); 50 + }); 51 + 52 + describe("getFilename", () => { 53 + test("returns custom filename when provided", () => { 54 + const result = getFilename("/path/to/file.txt", "abc1234", false, "custom.png"); 55 + expect(result).toBe("custom.png"); 56 + }); 57 + 58 + test("returns original filename when keepFilename is true", () => { 59 + const result = getFilename("/path/to/file.txt", "abc1234", true, null); 60 + expect(result).toBe("file.txt"); 61 + }); 62 + 63 + test("returns hash with extension by default", () => { 64 + const result = getFilename("/path/to/file.txt", "abc1234", false, null); 65 + expect(result).toBe("abc1234.txt"); 66 + }); 67 + 68 + test("returns just hash when no extension", () => { 69 + const result = getFilename("/path/to/file", "abc1234", false, null); 70 + expect(result).toBe("abc1234"); 71 + }); 72 + 73 + test("handles multiple dots in filename (uses last extension)", () => { 74 + const result = getFilename("/path/to/file.tar.gz", "abc1234", false, null); 75 + // extname() returns only the last extension (.gz) 76 + expect(result).toBe("abc1234.gz"); 77 + }); 78 + 79 + test("custom filename takes precedence over keepFilename", () => { 80 + const result = getFilename("/path/to/file.txt", "abc1234", true, "custom.png"); 81 + expect(result).toBe("custom.png"); 82 + }); 83 + 84 + test("handles files in root directory", () => { 85 + const result = getFilename("/file.txt", "abc1234", false, null); 86 + expect(result).toBe("abc1234.txt"); 87 + }); 88 + 89 + test("handles relative paths", () => { 90 + const result = getFilename("./file.txt", "abc1234", false, null); 91 + expect(result).toBe("abc1234.txt"); 92 + }); 93 + });
+41
src/hash.ts
··· 1 + import { createHash } from "crypto"; 2 + import { extname, basename } from "path"; 3 + import { HASH_LENGTH } from "./constants"; 4 + 5 + /** 6 + * Generates a SHA256 hash of a file's content 7 + * @param filePath Path to the file to hash 8 + * @returns Promise resolving to a truncated hash string 9 + */ 10 + export async function generateHash(filePath: string): Promise<string> { 11 + const file = Bun.file(filePath); 12 + const content = await file.arrayBuffer(); 13 + const hash = createHash("sha256").update(Buffer.from(content)).digest("hex"); 14 + return hash.slice(0, HASH_LENGTH); 15 + } 16 + 17 + /** 18 + * Determines the output filename based on options 19 + * @param filePath Original file path 20 + * @param hash Generated hash for the file 21 + * @param keepFilename Whether to keep the original filename 22 + * @param customFilename Custom filename to use (overrides other options) 23 + * @returns The final filename to use 24 + */ 25 + export function getFilename( 26 + filePath: string, 27 + hash: string, 28 + keepFilename: boolean = false, 29 + customFilename: string | null = null 30 + ): string { 31 + if (customFilename) { 32 + return customFilename; 33 + } 34 + 35 + if (keepFilename) { 36 + return basename(filePath); 37 + } 38 + 39 + const extension = extname(filePath); 40 + return extension ? `${hash}${extension}` : hash; 41 + }
+199
src/index.ts
··· 1 + #!/usr/bin/env bun 2 + import { existsSync } from "fs"; 3 + import { loadConfig } from "./config"; 4 + import { generateHash, getFilename } from "./hash"; 5 + import { uploadWithProgress } from "./upload"; 6 + import { copyToClipboard } from "./clipboard"; 7 + import { captureScreenshot } from "./screenshot"; 8 + import { captureRecording } from "./record"; 9 + import { validateFilename } from "./utils"; 10 + 11 + interface ParsedArgs { 12 + filePath: string | null; 13 + verbose: boolean; 14 + keepFilename: boolean; 15 + customFilename: string | null; 16 + showHelp: boolean; 17 + screenshot: boolean; 18 + screenshotRegion: boolean; 19 + record: boolean; 20 + recordAudio: boolean; 21 + } 22 + 23 + function printHelp(): void { 24 + console.log(`hop - Quick file upload to remote server via rsync 25 + 26 + Usage: hop [options] <file> 27 + 28 + Options: 29 + -h, --help Show this help message and exit 30 + -v, --verbose Enable verbose output 31 + -k, --keep-filename Keep original filename (don't hash) 32 + -f, --filename <name> Use custom filename 33 + -s, --screenshot Capture fullscreen and upload 34 + -sr, --screenshot-region Capture region/window and upload 35 + -r, --record Record screen (silent) 36 + -ra, --record-audio Record with microphone audio`); 37 + } 38 + 39 + function parseArgs(): ParsedArgs { 40 + const args = process.argv.slice(2); 41 + let verbose = false; 42 + let keepFilename = false; 43 + let customFilename: string | null = null; 44 + let showHelp = false; 45 + let screenshot = false; 46 + let screenshotRegion = false; 47 + let record = false; 48 + let recordAudio = false; 49 + const fileArgs: string[] = []; 50 + 51 + for (let i = 0; i < args.length; i++) { 52 + const arg = args[i]; 53 + if (arg === "-h" || arg === "--help") { 54 + showHelp = true; 55 + } else if (arg === "-v" || arg === "--verbose") { 56 + verbose = true; 57 + } else if (arg === "-k" || arg === "--keep-filename") { 58 + keepFilename = true; 59 + } else if (arg === "-f" || arg === "--filename") { 60 + if (i + 1 < args.length && !args[i + 1].startsWith("-")) { 61 + customFilename = args[++i]; 62 + } else { 63 + console.error(`Error: ${arg} requires a filename argument`); 64 + process.exit(1); 65 + } 66 + } else if (arg === "-sr" || arg === "--screenshot-region") { 67 + screenshotRegion = true; 68 + } else if (arg === "-s" || arg === "--screenshot") { 69 + screenshot = true; 70 + } else if (arg === "-ra" || arg === "--record-audio") { 71 + recordAudio = true; 72 + } else if (arg === "-r" || arg === "--record") { 73 + record = true; 74 + } else if (!arg.startsWith("-")) { 75 + fileArgs.push(arg); 76 + } 77 + } 78 + 79 + return { filePath: fileArgs[0] || null, verbose, keepFilename, customFilename, showHelp, screenshot, screenshotRegion, record, recordAudio }; 80 + } 81 + 82 + async function main() { 83 + let { filePath, verbose, keepFilename, customFilename, showHelp, screenshot, screenshotRegion, record, recordAudio } = parseArgs(); 84 + 85 + if (showHelp) { 86 + printHelp(); 87 + process.exit(0); 88 + } 89 + 90 + // Load config first (creates template if missing) 91 + const config = await loadConfig(); 92 + 93 + // Validate screenshot options 94 + if (screenshot && screenshotRegion) { 95 + console.error("Error: cannot use both --screenshot and --screenshot-region together"); 96 + process.exit(1); 97 + } 98 + 99 + // Validate recording options 100 + if (record && recordAudio) { 101 + console.error("Error: cannot use both --record and --record-audio together"); 102 + process.exit(1); 103 + } 104 + 105 + // Validate mutual exclusivity between screenshot and recording 106 + if ((screenshot || screenshotRegion) && (record || recordAudio)) { 107 + console.error("Error: cannot use screenshot and recording options together"); 108 + process.exit(1); 109 + } 110 + 111 + if ((screenshot || screenshotRegion || record || recordAudio) && filePath) { 112 + console.error("Error: cannot specify a file with screenshot or recording options"); 113 + process.exit(1); 114 + } 115 + 116 + // Capture screenshot if requested 117 + if (screenshot || screenshotRegion) { 118 + const captured = await captureScreenshot({ region: screenshotRegion }); 119 + if (!captured) { 120 + // User cancelled - silent exit 121 + process.exit(0); 122 + } 123 + filePath = captured; 124 + } 125 + 126 + // Capture recording if requested 127 + if (record || recordAudio) { 128 + const captured = await captureRecording({ 129 + audio: recordAudio 130 + }); 131 + if (!captured) { 132 + // User cancelled or conversion failed - silent exit 133 + process.exit(0); 134 + } 135 + filePath = captured; 136 + } 137 + 138 + if (!filePath) { 139 + console.error("Error: No file specified"); 140 + console.error("Run 'hop --help' for usage information"); 141 + process.exit(1); 142 + } 143 + 144 + if (keepFilename && customFilename) { 145 + console.error("Error: cannot use both --keep-filename and --filename together"); 146 + process.exit(1); 147 + } 148 + 149 + // Validate custom filename if provided 150 + if (customFilename) { 151 + const validation = validateFilename(customFilename); 152 + if (!validation.ok) { 153 + const errorMessages: Record<string, string> = { 154 + empty: "Filename cannot be empty", 155 + too_long: "Filename too long (max 255 characters)", 156 + path_traversal: "Filename cannot contain path traversal characters (.., /, \\)", 157 + invalid_chars: "Filename contains invalid characters" 158 + }; 159 + console.error(`Error: ${errorMessages[validation.error] || "Invalid filename"}`); 160 + process.exit(1); 161 + } 162 + customFilename = validation.value; 163 + } 164 + 165 + if (!existsSync(filePath)) { 166 + console.error(`File not found: ${filePath}`); 167 + process.exit(1); 168 + } 169 + const hash = await generateHash(filePath); 170 + const filename = getFilename(filePath, hash, keepFilename, customFilename); 171 + const remoteDest = `${config.server}:${config.remotePath}/${filename}`; 172 + const originalName = filePath.split('/').pop() || filename; 173 + 174 + try { 175 + await uploadWithProgress(filePath, remoteDest, originalName, verbose); 176 + } catch (error) { 177 + // Error already displayed by spinner, but log to stderr for debugging 178 + if (verbose && error instanceof Error) { 179 + console.error("Upload error:", error.message); 180 + console.error(error.stack); 181 + } 182 + process.exit(1); 183 + } 184 + 185 + const url = `${config.baseUrl}/${encodeURI(filename)}`; 186 + console.log(`✓ uploaded ${originalName} to ${url}`); 187 + 188 + try { 189 + await copyToClipboard(url); 190 + } catch (error) { 191 + // Log clipboard errors in verbose mode 192 + if (verbose && error instanceof Error) { 193 + console.error("Clipboard error:", error.message); 194 + } 195 + // Silently fail clipboard copy - URL is already printed 196 + } 197 + } 198 + 199 + main();
+291
src/record.ts
··· 1 + import { spawn } from "child_process"; 2 + import { join } from "path"; 3 + import { existsSync, unlinkSync } from "fs"; 4 + import readline from "readline"; 5 + import ora from "ora"; 6 + import { getCaptureDirectory, formatTimestamp } from "./utils"; 7 + import { VIDEO_QUALITY_CRF, FFPROBE_TIMEOUT_MS } from "./constants"; 8 + import { hasRawMode } from "./types"; 9 + 10 + /** Options for screen recording */ 11 + interface RecordingOptions { 12 + /** Whether to include microphone audio */ 13 + audio: boolean; 14 + } 15 + 16 + /** Recording state machine to prevent race conditions */ 17 + type RecordingState = "idle" | "recording" | "finishing" | "cancelled"; 18 + 19 + function generateRecordingFilename(extension: string): string { 20 + const timestamp = formatTimestamp(new Date()); 21 + return `Screen Recording ${timestamp}.${extension}`; 22 + } 23 + 24 + async function getVideoDuration(movPath: string): Promise<number | null> { 25 + return new Promise((resolve) => { 26 + const ffprobe = spawn("ffprobe", [ 27 + "-v", "error", 28 + "-show_entries", "format=duration", 29 + "-of", "default=noprint_wrappers=1:nokey=1", 30 + movPath 31 + ], { stdio: ["ignore", "pipe", "ignore"] }); 32 + 33 + let output = ""; 34 + let timeoutId: Timer | null = null; 35 + 36 + ffprobe.stdout.on("data", (data) => { 37 + output += data.toString(); 38 + }); 39 + 40 + ffprobe.on("close", (code) => { 41 + if (timeoutId) clearTimeout(timeoutId); 42 + if (code === 0) { 43 + const duration = parseFloat(output.trim()); 44 + resolve(isNaN(duration) ? null : duration); 45 + } else { 46 + resolve(null); 47 + } 48 + }); 49 + 50 + ffprobe.on("error", () => { 51 + if (timeoutId) clearTimeout(timeoutId); 52 + resolve(null); 53 + }); 54 + 55 + // Add timeout for ffprobe 56 + timeoutId = setTimeout(() => { 57 + ffprobe.kill("SIGTERM"); 58 + resolve(null); 59 + }, FFPROBE_TIMEOUT_MS); 60 + }); 61 + } 62 + 63 + async function convertToWebM( 64 + movPath: string, 65 + webmPath: string, 66 + hasAudio: boolean, 67 + spinner: ReturnType<typeof ora> 68 + ): Promise<boolean> { 69 + // Get duration first for progress tracking 70 + const totalDuration = await getVideoDuration(movPath); 71 + 72 + return new Promise((resolve) => { 73 + const args = [ 74 + "-i", movPath, 75 + "-c:v", "libvpx-vp9", 76 + "-preset", "slow", 77 + "-crf", String(VIDEO_QUALITY_CRF), 78 + "-b:v", "0" 79 + ]; 80 + 81 + if (hasAudio) { 82 + args.push("-c:a", "libopus"); 83 + args.push("-af", "volume=-0.3dB"); 84 + } else { 85 + args.push("-an"); 86 + } 87 + 88 + args.push("-y", webmPath); 89 + 90 + const proc = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] }); 91 + 92 + let stderrOutput = ""; 93 + 94 + proc.stderr.on("data", (data) => { 95 + const line = data.toString(); 96 + stderrOutput += line; 97 + 98 + // Parse progress 99 + const timeMatch = line.match(/time=(\d+):(\d+):(\d+\.\d+)/); 100 + if (timeMatch && totalDuration) { 101 + const hours = parseInt(timeMatch[1]); 102 + const minutes = parseInt(timeMatch[2]); 103 + const seconds = parseFloat(timeMatch[3]); 104 + const currentTime = hours * 3600 + minutes * 60 + seconds; 105 + const percentage = Math.min(100, Math.round((currentTime / totalDuration) * 100)); 106 + spinner.text = `Converting to webm... ${percentage}%`; 107 + } else if (timeMatch && !totalDuration) { 108 + // Fallback: show elapsed time if we don't have total duration 109 + const hours = parseInt(timeMatch[1]); 110 + const minutes = parseInt(timeMatch[2]); 111 + const seconds = parseFloat(timeMatch[3]); 112 + const timeStr = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${Math.floor(seconds).toString().padStart(2, "0")}`; 113 + spinner.text = `Converting to webm... ${timeStr}`; 114 + } 115 + }); 116 + 117 + proc.on("close", (code) => { 118 + if (code === 0) { 119 + spinner.text = "Converting to webm... 100%"; 120 + } 121 + resolve(code === 0); 122 + }); 123 + 124 + proc.on("error", () => { 125 + resolve(false); 126 + }); 127 + }); 128 + } 129 + 130 + /** 131 + * Records the screen using macOS screencapture and converts to WebM 132 + * @param options Recording options 133 + * @returns Promise resolving to WebM file path on success, null on cancellation or error 134 + */ 135 + export async function captureRecording( 136 + options: RecordingOptions 137 + ): Promise<string | null> { 138 + const dir = await getCaptureDirectory(); 139 + const movFilename = generateRecordingFilename("mov"); 140 + const webmFilename = generateRecordingFilename("webm"); 141 + const movPath = join(dir, movFilename); 142 + const webmPath = join(dir, webmFilename); 143 + 144 + const args = ["-v", "-x"]; 145 + 146 + if (options.audio) { 147 + args.push("-g"); 148 + } 149 + 150 + args.push(movPath); 151 + 152 + return new Promise((resolve) => { 153 + // Use state machine to prevent race conditions 154 + let state: RecordingState = "idle"; 155 + 156 + const spinner = ora({ 157 + spinner: "star2", 158 + text: options.audio ? "Recording screen with microphone. Hit enter when done" : "Recording screen. Hit enter when done" 159 + }).start(); 160 + 161 + // Pipe stdin to screencapture so it can wait for keypress to stop 162 + const screencapture = spawn("screencapture", args, { 163 + stdio: ["pipe", "ignore", "pipe"] 164 + }); 165 + 166 + state = "recording"; 167 + 168 + // Capture stderr to show errors if needed 169 + let screencaptureErrors = ""; 170 + screencapture.stderr?.on("data", (data) => { 171 + screencaptureErrors += data.toString(); 172 + }); 173 + 174 + const cleanup = (deleteMov: boolean) => { 175 + if (deleteMov && existsSync(movPath)) { 176 + try { 177 + unlinkSync(movPath); 178 + } catch { 179 + // Ignore cleanup errors 180 + } 181 + } 182 + }; 183 + 184 + const rl = readline.createInterface({ 185 + input: process.stdin, 186 + output: process.stdout 187 + }); 188 + 189 + // Only enable raw mode if stdin supports it (not in compiled binary) 190 + const stdinHasRawMode = hasRawMode(process.stdin); 191 + if (stdinHasRawMode) { 192 + process.stdin.setRawMode(true); 193 + process.stdin.resume(); 194 + } 195 + 196 + const handleKeypress = (key: Buffer) => { 197 + // State machine prevents race conditions 198 + if (state !== "recording") return; 199 + 200 + if (key.toString() === "\r" || key.toString() === "\n") { 201 + state = "finishing"; 202 + if (screencapture.stdin) { 203 + screencapture.stdin.write(key); 204 + screencapture.stdin.end(); 205 + } 206 + } 207 + }; 208 + 209 + process.stdin.on("data", handleKeypress); 210 + 211 + const sigintHandler = () => { 212 + // State machine prevents double-handling 213 + if (state !== "recording") return; 214 + 215 + state = "cancelled"; 216 + screencapture.kill("SIGTERM"); 217 + spinner.stop(); 218 + cleanup(true); 219 + process.removeListener("SIGINT", sigintHandler); 220 + process.stdin.removeListener("data", handleKeypress); 221 + if (stdinHasRawMode) { 222 + process.stdin.setRawMode(false); 223 + } 224 + process.stdin.pause(); 225 + rl.close(); 226 + resolve(null); 227 + }; 228 + 229 + process.on("SIGINT", sigintHandler); 230 + 231 + screencapture.on("close", async (code) => { 232 + process.stdin.removeListener("data", handleKeypress); 233 + process.removeListener("SIGINT", sigintHandler); 234 + if (stdinHasRawMode) { 235 + process.stdin.setRawMode(false); 236 + } 237 + process.stdin.pause(); 238 + rl.close(); 239 + 240 + if (state === "cancelled") { 241 + resolve(null); 242 + return; 243 + } 244 + 245 + if (code !== 0 && code !== null) { 246 + spinner.stop(); 247 + if (screencaptureErrors) { 248 + console.error(`\nError: ${screencaptureErrors.trim()}`); 249 + } 250 + cleanup(true); 251 + resolve(null); 252 + return; 253 + } 254 + 255 + if (!existsSync(movPath)) { 256 + spinner.stop(); 257 + cleanup(false); 258 + resolve(null); 259 + return; 260 + } 261 + 262 + spinner.text = "Converting to webm..."; 263 + 264 + const success = await convertToWebM(movPath, webmPath, options.audio, spinner); 265 + 266 + if (success) { 267 + cleanup(true); 268 + spinner.stop(); 269 + resolve(webmPath); 270 + } else { 271 + spinner.stop(); 272 + console.error(`\nError: Conversion to webm failed. Original saved to: ${movPath}`); 273 + resolve(null); 274 + } 275 + }); 276 + 277 + screencapture.on("error", (err) => { 278 + spinner.stop(); 279 + cleanup(true); 280 + process.stdin.removeListener("data", handleKeypress); 281 + process.removeListener("SIGINT", sigintHandler); 282 + if (stdinHasRawMode) { 283 + process.stdin.setRawMode(false); 284 + } 285 + process.stdin.pause(); 286 + rl.close(); 287 + console.error(`\nError: screencapture failed - ${err.message}`); 288 + resolve(null); 289 + }); 290 + }); 291 + }
+51
src/screenshot.ts
··· 1 + import { spawn } from "child_process"; 2 + import { join } from "path"; 3 + import { getCaptureDirectory, formatTimestamp } from "./utils"; 4 + 5 + /** Options for screenshot capture */ 6 + interface ScreenshotOptions { 7 + /** Whether to capture a region/window instead of fullscreen */ 8 + region: boolean; 9 + } 10 + 11 + /** 12 + * Captures a screenshot using macOS screencapture utility 13 + * @param options Screenshot capture options 14 + * @returns Promise resolving to file path on success, null on cancellation or error 15 + */ 16 + 17 + function generateScreenshotFilename(): string { 18 + const timestamp = formatTimestamp(new Date()); 19 + return `Screenshot ${timestamp}.png`; 20 + } 21 + 22 + export async function captureScreenshot( 23 + options: ScreenshotOptions 24 + ): Promise<string | null> { 25 + const dir = await getCaptureDirectory(); 26 + const filename = generateScreenshotFilename(); 27 + const filepath = join(dir, filename); 28 + 29 + const args = ["-t", "png", "-x"]; 30 + 31 + if (options.region) { 32 + args.push("-i"); 33 + args.push("-o"); 34 + } else { 35 + args.push("-m"); 36 + } 37 + 38 + args.push(filepath); 39 + 40 + return new Promise((resolve) => { 41 + const proc = spawn("screencapture", args, { stdio: "inherit" }); 42 + 43 + proc.on("close", (code) => { 44 + if (code === 0) { 45 + resolve(filepath); 46 + } else { 47 + resolve(null); 48 + } 49 + }); 50 + }); 51 + }
+17
src/types.ts
··· 1 + /** 2 + * Type definitions for Node.js stdin with raw mode support 3 + */ 4 + 5 + /** Interface for stdin that supports raw mode */ 6 + export interface RawModeStream { 7 + setRawMode(mode: boolean): void; 8 + } 9 + 10 + /** 11 + * Type guard to check if stdin supports raw mode 12 + * @param stdin The stdin stream to check 13 + * @returns True if stdin supports raw mode 14 + */ 15 + export function hasRawMode(stdin: NodeJS.ReadStream): stdin is NodeJS.ReadStream & RawModeStream { 16 + return "setRawMode" in stdin && typeof (stdin as RawModeStream).setRawMode === "function"; 17 + }
+42
src/upload.test.ts
··· 1 + import { describe, test, expect } from "bun:test"; 2 + import { uploadWithProgress } from "./upload"; 3 + 4 + describe("uploadWithProgress", () => { 5 + test("function signature is correct", () => { 6 + // Verify the function exists and has correct parameter count 7 + expect(typeof uploadWithProgress).toBe("function"); 8 + expect(uploadWithProgress.length).toBe(4); // localPath, remoteDest, originalName, verbose 9 + }); 10 + 11 + test("rsync error codes are handled", () => { 12 + const errorCodes: Record<number, string> = { 13 + 1: "syntax or usage error", 14 + 2: "protocol incompatibility", 15 + 3: "errors selecting input/output files", 16 + 4: "requested action not supported", 17 + 5: "error starting client-server protocol", 18 + 6: "daemon unable to append to log-file", 19 + 10: "error in socket I/O", 20 + 11: "input/output error", 21 + 12: "disk full on remote server", 22 + 13: "permission denied", 23 + 14: "protocol data stream error", 24 + 23: "partial transfer due to error", 25 + 24: "partial transfer due to vanished source files", 26 + 30: "timeout in data send/receive", 27 + 35: "timeout waiting for daemon connection", 28 + 255: "could not connect to server", 29 + }; 30 + 31 + // Verify all expected error codes are present 32 + expect(Object.keys(errorCodes).length).toBeGreaterThan(0); 33 + expect(errorCodes[13]).toBe("permission denied"); 34 + expect(errorCodes[255]).toBe("could not connect to server"); 35 + }); 36 + 37 + test("handles unknown error codes", () => { 38 + const unknownCode = 999; 39 + const expectedMessage = `rsync exited with code ${unknownCode}`; 40 + expect(expectedMessage).toBe("rsync exited with code 999"); 41 + }); 42 + });
+100
src/upload.ts
··· 1 + import { spawn } from "child_process"; 2 + import ora from "ora"; 3 + 4 + const RSYNC_ERRORS: Record<number, string> = { 5 + 1: "syntax or usage error", 6 + 2: "protocol incompatibility", 7 + 3: "errors selecting input/output files", 8 + 4: "requested action not supported", 9 + 5: "error starting client-server protocol", 10 + 6: "daemon unable to append to log-file", 11 + 10: "error in socket I/O", 12 + 11: "input/output error", 13 + 12: "disk full on remote server", 14 + 13: "permission denied", 15 + 14: "protocol data stream error", 16 + 23: "partial transfer due to error", 17 + 24: "partial transfer due to vanished source files", 18 + 30: "timeout in data send/receive", 19 + 35: "timeout waiting for daemon connection", 20 + 255: "could not connect to server", 21 + }; 22 + 23 + function getErrorMessage(code: number | null): string { 24 + if (code === null) return "unknown error"; 25 + return RSYNC_ERRORS[code] || `rsync exited with code ${code}`; 26 + } 27 + 28 + /** 29 + * Uploads a file to a remote server via rsync with progress display 30 + * @param localPath Path to the local file to upload 31 + * @param remoteDest Remote destination in format "server:path" 32 + * @param originalName Original filename for display purposes 33 + * @param verbose Whether to show verbose output 34 + * @returns Promise that resolves on success, rejects on failure 35 + * @throws Error if rsync fails 36 + */ 37 + export async function uploadWithProgress( 38 + localPath: string, 39 + remoteDest: string, 40 + originalName: string, 41 + verbose: boolean 42 + ): Promise<void> { 43 + const args = ["-avz", "--progress", localPath, remoteDest]; 44 + 45 + if (verbose) { 46 + console.log(`> rsync ${args.join(" ")}`); 47 + } 48 + 49 + const spinner = ora({ 50 + text: `uploading ${originalName}...`, 51 + spinner: "star2", 52 + color: "green", 53 + }).start(); 54 + 55 + const stderrBuffer: string[] = []; 56 + 57 + return new Promise((resolve, reject) => { 58 + const rsync = spawn("rsync", args); 59 + 60 + rsync.stdout.on("data", (data: Buffer) => { 61 + const lines = data.toString().split("\n"); 62 + for (const line of lines) { 63 + const match = line.match(/(\d+)%/); 64 + if (match) { 65 + const percent = match[1]; 66 + spinner.text = `uploading ${originalName}... ${percent}%`; 67 + } 68 + } 69 + }); 70 + 71 + rsync.stderr.on("data", (data: Buffer) => { 72 + const str = data.toString(); 73 + stderrBuffer.push(str); 74 + if (verbose) { 75 + spinner.clear(); 76 + process.stderr.write(str); 77 + } 78 + }); 79 + 80 + rsync.on("close", (code) => { 81 + if (code === 0) { 82 + spinner.stop(); 83 + resolve(); 84 + } else { 85 + const message = getErrorMessage(code); 86 + spinner.fail(`upload failed: ${message}`); 87 + // Show stderr buffer in verbose mode 88 + if (verbose && stderrBuffer.length > 0) { 89 + console.error(stderrBuffer.join("")); 90 + } 91 + reject(new Error(message)); 92 + } 93 + }); 94 + 95 + rsync.on("error", (err) => { 96 + spinner.fail(`upload failed: ${err.message}`); 97 + reject(err); 98 + }); 99 + }); 100 + }
+127
src/utils.test.ts
··· 1 + import { describe, test, expect } from "bun:test"; 2 + import { formatTimestamp, validateFilename } from "./utils"; 3 + 4 + describe("formatTimestamp", () => { 5 + test("formats date correctly", () => { 6 + const date = new Date(2024, 0, 15, 14, 30, 45); // Jan 15, 2024 14:30:45 7 + const result = formatTimestamp(date); 8 + expect(result).toBe("2024-01-15 at 14.30.45"); 9 + }); 10 + 11 + test("pads single digit values", () => { 12 + const date = new Date(2024, 2, 5, 9, 5, 5); // Mar 5, 2024 09:05:05 13 + const result = formatTimestamp(date); 14 + expect(result).toBe("2024-03-05 at 09.05.05"); 15 + }); 16 + 17 + test("handles midnight", () => { 18 + const date = new Date(2024, 0, 1, 0, 0, 0); 19 + const result = formatTimestamp(date); 20 + expect(result).toBe("2024-01-01 at 00.00.00"); 21 + }); 22 + 23 + test("handles end of day", () => { 24 + const date = new Date(2024, 0, 1, 23, 59, 59); 25 + const result = formatTimestamp(date); 26 + expect(result).toBe("2024-01-01 at 23.59.59"); 27 + }); 28 + }); 29 + 30 + describe("validateFilename", () => { 31 + test("accepts valid filename", () => { 32 + const result = validateFilename("my-file.txt"); 33 + expect(result.ok).toBe(true); 34 + if (result.ok) { 35 + expect(result.value).toBe("my-file.txt"); 36 + } 37 + }); 38 + 39 + test("accepts filename with spaces", () => { 40 + const result = validateFilename("my file.txt"); 41 + expect(result.ok).toBe(true); 42 + }); 43 + 44 + test("accepts unicode characters", () => { 45 + const result = validateFilename("文件.txt"); 46 + expect(result.ok).toBe(true); 47 + }); 48 + 49 + test("rejects empty string", () => { 50 + const result = validateFilename(""); 51 + expect(result.ok).toBe(false); 52 + if (!result.ok) { 53 + expect(result.error).toBe("empty"); 54 + } 55 + }); 56 + 57 + test("rejects whitespace-only string", () => { 58 + const result = validateFilename(" "); 59 + expect(result.ok).toBe(false); 60 + if (!result.ok) { 61 + expect(result.error).toBe("empty"); 62 + } 63 + }); 64 + 65 + test("rejects path traversal with ..", () => { 66 + const result = validateFilename("../etc/passwd"); 67 + expect(result.ok).toBe(false); 68 + if (!result.ok) { 69 + expect(result.error).toBe("path_traversal"); 70 + } 71 + }); 72 + 73 + test("rejects path traversal with /", () => { 74 + const result = validateFilename("/etc/passwd"); 75 + expect(result.ok).toBe(false); 76 + if (!result.ok) { 77 + expect(result.error).toBe("path_traversal"); 78 + } 79 + }); 80 + 81 + test("rejects path traversal with backslash", () => { 82 + const result = validateFilename("file\\..\\passwd"); 83 + expect(result.ok).toBe(false); 84 + if (!result.ok) { 85 + expect(result.error).toBe("path_traversal"); 86 + } 87 + }); 88 + 89 + test("rejects filename with null byte", () => { 90 + const result = validateFilename("file\x00.txt"); 91 + expect(result.ok).toBe(false); 92 + if (!result.ok) { 93 + expect(result.error).toBe("invalid_chars"); 94 + } 95 + }); 96 + 97 + test("rejects filename with control characters", () => { 98 + const result = validateFilename("file\x01.txt"); 99 + expect(result.ok).toBe(false); 100 + if (!result.ok) { 101 + expect(result.error).toBe("invalid_chars"); 102 + } 103 + }); 104 + 105 + test("rejects filename exceeding max length", () => { 106 + const longName = "a".repeat(256); 107 + const result = validateFilename(longName); 108 + expect(result.ok).toBe(false); 109 + if (!result.ok) { 110 + expect(result.error).toBe("too_long"); 111 + } 112 + }); 113 + 114 + test("accepts filename at max length", () => { 115 + const maxName = "a".repeat(255); 116 + const result = validateFilename(maxName); 117 + expect(result.ok).toBe(true); 118 + }); 119 + 120 + test("trims whitespace from valid filename", () => { 121 + const result = validateFilename(" file.txt "); 122 + expect(result.ok).toBe(true); 123 + if (result.ok) { 124 + expect(result.value).toBe("file.txt"); 125 + } 126 + }); 127 + });
+101
src/utils.ts
··· 1 + import { spawn } from "child_process"; 2 + import { join } from "path"; 3 + import { homedir } from "os"; 4 + import { DEFAULTS_TIMEOUT_MS } from "./constants"; 5 + 6 + /** Result type for operations that can fail */ 7 + export type Result<T, E> = 8 + | { ok: true; value: T } 9 + | { ok: false; error: E }; 10 + 11 + /** 12 + * Formats a date into macOS-style timestamp string 13 + * Format: "YYYY-MM-DD at HH.MM.SS" 14 + */ 15 + export function formatTimestamp(date: Date): string { 16 + const year = date.getFullYear(); 17 + const month = String(date.getMonth() + 1).padStart(2, "0"); 18 + const day = String(date.getDate()).padStart(2, "0"); 19 + const hours = String(date.getHours()).padStart(2, "0"); 20 + const minutes = String(date.getMinutes()).padStart(2, "0"); 21 + const seconds = String(date.getSeconds()).padStart(2, "0"); 22 + 23 + return `${year}-${month}-${day} at ${hours}.${minutes}.${seconds}`; 24 + } 25 + 26 + /** Validation errors for filenames */ 27 + export type FilenameValidationError = 28 + | "empty" 29 + | "too_long" 30 + | "path_traversal" 31 + | "invalid_chars"; 32 + 33 + /** 34 + * Validates a custom filename to prevent path traversal and invalid characters 35 + * @param filename The filename to validate 36 + * @returns Result with validated filename or error type 37 + */ 38 + export function validateFilename( 39 + filename: string 40 + ): Result<string, FilenameValidationError> { 41 + if (!filename || filename.trim() === "") { 42 + return { ok: false, error: "empty" }; 43 + } 44 + 45 + if (filename.length > 255) { 46 + return { ok: false, error: "too_long" }; 47 + } 48 + 49 + // Check for path traversal attempts 50 + if ( 51 + filename.includes("..") || 52 + filename.includes("/") || 53 + filename.includes("\\") 54 + ) { 55 + return { ok: false, error: "path_traversal" }; 56 + } 57 + 58 + // Check for null bytes and control characters 59 + if (/[\x00-\x1f\x7f-\x9f]/.test(filename)) { 60 + return { ok: false, error: "invalid_chars" }; 61 + } 62 + 63 + return { ok: true, value: filename.trim() }; 64 + } 65 + 66 + /** 67 + * Gets the macOS screenshot capture directory from system preferences 68 + * Falls back to Desktop if not set or command fails 69 + * @returns Promise resolving to directory path 70 + */ 71 + export async function getCaptureDirectory(): Promise<string> { 72 + return new Promise((resolve) => { 73 + const proc = spawn("defaults", ["read", "com.apple.screencapture", "location"]); 74 + let output = ""; 75 + let timeoutId: Timer | null = null; 76 + 77 + proc.stdout.on("data", (data) => { 78 + output += data.toString(); 79 + }); 80 + 81 + proc.on("close", (code) => { 82 + if (timeoutId) clearTimeout(timeoutId); 83 + if (code === 0 && output.trim()) { 84 + resolve(output.trim()); 85 + } else { 86 + resolve(join(homedir(), "Desktop")); 87 + } 88 + }); 89 + 90 + proc.on("error", () => { 91 + if (timeoutId) clearTimeout(timeoutId); 92 + resolve(join(homedir(), "Desktop")); 93 + }); 94 + 95 + // Add timeout for defaults command 96 + timeoutId = setTimeout(() => { 97 + proc.kill("SIGTERM"); 98 + resolve(join(homedir(), "Desktop")); 99 + }, DEFAULTS_TIMEOUT_MS); 100 + }); 101 + }
+19
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "lib": ["ESNext"], 4 + "target": "ESNext", 5 + "module": "ESNext", 6 + "moduleResolution": "bundler", 7 + "moduleDetection": "force", 8 + "jsx": "react-jsx", 9 + "allowImportingTsExtensions": true, 10 + "noEmit": true, 11 + "strict": true, 12 + "skipLibCheck": true, 13 + "noFallthroughCasesInSwitch": true, 14 + "noUnusedLocals": true, 15 + "noUnusedParameters": true, 16 + "noPropertyAccessFromIndexSignature": true 17 + }, 18 + "include": ["src/**/*"] 19 + }