@xditya/pastr 0.2.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +4 -3
  2. package/package.json +1 -1
  3. package/pastr.mjs +204 -36
package/README.md CHANGED
@@ -4,7 +4,7 @@ Paste from the terminal to any [pastr](https://github.com/xditya/pastr) instance
4
4
 
5
5
  ```sh
6
6
  npm install -g @xditya/pastr # or: npx @xditya/pastr …
7
- pastr config host https://your-pastr.example
7
+ pastr config host https://your-pastr.example # optional, defaults to pastr.xditya.me
8
8
 
9
9
  ls -la | pastr # stdin → link
10
10
  pastr main.go --expires 1d # file (language from the extension)
@@ -12,13 +12,14 @@ pastr clip -E -c # clipboard, encrypted in the terminal, link cop
12
12
  pastr shot.png # images (png/jpeg/gif/webp, up to 700 KB); clip also takes a copied image
13
13
  pastr text "hello there" -b # literal text, burn after read
14
14
  pastr get https://host/AbCd1234#key
15
- pastr ls # what you pasted from this machine
15
+ pastr ls # browse what you pasted here: ↑↓ or click a row, enter reveals the edit token, again copies it
16
16
  pastr rm AbCd1234 # delete (uses the locally stored edit token)
17
+ pastr token AbCd1234 # show that token, for the site's Edit/Delete prompt
17
18
  ```
18
19
 
19
20
  - Zero dependencies, Node 20+, macOS/Linux/Windows/WSL.
20
21
  - `-E` encrypts with AES-256-GCM before upload; the key is only in the URL fragment. `-p`/`-P` uses a password instead.
21
22
  - Edit tokens and link keys are kept in `~/.config/pastr/history.json` (`%APPDATA%\pastr` on Windows), mode 0600.
22
- - Set the host once with `pastr config host …` or `PASTR_HOST`.
23
+ - Talks to pastr.xditya.me unless you point it elsewhere with `pastr config host …`, `PASTR_HOST` or `-H`.
23
24
 
24
25
  Prefer no Node at all? Every pastr instance serves a tiny POSIX shell version: `curl -fsSL https://your-pastr.example/install.sh | sh`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xditya/pastr",
3
- "version": "0.2.0",
3
+ "version": "0.4.2",
4
4
  "description": "Paste from the terminal to a pastr instance: pipe, files, clipboard, with optional end-to-end encryption.",
5
5
  "type": "module",
6
6
  "bin": {
package/pastr.mjs CHANGED
@@ -11,11 +11,49 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
11
  import { homedir, platform } from "node:os";
12
12
  import { basename, join } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
- import { parseArgs } from "node:util";
14
+ import { parseArgs, styleText } from "node:util";
15
15
  import { createInterface } from "node:readline";
16
16
 
17
- export const VERSION = "0.2.0";
17
+ export const VERSION = "0.4.2";
18
18
  const NAME = "pastr";
19
+ const DEFAULT_HOST = "https://pastr.xditya.me";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Terminal styling: colour only on a TTY, never when piped or under NO_COLOR,
23
+ // so `pastr | pbcopy` and scripts see plain text.
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const OUT_TTY = !process.env.NO_COLOR && !!process.stdout.isTTY;
27
+ const ERR_TTY = !process.env.NO_COLOR && !!process.stderr.isTTY;
28
+ const paint = (style) => (s) => (OUT_TTY && styleText ? styleText(style, s) : s);
29
+ const bold = paint("bold");
30
+ const dim = paint("dim");
31
+ const cyan = paint("cyan");
32
+ const yellow = paint("yellow");
33
+ const green = paint("green");
34
+ const link = paint(["cyan", "underline"]);
35
+
36
+ const len = (s) => [...s].length;
37
+ const fit = (s, n) => (len(s) > n ? [...s].slice(0, Math.max(n - 1, 0)).join("") + "…" : s.padEnd(n));
38
+
39
+ /** Box-drawn table sized to the terminal; column `shrink` gives up characters first, then the widest. */
40
+ export function table(head, rows, styles = [], shrink = -1, width = process.stdout.columns || 120, selected = -1) {
41
+ const w = head.map((h, i) => Math.max(len(h), ...rows.map((r) => len(r[i]))));
42
+ let over = w.reduce((a, b) => a + b, 0) + 3 * w.length + 1 - width;
43
+ while (over > 0) {
44
+ const i = w[shrink] > 8 ? shrink : w.indexOf(Math.max(...w));
45
+ if (w[i] <= 8) break;
46
+ w[i]--;
47
+ over--;
48
+ }
49
+ const rule = (l, m, r) => dim(l + w.map((n) => "─".repeat(n + 2)).join(m) + r);
50
+ const line = (cells, f) => dim("│ ") + cells.map((c, i) => (f[i] ?? ((s) => s))(fit(c, w[i]))).join(dim(" │ ")) + dim(" │");
51
+ const body = rows.map((r, i) => (i === selected ? line(r, r.map(() => paint("inverse"))) : line(r, styles)));
52
+ return [rule("╭", "┬", "╮"), line(head, head.map(() => bold)), rule("├", "┼", "┤"), ...body, rule("╰", "┴", "╯")].join("\n") + "\n";
53
+ }
54
+
55
+ const ok = (msg) => out(`${OUT_TTY ? green("✓ ") : ""}${msg}\n`);
56
+ const note = (msg) => process.stderr.write(ERR_TTY ? styleText("dim", ` ${msg}\n`) : `${msg}\n`);
19
57
 
20
58
  // ---------------------------------------------------------------------------
21
59
  // Config & history
@@ -67,10 +105,7 @@ function forget(id) {
67
105
  }
68
106
 
69
107
  export function resolveHost(flag) {
70
- const host = flag || process.env.PASTR_HOST || getConfig().host;
71
- if (!host) {
72
- throw new UsageError(`no host configured. Run \`${NAME} config host https://your-pastr.example\` or set PASTR_HOST.`);
73
- }
108
+ const host = flag || process.env.PASTR_HOST || getConfig().host || DEFAULT_HOST;
74
109
  return host.replace(/\/+$/, "");
75
110
  }
76
111
 
@@ -226,10 +261,20 @@ function promptHidden(question) {
226
261
  }
227
262
 
228
263
  async function readStdin() {
229
- if (process.stdin.isTTY) process.stderr.write("Type or paste, then press Ctrl-D:\n");
230
- const chunks = [];
231
- for await (const chunk of process.stdin) chunks.push(chunk);
232
- return Buffer.concat(chunks).toString("utf8");
264
+ if (!process.stdin.isTTY) {
265
+ const chunks = [];
266
+ for await (const chunk of process.stdin) chunks.push(chunk);
267
+ return Buffer.concat(chunks).toString("utf8");
268
+ }
269
+ // Interactive: readline handles Ctrl-D itself, so it ends the paste on Windows too (the console's
270
+ // Ctrl-Z convention never reaches a Node stream reliably there).
271
+ note("Type or paste, then press Ctrl-D on an empty line to finish:");
272
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true, prompt: "" });
273
+ const lines = [];
274
+ rl.on("line", (l) => lines.push(l));
275
+ rl.on("SIGINT", () => process.exit(130));
276
+ await new Promise((resolve) => rl.on("close", resolve));
277
+ return lines.length ? lines.join("\n") + "\n" : "";
233
278
  }
