Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ TypeScript, installed as executables on `PATH`.
| [`favicon`](#favicon) | Every icon a site links, rendered from one SVG |
| [`vid`](#vid) | Inspect, thumbnail, clip and shrink video, through ffmpeg |
| [`dl`](#dl) | Download a video, or just its audio, through yt-dlp |
| [`torrent`](#torrent) | Make a torrent out of a directory, and get it seeded |
| [`codeburn`](#codeburn) | See where your AI spend goes, by task, tool, model and project |
| [`shorten`](#shorten) | Mint a short link on the pit, and follow it from `/f/<code>` |

Expand All @@ -44,6 +45,9 @@ One thing here is not a `PATH` command and does not need Node:
best single stream and says so
- **[`yt-dlp`](https://github.com/yt-dlp/yt-dlp)** — `dl` only
(`moshcode install yt-dlp`, `pipx install yt-dlp`, `brew install yt-dlp`)
- **[`create-torrent`](https://www.npmjs.com/package/create-torrent)** — `torrent`
only (`npm i -g create-torrent`); `torrent seed` additionally needs
[torlnk](https://www.npmjs.com/package/torlnk) running
- **ImageMagick** (`magick`) — `img` only, and only for what sharp cannot do
(PDF, PSD, animated GIF); sharp ships with this repo as an optional dependency
- **Network on first use** — `favicon` only: the generation is
Expand Down Expand Up @@ -807,6 +811,56 @@ merge. `dl audio` is `-x`, which *is* ffmpeg, so there it is a hard requirement.
A URL that fails does not stop the ones after it; the exit code still reports
the failure.

### `torrent`

Turn a directory into a torrent, and get it seeded:

```sh
torrent create ./album # writes album.torrent, prints the magnet
torrent seed ./album # …and hands the magnet to torlnk
torrent magnet album.torrent # the magnet for one you already have
torrent info album.torrent # name, info hash, magnet
```

`create-torrent` writes a .torrent and never prints a hash; torlnk takes a
magnet rather than a file. So the two do not actually meet without something in
between, and that is what this is. The info hash is computed here — a SHA-1 over
the bencoded `info` dictionary, read out of the file verbatim — rather than by
adding a bencode parser as a dependency for forty lines.

**Trackers matter more than they look.** A browser can only ever be a WebRTC
peer, so a torrent with no `wss://` tracker is invisible to every web player:
it is on the DHT, desktop clients find it, and the browser sees a torrent with
no peers — which reads as a dead torrent rather than as a missing tracker. The
default list carries both kinds, and every entry was checked rather than copied.
The announce list the WebTorrent tooling ships by default still names
`tracker.leechers-paradise.org` (no DNS at all), `coppersurfer.tk` and
`empire-js.us` (both time out on a UDP connect), and `tracker.btorrent.xyz`
(a self-signed certificate, which a browser refuses outright). Override the lot
with `--tracker`.

`--private` exists and is opt-in, because a private torrent is excluded from the
DHT by every client that honours the flag — the opposite of the reason to make
one here.

**From a URL rather than a seeding process.** `--webseed` embeds HTTP URLs that
already serve the same bytes (BEP 19), so the torrent is downloadable the moment
it exists — before any peer has it, and without anything staying running:

```sh
torrent create ./album --webseed https://files.example.com/album
```

The URL has to serve the *exact* bytes the torrent was made from. A redirect to
a re-encoded or recompressed copy is a torrent that fails its hash check, which
looks like corruption rather than like a misconfigured seed.

`seed` hands the magnet to torlnk, which is the process that stays running:
its serve API by default (`--api`, `$TORLINK_API`, `$TORLINK_API_TOKEN`), or a
watch directory (`--watch`, `$TORLINK_WATCH`) as the offline handoff. How long
it seeds for is a torlnk daemon setting (`--seed-time`), not a per-torrent one;
left alone, it seeds indefinitely.

### `codeburn`

Where your AI spend actually went — [codeburn](https://www.npmjs.com/package/codeburn),
Expand Down
197 changes: 197 additions & 0 deletions bin/torrent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
#!/usr/bin/env -S npx --yes tsx
/**
* torrent -- turn a directory into a torrent, and get it seeded.
*
* Two programs do the work, in the same way `dl` leans on yt-dlp and `vid` on
* ffmpeg: `create-torrent` writes the .torrent, and torlnk seeds it. What this
* adds is the part neither of them does -- handing you the magnet.
*
* `create-torrent` writes a file and never prints a hash, and torlnk takes a
* magnet rather than a file, so the two do not actually meet without something
* in between. src/torrent.ts is that something.
*/

import { spawn } from 'node:child_process';
import { readFile, writeFile } from 'node:fs/promises';
import { basename, join, resolve } from 'node:path';

import { UsageError, csv, parseArgs } from '../src/args.ts';
import { isMain } from '../src/is-main.ts';
import { MissingEngineError, findBinary, humanBytes } from '../src/media.ts';
import {
DEFAULT_TORLINK_API,
DEFAULT_TRACKERS,
addBody,
createArgs,
infoHash,
magnetFor,
torrentName,
} from '../src/torrent.ts';

const USAGE = `Usage:
torrent create <path> [-o OUT.torrent] make a torrent, print its magnet
torrent seed <path> make it, then hand it to torlnk
torrent magnet <file.torrent> the magnet for a torrent you have
torrent info <file.torrent> what is inside one

Options:
-o, --out PATH where to write the .torrent (default: <name>.torrent)
--tracker URLS comma-separated announce list, replacing the default
--name NAME torrent name, if not the directory's own
--comment TEXT a comment to embed
--private exclude it from the DHT (opt-in; the opposite of sharing)
--webseed URLS comma-separated HTTP URLs already serving this data
--api URL torlnk's serve API (default: ${DEFAULT_TORLINK_API})
--watch DIR a torlnk watch directory, instead of its API
--json machine-readable output
--help show this help

Trackers matter more than they look. A browser can only be a peer over WebRTC,
so a torrent with no wss:// tracker is invisible to every web player -- it is on
the DHT, desktop clients find it, and the browser sees a torrent with no peers.
The default list carries both kinds, and every entry in it was checked rather
than copied: the announce list the WebTorrent tooling ships by default still
names three trackers that are dead and one with a self-signed certificate.

Needs \`create-torrent\` on PATH (npm i -g create-torrent). \`seed\` also needs
torlnk running -- either its serve API, or a watch directory.
`;

/** Run a child with our stdio, and resolve its exit code. */
function passthrough(file: string, args: string[]): Promise<number> {
return new Promise((done) => {
const child = spawn(file, args, { stdio: 'inherit' });
child.on('error', () => done(127));
child.on('close', (code) => done(code ?? 1));
});
}

if (isMain(import.meta.url)) {
try {
const { flags, values, positional } = parseArgs(process.argv.slice(2), {
boolean: ['--help', '--private', '--json'],
string: ['-o', '--out', '--tracker', '--name', '--comment', '--api', '--watch', '--webseed'],
});

if (flags.has('--help') || positional.length === 0) {
process.stdout.write(USAGE);
process.exit(positional.length === 0 ? 1 : 0);
}

const [command, target] = positional;
if (!target) throw new UsageError(`${command} needs a path`);

const trackers = values.has('--tracker') ? csv(values, '--tracker') : DEFAULT_TRACKERS;
if (trackers.length === 0) throw new UsageError('--tracker was given no URLs');

// Reading an existing torrent needs nothing installed at all.
if (command === 'magnet' || command === 'info') {
const buf = await readFile(target);
if (command === 'magnet') {
process.stdout.write(`${magnetFor(buf, trackers)}\n`);
process.exit(0);
}
const out = {
name: torrentName(buf),
infoHash: infoHash(buf),
size: humanBytes(buf.length),
magnet: magnetFor(buf, trackers),
};
process.stdout.write(
flags.has('--json')
? `${JSON.stringify(out, null, 2)}\n`
: `${out.name}\n${out.infoHash}\n${out.magnet}\n`,
);
process.exit(0);
}

if (command !== 'create' && command !== 'seed') {
throw new UsageError(`unknown command "${command}"`);
}

const creator = await findBinary(['create-torrent'], '--help');
if (!creator) {
throw new MissingEngineError(
'create-torrent is not on PATH. Install it (npm i -g create-torrent).',
);
}

const out = resolve(
values.get('-o') ?? values.get('--out') ?? `${values.get('--name') ?? basename(resolve(target))}.torrent`,
);

const code = await passthrough(
creator,
createArgs(target, {
out,
trackers,
...(values.has('--name') ? { name: values.get('--name') as string } : {}),
...(values.has('--comment') ? { comment: values.get('--comment') as string } : {}),
isPrivate: flags.has('--private'),
...(values.has('--webseed') ? { webSeeds: csv(values, '--webseed') } : {}),
}),
);
if (code !== 0) throw new Error(`create-torrent exited ${code}`);

const buf = await readFile(out);
const magnet = magnetFor(buf, trackers);
const hash = infoHash(buf);

if (command === 'create') {
process.stdout.write(
flags.has('--json')
? `${JSON.stringify({ torrent: out, infoHash: hash, magnet }, null, 2)}\n`
: `${out}\n${magnet}\n`,
);
process.exit(0);
}

// seed: hand the magnet to torlnk, which is the thing that stays running.
//
// A watch directory is the offline handoff -- drop the file, torlnk picks it
// up whenever it next looks -- and the API is the online one. The API is
// tried first when either is available, because it answers.
const watch = values.get('--watch') ?? process.env.TORLINK_WATCH;
const api = values.get('--api') ?? process.env.TORLINK_API ?? (watch ? null : DEFAULT_TORLINK_API);

if (api) {
const res = await fetch(`${api.replace(/\/$/, '')}/add`, {
method: 'POST',
headers: {
'content-type': 'application/json',
// torlnk requires a token only when it is bound to a public address;
// sending one it did not ask for is harmless, so this is set-and-forget.
...(process.env.TORLINK_API_TOKEN
? { authorization: `Bearer ${process.env.TORLINK_API_TOKEN}` }
: {}),
},
body: addBody(magnet),
}).catch((err: Error) => {
throw new MissingEngineError(
`torlnk's API did not answer at ${api} (${err.message}). Start it with \`torlnk serve --daemon\`, or pass --watch <dir>.`,
);
});
if (!res.ok) throw new Error(`torlnk answered ${res.status} ${res.statusText}`);
process.stdout.write(`${out}\n${magnet}\nqueued with torlnk at ${api}\n`);
process.exit(0);
}

// A .magnet file is what torlnk's watch mode reads; the .torrent would work
// too, but the magnet carries the tracker list we just chose.
const dropped = join(resolve(watch as string), `${basename(out, '.torrent')}.magnet`);
await writeFile(dropped, `${magnet}\n`, 'utf8');
process.stdout.write(`${out}\n${magnet}\ndropped for torlnk at ${dropped}\n`);
process.exit(0);
} catch (err) {
if (err instanceof MissingEngineError) {
process.stderr.write(`torrent: ${err.message}\n`);
process.exit(2);
}
if (err instanceof UsageError) {
process.stderr.write(`torrent: ${err.message}\n\n${USAGE}`);
process.exit(1);
}
process.stderr.write(`torrent: ${(err as Error).message}\n`);
process.exit(1);
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/cli-tools",
"version": "0.13.0",
"version": "0.14.0",
"private": true,
"description": "Local command-line tools, in TypeScript, exposed on PATH.",
"type": "module",
Expand Down
1 change: 1 addition & 0 deletions src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const SUMMARIES: Record<string, string> = {
porkbun: 'Read and change DNS at Porkbun, and un-park a domain',
shorten: 'Mint a short link on the pit, and follow it from /f/<code>',
tcfeed: 'Find repositories worth scanning, scan them, print a shortlist',
torrent: 'Make a torrent out of a directory, and get it seeded',
tts: 'Read text aloud and keep the audio',
vid: 'Inspect, thumbnail, clip and shrink video, through ffmpeg',
};
Expand Down
Loading
Loading