@tsufbarkai/relay-stream 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/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # @relay/stream
2
+
3
+ Stream your terminal output to a Relay session in real time.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @relay/stream
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ All modes require `--relay <id>` and `--token <jwt>` (or the environment variables `RELAY_ID` and `RELAY_TOKEN`). Use `--url <base>` to point at a non-default API server (defaults to `http://localhost:3002`).
14
+
15
+ ### Pipe mode
16
+
17
+ Pipe any command's output into a Relay tile:
18
+
19
+ ```bash
20
+ some-command | relay-stream --relay <id> --token <jwt>
21
+ ```
22
+
23
+ ### Wrap mode
24
+
25
+ Wrap a command — stdout and stderr are captured and streamed:
26
+
27
+ ```bash
28
+ relay-stream --relay <id> --token <jwt> -- npm test
29
+ ```
30
+
31
+ ### PTY mode
32
+
33
+ Stream a full interactive terminal session with ANSI colors and cursor control:
34
+
35
+ ```bash
36
+ relay-stream --relay <id> --token <jwt> --pty
37
+ relay-stream --relay <id> --token <jwt> --pty -- python3
38
+ ```
39
+
40
+ PTY mode requires the optional `node-pty-prebuilt-multiarch` dependency, which is installed automatically on supported platforms. If it fails to install (e.g. missing build tools), pipe and wrap modes still work.
41
+
42
+ ## Options
43
+
44
+ | Flag | Env variable | Description |
45
+ |---|---|---|
46
+ | `--relay <id>` | `RELAY_ID` | Relay ID to stream to (required) |
47
+ | `--token <jwt>` | `RELAY_TOKEN` | JWT auth token (required) |
48
+ | `--url <base>` | `RELAY_URL` | API base URL (default: `http://localhost:3002`) |
49
+ | `--label <name>` | — | Display label for the stream tile |
50
+ | `--pty` | — | Enable PTY mode |
51
+
52
+ ## License
53
+
54
+ MIT
@@ -0,0 +1,336 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // ─── HTTP helper ─────────────────────────────────────────────────────────────
5
+
6
+ function post(url, body, token) {
7
+ return new Promise((resolve, reject) => {
8
+ const parsed = new URL(url);
9
+ const lib = parsed.protocol === 'https:' ? require('https') : require('http');
10
+ const data = JSON.stringify(body);
11
+ const req = lib.request(
12
+ {
13
+ hostname: parsed.hostname,
14
+ port: parsed.port,
15
+ path: parsed.pathname + parsed.search,
16
+ method: 'POST',
17
+ headers: {
18
+ 'Content-Type': 'application/json',
19
+ 'Content-Length': Buffer.byteLength(data),
20
+ Authorization: `Bearer ${token}`,
21
+ },
22
+ },
23
+ (res) => {
24
+ let body = '';
25
+ res.on('data', (d) => (body += d));
26
+ res.on('end', () => {
27
+ try {
28
+ resolve({ status: res.statusCode, body: JSON.parse(body) });
29
+ } catch {
30
+ resolve({ status: res.statusCode, body });
31
+ }
32
+ });
33
+ }
34
+ );
35
+ req.on('error', reject);
36
+ req.write(data);
37
+ req.end();
38
+ });
39
+ }
40
+
41
+ // ─── Arg parsing ─────────────────────────────────────────────────────────────
42
+
43
+ function parseArgs(argv) {
44
+ const result = {
45
+ token: process.env.RELAY_TOKEN || null,
46
+ relay: process.env.RELAY_ID || null,
47
+ url: process.env.RELAY_URL || 'http://localhost:3002',
48
+ label: null,
49
+ pty: false,
50
+ command: [],
51
+ };
52
+
53
+ let i = 0;
54
+ while (i < argv.length) {
55
+ const arg = argv[i];
56
+ if (arg === '--') {
57
+ result.command = argv.slice(i + 1);
58
+ break;
59
+ } else if (arg === '--token' && argv[i + 1]) {
60
+ result.token = argv[++i];
61
+ } else if (arg === '--relay' && argv[i + 1]) {
62
+ result.relay = argv[++i];
63
+ } else if (arg === '--url' && argv[i + 1]) {
64
+ result.url = argv[++i];
65
+ } else if (arg === '--label' && argv[i + 1]) {
66
+ result.label = argv[++i];
67
+ } else if (arg === '--pty') {
68
+ result.pty = true;
69
+ }
70
+ i++;
71
+ }
72
+
73
+ return result;
74
+ }
75
+
76
+ // ─── Main ─────────────────────────────────────────────────────────────────────
77
+
78
+ async function main() {
79
+ const args = parseArgs(process.argv.slice(2));
80
+
81
+ if (!args.token) {
82
+ process.stderr.write('Error: --token or RELAY_TOKEN required\n');
83
+ process.exit(1);
84
+ }
85
+ if (!args.relay) {
86
+ process.stderr.write('Error: --relay or RELAY_ID required\n');
87
+ process.exit(1);
88
+ }
89
+
90
+ const { token, relay, url: baseUrl, label, command, pty: isPtyMode } = args;
91
+ const isWrapMode = !isPtyMode && command.length > 0;
92
+ const tileLabel =
93
+ label ||
94
+ (isPtyMode
95
+ ? command.length > 0
96
+ ? command.join(' ')
97
+ : 'terminal session'
98
+ : isWrapMode
99
+ ? command.join(' ')
100
+ : 'stdin stream');
101
+
102
+ // ── Create stream tile ───────────────────────────────────────────────────
103
+ let tileId;
104
+ try {
105
+ const tileBody = { type: 'stream', content: '', label: tileLabel, status: 'running' };
106
+ if (isPtyMode) tileBody.terminal = true;
107
+ const res = await post(
108
+ `${baseUrl}/api/relays/${relay}/tiles`,
109
+ tileBody,
110
+ token
111
+ );
112
+ if (res.status < 200 || res.status >= 300) {
113
+ process.stderr.write(
114
+ `[relay-stream] failed to create tile (HTTP ${res.status}): ${JSON.stringify(res.body)}\n`
115
+ );
116
+ process.exit(1);
117
+ }
118
+ tileId = res.body.id;
119
+ } catch (err) {
120
+ process.stderr.write(`[relay-stream] failed to create tile: ${err.message}\n`);
121
+ process.exit(1);
122
+ }
123
+
124
+ process.stderr.write(
125
+ `[relay-stream] streaming to relay ${relay}, tile ${tileId}\n[relay-stream] ${baseUrl}/api/relays/${relay}/tiles/${tileId}\n`
126
+ );
127
+
128
+ // ── Buffer + flush machinery ─────────────────────────────────────────────
129
+ const FLUSH_INTERVAL_MS = 200;
130
+ const FLUSH_SIZE_BYTES = 4 * 1024; // 4 KB
131
+
132
+ let buffer = '';
133
+ let flushTimer = null;
134
+
135
+ async function flushBuffer() {
136
+ if (!buffer) return;
137
+ const chunk = buffer;
138
+ buffer = '';
139
+ try {
140
+ const res = await post(
141
+ `${baseUrl}/api/relays/${relay}/tiles/${tileId}/stream`,
142
+ { chunk },
143
+ token
144
+ );
145
+ if (res.status < 200 || res.status >= 300) {
146
+ process.stderr.write(
147
+ `[relay-stream] stream chunk failed (HTTP ${res.status}): ${JSON.stringify(res.body)}\n`
148
+ );
149
+ }
150
+ } catch (err) {
151
+ process.stderr.write(`[relay-stream] stream chunk error: ${err.message}\n`);
152
+ }
153
+ }
154
+
155
+ function scheduleFlush() {
156
+ if (!flushTimer) {
157
+ flushTimer = setTimeout(async () => {
158
+ flushTimer = null;
159
+ await flushBuffer();
160
+ }, FLUSH_INTERVAL_MS);
161
+ }
162
+ }
163
+
164
+ function appendChunk(text) {
165
+ buffer += text;
166
+ if (Buffer.byteLength(buffer) >= FLUSH_SIZE_BYTES) {
167
+ if (flushTimer) {
168
+ clearTimeout(flushTimer);
169
+ flushTimer = null;
170
+ }
171
+ flushBuffer();
172
+ } else {
173
+ scheduleFlush();
174
+ }
175
+ }
176
+
177
+ async function closeStream(exitCode) {
178
+ if (flushTimer) {
179
+ clearTimeout(flushTimer);
180
+ flushTimer = null;
181
+ }
182
+ await flushBuffer();
183
+
184
+ const status = exitCode === 0 ? 'done' : 'error';
185
+ try {
186
+ const res = await post(
187
+ `${baseUrl}/api/relays/${relay}/tiles/${tileId}/stream/close`,
188
+ { status },
189
+ token
190
+ );
191
+ if (res.status < 200 || res.status >= 300) {
192
+ process.stderr.write(
193
+ `[relay-stream] close failed (HTTP ${res.status}): ${JSON.stringify(res.body)}\n`
194
+ );
195
+ }
196
+ } catch (err) {
197
+ process.stderr.write(`[relay-stream] close error: ${err.message}\n`);
198
+ }
199
+ process.stderr.write('[relay-stream] done\n');
200
+ }
201
+
202
+ // ── Mode: pty (interactive terminal) ────────────────────────────────────
203
+ if (isPtyMode) {
204
+ let pty;
205
+ try {
206
+ pty = require('node-pty-prebuilt-multiarch');
207
+ } catch {
208
+ process.stderr.write(
209
+ 'PTY mode requires node-pty-prebuilt-multiarch. Install it with: npm install node-pty-prebuilt-multiarch\n'
210
+ );
211
+ process.exit(1);
212
+ }
213
+
214
+ const shell = String(command.length > 0 ? command[0] : (process.env.SHELL || '/bin/bash'));
215
+ const shellArgs = command.length > 1 ? command.slice(1) : [];
216
+
217
+ if (!shell) {
218
+ process.stderr.write('[relay-stream] could not determine shell to spawn\n');
219
+ process.exit(1);
220
+ }
221
+
222
+ const ptyProcess = pty.spawn(shell, shellArgs, {
223
+ name: 'xterm-256color',
224
+ cols: process.stdout.columns || 120,
225
+ rows: process.stdout.rows || 30,
226
+ env: process.env,
227
+ });
228
+
229
+ // Forward PTY output to stdout and relay buffer
230
+ ptyProcess.onData((data) => {
231
+ process.stdout.write(data);
232
+ appendChunk(data);
233
+ });
234
+
235
+ // Forward stdin to PTY
236
+ if (process.stdin.isTTY) {
237
+ process.stdin.setRawMode(true);
238
+ }
239
+ process.stdin.resume();
240
+ process.stdin.on('data', (data) => {
241
+ ptyProcess.write(data.toString());
242
+ });
243
+
244
+ // Handle terminal resize
245
+ process.stdout.on('resize', () => {
246
+ ptyProcess.resize(process.stdout.columns, process.stdout.rows);
247
+ });
248
+
249
+ // Cleanup helper
250
+ let exiting = false;
251
+ async function cleanup(exitCode) {
252
+ if (exiting) return;
253
+ exiting = true;
254
+ try {
255
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
256
+ } catch {}
257
+ await closeStream(exitCode);
258
+ process.exit(exitCode);
259
+ }
260
+
261
+ // Handle signals
262
+ process.on('SIGINT', () => {
263
+ ptyProcess.kill();
264
+ cleanup(0);
265
+ });
266
+ process.on('SIGTERM', () => {
267
+ ptyProcess.kill();
268
+ cleanup(0);
269
+ });
270
+
271
+ // Handle PTY exit
272
+ ptyProcess.onExit(({ exitCode }) => {
273
+ cleanup(exitCode ?? 0);
274
+ });
275
+
276
+ return; // let PTY drive exit
277
+ }
278
+
279
+ // ── Mode: wrap (spawn child) ─────────────────────────────────────────────
280
+ if (isWrapMode) {
281
+ const { spawn } = require('child_process');
282
+ const [cmd, ...cmdArgs] = command;
283
+ const child = spawn(cmd, cmdArgs, {
284
+ stdio: ['inherit', 'pipe', 'pipe'],
285
+ env: process.env,
286
+ });
287
+
288
+ child.stdout.on('data', (chunk) => {
289
+ process.stdout.write(chunk);
290
+ appendChunk(chunk.toString());
291
+ });
292
+
293
+ child.stderr.on('data', (chunk) => {
294
+ process.stderr.write(chunk);
295
+ appendChunk(chunk.toString());
296
+ });
297
+
298
+ child.on('close', async (code) => {
299
+ const exitCode = code ?? 1;
300
+ await closeStream(exitCode);
301
+ process.exit(exitCode);
302
+ });
303
+
304
+ child.on('error', async (err) => {
305
+ process.stderr.write(`[relay-stream] failed to spawn command: ${err.message}\n`);
306
+ await closeStream(1);
307
+ process.exit(1);
308
+ });
309
+
310
+ return; // let child drive exit
311
+ }
312
+
313
+ // ── Mode: pipe (read from stdin) ─────────────────────────────────────────
314
+ const readline = require('readline');
315
+ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
316
+
317
+ rl.on('line', (line) => {
318
+ appendChunk(line + '\n');
319
+ });
320
+
321
+ rl.on('close', async () => {
322
+ await closeStream(0);
323
+ process.exit(0);
324
+ });
325
+
326
+ process.stdin.on('error', async (err) => {
327
+ process.stderr.write(`[relay-stream] stdin error: ${err.message}\n`);
328
+ await closeStream(1);
329
+ process.exit(1);
330
+ });
331
+ }
332
+
333
+ main().catch((err) => {
334
+ process.stderr.write(`[relay-stream] unexpected error: ${err.message}\n`);
335
+ process.exit(1);
336
+ });
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@tsufbarkai/relay-stream",
3
+ "version": "0.1.0",
4
+ "description": "Stream your terminal to a Relay session",
5
+ "bin": {
6
+ "relay-stream": "./bin/relay-stream.js"
7
+ },
8
+ "files": [
9
+ "bin/"
10
+ ],
11
+ "keywords": [
12
+ "relay",
13
+ "terminal",
14
+ "stream",
15
+ "pty",
16
+ "cli"
17
+ ],
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/tsufbarkai/relay.git",
22
+ "directory": "packages/relay-cli"
23
+ },
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "scripts": {
31
+ "postinstall": "node -e \"require('fs').chmodSync(require('path').join(__dirname, 'bin/relay-stream.js'), '755')\""
32
+ },
33
+ "optionalDependencies": {
34
+ "node-pty-prebuilt-multiarch": "^0.10.0"
35
+ }
36
+ }