···11+# hop
22+33+Quick file upload to remote server via rsync.
44+55+## Installation
66+77+```bash
88+bun install
99+bun run build
1010+# Move the binary to your PATH
1111+mv hop /usr/local/bin/
1212+```
1313+1414+## Configuration
1515+1616+On first run, hop will create a config file at `~/.config/hop/config.json`:
1717+1818+```json
1919+{
2020+ "server": "user@your-server.com",
2121+ "remotePath": "/home/user/serve/x",
2222+ "baseUrl": "https://x.eti.tf"
2323+}
2424+```
2525+2626+Edit this file with your server details.
2727+2828+## Usage
2929+3030+```bash
3131+hop [-v|--verbose] path/to/file.png
3232+```
3333+3434+Options:
3535+- `-v`, `--verbose` - Show rsync command and stderr output for debugging
3636+3737+Output:
3838+```
3939+✓ uploaded file.png to https://x.eti.tf/a1b2c3d.png
4040+```
4141+4242+The URL is also copied to your clipboard automatically.
4343+4444+## Features
4545+4646+- **SHA256 hash-based filenames** - Content-addressable storage with 7-character hashes
4747+- **Rsync upload with progress** - Visual spinner showing upload progress
4848+- **Detailed error messages** - Human-readable rsync error codes (permission denied, connection failed, etc.)
4949+- **Automatic clipboard copy** - URL copied to clipboard on macOS (via pbcopy)
5050+- **Verbose mode** - Debug rsync issues with `-v` flag
5151+5252+## How It Works
5353+5454+1. Generates SHA256 hash of file content (truncated to 7 chars)
5555+2. Uploads via rsync with progress display
5656+3. Outputs and copies the public URL
···11+/**
22+ * Type definitions for Node.js stdin with raw mode support
33+ */
44+55+/** Interface for stdin that supports raw mode */
66+export interface RawModeStream {
77+ setRawMode(mode: boolean): void;
88+}
99+1010+/**
1111+ * Type guard to check if stdin supports raw mode
1212+ * @param stdin The stdin stream to check
1313+ * @returns True if stdin supports raw mode
1414+ */
1515+export function hasRawMode(stdin: NodeJS.ReadStream): stdin is NodeJS.ReadStream & RawModeStream {
1616+ return "setRawMode" in stdin && typeof (stdin as RawModeStream).setRawMode === "function";
1717+}
+42
src/upload.test.ts
···11+import { describe, test, expect } from "bun:test";
22+import { uploadWithProgress } from "./upload";
33+44+describe("uploadWithProgress", () => {
55+ test("function signature is correct", () => {
66+ // Verify the function exists and has correct parameter count
77+ expect(typeof uploadWithProgress).toBe("function");
88+ expect(uploadWithProgress.length).toBe(4); // localPath, remoteDest, originalName, verbose
99+ });
1010+1111+ test("rsync error codes are handled", () => {
1212+ const errorCodes: Record<number, string> = {
1313+ 1: "syntax or usage error",
1414+ 2: "protocol incompatibility",
1515+ 3: "errors selecting input/output files",
1616+ 4: "requested action not supported",
1717+ 5: "error starting client-server protocol",
1818+ 6: "daemon unable to append to log-file",
1919+ 10: "error in socket I/O",
2020+ 11: "input/output error",
2121+ 12: "disk full on remote server",
2222+ 13: "permission denied",
2323+ 14: "protocol data stream error",
2424+ 23: "partial transfer due to error",
2525+ 24: "partial transfer due to vanished source files",
2626+ 30: "timeout in data send/receive",
2727+ 35: "timeout waiting for daemon connection",
2828+ 255: "could not connect to server",
2929+ };
3030+3131+ // Verify all expected error codes are present
3232+ expect(Object.keys(errorCodes).length).toBeGreaterThan(0);
3333+ expect(errorCodes[13]).toBe("permission denied");
3434+ expect(errorCodes[255]).toBe("could not connect to server");
3535+ });
3636+3737+ test("handles unknown error codes", () => {
3838+ const unknownCode = 999;
3939+ const expectedMessage = `rsync exited with code ${unknownCode}`;
4040+ expect(expectedMessage).toBe("rsync exited with code 999");
4141+ });
4242+});
+100
src/upload.ts
···11+import { spawn } from "child_process";
22+import ora from "ora";
33+44+const RSYNC_ERRORS: Record<number, string> = {
55+ 1: "syntax or usage error",
66+ 2: "protocol incompatibility",
77+ 3: "errors selecting input/output files",
88+ 4: "requested action not supported",
99+ 5: "error starting client-server protocol",
1010+ 6: "daemon unable to append to log-file",
1111+ 10: "error in socket I/O",
1212+ 11: "input/output error",
1313+ 12: "disk full on remote server",
1414+ 13: "permission denied",
1515+ 14: "protocol data stream error",
1616+ 23: "partial transfer due to error",
1717+ 24: "partial transfer due to vanished source files",
1818+ 30: "timeout in data send/receive",
1919+ 35: "timeout waiting for daemon connection",
2020+ 255: "could not connect to server",
2121+};
2222+2323+function getErrorMessage(code: number | null): string {
2424+ if (code === null) return "unknown error";
2525+ return RSYNC_ERRORS[code] || `rsync exited with code ${code}`;
2626+}
2727+2828+/**
2929+ * Uploads a file to a remote server via rsync with progress display
3030+ * @param localPath Path to the local file to upload
3131+ * @param remoteDest Remote destination in format "server:path"
3232+ * @param originalName Original filename for display purposes
3333+ * @param verbose Whether to show verbose output
3434+ * @returns Promise that resolves on success, rejects on failure
3535+ * @throws Error if rsync fails
3636+ */
3737+export async function uploadWithProgress(
3838+ localPath: string,
3939+ remoteDest: string,
4040+ originalName: string,
4141+ verbose: boolean
4242+): Promise<void> {
4343+ const args = ["-avz", "--progress", localPath, remoteDest];
4444+4545+ if (verbose) {
4646+ console.log(`> rsync ${args.join(" ")}`);
4747+ }
4848+4949+ const spinner = ora({
5050+ text: `uploading ${originalName}...`,
5151+ spinner: "star2",
5252+ color: "green",
5353+ }).start();
5454+5555+ const stderrBuffer: string[] = [];
5656+5757+ return new Promise((resolve, reject) => {
5858+ const rsync = spawn("rsync", args);
5959+6060+ rsync.stdout.on("data", (data: Buffer) => {
6161+ const lines = data.toString().split("\n");
6262+ for (const line of lines) {
6363+ const match = line.match(/(\d+)%/);
6464+ if (match) {
6565+ const percent = match[1];
6666+ spinner.text = `uploading ${originalName}... ${percent}%`;
6767+ }
6868+ }
6969+ });
7070+7171+ rsync.stderr.on("data", (data: Buffer) => {
7272+ const str = data.toString();
7373+ stderrBuffer.push(str);
7474+ if (verbose) {
7575+ spinner.clear();
7676+ process.stderr.write(str);
7777+ }
7878+ });
7979+8080+ rsync.on("close", (code) => {
8181+ if (code === 0) {
8282+ spinner.stop();
8383+ resolve();
8484+ } else {
8585+ const message = getErrorMessage(code);
8686+ spinner.fail(`upload failed: ${message}`);
8787+ // Show stderr buffer in verbose mode
8888+ if (verbose && stderrBuffer.length > 0) {
8989+ console.error(stderrBuffer.join(""));
9090+ }
9191+ reject(new Error(message));
9292+ }
9393+ });
9494+9595+ rsync.on("error", (err) => {
9696+ spinner.fail(`upload failed: ${err.message}`);
9797+ reject(err);
9898+ });
9999+ });
100100+}
+127
src/utils.test.ts
···11+import { describe, test, expect } from "bun:test";
22+import { formatTimestamp, validateFilename } from "./utils";
33+44+describe("formatTimestamp", () => {
55+ test("formats date correctly", () => {
66+ const date = new Date(2024, 0, 15, 14, 30, 45); // Jan 15, 2024 14:30:45
77+ const result = formatTimestamp(date);
88+ expect(result).toBe("2024-01-15 at 14.30.45");
99+ });
1010+1111+ test("pads single digit values", () => {
1212+ const date = new Date(2024, 2, 5, 9, 5, 5); // Mar 5, 2024 09:05:05
1313+ const result = formatTimestamp(date);
1414+ expect(result).toBe("2024-03-05 at 09.05.05");
1515+ });
1616+1717+ test("handles midnight", () => {
1818+ const date = new Date(2024, 0, 1, 0, 0, 0);
1919+ const result = formatTimestamp(date);
2020+ expect(result).toBe("2024-01-01 at 00.00.00");
2121+ });
2222+2323+ test("handles end of day", () => {
2424+ const date = new Date(2024, 0, 1, 23, 59, 59);
2525+ const result = formatTimestamp(date);
2626+ expect(result).toBe("2024-01-01 at 23.59.59");
2727+ });
2828+});
2929+3030+describe("validateFilename", () => {
3131+ test("accepts valid filename", () => {
3232+ const result = validateFilename("my-file.txt");
3333+ expect(result.ok).toBe(true);
3434+ if (result.ok) {
3535+ expect(result.value).toBe("my-file.txt");
3636+ }
3737+ });
3838+3939+ test("accepts filename with spaces", () => {
4040+ const result = validateFilename("my file.txt");
4141+ expect(result.ok).toBe(true);
4242+ });
4343+4444+ test("accepts unicode characters", () => {
4545+ const result = validateFilename("文件.txt");
4646+ expect(result.ok).toBe(true);
4747+ });
4848+4949+ test("rejects empty string", () => {
5050+ const result = validateFilename("");
5151+ expect(result.ok).toBe(false);
5252+ if (!result.ok) {
5353+ expect(result.error).toBe("empty");
5454+ }
5555+ });
5656+5757+ test("rejects whitespace-only string", () => {
5858+ const result = validateFilename(" ");
5959+ expect(result.ok).toBe(false);
6060+ if (!result.ok) {
6161+ expect(result.error).toBe("empty");
6262+ }
6363+ });
6464+6565+ test("rejects path traversal with ..", () => {
6666+ const result = validateFilename("../etc/passwd");
6767+ expect(result.ok).toBe(false);
6868+ if (!result.ok) {
6969+ expect(result.error).toBe("path_traversal");
7070+ }
7171+ });
7272+7373+ test("rejects path traversal with /", () => {
7474+ const result = validateFilename("/etc/passwd");
7575+ expect(result.ok).toBe(false);
7676+ if (!result.ok) {
7777+ expect(result.error).toBe("path_traversal");
7878+ }
7979+ });
8080+8181+ test("rejects path traversal with backslash", () => {
8282+ const result = validateFilename("file\\..\\passwd");
8383+ expect(result.ok).toBe(false);
8484+ if (!result.ok) {
8585+ expect(result.error).toBe("path_traversal");
8686+ }
8787+ });
8888+8989+ test("rejects filename with null byte", () => {
9090+ const result = validateFilename("file\x00.txt");
9191+ expect(result.ok).toBe(false);
9292+ if (!result.ok) {
9393+ expect(result.error).toBe("invalid_chars");
9494+ }
9595+ });
9696+9797+ test("rejects filename with control characters", () => {
9898+ const result = validateFilename("file\x01.txt");
9999+ expect(result.ok).toBe(false);
100100+ if (!result.ok) {
101101+ expect(result.error).toBe("invalid_chars");
102102+ }
103103+ });
104104+105105+ test("rejects filename exceeding max length", () => {
106106+ const longName = "a".repeat(256);
107107+ const result = validateFilename(longName);
108108+ expect(result.ok).toBe(false);
109109+ if (!result.ok) {
110110+ expect(result.error).toBe("too_long");
111111+ }
112112+ });
113113+114114+ test("accepts filename at max length", () => {
115115+ const maxName = "a".repeat(255);
116116+ const result = validateFilename(maxName);
117117+ expect(result.ok).toBe(true);
118118+ });
119119+120120+ test("trims whitespace from valid filename", () => {
121121+ const result = validateFilename(" file.txt ");
122122+ expect(result.ok).toBe(true);
123123+ if (result.ok) {
124124+ expect(result.value).toBe("file.txt");
125125+ }
126126+ });
127127+});
+101
src/utils.ts
···11+import { spawn } from "child_process";
22+import { join } from "path";
33+import { homedir } from "os";
44+import { DEFAULTS_TIMEOUT_MS } from "./constants";
55+66+/** Result type for operations that can fail */
77+export type Result<T, E> =
88+ | { ok: true; value: T }
99+ | { ok: false; error: E };
1010+1111+/**
1212+ * Formats a date into macOS-style timestamp string
1313+ * Format: "YYYY-MM-DD at HH.MM.SS"
1414+ */
1515+export function formatTimestamp(date: Date): string {
1616+ const year = date.getFullYear();
1717+ const month = String(date.getMonth() + 1).padStart(2, "0");
1818+ const day = String(date.getDate()).padStart(2, "0");
1919+ const hours = String(date.getHours()).padStart(2, "0");
2020+ const minutes = String(date.getMinutes()).padStart(2, "0");
2121+ const seconds = String(date.getSeconds()).padStart(2, "0");
2222+2323+ return `${year}-${month}-${day} at ${hours}.${minutes}.${seconds}`;
2424+}
2525+2626+/** Validation errors for filenames */
2727+export type FilenameValidationError =
2828+ | "empty"
2929+ | "too_long"
3030+ | "path_traversal"
3131+ | "invalid_chars";
3232+3333+/**
3434+ * Validates a custom filename to prevent path traversal and invalid characters
3535+ * @param filename The filename to validate
3636+ * @returns Result with validated filename or error type
3737+ */
3838+export function validateFilename(
3939+ filename: string
4040+): Result<string, FilenameValidationError> {
4141+ if (!filename || filename.trim() === "") {
4242+ return { ok: false, error: "empty" };
4343+ }
4444+4545+ if (filename.length > 255) {
4646+ return { ok: false, error: "too_long" };
4747+ }
4848+4949+ // Check for path traversal attempts
5050+ if (
5151+ filename.includes("..") ||
5252+ filename.includes("/") ||
5353+ filename.includes("\\")
5454+ ) {
5555+ return { ok: false, error: "path_traversal" };
5656+ }
5757+5858+ // Check for null bytes and control characters
5959+ if (/[\x00-\x1f\x7f-\x9f]/.test(filename)) {
6060+ return { ok: false, error: "invalid_chars" };
6161+ }
6262+6363+ return { ok: true, value: filename.trim() };
6464+}
6565+6666+/**
6767+ * Gets the macOS screenshot capture directory from system preferences
6868+ * Falls back to Desktop if not set or command fails
6969+ * @returns Promise resolving to directory path
7070+ */
7171+export async function getCaptureDirectory(): Promise<string> {
7272+ return new Promise((resolve) => {
7373+ const proc = spawn("defaults", ["read", "com.apple.screencapture", "location"]);
7474+ let output = "";
7575+ let timeoutId: Timer | null = null;
7676+7777+ proc.stdout.on("data", (data) => {
7878+ output += data.toString();
7979+ });
8080+8181+ proc.on("close", (code) => {
8282+ if (timeoutId) clearTimeout(timeoutId);
8383+ if (code === 0 && output.trim()) {
8484+ resolve(output.trim());
8585+ } else {
8686+ resolve(join(homedir(), "Desktop"));
8787+ }
8888+ });
8989+9090+ proc.on("error", () => {
9191+ if (timeoutId) clearTimeout(timeoutId);
9292+ resolve(join(homedir(), "Desktop"));
9393+ });
9494+9595+ // Add timeout for defaults command
9696+ timeoutId = setTimeout(() => {
9797+ proc.kill("SIGTERM");
9898+ resolve(join(homedir(), "Desktop"));
9999+ }, DEFAULTS_TIMEOUT_MS);
100100+ });
101101+}