luang-cli 0.2.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/ws.js ADDED
@@ -0,0 +1,552 @@
1
+ 'use strict';
2
+
3
+ // lib/ws.js — klien WebSocket (RFC 6455) minimal TANPA dependensi (Node >= 18).
4
+ // Dipakai daemon `luang-cli agent` untuk mode realtime. Cakupan kecil namun solid:
5
+ // • handshake GET ws://|wss://… (path + headers, mis. X-CLI-Key) + verifikasi Sec-WebSocket-Accept
6
+ // • semua frame client→server SELALU dimask (wajib RFC); pesan berupa teks (JSON)
7
+ // • baca: text/continuation (join fragmentasi), ping→pong otomatis, pong, close
8
+ // • keepalive: ping berkala + deteksi koneksi mati (tidak ada frame masuk)
9
+ // • auto-reconnect eksponensial 1s→30s (dengan jitter); close beres saat diminta
10
+ // • batas pesan 1 MB (masuk maupun keluar)
11
+ // Tidak diimplementasikan: permessage-deflate, subprotocol, TLS opsi lanjutan.
12
+
13
+ const { EventEmitter } = require('events');
14
+ const crypto = require('crypto');
15
+ const net = require('net');
16
+ const tls = require('tls');
17
+
18
+ const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
19
+ const MAX_PAYLOAD = 1024 * 1024; // 1 MB per pesan
20
+ const HS_MAX_HEAD = 16384; // batas header respons handshake
21
+ const CONNECT_TIMEOUT_MS = 10000;
22
+ const PING_INTERVAL_MS = 25000; // ping keepalive klien
23
+ const DEAD_AFTER_MS = 75000; // tanpa frame masuk (incl. pong) → koneksi dianggap mati
24
+ const BASE_BACKOFF_MS = 1000;
25
+ const MAX_BACKOFF_MS = 30000;
26
+ const CLOSE_TIMEOUT_MS = 1500;
27
+
28
+ const OP = { CONT: 0, TEXT: 1, BINARY: 2, CLOSE: 8, PING: 9, PONG: 10 };
29
+
30
+ // ── Util ─────────────────────────────────────────────────────────────────────
31
+
32
+ function wsTarget(url) {
33
+ let u;
34
+ try {
35
+ u = new URL(String(url));
36
+ } catch (e) {
37
+ throw new Error(`URL WebSocket tidak valid: ${url}`);
38
+ }
39
+ const secure = u.protocol === 'wss:' || u.protocol === 'https:';
40
+ if (!secure && u.protocol !== 'ws:' && u.protocol !== 'http:') {
41
+ throw new Error(`Skema WebSocket tidak didukung: ${u.protocol}`);
42
+ }
43
+ return {
44
+ host: u.hostname,
45
+ port: u.port ? Number(u.port) : secure ? 443 : 80,
46
+ path: (u.pathname || '/') + (u.search || ''),
47
+ secure,
48
+ };
49
+ }
50
+
51
+ function acceptKey(key) {
52
+ return crypto.createHash('sha1').update(String(key) + WS_GUID).digest('base64');
53
+ }
54
+
55
+ function encodeClosePayload(code, reason) {
56
+ const r = Buffer.from(String(reason || '').slice(0, 100), 'utf8');
57
+ const out = Buffer.allocUnsafe(2 + r.length);
58
+ out.writeUInt16BE((code || 1000) & 0xffff, 0);
59
+ r.copy(out, 2);
60
+ return out;
61
+ }
62
+
63
+ // Buat frame client→server: FIN + opcode + MASK (wajib) + payload termask.
64
+ function encodeFrame(opcode, payload) {
65
+ const len = payload.length;
66
+ let header;
67
+ if (len < 126) {
68
+ header = Buffer.allocUnsafe(2);
69
+ header[1] = 0x80 | len;
70
+ } else if (len < 65536) {
71
+ header = Buffer.allocUnsafe(4);
72
+ header[1] = 0x80 | 126;
73
+ header.writeUInt16BE(len, 2);
74
+ } else {
75
+ header = Buffer.allocUnsafe(10);
76
+ header[1] = 0x80 | 127;
77
+ header.writeBigUInt64BE(BigInt(len), 2);
78
+ }
79
+ header[0] = 0x80 | opcode;
80
+ const mask = crypto.randomBytes(4);
81
+ const body = Buffer.from(payload);
82
+ for (let i = 0; i < body.length; i++) body[i] ^= mask[i & 3];
83
+ return Buffer.concat([header, mask, body]);
84
+ }
85
+
86
+ // Ambil satu frame dari buffer. null = data belum lengkap; {error} = protocol error.
87
+ function extractFrame(buf) {
88
+ if (!buf || buf.length < 2) return null;
89
+ const b0 = buf[0];
90
+ const b1 = buf[1];
91
+ const fin = (b0 & 0x80) !== 0;
92
+ const opcode = b0 & 0x0f;
93
+ if (b0 & 0x70) return { error: new Error('Bit RSV ter-set tanpa ekstensi (RFC 6455)' + b0) };
94
+ const masked = (b1 & 0x80) !== 0;
95
+ let len = b1 & 0x7f;
96
+ let off = 2;
97
+ if (len === 126) {
98
+ if (buf.length < 4) return null;
99
+ len = buf.readUInt16BE(2);
100
+ off = 4;
101
+ } else if (len === 127) {
102
+ if (buf.length < 10) return null;
103
+ const big = buf.readBigUInt64BE(2);
104
+ len = Number(big);
105
+ off = 10;
106
+ }
107
+ if (len > MAX_PAYLOAD || !Number.isFinite(len)) return { error: new Error('Pesan melebihi batas 1 MB') };
108
+ const maskLen = masked ? 4 : 0;
109
+ const total = off + maskLen + len;
110
+ if (buf.length < total) return null;
111
+ const payload = Buffer.from(buf.subarray(off + maskLen, total));
112
+ if (masked) {
113
+ const mk = buf.subarray(off, off + 4);
114
+ for (let i = 0; i < payload.length; i++) payload[i] ^= mk[i & 3];
115
+ }
116
+ return { frame: { fin, opcode, payload }, rest: buf.subarray(total) };
117
+ }
118
+
119
+ // ── Klien ────────────────────────────────────────────────────────────────────
120
+
121
+ class WsClient extends EventEmitter {
122
+ constructor(url, opts = {}) {
123
+ super();
124
+ this.url = String(url);
125
+ this.headers = opts.headers || {};
126
+ this.state = 'idle'; // idle | connecting | open | closing | closed | fatal
127
+ this.autoReconnect = opts.autoReconnect !== false;
128
+ this.pingIntervalMs = opts.pingIntervalMs || PING_INTERVAL_MS;
129
+ this.deadAfterMs = opts.deadAfterMs || DEAD_AFTER_MS;
130
+ this.baseBackoffMs = opts.baseBackoffMs || BASE_BACKOFF_MS;
131
+ this.maxBackoffMs = opts.maxBackoffMs || MAX_BACKOFF_MS;
132
+ this._sock = null;
133
+ this._buf = null; // buffer frame masuk
134
+ this._hsBuf = null; // buffer header handshake
135
+ this._frag = null; // pesan terfragmentasi {opcode, parts, size}
136
+ this._queue = []; // antrean kecil saat belum open / sedang reconnect
137
+ this._attempt = 0;
138
+ this._pendingKey = null;
139
+ this._lastRecvAt = 0;
140
+ this._connectErr = null;
141
+ this._pingTimer = null;
142
+ this._reconTimer = null;
143
+ this._closeTimer = null;
144
+ this._userClosed = false;
145
+ }
146
+
147
+ isOpen() {
148
+ return this.state === 'open';
149
+ }
150
+
151
+ connect() {
152
+ if (this._userClosed || this._reconTimer) return; // sudah jalan / dijadwalkan
153
+ if (this.state === 'open' || this.state === 'connecting') return;
154
+ if (this.state === 'fatal') return; // permanen gagal (401/403/404…/URL rusak)
155
+ this._userClosed = false;
156
+ this._connectNow();
157
+ }
158
+
159
+ // Kirim teks/objek (di-JSON-kan). false bila belum open → masuk antrean kecil.
160
+ send(data) {
161
+ let buf;
162
+ if (typeof data === 'string') buf = Buffer.from(data, 'utf8');
163
+ else if (Buffer.isBuffer(data)) buf = data;
164
+ else {
165
+ try {
166
+ buf = Buffer.from(JSON.stringify(data), 'utf8');
167
+ } catch (e) {
168
+ this.emit('error', new Error('Pesan tidak bisa diserialisasi: ' + e.message));
169
+ return false;
170
+ }
171
+ }
172
+ if (buf.length > MAX_PAYLOAD) {
173
+ this.emit('error', new Error(`Pesan ${buf.length} byte melebihi batas 1 MB`));
174
+ return false;
175
+ }
176
+ if (this.state === 'open' && this._sock) {
177
+ this._sendFrame(OP.TEXT, buf);
178
+ return true;
179
+ }
180
+ if (this._queue.length >= 100) this._queue.shift(); // buang terlama (heartbeat lama tak berharga)
181
+ this._queue.push(buf);
182
+ return false;
183
+ }
184
+
185
+ // Close beres: kirim frame close, tunggu sebentar, lalu hancurkan socket.
186
+ close(code, reason) {
187
+ this._userClosed = true;
188
+ if (this._reconTimer) {
189
+ clearTimeout(this._reconTimer);
190
+ this._reconTimer = null;
191
+ }
192
+ const sock = this._sock;
193
+ if (!sock) {
194
+ this._clearPing();
195
+ this.state = 'closed';
196
+ this.emit('close', { code: code || 1000, expected: true });
197
+ return;
198
+ }
199
+ if (this.state === 'open') {
200
+ this.state = 'closing';
201
+ this._sendFrame(OP.CLOSE, encodeClosePayload(code || 1000, reason));
202
+ this._clearPing();
203
+ this._closeTimer = setTimeout(() => {
204
+ try {
205
+ if (this._sock) this._sock.destroy();
206
+ } catch (e) { /* abaikan */ }
207
+ }, CLOSE_TIMEOUT_MS);
208
+ this._closeTimer.unref();
209
+ } else {
210
+ // connecting → bongkar langsung tanpa reconnect
211
+ this._clearPing();
212
+ this.state = 'closed';
213
+ try { sock.destroy(); } catch (e) { /* abaikan */ }
214
+ this.emit('close', { code: code || 1000, expected: true });
215
+ }
216
+ }
217
+
218
+ // ── internal ──
219
+
220
+ _clearPing() {
221
+ if (this._pingTimer) {
222
+ clearInterval(this._pingTimer);
223
+ this._pingTimer = null;
224
+ }
225
+ }
226
+
227
+ _scheduleReconnect(reason) {
228
+ if (this._userClosed || !this.autoReconnect || this._reconTimer) return;
229
+ const n = Math.max(0, this._attempt - 1);
230
+ let delay = Math.min(this.maxBackoffMs, this.baseBackoffMs * 2 ** n);
231
+ delay += Math.floor(Math.random() * 300); // jitter
232
+ this.emit('reconnecting', { attempt: this._attempt, delay, reason });
233
+ this._reconTimer = setTimeout(() => {
234
+ this._reconTimer = null;
235
+ if (!this._userClosed) this._connectNow();
236
+ }, delay);
237
+ }
238
+
239
+ _connectNow() {
240
+ if (this._userClosed || this.state === 'open') return;
241
+ this.state = 'connecting';
242
+ this._connectErr = null;
243
+ let target;
244
+ try {
245
+ target = wsTarget(this.url);
246
+ } catch (e) {
247
+ this.state = 'fatal';
248
+ this.emit('fatal', e);
249
+ return;
250
+ }
251
+ let sock;
252
+ try {
253
+ sock = target.secure
254
+ ? tls.connect({ host: target.host, port: target.port, servername: target.host }, () => this._onConnected(target))
255
+ : net.connect({ host: target.host, port: target.port }, () => this._onConnected(target));
256
+ } catch (e) {
257
+ this.state = 'closed';
258
+ this._connectErr = e;
259
+ this._onSockClose();
260
+ return;
261
+ }
262
+ sock.setNoDelay(true);
263
+ sock.setTimeout(CONNECT_TIMEOUT_MS, () => {
264
+ if (this.state === 'connecting') {
265
+ const err = new Error(`Waktu habis ${CONNECT_TIMEOUT_MS / 1000}s saat handshake`);
266
+ err.handshakeStatus = 0;
267
+ this._connectErr = err;
268
+ try { sock.destroy(); } catch (e2) { /* abaikan */ }
269
+ }
270
+ });
271
+ sock.on('data', (chunk) => this._onData(chunk));
272
+ sock.on('error', (e) => this._onSockError(e));
273
+ sock.on('close', () => this._onSockClose());
274
+ this._sock = sock;
275
+ }
276
+
277
+ _onConnected(target) {
278
+ const sock = this._sock;
279
+ if (!sock || this.state !== 'connecting') return;
280
+ const defaultPort = target.secure ? 443 : 80;
281
+ const hostHeader = target.port === defaultPort ? target.host : `${target.host}:${target.port}`;
282
+ const key = crypto.randomBytes(16).toString('base64');
283
+ this._pendingKey = key;
284
+ const lines = [
285
+ `GET ${target.path} HTTP/1.1`,
286
+ `Host: ${hostHeader}`,
287
+ 'Upgrade: websocket',
288
+ 'Connection: Upgrade',
289
+ `Sec-WebSocket-Key: ${key}`,
290
+ 'Sec-WebSocket-Version: 13',
291
+ ];
292
+ for (const [k, v] of Object.entries(this.headers)) {
293
+ const kl = String(k).toLowerCase();
294
+ if (kl.startsWith('sec-websocket') || kl === 'connection' || kl === 'upgrade' || kl === 'host') continue;
295
+ lines.push(`${k}: ${v}`);
296
+ }
297
+ sock.write(lines.join('\r\n') + '\r\n\r\n', 'utf8');
298
+ }
299
+
300
+ _onData(chunk) {
301
+ if (this.state === 'open') {
302
+ this._lastRecvAt = Date.now();
303
+ this._feed(chunk);
304
+ return;
305
+ }
306
+ // masih handshaking
307
+ this._hsBuf = this._hsBuf ? Buffer.concat([this._hsBuf, chunk]) : chunk;
308
+ const idx = this._hsBuf.indexOf('\r\n\r\n');
309
+ if (idx === -1) {
310
+ if (this._hsBuf.length > HS_MAX_HEAD) {
311
+ const err = new Error('Header handshake terlalu besar');
312
+ err.handshakeStatus = 0;
313
+ this._connectErr = err;
314
+ try { this._sock.destroy(); } catch (e) { /* abaikan */ }
315
+ }
316
+ return;
317
+ }
318
+ const head = this._hsBuf.subarray(0, idx).toString('utf8');
319
+ const rest = this._hsBuf.subarray(idx + 4);
320
+ this._hsBuf = null;
321
+ this._finishHandshake(head, rest);
322
+ }
323
+
324
+ _finishHandshake(head, rest) {
325
+ const lines = head.split('\r\n');
326
+ const m = /^HTTP\/1\.\d\s+(\d{3})(?:\s+(.*))?$/.exec(String(lines[0] || '').trim());
327
+ const status = m ? Number(m[1]) : 0;
328
+ if (status !== 101) {
329
+ const err = new Error(`Handshake WebSocket ditolak (HTTP ${status})`);
330
+ err.handshakeStatus = status;
331
+ this._connectErr = err;
332
+ try { this._sock.destroy(); } catch (e) { /* abaikan */ }
333
+ return;
334
+ }
335
+ const headers = {};
336
+ for (const line of lines.slice(1)) {
337
+ const ci = line.indexOf(':');
338
+ if (ci > 0) headers[line.slice(0, ci).trim().toLowerCase()] = line.slice(ci + 1).trim();
339
+ }
340
+ if (!this._pendingKey || headers['sec-websocket-accept'] !== acceptKey(this._pendingKey)) {
341
+ const err = new Error('Sec-WebSocket-Accept tidak valid — server bukan WebSocket RFC 6455');
342
+ err.handshakeStatus = 101;
343
+ this._connectErr = err;
344
+ try { this._sock.destroy(); } catch (e) { /* abaikan */ }
345
+ return;
346
+ }
347
+ // 101 + accept valid → open
348
+ const sock = this._sock;
349
+ this.state = 'open';
350
+ this._attempt = 0;
351
+ this._frag = null;
352
+ this._pendingKey = null;
353
+ this._lastRecvAt = Date.now();
354
+ if (sock) sock.setTimeout(0);
355
+ this._startPing();
356
+ this.emit('open');
357
+ if (rest && rest.length) this._feed(rest);
358
+ const q = this._queue.splice(0);
359
+ for (const b of q) {
360
+ if (this.state === 'open') this._sendFrame(OP.TEXT, b);
361
+ }
362
+ }
363
+
364
+ _startPing() {
365
+ this._clearPing();
366
+ this._pingTimer = setInterval(() => {
367
+ if (this.state !== 'open' || !this._sock) return;
368
+ const now = Date.now();
369
+ if (this._lastRecvAt && now - this._lastRecvAt > this.deadAfterMs) {
370
+ this.emit('error', new Error(`Koneksi diam > ${Math.round(this.deadAfterMs / 1000)}s tanpa frame — diputus untuk sambung ulang`));
371
+ try { this._sock.destroy(); } catch (e) { /* abaikan */ }
372
+ return;
373
+ }
374
+ this._sendFrame(OP.PING, crypto.randomBytes(4));
375
+ }, this.pingIntervalMs);
376
+ }
377
+
378
+ _onSockError(e) {
379
+ if (this.state === 'open') {
380
+ this.emit('error', e);
381
+ try { this._sock.destroy(); } catch (e2) { /* abaikan */ }
382
+ return;
383
+ }
384
+ if (this.state === 'connecting') {
385
+ if (e && e.handshakeStatus === undefined) {
386
+ const err = e instanceof Error ? e : new Error(String(e));
387
+ err.handshakeStatus = 0;
388
+ this._connectErr = err;
389
+ }
390
+ try { this._sock.destroy(); } catch (e2) { /* abaikan */ }
391
+ }
392
+ // state 'closing'/'closed': socket error diabaikan — kita memang sedang memutus
393
+ }
394
+
395
+ _onSockClose() {
396
+ if (this._closeTimer) {
397
+ clearTimeout(this._closeTimer);
398
+ this._closeTimer = null;
399
+ }
400
+ this._clearPing();
401
+ const was = this.state;
402
+ this.state = 'closed';
403
+ this._sock = null;
404
+ this._buf = null;
405
+ this._hsBuf = null;
406
+ this._frag = null;
407
+ if (this._userClosed || was === 'closing') {
408
+ this.emit('close', { code: 1000, expected: true });
409
+ return;
410
+ }
411
+ if (was === 'open') {
412
+ this.emit('close', { code: 1006, expected: false });
413
+ this._scheduleReconnect('koneksi terputus');
414
+ return;
415
+ }
416
+ if (was === 'connecting') {
417
+ const err = this._connectErr || new Error('Gagal terhubung');
418
+ this._connectErr = null;
419
+ const st = Number(err.handshakeStatus || 0);
420
+ const permanent = st >= 400 && st < 500 && st !== 408 && st !== 429;
421
+ if (permanent) {
422
+ this.state = 'fatal';
423
+ this.emit('fatal', err);
424
+ return;
425
+ }
426
+ this.emit('error', err);
427
+ this._attempt += 1;
428
+ this._scheduleReconnect(err.message);
429
+ }
430
+ }
431
+
432
+ _protocolFail(err) {
433
+ this.emit('error', err);
434
+ const code = /1 MB/.test(err.message) ? 1009 : 1002;
435
+ if (this._sock && this.state === 'open') {
436
+ try { this._sock.write(encodeFrame(OP.CLOSE, encodeClosePayload(code, err.message))); } catch (e) { /* abaikan */ }
437
+ try { this._sock.end(); } catch (e2) { /* abaikan */ }
438
+ setTimeout(() => {
439
+ try { if (this._sock) this._sock.destroy(); } catch (e3) { /* abaikan */ }
440
+ }, 250).unref();
441
+ }
442
+ }
443
+
444
+ _feed(chunk) {
445
+ this._buf = this._buf ? Buffer.concat([this._buf, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]) : chunk;
446
+ for (;;) {
447
+ const r = extractFrame(this._buf);
448
+ if (r === null) break;
449
+ if (r.error) {
450
+ this._protocolFail(r.error);
451
+ return;
452
+ }
453
+ this._buf = r.rest;
454
+ this._onFrame(r.frame);
455
+ if (this.state !== 'open') return;
456
+ }
457
+ if (this._buf && this._buf.length > MAX_PAYLOAD + 16) {
458
+ this._protocolFail(new Error('Buffer frame melebihi batas 1 MB'));
459
+ }
460
+ }
461
+
462
+ _onFrame({ fin, opcode, payload }) {
463
+ switch (opcode) {
464
+ case OP.TEXT:
465
+ case OP.BINARY: {
466
+ if (this._frag) {
467
+ this._protocolFail(new Error('Frame teks baru saat fragmentasi belum selesai'));
468
+ return;
469
+ }
470
+ if (fin) {
471
+ this._deliver(opcode, payload);
472
+ return;
473
+ }
474
+ this._frag = { opcode, parts: [payload], size: payload.length };
475
+ return;
476
+ }
477
+ case OP.CONT: {
478
+ if (!this._frag) {
479
+ this._protocolFail(new Error('Continuation tanpa pesan induk'));
480
+ return;
481
+ }
482
+ this._frag.parts.push(payload);
483
+ this._frag.size += payload.length;
484
+ if (this._frag.size > MAX_PAYLOAD) {
485
+ const err = new Error('Pesan gabungan melebihi batas 1 MB');
486
+ this._frag = null;
487
+ this._protocolFail(err);
488
+ return;
489
+ }
490
+ if (fin) {
491
+ const { opcode: op, parts } = this._frag;
492
+ this._frag = null;
493
+ this._deliver(op, Buffer.concat(parts));
494
+ }
495
+ return;
496
+ }
497
+ case OP.PING: {
498
+ if (payload.length > 125) {
499
+ this._protocolFail(new Error('Ping > 125 byte'));
500
+ return;
501
+ }
502
+ this._sendFrame(OP.PONG, payload);
503
+ return;
504
+ }
505
+ case OP.PONG:
506
+ return;
507
+ case OP.CLOSE: {
508
+ let code = 1005;
509
+ let reason = '';
510
+ if (payload.length >= 2) {
511
+ code = payload.readUInt16BE(0);
512
+ reason = payload.subarray(2).toString('utf8');
513
+ }
514
+ // echo close lalu biarkan event 'close' yang memutus (reconnect bila perlu)
515
+ if (this._sock && this.state === 'open') {
516
+ try { this._sock.write(encodeFrame(OP.CLOSE, encodeClosePayload(code === 1005 ? 1000 : code, ''))); } catch (e) { /* abaikan */ }
517
+ }
518
+ setImmediate(() => {
519
+ try { if (this._sock) this._sock.destroy(); } catch (e) { /* abaikan */ }
520
+ });
521
+ return;
522
+ }
523
+ default:
524
+ this._protocolFail(new Error(`Opcode tidak dikenal: ${opcode}`));
525
+ }
526
+ }
527
+
528
+ _deliver(opcode, payload) {
529
+ if (opcode === OP.TEXT) {
530
+ let s = '';
531
+ try { s = payload.toString('utf8'); } catch (e) { s = ''; }
532
+ this.emit('message', s);
533
+ } else {
534
+ this.emit('binary', payload); // tidak dipakai CLI, tapi tetap tersedia
535
+ }
536
+ }
537
+
538
+ _sendFrame(opcode, payload) {
539
+ const sock = this._sock;
540
+ if (!sock || this.state !== 'open' || !sock.writable) return false;
541
+ try {
542
+ sock.write(encodeFrame(opcode, payload));
543
+ return true;
544
+ } catch (e) {
545
+ this.emit('error', e);
546
+ try { sock.destroy(); } catch (e2) { /* abaikan */ }
547
+ return false;
548
+ }
549
+ }
550
+ }
551
+
552
+ module.exports = { WsClient, MAX_PAYLOAD, acceptKey, encodeFrame, extractFrame, wsTarget };
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "luang-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.2",
4
4
  "description": "CLI Agent Orchestration Luang — login device flow, sinkronisasi provider AI, daemon tugas, dan manajemen task dari terminal. Zero-dependency, Node >= 18.",
