@danieltmn/openbridge 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 (44) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/LICENSE +21 -0
  3. package/README.md +143 -0
  4. package/bin/openbridge.js +18 -0
  5. package/docs/ARCHITECTURE.md +77 -0
  6. package/package.json +54 -0
  7. package/src/auth.js +204 -0
  8. package/src/bridge/bridge.js +2280 -0
  9. package/src/cli.js +791 -0
  10. package/src/config.js +108 -0
  11. package/src/log.js +22 -0
  12. package/src/paths.js +152 -0
  13. package/src/push.js +85 -0
  14. package/src/store/index.js +930 -0
  15. package/src/store/jsonfile.js +54 -0
  16. package/src/tunnel/index.js +141 -0
  17. package/src/web/assets/app.js +3939 -0
  18. package/src/web/assets/icons/icon-128x128.png +0 -0
  19. package/src/web/assets/icons/icon-144x144.png +0 -0
  20. package/src/web/assets/icons/icon-152x152.png +0 -0
  21. package/src/web/assets/icons/icon-192x192.png +0 -0
  22. package/src/web/assets/icons/icon-384x384.png +0 -0
  23. package/src/web/assets/icons/icon-512x512.png +0 -0
  24. package/src/web/assets/icons/icon-72x72.png +0 -0
  25. package/src/web/assets/icons/icon-96x96.png +0 -0
  26. package/src/web/assets/manifest.webmanifest +24 -0
  27. package/src/web/assets/sw.js +85 -0
  28. package/src/web/assets/themes/dark-plus.css +19 -0
  29. package/src/web/assets/themes/dark-red.css +18 -0
  30. package/src/web/assets/themes/default.css +18 -0
  31. package/src/web/assets/themes/github-light.css +19 -0
  32. package/src/web/assets/themes/high-contrast.css +18 -0
  33. package/src/web/assets/themes/index.json +15 -0
  34. package/src/web/assets/themes/light-plus.css +19 -0
  35. package/src/web/assets/themes/light.css +18 -0
  36. package/src/web/assets/themes/monokai.css +19 -0
  37. package/src/web/assets/themes/one-dark.css +19 -0
  38. package/src/web/assets/themes/solarized.css +18 -0
  39. package/src/web/assets/themes/terminal.css +28 -0
  40. package/src/web/assets/themes/themes.css +2 -0
  41. package/src/web/routes.js +871 -0
  42. package/src/web/server.js +130 -0
  43. package/src/web/templates/chat.html +1296 -0
  44. package/src/web/templates/login.html +144 -0
