reley 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.
Files changed (2) hide show
  1. package/dist/index.js +2750 -0
  2. package/package.json +35 -0
package/dist/index.js ADDED
@@ -0,0 +1,2750 @@
1
+ #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // src/config.ts
13
+ var config_exports = {};
14
+ __export(config_exports, {
15
+ clearConfig: () => clearConfig,
16
+ getConfigDir: () => getConfigDir,
17
+ getConfigPath: () => getConfigPath,
18
+ isConfigured: () => isConfigured,
19
+ loadConfig: () => loadConfig,
20
+ saveConfig: () => saveConfig
21
+ });
22
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
23
+ import { join } from "node:path";
24
+ import { homedir } from "node:os";
25
+ function getConfigDir() {
26
+ return CONFIG_DIR;
27
+ }
28
+ function getConfigPath() {
29
+ return CONFIG_FILE;
30
+ }
31
+ function loadConfig() {
32
+ try {
33
+ if (!existsSync(CONFIG_FILE)) return null;
34
+ const raw = readFileSync(CONFIG_FILE, "utf-8");
35
+ return JSON.parse(raw);
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+ function saveConfig(config) {
41
+ mkdirSync(CONFIG_DIR, { recursive: true });
42
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", "utf-8");
43
+ }
44
+ function clearConfig() {
45
+ try {
46
+ if (existsSync(CONFIG_FILE)) {
47
+ writeFileSync(CONFIG_FILE, "{}\n", "utf-8");
48
+ }
49
+ } catch {
50
+ }
51
+ }
52
+ function isConfigured() {
53
+ const config = loadConfig();
54
+ return !!(config?.reley_url && config?.device_token);
55
+ }
56
+ var CONFIG_DIR, CONFIG_FILE;
57
+ var init_config = __esm({
58
+ "src/config.ts"() {
59
+ "use strict";
60
+ CONFIG_DIR = join(homedir(), ".reley");
61
+ CONFIG_FILE = join(CONFIG_DIR, "config.json");
62
+ }
63
+ });
64
+
65
+ // src/index.ts
66
+ import { Command } from "commander";
67
+
68
+ // src/commands/run.ts
69
+ import { createServer as createHttpServer } from "node:http";
70
+ import { createServer as createNetServer } from "node:net";
71
+ import { fork } from "node:child_process";
72
+ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, unlinkSync } from "node:fs";
73
+ import { fileURLToPath } from "node:url";
74
+ import { dirname, join as join2 } from "node:path";
75
+ import { homedir as homedir2 } from "node:os";
76
+ import crypto from "node:crypto";
77
+ import WebSocket, { WebSocketServer } from "ws";
78
+ import { spawn } from "node-pty";
79
+
80
+ // ../../packages/crypto/dist/sodium.js
81
+ import { createRequire } from "node:module";
82
+ var require2 = createRequire(import.meta.url);
83
+ var sodium = require2("libsodium-wrappers-sumo");
84
+ var sodium_default = sodium;
85
+
86
+ // ../../packages/crypto/dist/keys.js
87
+ var initialized = false;
88
+ async function ensureSodium() {
89
+ if (!initialized) {
90
+ await sodium_default.ready;
91
+ initialized = true;
92
+ }
93
+ return sodium_default;
94
+ }
95
+ async function generateIdentityKeyPair() {
96
+ const s = await ensureSodium();
97
+ const kp = s.crypto_sign_keypair();
98
+ return {
99
+ publicKey: kp.publicKey,
100
+ secretKey: kp.privateKey,
101
+ keyType: "ed25519"
102
+ };
103
+ }
104
+ async function generateEphemeralKeyPair() {
105
+ const s = await ensureSodium();
106
+ const kp = s.crypto_kx_keypair();
107
+ return {
108
+ publicKey: kp.publicKey,
109
+ secretKey: kp.privateKey,
110
+ keyType: "x25519"
111
+ };
112
+ }
113
+ async function generateOneTimeCode(length = 32) {
114
+ const s = await ensureSodium();
115
+ return s.randombytes_buf(length);
116
+ }
117
+
118
+ // ../../packages/crypto/dist/ecdh.js
119
+ async function computeSharedSecret(ourSecretKey, theirPublicKey) {
120
+ const s = await ensureSodium();
121
+ return s.crypto_scalarmult(ourSecretKey, theirPublicKey);
122
+ }
123
+
124
+ // ../../packages/crypto/dist/hkdf.js
125
+ var HKDF_HASH_LEN = 32;
126
+ async function hkdfExtract(salt, ikm) {
127
+ const s = await ensureSodium();
128
+ const key = salt.length > 0 ? salt : new Uint8Array(HKDF_HASH_LEN);
129
+ return s.crypto_auth_hmacsha256(ikm, key);
130
+ }
131
+ async function hkdfExpand(prk, info, length) {
132
+ const s = await ensureSodium();
133
+ const n = Math.ceil(length / HKDF_HASH_LEN);
134
+ const okm = new Uint8Array(n * HKDF_HASH_LEN);
135
+ let prev = new Uint8Array(0);
136
+ for (let i = 1; i <= n; i++) {
137
+ const input = new Uint8Array(prev.length + info.length + 1);
138
+ input.set(prev, 0);
139
+ input.set(info, prev.length);
140
+ input[prev.length + info.length] = i;
141
+ prev = new Uint8Array(s.crypto_auth_hmacsha256(input, prk));
142
+ okm.set(prev, (i - 1) * HKDF_HASH_LEN);
143
+ }
144
+ return okm.slice(0, length);
145
+ }
146
+ async function deriveSessionKeys(sharedSecret, salt = new Uint8Array(0), info = "reley-v1") {
147
+ const infoBytes = new TextEncoder().encode(info);
148
+ const prk = await hkdfExtract(salt, sharedSecret);
149
+ const okm = await hkdfExpand(prk, infoBytes, 64);
150
+ return {
151
+ sendKey: okm.slice(0, 32),
152
+ recvKey: okm.slice(32, 64)
153
+ };
154
+ }
155
+ async function deriveChainKey(chainKey, info = "reley-chain") {
156
+ const s = await ensureSodium();
157
+ const msgKeyInput = new TextEncoder().encode(info + "-msg");
158
+ const chainKeyInput = new TextEncoder().encode(info + "-chain");
159
+ const messageKey = s.crypto_auth_hmacsha256(msgKeyInput, chainKey);
160
+ const nextChainKey = s.crypto_auth_hmacsha256(chainKeyInput, chainKey);
161
+ return { messageKey, nextChainKey };
162
+ }
163
+
164
+ // ../../packages/crypto/dist/aes-gcm.js
165
+ var NONCE_LENGTH = 12;
166
+ var KEY_LENGTH = 32;
167
+ async function encrypt(plaintext, key, aad = new Uint8Array(0)) {
168
+ const s = await ensureSodium();
169
+ if (key.length !== KEY_LENGTH) {
170
+ throw new Error(`Key must be ${KEY_LENGTH} bytes, got ${key.length}`);
171
+ }
172
+ const nonce = s.randombytes_buf(NONCE_LENGTH);
173
+ const ciphertext = s.crypto_aead_xchacha20poly1305_ietf_encrypt(
174
+ plaintext,
175
+ aad.length > 0 ? aad : null,
176
+ null,
177
+ // nsec (unused)
178
+ // xchacha uses 24-byte nonce, pad our 12-byte nonce
179
+ padNonce(nonce, s),
180
+ key
181
+ );
182
+ return { nonce, ciphertext };
183
+ }
184
+ async function decrypt(ciphertext, nonce, key, aad = new Uint8Array(0)) {
185
+ const s = await ensureSodium();
186
+ if (key.length !== KEY_LENGTH) {
187
+ throw new Error(`Key must be ${KEY_LENGTH} bytes, got ${key.length}`);
188
+ }
189
+ try {
190
+ return s.crypto_aead_xchacha20poly1305_ietf_decrypt(
191
+ null,
192
+ // nsec (unused)
193
+ ciphertext,
194
+ aad.length > 0 ? aad : null,
195
+ padNonce(nonce, s),
196
+ key
197
+ );
198
+ } catch {
199
+ throw new Error("Decryption failed: invalid ciphertext or key");
200
+ }
201
+ }
202
+ function padNonce(nonce, s) {
203
+ const padded = new Uint8Array(s.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
204
+ padded.set(nonce, 0);
205
+ return padded;
206
+ }
207
+
208
+ // ../../packages/crypto/dist/ratchet.js
209
+ var KEY_ROTATION_INTERVAL = 50;
210
+ function initRatchet(sendKey, recvKey) {
211
+ return {
212
+ sendChainKey: sendKey,
213
+ recvChainKey: recvKey,
214
+ sendCounter: 0,
215
+ recvCounter: 0,
216
+ maxRecvCounter: -1
217
+ };
218
+ }
219
+ async function ratchetEncrypt(state, plaintext) {
220
+ const { messageKey, nextChainKey } = await deriveChainKey(state.sendChainKey);
221
+ const counter = state.sendCounter;
222
+ const aad = buildAAD(1, counter);
223
+ const { nonce, ciphertext } = await encrypt(plaintext, messageKey, aad);
224
+ const newState = {
225
+ ...state,
226
+ sendChainKey: nextChainKey,
227
+ sendCounter: counter + 1
228
+ };
229
+ return { ciphertext, nonce, counter, state: newState };
230
+ }
231
+ async function ratchetDecrypt(state, ciphertext, nonce, counter) {
232
+ if (counter <= state.maxRecvCounter) {
233
+ throw new Error(`Replay attack detected: counter ${counter} <= ${state.maxRecvCounter}`);
234
+ }
235
+ let chainKey = state.recvChainKey;
236
+ let currentCounter = state.recvCounter;
237
+ let messageKey;
238
+ while (currentCounter <= counter) {
239
+ const derived = await deriveChainKey(chainKey);
240
+ if (currentCounter === counter) {
241
+ messageKey = derived.messageKey;
242
+ }
243
+ chainKey = derived.nextChainKey;
244
+ currentCounter++;
245
+ }
246
+ if (!messageKey) {
247
+ throw new Error("Failed to derive message key");
248
+ }
249
+ const aad = buildAAD(1, counter);
250
+ const plaintext = await decrypt(ciphertext, nonce, messageKey, aad);
251
+ const newState = {
252
+ ...state,
253
+ recvChainKey: chainKey,
254
+ recvCounter: currentCounter,
255
+ maxRecvCounter: counter
256
+ };
257
+ return { plaintext, state: newState };
258
+ }
259
+ function needsKeyRotation(state) {
260
+ return state.sendCounter > 0 && state.sendCounter % KEY_ROTATION_INTERVAL === 0;
261
+ }
262
+ function buildAAD(version, counter) {
263
+ const aad = new Uint8Array(6);
264
+ aad[0] = version;
265
+ aad[1] = 1;
266
+ aad[2] = counter >>> 24 & 255;
267
+ aad[3] = counter >>> 16 & 255;
268
+ aad[4] = counter >>> 8 & 255;
269
+ aad[5] = counter & 255;
270
+ return aad;
271
+ }
272
+
273
+ // ../../packages/crypto/dist/qr-payload.js
274
+ var MAGIC = "CB1";
275
+ async function encodeQRPayload(payload) {
276
+ const s = await ensureSodium();
277
+ const releyBytes = new TextEncoder().encode(payload.releyUrl);
278
+ const jwtBytes = new TextEncoder().encode(payload.jwt);
279
+ const totalLen = 3 + 2 + releyBytes.length + 32 + 32 + 2 + jwtBytes.length;
280
+ const buf = new Uint8Array(totalLen);
281
+ let offset = 0;
282
+ buf[offset++] = MAGIC.charCodeAt(0);
283
+ buf[offset++] = MAGIC.charCodeAt(1);
284
+ buf[offset++] = MAGIC.charCodeAt(2);
285
+ buf[offset++] = releyBytes.length >>> 8 & 255;
286
+ buf[offset++] = releyBytes.length & 255;
287
+ buf.set(releyBytes, offset);
288
+ offset += releyBytes.length;
289
+ buf.set(payload.publicKey, offset);
290
+ offset += 32;
291
+ buf.set(payload.oneTimeCode, offset);
292
+ offset += 32;
293
+ buf[offset++] = jwtBytes.length >>> 8 & 255;
294
+ buf[offset++] = jwtBytes.length & 255;
295
+ buf.set(jwtBytes, offset);
296
+ return s.to_base64(buf, s.base64_variants.URLSAFE_NO_PADDING);
297
+ }
298
+ async function hashOneTimeCode(otc) {
299
+ const s = await ensureSodium();
300
+ return s.crypto_generichash(32, otc, null);
301
+ }
302
+
303
+ // ../../packages/protocol/dist/constants.js
304
+ var PROTOCOL_VERSION = 1;
305
+ var WIRE = {
306
+ VERSION: 1,
307
+ TYPE: 1,
308
+ COUNTER: 4,
309
+ NONCE: 12,
310
+ TAG: 16,
311
+ HEADER: 1 + 1 + 4 + 12
312
+ // 18 bytes total header before ciphertext
313
+ };
314
+ var MessageType = {
315
+ TERMINAL_DATA: 1,
316
+ TERMINAL_RESIZE: 2,
317
+ TERMINAL_INPUT: 3,
318
+ HOOK_EVENT: 16,
319
+ HOOK_RESPONSE: 17,
320
+ SESSION_PING: 32,
321
+ SESSION_PONG: 33,
322
+ SESSION_CLOSE: 34,
323
+ KEY_ROTATION: 48,
324
+ KEY_EXCHANGE: 49
325
+ };
326
+ var TIMEOUTS = {
327
+ PAIRING_EXPIRY: 5 * 60 * 1e3,
328
+ // 5 minutes
329
+ JWT_EXPIRY: 24 * 60 * 60 * 1e3,
330
+ // 24 hours
331
+ PING_INTERVAL: 30 * 1e3,
332
+ // 30 seconds
333
+ PONG_TIMEOUT: 10 * 1e3,
334
+ // 10 seconds
335
+ WS_RECONNECT_BASE: 1e3,
336
+ // 1 second base for exponential backoff
337
+ WS_RECONNECT_MAX: 30 * 1e3,
338
+ // 30 seconds max
339
+ HOOK_RESPONSE_TIMEOUT: 5 * 60 * 1e3
340
+ // 5 minutes for user approval
341
+ };
342
+
343
+ // ../../packages/protocol/dist/envelope.js
344
+ var MESSAGE_TYPE_MAP = {
345
+ terminal_data: MessageType.TERMINAL_DATA,
346
+ terminal_resize: MessageType.TERMINAL_RESIZE,
347
+ terminal_input: MessageType.TERMINAL_INPUT,
348
+ hook_event: MessageType.HOOK_EVENT,
349
+ hook_response: MessageType.HOOK_RESPONSE,
350
+ ping: MessageType.SESSION_PING,
351
+ pong: MessageType.SESSION_PONG,
352
+ session_close: MessageType.SESSION_CLOSE,
353
+ key_rotation: MessageType.KEY_ROTATION,
354
+ key_exchange: MessageType.KEY_EXCHANGE
355
+ };
356
+ var REVERSE_TYPE_MAP = /* @__PURE__ */ new Map();
357
+ for (const [k, v] of Object.entries(MESSAGE_TYPE_MAP)) {
358
+ REVERSE_TYPE_MAP.set(v, k);
359
+ }
360
+ function getWireType(messageType) {
361
+ const t = MESSAGE_TYPE_MAP[messageType];
362
+ if (t === void 0) {
363
+ throw new Error(`Unknown message type: ${messageType}`);
364
+ }
365
+ return t;
366
+ }
367
+ function encodeEnvelope(envelope) {
368
+ const totalLen = WIRE.HEADER + envelope.ciphertext.length;
369
+ const buf = new Uint8Array(totalLen);
370
+ let offset = 0;
371
+ buf[offset++] = envelope.version;
372
+ buf[offset++] = envelope.type;
373
+ buf[offset++] = envelope.counter >>> 24 & 255;
374
+ buf[offset++] = envelope.counter >>> 16 & 255;
375
+ buf[offset++] = envelope.counter >>> 8 & 255;
376
+ buf[offset++] = envelope.counter & 255;
377
+ buf.set(envelope.nonce, offset);
378
+ offset += WIRE.NONCE;
379
+ buf.set(envelope.ciphertext, offset);
380
+ return buf;
381
+ }
382
+ function decodeEnvelope(data) {
383
+ if (data.length < WIRE.HEADER + WIRE.TAG) {
384
+ throw new Error(`Envelope too short: ${data.length} bytes`);
385
+ }
386
+ let offset = 0;
387
+ const version = data[offset++];
388
+ if (version !== PROTOCOL_VERSION) {
389
+ throw new Error(`Unsupported protocol version: ${version}`);
390
+ }
391
+ const type = data[offset++];
392
+ const counter = data[offset] << 24 | data[offset + 1] << 16 | data[offset + 2] << 8 | data[offset + 3];
393
+ offset += 4;
394
+ const nonce = data.slice(offset, offset + WIRE.NONCE);
395
+ offset += WIRE.NONCE;
396
+ const ciphertext = data.slice(offset);
397
+ return { version, type, counter, nonce, ciphertext };
398
+ }
399
+ function serializeMessage(message) {
400
+ return new TextEncoder().encode(JSON.stringify(message));
401
+ }
402
+ function deserializeMessage(data) {
403
+ return JSON.parse(new TextDecoder().decode(data));
404
+ }
405
+
406
+ // src/commands/run.ts
407
+ var __dirname = dirname(fileURLToPath(import.meta.url));
408
+ var loggingEnabled = true;
409
+ function log(label, msg) {
410
+ if (!loggingEnabled) return;
411
+ const colors = {
412
+ RELEY: "\x1B[34m",
413
+ PTY: "\x1B[32m",
414
+ WEB: "\x1B[35m",
415
+ CRYPTO: "\x1B[33m",
416
+ HOOKS: "\x1B[36m"
417
+ };
418
+ console.error(`${colors[label] ?? ""}[${label}]\x1B[0m ${msg}`);
419
+ }
420
+ function findReleyServer() {
421
+ const candidates = [
422
+ join2(__dirname, "../../../reley/dist/server.js"),
423
+ join2(__dirname, "../../../../apps/reley/dist/server.js")
424
+ ];
425
+ for (const p of candidates) {
426
+ if (existsSync2(p)) return p;
427
+ }
428
+ throw new Error("Reley server not found. Run `pnpm run build` first.");
429
+ }
430
+ async function isReleyRunning(url) {
431
+ try {
432
+ const res = await fetch(`${url}/health`);
433
+ return res.ok;
434
+ } catch {
435
+ return false;
436
+ }
437
+ }
438
+ async function waitForReley(url) {
439
+ for (let i = 0; i < 50; i++) {
440
+ if (await isReleyRunning(url)) return;
441
+ await new Promise((r) => setTimeout(r, 200));
442
+ }
443
+ throw new Error("Reley server failed to start");
444
+ }
445
+ function getTerminalHtml() {
446
+ return `<!DOCTYPE html>
447
+ <html lang="ko"><head>
448
+ <meta charset="utf-8">
449
+ <meta name="viewport" content="width=device-width, initial-scale=1">
450
+ <title>Reley</title>
451
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
452
+ <style>
453
+ :root {
454
+ --bg: #0E0F13;
455
+ --surface: rgba(255,255,255,0.06);
456
+ --surface-hover: rgba(255,255,255,0.10);
457
+ --border: rgba(255,255,255,0.08);
458
+ --border-hover: rgba(255,255,255,0.14);
459
+ --primary: #3182F6;
460
+ --primary-hover: #1B64DA;
461
+ --success: #34C759;
462
+ --danger: #FF3B30;
463
+ --warning: #FFD60A;
464
+ --text: #F5F5F7;
465
+ --text-secondary: rgba(255,255,255,0.6);
466
+ --text-tertiary: rgba(255,255,255,0.35);
467
+ --font: -apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, sans-serif;
468
+ --mono: 'SF Mono', Menlo, Monaco, 'Courier New', monospace;
469
+ --radius-card: 16px;
470
+ --radius-btn: 12px;
471
+ --spring: cubic-bezier(0.32, 0.72, 0, 1);
472
+ --ease: cubic-bezier(0.25, 0.1, 0.25, 1);
473
+ }
474
+
475
+ * { margin: 0; padding: 0; box-sizing: border-box; }
476
+
477
+ body {
478
+ background: var(--bg);
479
+ color: var(--text);
480
+ font-family: var(--font);
481
+ display: flex;
482
+ flex-direction: column;
483
+ height: 100vh;
484
+ overflow: hidden;
485
+ -webkit-font-smoothing: antialiased;
486
+ }
487
+
488
+ /* \u2500\u2500 Header \u2500\u2500 */
489
+ #header {
490
+ padding: 12px 20px;
491
+ display: flex;
492
+ align-items: center;
493
+ justify-content: space-between;
494
+ flex-shrink: 0;
495
+ border-bottom: 1px solid var(--border);
496
+ background: rgba(14,15,19,0.8);
497
+ backdrop-filter: blur(20px) saturate(180%);
498
+ -webkit-backdrop-filter: blur(20px) saturate(180%);
499
+ z-index: 10;
500
+ }
501
+
502
+ .header-left {
503
+ display: flex;
504
+ align-items: center;
505
+ gap: 8px;
506
+ }
507
+
508
+ .logo {
509
+ font-size: 15px;
510
+ font-weight: 600;
511
+ letter-spacing: -0.3px;
512
+ color: var(--text);
513
+ }
514
+
515
+ .logo-icon {
516
+ width: 20px;
517
+ height: 20px;
518
+ background: var(--primary);
519
+ border-radius: 6px;
520
+ display: flex;
521
+ align-items: center;
522
+ justify-content: center;
523
+ font-size: 11px;
524
+ }
525
+
526
+ .header-center {
527
+ position: absolute;
528
+ left: 50%;
529
+ transform: translateX(-50%);
530
+ }
531
+
532
+ #status {
533
+ display: inline-flex;
534
+ align-items: center;
535
+ gap: 6px;
536
+ font-size: 12px;
537
+ font-weight: 500;
538
+ padding: 4px 12px;
539
+ border-radius: 100px;
540
+ background: var(--surface);
541
+ border: 1px solid var(--border);
542
+ color: var(--text-secondary);
543
+ transition: all 0.3s var(--ease);
544
+ }
545
+
546
+ .status-dot {
547
+ width: 6px;
548
+ height: 6px;
549
+ border-radius: 50%;
550
+ background: var(--warning);
551
+ transition: background 0.3s var(--ease);
552
+ }
553
+
554
+ #status.connected .status-dot { background: var(--success); }
555
+ #status.connected { color: var(--success); border-color: rgba(52,199,89,0.2); background: rgba(52,199,89,0.08); }
556
+ #status.disconnected .status-dot { background: var(--danger); }
557
+ #status.disconnected { color: var(--danger); border-color: rgba(255,59,48,0.2); background: rgba(255,59,48,0.08); }
558
+
559
+ .header-right {
560
+ display: flex;
561
+ align-items: center;
562
+ }
563
+
564
+ .badge-e2e {
565
+ display: inline-flex;
566
+ align-items: center;
567
+ gap: 5px;
568
+ font-size: 11px;
569
+ font-weight: 500;
570
+ padding: 4px 10px;
571
+ border-radius: 100px;
572
+ background: rgba(52,199,89,0.1);
573
+ border: 1px solid rgba(52,199,89,0.15);
574
+ color: var(--success);
575
+ }
576
+
577
+ .badge-e2e svg { width: 12px; height: 12px; }
578
+
579
+ /* \u2500\u2500 Terminal Window \u2500\u2500 */
580
+ #wrap {
581
+ flex: 1;
582
+ overflow: hidden;
583
+ display: flex;
584
+ align-items: center;
585
+ justify-content: center;
586
+ padding: 16px;
587
+ position: relative;
588
+ }
589
+
590
+ .terminal-window {
591
+ border-radius: var(--radius-card);
592
+ overflow: hidden;
593
+ box-shadow: 0 8px 40px rgba(0,0,0,0.5), 0 0 0 1px var(--border);
594
+ background: #1A1B23;
595
+ position: relative;
596
+ }
597
+
598
+ .terminal-titlebar {
599
+ height: 38px;
600
+ background: rgba(255,255,255,0.04);
601
+ border-bottom: 1px solid var(--border);
602
+ display: flex;
603
+ align-items: center;
604
+ padding: 0 14px;
605
+ gap: 8px;
606
+ user-select: none;
607
+ -webkit-user-select: none;
608
+ }
609
+
610
+ .titlebar-dots {
611
+ display: flex;
612
+ gap: 7px;
613
+ }
614
+
615
+ .titlebar-dot {
616
+ width: 11px;
617
+ height: 11px;
618
+ border-radius: 50%;
619
+ }
620
+
621
+ .titlebar-dot.red { background: #FF5F57; }
622
+ .titlebar-dot.yellow { background: #FEBC2E; }
623
+ .titlebar-dot.green { background: #28C840; }
624
+
625
+ .titlebar-title {
626
+ flex: 1;
627
+ text-align: center;
628
+ font-size: 12px;
629
+ font-weight: 500;
630
+ color: var(--text-tertiary);
631
+ letter-spacing: -0.2px;
632
+ }
633
+
634
+ .titlebar-spacer { width: 52px; }
635
+
636
+ #terminal {
637
+ transform-origin: top left;
638
+ }
639
+
640
+ /* \u2500\u2500 Event Cards Container \u2500\u2500 */
641
+ #event-cards {
642
+ position: fixed;
643
+ bottom: 20px;
644
+ right: 20px;
645
+ display: flex;
646
+ flex-direction: column;
647
+ gap: 10px;
648
+ z-index: 100;
649
+ max-width: 420px;
650
+ width: calc(100% - 40px);
651
+ pointer-events: none;
652
+ }
653
+
654
+ /* \u2500\u2500 Event Card \u2500\u2500 */
655
+ .event-card {
656
+ background: rgba(30, 32, 42, 0.85);
657
+ backdrop-filter: blur(24px) saturate(180%);
658
+ -webkit-backdrop-filter: blur(24px) saturate(180%);
659
+ border: 1px solid var(--border);
660
+ border-radius: var(--radius-card);
661
+ padding: 16px;
662
+ animation: slideUp 0.5s var(--spring) forwards;
663
+ pointer-events: auto;
664
+ box-shadow: 0 8px 32px rgba(0,0,0,0.3), 0 0 0 1px rgba(255,255,255,0.05);
665
+ transition: border-color 0.2s var(--ease);
666
+ }
667
+
668
+ .event-card:hover {
669
+ border-color: var(--border-hover);
670
+ }
671
+
672
+ .event-card.removing {
673
+ animation: fadeOut 0.3s var(--ease) forwards;
674
+ }
675
+
676
+ @keyframes slideUp {
677
+ from { transform: translateY(24px); opacity: 0; }
678
+ to { transform: translateY(0); opacity: 1; }
679
+ }
680
+
681
+ @keyframes fadeOut {
682
+ to { transform: translateY(12px); opacity: 0; }
683
+ }
684
+
685
+ .card-header {
686
+ display: flex;
687
+ align-items: center;
688
+ gap: 10px;
689
+ margin-bottom: 12px;
690
+ }
691
+
692
+ .card-icon {
693
+ width: 32px;
694
+ height: 32px;
695
+ border-radius: 10px;
696
+ display: flex;
697
+ align-items: center;
698
+ justify-content: center;
699
+ font-size: 15px;
700
+ flex-shrink: 0;
701
+ }
702
+
703
+ .card-icon.bash { background: rgba(49,130,246,0.15); color: var(--primary); }
704
+ .card-icon.file { background: rgba(168,130,246,0.15); color: #A882F6; }
705
+ .card-icon.stop { background: rgba(52,199,89,0.15); color: var(--success); }
706
+ .card-icon.info { background: rgba(49,130,246,0.15); color: var(--primary); }
707
+ .card-icon.warning { background: rgba(255,214,10,0.15); color: var(--warning); }
708
+ .card-icon.error { background: rgba(255,59,48,0.15); color: var(--danger); }
709
+
710
+ .card-title {
711
+ font-size: 13px;
712
+ font-weight: 600;
713
+ color: var(--text);
714
+ letter-spacing: -0.2px;
715
+ }
716
+
717
+ .card-subtitle {
718
+ font-size: 11px;
719
+ color: var(--text-tertiary);
720
+ margin-top: 1px;
721
+ }
722
+
723
+ .card-body {
724
+ margin-bottom: 14px;
725
+ }
726
+
727
+ .card-code {
728
+ background: rgba(0,0,0,0.3);
729
+ border: 1px solid rgba(255,255,255,0.06);
730
+ border-radius: 10px;
731
+ padding: 10px 12px;
732
+ font-family: var(--mono);
733
+ font-size: 12px;
734
+ line-height: 1.5;
735
+ color: var(--text);
736
+ overflow-x: auto;
737
+ white-space: pre-wrap;
738
+ word-break: break-all;
739
+ max-height: 120px;
740
+ overflow-y: auto;
741
+ }
742
+
743
+ .card-filepath {
744
+ background: rgba(0,0,0,0.3);
745
+ border: 1px solid rgba(255,255,255,0.06);
746
+ border-radius: 10px;
747
+ padding: 8px 12px;
748
+ font-family: var(--mono);
749
+ font-size: 12px;
750
+ color: #A882F6;
751
+ display: flex;
752
+ align-items: center;
753
+ gap: 6px;
754
+ }
755
+
756
+ .card-message {
757
+ font-size: 13px;
758
+ color: var(--text-secondary);
759
+ line-height: 1.5;
760
+ }
761
+
762
+ /* \u2500\u2500 Scrollbar \u2500\u2500 */
763
+ ::-webkit-scrollbar { width: 6px; }
764
+ ::-webkit-scrollbar-track { background: transparent; }
765
+ ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; }
766
+ ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.25); }
767
+ </style>
768
+ </head><body>
769
+
770
+ <!-- Header -->
771
+ <div id="header">
772
+ <div class="header-left">
773
+ <div class="logo-icon">
774
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round">
775
+ <polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
776
+ </svg>
777
+ </div>
778
+ <span class="logo">Reley</span>
779
+ </div>
780
+ <div class="header-center">
781
+ <div id="status">
782
+ <span class="status-dot"></span>
783
+ <span id="st">Connecting...</span>
784
+ </div>
785
+ </div>
786
+ <div class="header-right">
787
+ <div class="badge-e2e">
788
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
789
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
790
+ </svg>
791
+ E2E Encrypted
792
+ </div>
793
+ </div>
794
+ </div>
795
+
796
+ <!-- Terminal -->
797
+ <div id="wrap">
798
+ <div class="terminal-window">
799
+ <div class="terminal-titlebar">
800
+ <div class="titlebar-dots">
801
+ <div class="titlebar-dot red"></div>
802
+ <div class="titlebar-dot yellow"></div>
803
+ <div class="titlebar-dot green"></div>
804
+ </div>
805
+ <div class="titlebar-title">Reley Terminal</div>
806
+ <div class="titlebar-spacer"></div>
807
+ </div>
808
+ <div id="terminal"></div>
809
+ </div>
810
+ </div>
811
+
812
+ <!-- Event Cards -->
813
+ <div id="event-cards"></div>
814
+
815
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
816
+ <script>
817
+ // \u2500\u2500 Terminal Setup \u2500\u2500
818
+ const term = new window.Terminal({
819
+ theme: {
820
+ background: '#1A1B23',
821
+ foreground: '#F5F5F7',
822
+ cursor: '#3182F6',
823
+ cursorAccent: '#1A1B23',
824
+ selectionBackground: '#3182F644',
825
+ black: '#1A1B23',
826
+ brightBlack: '#4A4B57',
827
+ },
828
+ fontSize: 14,
829
+ fontFamily: "'SF Mono', Menlo, Monaco, 'Courier New', monospace",
830
+ cursorBlink: true,
831
+ scrollback: 10000,
832
+ cols: 80,
833
+ rows: 24,
834
+ });
835
+ term.open(document.getElementById('terminal'));
836
+
837
+ // \u2500\u2500 CSS Transform Scaling \u2500\u2500
838
+ function fitScale() {
839
+ requestAnimationFrame(() => {
840
+ const wrap = document.getElementById('wrap');
841
+ const win = document.querySelector('.terminal-window');
842
+ const scr = document.querySelector('.xterm-screen');
843
+ if (!wrap || !scr || !win) return;
844
+ const sw = scr.offsetWidth;
845
+ const sh = scr.offsetHeight + 38; // titlebar height
846
+ if (!sw || !sh) return;
847
+ const pad = 32;
848
+ const maxW = wrap.clientWidth - pad;
849
+ const maxH = wrap.clientHeight - pad;
850
+ const scale = Math.min(maxW / sw, maxH / sh, 1.5);
851
+ win.style.transform = 'scale(' + scale + ')';
852
+ win.style.transformOrigin = 'center center';
853
+ });
854
+ }
855
+ window.addEventListener('resize', fitScale);
856
+
857
+ // \u2500\u2500 WebSocket \u2500\u2500
858
+ const statusEl = document.getElementById('status');
859
+ const st = document.getElementById('st');
860
+ const ws = new WebSocket('ws://' + location.host);
861
+
862
+ ws.onopen = () => {
863
+ statusEl.className = 'connected';
864
+ st.textContent = 'Connected';
865
+ term.focus();
866
+ };
867
+
868
+ ws.onmessage = (e) => {
869
+ const m = JSON.parse(e.data);
870
+ if (m.type === 'output') {
871
+ term.write(m.data);
872
+ } else if (m.type === 'sync_size') {
873
+ term.resize(m.cols, m.rows);
874
+ setTimeout(fitScale, 50);
875
+ } else if (m.type === 'hook_event') {
876
+ renderHookEvent(m);
877
+ }
878
+ };
879
+
880
+ ws.onclose = () => {
881
+ statusEl.className = 'disconnected';
882
+ st.textContent = 'Disconnected';
883
+ };
884
+
885
+ term.onData((d) => ws.send(JSON.stringify({ type: 'input', data: d })));
886
+ setTimeout(fitScale, 200);
887
+
888
+ // \u2500\u2500 Hook Event Cards \u2500\u2500
889
+ const cardsContainer = document.getElementById('event-cards');
890
+
891
+ function renderHookEvent(evt) {
892
+ const hookType = evt.hookType;
893
+ const data = evt.data || {};
894
+ const requestId = evt.requestId;
895
+
896
+ if (hookType === 'pre-tool-use') {
897
+ renderPreToolUseCard(data, requestId);
898
+ } else if (hookType === 'stop') {
899
+ renderStopCard(data);
900
+ } else if (hookType === 'notification') {
901
+ renderNotificationCard(data);
902
+ }
903
+ }
904
+
905
+ function renderPreToolUseCard(data, requestId) {
906
+ const toolName = (data.tool_name || '').toLowerCase();
907
+ const input = data.tool_input || {};
908
+
909
+ const card = document.createElement('div');
910
+ card.className = 'event-card';
911
+ card.dataset.requestId = requestId;
912
+
913
+ let iconClass, iconSvg, title, subtitle, bodyHtml;
914
+
915
+ if (toolName === 'bash') {
916
+ iconClass = 'bash';
917
+ iconSvg = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>';
918
+ title = 'Bash Command';
919
+ subtitle = 'Terminal execution request';
920
+ bodyHtml = '<div class="card-code">' + escapeHtml(input.command || '') + '</div>';
921
+ } else if (toolName === 'write' || toolName === 'edit') {
922
+ iconClass = 'file';
923
+ iconSvg = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>';
924
+ title = toolName === 'write' ? 'Write File' : 'Edit File';
925
+ subtitle = 'File modification request';
926
+ const fp = input.file_path || input.filePath || '';
927
+ bodyHtml = '<div class="card-filepath"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>' + escapeHtml(fp) + '</div>';
928
+ } else {
929
+ iconClass = 'bash';
930
+ iconSvg = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>';
931
+ title = 'Tool: ' + escapeHtml(data.tool_name || 'Unknown');
932
+ subtitle = 'Tool execution request';
933
+ bodyHtml = '<div class="card-code">' + escapeHtml(JSON.stringify(input, null, 2)) + '</div>';
934
+ }
935
+
936
+ card.innerHTML =
937
+ '<div class="card-header">' +
938
+ '<div class="card-icon ' + iconClass + '">' + iconSvg + '</div>' +
939
+ '<div><div class="card-title">' + title + '</div><div class="card-subtitle">' + subtitle + '</div></div>' +
940
+ '</div>' +
941
+ '<div class="card-body">' + bodyHtml + '</div>';
942
+
943
+ cardsContainer.appendChild(card);
944
+ card.addEventListener('click', () => removeCard(card));
945
+ setTimeout(() => removeCard(card), 8000);
946
+ }
947
+
948
+ function renderStopCard(data) {
949
+ const card = document.createElement('div');
950
+ card.className = 'event-card';
951
+ card.innerHTML =
952
+ '<div class="card-header">' +
953
+ '<div class="card-icon stop"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></div>' +
954
+ '<div><div class="card-title">Task Complete</div><div class="card-subtitle">' + escapeHtml(data.stop_reason || 'Finished') + '</div></div>' +
955
+ '</div>' +
956
+ '<div class="card-body"><div class="card-message">' + escapeHtml(data.message || 'Claude has finished the task.') + '</div></div>';
957
+
958
+ cardsContainer.appendChild(card);
959
+ card.addEventListener('click', () => removeCard(card));
960
+ setTimeout(() => removeCard(card), 5000);
961
+ }
962
+
963
+ function renderNotificationCard(data) {
964
+ const level = (data.level || 'info').toLowerCase();
965
+ let iconClass = 'info';
966
+ let iconSvg = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>';
967
+ if (level === 'warning') {
968
+ iconClass = 'warning';
969
+ iconSvg = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
970
+ } else if (level === 'error') {
971
+ iconClass = 'error';
972
+ iconSvg = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>';
973
+ }
974
+
975
+ const card = document.createElement('div');
976
+ card.className = 'event-card';
977
+ card.innerHTML =
978
+ '<div class="card-header">' +
979
+ '<div class="card-icon ' + iconClass + '">' + iconSvg + '</div>' +
980
+ '<div><div class="card-title">' + escapeHtml(data.title || 'Notification') + '</div></div>' +
981
+ '</div>' +
982
+ '<div class="card-body"><div class="card-message">' + escapeHtml(data.message || '') + '</div></div>';
983
+
984
+ cardsContainer.appendChild(card);
985
+ card.addEventListener('click', () => removeCard(card));
986
+ setTimeout(() => removeCard(card), 5000);
987
+ }
988
+
989
+ function removeCard(card) {
990
+ if (card.classList.contains('removing')) return;
991
+ card.classList.add('removing');
992
+ card.addEventListener('animationend', () => card.remove());
993
+ }
994
+
995
+ function escapeHtml(str) {
996
+ const d = document.createElement('div');
997
+ d.textContent = str;
998
+ return d.innerHTML;
999
+ }
1000
+ </script>
1001
+ </body></html>`;
1002
+ }
1003
+ function getClaudeSettingsPath() {
1004
+ return join2(homedir2(), ".claude", "settings.json");
1005
+ }
1006
+ function createHookScript(sockPath) {
1007
+ const scriptPath = `/tmp/reley-hook-${process.pid}`;
1008
+ const script = `#!/usr/bin/env node
1009
+ const net = require('net');
1010
+
1011
+ // Session isolation: only run for the Reley session that set this env var.
1012
+ // Other Claude sessions won't have it, so they skip immediately.
1013
+ const sockPath = process.env.RELEY_HOOK_SOCK;
1014
+ if (!sockPath) process.exit(0);
1015
+
1016
+ const hookType = process.argv[2] || 'unknown';
1017
+
1018
+ // Read stdin (Claude Code sends JSON via stdin for hooks)
1019
+ let input = '';
1020
+ process.stdin.setEncoding('utf8');
1021
+ process.stdin.on('data', (c) => { input += c; });
1022
+ process.stdin.on('end', () => {
1023
+ const payload = JSON.stringify({ hookType, data: tryParse(input) }) + '\\n';
1024
+ const client = net.createConnection(sockPath, () => {
1025
+ client.write(payload);
1026
+ });
1027
+ let resp = '';
1028
+ client.on('data', (d) => { resp += d.toString(); });
1029
+ client.on('end', () => {
1030
+ process.exit(0);
1031
+ });
1032
+ client.on('error', () => {
1033
+ // Socket not available, approve by default
1034
+ process.exit(0);
1035
+ });
1036
+ setTimeout(() => process.exit(0), 30000);
1037
+ });
1038
+
1039
+ function tryParse(s) {
1040
+ try { return JSON.parse(s); } catch { return { raw: s }; }
1041
+ }
1042
+ `;
1043
+ writeFileSync2(scriptPath, script, { mode: 493 });
1044
+ return scriptPath;
1045
+ }
1046
+ function installClaudeHooks(hookScriptPath) {
1047
+ const settingsPath = getClaudeSettingsPath();
1048
+ let settings = {};
1049
+ const result = {
1050
+ path: settingsPath,
1051
+ hadHooks: false,
1052
+ originalHooks: void 0
1053
+ };
1054
+ try {
1055
+ if (existsSync2(settingsPath)) {
1056
+ settings = JSON.parse(readFileSync2(settingsPath, "utf-8"));
1057
+ }
1058
+ } catch {
1059
+ settings = {};
1060
+ }
1061
+ if (settings.hooks) {
1062
+ result.hadHooks = true;
1063
+ result.originalHooks = JSON.parse(JSON.stringify(settings.hooks));
1064
+ }
1065
+ if (!settings.hooks) settings.hooks = {};
1066
+ const cbCommand = (type) => `${hookScriptPath} ${type}`;
1067
+ const hookDefs = {
1068
+ PreToolUse: [
1069
+ { matcher: "Bash", type: "pre-tool-use" },
1070
+ { matcher: "Write", type: "pre-tool-use" },
1071
+ { matcher: "Edit", type: "pre-tool-use" }
1072
+ ],
1073
+ Stop: [
1074
+ { matcher: "", type: "stop" }
1075
+ ],
1076
+ Notification: [
1077
+ { matcher: "", type: "notification" }
1078
+ ]
1079
+ };
1080
+ for (const [event, entries] of Object.entries(hookDefs)) {
1081
+ if (!settings.hooks[event]) settings.hooks[event] = [];
1082
+ for (const entry of entries) {
1083
+ const cmd = cbCommand(entry.type);
1084
+ const exists = settings.hooks[event].some(
1085
+ (h) => Array.isArray(h.hooks) && h.hooks.some((hk) => hk.command === cmd)
1086
+ );
1087
+ if (!exists) {
1088
+ const hookEntry = {
1089
+ matcher: entry.matcher,
1090
+ hooks: [{ type: "command", command: cmd }]
1091
+ };
1092
+ settings.hooks[event].push(hookEntry);
1093
+ }
1094
+ }
1095
+ }
1096
+ writeFileSync2(settingsPath, JSON.stringify(settings, null, 2), "utf-8");
1097
+ return result;
1098
+ }
1099
+ function uninstallClaudeHooks(original, hookScriptPath) {
1100
+ try {
1101
+ const settingsPath = original.path;
1102
+ if (!existsSync2(settingsPath)) return;
1103
+ const settings = JSON.parse(readFileSync2(settingsPath, "utf-8"));
1104
+ if (!settings.hooks) return;
1105
+ for (const event of Object.keys(settings.hooks)) {
1106
+ if (Array.isArray(settings.hooks[event])) {
1107
+ settings.hooks[event] = settings.hooks[event].filter(
1108
+ (h) => !Array.isArray(h.hooks) || !h.hooks.some((hk) => hk.command?.includes(hookScriptPath))
1109
+ );
1110
+ if (settings.hooks[event].length === 0) {
1111
+ delete settings.hooks[event];
1112
+ }
1113
+ }
1114
+ }
1115
+ if (Object.keys(settings.hooks).length === 0) {
1116
+ delete settings.hooks;
1117
+ }
1118
+ writeFileSync2(settingsPath, JSON.stringify(settings, null, 2), "utf-8");
1119
+ } catch {
1120
+ }
1121
+ }
1122
+ async function runCommand(commandArgs, options) {
1123
+ if (options.online) {
1124
+ return runOnlineCommand(commandArgs, options);
1125
+ }
1126
+ const webPort = parseInt(options.webPort, 10);
1127
+ const releyPort = parseInt(options.releyPort, 10);
1128
+ const releyUrl = `http://localhost:${releyPort}`;
1129
+ const wsReleyUrl = `ws://localhost:${releyPort}/ws`;
1130
+ const command = commandArgs.length > 0 ? commandArgs[0] : process.env.SHELL || "/bin/zsh";
1131
+ const args = commandArgs.length > 1 ? commandArgs.slice(1) : [];
1132
+ console.error("\n\x1B[1m Reley\x1B[0m");
1133
+ console.error(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n");
1134
+ let releyProc = null;
1135
+ if (await isReleyRunning(releyUrl)) {
1136
+ log("RELEY", `Already running on :${releyPort}`);
1137
+ } else {
1138
+ log("RELEY", `Starting on :${releyPort}...`);
1139
+ const releyPath = findReleyServer();
1140
+ releyProc = fork(releyPath, [], {
1141
+ env: { ...process.env, PORT: String(releyPort), LOG_LEVEL: "warn" },
1142
+ stdio: "pipe"
1143
+ });
1144
+ await waitForReley(releyUrl);
1145
+ log("RELEY", "Ready");
1146
+ }
1147
+ const sodium2 = await ensureSodium();
1148
+ const cliKeys = await generateEphemeralKeyPair();
1149
+ const viewerKeys = await generateEphemeralKeyPair();
1150
+ const otc = sodium2.randombytes_buf(32);
1151
+ const otcHash = crypto.createHash("sha256").update(Buffer.from(otc)).digest();
1152
+ const initRes = await fetch(`${releyUrl}/api/v1/pair/initiate`, {
1153
+ method: "POST",
1154
+ headers: { "Content-Type": "application/json" },
1155
+ body: JSON.stringify({
1156
+ publicKey: sodium2.to_base64(cliKeys.publicKey, sodium2.base64_variants.URLSAFE_NO_PADDING),
1157
+ otcHash: Buffer.from(otcHash).toString("base64"),
1158
+ deviceId: crypto.randomUUID()
1159
+ })
1160
+ });
1161
+ const { roomId, token: cliToken } = await initRes.json();
1162
+ const compRes = await fetch(`${releyUrl}/api/v1/pair/complete`, {
1163
+ method: "POST",
1164
+ headers: { "Content-Type": "application/json" },
1165
+ body: JSON.stringify({
1166
+ roomId,
1167
+ otc: Buffer.from(otc).toString("base64"),
1168
+ publicKey: sodium2.to_base64(viewerKeys.publicKey, sodium2.base64_variants.URLSAFE_NO_PADDING),
1169
+ deviceId: crypto.randomUUID()
1170
+ })
1171
+ });
1172
+ const { token: viewerToken } = await compRes.json();
1173
+ log("CRYPTO", "E2E ready");
1174
+ const cliShared = await computeSharedSecret(cliKeys.secretKey, viewerKeys.publicKey);
1175
+ const viewerShared = await computeSharedSecret(viewerKeys.secretKey, cliKeys.publicKey);
1176
+ const cliSessionKeys = await deriveSessionKeys(cliShared);
1177
+ const viewerSessionKeys = await deriveSessionKeys(viewerShared);
1178
+ let cliRatchet = initRatchet(cliSessionKeys.sendKey, cliSessionKeys.recvKey);
1179
+ let viewerRatchet = initRatchet(viewerSessionKeys.recvKey, viewerSessionKeys.sendKey);
1180
+ const cliWs = new WebSocket(wsReleyUrl, { headers: { Authorization: `Bearer ${cliToken}` } });
1181
+ await new Promise((res, rej) => {
1182
+ cliWs.on("open", res);
1183
+ cliWs.on("error", rej);
1184
+ });
1185
+ const viewerWs = new WebSocket(wsReleyUrl, { headers: { Authorization: `Bearer ${viewerToken}` } });
1186
+ await new Promise((res, rej) => {
1187
+ viewerWs.on("open", res);
1188
+ viewerWs.on("error", rej);
1189
+ });
1190
+ const webWss = new WebSocketServer({ noServer: true });
1191
+ const webClients = /* @__PURE__ */ new Set();
1192
+ let ptyCols = process.stdout.columns || 80;
1193
+ let ptyRows = process.stdout.rows || 24;
1194
+ const hookSockPath = `/tmp/reley-hooks-${process.pid}.sock`;
1195
+ try {
1196
+ unlinkSync(hookSockPath);
1197
+ } catch {
1198
+ }
1199
+ const hookServer = createNetServer((socket) => {
1200
+ let buf = "";
1201
+ socket.on("data", (chunk) => {
1202
+ buf += chunk.toString();
1203
+ const nl = buf.indexOf("\n");
1204
+ if (nl === -1) return;
1205
+ const line = buf.substring(0, nl);
1206
+ buf = buf.substring(nl + 1);
1207
+ let json;
1208
+ try {
1209
+ json = JSON.parse(line);
1210
+ } catch {
1211
+ socket.end(JSON.stringify({ action: "approve" }));
1212
+ return;
1213
+ }
1214
+ const requestId = `hook-${Date.now()}-${Math.random().toString(36).slice(2)}`;
1215
+ const payload = JSON.stringify({
1216
+ type: "hook_event",
1217
+ hookType: json.hookType,
1218
+ data: json.data,
1219
+ requestId
1220
+ });
1221
+ for (const c of webClients) {
1222
+ if (c.readyState === WebSocket.OPEN) c.send(payload);
1223
+ }
1224
+ socket.end(JSON.stringify({ action: "approve" }));
1225
+ });
1226
+ });
1227
+ await new Promise((resolve2) => {
1228
+ hookServer.listen(hookSockPath, () => resolve2());
1229
+ });
1230
+ log("HOOKS", `Socket at ${hookSockPath}`);
1231
+ let hookScriptPath = null;
1232
+ let originalSettings = null;
1233
+ const isClaudeCommand = command === "claude" || command.endsWith("/claude");
1234
+ if (isClaudeCommand) {
1235
+ hookScriptPath = createHookScript(hookSockPath);
1236
+ originalSettings = installClaudeHooks(hookScriptPath);
1237
+ log("HOOKS", "Claude hooks installed");
1238
+ }
1239
+ viewerWs.on("message", async (raw) => {
1240
+ if (!Buffer.isBuffer(raw)) return;
1241
+ try {
1242
+ const env = decodeEnvelope(new Uint8Array(raw));
1243
+ const dec = await ratchetDecrypt(viewerRatchet, env.ciphertext, env.nonce, env.counter);
1244
+ viewerRatchet = dec.state;
1245
+ const msg = deserializeMessage(dec.plaintext);
1246
+ if (msg.type === "terminal_data") {
1247
+ const text = Buffer.from(msg.data, "base64").toString();
1248
+ const payload = JSON.stringify({ type: "output", data: text });
1249
+ for (const c of webClients) {
1250
+ if (c.readyState === WebSocket.OPEN) c.send(payload);
1251
+ }
1252
+ }
1253
+ } catch {
1254
+ }
1255
+ });
1256
+ webWss.on("connection", (ws) => {
1257
+ webClients.add(ws);
1258
+ ws.send(JSON.stringify({ type: "sync_size", cols: ptyCols, rows: ptyRows }));
1259
+ ws.on("message", async (raw) => {
1260
+ try {
1261
+ const msg = JSON.parse(raw.toString());
1262
+ if (msg.type === "input") {
1263
+ pty2.write(msg.data);
1264
+ }
1265
+ } catch {
1266
+ }
1267
+ });
1268
+ ws.on("close", () => {
1269
+ webClients.delete(ws);
1270
+ });
1271
+ });
1272
+ const httpServer = createHttpServer((req, res) => {
1273
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1274
+ res.end(getTerminalHtml());
1275
+ });
1276
+ httpServer.on("upgrade", (req, socket, head) => {
1277
+ webWss.handleUpgrade(req, socket, head, (ws) => {
1278
+ webWss.emit("connection", ws, req);
1279
+ });
1280
+ });
1281
+ await new Promise((resolve2) => {
1282
+ httpServer.listen(webPort, () => resolve2());
1283
+ });
1284
+ log("WEB", `http://localhost:${webPort}`);
1285
+ console.error(`
1286
+ \x1B[1m\x1B[36m\u2192 http://localhost:${webPort}\x1B[0m \uC6F9\uC5D0\uC11C\uB3C4 \uD655\uC778/\uC785\uB825 \uAC00\uB2A5
1287
+ `);
1288
+ loggingEnabled = false;
1289
+ const ptyEnv = {
1290
+ ...process.env
1291
+ };
1292
+ if (hookSockPath) {
1293
+ ptyEnv.RELEY_HOOK_SOCK = hookSockPath;
1294
+ }
1295
+ const pty2 = spawn(command, args, {
1296
+ name: "xterm-256color",
1297
+ cols: ptyCols,
1298
+ rows: ptyRows,
1299
+ cwd: process.cwd(),
1300
+ env: ptyEnv
1301
+ });
1302
+ pty2.onData(async (data) => {
1303
+ process.stdout.write(data);
1304
+ try {
1305
+ const msg = {
1306
+ type: "terminal_data",
1307
+ data: Buffer.from(data).toString("base64")
1308
+ };
1309
+ const plain = serializeMessage(msg);
1310
+ const enc = await ratchetEncrypt(cliRatchet, plain);
1311
+ cliRatchet = enc.state;
1312
+ const envelope = encodeEnvelope({
1313
+ version: PROTOCOL_VERSION,
1314
+ type: getWireType("terminal_data"),
1315
+ counter: enc.counter,
1316
+ nonce: enc.nonce,
1317
+ ciphertext: enc.ciphertext
1318
+ });
1319
+ if (cliWs.readyState === WebSocket.OPEN) {
1320
+ cliWs.send(Buffer.from(envelope));
1321
+ }
1322
+ } catch {
1323
+ }
1324
+ });
1325
+ if (process.stdin.isTTY) {
1326
+ process.stdin.setRawMode(true);
1327
+ }
1328
+ process.stdin.resume();
1329
+ process.stdin.on("data", (data) => {
1330
+ pty2.write(data.toString());
1331
+ });
1332
+ process.stdout.on("resize", () => {
1333
+ ptyCols = process.stdout.columns || 80;
1334
+ ptyRows = process.stdout.rows || 24;
1335
+ pty2.resize(ptyCols, ptyRows);
1336
+ const sizeMsg = JSON.stringify({ type: "sync_size", cols: ptyCols, rows: ptyRows });
1337
+ for (const c of webClients) {
1338
+ if (c.readyState === WebSocket.OPEN) c.send(sizeMsg);
1339
+ }
1340
+ });
1341
+ const cleanup = () => {
1342
+ hookServer.close();
1343
+ try {
1344
+ unlinkSync(hookSockPath);
1345
+ } catch {
1346
+ }
1347
+ if (hookScriptPath) {
1348
+ try {
1349
+ unlinkSync(hookScriptPath);
1350
+ } catch {
1351
+ }
1352
+ }
1353
+ if (originalSettings && hookScriptPath) {
1354
+ uninstallClaudeHooks(originalSettings, hookScriptPath);
1355
+ }
1356
+ cliWs.close();
1357
+ viewerWs.close();
1358
+ httpServer.close();
1359
+ if (releyProc) releyProc.kill();
1360
+ };
1361
+ pty2.onExit(() => {
1362
+ cleanup();
1363
+ process.exit(0);
1364
+ });
1365
+ process.on("SIGINT", () => pty2.kill());
1366
+ process.on("SIGTERM", () => pty2.kill());
1367
+ }
1368
+ async function runOnlineCommand(commandArgs, options) {
1369
+ const { loadConfig: loadConfig2 } = await Promise.resolve().then(() => (init_config(), config_exports));
1370
+ const config = loadConfig2();
1371
+ if (!config?.device_token || !config?.reley_url) {
1372
+ console.error("\x1B[31mError:\x1B[0m Not logged in. Run `reley login` first.");
1373
+ process.exit(1);
1374
+ }
1375
+ const command = commandArgs.length > 0 ? commandArgs[0] : process.env.SHELL || "/bin/zsh";
1376
+ const args = commandArgs.length > 1 ? commandArgs.slice(1) : [];
1377
+ console.error("\n\x1B[1m Reley\x1B[0m \x1B[36m(online)\x1B[0m");
1378
+ console.error(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n");
1379
+ console.error(` \x1B[2mUser: ${config.user_email}\x1B[0m`);
1380
+ console.error(` \x1B[2mServer: ${config.reley_url}\x1B[0m
1381
+ `);
1382
+ const sodium2 = await ensureSodium();
1383
+ const cliKeys = await generateEphemeralKeyPair();
1384
+ const cliPkB64 = sodium2.to_base64(cliKeys.publicKey, sodium2.base64_variants.URLSAFE_NO_PADDING);
1385
+ log("RELEY", "Creating session...");
1386
+ const sessionName = commandArgs.length > 0 ? commandArgs.join(" ") : "Terminal session";
1387
+ const res = await fetch(`${config.reley_url}/api/v1/sessions`, {
1388
+ method: "POST",
1389
+ headers: {
1390
+ "Content-Type": "application/json",
1391
+ Authorization: `Bearer ${config.device_token}`
1392
+ },
1393
+ body: JSON.stringify({ name: sessionName, publicKey: cliPkB64 })
1394
+ });
1395
+ if (!res.ok) {
1396
+ const body = await res.json().catch(() => ({}));
1397
+ console.error(`\x1B[31mError:\x1B[0m Failed to create session: ${body.error || res.statusText}`);
1398
+ process.exit(1);
1399
+ }
1400
+ const { roomId, cliToken, sessionId } = await res.json();
1401
+ log("RELEY", `Session: ${sessionId}`);
1402
+ log("RELEY", `Room: ${roomId}`);
1403
+ const wsProtocol = config.reley_url.startsWith("https") ? "wss" : "ws";
1404
+ const wsHost = config.reley_url.replace(/^https?:\/\//, "");
1405
+ const wsUrl = `${wsProtocol}://${wsHost}/ws`;
1406
+ const cliWs = new WebSocket(wsUrl, { headers: { Authorization: `Bearer ${cliToken}` } });
1407
+ await new Promise((resolve2, reject) => {
1408
+ cliWs.on("open", resolve2);
1409
+ cliWs.on("error", reject);
1410
+ });
1411
+ log("RELEY", "WebSocket connected");
1412
+ let ratchetState = null;
1413
+ let e2eReady = false;
1414
+ const pendingData = [];
1415
+ let ratchetQueue = Promise.resolve();
1416
+ function withRatchet(fn) {
1417
+ const p = ratchetQueue.then(fn, () => fn());
1418
+ ratchetQueue = p.then(() => {
1419
+ }, () => {
1420
+ });
1421
+ return p;
1422
+ }
1423
+ const SCROLLBACK_MAX = 100 * 1024;
1424
+ let scrollbackBuf = Buffer.alloc(0);
1425
+ function appendScrollback(data) {
1426
+ scrollbackBuf = Buffer.concat([scrollbackBuf, data]);
1427
+ if (scrollbackBuf.length > SCROLLBACK_MAX) {
1428
+ scrollbackBuf = scrollbackBuf.slice(scrollbackBuf.length - SCROLLBACK_MAX);
1429
+ }
1430
+ }
1431
+ async function initE2E(viewerPkB64) {
1432
+ const viewerPk = sodium2.from_base64(viewerPkB64, sodium2.base64_variants.URLSAFE_NO_PADDING);
1433
+ const shared = await computeSharedSecret(cliKeys.secretKey, viewerPk);
1434
+ const sessionKeys = await deriveSessionKeys(shared);
1435
+ ratchetState = initRatchet(sessionKeys.sendKey, sessionKeys.recvKey);
1436
+ const fp = computeFingerprint(sodium2, cliKeys.publicKey, viewerPk);
1437
+ console.error(` \x1B[33m\u{1F510} E2E Fingerprint: ${fp}\x1B[0m`);
1438
+ sodium2.memzero(shared);
1439
+ if (cliWs.readyState === WebSocket.OPEN) {
1440
+ cliWs.send(JSON.stringify({
1441
+ type: "key_exchange",
1442
+ publicKey: cliPkB64,
1443
+ role: "cli"
1444
+ }));
1445
+ }
1446
+ await withRatchet(async () => {
1447
+ if (!ratchetState) return;
1448
+ if (scrollbackBuf.length > 0) {
1449
+ const msg = {
1450
+ type: "terminal_data",
1451
+ data: scrollbackBuf.toString("base64")
1452
+ };
1453
+ const plain = serializeMessage(msg);
1454
+ const enc = await ratchetEncrypt(ratchetState, plain);
1455
+ ratchetState = enc.state;
1456
+ const envelope = encodeEnvelope({
1457
+ version: PROTOCOL_VERSION,
1458
+ type: getWireType("terminal_data"),
1459
+ counter: enc.counter,
1460
+ nonce: enc.nonce,
1461
+ ciphertext: enc.ciphertext
1462
+ });
1463
+ if (cliWs.readyState === WebSocket.OPEN) {
1464
+ cliWs.send(Buffer.from(envelope));
1465
+ }
1466
+ }
1467
+ });
1468
+ e2eReady = true;
1469
+ pendingData.length = 0;
1470
+ }
1471
+ function sendEncrypted(data) {
1472
+ if (!ratchetState || !e2eReady) {
1473
+ pendingData.push(data);
1474
+ return;
1475
+ }
1476
+ withRatchet(async () => {
1477
+ if (!ratchetState || cliWs.readyState !== WebSocket.OPEN) return;
1478
+ try {
1479
+ const msg = {
1480
+ type: "terminal_data",
1481
+ data: data.toString("base64")
1482
+ };
1483
+ const plain = serializeMessage(msg);
1484
+ const enc = await ratchetEncrypt(ratchetState, plain);
1485
+ ratchetState = enc.state;
1486
+ const envelope = encodeEnvelope({
1487
+ version: PROTOCOL_VERSION,
1488
+ type: getWireType("terminal_data"),
1489
+ counter: enc.counter,
1490
+ nonce: enc.nonce,
1491
+ ciphertext: enc.ciphertext
1492
+ });
1493
+ cliWs.send(Buffer.from(envelope));
1494
+ } catch {
1495
+ }
1496
+ });
1497
+ }
1498
+ const hookSockPath = `/tmp/reley-hooks-${process.pid}.sock`;
1499
+ try {
1500
+ unlinkSync(hookSockPath);
1501
+ } catch {
1502
+ }
1503
+ const hookServer = createNetServer((socket) => {
1504
+ let buf = "";
1505
+ socket.on("data", (chunk) => {
1506
+ buf += chunk.toString();
1507
+ const nl = buf.indexOf("\n");
1508
+ if (nl === -1) return;
1509
+ const line = buf.substring(0, nl);
1510
+ buf = buf.substring(nl + 1);
1511
+ let json;
1512
+ try {
1513
+ json = JSON.parse(line);
1514
+ } catch {
1515
+ socket.end(JSON.stringify({ action: "approve" }));
1516
+ return;
1517
+ }
1518
+ if (e2eReady && ratchetState && cliWs.readyState === WebSocket.OPEN) {
1519
+ withRatchet(async () => {
1520
+ if (!ratchetState || cliWs.readyState !== WebSocket.OPEN) return;
1521
+ const hookMsg = {
1522
+ type: "hook_event",
1523
+ hookType: json.hookType === "pre-tool-use" ? "pre_tool_use" : json.hookType,
1524
+ sessionId,
1525
+ payload: json.data ?? { kind: json.hookType, raw: true }
1526
+ };
1527
+ const plain = serializeMessage(hookMsg);
1528
+ const enc = await ratchetEncrypt(ratchetState, plain);
1529
+ ratchetState = enc.state;
1530
+ const envelope = encodeEnvelope({
1531
+ version: PROTOCOL_VERSION,
1532
+ type: getWireType("hook_event"),
1533
+ counter: enc.counter,
1534
+ nonce: enc.nonce,
1535
+ ciphertext: enc.ciphertext
1536
+ });
1537
+ cliWs.send(Buffer.from(envelope));
1538
+ });
1539
+ } else if (cliWs.readyState === WebSocket.OPEN) {
1540
+ cliWs.send(JSON.stringify({
1541
+ type: "hook_event",
1542
+ hookType: json.hookType,
1543
+ data: json.data
1544
+ }));
1545
+ }
1546
+ socket.end(JSON.stringify({ action: "approve" }));
1547
+ });
1548
+ });
1549
+ await new Promise((resolve2) => {
1550
+ hookServer.listen(hookSockPath, () => resolve2());
1551
+ });
1552
+ log("HOOKS", `Socket at ${hookSockPath}`);
1553
+ let hookScriptPath = null;
1554
+ let originalSettings = null;
1555
+ const isClaudeCommand = command === "claude" || command.endsWith("/claude");
1556
+ if (isClaudeCommand) {
1557
+ hookScriptPath = createHookScript(hookSockPath);
1558
+ originalSettings = installClaudeHooks(hookScriptPath);
1559
+ log("HOOKS", "Claude hooks installed");
1560
+ }
1561
+ let ptyCols = process.stdout.columns || 80;
1562
+ let ptyRows = process.stdout.rows || 24;
1563
+ console.error(`
1564
+ \x1B[1m\x1B[36m\u2192 View in web dashboard\x1B[0m
1565
+ `);
1566
+ loggingEnabled = false;
1567
+ const ptyEnv = {
1568
+ ...process.env
1569
+ };
1570
+ if (hookSockPath) {
1571
+ ptyEnv.RELEY_HOOK_SOCK = hookSockPath;
1572
+ }
1573
+ const pty2 = spawn(command, args, {
1574
+ name: "xterm-256color",
1575
+ cols: ptyCols,
1576
+ rows: ptyRows,
1577
+ cwd: process.cwd(),
1578
+ env: ptyEnv
1579
+ });
1580
+ pty2.onData((data) => {
1581
+ process.stdout.write(data);
1582
+ const buf = Buffer.from(data);
1583
+ appendScrollback(buf);
1584
+ sendEncrypted(buf);
1585
+ });
1586
+ cliWs.on("message", async (raw, isBinary) => {
1587
+ if (!isBinary) {
1588
+ try {
1589
+ const msg = JSON.parse(raw.toString());
1590
+ if (msg.type === "key_exchange" && msg.publicKey && msg.role === "viewer") {
1591
+ await initE2E(msg.publicKey);
1592
+ log("CRYPTO", "E2E established with viewer");
1593
+ } else if (msg.type === "peer_joined") {
1594
+ e2eReady = false;
1595
+ ratchetState = null;
1596
+ }
1597
+ } catch {
1598
+ }
1599
+ return;
1600
+ }
1601
+ if (!e2eReady || !ratchetState) return;
1602
+ withRatchet(async () => {
1603
+ if (!ratchetState) return;
1604
+ try {
1605
+ const env = decodeEnvelope(new Uint8Array(raw));
1606
+ const dec = await ratchetDecrypt(ratchetState, env.ciphertext, env.nonce, env.counter);
1607
+ ratchetState = dec.state;
1608
+ const msg = deserializeMessage(dec.plaintext);
1609
+ if (msg.type === "terminal_input") {
1610
+ const input = Buffer.from(msg.data, "base64").toString();
1611
+ pty2.write(input);
1612
+ } else if (msg.type === "terminal_resize") {
1613
+ const { cols, rows } = msg;
1614
+ if (cols && rows) {
1615
+ ptyCols = cols;
1616
+ ptyRows = rows;
1617
+ pty2.resize(cols, rows);
1618
+ }
1619
+ }
1620
+ } catch {
1621
+ }
1622
+ });
1623
+ });
1624
+ if (process.stdin.isTTY) {
1625
+ process.stdin.setRawMode(true);
1626
+ }
1627
+ process.stdin.resume();
1628
+ process.stdin.on("data", (data) => {
1629
+ pty2.write(data.toString());
1630
+ });
1631
+ process.stdout.on("resize", () => {
1632
+ ptyCols = process.stdout.columns || 80;
1633
+ ptyRows = process.stdout.rows || 24;
1634
+ pty2.resize(ptyCols, ptyRows);
1635
+ });
1636
+ const cleanup = () => {
1637
+ hookServer.close();
1638
+ try {
1639
+ unlinkSync(hookSockPath);
1640
+ } catch {
1641
+ }
1642
+ if (hookScriptPath) {
1643
+ try {
1644
+ unlinkSync(hookScriptPath);
1645
+ } catch {
1646
+ }
1647
+ }
1648
+ if (originalSettings && hookScriptPath) {
1649
+ uninstallClaudeHooks(originalSettings, hookScriptPath);
1650
+ }
1651
+ cliWs.close();
1652
+ fetch(`${config.reley_url}/api/v1/sessions/${sessionId}`, {
1653
+ method: "PATCH",
1654
+ headers: {
1655
+ "Content-Type": "application/json",
1656
+ Authorization: `Bearer ${config.device_token}`
1657
+ },
1658
+ body: JSON.stringify({ status: "closed" })
1659
+ }).catch(() => {
1660
+ });
1661
+ };
1662
+ pty2.onExit(() => {
1663
+ cleanup();
1664
+ process.exit(0);
1665
+ });
1666
+ process.on("SIGINT", () => pty2.kill());
1667
+ process.on("SIGTERM", () => pty2.kill());
1668
+ }
1669
+ function computeFingerprint(sodium2, pk1, pk2) {
1670
+ const sorted = [pk1, pk2].sort((a, b) => {
1671
+ for (let i = 0; i < a.length; i++) {
1672
+ if (a[i] !== b[i]) return a[i] - b[i];
1673
+ }
1674
+ return 0;
1675
+ });
1676
+ const combined = new Uint8Array(sorted[0].length + sorted[1].length);
1677
+ combined.set(sorted[0], 0);
1678
+ combined.set(sorted[1], sorted[0].length);
1679
+ const hash = sodium2.crypto_generichash(16, combined, null);
1680
+ const hex = sodium2.to_hex(hash).toUpperCase();
1681
+ return hex.match(/.{4}/g).join("-");
1682
+ }
1683
+
1684
+ // src/commands/pair.ts
1685
+ import * as fs from "node:fs/promises";
1686
+ import * as path from "node:path";
1687
+ import * as crypto2 from "node:crypto";
1688
+ import qrcode from "qrcode-terminal";
1689
+ async function pairCommand(options) {
1690
+ const { releyUrl, configDir } = options;
1691
+ const sodium2 = await ensureSodium();
1692
+ console.log("Generating identity key pair...");
1693
+ const identityKp = await generateIdentityKeyPair();
1694
+ console.log("Generating ephemeral key pair...");
1695
+ const ephemeralKp = await generateEphemeralKeyPair();
1696
+ console.log("Generating one-time pairing code...");
1697
+ const oneTimeCode = await generateOneTimeCode(32);
1698
+ const otcHash = await hashOneTimeCode(oneTimeCode);
1699
+ const deviceId = crypto2.randomUUID();
1700
+ console.log("Initiating pairing with reley...");
1701
+ const initiateBody = {
1702
+ publicKey: sodium2.to_base64(identityKp.publicKey, sodium2.base64_variants.URLSAFE_NO_PADDING),
1703
+ otcHash: sodium2.to_base64(otcHash, sodium2.base64_variants.URLSAFE_NO_PADDING),
1704
+ deviceId
1705
+ };
1706
+ const initiateRes = await fetch(`${releyUrl}/api/v1/pair/initiate`, {
1707
+ method: "POST",
1708
+ headers: { "Content-Type": "application/json" },
1709
+ body: JSON.stringify(initiateBody)
1710
+ });
1711
+ if (!initiateRes.ok) {
1712
+ const errText = await initiateRes.text();
1713
+ throw new Error(`Failed to initiate pairing: ${initiateRes.status} ${errText}`);
1714
+ }
1715
+ const { sessionId, jwt } = await initiateRes.json();
1716
+ console.log(`Pairing session created: ${sessionId}`);
1717
+ const qrPayloadStr = await encodeQRPayload({
1718
+ releyUrl,
1719
+ publicKey: ephemeralKp.publicKey,
1720
+ oneTimeCode,
1721
+ jwt
1722
+ });
1723
+ console.log("\nScan this QR code with the Reley mobile app:\n");
1724
+ await new Promise((resolve2) => {
1725
+ qrcode.generate(qrPayloadStr, { small: true }, (output) => {
1726
+ console.log(output);
1727
+ resolve2();
1728
+ });
1729
+ });
1730
+ console.log("\nWaiting for mobile device to complete pairing...");
1731
+ const POLL_INTERVAL_MS = 2e3;
1732
+ const MAX_POLL_DURATION_MS = 5 * 60 * 1e3;
1733
+ const startTime = Date.now();
1734
+ let peerPublicKey;
1735
+ while (Date.now() - startTime < MAX_POLL_DURATION_MS) {
1736
+ await sleep(POLL_INTERVAL_MS);
1737
+ const statusRes = await fetch(`${releyUrl}/api/v1/pair/status/${sessionId}`, {
1738
+ headers: { Authorization: `Bearer ${jwt}` }
1739
+ });
1740
+ if (!statusRes.ok) {
1741
+ console.error(`Poll error: ${statusRes.status}`);
1742
+ continue;
1743
+ }
1744
+ const statusData = await statusRes.json();
1745
+ if (statusData.status === "completed") {
1746
+ peerPublicKey = statusData.peerPublicKey;
1747
+ break;
1748
+ }
1749
+ if (statusData.status === "expired") {
1750
+ throw new Error("Pairing session expired. Please try again.");
1751
+ }
1752
+ process.stdout.write(".");
1753
+ }
1754
+ if (!peerPublicKey) {
1755
+ throw new Error("Pairing timed out after 5 minutes.");
1756
+ }
1757
+ console.log("\n\nPairing successful!");
1758
+ const sessionConfig = {
1759
+ deviceId,
1760
+ sessionId,
1761
+ releyUrl,
1762
+ jwt,
1763
+ identityPublicKey: sodium2.to_base64(identityKp.publicKey, sodium2.base64_variants.URLSAFE_NO_PADDING),
1764
+ identitySecretKey: sodium2.to_base64(identityKp.secretKey, sodium2.base64_variants.URLSAFE_NO_PADDING),
1765
+ ephemeralPublicKey: sodium2.to_base64(ephemeralKp.publicKey, sodium2.base64_variants.URLSAFE_NO_PADDING),
1766
+ ephemeralSecretKey: sodium2.to_base64(ephemeralKp.secretKey, sodium2.base64_variants.URLSAFE_NO_PADDING),
1767
+ peerPublicKey,
1768
+ pairedAt: (/* @__PURE__ */ new Date()).toISOString()
1769
+ };
1770
+ await fs.mkdir(configDir, { recursive: true });
1771
+ const configPath = path.join(configDir, "session.json");
1772
+ await fs.writeFile(configPath, JSON.stringify(sessionConfig, null, 2), "utf-8");
1773
+ console.log(`Session saved to ${configPath}`);
1774
+ console.log(`Device ID: ${deviceId}`);
1775
+ console.log(`Session ID: ${sessionId}`);
1776
+ }
1777
+ function sleep(ms) {
1778
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
1779
+ }
1780
+
1781
+ // src/commands/wrap.ts
1782
+ import * as fs3 from "node:fs/promises";
1783
+ import * as path3 from "node:path";
1784
+
1785
+ // src/daemon/pty-manager.ts
1786
+ import pty from "node-pty";
1787
+ var PtyManager = class {
1788
+ process = null;
1789
+ dataCallbacks = [];
1790
+ exitCallbacks = [];
1791
+ /**
1792
+ * Spawn a command in a pseudo-terminal.
1793
+ */
1794
+ spawn(command, args, options = {}) {
1795
+ const { cols = 80, rows = 24, cwd, env } = options;
1796
+ this.process = pty.spawn(command, args, {
1797
+ name: "xterm-256color",
1798
+ cols,
1799
+ rows,
1800
+ cwd: cwd || process.cwd(),
1801
+ env: env || process.env
1802
+ });
1803
+ this.process.onData((data) => {
1804
+ for (const cb of this.dataCallbacks) {
1805
+ cb(data);
1806
+ }
1807
+ });
1808
+ this.process.onExit(({ exitCode, signal }) => {
1809
+ for (const cb of this.exitCallbacks) {
1810
+ cb(exitCode, signal);
1811
+ }
1812
+ this.process = null;
1813
+ });
1814
+ return this.process;
1815
+ }
1816
+ /**
1817
+ * Register a callback for PTY data output.
1818
+ */
1819
+ onData(callback) {
1820
+ this.dataCallbacks.push(callback);
1821
+ }
1822
+ /**
1823
+ * Register a callback for PTY exit.
1824
+ */
1825
+ onExit(callback) {
1826
+ this.exitCallbacks.push(callback);
1827
+ }
1828
+ /**
1829
+ * Write data to the PTY input.
1830
+ */
1831
+ write(data) {
1832
+ if (!this.process) {
1833
+ throw new Error("PTY process not running");
1834
+ }
1835
+ this.process.write(data);
1836
+ }
1837
+ /**
1838
+ * Resize the PTY to the given dimensions.
1839
+ */
1840
+ resize(cols, rows) {
1841
+ if (!this.process) {
1842
+ return;
1843
+ }
1844
+ this.process.resize(cols, rows);
1845
+ }
1846
+ /**
1847
+ * Kill the PTY process.
1848
+ */
1849
+ kill(signal) {
1850
+ if (!this.process) {
1851
+ return;
1852
+ }
1853
+ try {
1854
+ this.process.kill(signal);
1855
+ } catch {
1856
+ }
1857
+ this.process = null;
1858
+ }
1859
+ /**
1860
+ * Get the PID of the PTY process.
1861
+ */
1862
+ get pid() {
1863
+ return this.process?.pid;
1864
+ }
1865
+ /**
1866
+ * Check if the PTY process is running.
1867
+ */
1868
+ get isRunning() {
1869
+ return this.process !== null;
1870
+ }
1871
+ /**
1872
+ * Clean shutdown: kill process and clear callbacks.
1873
+ */
1874
+ shutdown() {
1875
+ this.kill();
1876
+ this.dataCallbacks = [];
1877
+ this.exitCallbacks = [];
1878
+ }
1879
+ };
1880
+
1881
+ // src/daemon/ws-client.ts
1882
+ import { EventEmitter } from "node:events";
1883
+ import WebSocket2 from "ws";
1884
+ var WsClient = class extends EventEmitter {
1885
+ ws = null;
1886
+ url = "";
1887
+ token = "";
1888
+ releyHttpUrl = "";
1889
+ reconnectAttempts = 0;
1890
+ reconnectTimer = null;
1891
+ pingTimer = null;
1892
+ pongTimer = null;
1893
+ tokenRefreshTimer = null;
1894
+ shouldReconnect = true;
1895
+ messageCallbacks = [];
1896
+ connectCallbacks = [];
1897
+ disconnectCallbacks = [];
1898
+ /**
1899
+ * Connect to a WebSocket reley server.
1900
+ */
1901
+ async connect(url, token) {
1902
+ this.url = url;
1903
+ this.token = token;
1904
+ this.shouldReconnect = true;
1905
+ this.releyHttpUrl = url.replace(/^ws:/, "http:").replace(/^wss:/, "https:").replace(/\/ws\??.*$/, "");
1906
+ this.scheduleTokenRefresh();
1907
+ return this.doConnect();
1908
+ }
1909
+ doConnect() {
1910
+ return new Promise((resolve2, reject) => {
1911
+ const ws = new WebSocket2(this.url, {
1912
+ headers: {
1913
+ Authorization: `Bearer ${this.token}`
1914
+ }
1915
+ });
1916
+ ws.binaryType = "arraybuffer";
1917
+ ws.on("open", () => {
1918
+ this.ws = ws;
1919
+ this.reconnectAttempts = 0;
1920
+ this.startPingInterval();
1921
+ for (const cb of this.connectCallbacks) {
1922
+ cb();
1923
+ }
1924
+ this.emit("connect");
1925
+ resolve2(ws);
1926
+ });
1927
+ ws.on("message", (data) => {
1928
+ let bytes;
1929
+ if (data instanceof ArrayBuffer) {
1930
+ bytes = new Uint8Array(data);
1931
+ } else if (Buffer.isBuffer(data)) {
1932
+ bytes = new Uint8Array(data);
1933
+ } else if (Array.isArray(data)) {
1934
+ bytes = new Uint8Array(Buffer.concat(data));
1935
+ } else {
1936
+ return;
1937
+ }
1938
+ for (const cb of this.messageCallbacks) {
1939
+ cb(bytes);
1940
+ }
1941
+ this.emit("message", bytes);
1942
+ });
1943
+ ws.on("pong", () => {
1944
+ this.clearPongTimeout();
1945
+ });
1946
+ ws.on("close", (code, reason) => {
1947
+ const reasonStr = reason.toString("utf-8");
1948
+ this.ws = null;
1949
+ this.stopPingInterval();
1950
+ for (const cb of this.disconnectCallbacks) {
1951
+ cb(code, reasonStr);
1952
+ }
1953
+ this.emit("disconnect", code, reasonStr);
1954
+ if (this.shouldReconnect) {
1955
+ this.scheduleReconnect();
1956
+ }
1957
+ });
1958
+ ws.on("error", (err) => {
1959
+ this.emit("error", err);
1960
+ if (!this.ws && this.reconnectAttempts === 0) {
1961
+ reject(err);
1962
+ }
1963
+ });
1964
+ });
1965
+ }
1966
+ /**
1967
+ * Register a callback for incoming binary messages.
1968
+ */
1969
+ onMessage(callback) {
1970
+ this.messageCallbacks.push(callback);
1971
+ }
1972
+ /**
1973
+ * Register a callback for successful connection.
1974
+ */
1975
+ onConnect(callback) {
1976
+ this.connectCallbacks.push(callback);
1977
+ }
1978
+ /**
1979
+ * Register a callback for disconnection.
1980
+ */
1981
+ onDisconnect(callback) {
1982
+ this.disconnectCallbacks.push(callback);
1983
+ }
1984
+ /**
1985
+ * Send binary data through the WebSocket.
1986
+ */
1987
+ send(data) {
1988
+ if (!this.ws || this.ws.readyState !== WebSocket2.OPEN) {
1989
+ throw new Error("WebSocket is not connected");
1990
+ }
1991
+ this.ws.send(data);
1992
+ }
1993
+ /**
1994
+ * Close the WebSocket connection and stop reconnecting.
1995
+ */
1996
+ /**
1997
+ * Update the token (called after refresh).
1998
+ */
1999
+ updateToken(newToken) {
2000
+ this.token = newToken;
2001
+ }
2002
+ close() {
2003
+ this.shouldReconnect = false;
2004
+ if (this.reconnectTimer) {
2005
+ clearTimeout(this.reconnectTimer);
2006
+ this.reconnectTimer = null;
2007
+ }
2008
+ if (this.tokenRefreshTimer) {
2009
+ clearTimeout(this.tokenRefreshTimer);
2010
+ this.tokenRefreshTimer = null;
2011
+ }
2012
+ this.stopPingInterval();
2013
+ if (this.ws) {
2014
+ this.ws.close(1e3, "Client closing");
2015
+ this.ws = null;
2016
+ }
2017
+ }
2018
+ /**
2019
+ * Check if the WebSocket is currently connected.
2020
+ */
2021
+ get isConnected() {
2022
+ return this.ws !== null && this.ws.readyState === WebSocket2.OPEN;
2023
+ }
2024
+ /**
2025
+ * Schedule a reconnection attempt with exponential backoff.
2026
+ */
2027
+ scheduleReconnect() {
2028
+ const baseDelay = TIMEOUTS.WS_RECONNECT_BASE;
2029
+ const maxDelay = TIMEOUTS.WS_RECONNECT_MAX;
2030
+ const delay = Math.min(baseDelay * Math.pow(2, this.reconnectAttempts), maxDelay);
2031
+ const jitter = Math.random() * delay * 0.25;
2032
+ const actualDelay = delay + jitter;
2033
+ this.reconnectAttempts++;
2034
+ this.reconnectTimer = setTimeout(async () => {
2035
+ try {
2036
+ await this.doConnect();
2037
+ } catch {
2038
+ }
2039
+ }, actualDelay);
2040
+ }
2041
+ /**
2042
+ * Start sending periodic ping frames.
2043
+ */
2044
+ startPingInterval() {
2045
+ this.stopPingInterval();
2046
+ this.pingTimer = setInterval(() => {
2047
+ if (this.ws && this.ws.readyState === WebSocket2.OPEN) {
2048
+ this.ws.ping();
2049
+ this.startPongTimeout();
2050
+ }
2051
+ }, TIMEOUTS.PING_INTERVAL);
2052
+ }
2053
+ /**
2054
+ * Stop the ping interval.
2055
+ */
2056
+ stopPingInterval() {
2057
+ if (this.pingTimer) {
2058
+ clearInterval(this.pingTimer);
2059
+ this.pingTimer = null;
2060
+ }
2061
+ this.clearPongTimeout();
2062
+ }
2063
+ /**
2064
+ * Start a timeout waiting for pong response.
2065
+ */
2066
+ startPongTimeout() {
2067
+ this.clearPongTimeout();
2068
+ this.pongTimer = setTimeout(() => {
2069
+ if (this.ws) {
2070
+ this.ws.terminate();
2071
+ }
2072
+ }, TIMEOUTS.PONG_TIMEOUT);
2073
+ }
2074
+ /**
2075
+ * Clear the pong timeout.
2076
+ */
2077
+ clearPongTimeout() {
2078
+ if (this.pongTimer) {
2079
+ clearTimeout(this.pongTimer);
2080
+ this.pongTimer = null;
2081
+ }
2082
+ }
2083
+ /**
2084
+ * Schedule automatic token refresh 1 hour before expiry (i.e., every 23 hours).
2085
+ */
2086
+ scheduleTokenRefresh() {
2087
+ if (this.tokenRefreshTimer) {
2088
+ clearTimeout(this.tokenRefreshTimer);
2089
+ }
2090
+ const refreshInterval = 23 * 60 * 60 * 1e3;
2091
+ this.tokenRefreshTimer = setTimeout(async () => {
2092
+ await this.refreshToken();
2093
+ }, refreshInterval);
2094
+ this.tokenRefreshTimer.unref();
2095
+ }
2096
+ /**
2097
+ * Refresh the JWT token via the reley's refresh endpoint.
2098
+ */
2099
+ async refreshToken() {
2100
+ try {
2101
+ const response = await fetch(`${this.releyHttpUrl}/api/v1/auth/refresh`, {
2102
+ method: "POST",
2103
+ headers: { "Content-Type": "application/json" },
2104
+ body: JSON.stringify({ token: this.token })
2105
+ });
2106
+ if (response.ok) {
2107
+ const data = await response.json();
2108
+ this.token = data.token;
2109
+ this.scheduleTokenRefresh();
2110
+ }
2111
+ } catch {
2112
+ this.tokenRefreshTimer = setTimeout(async () => {
2113
+ await this.refreshToken();
2114
+ }, 5 * 60 * 1e3);
2115
+ this.tokenRefreshTimer?.unref();
2116
+ }
2117
+ }
2118
+ };
2119
+
2120
+ // src/daemon/crypto-session.ts
2121
+ import * as fs2 from "node:fs/promises";
2122
+ import * as path2 from "node:path";
2123
+ var CryptoSession = class {
2124
+ ratchetState = null;
2125
+ isInitialized = false;
2126
+ /**
2127
+ * Initialize the crypto session from saved pairing configuration.
2128
+ */
2129
+ async initFromConfig(configDir) {
2130
+ const sodium2 = await ensureSodium();
2131
+ const ratchetPath = path2.join(configDir, "ratchet-state.json");
2132
+ try {
2133
+ const raw2 = await fs2.readFile(ratchetPath, "utf-8");
2134
+ const serialized = JSON.parse(raw2);
2135
+ this.ratchetState = {
2136
+ sendChainKey: sodium2.from_base64(serialized.sendChainKey, sodium2.base64_variants.URLSAFE_NO_PADDING),
2137
+ recvChainKey: sodium2.from_base64(serialized.recvChainKey, sodium2.base64_variants.URLSAFE_NO_PADDING),
2138
+ sendCounter: serialized.sendCounter,
2139
+ recvCounter: serialized.recvCounter,
2140
+ maxRecvCounter: serialized.maxRecvCounter
2141
+ };
2142
+ this.isInitialized = true;
2143
+ return;
2144
+ } catch {
2145
+ }
2146
+ const configPath = path2.join(configDir, "session.json");
2147
+ const raw = await fs2.readFile(configPath, "utf-8");
2148
+ const config = JSON.parse(raw);
2149
+ const ourSecretKey = sodium2.from_base64(config.ephemeralSecretKey, sodium2.base64_variants.URLSAFE_NO_PADDING);
2150
+ const peerPublicKey = sodium2.from_base64(config.peerPublicKey, sodium2.base64_variants.URLSAFE_NO_PADDING);
2151
+ const sharedSecret = await computeSharedSecret(ourSecretKey, peerPublicKey);
2152
+ const { sendKey, recvKey } = await deriveSessionKeys(sharedSecret);
2153
+ this.ratchetState = initRatchet(sendKey, recvKey);
2154
+ this.isInitialized = true;
2155
+ }
2156
+ /**
2157
+ * Initialize from raw keys (for testing or manual setup).
2158
+ */
2159
+ async initFromKeys(ourSecretKey, peerPublicKey) {
2160
+ const sharedSecret = await computeSharedSecret(ourSecretKey, peerPublicKey);
2161
+ const { sendKey, recvKey } = await deriveSessionKeys(sharedSecret);
2162
+ this.ratchetState = initRatchet(sendKey, recvKey);
2163
+ this.isInitialized = true;
2164
+ }
2165
+ /**
2166
+ * Encrypt a protocol message and build a wire-format envelope.
2167
+ */
2168
+ async encryptMessage(message) {
2169
+ if (!this.ratchetState) {
2170
+ throw new Error("Crypto session not initialized");
2171
+ }
2172
+ const plaintext = serializeMessage(message);
2173
+ const wireType = getWireType(message.type);
2174
+ const { ciphertext, nonce, counter, state } = await ratchetEncrypt(
2175
+ this.ratchetState,
2176
+ plaintext
2177
+ );
2178
+ this.ratchetState = state;
2179
+ const envelope = {
2180
+ version: PROTOCOL_VERSION,
2181
+ type: wireType,
2182
+ counter,
2183
+ nonce,
2184
+ ciphertext
2185
+ };
2186
+ if (needsKeyRotation(this.ratchetState)) {
2187
+ await this.performKeyRotation();
2188
+ }
2189
+ return encodeEnvelope(envelope);
2190
+ }
2191
+ /**
2192
+ * Decrypt a wire-format envelope and return the protocol message.
2193
+ */
2194
+ async decryptMessage(data) {
2195
+ if (!this.ratchetState) {
2196
+ throw new Error("Crypto session not initialized");
2197
+ }
2198
+ const envelope = decodeEnvelope(data);
2199
+ const { plaintext, state } = await ratchetDecrypt(
2200
+ this.ratchetState,
2201
+ envelope.ciphertext,
2202
+ envelope.nonce,
2203
+ envelope.counter
2204
+ );
2205
+ this.ratchetState = state;
2206
+ return deserializeMessage(plaintext);
2207
+ }
2208
+ /**
2209
+ * Save the current ratchet state to disk.
2210
+ */
2211
+ async saveState(configDir) {
2212
+ if (!this.ratchetState) {
2213
+ return;
2214
+ }
2215
+ const sodium2 = await ensureSodium();
2216
+ const serialized = {
2217
+ sendChainKey: sodium2.to_base64(this.ratchetState.sendChainKey, sodium2.base64_variants.URLSAFE_NO_PADDING),
2218
+ recvChainKey: sodium2.to_base64(this.ratchetState.recvChainKey, sodium2.base64_variants.URLSAFE_NO_PADDING),
2219
+ sendCounter: this.ratchetState.sendCounter,
2220
+ recvCounter: this.ratchetState.recvCounter,
2221
+ maxRecvCounter: this.ratchetState.maxRecvCounter
2222
+ };
2223
+ const ratchetPath = path2.join(configDir, "ratchet-state.json");
2224
+ await fs2.writeFile(ratchetPath, JSON.stringify(serialized, null, 2), "utf-8");
2225
+ }
2226
+ /**
2227
+ * Load ratchet state from disk.
2228
+ */
2229
+ async loadState(configDir) {
2230
+ const sodium2 = await ensureSodium();
2231
+ const ratchetPath = path2.join(configDir, "ratchet-state.json");
2232
+ try {
2233
+ const raw = await fs2.readFile(ratchetPath, "utf-8");
2234
+ const serialized = JSON.parse(raw);
2235
+ this.ratchetState = {
2236
+ sendChainKey: sodium2.from_base64(serialized.sendChainKey, sodium2.base64_variants.URLSAFE_NO_PADDING),
2237
+ recvChainKey: sodium2.from_base64(serialized.recvChainKey, sodium2.base64_variants.URLSAFE_NO_PADDING),
2238
+ sendCounter: serialized.sendCounter,
2239
+ recvCounter: serialized.recvCounter,
2240
+ maxRecvCounter: serialized.maxRecvCounter
2241
+ };
2242
+ this.isInitialized = true;
2243
+ return true;
2244
+ } catch {
2245
+ return false;
2246
+ }
2247
+ }
2248
+ /**
2249
+ * Get current ratchet state (for diagnostics).
2250
+ */
2251
+ getRatchetInfo() {
2252
+ return {
2253
+ sendCounter: this.ratchetState?.sendCounter ?? 0,
2254
+ recvCounter: this.ratchetState?.recvCounter ?? 0,
2255
+ initialized: this.isInitialized
2256
+ };
2257
+ }
2258
+ /**
2259
+ * Perform key rotation by generating new ephemeral keys.
2260
+ * This is triggered automatically when the ratchet reaches KEY_ROTATION_INTERVAL.
2261
+ */
2262
+ async performKeyRotation() {
2263
+ }
2264
+ };
2265
+
2266
+ // src/commands/wrap.ts
2267
+ async function wrapCommand(commandArgs, options) {
2268
+ const { configDir } = options;
2269
+ const configPath = path3.join(configDir, "session.json");
2270
+ let sessionConfig;
2271
+ try {
2272
+ const raw = await fs3.readFile(configPath, "utf-8");
2273
+ sessionConfig = JSON.parse(raw);
2274
+ } catch {
2275
+ console.error('No active session found. Run "reley pair" first.');
2276
+ process.exit(1);
2277
+ }
2278
+ const cryptoSession = new CryptoSession();
2279
+ await cryptoSession.initFromConfig(configDir);
2280
+ const command = commandArgs[0];
2281
+ const args = commandArgs.slice(1);
2282
+ console.log(`Wrapping command: ${commandArgs.join(" ")}`);
2283
+ const ptyManager = new PtyManager();
2284
+ const pty2 = ptyManager.spawn(command, args, {
2285
+ cols: process.stdout.columns || 80,
2286
+ rows: process.stdout.rows || 24,
2287
+ cwd: process.cwd(),
2288
+ env: process.env
2289
+ });
2290
+ const wsUrl = sessionConfig.releyUrl.replace(/^http/, "ws");
2291
+ const wsClient = new WsClient();
2292
+ await wsClient.connect(`${wsUrl}/api/v1/ws/${sessionConfig.sessionId}`, sessionConfig.jwt);
2293
+ console.log("Connected to reley. Forwarding terminal output...");
2294
+ ptyManager.onData(async (data) => {
2295
+ process.stdout.write(data);
2296
+ const message = {
2297
+ type: "terminal_data",
2298
+ data: Buffer.from(data, "utf-8").toString("base64")
2299
+ };
2300
+ try {
2301
+ const encrypted = await cryptoSession.encryptMessage(message);
2302
+ wsClient.send(encrypted);
2303
+ } catch (err) {
2304
+ console.error("Encryption error:", err);
2305
+ }
2306
+ });
2307
+ if (process.stdin.isTTY) {
2308
+ process.stdin.setRawMode(true);
2309
+ }
2310
+ process.stdin.resume();
2311
+ process.stdin.on("data", (data) => {
2312
+ ptyManager.write(data.toString("utf-8"));
2313
+ });
2314
+ process.stdout.on("resize", () => {
2315
+ const cols = process.stdout.columns || 80;
2316
+ const rows = process.stdout.rows || 24;
2317
+ ptyManager.resize(cols, rows);
2318
+ });
2319
+ wsClient.onMessage(async (data) => {
2320
+ try {
2321
+ const message = await cryptoSession.decryptMessage(data);
2322
+ switch (message.type) {
2323
+ case "terminal_input": {
2324
+ const inputMsg = message;
2325
+ const inputData = Buffer.from(inputMsg.data, "base64").toString("utf-8");
2326
+ ptyManager.write(inputData);
2327
+ break;
2328
+ }
2329
+ case "terminal_resize": {
2330
+ const resizeMsg = message;
2331
+ ptyManager.resize(resizeMsg.cols, resizeMsg.rows);
2332
+ break;
2333
+ }
2334
+ case "session_close": {
2335
+ const closeMsg = message;
2336
+ console.log(`
2337
+ Remote session closed: ${closeMsg.reason}`);
2338
+ cleanup();
2339
+ break;
2340
+ }
2341
+ default:
2342
+ break;
2343
+ }
2344
+ } catch (err) {
2345
+ console.error("Decryption/handling error:", err);
2346
+ }
2347
+ });
2348
+ ptyManager.onExit(async (exitCode, signal) => {
2349
+ console.log(`
2350
+ Process exited with code ${exitCode}${signal ? ` (signal ${signal})` : ""}`);
2351
+ const closeMessage = {
2352
+ type: "session_close",
2353
+ reason: `Process exited with code ${exitCode}`
2354
+ };
2355
+ try {
2356
+ const encrypted = await cryptoSession.encryptMessage(closeMessage);
2357
+ wsClient.send(encrypted);
2358
+ } catch {
2359
+ }
2360
+ await cryptoSession.saveState(configDir);
2361
+ cleanup();
2362
+ });
2363
+ wsClient.onDisconnect(() => {
2364
+ console.log("\nDisconnected from reley. Attempting reconnection...");
2365
+ });
2366
+ wsClient.onConnect(() => {
2367
+ console.log("Reconnected to reley.");
2368
+ });
2369
+ function cleanup() {
2370
+ ptyManager.kill();
2371
+ wsClient.close();
2372
+ if (process.stdin.isTTY) {
2373
+ process.stdin.setRawMode(false);
2374
+ }
2375
+ process.stdin.pause();
2376
+ process.exit(0);
2377
+ }
2378
+ process.on("SIGINT", cleanup);
2379
+ process.on("SIGTERM", cleanup);
2380
+ }
2381
+
2382
+ // src/commands/status.ts
2383
+ import * as fs4 from "node:fs/promises";
2384
+ import * as path4 from "node:path";
2385
+ async function statusCommand(options) {
2386
+ const { configDir } = options;
2387
+ console.log("Reley Status");
2388
+ console.log("=================\n");
2389
+ const configPath = path4.join(configDir, "session.json");
2390
+ let sessionConfig = null;
2391
+ try {
2392
+ const raw = await fs4.readFile(configPath, "utf-8");
2393
+ sessionConfig = JSON.parse(raw);
2394
+ } catch {
2395
+ }
2396
+ if (!sessionConfig) {
2397
+ console.log("Pairing: Not paired");
2398
+ console.log('\nRun "reley pair" to pair with a mobile device.\n');
2399
+ } else {
2400
+ console.log("Pairing: Paired");
2401
+ console.log(`Device ID: ${sessionConfig.deviceId}`);
2402
+ console.log(`Session ID: ${sessionConfig.sessionId}`);
2403
+ console.log(`Reley URL: ${sessionConfig.releyUrl}`);
2404
+ console.log(`Paired at: ${sessionConfig.pairedAt || "Unknown"}`);
2405
+ if (sessionConfig.peerPublicKey) {
2406
+ const truncatedKey = sessionConfig.peerPublicKey.substring(0, 16) + "...";
2407
+ console.log(`Peer key: ${truncatedKey}`);
2408
+ }
2409
+ console.log(`Identity: ${sessionConfig.identityPublicKey.substring(0, 16)}...`);
2410
+ }
2411
+ const releyUrl = sessionConfig?.releyUrl || options.releyUrl;
2412
+ console.log(`
2413
+ Reley Health (${releyUrl}):`);
2414
+ try {
2415
+ const healthRes = await fetch(`${releyUrl}/api/v1/health`, {
2416
+ signal: AbortSignal.timeout(5e3)
2417
+ });
2418
+ if (healthRes.ok) {
2419
+ const healthData = await healthRes.json();
2420
+ console.log(` Status: ${healthData.status}`);
2421
+ if (healthData.version) {
2422
+ console.log(` Version: ${healthData.version}`);
2423
+ }
2424
+ if (healthData.uptime !== void 0) {
2425
+ console.log(` Uptime: ${Math.round(healthData.uptime)}s`);
2426
+ }
2427
+ } else {
2428
+ console.log(` Status: Error (HTTP ${healthRes.status})`);
2429
+ }
2430
+ } catch (err) {
2431
+ const errorMessage = err instanceof Error ? err.message : String(err);
2432
+ console.log(` Status: Unreachable (${errorMessage})`);
2433
+ }
2434
+ const ratchetPath = path4.join(configDir, "ratchet-state.json");
2435
+ try {
2436
+ await fs4.access(ratchetPath);
2437
+ console.log("\nCrypto: Ratchet state saved");
2438
+ } catch {
2439
+ console.log("\nCrypto: No ratchet state (new session)");
2440
+ }
2441
+ const hooksSocketPath = path4.join(configDir, "hooks.sock");
2442
+ try {
2443
+ await fs4.access(hooksSocketPath);
2444
+ console.log("Hooks: Socket exists");
2445
+ } catch {
2446
+ console.log("Hooks: Not active");
2447
+ }
2448
+ console.log("");
2449
+ }
2450
+
2451
+ // src/commands/install-hooks.ts
2452
+ import * as fs5 from "node:fs/promises";
2453
+ import * as path5 from "node:path";
2454
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
2455
+ async function installHooksCommand(options) {
2456
+ const { configDir } = options;
2457
+ console.log("Installing Claude Code hooks for Reley...\n");
2458
+ const __filename = fileURLToPath2(import.meta.url);
2459
+ const __dirname2 = path5.dirname(__filename);
2460
+ const hooksTemplatePath = path5.resolve(__dirname2, "..", "hooks", "hooks.json");
2461
+ let hooksConfig;
2462
+ try {
2463
+ const raw = await fs5.readFile(hooksTemplatePath, "utf-8");
2464
+ hooksConfig = JSON.parse(raw);
2465
+ } catch {
2466
+ hooksConfig = {
2467
+ hooks: {
2468
+ Stop: [{ matcher: "", command: "reley-hook stop" }],
2469
+ Notification: [{ matcher: "", command: "reley-hook notification" }],
2470
+ PreToolUse: [
2471
+ { matcher: "Bash", command: "reley-hook pre-tool-use" },
2472
+ { matcher: "Write", command: "reley-hook pre-tool-use" },
2473
+ { matcher: "Edit", command: "reley-hook pre-tool-use" }
2474
+ ]
2475
+ }
2476
+ };
2477
+ }
2478
+ const claudeConfigDir = path5.join(process.env.HOME || "~", ".claude");
2479
+ const hooksDir = path5.join(claudeConfigDir, "hooks");
2480
+ await fs5.mkdir(hooksDir, { recursive: true });
2481
+ const hooksOutputPath = path5.join(hooksDir, "reley-hooks.json");
2482
+ await fs5.writeFile(hooksOutputPath, JSON.stringify(hooksConfig, null, 2), "utf-8");
2483
+ const socketPath = path5.join(configDir, "hooks.sock");
2484
+ const hookScript = createHookScript2(socketPath);
2485
+ const binDir = path5.join(configDir, "bin");
2486
+ await fs5.mkdir(binDir, { recursive: true });
2487
+ const hookScriptPath = path5.join(binDir, "reley-hook");
2488
+ await fs5.writeFile(hookScriptPath, hookScript, { mode: 493 });
2489
+ console.log("Installed files:");
2490
+ console.log(` Hooks config: ${hooksOutputPath}`);
2491
+ console.log(` Hook script: ${hookScriptPath}`);
2492
+ console.log(` Socket path: ${socketPath}`);
2493
+ console.log("");
2494
+ console.log("Hook configuration:");
2495
+ console.log(" Stop: Sends stop events to mobile");
2496
+ console.log(" Notification: Forwards notifications to mobile");
2497
+ console.log(" PreToolUse: Requires mobile approval for Bash, Write, Edit");
2498
+ console.log("");
2499
+ console.log("To activate, add to your PATH:");
2500
+ console.log(` export PATH="${binDir}:$PATH"`);
2501
+ console.log("");
2502
+ console.log("Or create a symlink:");
2503
+ console.log(` ln -sf ${hookScriptPath} /usr/local/bin/reley-hook`);
2504
+ console.log("");
2505
+ }
2506
+ function createHookScript2(socketPath) {
2507
+ return `#!/usr/bin/env node
2508
+ import * as net from 'node:net';
2509
+ import * as process from 'node:process';
2510
+
2511
+ const SOCKET_PATH = ${JSON.stringify(socketPath)};
2512
+ const HOOK_TYPE = process.argv[2]; // stop, notification, pre-tool-use
2513
+ const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
2514
+
2515
+ async function main() {
2516
+ // Read hook data from stdin
2517
+ const chunks = [];
2518
+ for await (const chunk of process.stdin) {
2519
+ chunks.push(chunk);
2520
+ }
2521
+ const hookData = Buffer.concat(chunks).toString('utf-8');
2522
+
2523
+ // Send to daemon via Unix socket
2524
+ const payload = JSON.stringify({
2525
+ hookType: HOOK_TYPE,
2526
+ data: hookData ? JSON.parse(hookData) : {},
2527
+ timestamp: Date.now(),
2528
+ });
2529
+
2530
+ const response = await new Promise((resolve, reject) => {
2531
+ const timeout = setTimeout(() => {
2532
+ reject(new Error('Hook response timeout'));
2533
+ }, TIMEOUT_MS);
2534
+
2535
+ const client = net.createConnection(SOCKET_PATH, () => {
2536
+ client.write(payload + '\\n');
2537
+ });
2538
+
2539
+ let responseData = '';
2540
+ client.on('data', (data) => {
2541
+ responseData += data.toString();
2542
+ });
2543
+
2544
+ client.on('end', () => {
2545
+ clearTimeout(timeout);
2546
+ try {
2547
+ resolve(JSON.parse(responseData));
2548
+ } catch {
2549
+ resolve({ action: 'approve' });
2550
+ }
2551
+ });
2552
+
2553
+ client.on('error', (err) => {
2554
+ clearTimeout(timeout);
2555
+ // If daemon is not running, default to approve
2556
+ console.error('Reley daemon not available:', err.message);
2557
+ resolve({ action: 'approve' });
2558
+ });
2559
+ });
2560
+
2561
+ // Output response for Claude Code to read
2562
+ console.log(JSON.stringify(response));
2563
+ }
2564
+
2565
+ main().catch((err) => {
2566
+ console.error('reley-hook error:', err);
2567
+ // Default to approve on error
2568
+ console.log(JSON.stringify({ action: 'approve' }));
2569
+ });
2570
+ `;
2571
+ }
2572
+
2573
+ // src/commands/login.ts
2574
+ init_config();
2575
+ import { createServer } from "node:http";
2576
+ import { URL } from "node:url";
2577
+ import { hostname } from "node:os";
2578
+ var DEFAULT_RELEY_URL = "https://api.reley.sh";
2579
+ async function loginCommand(opts) {
2580
+ const releyUrl = opts.releyUrl || process.env.RELEY_URL || DEFAULT_RELEY_URL;
2581
+ let supabaseUrl = process.env.SUPABASE_URL;
2582
+ let supabaseAnonKey = process.env.SUPABASE_ANON_KEY;
2583
+ if (!supabaseUrl || !supabaseAnonKey) {
2584
+ try {
2585
+ const configRes = await fetch(`${releyUrl}/api/v1/auth/config`);
2586
+ if (!configRes.ok) throw new Error(`HTTP ${configRes.status}`);
2587
+ const config = await configRes.json();
2588
+ supabaseUrl = config.supabaseUrl;
2589
+ supabaseAnonKey = config.supabaseAnonKey;
2590
+ } catch {
2591
+ console.error(
2592
+ "\x1B[31mError:\x1B[0m Could not connect to reley server at " + releyUrl
2593
+ );
2594
+ console.error("Check that the server is running and the URL is correct.");
2595
+ process.exit(1);
2596
+ }
2597
+ }
2598
+ console.log("\x1B[36m[Reley]\x1B[0m Starting Google login...\n");
2599
+ const { token, email } = await startOAuthFlow(supabaseUrl, supabaseAnonKey);
2600
+ console.log(`\x1B[32m\u2713\x1B[0m Logged in as \x1B[1m${email}\x1B[0m
2601
+ `);
2602
+ console.log("\x1B[36m[Reley]\x1B[0m Registering device...");
2603
+ const deviceName = hostname() || "Unknown Device";
2604
+ const res = await fetch(`${releyUrl}/api/v1/devices/register`, {
2605
+ method: "POST",
2606
+ headers: {
2607
+ "Content-Type": "application/json",
2608
+ Authorization: `Bearer ${token}`
2609
+ },
2610
+ body: JSON.stringify({ name: deviceName })
2611
+ });
2612
+ if (!res.ok) {
2613
+ const body = await res.json().catch(() => ({}));
2614
+ console.error(
2615
+ `\x1B[31mError:\x1B[0m Failed to register device: ${body.error || res.statusText}`
2616
+ );
2617
+ process.exit(1);
2618
+ }
2619
+ const { deviceId, deviceToken } = await res.json();
2620
+ saveConfig({
2621
+ reley_url: releyUrl,
2622
+ device_token: deviceToken,
2623
+ device_id: deviceId,
2624
+ user_email: email
2625
+ });
2626
+ console.log(`\x1B[32m\u2713\x1B[0m Device registered: \x1B[1m${deviceName}\x1B[0m`);
2627
+ console.log(`\x1B[32m\u2713\x1B[0m Config saved to ${getConfigPath()}
2628
+ `);
2629
+ console.log("You can now run \x1B[1mreley\x1B[0m to start a session.");
2630
+ }
2631
+ async function startOAuthFlow(supabaseUrl, supabaseAnonKey) {
2632
+ return new Promise((resolve2, reject) => {
2633
+ const CALLBACK_PORT = 54321;
2634
+ const redirectUri = `http://localhost:${CALLBACK_PORT}/auth/callback`;
2635
+ const server = createServer((req, res) => {
2636
+ const url = new URL(req.url, `http://localhost:${CALLBACK_PORT}`);
2637
+ if (url.pathname === "/auth/callback") {
2638
+ res.writeHead(200, { "Content-Type": "text/html" });
2639
+ res.end(`<!DOCTYPE html>
2640
+ <html><body>
2641
+ <script>
2642
+ // Supabase puts tokens in the hash fragment
2643
+ const hash = window.location.hash.substring(1);
2644
+ const params = new URLSearchParams(hash);
2645
+ const accessToken = params.get('access_token');
2646
+
2647
+ if (accessToken) {
2648
+ fetch('/auth/token', {
2649
+ method: 'POST',
2650
+ headers: { 'Content-Type': 'application/json' },
2651
+ body: JSON.stringify({ access_token: accessToken })
2652
+ }).then(() => {
2653
+ document.body.innerHTML = '<h2 style="font-family:system-ui;text-align:center;margin-top:100px">Login successful! You can close this tab.</h2>';
2654
+ });
2655
+ } else {
2656
+ document.body.innerHTML = '<h2 style="font-family:system-ui;text-align:center;margin-top:100px;color:red">Login failed. Please try again.</h2>';
2657
+ }
2658
+ </script>
2659
+ <h2 style="font-family:system-ui;text-align:center;margin-top:100px">Processing login...</h2>
2660
+ </body></html>`);
2661
+ return;
2662
+ }
2663
+ if (url.pathname === "/auth/token" && req.method === "POST") {
2664
+ let body = "";
2665
+ req.on("data", (chunk) => {
2666
+ body += chunk.toString();
2667
+ });
2668
+ req.on("end", async () => {
2669
+ res.writeHead(200, { "Content-Type": "application/json" });
2670
+ res.end(JSON.stringify({ ok: true }));
2671
+ try {
2672
+ const { access_token } = JSON.parse(body);
2673
+ const userRes = await fetch(`${supabaseUrl}/auth/v1/user`, {
2674
+ headers: {
2675
+ Authorization: `Bearer ${access_token}`,
2676
+ apikey: supabaseAnonKey
2677
+ }
2678
+ });
2679
+ if (!userRes.ok) {
2680
+ reject(new Error("Failed to verify token"));
2681
+ return;
2682
+ }
2683
+ const user = await userRes.json();
2684
+ server.close();
2685
+ resolve2({ token: access_token, email: user.email });
2686
+ } catch (err) {
2687
+ reject(err);
2688
+ }
2689
+ });
2690
+ return;
2691
+ }
2692
+ res.writeHead(404);
2693
+ res.end("Not found");
2694
+ });
2695
+ server.listen(CALLBACK_PORT, () => {
2696
+ const authUrl = `${supabaseUrl}/auth/v1/authorize?` + new URLSearchParams({
2697
+ provider: "google",
2698
+ redirect_to: redirectUri
2699
+ }).toString();
2700
+ console.log(
2701
+ `\x1B[36m[Reley]\x1B[0m Opening browser for Google login...`
2702
+ );
2703
+ console.log(`\x1B[2m${authUrl}\x1B[0m
2704
+ `);
2705
+ const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
2706
+ import("node:child_process").then(({ spawn: spawnChild }) => {
2707
+ spawnChild(openCmd, [authUrl], { stdio: "ignore", detached: true }).unref();
2708
+ });
2709
+ setTimeout(() => {
2710
+ server.close();
2711
+ reject(new Error("Login timed out (5 minutes)"));
2712
+ }, 5 * 60 * 1e3);
2713
+ });
2714
+ server.on("error", (err) => {
2715
+ reject(new Error(`Failed to start callback server: ${err.message}`));
2716
+ });
2717
+ });
2718
+ }
2719
+
2720
+ // src/index.ts
2721
+ var program = new Command();
2722
+ program.name("reley").description("Bridge your terminal to mobile/web with E2E encryption").version("0.1.0");
2723
+ program.option("--reley-url <url>", "Reley server URL", "http://localhost:3100").option("--config-dir <path>", "Configuration directory", `${process.env.HOME}/.reley`);
2724
+ program.command("run [command...]", { isDefault: true }).description("Run a command with web terminal sync (default)").option("--web-port <port>", "Web UI port", "3200").option("--reley-port <port>", "Reley server port", "3100").option("--online", "Use online reley server (requires `reley login` first)").action(async (commandArgs, opts) => {
2725
+ await runCommand(commandArgs, { webPort: opts.webPort, releyPort: opts.releyPort, online: opts.online });
2726
+ });
2727
+ program.command("login").description("Login with Google and register this device").option("--reley-url <url>", "Reley server URL").action(async (opts) => {
2728
+ const parentOpts = program.opts();
2729
+ await loginCommand({ releyUrl: opts.releyUrl || parentOpts.releyUrl });
2730
+ });
2731
+ program.command("pair").description("Pair this device with a mobile client").action(async () => {
2732
+ const opts = program.opts();
2733
+ await pairCommand({ releyUrl: opts.releyUrl, configDir: opts.configDir });
2734
+ });
2735
+ program.command("wrap <command...>").description("Wrap a command with existing session (requires prior pairing)").action(async (commandArgs) => {
2736
+ const opts = program.opts();
2737
+ await wrapCommand(commandArgs, { releyUrl: opts.releyUrl, configDir: opts.configDir });
2738
+ });
2739
+ program.command("status").description("Show pairing and connection status").action(async () => {
2740
+ const opts = program.opts();
2741
+ await statusCommand({ releyUrl: opts.releyUrl, configDir: opts.configDir });
2742
+ });
2743
+ program.command("install-hooks").description("Install Claude Code hooks for Reley integration").action(async () => {
2744
+ const opts = program.opts();
2745
+ await installHooksCommand({ releyUrl: opts.releyUrl, configDir: opts.configDir });
2746
+ });
2747
+ program.parseAsync(process.argv).catch((err) => {
2748
+ console.error("Fatal error:", err);
2749
+ process.exit(1);
2750
+ });