174 lines · 6.5 KB · TypeScript
RawDownload
import { isInfoHash } from "../sources/magnet";import { parseDuration } from "../util/duration"; export type CliCommand =  | { kind: "version" }  | { kind: "help" }  | { kind: "run"; initialMagnet?: string; initialTorrent?: string }  | {      kind: "watch";      dir: string;      downloadDir?: string;      seedTimeMs?: number;      deleteFiles?: boolean;      daemon?: boolean;    }  | {      kind: "serve";      port?: number;      host?: string;      token?: string;      downloadDir?: string;      seedTimeMs?: number;      deleteFiles?: boolean;      daemon?: boolean;    }  | { kind: "files"; port?: number; host?: string; token?: string; dir?: string; daemon?: boolean }  | { kind: "attach" }  | { kind: "update"; force?: boolean }  | { kind: "invalid"; arg: string }; // Valueless boolean flags for the headless subcommands (everything else is a// `--flag value` pair).const BOOL_FLAGS = new Set(["delete-files", "daemon"]); function splitBooleans(args: string[]): { bools: Set<string>; rest: string[] } {  const bools = new Set<string>();  const rest: string[] = [];  for (const arg of args) {    if (arg.startsWith("--") && BOOL_FLAGS.has(arg.slice(2))) bools.add(arg.slice(2));    else rest.push(arg);  }  return { bools, rest };} // Minimal `--flag value` reader for the headless subcommands. Unknown tokens are// left in `rest` so the caller can decide what to do with them.function readFlags(args: string[]): { flags: Record<string, string>; rest: string[] } {  const flags: Record<string, string> = {};  const rest: string[] = [];  for (let i = 0; i < args.length; i++) {    const arg = args[i]!;    if (arg.startsWith("--") && i + 1 < args.length) {      flags[arg.slice(2)] = args[++i]!;    } else {      rest.push(arg);    }  }  return { flags, rest };} function parsePort(raw: string | undefined): number | undefined {  if (!raw) return undefined;  const n = Number.parseInt(raw, 10);  return Number.isFinite(n) && n > 0 ? n : undefined;} function seedTimeFrom(raw: string | undefined): number | undefined {  if (!raw) return undefined;  return parseDuration(raw) ?? undefined;} export function parseCliArgs(argv: string[]): CliCommand {  const args = argv.filter((a) => a.trim() !== "");  if (args.length === 0) return { kind: "run" };  const a = args[0]!;  if (a === "--version" || a === "-v") return { kind: "version" };  if (a === "--help" || a === "-h") return { kind: "help" };  if (a === "attach") return { kind: "attach" };  if (a === "update") return { kind: "update", force: args.slice(1).includes("--force") };  if (a === "watch") {    const { bools, rest: r0 } = splitBooleans(args.slice(1));    const { flags, rest } = readFlags(r0);    const dir = rest[0];    if (!dir) return { kind: "invalid", arg: "watch (missing directory)" };    return {      kind: "watch",      dir,      downloadDir: flags.to ?? flags.dir,      seedTimeMs: seedTimeFrom(flags["seed-time"]),      deleteFiles: bools.has("delete-files"),      daemon: bools.has("daemon"),    };  }  if (a === "serve") {    const { bools, rest: r0 } = splitBooleans(args.slice(1));    const { flags } = readFlags(r0);    return {      kind: "serve",      port: parsePort(flags.port),      host: flags.host,      token: flags.token,      downloadDir: flags.to ?? flags.dir,      seedTimeMs: seedTimeFrom(flags["seed-time"]),      deleteFiles: bools.has("delete-files"),      daemon: bools.has("daemon"),    };  }  if (a === "files") {    const { bools, rest: r0 } = splitBooleans(args.slice(1));    const { flags } = readFlags(r0);    return {      kind: "files",      port: parsePort(flags.port),      host: flags.host,      token: flags.token,      dir: flags.dir,      daemon: bools.has("daemon"),    };  }  if (/^magnet:\?/i.test(a)) return { kind: "run", initialMagnet: a };  if (isInfoHash(a)) return { kind: "run", initialMagnet: a };  if (/\.torrent$/i.test(a)) return { kind: "run", initialTorrent: a };  return { kind: "invalid", arg: a };} export const HELP_TEXT = `torlink, terminal-native torrent search usage  torlnk                      open the search TUI  torlnk "magnet:?xt=..."     start a download on launch  torlnk path/to/file.torrent open a .torrent file on launch  torlnk watch <dir>          headless: download torrents dropped into <dir>  torlnk serve                headless: HTTP add API (POST /add) on :9161  torlnk files                headless: serve downloads over HTTP on :9160  torlnk attach               open/reattach the TUI in a persistent tmux session  torlnk update [--force]     update to the latest release and restart any daemon                              (--force rebuilds/restarts even if already current)  torlnk --version            print the version once open: type to search every source at once, enter to run, arrows to move,d to download, ? for keystip: quote magnet links (they contain & characters) watch mode (no TUI): drop a .torrent, or a .magnet/.txt holding a magnet orinfo hash, into <dir> and it downloads then seeds. Add --to <dir> to choosewhere files land. Handled files move to <dir>/.processed (or /.failed). seed mode (watch/serve): --seed-time <dur> stops seeding a torrent that longafter it finishes (e.g. 1h, 30m, 90s, 2d); files are kept by default. Add--delete-files to also remove the downloaded data when the timer expires. --daemon (watch/serve/files): background the process (own session, logs to afile), so you can log out and it keeps running. Prints the pid and log path. torlnk attach: run the TUI inside a persistent tmux session. Detach withtmux's ctrl-b d, log out, then torlnk attach again to reattach where youleft off. Downloads and seeds keep running while detached. serve mode (no TUI): a small HTTP API for handing torlink a magnet.  POST /add {"magnet":"..."}   queue a magnet or info hash  GET  /downloads              list active downloads and seeds  GET  /health                 liveness (no auth)flags: --port <n> (default 9161), --host <addr> (default 127.0.0.1),--token <secret> (required to bind a public --host; or TORLINK_API_TOKEN),--to <dir> (where files land). files mode (no TUI): a read-only, range-aware HTTP server over the downloadsfolder, so finished files stream to a browser or media player.  GET /            list the folder (JSON)  GET /<path>      stream a file (supports Range for seeking/resuming)flags: --port <n> (default 9160), --host <addr> (default 127.0.0.1),--token <secret> (required to bind a public --host; or TORLINK_FILES_TOKEN),--dir <dir> (folder to serve; defaults to your downloads folder).`;