package/src/cli.js ADDED
@@ -0,0 +1,791 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const fsp = require('node:fs/promises');
5
+ const path = require('node:path');
6
+ const os = require('node:os');
7
+ const { spawn, spawnSync } = require('node:child_process');
8
+ const paths = require('./paths');
9
+ const config = require('./config');
10
+ const store = require('./store');
11
+ const auth = require('./auth');
12
+ const push = require('./push');
13
+ const web = require('./web/server');
14
+ const tunnel = require('./tunnel');
15
+ const log = require('./log');
16
+
17
+ const VERSION = require('../package.json').version;
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Utilidades CLI
21
+ // ---------------------------------------------------------------------------
22
+ function parseArgs(argv) {
23
+ const out = { _: [], flags: {} };
24
+ for (let i = 0; i < argv.length; i++) {
25
+ const a = argv[i];
26
+ if (a.startsWith('--')) {
27
+ const key = a.slice(2);
28
+ const next = argv[i + 1];
29
+ if (next !== undefined && !next.startsWith('-')) { out.flags[key] = next; i++; }
30
+ else out.flags[key] = true;
31
+ } else if (a.startsWith('-') && a.length === 2) {
32
+ const next = argv[i + 1];
33
+ if (next !== undefined && !next.startsWith('-')) { out.flags[a.slice(1)] = next; i++; }
34
+ else out.flags[a.slice(1)] = true;
35
+ } else {
36
+ out._.push(a);
37
+ }
38
+ }
39
+ return out;
40
+ }
41
+
42
+ // Una sola interfaz de consola para todas las preguntas (crear/cerrar una por
43
+ // pregunta puede perder entrada en Windows y guardar una contrasena equivocada).
44
+ function createPrompter() {
45
+ const rl = require('node:readline/promises').createInterface({ input: process.stdin, output: process.stdout });
46
+ return {
47
+ async ask(question, def) {
48
+ const answer = (await rl.question(question + (def ? ' [' + def + ']' : '') + ': ')).trim();
49
+ return answer === '' ? (def || '') : answer;
50
+ },
51
+ close() { try { rl.close(); } catch (e) { /* nada */ } },
52
+ };
53
+ }
54
+
55
+ // Pregunta ocultando lo que se escribe (contrasenas). Lee en modo raw y no
56
+ // deja eco; funciona en Windows y POSIX.
57
+ function askHidden(question) {
58
+ return new Promise((resolve) => {
59
+ const stdin = process.stdin;
60
+ const stdout = process.stdout;
61
+ stdout.write(question);
62
+ let buf = '';
63
+ const wasRaw = stdin.isRaw;
64
+ if (stdin.isTTY) stdin.setRawMode(true);
65
+ stdin.resume();
66
+ const cleanup = () => {
67
+ stdin.removeListener('data', onData);
68
+ if (stdin.isTTY) stdin.setRawMode(wasRaw || false);
69
+ stdin.pause();
70
+ };
71
+ const onData = (chunk) => {
72
+ for (const ch of chunk.toString('utf8')) {
73
+ if (ch === '\r' || ch === '\n') { cleanup(); stdout.write('\n'); resolve(buf); return; }
74
+ if (ch === '\u0003') { cleanup(); stdout.write('\n'); process.exit(130); }
75
+ if (ch === '\u007f' || ch === '\b') { if (buf.length) buf = buf.slice(0, -1); continue; }
76
+ buf += ch;
77
+ }
78
+ };
79
+ stdin.on('data', onData);
80
+ });
81
+ }
82
+
83
+ function sanitizeId(s) {
84
+ let id = String(s || '').replace(/[^A-Za-z0-9._-]/g, '').replace(/^[^A-Za-z0-9]+/, '').slice(0, 40);
85
+ return /^[A-Za-z0-9]/.test(id) ? id : 'pc';
86
+ }
87
+
88
+ function killPid(pid) {
89
+ if (!pid) return;
90
+ try {
91
+ if (process.platform === 'win32') {
92
+ spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true });
93
+ } else {
94
+ // Los hijos se lanzan detached (grupo propio): matamos el grupo y,
95
+ // por si no lo es, tambien el pid suelto.
96
+ try { process.kill(-pid, 'SIGTERM'); } catch (e) { /* sin grupo */ }
97
+ try { process.kill(pid, 'SIGTERM'); } catch (e) { /* ya no esta */ }
98
+ }
99
+ } catch (e) { /* ya no esta */ }
100
+ }
101
+
102
+ function pidAlive(pid) {
103
+ if (!pid) return false;
104
+ try { process.kill(pid, 0); return true; } catch (e) { return false; }
105
+ }
106
+
107
+ function runtimePath() { return paths.p('runtime.json'); }
108
+ function readRuntime() {
109
+ try { return JSON.parse(fs.readFileSync(runtimePath(), 'utf8')); } catch (e) { return null; }
110
+ }
111
+ function writeRuntime(data) {
112
+ try { fs.writeFileSync(runtimePath(), JSON.stringify(data, null, 2)); } catch (e) { /* nada */ }
113
+ }
114
+ function clearRuntime() {
115
+ for (const f of [runtimePath(), paths.pidPath(), paths.bridgeLockPath()]) {
116
+ try { fs.unlinkSync(f); } catch (e) { /* nada */ }
117
+ }
118
+ }
119
+
120
+ // Detiene el server en ejecucion (si lo hay) matando server + hijos. Devuelve
121
+ // true si habia uno. Se usa antes de reconfigurar para no dejar un proceso con
122
+ // la config vieja en memoria (p.ej. la contrasena).
123
+ function stopRunningServer() {
124
+ const rt = readRuntime();
125
+ if (!rt || !pidAlive(rt.pid)) { clearRuntime(); return false; }
126
+ killPid(rt.pid);
127
+ if (Array.isArray(rt.children)) {
128
+ for (const c of rt.children) killPid(c);
129
+ }
130
+ clearRuntime();
131
+ return true;
132
+ }
133
+
134
+ // Espera a que el hijo detached escriba runtime.json (con todo levantado:
135
+ // server + tunel + puente). Devuelve el runtime o null si el hijo murio/timeout.
136
+ function waitForRuntime(childPid, isDead, timeoutMs) {
137
+ return new Promise((resolve) => {
138
+ const start = Date.now();
139
+ const tick = () => {
140
+ if (isDead()) return resolve(null);
141
+ const rt = readRuntime();
142
+ if (rt && rt.pid && pidAlive(rt.pid) && (!childPid || rt.pid === childPid)) return resolve(rt);
143
+ if (Date.now() - start > timeoutMs) return resolve(null);
144
+ setTimeout(tick, 400);
145
+ };
146
+ tick();
147
+ });
148
+ }
149
+
150
+ async function scanFolders(workspace) {
151
+ const out = [];
152
+ try {
153
+ const items = await fsp.readdir(workspace, { withFileTypes: true });
154
+ for (const it of items) {
155
+ if (it.name.startsWith('.')) continue;
156
+ if (!it.isDirectory()) continue;
157
+ out.push({ name: it.name, path: path.join(workspace, it.name) });
158
+ }
159
+ } catch (e) { /* workspace inexistente */ }
160
+ return out.sort((a, b) => a.name.localeCompare(b.name));
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // init
165
+ // ---------------------------------------------------------------------------
166
+ async function cmdInit(argv) {
167
+ const { flags } = parseArgs(argv);
168
+ const home = paths.home();
169
+ paths.ensureDirs();
170
+
171
+ if (paths.exists() && !flags.force) {
172
+ console.log('Ya existe una configuracion en ' + home);
173
+ console.log('Usa --force para reconfigurar (se pisan config.json/app.json).');
174
+ return 0;
175
+ }
176
+
177
+ if (stopRunningServer()) {
178
+ console.log('Habia un server corriendo; lo detuve para reconfigurar (reinicialo con `openbridge server`).');
179
+ }
180
+
181
+ const cwd = process.cwd();
182
+ const interactive = !flags.yes && process.stdin.isTTY;
183
+ const prompt = interactive ? createPrompter() : null;
184
+
185
+ let workspace, name, port, provider, password;
186
+ try {
187
+ workspace = flags.workspace || (interactive ? await prompt.ask('Carpeta de trabajo (workspace)', cwd) : cwd);
188
+ workspace = path.resolve(workspace);
189
+ name = flags.name || (interactive ? await prompt.ask('Nombre de esta computadora', os.hostname()) : os.hostname());
190
+ port = parseInt(flags.port || (interactive ? await prompt.ask('Puerto local', '8799') : '8799'), 10) || 8799;
191
+ provider = flags.tunnel || (interactive ? await prompt.ask('Tunel (tunnelmole/ngrok/cloudflare/none)', 'tunnelmole') : 'tunnelmole');
192
+ password = flags.password || '';
193
+ } finally {
194
+ if (prompt) prompt.close();
195
+ }
196
+ let generated = false;
197
+ if (!password && interactive) password = await askHidden('Contrasena de acceso (enter = generar): ');
198
+ if (!password) {
199
+ password = config.randomToken(12);
200
+ generated = true;
201
+ }
202
+ provider = String(provider || 'tunnelmole').toLowerCase();
203
+ if (provider === 'cloudflared') provider = 'cloudflare';
204
+ if (!['tunnelmole', 'ngrok', 'cloudflare', 'none'].includes(provider)) {
205
+ console.log('aviso: proveedor de tunel desconocido "' + provider + '"; uso tunnelmole.');
206
+ provider = 'tunnelmole';
207
+ }
208
+ const id = flags.id || sanitizeId(name);
209
+
210
+ const bridgeToken = config.randomToken(32);
211
+ const csrfSecret = config.randomToken(32);
212
+ let vapid = { publicKey: '', privateKey: '' };
213
+ try { vapid = config.genVapid(); } catch (e) { console.log('aviso: no se pudieron generar claves VAPID (avisos push desactivados)'); }
214
+
215
+ const bridgeCfg = {
216
+ ...config.DEFAULT_BRIDGE,
217
+ apiUrl: 'http://127.0.0.1:' + port + '/api.php',
218
+ apiUrlLocal: '',
219
+ mode: 'remoto',
220
+ apiToken: bridgeToken,
221
+ logFile: 'logs/bridge.log',
222
+ foldersFile: 'folders.json',
223
+ workspace,
224
+ allowCreateFolders: true,
225
+ bridgeId: id,
226
+ bridgeName: name,
227
+ };
228
+ const appCfg = {
229
+ ...config.DEFAULT_APP,
230
+ port,
231
+ host: '127.0.0.1',
232
+ username: flags.user || 'admin',
233
+ password: config.hashPassword(password),
234
+ csrfSecret,
235
+ bridgeToken,
236
+ vapid,
237
+ tunnel: { provider, domain: '' },
238
+ };
239
+ config.writeBridge(bridgeCfg);
240
+ config.writeApp(appCfg);
241
+
242
+ const folders = await scanFolders(workspace);
243
+ config.writeJsonFile(paths.foldersPath(), { folders });
244
+
245
+ console.log('');
246
+ console.log('OpenBridge configurado.');
247
+ console.log(' carpeta : ' + paths.baseDir());
248
+ console.log(' datos : ' + home + ' (config, data, logs)');
249
+ console.log(' workspace : ' + workspace);
250
+ console.log(' puente : ' + id + ' (' + name + ')');
251
+ console.log(' URL local : http://127.0.0.1:' + port + '/chat.php');
252
+ console.log(' usuario : ' + appCfg.username);
253
+ console.log(' contrasena: ' + password + (generated ? ' (generada)' : ''));
254
+ console.log(' tunel : ' + provider);
255
+ console.log(' carpetas : ' + folders.length + ' en folders.json');
256
+ if (generated) console.log(' -> guarda esta contrasena: no se vuelve a mostrar.');
257
+ console.log('');
258
+ console.log('Siguiente paso: openbridge server');
259
+ return 0;
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // server
264
+ // ---------------------------------------------------------------------------
265
+ async function cmdServer(argv) {
266
+ const { flags } = parseArgs(argv);
267
+ if (!paths.exists()) {
268
+ console.error('No hay configuracion en ' + paths.home() + '. Corre primero: openbridge init');
269
+ return 1;
270
+ }
271
+ paths.ensureDirs();
272
+
273
+ const app = config.readApp();
274
+ const bridgeCfg = config.readBridge();
275
+ const port = parseInt(flags.port || app.port, 10) || 8799;
276
+ const host = app.host || '127.0.0.1';
277
+
278
+ const isChild = !!process.env.OPENBRIDGE_DETACHED;
279
+ const foreground = !!flags.stream || isChild;
280
+
281
+ // Ya hay un server corriendo: no arrancamos otro.
282
+ if (!isChild) {
283
+ const cur = readRuntime();
284
+ if (cur && pidAlive(cur.pid)) {
285
+ console.log('OpenBridge ya esta corriendo (pid ' + cur.pid + ').');
286
+ await printStatus();
287
+ return 0;
288
+ }
289
+ }
290
+
291
+ // Por defecto en segundo plano: relanzamos este comando detached (con
292
+ // --stream para que el hijo corra en primer plano) y esperamos a que
293
+ // levante todo para mostrar el status.
294
+ if (!foreground) {
295
+ const bin = process.argv[1];
296
+ const passthrough = argv.filter((a) => a !== '--detach' && a !== '--stream');
297
+ const child = spawn(process.execPath, [bin, 'server', '--stream', ...passthrough], {
298
+ detached: true,
299
+ stdio: 'ignore',
300
+ windowsHide: true,
301
+ env: { ...process.env, OPENBRIDGE_HOME: paths.baseDir(), OPENBRIDGE_DETACHED: '1' },
302
+ });
303
+ child.unref();
304
+ let dead = false;
305
+ child.on('exit', () => { dead = true; });
306
+ console.log('OpenBridge arrancando en segundo plano (pid ' + child.pid + ')...');
307
+ const rt = await waitForRuntime(child.pid, () => dead, 120000);
308
+ console.log('');
309
+ await printStatus();
310
+ if (!rt) {
311
+ console.log('');
312
+ console.log('No pude confirmar el arranque. Revisa: openbridge logs');
313
+ }
314
+ return 0;
315
+ }
316
+
317
+ // El puente de este server siempre apunta al server local.
318
+ const localApi = 'http://127.0.0.1:' + port + '/api.php';
319
+ let cfgChanged = false;
320
+ if (bridgeCfg.apiUrl !== localApi) { bridgeCfg.apiUrl = localApi; cfgChanged = true; }
321
+ if (bridgeCfg.apiToken !== app.bridgeToken) { bridgeCfg.apiToken = app.bridgeToken; cfgChanged = true; }
322
+ if (cfgChanged) config.writeBridge(bridgeCfg);
323
+
324
+ const server = web.createServer(app);
325
+ try {
326
+ await new Promise((resolve, reject) => {
327
+ server.once('error', reject);
328
+ server.listen(port, host, resolve);
329
+ });
330
+ } catch (e) {
331
+ if (e && e.code === 'EADDRINUSE') {
332
+ log.error('puerto ' + port + ' ocupado (¿ya corre OpenBridge?). Usa --port <otro> o `openbridge stop`.');
333
+ return 1;
334
+ }
335
+ throw e;
336
+ }
337
+ log.log('OpenBridge ' + VERSION + ' escuchando en http://' + host + ':' + port + '/chat.php');
338
+
339
+ // Tunel
340
+ let tun = { url: '', provider: 'none', stop: () => {}, pid: 0 };
341
+ if (!flags['no-tunnel'] && (app.tunnel.provider || 'tunnelmole') !== 'none') {
342
+ try {
343
+ log.log('Abriendo tunel (' + app.tunnel.provider + ')...');
344
+ tun = await tunnel.startTunnel(port, app.tunnel.provider, {
345
+ domain: app.tunnel.domain || '',
346
+ log: (m) => log.log(m),
347
+ });
348
+ if (tun.url) {
349
+ log.log('URL publica: ' + tun.url + '/chat.php');
350
+ log.warn('el tunel es publico mientras corre; cerralo con Ctrl+C cuando no lo uses.');
351
+ }
352
+ } catch (e) {
353
+ log.warn('no se pudo abrir el tunel: ' + e.message);
354
+ }
355
+ }
356
+ app.baseUrl = tun.url || ('http://' + host + ':' + port);
357
+
358
+ // Puente (proceso hijo). En POSIX detached crea su propio grupo para poder
359
+ // matar tambien a los opencode que lance.
360
+ const bridgePath = path.join(__dirname, 'bridge', 'bridge.js');
361
+ const bridge = spawn(process.execPath, [bridgePath], {
362
+ cwd: paths.home(),
363
+ env: { ...process.env, OPENBRIDGE_HOME: paths.baseDir() },
364
+ stdio: 'inherit',
365
+ windowsHide: true,
366
+ detached: process.platform !== 'win32',
367
+ });
368
+ bridge.on('exit', (code) => log.log('puente termino (codigo ' + code + ')'));
369
+
370
+ const runtime = {
371
+ pid: process.pid,
372
+ port,
373
+ host,
374
+ bridge: bridgeCfg.bridgeId || '',
375
+ localUrl: 'http://' + host + ':' + port + '/chat.php',
376
+ publicUrl: tun.url ? tun.url + '/chat.php' : '',
377
+ tunnel: tun.provider,
378
+ startedAt: new Date().toISOString(),
379
+ children: [bridge.pid, tun.pid].filter((n) => typeof n === 'number' && n > 0),
380
+ };
381
+ writeRuntime(runtime);
382
+ try { fs.writeFileSync(paths.pidPath(), String(process.pid)); } catch (e) { /* nada */ }
383
+
384
+ let closing = false;
385
+ const shutdown = () => {
386
+ if (closing) return;
387
+ closing = true;
388
+ log.log('Cerrando OpenBridge...');
389
+ try { killPid(bridge.pid); } catch (e) { /* nada */ }
390
+ try { tun.stop(); } catch (e) { /* nada */ }
391
+ try { server.close(); } catch (e) { /* nada */ }
392
+ clearRuntime();
393
+ process.exit(0);
394
+ };
395
+ process.on('SIGINT', shutdown);
396
+ process.on('SIGTERM', shutdown);
397
+ return new Promise(() => { /* corre hasta Ctrl+C */ });
398
+ }
399
+
400
+ // ---------------------------------------------------------------------------
401
+ // stop / status / logs
402
+ // ---------------------------------------------------------------------------
403
+ async function cmdStop() {
404
+ const rt = readRuntime();
405
+ const pid = rt && rt.pid ? rt.pid : parseInt((() => { try { return fs.readFileSync(paths.pidPath(), 'utf8'); } catch (e) { return ''; } })(), 10);
406
+ if (!pid || !pidAlive(pid)) {
407
+ clearRuntime();
408
+ console.log('OpenBridge no esta corriendo.');
409
+ return 0;
410
+ }
411
+ killPid(pid);
412
+ if (rt && Array.isArray(rt.children)) {
413
+ for (const c of rt.children) killPid(c);
414
+ }
415
+ clearRuntime();
416
+ console.log('OpenBridge detenido (pid ' + pid + ').');
417
+ return 0;
418
+ }
419
+
420
+ async function printStatus() {
421
+ const rt = readRuntime();
422
+ if (!rt || !pidAlive(rt.pid)) {
423
+ console.log('OpenBridge: detenido');
424
+ if (paths.exists()) {
425
+ console.log(' carpeta: ' + paths.baseDir());
426
+ console.log(' datos : ' + paths.home());
427
+ }
428
+ return false;
429
+ }
430
+ console.log('OpenBridge: corriendo (pid ' + rt.pid + ')');
431
+ console.log(' local : ' + rt.localUrl);
432
+ if (rt.publicUrl) console.log(' publico: ' + rt.publicUrl);
433
+ console.log(' tunel : ' + rt.tunnel);
434
+ console.log(' inicio : ' + rt.startedAt);
435
+ try {
436
+ const bridgeId = rt.bridge || await store.soleBridgeId();
437
+ const online = bridgeId ? await store.bridgeOnlineLive(bridgeId) : false;
438
+ console.log(' puente : ' + (bridgeId || '(sin id)') + (online ? ' en linea' : ' sin senal'));
439
+ const sessions = await store.sessionsListFull();
440
+ console.log(' chats : ' + sessions.length);
441
+ } catch (e) { /* sin datos todavia */ }
442
+ return true;
443
+ }
444
+
445
+ async function cmdStatus() {
446
+ await printStatus();
447
+ return 0;
448
+ }
449
+
450
+ async function cmdLogs(argv) {
451
+ const { flags } = parseArgs(argv);
452
+ const n = parseInt(flags.n || '40', 10) || 40;
453
+ const files = [];
454
+ if (flags.server) files.push(paths.serverLogPath());
455
+ if (flags.bridge) files.push(paths.bridgeLogPath());
456
+ if (!files.length) files.push(paths.serverLogPath(), paths.bridgeLogPath());
457
+ if (flags.follow) {
458
+ let stopped = false;
459
+ const tails = [];
460
+ for (const file of files) {
461
+ console.log('=== siguiendo ' + file + ' (Ctrl+C para salir) ===');
462
+ let pos = 0;
463
+ try { pos = fs.statSync(file).size; } catch (e) { pos = 0; }
464
+ const t = setInterval(() => {
465
+ try {
466
+ const st = fs.statSync(file);
467
+ if (st.size < pos) pos = 0;
468
+ if (st.size > pos) {
469
+ const fd = fs.openSync(file, 'r');
470
+ const len = st.size - pos;
471
+ const buf = Buffer.alloc(len);
472
+ fs.readSync(fd, buf, 0, len, pos);
473
+ fs.closeSync(fd);
474
+ process.stdout.write(buf.toString('utf8'));
475
+ pos = st.size;
476
+ }
477
+ } catch (e) { /* sin log todavia */ }
478
+ }, 700);
479
+ tails.push(t);
480
+ }
481
+ await new Promise((resolve) => {
482
+ const stop = () => {
483
+ if (stopped) return;
484
+ stopped = true;
485
+ for (const t of tails) clearInterval(t);
486
+ resolve();
487
+ };
488
+ process.on('SIGINT', stop);
489
+ process.on('SIGTERM', stop);
490
+ });
491
+ return 0;
492
+ }
493
+ for (const file of files) {
494
+ console.log('=== ' + file + ' ===');
495
+ try {
496
+ const lines = (await fsp.readFile(file, 'utf8')).split(/\r?\n/);
497
+ console.log(lines.slice(-n).join('\n'));
498
+ } catch (e) {
499
+ console.log('(sin log)');
500
+ }
501
+ console.log('');
502
+ }
503
+ return 0;
504
+ }
505
+
506
+ // ---------------------------------------------------------------------------
507
+ // bridge (solo el puente, para modo hub remoto)
508
+ // ---------------------------------------------------------------------------
509
+ async function cmdBridge(argv) {
510
+ const { flags } = parseArgs(argv);
511
+ if (!paths.exists()) {
512
+ console.error('No hay configuracion en ' + paths.home() + '. Corre primero: openbridge init');
513
+ return 1;
514
+ }
515
+ // Permite apuntar a otro hub sin editar config.json a mano.
516
+ const env = { ...process.env, OPENBRIDGE_HOME: paths.baseDir() };
517
+ if (flags.api) env.OPENBRIDGE_API_URL = String(flags.api);
518
+ if (flags.token) env.OPENBRIDGE_API_TOKEN = String(flags.token);
519
+ if (flags.id) env.OPENBRIDGE_BRIDGE_ID = String(flags.id);
520
+ if (flags.name) env.OPENBRIDGE_BRIDGE_NAME = String(flags.name);
521
+ const bridgePath = path.join(__dirname, 'bridge', 'bridge.js');
522
+ const child = spawn(process.execPath, [bridgePath], {
523
+ cwd: paths.home(),
524
+ env,
525
+ stdio: 'inherit',
526
+ windowsHide: true,
527
+ });
528
+ return new Promise((resolve) => child.on('exit', (code) => resolve(code || 0)));
529
+ }
530
+
531
+ // ---------------------------------------------------------------------------
532
+ // import / reset (datos)
533
+ // ---------------------------------------------------------------------------
534
+ async function cmdImport(argv) {
535
+ const { flags } = parseArgs(argv);
536
+ const src = argv[0] || flags.dir;
537
+ if (!src) {
538
+ console.error('Uso: openbridge import <data-dir> [--force]');
539
+ return 1;
540
+ }
541
+ const srcDir = path.resolve(src);
542
+ let files;
543
+ try {
544
+ files = (await fsp.readdir(srcDir)).filter((f) => f.toLowerCase().endsWith('.json'));
545
+ } catch (e) {
546
+ console.error('No se pudo leer ' + srcDir + ': ' + e.message);
547
+ return 1;
548
+ }
549
+ if (!files.length) {
550
+ console.error('No hay archivos .json en ' + srcDir);
551
+ return 1;
552
+ }
553
+ paths.ensureDirs();
554
+ const destDir = paths.dataDir();
555
+ let copied = 0, skipped = 0;
556
+ for (const f of files) {
557
+ const dest = path.join(destDir, f);
558
+ if (fs.existsSync(dest) && !flags.force) { skipped++; continue; }
559
+ await fsp.copyFile(path.join(srcDir, f), dest);
560
+ copied++;
561
+ }
562
+ console.log('Importados ' + copied + ' archivo(s) a ' + destDir + (skipped ? ' (' + skipped + ' existentes, usa --force)' : ''));
563
+ return 0;
564
+ }
565
+
566
+ async function cmdReset(argv) {
567
+ const { flags } = parseArgs(argv);
568
+ if (!paths.exists()) {
569
+ console.error('No hay configuracion en ' + paths.home() + '. Corre primero: openbridge init');
570
+ return 1;
571
+ }
572
+ if (flags.session) {
573
+ const id = parseInt(flags.session, 10) || 0;
574
+ if (id <= 0) { console.error('Sesion invalida.'); return 1; }
575
+ await store.deleteSession(id);
576
+ try { fs.unlinkSync(paths.messagesFile(id)); } catch (e) { /* nada */ }
577
+ console.log('Sesion ' + id + ' borrada.');
578
+ return 0;
579
+ }
580
+ if (!flags.yes && process.stdin.isTTY) {
581
+ const prompt = createPrompter();
582
+ const answer = await prompt.ask('Esto borra TODOS los chats y datos. Escribi "si" para confirmar', '');
583
+ prompt.close();
584
+ if (answer.toLowerCase() !== 'si' && answer.toLowerCase() !== 'sí') {
585
+ console.log('Cancelado.');
586
+ return 0;
587
+ }
588
+ }
589
+ let removed = 0;
590
+ try {
591
+ for (const f of await fsp.readdir(paths.dataDir())) {
592
+ if (!f.toLowerCase().endsWith('.json')) continue;
593
+ try { await fsp.unlink(path.join(paths.dataDir(), f)); removed++; } catch (e) { /* nada */ }
594
+ }
595
+ } catch (e) { /* sin data */ }
596
+ console.log('Datos reseteados (' + removed + ' archivo(s)).');
597
+ return 0;
598
+ }
599
+
600
+ // ---------------------------------------------------------------------------
601
+ // passwd: cambia la contrasena sin reconfigurar todo
602
+ // ---------------------------------------------------------------------------
603
+ async function cmdPasswd(argv) {
604
+ const { flags } = parseArgs(argv);
605
+ if (!paths.exists()) {
606
+ console.error('No hay configuracion en ' + paths.home() + '. Corre primero: openbridge init');
607
+ return 1;
608
+ }
609
+ const app = config.readApp();
610
+ const interactive = !flags.yes && process.stdin.isTTY;
611
+ let password = flags.password || '';
612
+ if (!password && interactive) password = await askHidden('Nueva contrasena (enter = generar): ');
613
+ let generated = false;
614
+ if (!password) {
615
+ password = config.randomToken(12);
616
+ generated = true;
617
+ }
618
+ if (stopRunningServer()) console.log('Habia un server corriendo; lo detuve para aplicar el cambio.');
619
+ app.password = config.hashPassword(password);
620
+ config.writeApp(app);
621
+ console.log('Contrasena actualizada para "' + app.username + '".');
622
+ console.log(' contrasena: ' + password + (generated ? ' (generada)' : ''));
623
+ console.log(' volve a arrancar: openbridge server');
624
+ return 0;
625
+ }
626
+
627
+ // ---------------------------------------------------------------------------
628
+ // autostart
629
+ // ---------------------------------------------------------------------------
630
+ function autostartPath() {
631
+ if (process.platform === 'win32') {
632
+ const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
633
+ return path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'OpenBridge.lnk');
634
+ }
635
+ if (process.platform === 'darwin') {
636
+ return path.join(os.homedir(), 'Library', 'LaunchAgents', 'net.openbridge.server.plist');
637
+ }
638
+ return path.join(os.homedir(), '.config', 'systemd', 'user', 'openbridge.service');
639
+ }
640
+
641
+ async function cmdAutostart(argv) {
642
+ const action = String(argv[0] || '').toLowerCase();
643
+ if (action !== 'install' && action !== 'remove') {
644
+ console.error('Uso: openbridge autostart install|remove');
645
+ return 1;
646
+ }
647
+ const bin = process.argv[1];
648
+ const node = process.execPath;
649
+ const target = autostartPath();
650
+ if (action === 'remove') {
651
+ try { fs.unlinkSync(target); console.log('autostart quitado: ' + target); }
652
+ catch (e) { console.log('no habia autostart instalado.'); }
653
+ return 0;
654
+ }
655
+ try { fs.mkdirSync(path.dirname(target), { recursive: true }); } catch (e) { /* nada */ }
656
+ const home = paths.baseDir();
657
+ if (process.platform === 'win32') {
658
+ const ps = [
659
+ '$ws = New-Object -ComObject WScript.Shell;',
660
+ '$sc = $ws.CreateShortcut(' + JSON.stringify(target) + ');',
661
+ '$sc.TargetPath = ' + JSON.stringify(node) + ';',
662
+ '$sc.Arguments = ' + JSON.stringify('"' + bin + '" server --stream --no-tunnel') + ';',
663
+ '$sc.WorkingDirectory = ' + JSON.stringify(home) + ';',
664
+ '$sc.WindowStyle = 7;',
665
+ '$sc.Save();',
666
+ ].join(' ');
667
+ const r = spawnSync('powershell', ['-NoProfile', '-Command', ps], { encoding: 'utf8', windowsHide: true });
668
+ if (r.status !== 0) { console.error('No se pudo crear el acceso directo: ' + (r.stderr || '').trim()); return 1; }
669
+ } else if (process.platform === 'darwin') {
670
+ const plist = '<?xml version="1.0" encoding="UTF-8"?>\n'
671
+ + '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
672
+ + '<plist version="1.0"><dict>'
673
+ + '<key>Label</key><string>net.openbridge.server</string>'
674
+ + '<key>ProgramArguments</key><array>'
675
+ + '<string>' + node + '</string><string>' + bin + '</string><string>server</string><string>--stream</string><string>--no-tunnel</string>'
676
+ + '</array>'
677
+ + '<key>WorkingDirectory</key><string>' + home + '</string>'
678
+ + '<key>RunAtLoad</key><true/><key>KeepAlive</key><true/>'
679
+ + '</dict></plist>\n';
680
+ fs.writeFileSync(target, plist);
681
+ } else {
682
+ const unit = '[Unit]\nDescription=OpenBridge\nAfter=network-online.target\n\n'
683
+ + '[Service]\nType=simple\n'
684
+ + 'ExecStart=' + node + ' ' + bin + ' server --stream --no-tunnel\n'
685
+ + 'WorkingDirectory=' + home + '\nRestart=on-failure\n\n'
686
+ + '[Install]\nWantedBy=default.target\n';
687
+ fs.writeFileSync(target, unit);
688
+ spawnSync('systemctl', ['--user', 'daemon-reload'], { windowsHide: true });
689
+ spawnSync('systemctl', ['--user', 'enable', '--now', 'openbridge.service'], { windowsHide: true });
690
+ }
691
+ console.log('autostart instalado: ' + target);
692
+ console.log(' (arranca `openbridge server --stream --no-tunnel` en ' + home + ')');
693
+ return 0;
694
+ }
695
+
696
+ // ---------------------------------------------------------------------------
697
+ // doctor
698
+ // ---------------------------------------------------------------------------
699
+ async function cmdDoctor() {
700
+ const checks = [];
701
+ const nodeMajor = parseInt(process.versions.node.split('.')[0], 10);
702
+ checks.push(['Node ' + process.versions.node, nodeMajor >= 18, nodeMajor >= 18 ? '' : 'requiere Node 18+']);
703
+
704
+ const oc = spawnSync('opencode', ['--version'], { encoding: 'utf8', shell: true });
705
+ const ocOk = oc.status === 0;
706
+ checks.push(['opencode', ocOk, ocOk ? (oc.stdout || '').trim() : 'no se encontro "opencode" en el PATH (npm i -g opencode-ai)']);
707
+
708
+ const cfgOk = paths.exists();
709
+ checks.push(['configuracion (' + paths.home() + ')', cfgOk, cfgOk ? '' : 'corre: openbridge init']);
710
+
711
+ let portFree = true;
712
+ if (cfgOk) {
713
+ const app = config.readApp();
714
+ portFree = await new Promise((resolve) => {
715
+ const net = require('node:net');
716
+ const s = net.createServer();
717
+ s.once('error', () => resolve(false));
718
+ s.once('listening', () => s.close(() => resolve(true)));
719
+ s.listen(app.port, app.host || '127.0.0.1');
720
+ });
721
+ checks.push(['puerto ' + app.port, portFree, portFree ? '' : 'ocupado (¿ya corre OpenBridge?)']);
722
+ }
723
+
724
+ const webpush = (() => { try { require('web-push'); return true; } catch (e) { return false; } })();
725
+ checks.push(['web-push (dependencias)', webpush, webpush ? '' : 'corre: npm install']);
726
+
727
+ let ok = true;
728
+ for (const [name, pass, hint] of checks) {
729
+ console.log((pass ? ' OK ' : ' FALTA') + ' ' + name + (hint ? ' -> ' + hint : ''));
730
+ if (!pass) ok = false;
731
+ }
732
+ return ok ? 0 : 1;
733
+ }
734
+
735
+ // ---------------------------------------------------------------------------
736
+ function usage() {
737
+ console.log('OpenBridge ' + VERSION + ' — tu opencode en el celular, sin hosting');
738
+ console.log('');
739
+ console.log('Uso: openbridge <comando> [opciones]');
740
+ console.log('');
741
+ console.log(' init Configura la casa (workspace, contrasena, tunel)');
742
+ console.log(' server Arranca en segundo plano (muestra el estado al levantar)');
743
+ console.log(' passwd Cambia la contrasena de acceso (--password <clave>)');
744
+ console.log(' stop Detiene el server en segundo plano');
745
+ console.log(' status Estado del server, puente y chats');
746
+ console.log(' logs Ultimas lineas de los logs (--follow --server --bridge)');
747
+ console.log(' bridge Corre solo el puente (--api --token --id --name)');
748
+ console.log(' import Trae data/ de OpenConex (<data-dir> [--force])');
749
+ console.log(' reset Borra todos los chats/datos (--session <id> --yes)');
750
+ console.log(' autostart Instala/quita el arranque automatico (install|remove)');
751
+ console.log(' doctor Verifica Node, opencode, configuracion y puerto');
752
+ console.log('');
753
+ console.log('Opciones comunes: --dir <ruta> (casa portable; default: directorio actual)');
754
+ console.log('init: --workspace --name --id --port --password --tunnel --yes --force');
755
+ console.log('server: --port --no-tunnel --stream (sin --stream corre en segundo plano)');
756
+ }
757
+
758
+ async function main(argv) {
759
+ const args = argv && argv.length ? argv : ['help'];
760
+ const { flags } = parseArgs(args);
761
+ if (flags.dir) paths.setHome(flags.dir);
762
+ const cmd = args[0];
763
+ const skipMigrate = ['help', '-h', '--help', 'version', '-v', '--version'].includes(cmd);
764
+ if (!skipMigrate) {
765
+ const moved = paths.migrate();
766
+ if (moved && moved.length) {
767
+ console.log('Migre la configuracion a ' + paths.home() + ' (' + moved.join(', ') + ')');
768
+ }
769
+ }
770
+ switch (cmd) {
771
+ case 'init': return cmdInit(args.slice(1));
772
+ case 'passwd': case 'password': return cmdPasswd(args.slice(1));
773
+ case 'server': case 'start': return cmdServer(args.slice(1));
774
+ case 'stop': return cmdStop();
775
+ case 'status': return cmdStatus();
776
+ case 'logs': return cmdLogs(args.slice(1));
777
+ case 'bridge': return cmdBridge(args.slice(1));
778
+ case 'import': return cmdImport(args.slice(1));
779
+ case 'reset': return cmdReset(args.slice(1));
780
+ case 'autostart': return cmdAutostart(args.slice(1));
781
+ case 'doctor': return cmdDoctor();
782
+ case 'version': case '-v': case '--version': console.log(VERSION); return 0;
783
+ case 'help': case '-h': case '--help': usage(); return 0;
784
+ default:
785
+ console.error('Comando desconocido: ' + cmd);
786
+ usage();
787
+ return 1;
788
+ }
789
+ }
790
+
791
+ module.exports = { main };