@jaw.id/cli 0.0.3 → 0.0.6

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 (42) hide show
  1. package/dist/base-command.js +1 -2
  2. package/dist/base-command.js.map +1 -1
  3. package/dist/commands/config/set.js +19 -3
  4. package/dist/commands/config/set.js.map +1 -1
  5. package/dist/commands/config/show.js +1 -2
  6. package/dist/commands/config/show.js.map +1 -1
  7. package/dist/commands/disconnect.js +439 -36
  8. package/dist/commands/disconnect.js.map +1 -1
  9. package/dist/commands/mcp/index.js +472 -218
  10. package/dist/commands/mcp/index.js.map +1 -1
  11. package/dist/commands/rpc/call.js +429 -204
  12. package/dist/commands/rpc/call.js.map +1 -1
  13. package/dist/commands/version.js +134 -0
  14. package/dist/commands/version.js.map +1 -0
  15. package/dist/index.js +455 -203
  16. package/dist/index.js.map +1 -1
  17. package/dist/lib/bridge-singleton.js +457 -205
  18. package/dist/lib/bridge-singleton.js.map +1 -1
  19. package/dist/lib/config.js +17 -2
  20. package/dist/lib/config.js.map +1 -1
  21. package/dist/lib/crypto.js +74 -0
  22. package/dist/lib/crypto.js.map +1 -0
  23. package/dist/lib/paths.js +1 -2
  24. package/dist/lib/paths.js.map +1 -1
  25. package/dist/lib/session-store.js +1 -2
  26. package/dist/lib/session-store.js.map +1 -1
  27. package/dist/lib/validation.js +12 -1
  28. package/dist/lib/validation.js.map +1 -1
  29. package/dist/lib/ws-bridge.js +321 -59
  30. package/dist/lib/ws-bridge.js.map +1 -1
  31. package/dist/mcp/handlers/config.js +17 -2
  32. package/dist/mcp/handlers/config.js.map +1 -1
  33. package/dist/mcp/handlers/daemon.js +446 -46
  34. package/dist/mcp/handlers/daemon.js.map +1 -1
  35. package/dist/mcp/handlers/rpc.js +429 -204
  36. package/dist/mcp/handlers/rpc.js.map +1 -1
  37. package/dist/mcp/server.js +472 -218
  38. package/dist/mcp/server.js.map +1 -1
  39. package/oclif.manifest.json +70 -2
  40. package/package.json +5 -1
  41. package/dist/lib/ws-daemon.js +0 -382
  42. package/dist/lib/ws-daemon.js.map +0 -1
@@ -2,10 +2,8 @@ import { Command, Flags } from '@oclif/core';
2
2
  import * as fs2 from 'fs';
3
3
  import * as path from 'path';
4
4
  import * as os from 'os';
5
- import 'url';
6
- import { execSync } from 'child_process';
7
- import 'crypto';
8
- import 'ws';
5
+ import * as crypto from 'crypto';
6
+ import WebSocket from 'ws';
9
7
 
10
8
  // src/base-command.ts
11
9
  var JAW_DIR = path.join(os.homedir(), ".jaw");
@@ -13,8 +11,7 @@ var PATHS = {
13
11
  root: JAW_DIR,
14
12
  config: path.join(JAW_DIR, "config.json"),
15
13
  session: path.join(JAW_DIR, "session.json"),
16
- bridge: path.join(JAW_DIR, "bridge.json"),
17
- daemonLog: path.join(JAW_DIR, "daemon.log")
14
+ relay: path.join(JAW_DIR, "relay.json")
18
15
  };
19
16
 
20
17
  // src/lib/config.ts
@@ -124,58 +121,464 @@ var BaseCommand = class extends Command {
124
121
  }
125
122
  };
126
123
 
