@xditya/pastr 0.2.0 → 0.4.0
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.
- package/README.md +4 -3
- package/package.json +1 -1
- package/pastr.mjs +670 -511
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
|
|
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
|
-
-
|
|
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
package/pastr.mjs
CHANGED
|
@@ -1,524 +1,683 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* pastr — paste from the terminal.
|
|
4
|
-
*
|
|
5
|
-
* Zero dependencies (Node ≥ 20). Talks to any pastr instance over its HTTP API and
|
|
6
|
-
* implements the same AES-256-GCM envelope as the website, so `pastr -E` pastes are
|
|
7
|
-
* end-to-end encrypted without ever opening a browser.
|
|
8
|
-
*/
|
|
9
|
-
import { execFileSync, spawnSync } from "node:child_process";
|
|
10
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
-
import { homedir, platform } from "node:os";
|
|
12
|
-
import { basename, join } from "node:path";
|
|
13
|
-
import { fileURLToPath } from "node:url";
|
|
14
|
-
import { parseArgs } from "node:util";
|
|
15
|
-
import { createInterface } from "node:readline";
|
|
16
|
-
|
|
17
|
-
export const VERSION = "0.
|
|
18
|
-
const NAME = "pastr";
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
function
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
return
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pastr — paste from the terminal.
|
|
4
|
+
*
|
|
5
|
+
* Zero dependencies (Node ≥ 20). Talks to any pastr instance over its HTTP API and
|
|
6
|
+
* implements the same AES-256-GCM envelope as the website, so `pastr -E` pastes are
|
|
7
|
+
* end-to-end encrypted without ever opening a browser.
|
|
8
|
+
*/
|
|
9
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { homedir, platform } from "node:os";
|
|
12
|
+
import { basename, join } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { parseArgs, styleText } from "node:util";
|
|
15
|
+
import { createInterface } from "node:readline";
|
|
16
|
+
|
|
17
|
+
export const VERSION = "0.4.0";
|
|
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`);
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Config & history
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
export function configDir() {
|
|
63
|
+
if (process.env.PASTR_CONFIG_DIR) return process.env.PASTR_CONFIG_DIR;
|
|
64
|
+
if (platform() === "win32") return join(process.env.APPDATA || join(homedir(), "AppData", "Roaming"), NAME);
|
|
65
|
+
return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), NAME);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readJson(file, fallback) {
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
71
|
+
} catch {
|
|
72
|
+
return fallback;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function writeJson(file, data) {
|
|
77
|
+
mkdirSync(configDir(), { recursive: true, mode: 0o700 });
|
|
78
|
+
writeFileSync(file, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function getConfig() {
|
|
82
|
+
return readJson(join(configDir(), "config.json"), {});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function setConfig(patch) {
|
|
86
|
+
writeJson(join(configDir(), "config.json"), { ...getConfig(), ...patch });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function getHistory() {
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
return readJson(join(configDir(), "history.json"), []).filter((p) => p.expires === null || p.expires === undefined || p.expires > now);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function remember(entry) {
|
|
95
|
+
const list = getHistory().filter((p) => p.id !== entry.id);
|
|
96
|
+
list.unshift(entry);
|
|
97
|
+
writeJson(join(configDir(), "history.json"), list.slice(0, 500));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function forget(id) {
|
|
101
|
+
writeJson(
|
|
102
|
+
join(configDir(), "history.json"),
|
|
103
|
+
getHistory().filter((p) => p.id !== id),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function resolveHost(flag) {
|
|
108
|
+
const host = flag || process.env.PASTR_HOST || getConfig().host || DEFAULT_HOST;
|
|
109
|
+
return host.replace(/\/+$/, "");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Crypto — identical envelope to the website (src/lib/crypto.ts)
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
const subtle = globalThis.crypto.subtle;
|
|
117
|
+
const enc = new TextEncoder();
|
|
118
|
+
const dec = new TextDecoder();
|
|
119
|
+
export const PBKDF2_ITERATIONS = 600_000;
|
|
120
|
+
|
|
121
|
+
export const b64u = (bytes) => Buffer.from(bytes).toString("base64url");
|
|
122
|
+
export const unb64u = (s) => new Uint8Array(Buffer.from(s, "base64url"));
|
|
123
|
+
|
|
124
|
+
async function deriveKey(password, salt, iterations = PBKDF2_ITERATIONS) {
|
|
125
|
+
const material = await subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey"]);
|
|
126
|
+
return subtle.deriveKey({ name: "PBKDF2", salt, iterations, hash: "SHA-256" }, material, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function encryptEnvelope(envelope, opts) {
|
|
130
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12));
|
|
131
|
+
const plaintext = enc.encode(JSON.stringify(envelope));
|
|
132
|
+
if (opts.password === undefined) {
|
|
133
|
+
const key = await subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
|
|
134
|
+
const ct = await subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext);
|
|
135
|
+
const raw = await subtle.exportKey("raw", key);
|
|
136
|
+
return { ciphertext: b64u(ct), meta: { alg: "AES-GCM", kdf: "fragment", iv: b64u(iv) }, fragment: b64u(raw) };
|
|
137
|
+
}
|
|
138
|
+
const salt = globalThis.crypto.getRandomValues(new Uint8Array(16));
|
|
139
|
+
const key = await deriveKey(opts.password, salt);
|
|
140
|
+
const ct = await subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext);
|
|
141
|
+
return { ciphertext: b64u(ct), meta: { alg: "AES-GCM", kdf: "password", iv: b64u(iv), salt: b64u(salt), iterations: PBKDF2_ITERATIONS } };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function decryptEnvelope(ciphertext, meta, secret) {
|
|
145
|
+
const key =
|
|
146
|
+
secret.fragment !== undefined
|
|
147
|
+
? await subtle.importKey("raw", unb64u(secret.fragment), { name: "AES-GCM" }, false, ["decrypt"])
|
|
148
|
+
: await deriveKey(secret.password, unb64u(meta.salt), meta.iterations ?? PBKDF2_ITERATIONS);
|
|
149
|
+
let pt;
|
|
150
|
+
try {
|
|
151
|
+
pt = await subtle.decrypt({ name: "AES-GCM", iv: unb64u(meta.iv) }, key, unb64u(ciphertext));
|
|
152
|
+
} catch {
|
|
153
|
+
throw new CliError("wrong key or corrupted data");
|
|
154
|
+
}
|
|
155
|
+
return JSON.parse(dec.decode(pt));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Platform helpers: clipboard, browser, prompts
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
const isWsl = () => platform() === "linux" && /microsoft/i.test(safeRead("/proc/version"));
|
|
163
|
+
function safeRead(file) {
|
|
164
|
+
try {
|
|
165
|
+
return readFileSync(file, "utf8");
|
|
166
|
+
} catch {
|
|
167
|
+
return "";
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function tryExec(cmds, encoding = "utf8") {
|
|
172
|
+
for (const [cmd, args] of cmds) {
|
|
173
|
+
try {
|
|
174
|
+
return execFileSync(cmd, args, { encoding, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 });
|
|
175
|
+
} catch {
|
|
176
|
+
/* try the next one */
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function readClipboard() {
|
|
183
|
+
const os = platform();
|
|
184
|
+
const cmds =
|
|
185
|
+
os === "darwin"
|
|
186
|
+
? [["pbpaste", []]]
|
|
187
|
+
: os === "win32"
|
|
188
|
+
? [["powershell", ["-NoProfile", "-Command", "Get-Clipboard -Raw"]]]
|
|
189
|
+
: [
|
|
190
|
+
...(process.env.WAYLAND_DISPLAY ? [["wl-paste", ["--no-newline"]]] : []),
|
|
191
|
+
["xclip", ["-selection", "clipboard", "-o"]],
|
|
192
|
+
["xsel", ["--clipboard", "--output"]],
|
|
193
|
+
...(isWsl() ? [["powershell.exe", ["-NoProfile", "-Command", "Get-Clipboard -Raw"]]] : []),
|
|
194
|
+
];
|
|
195
|
+
const out = tryExec(cmds);
|
|
196
|
+
if (out === null) throw new CliError("could not read the clipboard (install wl-clipboard, xclip or xsel on Linux)");
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** PNG bytes of an image on the clipboard (a screenshot, say), or null when the clipboard holds no image. */
|
|
201
|
+
export function readClipboardImage() {
|
|
202
|
+
const os = platform();
|
|
203
|
+
const ps = "$i = Get-Clipboard -Format Image; if ($i) { $m = New-Object IO.MemoryStream; $i.Save($m, [Drawing.Imaging.ImageFormat]::Png); [Convert]::ToBase64String($m.ToArray()) }";
|
|
204
|
+
if (os === "darwin") {
|
|
205
|
+
// osascript prints the PNG as «data PNGf89504E47...»
|
|
206
|
+
const hex = tryExec([["osascript", ["-e", "the clipboard as «class PNGf»"]]])?.match(/PNGf([0-9A-Fa-f]+)/)?.[1];
|
|
207
|
+
return hex ? Buffer.from(hex, "hex") : null;
|
|
208
|
+
}
|
|
209
|
+
if (os === "win32" || isWsl()) {
|
|
210
|
+
const b64 = tryExec([[os === "win32" ? "powershell" : "powershell.exe", ["-NoProfile", "-Command", ps]]])?.trim();
|
|
211
|
+
return b64 ? Buffer.from(b64, "base64") : null;
|
|
212
|
+
}
|
|
213
|
+
const buf = tryExec(
|
|
214
|
+
[...(process.env.WAYLAND_DISPLAY ? [["wl-paste", ["-t", "image/png"]]] : []), ["xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]]],
|
|
215
|
+
"buffer",
|
|
216
|
+
);
|
|
217
|
+
return buf?.length ? buf : null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function writeClipboard(text) {
|
|
221
|
+
const os = platform();
|
|
222
|
+
const cmds =
|
|
223
|
+
os === "darwin"
|
|
224
|
+
? [["pbcopy", []]]
|
|
225
|
+
: os === "win32"
|
|
226
|
+
? [["clip", []]]
|
|
227
|
+
: [
|
|
228
|
+
...(process.env.WAYLAND_DISPLAY ? [["wl-copy", []]] : []),
|
|
229
|
+
["xclip", ["-selection", "clipboard"]],
|
|
230
|
+
["xsel", ["--clipboard", "--input"]],
|
|
231
|
+
...(isWsl() ? [["clip.exe", []]] : []),
|
|
232
|
+
];
|
|
233
|
+
for (const [cmd, args] of cmds) {
|
|
234
|
+
const r = spawnSync(cmd, args, { input: text, stdio: ["pipe", "ignore", "ignore"] });
|
|
235
|
+
if (r.status === 0) return true;
|
|
236
|
+
}
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function openInBrowser(url) {
|
|
241
|
+
const os = platform();
|
|
242
|
+
const [cmd, args] = os === "darwin" ? ["open", [url]] : os === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
243
|
+
const r = spawnSync(cmd, args, { stdio: "ignore" });
|
|
244
|
+
return r.status === 0;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function promptHidden(question) {
|
|
248
|
+
return new Promise((resolve) => {
|
|
249
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
|
|
250
|
+
const write = rl._writeToOutput;
|
|
251
|
+
rl._writeToOutput = function (s) {
|
|
252
|
+
if (s.includes(question)) write.call(rl, s);
|
|
253
|
+
else write.call(rl, "");
|
|
254
|
+
};
|
|
255
|
+
rl.question(question, (answer) => {
|
|
256
|
+
rl.close();
|
|
257
|
+
process.stderr.write("\n");
|
|
258
|
+
resolve(answer);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function readStdin() {
|
|
264
|
+
if (process.stdin.isTTY) note(`Type or paste, then press ${platform() === "win32" ? "Ctrl-Z, Enter" : "Ctrl-D"}:`);
|
|
265
|
+
const chunks = [];
|
|
266
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
267
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Language from file name (mirrors the server's registry for the common cases)
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
const EXT = {
|
|
275
|
+
txt: "text", md: "markdown", markdown: "markdown", js: "javascript", mjs: "javascript", cjs: "javascript", ts: "typescript", mts: "typescript",
|
|
276
|
+
jsx: "jsx", tsx: "tsx", json: "json", jsonc: "jsonc", yml: "yaml", yaml: "yaml", toml: "toml", html: "html", htm: "html", css: "css", scss: "scss",
|
|
277
|
+
vue: "vue", svelte: "svelte", py: "python", go: "go", rs: "rust", java: "java", kt: "kotlin", swift: "swift", c: "c", h: "c", cpp: "cpp", cc: "cpp",
|
|
278
|
+
hpp: "cpp", cs: "csharp", php: "php", rb: "ruby", dart: "dart", scala: "scala", hs: "haskell", ex: "elixir", exs: "elixir", erl: "erlang",
|
|
279
|
+
clj: "clojure", lua: "lua", pl: "perl", r: "r", jl: "julia", zig: "zig", nim: "nim", ml: "ocaml", sh: "shellscript", bash: "shellscript",
|
|
280
|
+
zsh: "shellscript", ps1: "powershell", bat: "bat", fish: "fish", nix: "nix", sql: "sql", graphql: "graphql", gql: "graphql", prisma: "prisma",
|
|
281
|
+
proto: "proto", xml: "xml", svg: "xml", diff: "diff", patch: "diff", log: "log", csv: "csv", tex: "latex", tf: "terraform", hcl: "terraform",
|
|
282
|
+
ini: "ini", conf: "ini", cfg: "ini", env: "dotenv", mmd: "mermaid", sol: "solidity", http: "http",
|
|
283
|
+
png: "png", jpg: "jpeg", jpeg: "jpeg", gif: "gif", webp: "webp",
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
/** Image "languages": the content is the file as base64 and the server caps the decoded size. */
|
|
287
|
+
const IMAGE_LANGS = new Set(["png", "jpeg", "gif", "webp"]);
|
|
288
|
+
const MAX_IMAGE_BYTES = 700 * 1024;
|
|
289
|
+
|
|
290
|
+
function imageItem(buf, lang, title) {
|
|
291
|
+
if (buf.length > MAX_IMAGE_BYTES) throw new CliError(`${title} is ${Math.round(buf.length / 1024)} KB; images are limited to ${MAX_IMAGE_BYTES / 1024} KB`);
|
|
292
|
+
return { content: buf.toString("base64"), lang, title };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function langFromFilename(name) {
|
|
296
|
+
const lower = name.toLowerCase();
|
|
297
|
+
if (lower === "dockerfile" || lower.startsWith("dockerfile.")) return "dockerfile";
|
|
298
|
+
if (lower === "makefile") return "makefile";
|
|
299
|
+
if (lower === ".env" || lower.startsWith(".env.")) return "dotenv";
|
|
300
|
+
const i = lower.lastIndexOf(".");
|
|
301
|
+
return i === -1 ? undefined : EXT[lower.slice(i + 1)];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function parsePasteRef(ref) {
|
|
305
|
+
// Accepts an id, or a URL like https://host/AbCd1234.go#key or https://host/AbCd1234/raw
|
|
306
|
+
let id = ref;
|
|
307
|
+
let key;
|
|
308
|
+
let host;
|
|
309
|
+
try {
|
|
310
|
+
const u = new URL(ref);
|
|
311
|
+
host = u.origin;
|
|
312
|
+
const seg = u.pathname.split("/").filter(Boolean)[0] ?? "";
|
|
313
|
+
id = seg.split(".")[0];
|
|
314
|
+
if (u.hash && !/^#L\d/.test(u.hash)) key = u.hash.slice(1);
|
|
315
|
+
} catch {
|
|
316
|
+
/* plain id */
|
|
317
|
+
}
|
|
318
|
+
if (!/^[A-Za-z0-9]{4,32}$/.test(id)) throw new UsageError(`"${ref}" doesn't look like a paste id or URL`);
|
|
319
|
+
return { id, key, host };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// API
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
class UsageError extends Error {}
|
|
327
|
+
class CliError extends Error {}
|
|
328
|
+
|
|
329
|
+
async function api(host, path, init = {}) {
|
|
330
|
+
let res;
|
|
331
|
+
try {
|
|
332
|
+
res = await fetch(host + path, {
|
|
333
|
+
...init,
|
|
334
|
+
headers: { Accept: "application/json", "User-Agent": `${NAME}-cli/${VERSION}`, ...(init.body ? { "Content-Type": "application/json" } : {}), ...(init.headers ?? {}) },
|
|
335
|
+
});
|
|
336
|
+
} catch (e) {
|
|
337
|
+
throw new CliError(`could not reach ${host} (${e.cause?.code ?? e.message})`);
|
|
338
|
+
}
|
|
339
|
+
if (res.status === 204) return null;
|
|
340
|
+
const text = await res.text();
|
|
341
|
+
let data = null;
|
|
342
|
+
try {
|
|
343
|
+
data = JSON.parse(text);
|
|
344
|
+
} catch {
|
|
345
|
+
/* not json */
|
|
346
|
+
}
|
|
347
|
+
if (!res.ok) {
|
|
348
|
+
const msg = data?.error?.message ?? text.trim() ?? res.statusText;
|
|
349
|
+
throw new CliError(`${res.status} ${msg}`);
|
|
350
|
+
}
|
|
351
|
+
return data;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function createPaste(host, { content, title, lang, expires, burn, encrypt, password }) {
|
|
355
|
+
let body;
|
|
356
|
+
let fragment;
|
|
357
|
+
if (encrypt || password !== undefined) {
|
|
358
|
+
const r = await encryptEnvelope({ title, lang: lang ?? "text", content }, { password });
|
|
359
|
+
fragment = r.fragment;
|
|
360
|
+
body = { content: r.ciphertext, enc: r.meta, expires, burn };
|
|
361
|
+
} else {
|
|
362
|
+
body = { content, title, lang, expires, burn };
|
|
363
|
+
}
|
|
364
|
+
const p = await api(host, "/api/v1/pastes", { method: "POST", body: JSON.stringify(body) });
|
|
365
|
+
const url = fragment ? `${p.url}#${fragment}` : p.url;
|
|
366
|
+
remember({ id: p.id, url, editToken: p.editToken, key: fragment, title, lang: p.lang, created: p.created, expires: p.expires, burn: p.burn, encrypted: !!p.enc });
|
|
367
|
+
return { ...p, url };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
// CLI
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
const HELP = `${NAME} ${VERSION} · paste from the terminal
|
|
375
|
+
|
|
376
|
+
Usage
|
|
377
|
+
${NAME} [options] [file ...] paste files (text, or png/jpeg/gif/webp up to 700 KB), or stdin
|
|
378
|
+
${NAME} clip [options] paste the clipboard (text or an image)
|
|
379
|
+
${NAME} text [options] <words ...> paste literal text
|
|
380
|
+
${NAME} get <id|url> [--json] print a paste (decrypts when the URL carries a #key)
|
|
381
|
+
${NAME} ls [--json] browse pastes made here: arrows or click, enter reveals the edit token
|
|
382
|
+
${NAME} rm <id|url> delete a paste created from this machine
|
|
383
|
+
${NAME} token <id|url> print the edit token (paste it into the site's Edit/Delete prompt)
|
|
384
|
+
${NAME} config [host <url>] show or set the default host
|
|
385
|
+
|
|
386
|
+
Options
|
|
387
|
+
-t, --title <text> title (defaults to the file name)
|
|
388
|
+
-l, --lang <id> language id or alias (default: from the file name, else plain text)
|
|
389
|
+
-e, --expires <when> 10m | 1h | 1d | 7d | 30d | never (default: 7d)
|
|
390
|
+
-b, --burn destroy after the first read
|
|
391
|
+
-E, --encrypt encrypt here; the key goes in the URL after #
|
|
392
|
+
-p, --password <pw> encrypt with a password instead ("-P" prompts for it)
|
|
393
|
+
-P, --ask-password prompt for a password
|
|
394
|
+
-c, --copy copy the URL to the clipboard
|
|
395
|
+
-o, --open open the URL in a browser
|
|
396
|
+
-r, --raw print the raw URL (plain text) instead of the page URL
|
|
397
|
+
-j, --json print the full API response
|
|
398
|
+
-H, --host <url> server to use (default ${DEFAULT_HOST}; env PASTR_HOST, or \`${NAME} config host …\`)
|
|
399
|
+
-h, --help show this help
|
|
400
|
+
-v, --version show the version
|
|
401
|
+
|
|
402
|
+
Examples
|
|
403
|
+
ls -la | ${NAME}
|
|
404
|
+
${NAME} main.go --expires 1d
|
|
405
|
+
${NAME} clip -E -c # encrypted clipboard paste, URL copied back
|
|
406
|
+
${NAME} text "hello there" -b # burn after read
|
|
407
|
+
${NAME} get https://host/AbCd1234#key > file.txt
|
|
408
|
+
${NAME} shot.png -e 1d # image paste; "get" writes the bytes back
|
|
409
|
+
`;
|
|
410
|
+
|
|
411
|
+
/** Bold section headings, cyan command/flag column, dim trailing comments. */
|
|
412
|
+
const styleHelp = (s) =>
|
|
413
|
+
s
|
|
414
|
+
.split("\n")
|
|
415
|
+
.map((l) => (/^\S/.test(l) ? bold(l) : l.replace(/^(\s+)(\S.*?)(\s{2,}|$)/, (_, a, b, c) => a + cyan(b) + c).replace(/#.*$/, dim)))
|
|
416
|
+
.join("\n");
|
|
417
|
+
|
|
418
|
+
function fmtRel(ms) {
|
|
419
|
+
const d = ms - Date.now();
|
|
420
|
+
const abs = Math.abs(d);
|
|
421
|
+
const u = abs < 60e3 ? [1e3, "s"] : abs < 3600e3 ? [60e3, "m"] : abs < 86400e3 ? [3600e3, "h"] : [86400e3, "d"];
|
|
422
|
+
const n = Math.round(abs / u[0]);
|
|
423
|
+
return d >= 0 ? `in ${n}${u[1]}` : `${n}${u[1]} ago`;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const flags = (p) => [p.encrypted && "enc", p.burn && "burn"].filter(Boolean).join(" ");
|
|
427
|
+
|
|
428
|
+
/** Keys stay out of the table (`get <id>` finds them in history); the piped form has the full URL. */
|
|
429
|
+
function lsTable(list, selected = -1) {
|
|
430
|
+
const head = ["id", "title", "lang", "created", "expires", "", "url"];
|
|
431
|
+
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]]);
|
|
432
|
+
// Narrow window: drop the url column (the id is enough for `get`) rather than mangling it.
|
|
433
|
+
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);
|
|
434
|
+
if (need > (process.stdout.columns || 120)) for (const r of [head, ...rows]) r.pop();
|
|
435
|
+
return table(head, rows, [cyan, undefined, dim, dim, undefined, yellow, dim], 1, process.stdout.columns || 120, selected);
|
|
92
436
|
}
|
|
93
437
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
?
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
} catch {
|
|
132
|
-
return "";
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function tryExec(cmds, encoding = "utf8") {
|
|
137
|
-
for (const [cmd, args] of cmds) {
|
|
138
|
-
try {
|
|
139
|
-
return execFileSync(cmd, args, { encoding, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 });
|
|
140
|
-
} catch {
|
|
141
|
-
/* try the next one */
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
return null;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
export function readClipboard() {
|
|
148
|
-
const os = platform();
|
|
149
|
-
const cmds =
|
|
150
|
-
os === "darwin"
|
|
151
|
-
? [["pbpaste", []]]
|
|
152
|
-
: os === "win32"
|
|
153
|
-
? [["powershell", ["-NoProfile", "-Command", "Get-Clipboard -Raw"]]]
|
|
154
|
-
: [
|
|
155
|
-
...(process.env.WAYLAND_DISPLAY ? [["wl-paste", ["--no-newline"]]] : []),
|
|
156
|
-
["xclip", ["-selection", "clipboard", "-o"]],
|
|
157
|
-
["xsel", ["--clipboard", "--output"]],
|
|
158
|
-
...(isWsl() ? [["powershell.exe", ["-NoProfile", "-Command", "Get-Clipboard -Raw"]]] : []),
|
|
159
|
-
];
|
|
160
|
-
const out = tryExec(cmds);
|
|
161
|
-
if (out === null) throw new CliError("could not read the clipboard (install wl-clipboard, xclip or xsel on Linux)");
|
|
162
|
-
return out;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/** PNG bytes of an image on the clipboard (a screenshot, say), or null when the clipboard holds no image. */
|
|
166
|
-
export function readClipboardImage() {
|
|
167
|
-
const os = platform();
|
|
168
|
-
const ps = "$i = Get-Clipboard -Format Image; if ($i) { $m = New-Object IO.MemoryStream; $i.Save($m, [Drawing.Imaging.ImageFormat]::Png); [Convert]::ToBase64String($m.ToArray()) }";
|
|
169
|
-
if (os === "darwin") {
|
|
170
|
-
// osascript prints the PNG as «data PNGf89504E47...»
|
|
171
|
-
const hex = tryExec([["osascript", ["-e", "the clipboard as «class PNGf»"]]])?.match(/PNGf([0-9A-Fa-f]+)/)?.[1];
|
|
172
|
-
return hex ? Buffer.from(hex, "hex") : null;
|
|
173
|
-
}
|
|
174
|
-
if (os === "win32" || isWsl()) {
|
|
175
|
-
const b64 = tryExec([[os === "win32" ? "powershell" : "powershell.exe", ["-NoProfile", "-Command", ps]]])?.trim();
|
|
176
|
-
return b64 ? Buffer.from(b64, "base64") : null;
|
|
177
|
-
}
|
|
178
|
-
const buf = tryExec(
|
|
179
|
-
[...(process.env.WAYLAND_DISPLAY ? [["wl-paste", ["-t", "image/png"]]] : []), ["xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]]],
|
|
180
|
-
"buffer",
|
|
181
|
-
);
|
|
182
|
-
return buf?.length ? buf : null;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
export function writeClipboard(text) {
|
|
186
|
-
const os = platform();
|
|
187
|
-
const cmds =
|
|
188
|
-
os === "darwin"
|
|
189
|
-
? [["pbcopy", []]]
|
|
190
|
-
: os === "win32"
|
|
191
|
-
? [["clip", []]]
|
|
192
|
-
: [
|
|
193
|
-
...(process.env.WAYLAND_DISPLAY ? [["wl-copy", []]] : []),
|
|
194
|
-
["xclip", ["-selection", "clipboard"]],
|
|
195
|
-
["xsel", ["--clipboard", "--input"]],
|
|
196
|
-
...(isWsl() ? [["clip.exe", []]] : []),
|
|
197
|
-
];
|
|
198
|
-
for (const [cmd, args] of cmds) {
|
|
199
|
-
const r = spawnSync(cmd, args, { input: text, stdio: ["pipe", "ignore", "ignore"] });
|
|
200
|
-
if (r.status === 0) return true;
|
|
201
|
-
}
|
|
202
|
-
return false;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function openInBrowser(url) {
|
|
206
|
-
const os = platform();
|
|
207
|
-
const [cmd, args] = os === "darwin" ? ["open", [url]] : os === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
208
|
-
const r = spawnSync(cmd, args, { stdio: "ignore" });
|
|
209
|
-
return r.status === 0;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function promptHidden(question) {
|
|
438
|
+
/**
|
|
439
|
+
* Interactive `ls`: arrow keys or a mouse click pick a paste, Enter (or a second click) reveals its
|
|
440
|
+
* edit token, once more copies it. Runs on the alternate screen so the shell scrollback stays clean.
|
|
441
|
+
*/
|
|
442
|
+
function browse(list) {
|
|
443
|
+
const { stdin, stdout } = process;
|
|
444
|
+
let sel = 0;
|
|
445
|
+
let revealed = false;
|
|
446
|
+
let msg = "";
|
|
447
|
+
let confirmDelete = false;
|
|
448
|
+
const rowsVisible = () => Math.max(3, (stdout.rows || 24) - 10);
|
|
449
|
+
const first = () => Math.min(Math.max(0, sel - rowsVisible() + 1), Math.max(0, list.length - rowsVisible()));
|
|
450
|
+
const draw = () => {
|
|
451
|
+
const p = list[sel];
|
|
452
|
+
const start = first();
|
|
453
|
+
const detail = [
|
|
454
|
+
` ${cyan(p.id)} ${p.title || dim("untitled")}`,
|
|
455
|
+
` ${dim("url ")}${link(p.url)}`,
|
|
456
|
+
` ${dim("token ")}${p.editToken ? (revealed ? yellow(p.editToken) : dim("•".repeat(24) + " enter to reveal")) : dim("not on this machine")}`,
|
|
457
|
+
"",
|
|
458
|
+
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"),
|
|
459
|
+
];
|
|
460
|
+
stdout.write("\x1b[H\x1b[2J" + lsTable(list.slice(start, start + rowsVisible()), sel - start) + detail.join("\n") + "\n");
|
|
461
|
+
};
|
|
462
|
+
const enter = async () => {
|
|
463
|
+
const p = list[sel];
|
|
464
|
+
if (!p.editToken) return (msg = yellow("no token stored for this paste"));
|
|
465
|
+
if (!revealed) return (revealed = true);
|
|
466
|
+
msg = writeClipboard(p.editToken) ? green("✓ token copied") : yellow("could not copy");
|
|
467
|
+
};
|
|
468
|
+
const select = (i) => {
|
|
469
|
+
if (i === sel || i < 0 || i >= list.length) return false;
|
|
470
|
+
sel = i;
|
|
471
|
+
revealed = false;
|
|
472
|
+
msg = "";
|
|
473
|
+
return true;
|
|
474
|
+
};
|
|
213
475
|
return new Promise((resolve) => {
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
476
|
+
const done = () => {
|
|
477
|
+
stdout.write("\x1b[?1006l\x1b[?1000l\x1b[?25h\x1b[?1049l");
|
|
478
|
+
stdin.setRawMode(false);
|
|
479
|
+
stdin.pause();
|
|
480
|
+
resolve();
|
|
219
481
|
};
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
482
|
+
stdin.setRawMode(true);
|
|
483
|
+
stdin.resume();
|
|
484
|
+
stdin.setEncoding("utf8");
|
|
485
|
+
stdout.write("\x1b[?1049h\x1b[?25l\x1b[?1000h\x1b[?1006h");
|
|
486
|
+
draw();
|
|
487
|
+
stdin.on("data", async (k) => {
|
|
488
|
+
const mouse = /^\x1b\[<(\d+);(\d+);(\d+)M/.exec(k);
|
|
489
|
+
if (confirmDelete) {
|
|
490
|
+
confirmDelete = false;
|
|
491
|
+
if (k === "y") {
|
|
492
|
+
const p = list[sel];
|
|
493
|
+
try {
|
|
494
|
+
await api(new URL(p.url).origin, `/api/v1/pastes/${p.id}`, { method: "DELETE", headers: { Authorization: `Bearer ${p.editToken}` } });
|
|
495
|
+
forget(p.id);
|
|
496
|
+
list.splice(sel, 1);
|
|
497
|
+
if (!list.length) return done();
|
|
498
|
+
sel = Math.min(sel, list.length - 1);
|
|
499
|
+
msg = green(`✓ deleted ${p.id}`);
|
|
500
|
+
} catch (err) {
|
|
501
|
+
msg = yellow(err.message);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
} else if (mouse) {
|
|
505
|
+
const [, button, , y] = mouse.map(Number);
|
|
506
|
+
if (button === 64) select(sel - 1);
|
|
507
|
+
else if (button === 65) select(sel + 1);
|
|
508
|
+
else if (button === 0) {
|
|
509
|
+
const i = y - 4 + first(); // 3 header lines above the first row
|
|
510
|
+
if (i >= 0 && i < list.length && !select(i)) await enter();
|
|
511
|
+
}
|
|
512
|
+
} else if (k === "\x1b[A" || k === "k") select(sel - 1);
|
|
513
|
+
else if (k === "\x1b[B" || k === "j") select(sel + 1);
|
|
514
|
+
else if (k === "\r") await enter();
|
|
515
|
+
else if (k === "c") msg = writeClipboard(list[sel].url) ? green("✓ url copied") : yellow("could not copy");
|
|
516
|
+
else if (k === "o") openInBrowser(list[sel].url);
|
|
517
|
+
else if (k === "d") confirmDelete = !!list[sel].editToken || ((msg = yellow("no token stored for this paste")), false);
|
|
518
|
+
else if (k === "q" || k === "\x1b" || k === "\x03") return done();
|
|
519
|
+
draw();
|
|
224
520
|
});
|
|
225
521
|
});
|
|
226
522
|
}
|
|
227
523
|
|
|
228
|
-
async function
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const i = lower.lastIndexOf(".");
|
|
266
|
-
return i === -1 ? undefined : EXT[lower.slice(i + 1)];
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
export function parsePasteRef(ref) {
|
|
270
|
-
// Accepts an id, or a URL like https://host/AbCd1234.go#key or https://host/AbCd1234/raw
|
|
271
|
-
let id = ref;
|
|
272
|
-
let key;
|
|
273
|
-
let host;
|
|
274
|
-
try {
|
|
275
|
-
const u = new URL(ref);
|
|
276
|
-
host = u.origin;
|
|
277
|
-
const seg = u.pathname.split("/").filter(Boolean)[0] ?? "";
|
|
278
|
-
id = seg.split(".")[0];
|
|
279
|
-
if (u.hash && !/^#L\d/.test(u.hash)) key = u.hash.slice(1);
|
|
280
|
-
} catch {
|
|
281
|
-
/* plain id */
|
|
282
|
-
}
|
|
283
|
-
if (!/^[A-Za-z0-9]{4,32}$/.test(id)) throw new UsageError(`"${ref}" doesn't look like a paste id or URL`);
|
|
284
|
-
return { id, key, host };
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// ---------------------------------------------------------------------------
|
|
288
|
-
// API
|
|
289
|
-
// ---------------------------------------------------------------------------
|
|
290
|
-
|
|
291
|
-
class UsageError extends Error {}
|
|
292
|
-
class CliError extends Error {}
|
|
293
|
-
|
|
294
|
-
async function api(host, path, init = {}) {
|
|
295
|
-
let res;
|
|
296
|
-
try {
|
|
297
|
-
res = await fetch(host + path, {
|
|
298
|
-
...init,
|
|
299
|
-
headers: { Accept: "application/json", "User-Agent": `${NAME}-cli/${VERSION}`, ...(init.body ? { "Content-Type": "application/json" } : {}), ...(init.headers ?? {}) },
|
|
300
|
-
});
|
|
301
|
-
} catch (e) {
|
|
302
|
-
throw new CliError(`could not reach ${host} (${e.cause?.code ?? e.message})`);
|
|
303
|
-
}
|
|
304
|
-
if (res.status === 204) return null;
|
|
305
|
-
const text = await res.text();
|
|
306
|
-
let data = null;
|
|
307
|
-
try {
|
|
308
|
-
data = JSON.parse(text);
|
|
309
|
-
} catch {
|
|
310
|
-
/* not json */
|
|
311
|
-
}
|
|
312
|
-
if (!res.ok) {
|
|
313
|
-
const msg = data?.error?.message ?? text.trim() ?? res.statusText;
|
|
314
|
-
throw new CliError(`${res.status} ${msg}`);
|
|
315
|
-
}
|
|
316
|
-
return data;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
async function createPaste(host, { content, title, lang, expires, burn, encrypt, password }) {
|
|
320
|
-
let body;
|
|
321
|
-
let fragment;
|
|
322
|
-
if (encrypt || password !== undefined) {
|
|
323
|
-
const r = await encryptEnvelope({ title, lang: lang ?? "text", content }, { password });
|
|
324
|
-
fragment = r.fragment;
|
|
325
|
-
body = { content: r.ciphertext, enc: r.meta, expires, burn };
|
|
326
|
-
} else {
|
|
327
|
-
body = { content, title, lang, expires, burn };
|
|
328
|
-
}
|
|
329
|
-
const p = await api(host, "/api/v1/pastes", { method: "POST", body: JSON.stringify(body) });
|
|
330
|
-
const url = fragment ? `${p.url}#${fragment}` : p.url;
|
|
331
|
-
remember({ id: p.id, url, editToken: p.editToken, key: fragment, title, lang: p.lang, created: p.created, expires: p.expires, burn: p.burn, encrypted: !!p.enc });
|
|
332
|
-
return { ...p, url };
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// ---------------------------------------------------------------------------
|
|
336
|
-
// CLI
|
|
337
|
-
// ---------------------------------------------------------------------------
|
|
338
|
-
|
|
339
|
-
const HELP = `${NAME} ${VERSION} — paste from the terminal
|
|
340
|
-
|
|
341
|
-
Usage
|
|
342
|
-
${NAME} [options] [file ...] paste files (text, or png/jpeg/gif/webp up to 700 KB), or stdin
|
|
343
|
-
${NAME} clip [options] paste the clipboard (text or an image)
|
|
344
|
-
${NAME} text [options] <words ...> paste literal text
|
|
345
|
-
${NAME} get <id|url> [--json] print a paste (decrypts when the URL carries a #key)
|
|
346
|
-
${NAME} ls pastes created from this machine
|
|
347
|
-
${NAME} rm <id|url> delete a paste created from this machine
|
|
348
|
-
${NAME} config [host <url>] show or set the default host
|
|
349
|
-
|
|
350
|
-
Options
|
|
351
|
-
-t, --title <text> title (defaults to the file name)
|
|
352
|
-
-l, --lang <id> language id or alias (default: from the file name, else plain text)
|
|
353
|
-
-e, --expires <when> 10m | 1h | 1d | 7d | 30d | never (default: 7d)
|
|
354
|
-
-b, --burn destroy after the first read
|
|
355
|
-
-E, --encrypt encrypt here; the key goes in the URL after #
|
|
356
|
-
-p, --password <pw> encrypt with a password instead ("-P" prompts for it)
|
|
357
|
-
-P, --ask-password prompt for a password
|
|
358
|
-
-c, --copy copy the URL to the clipboard
|
|
359
|
-
-o, --open open the URL in a browser
|
|
360
|
-
-r, --raw print the raw URL (plain text) instead of the page URL
|
|
361
|
-
-j, --json print the full API response
|
|
362
|
-
-H, --host <url> server to use (env PASTR_HOST, or \`${NAME} config host …\`)
|
|
363
|
-
-h, --help show this help
|
|
364
|
-
-v, --version show the version
|
|
365
|
-
|
|
366
|
-
Examples
|
|
367
|
-
ls -la | ${NAME}
|
|
368
|
-
${NAME} main.go --expires 1d
|
|
369
|
-
${NAME} clip -E -c # encrypted clipboard paste, URL copied back
|
|
370
|
-
${NAME} text "hello there" -b # burn after read
|
|
371
|
-
${NAME} get https://host/AbCd1234#key > file.txt
|
|
372
|
-
${NAME} shot.png -e 1d # image paste; "get" writes the bytes back
|
|
373
|
-
`;
|
|
374
|
-
|
|
375
|
-
function fmtRel(ms) {
|
|
376
|
-
const d = ms - Date.now();
|
|
377
|
-
const abs = Math.abs(d);
|
|
378
|
-
const u = abs < 60e3 ? [1e3, "s"] : abs < 3600e3 ? [60e3, "m"] : abs < 86400e3 ? [3600e3, "h"] : [86400e3, "d"];
|
|
379
|
-
const n = Math.round(abs / u[0]);
|
|
380
|
-
return d >= 0 ? `in ${n}${u[1]}` : `${n}${u[1]} ago`;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
export async function main(argv) {
|
|
384
|
-
const { values: o, positionals } = parseArgs({
|
|
385
|
-
args: argv,
|
|
386
|
-
allowPositionals: true,
|
|
387
|
-
options: {
|
|
388
|
-
title: { type: "string", short: "t" },
|
|
389
|
-
lang: { type: "string", short: "l" },
|
|
390
|
-
expires: { type: "string", short: "e" },
|
|
391
|
-
burn: { type: "boolean", short: "b", default: false },
|
|
392
|
-
encrypt: { type: "boolean", short: "E", default: false },
|
|
393
|
-
password: { type: "string", short: "p" },
|
|
394
|
-
"ask-password": { type: "boolean", short: "P", default: false },
|
|
395
|
-
copy: { type: "boolean", short: "c", default: false },
|
|
396
|
-
open: { type: "boolean", short: "o", default: false },
|
|
397
|
-
raw: { type: "boolean", short: "r", default: false },
|
|
398
|
-
json: { type: "boolean", short: "j", default: false },
|
|
399
|
-
host: { type: "string", short: "H" },
|
|
400
|
-
help: { type: "boolean", short: "h", default: false },
|
|
401
|
-
version: { type: "boolean", short: "v", default: false },
|
|
402
|
-
},
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
if (o.help) return out(HELP);
|
|
406
|
-
if (o.version) return out(`${NAME} ${VERSION}\n`);
|
|
407
|
-
|
|
408
|
-
const [cmd, ...rest] = positionals;
|
|
409
|
-
|
|
410
|
-
if (cmd === "config") {
|
|
411
|
-
if (rest[0] === "host" && rest[1]) {
|
|
412
|
-
setConfig({ host: rest[1].replace(/\/+$/, "") });
|
|
413
|
-
return out(`host set to ${rest[1]}\n`);
|
|
414
|
-
}
|
|
415
|
-
const cfg = getConfig();
|
|
416
|
-
return out(`host: ${process.env.PASTR_HOST || cfg.host || "(not set)"}\nconfig: ${configDir()}\n`);
|
|
417
|
-
}
|
|
418
|
-
|
|
524
|
+
export async function main(argv) {
|
|
525
|
+
const { values: o, positionals } = parseArgs({
|
|
526
|
+
args: argv,
|
|
527
|
+
allowPositionals: true,
|
|
528
|
+
options: {
|
|
529
|
+
title: { type: "string", short: "t" },
|
|
530
|
+
lang: { type: "string", short: "l" },
|
|
531
|
+
expires: { type: "string", short: "e" },
|
|
532
|
+
burn: { type: "boolean", short: "b", default: false },
|
|
533
|
+
encrypt: { type: "boolean", short: "E", default: false },
|
|
534
|
+
password: { type: "string", short: "p" },
|
|
535
|
+
"ask-password": { type: "boolean", short: "P", default: false },
|
|
536
|
+
copy: { type: "boolean", short: "c", default: false },
|
|
537
|
+
open: { type: "boolean", short: "o", default: false },
|
|
538
|
+
raw: { type: "boolean", short: "r", default: false },
|
|
539
|
+
json: { type: "boolean", short: "j", default: false },
|
|
540
|
+
host: { type: "string", short: "H" },
|
|
541
|
+
help: { type: "boolean", short: "h", default: false },
|
|
542
|
+
version: { type: "boolean", short: "v", default: false },
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
if (o.help) return out(styleHelp(HELP));
|
|
547
|
+
if (o.version) return out(`${NAME} ${VERSION}\n`);
|
|
548
|
+
|
|
549
|
+
const [cmd, ...rest] = positionals;
|
|
550
|
+
|
|
551
|
+
if (cmd === "config") {
|
|
552
|
+
if (rest[0] === "host" && rest[1]) {
|
|
553
|
+
setConfig({ host: rest[1].replace(/\/+$/, "") });
|
|
554
|
+
return ok(`host set to ${rest[1]}`);
|
|
555
|
+
}
|
|
556
|
+
const cfg = getConfig();
|
|
557
|
+
const source = process.env.PASTR_HOST ? "env PASTR_HOST" : cfg.host ? "config" : "default";
|
|
558
|
+
return out(`${dim("host ")} ${link(process.env.PASTR_HOST || cfg.host || DEFAULT_HOST)} ${dim(`(${source})`)}\n${dim("config ")} ${configDir()}\n`);
|
|
559
|
+
}
|
|
560
|
+
|
|
419
561
|
if (cmd === "ls") {
|
|
420
562
|
const list = getHistory();
|
|
421
|
-
if (
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
if (cmd === "get") {
|
|
430
|
-
if (!rest[0]) throw new UsageError("usage: get <id|url>");
|
|
431
|
-
const ref = parsePasteRef(rest[0]);
|
|
432
|
-
const host = ref.host ?? resolveHost(o.host);
|
|
433
|
-
const p = await api(host, `/api/v1/pastes/${ref.id}`);
|
|
434
|
-
if (o.json) return out(JSON.stringify(p, null, 2) + "\n");
|
|
435
|
-
if (!p.enc) return emit(p.content, p.lang);
|
|
436
|
-
let secret;
|
|
437
|
-
if (p.enc.kdf === "fragment") {
|
|
438
|
-
const key = ref.key ?? getHistory().find((h) => h.id === ref.id)?.key;
|
|
439
|
-
if (!key) throw new CliError("this paste is encrypted; pass the full URL including the #key");
|
|
440
|
-
secret = { fragment: key };
|
|
441
|
-
} else {
|
|
442
|
-
secret = { password: o.password ?? (await promptHidden("Password: ")) };
|
|
563
|
+
if (o.json) return out(JSON.stringify(list, null, 2) + "\n");
|
|
564
|
+
if (!list.length) return out(`no pastes yet${OUT_TTY ? dim(` · try: ls -la | ${NAME}`) : ""}\n`);
|
|
565
|
+
// Piped: one tab-separated line per paste, ISO dates. TTY: a table sized to the window.
|
|
566
|
+
if (!OUT_TTY) {
|
|
567
|
+
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");
|
|
568
|
+
return;
|
|
443
569
|
}
|
|
444
|
-
|
|
445
|
-
|
|
570
|
+
if (process.stdin.isTTY) return browse(list);
|
|
571
|
+
out(lsTable(list));
|
|
572
|
+
return out(dim(` ${list.length} ${list.length === 1 ? "paste" : "pastes"} · ${NAME} get <id> · ${NAME} rm <id>\n`));
|
|
446
573
|
}
|
|
447
574
|
|
|
448
|
-
if (cmd === "
|
|
449
|
-
if (!rest[0]) throw new UsageError("usage:
|
|
450
|
-
const ref = parsePasteRef(rest[0]);
|
|
451
|
-
const host = ref.host ?? resolveHost(o.host);
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
if (!
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
if (!rest
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
if (
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
if (
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
}
|
|
575
|
+
if (cmd === "get") {
|
|
576
|
+
if (!rest[0]) throw new UsageError("usage: get <id|url>");
|
|
577
|
+
const ref = parsePasteRef(rest[0]);
|
|
578
|
+
const host = ref.host ?? resolveHost(o.host);
|
|
579
|
+
const p = await api(host, `/api/v1/pastes/${ref.id}`);
|
|
580
|
+
if (o.json) return out(JSON.stringify(p, null, 2) + "\n");
|
|
581
|
+
if (!p.enc) return emit(p.content, p.lang);
|
|
582
|
+
let secret;
|
|
583
|
+
if (p.enc.kdf === "fragment") {
|
|
584
|
+
const key = ref.key ?? getHistory().find((h) => h.id === ref.id)?.key;
|
|
585
|
+
if (!key) throw new CliError("this paste is encrypted; pass the full URL including the #key");
|
|
586
|
+
secret = { fragment: key };
|
|
587
|
+
} else {
|
|
588
|
+
secret = { password: o.password ?? (await promptHidden("Password: ")) };
|
|
589
|
+
}
|
|
590
|
+
const env = await decryptEnvelope(p.content, p.enc, secret);
|
|
591
|
+
return emit(env.content, env.lang);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (cmd === "token") {
|
|
595
|
+
if (!rest[0]) throw new UsageError("usage: token <id|url>");
|
|
596
|
+
const { id } = parsePasteRef(rest[0]);
|
|
597
|
+
const entry = getHistory().find((h) => h.id === id);
|
|
598
|
+
if (!entry?.editToken) throw new CliError(`no edit token for ${id} on this machine`);
|
|
599
|
+
if (OUT_TTY) note("Anyone with this token can edit or delete the paste.");
|
|
600
|
+
return out(entry.editToken + "\n");
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (cmd === "rm") {
|
|
604
|
+
if (!rest[0]) throw new UsageError("usage: rm <id|url>");
|
|
605
|
+
const ref = parsePasteRef(rest[0]);
|
|
606
|
+
const host = ref.host ?? resolveHost(o.host);
|
|
607
|
+
const entry = getHistory().find((h) => h.id === ref.id);
|
|
608
|
+
const token = entry?.editToken ?? process.env.PASTR_EDIT_TOKEN;
|
|
609
|
+
if (!token) throw new CliError(`no edit token for ${ref.id} on this machine (set PASTR_EDIT_TOKEN to use one)`);
|
|
610
|
+
await api(host, `/api/v1/pastes/${ref.id}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` } });
|
|
611
|
+
forget(ref.id);
|
|
612
|
+
return ok(`deleted ${ref.id}`);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ---- create ----
|
|
616
|
+
const host = resolveHost(o.host);
|
|
617
|
+
const password = o["ask-password"] ? await promptHidden("Password: ") : o.password;
|
|
618
|
+
const items = [];
|
|
619
|
+
if (cmd === "clip") {
|
|
620
|
+
const img = readClipboardImage();
|
|
621
|
+
items.push(img ? imageItem(img, "png", o.title ?? "clipboard.png") : { content: readClipboard(), title: o.title });
|
|
622
|
+
} else if (cmd === "text") {
|
|
623
|
+
if (!rest.length) throw new UsageError("usage: text <words ...>");
|
|
624
|
+
items.push({ content: rest.join(" "), title: o.title });
|
|
625
|
+
} else {
|
|
626
|
+
const files = cmd ? [cmd, ...rest] : [];
|
|
627
|
+
if (!files.length) items.push({ content: await readStdin(), title: o.title });
|
|
628
|
+
for (const f of files) {
|
|
629
|
+
if (!existsSync(f)) throw new UsageError(`no such file: ${f}`);
|
|
630
|
+
const buf = readFileSync(f);
|
|
631
|
+
const lang = o.lang ?? langFromFilename(basename(f));
|
|
632
|
+
if (IMAGE_LANGS.has(lang)) {
|
|
633
|
+
items.push(imageItem(buf, lang, o.title ?? basename(f)));
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
if (buf.includes(0)) throw new CliError(`${f} looks binary; only text and png/jpeg/gif/webp images can be pasted`);
|
|
637
|
+
items.push({ content: buf.toString("utf8"), title: o.title ?? basename(f), lang });
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
for (const item of items) {
|
|
642
|
+
if (!item.content.trim()) throw new CliError("nothing to paste");
|
|
643
|
+
const p = await createPaste(host, {
|
|
644
|
+
content: item.content,
|
|
645
|
+
title: item.title,
|
|
646
|
+
lang: item.lang ?? o.lang,
|
|
647
|
+
expires: o.expires,
|
|
648
|
+
burn: o.burn,
|
|
649
|
+
encrypt: o.encrypt,
|
|
650
|
+
password,
|
|
651
|
+
});
|
|
652
|
+
const url = o.raw ? p.rawUrl : p.url;
|
|
653
|
+
if (o.json) out(JSON.stringify(p, null, 2) + "\n");
|
|
654
|
+
else out(link(url) + "\n");
|
|
655
|
+
const copied = o.copy ? (writeClipboard(url) ? "copied to clipboard" : "could not copy to clipboard") : "";
|
|
656
|
+
// The URL alone goes to stdout; everything else is a dim stderr line so pipes stay clean.
|
|
657
|
+
if (!o.json && (ERR_TTY || copied)) {
|
|
658
|
+
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];
|
|
659
|
+
note(meta.filter(Boolean).join(" · "));
|
|
660
|
+
}
|
|
661
|
+
if (o.open) openInBrowser(url);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function out(s) {
|
|
666
|
+
process.stdout.write(s);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Text goes out as is; image pastes are decoded so `get shot > shot.png` works. */
|
|
670
|
+
function emit(content, lang) {
|
|
671
|
+
process.stdout.write(IMAGE_LANGS.has(lang) ? Buffer.from(content, "base64") : content);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const isMain = process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href;
|
|
675
|
+
if (isMain || (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1])) {
|
|
676
|
+
main(process.argv.slice(2)).catch((err) => {
|
|
677
|
+
const usage = err instanceof UsageError || err?.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION" || err?.code?.startsWith?.("ERR_PARSE_ARGS");
|
|
678
|
+
const prefix = ERR_TTY ? styleText("red", "✗") : `${NAME}:`;
|
|
679
|
+
const hint = usage ? `Run \`${NAME} --help\` for usage.\n` : "";
|
|
680
|
+
process.stderr.write(`${prefix} ${err.message}\n${ERR_TTY ? styleText("dim", hint) : hint}`);
|
|
681
|
+
process.exit(usage ? 2 : 1);
|
|
682
|
+
});
|
|
683
|
+
}
|