stdout-chat 0.1.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/LICENSE +21 -0
- package/README.md +114 -0
- package/bin/stdout-chat.js +168 -0
- package/lib/api.js +115 -0
- package/lib/config.js +64 -0
- package/lib/render.js +265 -0
- package/lib/session.js +364 -0
- package/lib/sse.js +114 -0
- package/lib/ui.js +59 -0
- package/package.json +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 stdout.chat
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# stdout-chat
|
|
2
|
+
|
|
3
|
+
`#void` from your terminal. A tiny client for the public room of [stdout.chat](https://stdout.chat) — read it, tail it live, post to it from a shell.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npx stdout-chat
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Zero dependencies · Node ≥ 18 · ESM · MIT · no telemetry.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
#void · what's the most underrated sound? · 3 in room
|
|
13
|
+
a1b2 kira 👑 the hum of a fridge at 3am ❤️ 2 😂 1
|
|
14
|
+
a1b4 mox → kira ceiling fans, obviously
|
|
15
|
+
↳ the hum of a fridge at 3am
|
|
16
|
+
a1b5 anon·c31d ping
|
|
17
|
+
type /key sc_… to post · get it in the app: /key
|
|
18
|
+
>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Reading
|
|
22
|
+
|
|
23
|
+
Anyone can read. The first run prints the last 30 lines and keeps tailing.
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
npx stdout-chat # history + live tail + prompt
|
|
27
|
+
npx stdout-chat --read # print the last N lines and exit
|
|
28
|
+
npx stdout-chat --tail # live tail only, no prompt (pipe-friendly)
|
|
29
|
+
npx stdout-chat -n 100 # more history
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Posting: get a key
|
|
33
|
+
|
|
34
|
+
Posting is tied to your identity in the app. Open **#void** in the stdout.chat iOS app and type `/key` — it whispers you a key starting with `sc_`. Then, at the CLI prompt:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
/key sc_…
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The key is checked against the server, then saved to `~/.config/stdout-chat/config.json` (directory `0700`, file `0600`) with a plain-text twin `~/.config/stdout-chat/key` for shell scripts. Nothing else is stored. Rotate with `/key new` in the app; revoke with `/key off` in the app (`/key off` at the CLI only forgets the local copy).
|
|
41
|
+
|
|
42
|
+
## Commands
|
|
43
|
+
|
|
44
|
+
| at the `>` prompt | |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `anything else` | posted to #void as-is (`@nick` mentions pass through) |
|
|
47
|
+
| `/r <id> text` | reply to a line — ids are the dim column on the left |
|
|
48
|
+
| `/top` | this week's top authors |
|
|
49
|
+
| `/who` | how many are in the room |
|
|
50
|
+
| `/key sc_…` | save a key · `/key` shows who you are · `/key off` forgets it |
|
|
51
|
+
| `/clear` | clear the screen |
|
|
52
|
+
| `/help` | list this |
|
|
53
|
+
| `/quit` | leave (Ctrl-C and Ctrl-D too) |
|
|
54
|
+
|
|
55
|
+
Server errors are printed as the server phrases them (`slow down · retry in 2s`), never as stack traces. Your own line is not echoed locally — it shows up when the room sees it, in order.
|
|
56
|
+
|
|
57
|
+
## Flags
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
--read print the last N lines and exit
|
|
61
|
+
--tail stream only: no history, no prompt
|
|
62
|
+
-n <count> lines of history (default 30, max 100)
|
|
63
|
+
--api <url> API base (default https://api.stdout.chat; env STDOUT_CHAT_API)
|
|
64
|
+
--no-color plain output (NO_COLOR is honoured too)
|
|
65
|
+
--help, --version
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Without the CLI: curl
|
|
69
|
+
|
|
70
|
+
The same room over plain HTTP.
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
curl https://stdout.chat/void
|
|
74
|
+
curl -N https://api.stdout.chat/void/stream
|
|
75
|
+
curl -d 'hi' -H "Authorization: Bearer $(cat ~/.config/stdout-chat/key)" https://stdout.chat/void
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
A `void()` for your `.bashrc` / `.zshrc`:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
void() { # void · void -f · void -r a1b4 text · void some words
|
|
82
|
+
local api=https://stdout.chat/void key=~/.config/stdout-chat/key reply=
|
|
83
|
+
case "$1" in
|
|
84
|
+
"") curl -s "$api"; return ;;
|
|
85
|
+
-f) curl -sN https://api.stdout.chat/void/stream; return ;;
|
|
86
|
+
-r) reply="$2"; shift 2 ;;
|
|
87
|
+
esac
|
|
88
|
+
[ -s "$key" ] || { echo "no key · in the app: /key, then: umask 077; echo sc_… > $key" >&2; return 1; }
|
|
89
|
+
curl -s -H "Authorization: Bearer $(cat "$key")" --data-urlencode "text=$*" ${reply:+--data-urlencode "reply=$reply"} "$api"
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## How it works
|
|
94
|
+
|
|
95
|
+
- `GET /void` for history, `GET /void/stream` (Server-Sent Events) for the live feed, `POST /void` to speak, `GET /void/me` to check a key. All JSON.
|
|
96
|
+
- Reconnects with backoff (1 → 30 s) and `Last-Event-ID`, so nothing is missed across the server's 15-minute stream rotation.
|
|
97
|
+
- Plain scrolling output with a `readline` prompt: no alternate screen, no curses — works in tmux splits and over ssh.
|
|
98
|
+
|
|
99
|
+
## Privacy
|
|
100
|
+
|
|
101
|
+
No telemetry, no analytics, no crash reporting. The only thing the server learns is what any HTTP client sends: your requests and a `User-Agent` of `stdout-chat-cli/<version> node/<version>`. The key lives only in your config directory.
|
|
102
|
+
|
|
103
|
+
## Development
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
git clone https://github.com/stdout-chat/cli && cd cli
|
|
107
|
+
node --test # unit + smoke tests (spins up scripts/stub-server.js)
|
|
108
|
+
node scripts/stub-server.js 4999 # fake server; STUB_KEY=sc_test
|
|
109
|
+
node bin/stdout-chat.js --api http://127.0.0.1:4999
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
MIT © 2026 stdout.chat
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// stdout-chat — #void from your terminal. Zero dependencies, Node ≥ 18.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { createApi, DEFAULT_API, errorMessage } from '../lib/api.js';
|
|
6
|
+
import { loadConfig } from '../lib/config.js';
|
|
7
|
+
import { createUI } from '../lib/ui.js';
|
|
8
|
+
import { Session } from '../lib/session.js';
|
|
9
|
+
import { renderError } from '../lib/render.js';
|
|
10
|
+
|
|
11
|
+
const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
12
|
+
|
|
13
|
+
const USAGE = `stdout-chat ${pkg.version} — #void from your terminal
|
|
14
|
+
|
|
15
|
+
usage: npx stdout-chat [options]
|
|
16
|
+
|
|
17
|
+
--read print the last N lines and exit
|
|
18
|
+
--tail stream only: no history, no prompt
|
|
19
|
+
-n <count> how many lines to load (default 30, max 100)
|
|
20
|
+
--api <url> API base (default ${DEFAULT_API}; env STDOUT_CHAT_API)
|
|
21
|
+
--no-color plain output (NO_COLOR is honoured too)
|
|
22
|
+
-h, --help this text
|
|
23
|
+
-v, --version print the version
|
|
24
|
+
|
|
25
|
+
at the prompt:
|
|
26
|
+
/help /r <id> text /top /who /key sc_… /key off /clear /quit
|
|
27
|
+
anything else is posted to #void
|
|
28
|
+
|
|
29
|
+
key file: ~/.config/stdout-chat/config.json (get a key in the app: /key)
|
|
30
|
+
no telemetry · https://stdout.chat/void`;
|
|
31
|
+
|
|
32
|
+
class UsageError extends Error {}
|
|
33
|
+
|
|
34
|
+
function parseArgs(argv) {
|
|
35
|
+
const o = { read: false, tail: false, api: null, color: true, help: false, version: false, n: 30 };
|
|
36
|
+
for (let i = 0; i < argv.length; i++) {
|
|
37
|
+
const a = argv[i];
|
|
38
|
+
const eq = a.startsWith('--') ? a.indexOf('=') : -1;
|
|
39
|
+
const k = eq > 0 ? a.slice(0, eq) : a;
|
|
40
|
+
const inline = eq > 0 ? a.slice(eq + 1) : undefined;
|
|
41
|
+
const next = () => (inline !== undefined ? inline : argv[++i]);
|
|
42
|
+
switch (k) {
|
|
43
|
+
case '--read': o.read = true; break;
|
|
44
|
+
case '--tail': o.tail = true; break;
|
|
45
|
+
case '--api': o.api = next(); if (!o.api) throw new UsageError('--api needs a url'); break;
|
|
46
|
+
case '--no-color': o.color = false; break;
|
|
47
|
+
case '--color': o.color = true; break;
|
|
48
|
+
case '-h': case '--help': o.help = true; break;
|
|
49
|
+
case '-v': case '--version': o.version = true; break;
|
|
50
|
+
case '-n': case '--lines': {
|
|
51
|
+
const v = next();
|
|
52
|
+
const n = Number(v);
|
|
53
|
+
if (!Number.isInteger(n) || n < 1) throw new UsageError(`-n needs a positive number, got "${v ?? ''}"`);
|
|
54
|
+
o.n = Math.min(n, 100);
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
default:
|
|
58
|
+
throw new UsageError(`unknown option: ${a}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return o;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function wantColor(opts) {
|
|
65
|
+
if (!opts.color) return false;
|
|
66
|
+
if (process.env.NO_COLOR != null && process.env.NO_COLOR !== '') return false;
|
|
67
|
+
if (process.env.FORCE_COLOR != null && process.env.FORCE_COLOR !== '0') return true;
|
|
68
|
+
return Boolean(process.stdout.isTTY);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function main() {
|
|
72
|
+
let opts;
|
|
73
|
+
try {
|
|
74
|
+
opts = parseArgs(process.argv.slice(2));
|
|
75
|
+
} catch (err) {
|
|
76
|
+
process.stderr.write(`${err.message}\n\n${USAGE}\n`);
|
|
77
|
+
return 2;
|
|
78
|
+
}
|
|
79
|
+
if (opts.help) { process.stdout.write(`${USAGE}\n`); return 0; }
|
|
80
|
+
if (opts.version) { process.stdout.write(`${pkg.version}\n`); return 0; }
|
|
81
|
+
|
|
82
|
+
const cfg = loadConfig();
|
|
83
|
+
const explicitApi = opts.api || process.env.STDOUT_CHAT_API || null;
|
|
84
|
+
const base = explicitApi || cfg.api || DEFAULT_API;
|
|
85
|
+
const api = createApi({ base, version: pkg.version });
|
|
86
|
+
const color = wantColor(opts);
|
|
87
|
+
|
|
88
|
+
const mode = opts.read ? 'read'
|
|
89
|
+
: opts.tail ? 'tail'
|
|
90
|
+
: (process.stdin.isTTY && process.stdout.isTTY) ? 'interactive'
|
|
91
|
+
: 'follow'; // piped: history + stream, no prompt
|
|
92
|
+
|
|
93
|
+
const ui = createUI();
|
|
94
|
+
const stop = new AbortController();
|
|
95
|
+
let closing = false;
|
|
96
|
+
|
|
97
|
+
const session = new Session({
|
|
98
|
+
api,
|
|
99
|
+
print: ui.print,
|
|
100
|
+
color,
|
|
101
|
+
width: () => process.stdout.columns || 80,
|
|
102
|
+
key: cfg.key || null,
|
|
103
|
+
persistApi: api.base !== DEFAULT_API ? api.base : null, // keep a non-default api next to the key
|
|
104
|
+
n: opts.n,
|
|
105
|
+
onQuit: () => shutdown(0),
|
|
106
|
+
});
|
|
107
|
+
session.onClear = () => ui.clear();
|
|
108
|
+
|
|
109
|
+
function shutdown(code = 0) {
|
|
110
|
+
if (closing) return;
|
|
111
|
+
closing = true;
|
|
112
|
+
stop.abort();
|
|
113
|
+
ui.close();
|
|
114
|
+
process.exitCode = code;
|
|
115
|
+
const t = setTimeout(() => process.exit(code), 500);
|
|
116
|
+
t.unref();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (mode === 'read') {
|
|
120
|
+
try {
|
|
121
|
+
await session.loadHistory({ show: true, n: opts.n });
|
|
122
|
+
return 0;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
process.stdout.write(`${renderError(errorMessage(err), { color })}\n`);
|
|
125
|
+
return 1;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
process.on('SIGTERM', () => shutdown(0));
|
|
130
|
+
process.on('SIGHUP', () => shutdown(0));
|
|
131
|
+
if (mode !== 'interactive') process.on('SIGINT', () => shutdown(0));
|
|
132
|
+
|
|
133
|
+
if (mode !== 'tail') {
|
|
134
|
+
await session.verifyKey();
|
|
135
|
+
try {
|
|
136
|
+
await session.loadHistory({ show: true, n: opts.n });
|
|
137
|
+
} catch (err) {
|
|
138
|
+
session.error(errorMessage(err));
|
|
139
|
+
if (mode === 'follow') return 1;
|
|
140
|
+
}
|
|
141
|
+
if (mode === 'interactive') session.greet();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (mode === 'interactive') {
|
|
145
|
+
ui.start({
|
|
146
|
+
onLine: async (line) => {
|
|
147
|
+
try {
|
|
148
|
+
await session.handleInput(line);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
session.error(errorMessage(err));
|
|
151
|
+
}
|
|
152
|
+
if (/^\s*\/key\s+sc_/i.test(line)) ui.scrubHistory((h) => /^\s*\/key\s+sc_/i.test(h));
|
|
153
|
+
},
|
|
154
|
+
onClose: () => shutdown(0),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
await session.runStream(stop.signal);
|
|
159
|
+
return process.exitCode || 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
main().then(
|
|
163
|
+
(code) => { if (!process.exitCode) process.exitCode = code; },
|
|
164
|
+
(err) => {
|
|
165
|
+
process.stderr.write(`${renderError(errorMessage(err), { color: false })}\n`);
|
|
166
|
+
process.exitCode = 1;
|
|
167
|
+
},
|
|
168
|
+
);
|
package/lib/api.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Thin fetch helpers for the #void HTTP contract. No retries here; the
|
|
2
|
+
// session decides. Every failure becomes an ApiError with a human `message`.
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_API = 'https://api.stdout.chat';
|
|
5
|
+
|
|
6
|
+
export class ApiError extends Error {
|
|
7
|
+
constructor(message, { status = 0, code = null, until = null, retryAfter = null } = {}) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'ApiError';
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.until = until;
|
|
13
|
+
this.retryAfter = retryAfter;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizeBase(base) {
|
|
18
|
+
let b = String(base || DEFAULT_API).trim();
|
|
19
|
+
if (!/^https?:\/\//i.test(b)) b = `https://${b}`;
|
|
20
|
+
return b.replace(/\/+$/, '');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function toError(res) {
|
|
24
|
+
let text = '';
|
|
25
|
+
try { text = await res.text(); } catch { /* body unreadable */ }
|
|
26
|
+
let parsed = null;
|
|
27
|
+
try { parsed = JSON.parse(text); } catch { /* not json */ }
|
|
28
|
+
const retryHeader = Number(res.headers.get('retry-after'));
|
|
29
|
+
const retryAfter = Number.isFinite(retryHeader) && retryHeader > 0 ? retryHeader : null;
|
|
30
|
+
if (parsed && typeof parsed === 'object' && typeof parsed.message === 'string' && parsed.message) {
|
|
31
|
+
return new ApiError(parsed.message, {
|
|
32
|
+
status: res.status,
|
|
33
|
+
code: typeof parsed.error === 'string' ? parsed.error : null,
|
|
34
|
+
until: parsed.until == null ? null : parsed.until,
|
|
35
|
+
retryAfter: parsed.retry_after == null ? retryAfter : Number(parsed.retry_after),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
const plain = text.replace(/\s+/g, ' ').trim();
|
|
39
|
+
return new ApiError(plain || `http ${res.status}`, { status: res.status, retryAfter });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function networkError(err) {
|
|
43
|
+
if (err && err.name === 'AbortError') throw err;
|
|
44
|
+
const cause = err && err.cause;
|
|
45
|
+
const detail = (cause && (cause.code || cause.message)) || (err && err.message) || String(err);
|
|
46
|
+
return new ApiError(`network error · ${detail}`, { status: 0, code: 'network' });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createApi({ base = DEFAULT_API, version = '0.0.0', fetchImpl = null } = {}) {
|
|
50
|
+
const root = normalizeBase(base);
|
|
51
|
+
const userAgent = `stdout-chat-cli/${version} node/${process.version}`;
|
|
52
|
+
const doFetch = (url, init) => (fetchImpl || globalThis.fetch)(url, init);
|
|
53
|
+
|
|
54
|
+
function headers(extra = {}, key = null) {
|
|
55
|
+
const h = { Accept: 'application/json', 'User-Agent': userAgent, ...extra };
|
|
56
|
+
if (key) h.Authorization = `Bearer ${key}`;
|
|
57
|
+
return h;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function json(url, init) {
|
|
61
|
+
let res;
|
|
62
|
+
try { res = await doFetch(url, init); } catch (err) { throw networkError(err); }
|
|
63
|
+
if (!res.ok) throw await toError(res);
|
|
64
|
+
try { return await res.json(); } catch { throw new ApiError('bad response · not json', { status: res.status }); }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
base: root,
|
|
69
|
+
userAgent,
|
|
70
|
+
|
|
71
|
+
/** GET /void?n=N → { topic, count, top_week, messages } */
|
|
72
|
+
getVoid(n = 30) {
|
|
73
|
+
const count = Math.max(1, Math.min(100, Number(n) || 30));
|
|
74
|
+
return json(`${root}/void?n=${count}`, { headers: headers() });
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
/** GET /void/me with Bearer → { username, tag, top?, week? } */
|
|
78
|
+
getMe(key) {
|
|
79
|
+
return json(`${root}/void/me`, { headers: headers({}, key) });
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
/** POST /void {text, reply?} → 201 { id, sid, username, text, ts } */
|
|
83
|
+
post({ text, reply } = {}, key) {
|
|
84
|
+
const body = { text: String(text) };
|
|
85
|
+
if (reply) body.reply = String(reply);
|
|
86
|
+
return json(`${root}/void`, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: headers({ 'Content-Type': 'application/json' }, key),
|
|
89
|
+
body: JSON.stringify(body),
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
/** GET /void/stream → Response (200, body is a ReadableStream). Throws ApiError on non-2xx. */
|
|
94
|
+
async openStream({ lastEventId = null, signal = null } = {}) {
|
|
95
|
+
const extra = { 'Cache-Control': 'no-cache' };
|
|
96
|
+
if (lastEventId != null && lastEventId !== '') extra['Last-Event-ID'] = String(lastEventId);
|
|
97
|
+
let res;
|
|
98
|
+
try {
|
|
99
|
+
res = await doFetch(`${root}/void/stream`, { headers: headers(extra), signal });
|
|
100
|
+
} catch (err) {
|
|
101
|
+
throw networkError(err);
|
|
102
|
+
}
|
|
103
|
+
if (!res.ok) throw await toError(res);
|
|
104
|
+
if (!res.body) throw new ApiError('stream has no body', { status: res.status });
|
|
105
|
+
return res;
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Human message for any thrown value — never a stack trace. */
|
|
111
|
+
export function errorMessage(err) {
|
|
112
|
+
if (err instanceof ApiError) return err.message;
|
|
113
|
+
if (err && typeof err.message === 'string' && err.message) return err.message;
|
|
114
|
+
return String(err);
|
|
115
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Key storage: ~/.config/stdout-chat/config.json { "key": "sc_…", "api": "https://…" }
|
|
2
|
+
// plus a plain-text twin ~/.config/stdout-chat/key so shell one-liners can
|
|
3
|
+
// `$(cat ~/.config/stdout-chat/key)`. Directory 0700, files 0600.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
export function configDir() {
|
|
9
|
+
if (process.env.STDOUT_CHAT_CONFIG_DIR) return process.env.STDOUT_CHAT_CONFIG_DIR;
|
|
10
|
+
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
11
|
+
return path.join(base, 'stdout-chat');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function configPath() {
|
|
15
|
+
return path.join(configDir(), 'config.json');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function keyPath() {
|
|
19
|
+
return path.join(configDir(), 'key');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Returns {} when missing or unreadable; never throws. */
|
|
23
|
+
export function loadConfig() {
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(fs.readFileSync(configPath(), 'utf8'));
|
|
26
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
27
|
+
const out = {};
|
|
28
|
+
if (typeof parsed.key === 'string' && parsed.key) out.key = parsed.key;
|
|
29
|
+
if (typeof parsed.api === 'string' && parsed.api) out.api = parsed.api;
|
|
30
|
+
return out;
|
|
31
|
+
} catch {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function writePrivate(file, content) {
|
|
37
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
38
|
+
fs.writeFileSync(tmp, content, { mode: 0o600 });
|
|
39
|
+
try { fs.chmodSync(tmp, 0o600); } catch { /* win32 */ }
|
|
40
|
+
fs.renameSync(tmp, file);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function rmQuiet(file) {
|
|
44
|
+
try { fs.unlinkSync(file); } catch { /* absent */ }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Writes config.json (+ plain `key` file). `api` is omitted when falsy. */
|
|
48
|
+
export function saveConfig({ key, api } = {}) {
|
|
49
|
+
const dir = configDir();
|
|
50
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
51
|
+
try { fs.chmodSync(dir, 0o700); } catch { /* win32 */ }
|
|
52
|
+
const data = {};
|
|
53
|
+
if (key) data.key = key;
|
|
54
|
+
if (api) data.api = api;
|
|
55
|
+
writePrivate(configPath(), `${JSON.stringify(data, null, 2)}\n`);
|
|
56
|
+
if (key) writePrivate(keyPath(), `${key}\n`);
|
|
57
|
+
else rmQuiet(keyPath());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Deletes the local key files (server-side revoke happens in the app). */
|
|
61
|
+
export function removeConfig() {
|
|
62
|
+
rmQuiet(configPath());
|
|
63
|
+
rmQuiet(keyPath());
|
|
64
|
+
}
|
package/lib/render.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// Pure line rendering: message object → string. No I/O, no process access.
|
|
2
|
+
// Layout (mirrors the app / the text mode of GET /void):
|
|
3
|
+
//
|
|
4
|
+
// a1b4 mox 👑 → kira ceiling fans, obviously ❤️ 2 😂 1
|
|
5
|
+
// ↳ the hum of a fridge at 3am
|
|
6
|
+
// ^sid ^nick(12) ^text column, wrapped with a hanging indent
|
|
7
|
+
|
|
8
|
+
export const CSI = {
|
|
9
|
+
reset: '\x1b[0m',
|
|
10
|
+
dim: '\x1b[2m',
|
|
11
|
+
bold: '\x1b[1m',
|
|
12
|
+
green: '\x1b[32m',
|
|
13
|
+
brightGreen: '\x1b[92m',
|
|
14
|
+
red: '\x1b[31m',
|
|
15
|
+
cyan: '\x1b[36m',
|
|
16
|
+
dimRed: '\x1b[2;31m',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const SID_W = 5;
|
|
20
|
+
export const NICK_W = 12;
|
|
21
|
+
export const TEXT_COL = SID_W + 1 + NICK_W + 1; // 19
|
|
22
|
+
export const QUOTE_MAX = 90;
|
|
23
|
+
export const MEDALS = { 1: '👑', 2: '🥈', 3: '🥉' };
|
|
24
|
+
|
|
25
|
+
/** Returns a paint(str, ...codes) function; identity when colour is off. */
|
|
26
|
+
export function painter(color) {
|
|
27
|
+
if (!color) return (s) => s;
|
|
28
|
+
return (s, ...codes) => (codes.length ? codes.join('') + s + CSI.reset : s);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isWide(cp) {
|
|
32
|
+
return (
|
|
33
|
+
(cp >= 0x1100 && cp <= 0x115f) ||
|
|
34
|
+
(cp >= 0x2e80 && cp <= 0x303e) ||
|
|
35
|
+
(cp >= 0x3041 && cp <= 0x33ff) ||
|
|
36
|
+
(cp >= 0x3400 && cp <= 0x4dbf) ||
|
|
37
|
+
(cp >= 0x4e00 && cp <= 0x9fff) ||
|
|
38
|
+
(cp >= 0xa000 && cp <= 0xa4cf) ||
|
|
39
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
40
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
41
|
+
(cp >= 0xfe30 && cp <= 0xfe4f) ||
|
|
42
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
43
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
44
|
+
(cp >= 0x1f300 && cp <= 0x1f64f) ||
|
|
45
|
+
(cp >= 0x1f680 && cp <= 0x1f6ff) ||
|
|
46
|
+
(cp >= 0x1f900 && cp <= 0x1f9ff) ||
|
|
47
|
+
(cp >= 0x1fa70 && cp <= 0x1faff) ||
|
|
48
|
+
(cp >= 0x20000 && cp <= 0x3fffd) ||
|
|
49
|
+
cp === 0x231a || cp === 0x231b || (cp >= 0x23e9 && cp <= 0x23ec) || cp === 0x23f0 || cp === 0x23f3 ||
|
|
50
|
+
(cp >= 0x25fd && cp <= 0x25fe) || (cp >= 0x2614 && cp <= 0x2615) || (cp >= 0x2648 && cp <= 0x2653) ||
|
|
51
|
+
cp === 0x267f || cp === 0x2693 || cp === 0x26a1 || (cp >= 0x26aa && cp <= 0x26ab) ||
|
|
52
|
+
(cp >= 0x26bd && cp <= 0x26be) || (cp >= 0x26c4 && cp <= 0x26c5) || cp === 0x26ce || cp === 0x26d4 ||
|
|
53
|
+
cp === 0x26ea || (cp >= 0x26f2 && cp <= 0x26f3) || cp === 0x26f5 || cp === 0x26fa || cp === 0x26fd ||
|
|
54
|
+
cp === 0x2705 || (cp >= 0x270a && cp <= 0x270b) || cp === 0x2728 || cp === 0x274c || cp === 0x274e ||
|
|
55
|
+
(cp >= 0x2753 && cp <= 0x2755) || cp === 0x2757 || (cp >= 0x2795 && cp <= 0x2797) || cp === 0x27b0 ||
|
|
56
|
+
cp === 0x27bf || (cp >= 0x2b1b && cp <= 0x2b1c) || cp === 0x2b50 || cp === 0x2b55
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isZeroWidth(cp) {
|
|
61
|
+
return (
|
|
62
|
+
cp === 0x200d || cp === 0xfe0e || cp === 0x20e3 ||
|
|
63
|
+
(cp >= 0x300 && cp <= 0x36f) ||
|
|
64
|
+
(cp >= 0x1f3fb && cp <= 0x1f3ff) ||
|
|
65
|
+
(cp >= 0xe0020 && cp <= 0xe007f) ||
|
|
66
|
+
cp < 0x20 || (cp >= 0x7f && cp < 0xa0)
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Terminal cell width of a string (best effort: CJK/emoji = 2, VS16 promotes, ZWJ joins). */
|
|
71
|
+
export function displayWidth(str) {
|
|
72
|
+
let w = 0;
|
|
73
|
+
let prev = 0;
|
|
74
|
+
for (const ch of String(str)) {
|
|
75
|
+
const cp = ch.codePointAt(0);
|
|
76
|
+
if (cp === 0xfe0f) {
|
|
77
|
+
// Variation selector 16: a narrow symbol rendered as emoji becomes wide.
|
|
78
|
+
if (prev >= 0x2000 && prev <= 0x2bff && !isWide(prev)) w += 1;
|
|
79
|
+
prev = cp;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (isZeroWidth(cp) || prev === 0x200d) { prev = cp; continue; }
|
|
83
|
+
w += isWide(cp) ? 2 : 1;
|
|
84
|
+
prev = cp;
|
|
85
|
+
}
|
|
86
|
+
return w;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Pad on the right with spaces to `n` display cells (never truncates). */
|
|
90
|
+
export function padTo(str, n) {
|
|
91
|
+
const w = displayWidth(str);
|
|
92
|
+
return w >= n ? str : str + ' '.repeat(n - w);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Cut a string to at most `n` cells, appending '…' when cut. */
|
|
96
|
+
export function truncate(str, n) {
|
|
97
|
+
if (displayWidth(str) <= n) return str;
|
|
98
|
+
let out = '';
|
|
99
|
+
let w = 0;
|
|
100
|
+
for (const ch of str) {
|
|
101
|
+
const cw = displayWidth(ch);
|
|
102
|
+
if (w + cw > n - 1) break;
|
|
103
|
+
out += ch;
|
|
104
|
+
w += cw;
|
|
105
|
+
}
|
|
106
|
+
return out + '…';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function splitAt(word, n) {
|
|
110
|
+
let head = '';
|
|
111
|
+
let w = 0;
|
|
112
|
+
let i = 0;
|
|
113
|
+
for (const ch of word) {
|
|
114
|
+
const cw = displayWidth(ch);
|
|
115
|
+
if (w + cw > n) break;
|
|
116
|
+
head += ch;
|
|
117
|
+
w += cw;
|
|
118
|
+
i += ch.length;
|
|
119
|
+
}
|
|
120
|
+
if (!head) { // pathological: first char wider than the line
|
|
121
|
+
const ch = String.fromCodePoint(word.codePointAt(0));
|
|
122
|
+
return [ch, word.slice(ch.length)];
|
|
123
|
+
}
|
|
124
|
+
return [head, word.slice(i)];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Greedy word wrap by display width. First line gets `first` cells, the rest `rest`. */
|
|
128
|
+
export function wrap(text, first, rest = first) {
|
|
129
|
+
const out = [];
|
|
130
|
+
let cur = '';
|
|
131
|
+
let curW = 0;
|
|
132
|
+
let avail = Math.max(first, 8);
|
|
133
|
+
const flush = () => { out.push(cur); cur = ''; curW = 0; avail = Math.max(rest, 8); };
|
|
134
|
+
for (let word of String(text).split(/\s+/).filter(Boolean)) {
|
|
135
|
+
for (;;) {
|
|
136
|
+
const ww = displayWidth(word);
|
|
137
|
+
const need = curW ? curW + 1 + ww : ww;
|
|
138
|
+
if (need <= avail) {
|
|
139
|
+
cur = curW ? `${cur} ${word}` : word;
|
|
140
|
+
curW = need;
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
if (curW) { flush(); continue; }
|
|
144
|
+
const [head, tail] = splitAt(word, avail);
|
|
145
|
+
cur = head;
|
|
146
|
+
curW = displayWidth(head);
|
|
147
|
+
flush();
|
|
148
|
+
word = tail;
|
|
149
|
+
if (!word) break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (cur || out.length === 0) out.push(cur);
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** `nick·tag` only when the nick collides with another tag this session, or for anon. */
|
|
157
|
+
export function displayNick(msg, collisions) {
|
|
158
|
+
const nick = msg.username == null || msg.username === '' ? 'anon' : String(msg.username);
|
|
159
|
+
const tag = msg.tag == null ? '' : String(msg.tag);
|
|
160
|
+
if (tag && (nick === 'anon' || (collisions && collisions.has(nick)))) return `${nick}·${tag}`;
|
|
161
|
+
return nick;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** `[["❤️",2],["😂",1]]` or `{"❤️":2}` → `❤️ 2 😂 1`. Unknown shapes → ''. */
|
|
165
|
+
export function formatReactions(reactions) {
|
|
166
|
+
if (!reactions) return '';
|
|
167
|
+
const pairs = Array.isArray(reactions) ? reactions : (typeof reactions === 'object' ? Object.entries(reactions) : []);
|
|
168
|
+
return pairs
|
|
169
|
+
.filter((p) => Array.isArray(p) && p.length >= 2 && Number(p[1]) > 0)
|
|
170
|
+
.map(([emoji, n]) => `${emoji} ${Number(n)}`)
|
|
171
|
+
.join(' ');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function oneLine(s) {
|
|
175
|
+
return String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Render one message. opts: { width=80, color=true, myNick=null, collisions=null }.
|
|
180
|
+
* Returns a string that may contain newlines (wrap + quote line).
|
|
181
|
+
*/
|
|
182
|
+
export function renderLine(msg, opts = {}) {
|
|
183
|
+
const { width = 80, color = true, myNick = null, collisions = null } = opts;
|
|
184
|
+
const p = painter(color);
|
|
185
|
+
const own = myNick != null && msg.username === myNick;
|
|
186
|
+
|
|
187
|
+
const sid = padTo(String(msg.sid == null ? '' : msg.sid), SID_W);
|
|
188
|
+
const nickText = displayNick(msg, collisions);
|
|
189
|
+
const medal = MEDALS[msg.top] ? ` ${MEDALS[msg.top]}` : '';
|
|
190
|
+
const nickPad = ' '.repeat(Math.max(0, NICK_W - displayWidth(nickText + medal)));
|
|
191
|
+
const head = `${p(sid, CSI.dim)} ${p(nickText, ...(own ? [CSI.bold, CSI.brightGreen] : [CSI.green]))}${medal}${nickPad} `;
|
|
192
|
+
|
|
193
|
+
const avail = Math.max(width - TEXT_COL, 10);
|
|
194
|
+
const reply = msg.reply && typeof msg.reply === 'object' ? msg.reply : null;
|
|
195
|
+
const prefix = reply ? `→ ${oneLine(reply.username) || 'anon'} ` : '';
|
|
196
|
+
const prefixW = displayWidth(prefix);
|
|
197
|
+
const lines = wrap(oneLine(msg.text), avail - prefixW, avail);
|
|
198
|
+
const indent = ' '.repeat(TEXT_COL);
|
|
199
|
+
|
|
200
|
+
const out = lines.map((l, i) => {
|
|
201
|
+
const body = own ? p(l, CSI.bold) : l;
|
|
202
|
+
return i === 0 ? `${head}${prefix ? p(prefix, CSI.dim) : ''}${body}` : `${indent}${body}`;
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const reactions = formatReactions(msg.reactions);
|
|
206
|
+
if (reactions) {
|
|
207
|
+
const lastW = (lines.length === 1 ? prefixW : 0) + displayWidth(lines[lines.length - 1]);
|
|
208
|
+
if (lastW + 2 + displayWidth(reactions) <= avail) out[out.length - 1] += ` ${p(reactions, CSI.dim)}`;
|
|
209
|
+
else out.push(`${indent}${p(reactions, CSI.dim)}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (reply) {
|
|
213
|
+
const quote = reply.gone ? '(gone)' : truncate(oneLine(reply.text), QUOTE_MAX);
|
|
214
|
+
out.push(`${' '.repeat(SID_W + 1)}${p(`↳ ${quote}`, CSI.dim)}`);
|
|
215
|
+
}
|
|
216
|
+
return out.join('\n');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Dim one-liner printed when a recent line's reactions change. */
|
|
220
|
+
export function renderReactions(msg, opts = {}) {
|
|
221
|
+
const p = painter(opts.color !== false);
|
|
222
|
+
const sid = padTo(String(msg.sid == null ? '' : msg.sid), SID_W);
|
|
223
|
+
return p(`${sid} ${formatReactions(msg.reactions) || '·'}`, CSI.dim);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function renderHide(sid, opts = {}) {
|
|
227
|
+
const p = painter(opts.color !== false);
|
|
228
|
+
return p(`${padTo(String(sid == null ? '' : sid), SID_W)} ↳ message removed`, CSI.dim);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function renderTopic(topic, opts = {}) {
|
|
232
|
+
const p = painter(opts.color !== false);
|
|
233
|
+
return p(`// topic: ${oneLine(topic)}`, CSI.cyan);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function renderHeader({ topic, count } = {}, opts = {}) {
|
|
237
|
+
const p = painter(opts.color !== false);
|
|
238
|
+
const parts = [p('#void', CSI.green)];
|
|
239
|
+
if (topic) parts.push(oneLine(topic));
|
|
240
|
+
if (count != null) parts.push(`${Number(count)} in room`);
|
|
241
|
+
return parts.join(' · ');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function renderTop(topWeek, opts = {}) {
|
|
245
|
+
const p = painter(opts.color !== false);
|
|
246
|
+
const list = Array.isArray(topWeek) ? topWeek.filter((t) => t && t.nick) : [];
|
|
247
|
+
if (!list.length) return p('no top this week yet', CSI.dim);
|
|
248
|
+
return list
|
|
249
|
+
.map((t, i) => {
|
|
250
|
+
const rank = Number(t.rank) || i + 1;
|
|
251
|
+
const medal = MEDALS[rank] ? ` ${MEDALS[rank]}` : '';
|
|
252
|
+
return `${p(`#${rank}`, CSI.dim)} ${p(String(t.nick), CSI.green)}${medal}`;
|
|
253
|
+
})
|
|
254
|
+
.join('\n');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function renderError(message, opts = {}) {
|
|
258
|
+
const p = painter(opts.color !== false);
|
|
259
|
+
return p(oneLine(message), CSI.dimRed);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function renderInfo(message, opts = {}) {
|
|
263
|
+
const p = painter(opts.color !== false);
|
|
264
|
+
return p(String(message), CSI.dim);
|
|
265
|
+
}
|
package/lib/session.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// Session state machine: history, SSE event handling, reconnect loop, and the
|
|
2
|
+
// slash commands. All I/O is injected (api, print, config) so it is testable.
|
|
3
|
+
import { errorMessage } from './api.js';
|
|
4
|
+
import { readEvents } from './sse.js';
|
|
5
|
+
import {
|
|
6
|
+
renderLine, renderReactions, renderHide, renderTopic, renderHeader, renderTop,
|
|
7
|
+
renderError, renderInfo, MEDALS,
|
|
8
|
+
} from './render.js';
|
|
9
|
+
import * as defaultConfig from './config.js';
|
|
10
|
+
|
|
11
|
+
export const HINT_NO_KEY = 'type /key sc_… to post · get it in the app: /key';
|
|
12
|
+
export const HELP_TEXT = [
|
|
13
|
+
'/help this list',
|
|
14
|
+
'/r <id> text reply to a line (ids are the dim column on the left)',
|
|
15
|
+
'/top this week\'s top authors',
|
|
16
|
+
'/who how many are in the room',
|
|
17
|
+
'/key sc_… save your key (get it in the app: /key)',
|
|
18
|
+
'/key off forget the key on this machine (revoke it in the app)',
|
|
19
|
+
'/clear clear the screen',
|
|
20
|
+
'/quit leave (Ctrl-C / Ctrl-D work too)',
|
|
21
|
+
'anything else is posted to #void · @nick mentions pass through as typed',
|
|
22
|
+
].join('\n');
|
|
23
|
+
|
|
24
|
+
const MAX_LINES = 1000;
|
|
25
|
+
const RECENT = 5;
|
|
26
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
27
|
+
const NOTICE_AFTER_MS = 5_000;
|
|
28
|
+
const STALL_MS = 60_000;
|
|
29
|
+
|
|
30
|
+
function sleep(ms, signal) {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
if (!ms || (signal && signal.aborted)) { resolve(); return; }
|
|
33
|
+
const t = setTimeout(done, ms);
|
|
34
|
+
function done() { clearTimeout(t); if (signal) signal.removeEventListener('abort', done); resolve(); }
|
|
35
|
+
if (signal) signal.addEventListener('abort', done, { once: true });
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function greeting(me) {
|
|
40
|
+
if (!me || !me.username) return null;
|
|
41
|
+
const medal = MEDALS[me.top] ? ` ${MEDALS[me.top]}` : '';
|
|
42
|
+
const rank = me.week && me.week.rank != null ? Number(me.week.rank) : (me.top ? Number(me.top) : null);
|
|
43
|
+
return `you are ${me.username}${medal}${rank ? ` · #${rank} this week` : ''}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class Session {
|
|
47
|
+
constructor({
|
|
48
|
+
api,
|
|
49
|
+
print,
|
|
50
|
+
color = true,
|
|
51
|
+
width = () => 80,
|
|
52
|
+
key = null,
|
|
53
|
+
persistApi = null,
|
|
54
|
+
config = defaultConfig,
|
|
55
|
+
n = 30,
|
|
56
|
+
onQuit = () => {},
|
|
57
|
+
onKeyChange = () => {},
|
|
58
|
+
timers = { setTimeout, clearTimeout },
|
|
59
|
+
}) {
|
|
60
|
+
this.api = api;
|
|
61
|
+
this.print = print;
|
|
62
|
+
this.color = color;
|
|
63
|
+
this.width = width;
|
|
64
|
+
this.key = key;
|
|
65
|
+
this.persistApi = persistApi;
|
|
66
|
+
this.config = config;
|
|
67
|
+
this.n = n;
|
|
68
|
+
this.onQuit = onQuit;
|
|
69
|
+
this.onKeyChange = onKeyChange;
|
|
70
|
+
this.timers = timers;
|
|
71
|
+
|
|
72
|
+
this.me = null;
|
|
73
|
+
this.topic = null;
|
|
74
|
+
this.count = null;
|
|
75
|
+
this.topWeek = [];
|
|
76
|
+
this.lines = new Map(); // String(id) → message
|
|
77
|
+
this.recent = []; // last printed ids (String)
|
|
78
|
+
this.nickTags = new Map(); // nick → Set(tag)
|
|
79
|
+
this.collisions = new Set();
|
|
80
|
+
this.lastEventId = null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
ropts() {
|
|
84
|
+
return {
|
|
85
|
+
width: this.width(),
|
|
86
|
+
color: this.color,
|
|
87
|
+
myNick: this.me ? this.me.username : null,
|
|
88
|
+
collisions: this.collisions,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
info(s) { this.print(renderInfo(s, { color: this.color })); }
|
|
93
|
+
error(s) { this.print(renderError(s, { color: this.color })); }
|
|
94
|
+
|
|
95
|
+
// ── startup ─────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/** Verifies the stored key via /void/me. Never throws; leaves the key in place on failure. */
|
|
98
|
+
async verifyKey() {
|
|
99
|
+
if (!this.key) return null;
|
|
100
|
+
try {
|
|
101
|
+
this.me = await this.api.getMe(this.key);
|
|
102
|
+
return this.me;
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (err && err.status === 401) this.error(`${errorMessage(err)} · /key sc_… to replace it`);
|
|
105
|
+
else this.info(`· couldn't verify key: ${errorMessage(err)}`);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** GET /void and (optionally) print header + lines. Throws ApiError on failure. */
|
|
111
|
+
async loadHistory({ show = true, n = this.n } = {}) {
|
|
112
|
+
const data = await this.api.getVoid(n);
|
|
113
|
+
this.absorbMeta(data);
|
|
114
|
+
const msgs = Array.isArray(data.messages) ? data.messages : [];
|
|
115
|
+
if (show) {
|
|
116
|
+
this.print(renderHeader({ topic: this.topic, count: this.count }, { color: this.color }));
|
|
117
|
+
if (!msgs.length) this.info('nobody has spoken in the last 24h · the room is not empty. it\'s waiting.');
|
|
118
|
+
}
|
|
119
|
+
for (const m of msgs) this.printMsg(m, { show });
|
|
120
|
+
return data;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
absorbMeta(data) {
|
|
124
|
+
if (!data || typeof data !== 'object') return;
|
|
125
|
+
if (typeof data.topic === 'string') this.topic = data.topic;
|
|
126
|
+
if (data.count != null && Number.isFinite(Number(data.count))) this.count = Number(data.count);
|
|
127
|
+
if (Array.isArray(data.top_week)) this.topWeek = data.top_week;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
greet() {
|
|
131
|
+
if (this.me) this.info(greeting(this.me));
|
|
132
|
+
else if (!this.key) this.info(HINT_NO_KEY);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── lines ───────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
ingest(msg) {
|
|
138
|
+
const nick = msg.username == null || msg.username === '' ? 'anon' : String(msg.username);
|
|
139
|
+
const tag = msg.tag == null ? '' : String(msg.tag);
|
|
140
|
+
if (tag) {
|
|
141
|
+
let set = this.nickTags.get(nick);
|
|
142
|
+
if (!set) { set = new Set(); this.nickTags.set(nick, set); }
|
|
143
|
+
set.add(tag);
|
|
144
|
+
if (set.size > 1) this.collisions.add(nick);
|
|
145
|
+
}
|
|
146
|
+
const id = String(msg.id);
|
|
147
|
+
this.lines.set(id, msg);
|
|
148
|
+
if (this.lines.size > MAX_LINES) {
|
|
149
|
+
const oldest = this.lines.keys().next().value;
|
|
150
|
+
this.lines.delete(oldest);
|
|
151
|
+
}
|
|
152
|
+
if (msg.id != null) this.lastEventId = String(msg.id);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Prints a message unless already seen. Returns true when printed. */
|
|
156
|
+
printMsg(msg, { show = true } = {}) {
|
|
157
|
+
if (!msg || typeof msg !== 'object') return false;
|
|
158
|
+
const id = msg.id == null ? null : String(msg.id);
|
|
159
|
+
if (id != null && this.lines.has(id)) return false;
|
|
160
|
+
this.ingest(msg);
|
|
161
|
+
if (!show) return false;
|
|
162
|
+
this.print(renderLine(msg, this.ropts()));
|
|
163
|
+
if (id != null) {
|
|
164
|
+
this.recent.push(id);
|
|
165
|
+
if (this.recent.length > RECENT) this.recent.shift();
|
|
166
|
+
}
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Handles one SSE event. Returns 'bye' when the server asked us to reconnect. */
|
|
171
|
+
handleEvent(ev) {
|
|
172
|
+
if (!ev) return undefined;
|
|
173
|
+
if (ev.event === 'bye') return 'bye';
|
|
174
|
+
let data;
|
|
175
|
+
try { data = JSON.parse(ev.data); } catch { return undefined; }
|
|
176
|
+
if (!data || typeof data !== 'object') return undefined;
|
|
177
|
+
switch (ev.event) {
|
|
178
|
+
case 'msg':
|
|
179
|
+
case 'message': {
|
|
180
|
+
if (data.id == null && ev.id) data.id = ev.id;
|
|
181
|
+
this.printMsg(data);
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
case 'react': {
|
|
185
|
+
const id = String(data.id);
|
|
186
|
+
const m = this.lines.get(id);
|
|
187
|
+
if (!m) break;
|
|
188
|
+
m.reactions = data.reactions;
|
|
189
|
+
if (this.recent.includes(id)) this.print(renderReactions(m, { color: this.color }));
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
case 'hide': {
|
|
193
|
+
const id = String(data.id);
|
|
194
|
+
const m = this.lines.get(id);
|
|
195
|
+
if (m) m.hidden = true;
|
|
196
|
+
const sid = data.sid != null ? data.sid : (m ? m.sid : null);
|
|
197
|
+
if (sid != null) this.print(renderHide(sid, { color: this.color }));
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
case 'topic': {
|
|
201
|
+
if (typeof data.topic === 'string') {
|
|
202
|
+
this.topic = data.topic;
|
|
203
|
+
this.print(renderTopic(data.topic, { color: this.color }));
|
|
204
|
+
}
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
default:
|
|
208
|
+
break; // unknown event types are ignored (forward compatible)
|
|
209
|
+
}
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── stream ──────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
/** Runs until `signal` aborts. Backoff 1,2,4…30 s; `bye` → immediate quiet reconnect. */
|
|
216
|
+
async runStream(signal) {
|
|
217
|
+
const { setTimeout: setT, clearTimeout: clearT } = this.timers;
|
|
218
|
+
let attempt = 0;
|
|
219
|
+
let noticeTimer = null;
|
|
220
|
+
let noticed = false;
|
|
221
|
+
let lastErrorShown = null;
|
|
222
|
+
|
|
223
|
+
const clearNotice = () => { if (noticeTimer) { clearT(noticeTimer); noticeTimer = null; } };
|
|
224
|
+
|
|
225
|
+
while (!signal.aborted) {
|
|
226
|
+
const conn = new AbortController();
|
|
227
|
+
const onAbort = () => conn.abort();
|
|
228
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
229
|
+
let bye = false;
|
|
230
|
+
let stall = null;
|
|
231
|
+
try {
|
|
232
|
+
const res = await this.api.openStream({ lastEventId: this.lastEventId, signal: conn.signal });
|
|
233
|
+
attempt = 0;
|
|
234
|
+
lastErrorShown = null;
|
|
235
|
+
clearNotice();
|
|
236
|
+
if (noticed) { this.info('· back'); noticed = false; }
|
|
237
|
+
const kick = () => { if (stall) clearT(stall); stall = setT(() => conn.abort(), STALL_MS); if (stall && stall.unref) stall.unref(); };
|
|
238
|
+
kick();
|
|
239
|
+
for await (const ev of readEvents(res.body, { onChunk: kick })) {
|
|
240
|
+
if (this.handleEvent(ev) === 'bye') { bye = true; break; }
|
|
241
|
+
}
|
|
242
|
+
} catch (err) {
|
|
243
|
+
if (!signal.aborted && !(err && err.name === 'AbortError')) {
|
|
244
|
+
const msg = errorMessage(err);
|
|
245
|
+
if (msg !== lastErrorShown) { this.error(msg); lastErrorShown = msg; }
|
|
246
|
+
}
|
|
247
|
+
} finally {
|
|
248
|
+
if (stall) clearT(stall);
|
|
249
|
+
signal.removeEventListener('abort', onAbort);
|
|
250
|
+
}
|
|
251
|
+
if (signal.aborted) break;
|
|
252
|
+
if (bye) continue;
|
|
253
|
+
if (!noticeTimer && !noticed) {
|
|
254
|
+
noticeTimer = setT(() => { noticeTimer = null; noticed = true; this.info('· reconnecting'); }, NOTICE_AFTER_MS);
|
|
255
|
+
if (noticeTimer && noticeTimer.unref) noticeTimer.unref();
|
|
256
|
+
}
|
|
257
|
+
const delay = Math.min(MAX_BACKOFF_MS, 1000 * 2 ** attempt);
|
|
258
|
+
attempt = Math.min(attempt + 1, 10);
|
|
259
|
+
await sleep(delay, signal);
|
|
260
|
+
}
|
|
261
|
+
clearNotice();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── input ───────────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
async handleInput(raw) {
|
|
267
|
+
const line = String(raw == null ? '' : raw).trim();
|
|
268
|
+
if (!line) return;
|
|
269
|
+
if (line[0] !== '/') { await this.post(line); return; }
|
|
270
|
+
const cmd = line.split(/\s+/, 1)[0].toLowerCase();
|
|
271
|
+
const rest = line.slice(cmd.length).trim();
|
|
272
|
+
switch (cmd) {
|
|
273
|
+
case '/help': case '/h': case '/?':
|
|
274
|
+
this.print(renderInfo(HELP_TEXT, { color: this.color }));
|
|
275
|
+
return;
|
|
276
|
+
case '/quit': case '/q': case '/exit':
|
|
277
|
+
this.onQuit();
|
|
278
|
+
return;
|
|
279
|
+
case '/clear':
|
|
280
|
+
this.onClear && this.onClear();
|
|
281
|
+
return;
|
|
282
|
+
case '/top':
|
|
283
|
+
await this.cmdTop();
|
|
284
|
+
return;
|
|
285
|
+
case '/who':
|
|
286
|
+
await this.cmdWho();
|
|
287
|
+
return;
|
|
288
|
+
case '/key':
|
|
289
|
+
await this.cmdKey(rest);
|
|
290
|
+
return;
|
|
291
|
+
case '/r': case '/reply': {
|
|
292
|
+
const m = rest.match(/^(\S+)\s+([\s\S]+)$/);
|
|
293
|
+
if (!m) { this.info('usage: /r <id> text'); return; }
|
|
294
|
+
await this.post(m[2].trim(), m[1]);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
default:
|
|
298
|
+
await this.post(line); // the server owns slash semantics (e.g. 422 command not found)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async post(text, reply = null) {
|
|
303
|
+
if (!this.key) { this.info(HINT_NO_KEY); return false; }
|
|
304
|
+
try {
|
|
305
|
+
await this.api.post({ text, reply }, this.key); // echo arrives via SSE, not from the response
|
|
306
|
+
return true;
|
|
307
|
+
} catch (err) {
|
|
308
|
+
this.error(errorMessage(err));
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async refreshMeta() {
|
|
314
|
+
try { this.absorbMeta(await this.api.getVoid(1)); } catch { /* fall back to cached */ }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async cmdTop() {
|
|
318
|
+
await this.refreshMeta();
|
|
319
|
+
this.print(renderTop(this.topWeek, { color: this.color }));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async cmdWho() {
|
|
323
|
+
await this.refreshMeta();
|
|
324
|
+
if (this.count == null) { this.info('no idea · try again'); return; }
|
|
325
|
+
this.info(this.count === 1 ? '1 in room' : `${this.count} in room`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async cmdKey(arg) {
|
|
329
|
+
if (!arg) {
|
|
330
|
+
if (this.me) this.info(greeting(this.me));
|
|
331
|
+
else if (this.key) this.info('a key is on file but not verified · /key sc_… to replace it');
|
|
332
|
+
else this.info('no key · get it in the app: /key');
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (arg.toLowerCase() === 'off') {
|
|
336
|
+
this.config.removeConfig();
|
|
337
|
+
this.key = null;
|
|
338
|
+
this.me = null;
|
|
339
|
+
this.onKeyChange(null);
|
|
340
|
+
this.info('key removed from this machine · to revoke it, run /key off in the app');
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (!/^sc_[A-Za-z0-9]+$/.test(arg)) {
|
|
344
|
+
this.info('usage: /key sc_… · /key off');
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
let me;
|
|
348
|
+
try {
|
|
349
|
+
me = await this.api.getMe(arg);
|
|
350
|
+
} catch (err) {
|
|
351
|
+
this.error(errorMessage(err));
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
this.key = arg;
|
|
355
|
+
this.me = me;
|
|
356
|
+
try {
|
|
357
|
+
this.config.saveConfig({ key: arg, api: this.persistApi || undefined });
|
|
358
|
+
} catch (err) {
|
|
359
|
+
this.error(`saved in memory only · ${errorMessage(err)}`);
|
|
360
|
+
}
|
|
361
|
+
this.onKeyChange(arg);
|
|
362
|
+
this.info(greeting(me) || 'key saved');
|
|
363
|
+
}
|
|
364
|
+
}
|
package/lib/sse.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Pure Server-Sent Events parser (WHATWG EventSource line semantics), no I/O.
|
|
2
|
+
//
|
|
3
|
+
// const p = new SSEParser();
|
|
4
|
+
// p.feed('event: msg\nid: 12\ndata: {"a":1}\n\n') // → [{ event: 'msg', id: '12', data: '{"a":1}' }]
|
|
5
|
+
//
|
|
6
|
+
// Handles events split across arbitrary chunk boundaries, LF / CRLF / CR line
|
|
7
|
+
// endings, `: comment` heartbeats, multi-line `data:` fields, and `retry:`.
|
|
8
|
+
// `id` on a yielded event is the *last seen* event id (spec behaviour), so an
|
|
9
|
+
// event without its own `id:` line inherits the previous one.
|
|
10
|
+
|
|
11
|
+
export class SSEParser {
|
|
12
|
+
constructor() {
|
|
13
|
+
this.buf = '';
|
|
14
|
+
this.lastEventId = '';
|
|
15
|
+
this.retry = null;
|
|
16
|
+
this._event = '';
|
|
17
|
+
this._data = [];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Feed a string chunk; returns an array of complete events (possibly empty). */
|
|
21
|
+
feed(chunk) {
|
|
22
|
+
if (!chunk) return [];
|
|
23
|
+
this.buf += chunk;
|
|
24
|
+
const out = [];
|
|
25
|
+
let start = 0;
|
|
26
|
+
for (;;) {
|
|
27
|
+
let i = start;
|
|
28
|
+
let term = -1;
|
|
29
|
+
let len = 0;
|
|
30
|
+
while (i < this.buf.length) {
|
|
31
|
+
const c = this.buf.charCodeAt(i);
|
|
32
|
+
if (c === 10) { term = i; len = 1; break; }
|
|
33
|
+
if (c === 13) {
|
|
34
|
+
// A trailing CR may be the first half of CRLF arriving in the next chunk.
|
|
35
|
+
if (i + 1 >= this.buf.length) { term = -2; break; }
|
|
36
|
+
term = i;
|
|
37
|
+
len = this.buf.charCodeAt(i + 1) === 10 ? 2 : 1;
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
i++;
|
|
41
|
+
}
|
|
42
|
+
if (term < 0) break;
|
|
43
|
+
const ev = this._line(this.buf.slice(start, term));
|
|
44
|
+
if (ev) out.push(ev);
|
|
45
|
+
start = term + len;
|
|
46
|
+
}
|
|
47
|
+
this.buf = this.buf.slice(start);
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Drop any partial event (spec: incomplete events at EOF are discarded). */
|
|
52
|
+
reset() {
|
|
53
|
+
this.buf = '';
|
|
54
|
+
this._event = '';
|
|
55
|
+
this._data = [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
_line(line) {
|
|
59
|
+
if (line === '') return this._dispatch();
|
|
60
|
+
if (line.charCodeAt(0) === 58) return null; // ':' comment
|
|
61
|
+
let field;
|
|
62
|
+
let value;
|
|
63
|
+
const colon = line.indexOf(':');
|
|
64
|
+
if (colon === -1) {
|
|
65
|
+
field = line;
|
|
66
|
+
value = '';
|
|
67
|
+
} else {
|
|
68
|
+
field = line.slice(0, colon);
|
|
69
|
+
value = line.slice(colon + 1);
|
|
70
|
+
if (value.charCodeAt(0) === 32) value = value.slice(1);
|
|
71
|
+
}
|
|
72
|
+
switch (field) {
|
|
73
|
+
case 'event': this._event = value; break;
|
|
74
|
+
case 'data': this._data.push(value); break;
|
|
75
|
+
case 'id': if (!value.includes('\0')) this.lastEventId = value; break;
|
|
76
|
+
case 'retry': if (/^\d+$/.test(value)) this.retry = Number(value); break;
|
|
77
|
+
default: break; // unknown fields are ignored
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
_dispatch() {
|
|
83
|
+
if (this._data.length === 0) {
|
|
84
|
+
this._event = '';
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const ev = { event: this._event || 'message', id: this.lastEventId, data: this._data.join('\n') };
|
|
88
|
+
this._event = '';
|
|
89
|
+
this._data = [];
|
|
90
|
+
return ev;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Async-iterate SSE events from a WHATWG ReadableStream of bytes (fetch body).
|
|
96
|
+
* `onChunk` fires for every received chunk (used as a liveness watchdog).
|
|
97
|
+
* Breaking out of the loop cancels the underlying stream.
|
|
98
|
+
*/
|
|
99
|
+
export async function* readEvents(body, { onChunk } = {}) {
|
|
100
|
+
const parser = new SSEParser();
|
|
101
|
+
const decoder = new TextDecoder('utf-8');
|
|
102
|
+
const reader = body.getReader();
|
|
103
|
+
try {
|
|
104
|
+
for (;;) {
|
|
105
|
+
const { done, value } = await reader.read();
|
|
106
|
+
if (done) break;
|
|
107
|
+
if (onChunk) onChunk();
|
|
108
|
+
const events = parser.feed(decoder.decode(value, { stream: true }));
|
|
109
|
+
for (const ev of events) yield ev;
|
|
110
|
+
}
|
|
111
|
+
} finally {
|
|
112
|
+
try { await reader.cancel(); } catch { /* already closed */ }
|
|
113
|
+
}
|
|
114
|
+
}
|
package/lib/ui.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Readline management: plain scrolling output with a `> ` prompt pinned at the
|
|
2
|
+
// bottom. No alternate screen, no curses — works in tmux splits and pipes.
|
|
3
|
+
import readline from 'node:readline';
|
|
4
|
+
|
|
5
|
+
export function createUI({ input = process.stdin, output = process.stdout, prompt = '> ' } = {}) {
|
|
6
|
+
let rl = null;
|
|
7
|
+
let closed = false;
|
|
8
|
+
|
|
9
|
+
function print(text) {
|
|
10
|
+
if (closed && !rl) { output.write(`${text}\n`); return; }
|
|
11
|
+
if (rl) {
|
|
12
|
+
readline.clearLine(output, 0);
|
|
13
|
+
readline.cursorTo(output, 0);
|
|
14
|
+
}
|
|
15
|
+
output.write(`${text}\n`);
|
|
16
|
+
if (rl) rl.prompt(true);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function start({ onLine, onClose }) {
|
|
20
|
+
rl = readline.createInterface({ input, output, prompt, terminal: true, historySize: 200 });
|
|
21
|
+
rl.on('line', (line) => { Promise.resolve(onLine(line)).catch(() => {}).then(() => { if (rl) rl.prompt(true); }); });
|
|
22
|
+
rl.on('SIGINT', () => close());
|
|
23
|
+
rl.on('close', () => {
|
|
24
|
+
const wasOpen = !closed;
|
|
25
|
+
closed = true;
|
|
26
|
+
rl = null;
|
|
27
|
+
if (wasOpen && onClose) onClose();
|
|
28
|
+
});
|
|
29
|
+
rl.prompt();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Closes readline (restores terminal mode) and clears the prompt line. */
|
|
33
|
+
function close() {
|
|
34
|
+
if (!rl) { closed = true; return; }
|
|
35
|
+
const r = rl;
|
|
36
|
+
readline.clearLine(output, 0);
|
|
37
|
+
readline.cursorTo(output, 0);
|
|
38
|
+
r.close(); // emits 'close' → onClose
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function clear() {
|
|
42
|
+
output.write('\x1b[2J\x1b[3J\x1b[H');
|
|
43
|
+
if (rl) rl.prompt(true);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Remove entries matching `pred` from the in-memory input history (e.g. `/key sc_…`). */
|
|
47
|
+
function scrubHistory(pred) {
|
|
48
|
+
if (rl && Array.isArray(rl.history)) rl.history = rl.history.filter((h) => !pred(h));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
print,
|
|
53
|
+
start,
|
|
54
|
+
close,
|
|
55
|
+
clear,
|
|
56
|
+
scrubHistory,
|
|
57
|
+
get interactive() { return rl !== null; },
|
|
58
|
+
};
|
|
59
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "stdout-chat",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "#void from your terminal — read, tail and post to stdout.chat's public room. Zero dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"stdout-chat": "bin/stdout-chat.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"lib",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"start": "node bin/stdout-chat.js",
|
|
20
|
+
"test": "node --test"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/stdout-chat/cli.git"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://stdout.chat/void",
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/stdout-chat/cli/issues"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"chat",
|
|
32
|
+
"terminal",
|
|
33
|
+
"cli",
|
|
34
|
+
"sse",
|
|
35
|
+
"void",
|
|
36
|
+
"stdout.chat"
|
|
37
|
+
],
|
|
38
|
+
"author": "stdout.chat",
|
|
39
|
+
"license": "MIT"
|
|
40
|
+
}
|