127
- // src/lib/bridge-singleton.ts
128
- path.join(PATHS.root, "daemon.lock");
129
- function isDaemonProcess(pid) {
130
- if (!Number.isInteger(pid) || pid <= 0 || pid > 4194304) return false;
131
- try {
132
- process.kill(pid, 0);
133
- } catch {
134
- return false;
124
+ // src/lib/crypto.ts
125
+ var subtle = globalThis.crypto.subtle;
126
+ async function deriveSharedSecret(privateKey, peerPublicKey) {
127
+ return subtle.deriveKey(
128
+ { name: "ECDH", public: peerPublicKey },
129
+ privateKey,
130
+ { name: "AES-GCM", length: 256 },
131
+ false,
132
+ ["encrypt", "decrypt"]
133
+ );
134
+ }
135
+ async function encryptMessage(sharedSecret, payload) {
136
+ const iv = globalThis.crypto.getRandomValues(new Uint8Array(12));
137
+ const plaintext = new TextEncoder().encode(JSON.stringify(payload));
138
+ const cipherBuf = await subtle.encrypt(
139
+ { name: "AES-GCM", iv },
140
+ sharedSecret,
141
+ plaintext
142
+ );
143
+ return {
144
+ iv: bufferToBase64(iv),
145
+ ciphertext: bufferToBase64(new Uint8Array(cipherBuf))
146
+ };
147
+ }
148
+ async function decryptMessage(sharedSecret, envelope) {
149
+ const iv = Buffer.from(envelope.iv, "base64");
150
+ const ciphertext = Buffer.from(envelope.ciphertext, "base64");
151
+ const plainBuf = await subtle.decrypt(
152
+ { name: "AES-GCM", iv },
153
+ sharedSecret,
154
+ ciphertext
155
+ );
156
+ return JSON.parse(new TextDecoder().decode(plainBuf));
157
+ }
158
+ async function importKeyFromHex(type, hex) {
159
+ const format = type === "private" ? "pkcs8" : "spki";
160
+ return subtle.importKey(
161
+ format,
162
+ Buffer.from(hexToBytes(hex)),
163
+ { name: "ECDH", namedCurve: "P-256" },
164
+ true,
165
+ type === "private" ? ["deriveKey"] : []
166
+ );
167
+ }
168
+ function hexToBytes(hex) {
169
+ if (hex.length % 2 !== 0) throw new Error("Invalid hex: odd length");
170
+ const bytes = new Uint8Array(hex.length / 2);
171
+ for (let i = 0; i < hex.length; i += 2) {
172
+ bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
173
+ }
174
+ return bytes;
175
+ }
176
+ function bufferToBase64(buf) {
177
+ return Buffer.from(buf).toString("base64");
178
+ }
179
+
180
+ // src/lib/ws-bridge.ts
181
+ var DEFAULT_TIMEOUT_MS = 12e4;
182
+ var MAX_MESSAGE_BYTES = 5 * 1024 * 1024;
183
+ var BROWSER_REOPEN_COOLDOWN_MS = 5e3;
184
+ var MAX_RECONNECT_ATTEMPTS = 3;
185
+ var RECONNECT_BASE_DELAY_MS = 1e3;
186
+ var WSBridge = class {
187
+ relayUrl;
188
+ session;
189
+ timeout;
190
+ config;
191
+ privateKeyHex;
192
+ publicKeyHex;
193
+ peerPublicKeyHex;
194
+ sharedSecret = null;
195
+ ws = null;
196
+ disposed = false;
197
+ // Auto-reopen browser state
198
+ onBrowserNeeded;
199
+ onPeerKeyChanged;
200
+ lastBrowserOpenTime = 0;
201
+ // Reconnection state
202
+ reconnectAttempts = 0;
203
+ /** Updated after key exchange — caller should persist this. */
204
+ get peerPublicKey() {
205
+ return this.peerPublicKeyHex;
206
+ }
207
+ constructor(options) {
208
+ this.relayUrl = options.relayUrl;
209
+ this.session = options.session;
210
+ this.timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
211
+ this.config = options.config;
212
+ this.privateKeyHex = options.privateKeyHex;
213
+ this.publicKeyHex = options.publicKeyHex;
214
+ this.peerPublicKeyHex = options.peerPublicKeyHex;
215
+ }
216
+ /**
217
+ * Connect to the relay and wait for the browser to be ready.
218
+ *
219
+ * @param onBrowserNeeded — called when the relay reports no browser connected.
220
+ * @param onPeerKeyChanged — called when a key_exchange updates the peer key.
221
+ */
222
+ async connect(onBrowserNeeded, onPeerKeyChanged) {
223
+ this.onBrowserNeeded = onBrowserNeeded;
224
+ this.onPeerKeyChanged = onPeerKeyChanged;
225
+ if (this.peerPublicKeyHex) {
226
+ await this.deriveSecret();
227
+ }
228
+ return this.connectInternal(onBrowserNeeded, onPeerKeyChanged);
229
+ }
230
+ async connectInternal(onBrowserNeeded, onPeerKeyChanged) {
231
+ return new Promise((resolve, reject) => {
232
+ const url = `${this.relayUrl}?session=${encodeURIComponent(this.session)}&role=cli`;
233
+ const ws = new WebSocket(url);
234
+ let browserOpened = false;
235
+ let resolved = false;
236
+ let expectingKeyExchange = !this.peerPublicKeyHex;
237
+ const timer = setTimeout(() => {
238
+ ws.close();
239
+ reject(
240
+ new Error(
241
+ "Browser did not connect in time.\nRun `jaw disconnect` then try again."
242
+ )
243
+ );
244
+ }, 3e4);
245
+ const sendEncryptedInit = async () => {
246
+ if (!this.sharedSecret) return;
247
+ const envelope = await encryptMessage(this.sharedSecret, {
248
+ type: "init",
249
+ apiKey: this.config.apiKey,
250
+ chainId: this.config.chainId,
251
+ ens: this.config.ens,
252
+ paymasterUrl: this.config.paymasterUrl
253
+ });
254
+ this.sendRaw(ws, JSON.stringify({ type: "encrypted", ...envelope }));
255
+ };
256
+ const waitForReady = () => {
257
+ const readyTimer = setTimeout(() => {
258
+ ws.close();
259
+ reject(new Error("Browser SDK did not become ready in time."));
260
+ }, 15e3);
261
+ const onMsg = async (data) => {
262
+ const msg = safeParse(data);
263
+ if (!msg) return;
264
+ if (msg.type === "encrypted" && this.sharedSecret) {
265
+ try {
266
+ const inner = await decryptMessage(
267
+ this.sharedSecret,
268
+ msg
269
+ );
270
+ if (inner.type === "ready") {
271
+ clearTimeout(readyTimer);
272
+ ws.off("message", onMsg);
273
+ this.reconnectAttempts = 0;
274
+ resolve();
275
+ }
276
+ } catch {
277
+ }
278
+ }
279
+ };
280
+ ws.on("message", onMsg);
281
+ };
282
+ const onBrowserReady = async () => {
283
+ if (resolved) return;
284
+ resolved = true;
285
+ clearTimeout(timer);
286
+ waitForReady();
287
+ await sendEncryptedInit();
288
+ };
289
+ ws.on("open", () => {
290
+ this.ws = ws;
291
+ });
292
+ ws.on("message", async (data) => {
293
+ const msg = safeParse(data);
294
+ if (!msg) return;
295
+ if (msg.type === "status") {
296
+ if (msg.browserConnected) {
297
+ if (this.sharedSecret) {
298
+ await onBrowserReady();
299
+ } else {
300
+ expectingKeyExchange = true;
301
+ }
302
+ } else if (!browserOpened && onBrowserNeeded) {
303
+ browserOpened = true;
304
+ expectingKeyExchange = true;
305
+ onBrowserNeeded().catch(() => {
306
+ });
307
+ }
308
+ } else if (msg.type === "browser_connected") {
309
+ expectingKeyExchange = true;
310
+ } else if (msg.type === "browser_disconnected") {
311
+ this.handleBrowserDisconnect();
312
+ } else if (msg.type === "key_exchange" && expectingKeyExchange) {
313
+ expectingKeyExchange = false;
314
+ const peerKey = msg.publicKey;
315
+ this.peerPublicKeyHex = peerKey;
316
+ await this.deriveSecret();
317
+ onPeerKeyChanged?.(peerKey);
318
+ await onBrowserReady();
319
+ }
320
+ });
321
+ ws.on("error", (err) => {
322
+ clearTimeout(timer);
323
+ reject(err);
324
+ });
325
+ ws.on("close", () => {
326
+ clearTimeout(timer);
327
+ if (!this.disposed) {
328
+ this.handleRelayDisconnect();
329
+ }
330
+ });
331
+ });
332
+ }
333
+ /**
334
+ * Send an encrypted RPC request through the relay to the browser SDK.
335
+ */
336
+ async request(method, params) {
337
+ const ws = this.ws;
338
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
339
+ throw new Error("Not connected to relay");
340
+ }
341
+ if (!this.sharedSecret) {
342
+ throw new Error("No shared secret \u2014 key exchange not completed");
343
+ }
344
+ const id = crypto.randomUUID();
345
+ const envelope = await encryptMessage(this.sharedSecret, {
346
+ type: "rpc_request",
347
+ id,
348
+ method,
349
+ params
350
+ });
351
+ const serialized = JSON.stringify({ type: "encrypted", ...envelope });
352
+ assertMessageSize(serialized, method);
353
+ return new Promise((resolve, reject) => {
354
+ const timer = setTimeout(() => {
355
+ reject(
356
+ new Error(
357
+ `Request timed out after ${this.timeout / 1e3}s. Did you complete the action in the browser?`
358
+ )
359
+ );
360
+ this.close();
361
+ }, this.timeout);
362
+ const onMessage = async (data) => {
363
+ const msg = safeParse(data);
364
+ if (!msg || msg.type !== "encrypted" || !this.sharedSecret) return;
365
+ try {
366
+ const inner = await decryptMessage(
367
+ this.sharedSecret,
368
+ msg
369
+ );
370
+ if (inner.type === "rpc_response" && inner.id === id) {
371
+ clearTimeout(timer);
372
+ ws.off("message", onMessage);
373
+ if (inner.success) {
374
+ resolve(inner.data);
375
+ } else {
376
+ const err = inner.error;
377
+ reject(
378
+ new Error(
379
+ err ? `[${err.code}] ${err.message}` : "Request failed"
380
+ )
381
+ );
382
+ }
383
+ }
384
+ } catch {
385
+ }
386
+ };
387
+ ws.on("message", onMessage);
388
+ this.sendRaw(ws, serialized);
389
+ });
390
+ }
391
+ isOpen() {
392
+ return this.ws?.readyState === WebSocket.OPEN;
393
+ }
394
+ async shutdown() {
395
+ this.disposed = true;
396
+ if (this.ws?.readyState === WebSocket.OPEN && this.sharedSecret) {
397
+ try {
398
+ const envelope = await encryptMessage(this.sharedSecret, {
399
+ type: "shutdown"
400
+ });
401
+ this.sendRaw(
402
+ this.ws,
403
+ JSON.stringify({ type: "encrypted", ...envelope })
404
+ );
405
+ } catch {
406
+ }
407
+ }
408
+ this.close();
409
+ }
410
+ /**
411
+ * Connect to relay and send shutdown directly — no init/ready handshake.
412
+ * Used by `jaw disconnect` when we just need to tell the browser to close.
413
+ */
414
+ async connectAndShutdown() {
415
+ if (!this.peerPublicKeyHex) {
416
+ return;
417
+ }
418
+ this.disposed = true;
419
+ await this.deriveSecret();
420
+ return new Promise((resolve) => {
421
+ const url = `${this.relayUrl}?session=${encodeURIComponent(this.session)}&role=cli`;
422
+ const ws = new WebSocket(url);
423
+ const timer = setTimeout(() => {
424
+ try {
425
+ ws.close();
426
+ } catch {
427
+ }
428
+ resolve();
429
+ }, 3e3);
430
+ ws.on("open", async () => {
431
+ this.ws = ws;
432
+ try {
433
+ await this.shutdown();
434
+ } catch {
435
+ }
436
+ clearTimeout(timer);
437
+ resolve();
438
+ });
439
+ ws.on("error", () => {
440
+ clearTimeout(timer);
441
+ resolve();
442
+ });
443
+ });
444
+ }
445
+ close() {
446
+ this.disposed = true;
447
+ if (this.ws) {
448
+ try {
449
+ this.ws.close();
450
+ } catch {
451
+ }
452
+ this.ws = null;
453
+ }
454
+ }
455
+ /**
456
+ * Auto-reopen browser when browser_disconnected is received from relay.
457
+ * Respects a cooldown to prevent rapid re-opening.
458
+ */
459
+ handleBrowserDisconnect() {
460
+ if (this.disposed || !this.onBrowserNeeded) return;
461
+ const now = Date.now();
462
+ if (now - this.lastBrowserOpenTime < BROWSER_REOPEN_COOLDOWN_MS) {
463
+ return;
464
+ }
465
+ this.lastBrowserOpenTime = now;
466
+ this.sharedSecret = null;
467
+ this.peerPublicKeyHex = null;
468
+ this.onBrowserNeeded().catch(() => {
469
+ });
470
+ }
471
+ /**
472
+ * Attempt to reconnect to the relay with exponential backoff
473
+ * when the WebSocket connection drops unexpectedly.
474
+ */
475
+ handleRelayDisconnect() {
476
+ if (this.disposed) return;
477
+ if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) return;
478
+ const delay = RECONNECT_BASE_DELAY_MS * Math.pow(2, this.reconnectAttempts);
479
+ this.reconnectAttempts++;
480
+ setTimeout(() => {
481
+ if (this.disposed) return;
482
+ this.connectInternal(this.onBrowserNeeded, this.onPeerKeyChanged).catch(
483
+ () => {
484
+ }
485
+ );
486
+ }, delay);
487
+ }
488
+ /** Send a raw string over the WebSocket, enforcing message size limits. */
489
+ sendRaw(ws, data) {
490
+ ws.send(data);
135
491
  }
492
+ async deriveSecret() {
493
+ if (!this.peerPublicKeyHex) return;
494
+ const privateKey = await importKeyFromHex("private", this.privateKeyHex);
495
+ const peerPublicKey = await importKeyFromHex(
496
+ "public",
497
+ this.peerPublicKeyHex
498
+ );
499
+ this.sharedSecret = await deriveSharedSecret(privateKey, peerPublicKey);
500
+ }
501
+ };
502
+ function assertMessageSize(serialized, method) {
503
+ const byteLength = Buffer.byteLength(serialized, "utf-8");
504
+ if (byteLength > MAX_MESSAGE_BYTES) {
505
+ const sizeMB = (byteLength / (1024 * 1024)).toFixed(2);
506
+ throw new Error(
507
+ `Message for ${method} is too large (${sizeMB} MB, limit ${MAX_MESSAGE_BYTES / (1024 * 1024)} MB). Try reducing the number of calls in your batch.`
508
+ );
509
+ }
510
+ }
511
+ function safeParse(data) {
136
512
  try {
137
- const cmd = execSync(`ps -p ${String(pid)} -o command=`, {
138
- encoding: "utf-8",
139
- timeout: 3e3
140
- }).trim();
141
- return cmd.includes("ws-daemon");
513
+ return JSON.parse(data.toString());
142
514
  } catch {
143
- return false;
515
+ return null;
144
516
  }
145
517
  }