234
279
 
235
280
  // ---------------------------------------------------------------------------
@@ -336,15 +381,16 @@ async function createPaste(host, { content, title, lang, expires, burn, encrypt,
336
381
  // CLI
337
382
  // ---------------------------------------------------------------------------
338
383
 
339
- const HELP = `${NAME} ${VERSION} paste from the terminal
384
+ const HELP = `${NAME} ${VERSION} · paste from the terminal
340
385
 
341
386
  Usage
342
387
  ${NAME} [options] [file ...] paste files (text, or png/jpeg/gif/webp up to 700 KB), or stdin
343
388
  ${NAME} clip [options] paste the clipboard (text or an image)
344
389
  ${NAME} text [options] <words ...> paste literal text
345
390
  ${NAME} get <id|url> [--json] print a paste (decrypts when the URL carries a #key)
346
- ${NAME} ls pastes created from this machine
391
+ ${NAME} ls [--json] browse pastes made here: arrows or click, enter reveals the edit token
347
392
  ${NAME} rm <id|url> delete a paste created from this machine
393
+ ${NAME} token <id|url> print the edit token (paste it into the site's Edit/Delete prompt)
348
394
  ${NAME} config [host <url>] show or set the default host
349
395
 
350
396
  Options
@@ -359,7 +405,7 @@ Options
359
405
  -o, --open open the URL in a browser
360
406
  -r, --raw print the raw URL (plain text) instead of the page URL
361
407
  -j, --json print the full API response
362
- -H, --host <url> server to use (env PASTR_HOST, or \`${NAME} config host …\`)
408
+ -H, --host <url> server to use (default ${DEFAULT_HOST}; env PASTR_HOST, or \`${NAME} config host …\`)
363
409
  -h, --help show this help
364
410
  -v, --version show the version
365
411
 
@@ -372,6 +418,13 @@ Examples
372
418
  ${NAME} shot.png -e 1d # image paste; "get" writes the bytes back
373
419
  `;
374
420
 
421
+ /** Bold section headings, cyan command/flag column, dim trailing comments. */
422
+ const styleHelp = (s) =>
423
+ s
424
+ .split("\n")
425
+ .map((l) => (/^\S/.test(l) ? bold(l) : l.replace(/^(\s+)(\S.*?)(\s{2,}|$)/, (_, a, b, c) => a + cyan(b) + c).replace(/#.*$/, dim)))
426
+ .join("\n");
427
+
375
428
  function fmtRel(ms) {
376
429
  const d = ms - Date.now();
377
430
  const abs = Math.abs(d);
@@ -380,6 +433,104 @@ function fmtRel(ms) {
380
433
  return d >= 0 ? `in ${n}${u[1]}` : `${n}${u[1]} ago`;
381
434
  }
382
435
 
436
+ const flags = (p) => [p.encrypted && "enc", p.burn && "burn"].filter(Boolean).join(" ");
437
+
438
+ /** Keys stay out of the table (`get <id>` finds them in history); the piped form has the full URL. */
439
+ function lsTable(list, selected = -1) {
440
+ const head = ["id", "title", "lang", "created", "expires", "", "url"];
441
+ const rows = list.map((p) => [p.id, p.title || "", p.lang || "", fmtRel(p.created), p.expires ? fmtRel(p.expires) : "never", flags(p), p.url.split("#")[0]]);
442
+ // Narrow window: drop the url column (the id is enough for `get`) rather than mangling it.
443
+ const need = head.reduce((sum, h, i) => sum + 3 + Math.min(i === 1 ? 20 : Infinity, Math.max(len(h), ...rows.map((r) => len(r[i])))), 1);
444
+ if (need > (process.stdout.columns || 120)) for (const r of [head, ...rows]) r.pop();
445
+ return table(head, rows, [cyan, undefined, dim, dim, undefined, yellow, dim], 1, process.stdout.columns || 120, selected);
446
+ }
447
+
448
+ /**
449
+ * Interactive `ls`: arrow keys or a mouse click pick a paste, Enter (or a second click) reveals its
450
+ * edit token, once more copies it. Runs on the alternate screen so the shell scrollback stays clean.
451
+ */
452
+ function browse(list) {
453
+ const { stdin, stdout } = process;
454
+ let sel = 0;
455
+ let revealed = false;
456
+ let msg = "";
457
+ let confirmDelete = false;
458
+ const rowsVisible = () => Math.max(3, (stdout.rows || 24) - 10);
459
+ const first = () => Math.min(Math.max(0, sel - rowsVisible() + 1), Math.max(0, list.length - rowsVisible()));
460
+ const draw = () => {
461
+ const p = list[sel];
462
+ const start = first();
463
+ const detail = [
464
+ ` ${cyan(p.id)} ${p.title || dim("untitled")}`,
465
+ ` ${dim("url ")}${link(p.url)}`,
466
+ ` ${dim("token ")}${p.editToken ? (revealed ? yellow(p.editToken) : dim("•".repeat(24) + " enter to reveal")) : dim("not on this machine")}`,
467
+ "",
468
+ confirmDelete ? yellow(` delete ${p.id} from the server? y/n`) : msg ? ` ${msg}` : dim(" ↑↓ move · enter reveal, again to copy token · c copy url · o open · d delete · q quit"),
469
+ ];
470
+ stdout.write("\x1b[H\x1b[2J" + lsTable(list.slice(start, start + rowsVisible()), sel - start) + detail.join("\n") + "\n");
471
+ };
472
+ const enter = async () => {
473
+ const p = list[sel];
474
+ if (!p.editToken) return (msg = yellow("no token stored for this paste"));
475
+ if (!revealed) return (revealed = true);
476
+ msg = writeClipboard(p.editToken) ? green("✓ token copied") : yellow("could not copy");
477
+ };
478
+ const select = (i) => {
479
+ if (i === sel || i < 0 || i >= list.length) return false;
480
+ sel = i;
481
+ revealed = false;
482
+ msg = "";
483
+ return true;
484
+ };
485
+ return new Promise((resolve) => {
486
+ const done = () => {
487
+ stdout.write("\x1b[?1006l\x1b[?1000l\x1b[?25h\x1b[?1049l");
488
+ stdin.setRawMode(false);
489
+ stdin.pause();
490
+ resolve();
491
+ };
492
+ stdin.setRawMode(true);
493
+ stdin.resume();
494
+ stdin.setEncoding("utf8");
495
+ stdout.write("\x1b[?1049h\x1b[?25l\x1b[?1000h\x1b[?1006h");
496
+ draw();
497
+ stdin.on("data", async (k) => {
498
+ const mouse = /^\x1b\[<(\d+);(\d+);(\d+)M/.exec(k);
499
+ if (confirmDelete) {
500
+ confirmDelete = false;
501
+ if (k === "y") {
502
+ const p = list[sel];
503
+ try {
504
+ await api(new URL(p.url).origin, `/api/v1/pastes/${p.id}`, { method: "DELETE", headers: { Authorization: `Bearer ${p.editToken}` } });
505
+ forget(p.id);
506
+ list.splice(sel, 1);
507
+ if (!list.length) return done();
508
+ sel = Math.min(sel, list.length - 1);
509
+ msg = green(`✓ deleted ${p.id}`);
510
+ } catch (err) {
511
+ msg = yellow(err.message);
512
+ }
513
+ }
514
+ } else if (mouse) {
515
+ const [, button, , y] = mouse.map(Number);
516
+ if (button === 64) select(sel - 1);
517
+ else if (button === 65) select(sel + 1);
518
+ else if (button === 0) {
519
+ const i = y - 4 + first(); // 3 header lines above the first row
520
+ if (i >= 0 && i < list.length && !select(i)) await enter();
521
+ }
522
+ } else if (k === "\x1b[A" || k === "k") select(sel - 1);
523
+ else if (k === "\x1b[B" || k === "j") select(sel + 1);
524
+ else if (k === "\r") await enter();
525
+ else if (k === "c") msg = writeClipboard(list[sel].url) ? green("✓ url copied") : yellow("could not copy");
526
+ else if (k === "o") openInBrowser(list[sel].url);
527
+ else if (k === "d") confirmDelete = !!list[sel].editToken || ((msg = yellow("no token stored for this paste")), false);
528
+ else if (k === "q" || k === "\x1b" || k === "\x03") return done();
529
+ draw();
530
+ });
531
+ });
532
+ }
533
+
383
534
  export async function main(argv) {
384
535
  const { values: o, positionals } = parseArgs({
385
536
  args: argv,
@@ -402,7 +553,7 @@ export async function main(argv) {
402
553
  },
403
554
  });
404
555
 
405
- if (o.help) return out(HELP);
556
+ if (o.help) return out(styleHelp(HELP));
406
557
  if (o.version) return out(`${NAME} ${VERSION}\n`);
407
558
 
408
559
  const [cmd, ...rest] = positionals;
@@ -410,20 +561,25 @@ export async function main(argv) {
410
561
  if (cmd === "config") {
411
562
  if (rest[0] === "host" && rest[1]) {
412
563
  setConfig({ host: rest[1].replace(/\/+$/, "") });
413
- return out(`host set to ${rest[1]}\n`);
564
+ return ok(`host set to ${rest[1]}`);
414
565
  }
415
566
  const cfg = getConfig();
416
- return out(`host: ${process.env.PASTR_HOST || cfg.host || "(not set)"}\nconfig: ${configDir()}\n`);
567
+ const source = process.env.PASTR_HOST ? "env PASTR_HOST" : cfg.host ? "config" : "default";
568
+ return out(`${dim("host ")} ${link(process.env.PASTR_HOST || cfg.host || DEFAULT_HOST)} ${dim(`(${source})`)}\n${dim("config ")} ${configDir()}\n`);
417
569
  }
418
570
 
419
571
  if (cmd === "ls") {
420
572
  const list = getHistory();
421
- if (!list.length) return out("no pastes yet\n");
422
- for (const p of list) {
423
- const flags = [p.encrypted && "enc", p.burn && "burn"].filter(Boolean).join(",");
424
- out(`${p.id} ${(p.title || p.lang || "").padEnd(24).slice(0, 24)} ${fmtRel(p.created).padEnd(9)} ${p.expires ? "expires " + fmtRel(p.expires) : "never expires"}${flags ? " [" + flags + "]" : ""}\n ${p.url}\n`);
573
+ if (o.json) return out(JSON.stringify(list, null, 2) + "\n");
574
+ if (!list.length) return out(`no pastes yet${OUT_TTY ? dim(` · try: ls -la | ${NAME}`) : ""}\n`);
575
+ // Piped: one tab-separated line per paste, ISO dates. TTY: a table sized to the window.
576
+ if (!OUT_TTY) {
577
+ for (const p of list) out([p.id, p.title || "", p.lang || "", new Date(p.created).toISOString(), p.expires ? new Date(p.expires).toISOString() : "never", flags(p), p.url].join("\t") + "\n");
578
+ return;
425
579
  }
426
- return;
580
+ if (process.stdin.isTTY) return browse(list);
581
+ out(lsTable(list));
582
+ return out(dim(` ${list.length} ${list.length === 1 ? "paste" : "pastes"} · ${NAME} get <id> · ${NAME} rm <id>\n`));
427
583
  }
428
584
 
429
585
  if (cmd === "get") {
@@ -445,6 +601,15 @@ export async function main(argv) {
445
601
  return emit(env.content, env.lang);
446
602
  }
447
603
 
604
+ if (cmd === "token") {
605
+ if (!rest[0]) throw new UsageError("usage: token <id|url>");
606
+ const { id } = parsePasteRef(rest[0]);
607
+ const entry = getHistory().find((h) => h.id === id);
608
+ if (!entry?.editToken) throw new CliError(`no edit token for ${id} on this machine`);
609
+ if (OUT_TTY) note("Anyone with this token can edit or delete the paste.");
610
+ return out(entry.editToken + "\n");
611
+ }
612
+
448
613
  if (cmd === "rm") {
449
614
  if (!rest[0]) throw new UsageError("usage: rm <id|url>");
450
615
  const ref = parsePasteRef(rest[0]);
@@ -454,7 +619,7 @@ export async function main(argv) {
454
619
  if (!token) throw new CliError(`no edit token for ${ref.id} on this machine (set PASTR_EDIT_TOKEN to use one)`);
455
620
  await api(host, `/api/v1/pastes/${ref.id}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` } });
456
621
  forget(ref.id);
457
- return out(`deleted ${ref.id}\n`);
622
+ return ok(`deleted ${ref.id}`);
458
623
  }
459
624
 
460
625
  // ---- create ----
@@ -485,21 +650,22 @@ export async function main(argv) {
485
650
 
486
651
  for (const item of items) {
487
652
  if (!item.content.trim()) throw new CliError("nothing to paste");
488
- const p = await createPaste(host, {
489
- content: item.content,
490
- title: item.title,
491
- lang: item.lang ?? o.lang,
492
- expires: o.expires,
493
- burn: o.burn,
494
- encrypt: o.encrypt,
495
- password,
496
- });
653
+ // Progress on stderr while the upload (and PBKDF2 for -p) runs, cleared before the result.
654
+ if (ERR_TTY) process.stderr.write(styleText("dim", " uploading…"));
655
+ let p;
656
+ try {
657
+ p = await createPaste(host, { content: item.content, title: item.title, lang: item.lang ?? o.lang, expires: o.expires, burn: o.burn, encrypt: o.encrypt, password });
658
+ } finally {
659
+ if (ERR_TTY) process.stderr.write("\r\x1b[2K");
660
+ }
497
661
  const url = o.raw ? p.rawUrl : p.url;
498
662
  if (o.json) out(JSON.stringify(p, null, 2) + "\n");
499
- else out(url + "\n");
500
- if (o.copy) {
501
- if (writeClipboard(url)) process.stderr.write("copied to clipboard\n");
502
- else process.stderr.write("could not copy to clipboard\n");
663
+ else out(link(url) + "\n");
664
+ const copied = o.copy ? (writeClipboard(url) ? "copied to clipboard" : "could not copy to clipboard") : "";
665
+ // The URL alone goes to stdout; everything else is a dim stderr line so pipes stay clean.
666
+ if (!o.json && (ERR_TTY || copied)) {
667
+ const meta = ERR_TTY ? [item.title, p.expires ? `expires ${fmtRel(p.expires)}` : "never expires", p.burn && "burn after read", p.enc && "encrypted", copied, `${NAME} token ${p.id} to edit on the site`] : [copied];
668
+ note(meta.filter(Boolean).join(" · "));
503
669
  }
504
670
  if (o.open) openInBrowser(url);
505
671
  }
@@ -518,7 +684,9 @@ const isMain = process.argv[1] && import.meta.url === new URL(`file://${process.
518
684
  if (isMain || (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1])) {
519
685
  main(process.argv.slice(2)).catch((err) => {
520
686
  const usage = err instanceof UsageError || err?.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION" || err?.code?.startsWith?.("ERR_PARSE_ARGS");
521
- process.stderr.write(`${NAME}: ${err.message}\n${usage ? `Run \`${NAME} --help\` for usage.\n` : ""}`);
687
+ const prefix = ERR_TTY ? styleText("red", "✗") : `${NAME}:`;
688
+ const hint = usage ? `Run \`${NAME} --help\` for usage.\n` : "";
689
+ process.stderr.write(`${prefix} ${err.message}\n${ERR_TTY ? styleText("dim", hint) : hint}`);
522
690
  process.exit(usage ? 2 : 1);
523
691
  });
524
692
  }