5
5
  "license": "MIT",
6
6
  "bin": {
7
7
  "luang-cli": "bin/luang-cli.js"
8
8
  },
9
+ "scripts": {
10
+ "test": "node test/run-e2e.js"
11
+ },
9
12
  "engines": {
10
13
  "node": ">=18"
11
14
  },
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env bash
2
+ # Stub provider untuk E2E offline luang-cli.
3
+ # Ganti binary `opencode` di PATH uji — TIDAK memanggil opencode/claude/gemini asli.
4
+ if [ "$1" = "models" ]; then
5
+ # Dipakai skenario `luang-cli models` (deteksi dinamis opencode).
6
+ printf '%s\n' '["stub-model-a","stub-model-b","stub-model-c"]'
7
+ exit 0
8
+ fi
9
+ printf 'PROVIDER-RUN %s\n' "$*"
10
+ exit 0
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ // test/http-stub.js — stub server HTTP minimal untuk E2E offline luang-cli.
4
+ // Berjalan sebagai proses TERPISAH (di-fork oleh run-e2e.js) supaya event loop
5
+ // induk tidak diblokir spawnSync saat CLI menghubungi /api/v1/agent/me.
6
+ // Flag telemetry_enabled di-toggle lewat pesan IPC: { telemetry_enabled: true }.
7
+ // Bukan bagian dari paket npm; hanya alat uji.
8
+
9
+ const http = require('http');
10
+
11
+ let telemetryEnabled = false;
12
+ let lastHeartbeat = null;
13
+ let workspace = '';
14
+
15
+ process.on('message', (msg) => {
16
+ if (msg && typeof msg === 'object' && 'telemetry_enabled' in msg) {
17
+ telemetryEnabled = msg.telemetry_enabled === true;
18
+ }
19
+ if (msg && typeof msg === 'object' && 'workspace' in msg) {
20
+ workspace = String(msg.workspace || '');
21
+ }
22
+ if (msg && msg.cmd === 'get_heartbeat') {
23
+ if (typeof process.send === 'function') process.send({ cmd: 'heartbeat', body: lastHeartbeat });
24
+ }
25
+ if (msg && msg.cmd === 'exit') {
26
+ process.exit(0);
27
+ }
28
+ });
29
+
30
+ const server = http.createServer((req, res) => {
31
+ if (req.url.startsWith('/api/v1/agent/me')) {
32
+ res.writeHead(200, { 'Content-Type': 'application/json' });
33
+ res.end(JSON.stringify({
34
+ success: true,
35
+ data: { name: 'Sesi Uji', key_id: 'k3', telemetry_enabled: telemetryEnabled, workspace, can_update_tasks: true },
36
+ }));
37
+ return;
38
+ }
39
+ if (req.url.startsWith('/api/v1/agent/workspace') && req.method === 'POST') {
40
+ let raw = '';
41
+ req.on('data', (d) => { raw += d; });
42
+ req.on('end', () => {
43
+ try {
44
+ const b = JSON.parse(raw);
45
+ workspace = typeof b.workspace === 'string' ? b.workspace : workspace;
46
+ } catch (e) { /* abaikan */ }
47
+ res.writeHead(200, { 'Content-Type': 'application/json' });
48
+ res.end(JSON.stringify({ success: true, message: 'Workspace disimpan', data: { workspace } }));
49
+ });
50
+ return;
51
+ }
52
+ if (req.url.startsWith('/api/v1/agent/heartbeat') && req.method === 'POST') {
53
+ let raw = '';
54
+ req.on('data', (d) => { raw += d; });
55
+ req.on('end', () => {
56
+ try {
57
+ lastHeartbeat = JSON.parse(raw);
58
+ } catch (e) {
59
+ lastHeartbeat = null;
60
+ }
61
+ // Kirim payload ke induk supaya E2E bisa memeriksa kontrak `providers[].models`.
62
+ if (typeof process.send === 'function') process.send({ cmd: 'heartbeat', body: lastHeartbeat });
63
+ const n = (lastHeartbeat && Array.isArray(lastHeartbeat.providers) ? lastHeartbeat.providers : []).length;
64
+ res.writeHead(200, { 'Content-Type': 'application/json' });
65
+ res.end(JSON.stringify({ success: true, data: { providers_count: n } }));
66
+ });
67
+ return;
68
+ }
69
+ res.writeHead(404, { 'Content-Type': 'application/json' });
70
+ res.end(JSON.stringify({ success: false, message: 'not found' }));
71
+ });
72
+
73
+ server.listen(0, '127.0.0.1', () => {
74
+ if (typeof process.send === 'function') {
75
+ process.send({ cmd: 'listening', port: server.address().port });
76
+ }
77
+ });