146
- function loadBridgeInfo() {
518
+
519
+ // src/lib/bridge-singleton.ts
520
+ function loadRelaySession() {
147
521
  try {
148
- if (!fs2.existsSync(PATHS.bridge)) return null;
149
- const raw = fs2.readFileSync(PATHS.bridge, "utf-8");
150
- const info = JSON.parse(raw);
151
- if (!isDaemonProcess(info.pid)) {
152
- try {
153
- fs2.unlinkSync(PATHS.bridge);
154
- } catch {
155
- }
522
+ if (!fs2.existsSync(PATHS.relay)) return null;
523
+ const raw = fs2.readFileSync(PATHS.relay, "utf-8");
524
+ const parsed = JSON.parse(raw);
525
+ if (!parsed.session || !parsed.relayUrl || !parsed.privateKey || !parsed.publicKey) {
156
526
  return null;
157
527
  }
158
- return info;
528
+ return parsed;
159
529
  } catch {
160
530
  return null;
161
531
  }
162
532
  }
533
+ function deleteRelaySession() {
534
+ try {
535
+ if (fs2.existsSync(PATHS.relay)) fs2.unlinkSync(PATHS.relay);
536
+ } catch {
537
+ }
538
+ }
163
539
  async function shutdownDaemon() {
164
- const info = loadBridgeInfo();
165
- if (!info) return;
540
+ const session = loadRelaySession();
541
+ if (!session) return;
166
542
  try {
167
- process.kill(info.pid, "SIGTERM");
543
+ const bridge = new WSBridge({
544
+ relayUrl: session.relayUrl,
545
+ session: session.session,
546
+ timeout: 5e3,
547
+ config: { apiKey: "", chainId: 1 },
548
+ privateKeyHex: session.privateKey,
549
+ publicKeyHex: session.publicKey,
550
+ peerPublicKeyHex: session.peerPublicKey
551
+ });
552
+ await bridge.connectAndShutdown();
168
553
  } catch {
169
554
  }
555
+ deleteRelaySession();
556
+ const legacyBridge = PATHS.root + "/bridge.json";
557
+ const legacyLog = PATHS.root + "/daemon.log";
558
+ const legacyLock = PATHS.root + "/daemon.lock";
170
559
  try {
171
- if (fs2.existsSync(PATHS.bridge)) fs2.unlinkSync(PATHS.bridge);
560
+ if (fs2.existsSync(legacyBridge)) {
561
+ const info = JSON.parse(fs2.readFileSync(legacyBridge, "utf-8"));
562
+ if (info.pid && Number.isInteger(info.pid) && info.pid > 0) {
563
+ try {
564
+ process.kill(info.pid, "SIGTERM");
565
+ } catch {
566
+ }
567
+ }
568
+ }
172
569
  } catch {
173
570
  }
571
+ for (const f of [legacyBridge, legacyLog, legacyLock]) {
572
+ try {
573
+ if (fs2.existsSync(f)) fs2.unlinkSync(f);
574
+ } catch {
575
+ }
576
+ }
174
577
  }
175
578
 
176
579
  // src/commands/disconnect.ts
177
580
  var Disconnect = class _Disconnect extends BaseCommand {
178
- static description = "Stop the background bridge daemon and close the browser session.";
581
+ static description = "Close the relay session and browser tab.";
179
582
  static examples = ["<%= config.bin %> disconnect"];
180
583
  static flags = {
181
584
  ...BaseCommand.baseFlags
@@ -186,7 +589,7 @@ var Disconnect = class _Disconnect extends BaseCommand {
186
589
  if (flags.output === "json") {
187
590
  this.outputResult({ success: true }, "json");
188
591
  } else {
189
- this.log("Bridge daemon stopped.");
592
+ this.log("Relay session closed.");
190
593
  }
191
594
  }
192
595
  };