@danieltmn/tamnora 0.8.0 → 0.9.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 (57) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/harness/packages/cli/src/cli.js +1 -1
  3. package/harness/packages/cli/src/commands/bridge.js +180 -119
  4. package/harness/packages/cli/src/commands/server.js +26 -50
  5. package/harness/packages/cli/src/lib/daemon.js +92 -40
  6. package/harness/packages/cli/test/release.test.js +9 -0
  7. package/harness/packages/core/src/agent/modes.js +1 -1
  8. package/harness/packages/core/src/index.js +14 -1
  9. package/harness/packages/core/src/open.js +43 -0
  10. package/harness/packages/core/src/pid.js +35 -0
  11. package/harness/packages/core/src/tools/executors.js +16 -0
  12. package/harness/packages/core/src/tools/labels.js +2 -0
  13. package/harness/packages/core/src/tools/permissions.js +1 -0
  14. package/harness/packages/core/src/tools/schema.js +17 -0
  15. package/harness/packages/core/src/tts/edge.js +232 -0
  16. package/harness/packages/core/src/tunnel.js +180 -19
  17. package/harness/packages/core/test/open.test.js +19 -0
  18. package/harness/packages/core/test/tools.test.js +12 -0
  19. package/harness/packages/core/test/tts.test.js +41 -0
  20. package/harness/packages/server/public/app.js +4715 -3975
  21. package/harness/packages/server/public/index.html +63 -5
  22. package/harness/packages/server/public/reconcile.js +121 -0
  23. package/harness/packages/server/public/relayClient.js +170 -0
  24. package/harness/packages/server/public/runStream.js +207 -0
  25. package/harness/packages/server/public/styles.css +142 -5
  26. package/harness/packages/server/src/auth.js +29 -5
  27. package/harness/packages/server/src/lib/agentRun.js +77 -0
  28. package/harness/packages/server/src/lib/announcer.js +92 -0
  29. package/harness/packages/server/src/lib/approvals.js +25 -3
  30. package/harness/packages/server/src/lib/channels/telegram.js +85 -20
  31. package/harness/packages/server/src/lib/ffmpeg.js +48 -0
  32. package/harness/packages/server/src/lib/procs.js +3 -18
  33. package/harness/packages/server/src/lib/runManager.js +24 -2
  34. package/harness/packages/server/src/lib/sessionView.js +43 -0
  35. package/harness/packages/server/src/lib/tasks.js +4 -9
  36. package/harness/packages/server/src/lib/ttsSettings.js +42 -0
  37. package/harness/packages/server/src/lib/workspace.js +37 -1
  38. package/harness/packages/server/src/routes/chat.js +48 -47
  39. package/harness/packages/server/src/routes/events.js +35 -0
  40. package/harness/packages/server/src/routes/files.js +22 -26
  41. package/harness/packages/server/src/routes/folders.js +9 -17
  42. package/harness/packages/server/src/routes/git.js +6 -14
  43. package/harness/packages/server/src/routes/procs.js +14 -16
  44. package/harness/packages/server/src/routes/sessions.js +12 -19
  45. package/harness/packages/server/src/routes/shell.js +5 -9
  46. package/harness/packages/server/src/routes/state.js +32 -0
  47. package/harness/packages/server/src/routes/tts.js +82 -0
  48. package/harness/packages/server/src/server.js +21 -1
  49. package/harness/packages/server/test/announcer.test.js +51 -0
  50. package/harness/packages/server/test/reconcile.test.js +146 -0
  51. package/harness/packages/server/test/relay.test.js +219 -0
  52. package/harness/packages/server/test/runStream.test.js +245 -0
  53. package/harness/packages/server/test/security.test.js +252 -0
  54. package/harness/packages/server/test/server.test.js +511 -1
  55. package/harness/packages/server/test/telegram-voz.test.js +156 -0
  56. package/harness/packages/server/test/tts.test.js +133 -0
  57. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,89 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
5
5
  El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
6
6
  y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
7
7
 
8
+ ## [0.9.0] - 2026-10-07
9
+
10
+ ### Agregado
11
+
12
+ - **Voz (TTS)**: el agente **narra su progreso** con frases cortas armadas por
13
+ plantillas sobre los eventos del run (nada de leer la respuesta) y las adjunta
14
+ como `say`. La consola las reproduce y Telegram manda una **nota de voz** al
15
+ cerrar el turno. El cliente (`core/src/tts/edge.js`) usa el **WebSocket global
16
+ de Node** contra las voces de Edge Read Aloud: gratis, sin API key ni
17
+ dependencias. Ajustes en la pestaña Voz (`app.json` -> `tts`).
18
+ - **Tool `speak`**: el agente puede pedir leer un texto en voz alta (por ejemplo,
19
+ un resumen en audio cuando se lo pedís). La consola lo reproduce en la tarjeta
20
+ del tool y Telegram lo manda como nota de voz.
21
+ - **`GET /api/state`, la verdad del servidor**: un snapshot chico con
22
+ `running`/`awaiting` de cada sesion y los permisos pendientes, en un solo
23
+ request y **sin mensajes**. Antes la consola necesitaba dos o tres round trips
24
+ por ciclo, y sobre el tunel cada uno es caro y una oportunidad de fallar.
25
+ - **Reconciliador con la logica testeada** (`public/reconcile.js` +
26
+ `test/reconcile.test.js`): `diffState` es puro y decide enganchar, soltar,
27
+ recargar y que permisos son nuevos o resueltos. Los SSE pasaron a ser **atajos
28
+ de latencia**: si se caen se pierde inmediatez, no correctitud. Antes la misma
29
+ decision estaba repartida en el canal global, un sweep y el handler de
30
+ permisos, y se contradijian.
31
+ - **Cadencia adaptativa**: 5 s cuando hay un permiso esperando una decision tuya,
32
+ 10 s con un run activo, 25 s idle.
33
+ - **Banner de permisos** (`#perm-banner`): no descartable, en el composer, con
34
+ `denegar` / `permitir una vez` / `autorizar todo`. Se puede decidir **desde
35
+ cualquier pestana o sesion**, sin estar enganchado al run.
36
+ - **Los argumentos de un permiso se leen en una linea**: `summarizeArgs` (puro,
37
+ en `reconcile.js`) muestra el comando de `bash`, la ruta y el tamano de `write`,
38
+ o la url de `fetch_url`. Antes se volcaba el JSON crudo y al pedir permiso para
39
+ escribir un archivo se veia medio archivo con los `\n` escapados.
40
+ - **Señales cuando la pestana esta en background**: `document.title` con `(⚠ n)`
41
+ y favicon con punto rojo. Con iOS mandando la app a background son las unicas
42
+ que quedan.
43
+ - **El server abre la consola en el navegador**: al quedar escuchando, `tamnora
44
+ server` abre la URL en el navegador predeterminado (tambien cuando ya estaba
45
+ corriendo). Se apaga con `--no-open`. New `core/src/open.js`; en Windows usa
46
+ `rundll32` para no pasar la URL por un shell.
47
+ - **SSE resumible** (`server/public/runStream.js`): el watchdog de silencio (45 s;
48
+ 30 s para el canal global) detecta el stream colgado en el que
49
+ `reader.read()` espera para siempre —tipico en un corte de tunel— y reconecta
50
+ con backoff desde el ultimo cursor, sin repintar el pane.
51
+ - **Canal global de eventos** (`GET /api/events`): un SSE por pestana con
52
+ `run_start`/`run_end`, permisos y un `snapshot`. Sirve para enterarse al
53
+ instante de una tarea pedida desde otro dispositivo.
54
+ - **Varios clientes en la misma sesion**: al pedir una tarea mientras otro cliente
55
+ tiene el turno, el mensaje va a la cola visible en vez de desaparecer; un
56
+ cliente observador ahora dibuja el mensaje del usuario (se emitia antes de
57
+ persistirse).
58
+
59
+ ### Corregido
60
+
61
+ - **Aprobar un permiso ya no lo hace reaparecer en bucle**: `decide()` se
62
+ tragaba el error del `POST` (`.catch(() => {})`). La UI se cerraba igual,
63
+ borraba el permiso local y el siguiente snapshot lo volvia a traer porque el
64
+ server seguia tenerlo pendiente: la tarjeta reaparecia una y otra vez sin
65
+ decir por que. Ahora el fallo **se avisa y se revierte**, y un 404 se
66
+ distingue como "ese permiso ya no aplicaba" en vez de dejarlo esperando.
67
+ - **Los permisos ya no se esconden**: eran estado invisible que solo existia
68
+ dentro del stream SSE de su sesion. Con el celular sin ese stream (o con el
69
+ tunel caido) la IA se quedaba esperando una decision que no aparecia en ningun
70
+ lado, y recargar la pagina dejaba el run bloqueado para siempre. Ahora son
71
+ estado de primera clase y se pueden resolver desde donde estes mirando.
72
+ - **Un permiso ya no desaparece al re-renderizar**: `renderPane` borra el pane
73
+ entero y los permisos no son mensajes, asi que la tarjeta se ve y al primer
74
+ re-render se perdia para siempre. Ahora se repintan.
75
+ - **Los fallos de red se ven**: cada camino era un `.catch(() => null)`, con lo
76
+ que un fallo se veía **exactamente igual** que "no hay nada pendiente" (y por eso
77
+ no se podia diagnosticar nada). Ahora hay backoff, `#sb-sync` con el motivo en
78
+ el tooltip y avisos flotantes.
79
+ - **Un error de la consola ya no se disfraza de corte de red**: si el handler de
80
+ un evento tiraba una excepcion, `streamSse` la propagaba como si fuera una
81
+ caida del transporte y se reintentaba en bucle — el sintoma era un
82
+ "reconectando..." perpetuo que no contaba nada. Ahora se propaga marcado como
83
+ error del handler, no se reintenta y se avisa con el mensaje real.
84
+ - **`tamnora server --stop` podia quedar colgado**: `close()` nunca destruia los
85
+ sockets SSE abiertos, asi que esperaba indefinidamente.
86
+ - **Cierre del server**: la base SQLite se cerraba antes de cortar las
87
+ conexiones, y un run abortado terminaba escribiendo sobre una base cerrada.
88
+ - Se mano `attachRun` sin reconexion: un corte de tunel dejaba el turno en
89
+ "pensando..." para siempre sin error que reintentar.
90
+
8
91
  ## [0.8.0] - 2026-10-06
9
92
 
10
93
  ### Documentacion
@@ -70,7 +70,7 @@ Uso:
70
70
  tamnora run "<prompt>" Ejecutar una tarea headless
71
71
  tamnora eval <dataset.jsonl> Correr un dataset de evaluación
72
72
  tamnora mcp Listar servidores y herramientas MCP
73
- tamnora server [--init|--start|--reload|--stop|--status|--logs] Server en segundo plano
73
+ tamnora server [--init|--start|--reload|--stop|--status|--logs] Server en segundo plano (abre el navegador)
74
74
  tamnora user Usuario local (login del server)
75
75
  tamnora bridge [--start|--reload|--stop|--status] Tunel publico (tunnelmole) + QR
76
76
  tamnora autostart install|remove Arranque automatico del server
@@ -1,16 +1,23 @@
1
- import { spawn } from 'node:child_process';
2
- import { readFileSync, writeFileSync, unlinkSync, appendFileSync } from 'node:fs';
3
- import path from 'node:path';
4
1
  import {
5
- setHome,
6
2
  homeDir,
7
3
  ensureHome,
8
4
  ensureApp,
9
- bridgeStatePath,
10
- bridgeLogPath,
5
+ writeApp,
6
+ readApp,
7
+ randomToken,
11
8
  } from '../../../core/src/index.js';
12
9
  import { parse, num } from '../lib/util.js';
13
- import { killPid, pidAlive } from '../lib/daemon.js';
10
+ import {
11
+ applyHomeOverride,
12
+ killPid,
13
+ pidAlive,
14
+ bridgeState,
15
+ createFileLogger,
16
+ spawnDetached,
17
+ waitForState,
18
+ tailLines,
19
+ bridgeLogPath,
20
+ } from '../lib/daemon.js';
14
21
  import { startTunnel } from '../lib/tunnel.js';
15
22
  import { qrTerminal } from '../lib/qr.js';
16
23
 
@@ -21,6 +28,7 @@ const OPTIONS = {
21
28
  status: { type: 'boolean' },
22
29
  foreground: { type: 'boolean' },
23
30
  stream: { type: 'boolean' },
31
+ insecure: { type: 'boolean' },
24
32
  tunnel: { type: 'string' },
25
33
  port: { type: 'string' },
26
34
  home: { type: 'string' },
@@ -28,26 +36,9 @@ const OPTIONS = {
28
36
  help: { type: 'boolean', short: 'h' },
29
37
  };
30
38
 
31
- function readBridgeState() {
32
- try {
33
- return JSON.parse(readFileSync(bridgeStatePath(), 'utf8'));
34
- } catch {
35
- return null;
36
- }
37
- }
38
-
39
- function writeBridgeState(data) {
40
- ensureHome();
41
- writeFileSync(bridgeStatePath(), `${JSON.stringify(data, null, 2)}\n`);
42
- }
43
-
44
- function clearBridgeState() {
45
- try {
46
- unlinkSync(bridgeStatePath());
47
- } catch {
48
- /* nada */
49
- }
50
- }
39
+ const readBridgeState = () => bridgeState.read();
40
+ const writeBridgeState = (data) => bridgeState.write(data);
41
+ const clearBridgeState = () => bridgeState.clear();
51
42
 
52
43
  export async function bridgeCommand(argv) {
53
44
  const { values } = parse(argv, OPTIONS);
@@ -56,8 +47,7 @@ export async function bridgeCommand(argv) {
56
47
  return 0;
57
48
  }
58
49
  if (values.home) {
59
- setHome(values.home);
60
- process.env.TAMNORA_HOME = path.resolve(values.home);
50
+ applyHomeOverride(values);
61
51
  }
62
52
 
63
53
  if (values.stop) return cmdStop();
@@ -75,9 +65,25 @@ function resolve(values) {
75
65
  return {
76
66
  port: num(values.port, app.port || 4517),
77
67
  provider: String(values.tunnel || app.tunnel?.provider || 'tunnelmole').toLowerCase(),
68
+ app,
78
69
  };
79
70
  }
80
71
 
72
+ /** Asegura la configuracion del relay (url + token) en app.json y la devuelve. */
73
+ function ensureRelayConfig(app) {
74
+ const tunnel = { ...app.tunnel };
75
+ if (!tunnel.relayUrl) {
76
+ tunnel.relayUrl = process.env.TAMNORA_RELAY_URL || 'https://harness.tamnora.com/index.php';
77
+ }
78
+ if (!tunnel.token) {
79
+ tunnel.token = process.env.TAMNORA_RELAY_TOKEN || randomToken(24);
80
+ const current = readApp();
81
+ writeApp({ ...current, tunnel: { ...current.tunnel, token: tunnel.token, relayUrl: tunnel.relayUrl } });
82
+ console.log(`Token de relay nuevo (guardado en app.json): ${tunnel.token}`);
83
+ }
84
+ return tunnel;
85
+ }
86
+
81
87
  async function cmdStart(values) {
82
88
  const { port, provider } = resolve(values);
83
89
  if (provider === 'none') {
@@ -85,6 +91,20 @@ async function cmdStart(values) {
85
91
  return 1;
86
92
  }
87
93
 
94
+ // Exponer el server a Internet sin usuario configurado deja /api/* abierto
95
+ // (incluido /api/chat con tool bash): hay que pedirlo explicito.
96
+ const app = ensureApp();
97
+ if (!app.user?.password && !values.insecure) {
98
+ console.error('No hay usuario configurado: por el tunel cualquiera con la URL tendria acceso total.');
99
+ console.error('Crea uno con: tamnora user --name <usuario> --password <clave>');
100
+ console.error('Si igual queres abrirlo sin proteccion: tamnora bridge --insecure');
101
+ return 1;
102
+ }
103
+ if (provider === 'tunnelmole' || provider === 'tmole') {
104
+ console.log('Aviso: tunnelmole gratuito admite una sola conexion; si abriste el');
105
+ console.log('preview de la consola, cerralo (o el bridge) antes de usar el otro.');
106
+ }
107
+
88
108
  const current = readBridgeState();
89
109
  if (current && pidAlive(current.pid)) {
90
110
  console.log(`Bridge ya esta corriendo (pid ${current.pid}).`);
@@ -96,22 +116,17 @@ async function cmdStart(values) {
96
116
  const args = [bin, 'bridge', '--foreground', '--port', String(port), '--tunnel', provider];
97
117
  if (process.env.TAMNORA_HOME) args.push('--home', process.env.TAMNORA_HOME);
98
118
  if (values.verbose) args.push('--verbose');
99
- const child = spawn(process.execPath, args, {
100
- detached: true,
101
- stdio: 'ignore',
102
- windowsHide: true,
103
- env: { ...process.env, TAMNORA_BRIDGE_CHILD: '1' },
104
- });
105
- child.unref();
106
- let dead = false;
107
- child.on('exit', () => {
108
- dead = true;
109
- });
119
+ const { child, isDead } = spawnDetached(args, { envFlag: 'TAMNORA_BRIDGE_CHILD' });
110
120
  console.log(`Abriendo tunel (${provider}) en el puerto ${port}...`);
111
121
 
112
- const state = await waitForBridge(child.pid, () => dead, 90000);
122
+ const state = await waitForState(() => readBridgeState(), {
123
+ childPid: child.pid,
124
+ isDead,
125
+ timeoutMs: 90000,
126
+ tickMs: 500,
127
+ });
113
128
  if (!state) {
114
- const tail = tailBridgeLog(8);
129
+ const tail = tailLines(bridgeLogPath(), 8);
115
130
  if (tail) console.log(tail);
116
131
  console.log('No pude abrir el tunel. Revisa: tamnora bridge --status');
117
132
  return 1;
@@ -120,64 +135,129 @@ async function cmdStart(values) {
120
135
  return 0;
121
136
  }
122
137
 
138
+ /** URL publica para el celular: la consola agregada al relay con el token. */
139
+ function relayConsoleUrl(extra) {
140
+ const base = String(extra.relayUrl ?? '').replace(/\/+$/, '').replace(/index\.php$/, '').replace(/\/$/, '');
141
+ return `${base}/console/?t=${extra.token}`;
142
+ }
143
+
123
144
  async function runForeground(values) {
124
- const { port, provider } = resolve(values);
125
- const log = (line) => {
126
- const text = `[${new Date().toISOString()}] ${line}`;
127
- try {
128
- ensureHome();
129
- appendFileSync(bridgeLogPath(), `${text}\n`);
130
- } catch {
131
- /* nada */
132
- }
133
- console.log(text);
145
+ const { port, provider, app } = resolve(values);
146
+ const log = createFileLogger(bridgeLogPath());
147
+ const extra = { provider };
148
+ if (provider === 'relay') {
149
+ const relay = ensureRelayConfig(app);
150
+ extra.relayUrl = relay.relayUrl;
151
+ extra.token = relay.token;
152
+ }
153
+
154
+ // Senal de apagado: el supervisor de abajo la consulta entre intentos.
155
+ let interrupted = false;
156
+ let notifyInterrupt = null;
157
+ const interruptPromise = new Promise((r) => {
158
+ notifyInterrupt = r;
159
+ });
160
+ const shutdown = () => {
161
+ interrupted = true;
162
+ notifyInterrupt();
134
163
  };
164
+ process.once('SIGINT', shutdown);
165
+ process.once('SIGTERM', shutdown);
166
+ const sleep = (ms) =>
167
+ new Promise((r) => {
168
+ const t = setTimeout(r, ms);
169
+ interruptPromise.then(() => {
170
+ clearTimeout(t);
171
+ r();
172
+ });
173
+ });
135
174
 
175
+ // Supervision: si el proceso del tunel muere (el servicio gratuito lo hace a
176
+ // menudo), reconecta con backoff y reescribe el estado con la URL nueva.
177
+ let attempt = 0;
136
178
  let tun;
137
- try {
138
- tun = await startTunnel(port, provider, { log, verbose: values.verbose });
139
- } catch (err) {
140
- log(`ERROR: ${err.message}`);
141
- return 1;
142
- }
143
- if (!tun.url) {
144
- log('ERROR: el tunel no devolvio URL.');
145
- return 1;
146
- }
179
+ for (;;) {
180
+ try {
181
+ tun = await startTunnel(port, provider, { log, verbose: values.verbose, ...extra });
182
+ } catch (err) {
183
+ if (interrupted) break;
184
+ log(`ERROR: ${err.message}`);
185
+ return 1;
186
+ }
187
+ if (!tun.url) {
188
+ log('ERROR: el tunel no devolvio URL.');
189
+ return 1;
190
+ }
147
191
 
148
- writeBridgeState({
149
- pid: process.pid,
150
- url: tun.url,
151
- provider: tun.provider,
152
- port,
153
- childPid: tun.pid,
154
- startedAt: new Date().toISOString(),
155
- });
156
- printStatus(readBridgeState(), true);
157
- log(`Tunel publico: ${tun.url}`);
192
+ writeBridgeState({
193
+ pid: process.pid,
194
+ url: tun.url,
195
+ publicUrl: provider === 'relay' ? relayConsoleUrl(extra) : undefined,
196
+ provider: tun.provider,
197
+ port,
198
+ childPid: tun.pid,
199
+ startedAt: new Date().toISOString(),
200
+ });
201
+ printStatus(readBridgeState(), true);
202
+ log(`Tunel publico: ${tun.url}`);
203
+ attempt = 0;
158
204
 
159
- return new Promise((resolve) => {
160
- let closing = false;
161
- const shutdown = () => {
162
- if (closing) return;
163
- closing = true;
164
- try {
165
- tun.stop();
166
- } catch {
167
- /* nada */
205
+ const exitCode = await new Promise((r) => {
206
+ // Health-check cada 60 s: si la URL publica deja de responder dos veces
207
+ // seguidas, matamos el tunel y el supervisor abre otro.
208
+ let fails = 0;
209
+ const probe = async () => {
210
+ const probeUrl = tun.probeUrl ?? `${tun.url}/api/health`;
211
+ try {
212
+ const res = await fetch(probeUrl, { signal: AbortSignal.timeout(8000) });
213
+ if (res.ok) fails = 0;
214
+ else fails += 1;
215
+ } catch {
216
+ fails += 1;
217
+ }
218
+ if (fails >= 2) {
219
+ log(`La URL publica no responde (${tun.url}); reiniciando el tunel...`);
220
+ tun.stop();
221
+ }
222
+ };
223
+ const health = setInterval(probe, 60000);
224
+ health.unref?.();
225
+ tun.child?.once('exit', (code) => {
226
+ clearInterval(health);
227
+ r(code ?? 1);
228
+ });
229
+ if (!tun.child) {
230
+ clearInterval(health);
231
+ r(0);
168
232
  }
169
- clearBridgeState();
170
- resolve(0);
171
- };
172
- process.once('SIGINT', shutdown);
173
- process.once('SIGTERM', shutdown);
174
- });
233
+ interruptPromise.then(() => {
234
+ clearInterval(health);
235
+ r('signal');
236
+ });
237
+ });
238
+
239
+ if (interrupted || exitCode === 'signal') break;
240
+ attempt += 1;
241
+ const wait = Math.min(30000, 2000 * attempt);
242
+ log(`El tunel termino (codigo ${exitCode}); reconectando en ${Math.round(wait / 1000)} s...`);
243
+ await sleep(wait);
244
+ }
245
+
246
+ try {
247
+ tun?.stop?.();
248
+ } catch {
249
+ /* nada */
250
+ }
251
+ clearBridgeState();
252
+ return 0;
175
253
  }
176
254
 
177
255
  async function cmdReload(values) {
256
+ // Leer el estado ANTES de parar: clearBridgeState borra el archivo y despues
257
+ // no habria nada que esperar (el tunel viejo seguia muriendo).
258
+ const previous = readBridgeState();
178
259
  await cmdStop();
179
- const current = readBridgeState();
180
- for (let i = 0; i < 25 && current && pidAlive(current.pid); i++) {
260
+ for (let i = 0; i < 25 && previous?.pid && pidAlive(previous.pid); i++) {
181
261
  await new Promise((r) => setTimeout(r, 200));
182
262
  }
183
263
  return cmdStart(values);
@@ -216,45 +296,21 @@ async function cmdStatus() {
216
296
  }
217
297
 
218
298
  function printStatus(state, withQr = false) {
299
+ const shown = state.publicUrl || state.url;
219
300
  console.log(`Bridge corriendo (pid ${state.pid})`);
220
- console.log(` url : ${state.url}`);
301
+ console.log(` url : ${shown}`);
221
302
  console.log(` proveedor: ${state.provider} puerto local: ${state.port}`);
222
303
  console.log(` casa : ${homeDir()}`);
223
304
  if (withQr && state.url) {
224
- const art = qrTerminal(state.url, 2);
305
+ const art = qrTerminal(shown, 2);
225
306
  if (art) {
226
307
  console.log('');
227
308
  console.log(art);
228
- console.log(` escanea para abrir ${state.url}`);
309
+ console.log(` escanea para abrir ${shown}`);
229
310
  }
230
311
  }
231
312
  }
232
313
 
233
- function tailBridgeLog(n) {
234
- try {
235
- const lines = readFileSync(bridgeLogPath(), 'utf8')
236
- .split(/\r?\n/)
237
- .filter((l) => l.trim() !== '');
238
- return lines.slice(-n).join('\n');
239
- } catch {
240
- return '';
241
- }
242
- }
243
-
244
- function waitForBridge(childPid, isDead, timeoutMs) {
245
- return new Promise((resolve) => {
246
- const start = Date.now();
247
- const tick = () => {
248
- if (isDead()) return resolve(null);
249
- const state = readBridgeState();
250
- if (state && state.pid && pidAlive(state.pid) && (!childPid || state.pid === childPid)) return resolve(state);
251
- if (Date.now() - start > timeoutMs) return resolve(null);
252
- setTimeout(tick, 500);
253
- };
254
- tick();
255
- });
256
- }
257
-
258
314
  function printHelp() {
259
315
  console.log(`Uso: tamnora bridge [accion] [opciones]
260
316
 
@@ -268,7 +324,12 @@ Acciones:
268
324
  --foreground Corre en primer plano (debug)
269
325
 
270
326
  Opciones:
271
- --tunnel <prov> Proveedor (default: el de app.json; hoy: tunnelmole)
327
+ --tunnel <prov> Proveedor (default: el de app.json; hoy: tunnelmole o relay)
272
328
  --port <n> Puerto local a exponer (default: el del server)
273
- --home <path> Casa de Tamnora (default: TAMNORA_HOME o datos del sistema)`);
329
+ --home <path> Casa de Tamnora (default: TAMNORA_HOME o datos del sistema)
330
+ --insecure Permitir el tunel sin usuario configurado (peligroso)
331
+
332
+ Relay propio (app.json -> tunnel: { provider: "relay", relayUrl, token }): URLs
333
+ estables via https://harness.tamnora.com; el QR abre la consola agregada con el
334
+ token ya puesto.`);
274
335
  }
@@ -1,8 +1,6 @@
1
- import { spawn } from 'node:child_process';
2
- import { appendFileSync, writeFileSync, readFileSync } from 'node:fs';
1
+ import { writeFileSync, readFileSync } from 'node:fs';
3
2
  import path from 'node:path';
4
3
  import {
5
- setHome,
6
4
  homeDir,
7
5
  ensureHome,
8
6
  homeExists,
@@ -12,10 +10,12 @@ import {
12
10
  databasePath,
13
11
  pidPath,
14
12
  serverLogPath,
13
+ openBrowser,
15
14
  } from '../../../core/src/index.js';
16
15
  import { createTamnoraServer } from '../../../server/src/index.js';
17
16
  import { parse, num } from '../lib/util.js';
18
17
  import {
18
+ applyHomeOverride,
19
19
  killPid,
20
20
  pidAlive,
21
21
  readRuntime,
@@ -23,6 +23,9 @@ import {
23
23
  clearRuntime,
24
24
  stopRunningServer,
25
25
  portHint,
26
+ createFileLogger,
27
+ spawnDetached,
28
+ waitForState,
26
29
  tailLines,
27
30
  } from '../lib/daemon.js';
28
31
 
@@ -42,29 +45,16 @@ const OPTIONS = {
42
45
  dir: { type: 'string' },
43
46
  home: { type: 'string' },
44
47
  force: { type: 'boolean' },
48
+ 'no-open': { type: 'boolean' },
45
49
  yes: { type: 'boolean', short: 'y' },
46
50
  help: { type: 'boolean', short: 'h' },
47
51
  };
48
52
 
49
53
  function applyHome(values) {
50
- const override = values.home ? path.resolve(values.home) : process.env.TAMNORA_HOME || null;
51
- if (values.home) {
52
- setHome(values.home);
53
- process.env.TAMNORA_HOME = override;
54
- }
55
- return override;
54
+ applyHomeOverride(values);
56
55
  }
57
56
 
58
- function log(line) {
59
- const text = `[${new Date().toISOString()}] ${line}`;
60
- try {
61
- ensureHome();
62
- appendFileSync(serverLogPath(), `${text}\n`);
63
- } catch {
64
- /* sin log no detenemos nada */
65
- }
66
- console.log(text);
67
- }
57
+ const log = createFileLogger(serverLogPath());
68
58
 
69
59
  export async function serverCommand(argv) {
70
60
  const { values } = parse(argv, OPTIONS);
@@ -118,34 +108,29 @@ async function cmdStart(values) {
118
108
  const isChild = Boolean(process.env.TAMNORA_DETACHED);
119
109
  const foreground = Boolean(values.stream || values.foreground) || isChild;
120
110
 
121
- if (!isChild) {
111
+ if (!isChild) {
122
112
  const cur = readRuntime();
123
113
  if (cur && pidAlive(cur.pid)) {
124
114
  console.log(`Tamnora ya esta corriendo (pid ${cur.pid}).`);
125
115
  printStatus(cur);
116
+ // Aprovechamos que ya esta sirviendo para abrir la consola.
117
+ if (!values['no-open']) await openBrowser(cur.url, { log: (line) => console.warn(line) });
126
118
  return 0;
127
119
  }
128
120
  }
129
121
 
130
- if (!foreground) {
122
+ if (!foreground) {
131
123
  const bin = process.argv[1];
132
124
  const args = [bin, 'server', '--stream', '--port', String(port), '--host', host];
133
125
  if (values.dir) args.push('--dir', cwd);
134
126
  if (process.env.TAMNORA_HOME) args.push('--home', process.env.TAMNORA_HOME);
135
127
  if (values.yes) args.push('--yes');
136
- const child = spawn(process.execPath, args, {
137
- detached: true,
138
- stdio: 'ignore',
139
- windowsHide: true,
140
- env: { ...process.env, TAMNORA_DETACHED: '1' },
141
- });
142
- child.unref();
143
- let dead = false;
144
- child.on('exit', () => {
145
- dead = true;
146
- });
128
+ // El hijo es quien.listen() y por lo tanto quien abre el navegador: el flag
129
+ // tiene que viajar con el o `--no-open` no serviria de nada.
130
+ if (values['no-open']) args.push('--no-open');
131
+ const { child, isDead } = spawnDetached(args, { envFlag: 'TAMNORA_DETACHED' });
147
132
  console.log(`Tamnora arrancando en segundo plano (pid ${child.pid})...`);
148
- const rt = await waitForRuntime(child.pid, () => dead, 30000);
133
+ const rt = await waitForState(() => readRuntime(), { childPid: child.pid, isDead, timeoutMs: 30000, tickMs: 400 });
149
134
  const tail = rt ? '' : tailLines(serverLogPath(), 8);
150
135
  if (tail) {
151
136
  console.log('');
@@ -202,10 +187,13 @@ async function runForeground({ values, port, host, cwd }) {
202
187
  } catch {
203
188
  /* nada */
204
189
  }
205
- log(`Tamnora escuchando en ${info.url} (workspace ${cwd})`);
206
- if (!instance.ctx.authEnabled) {
190
+ log(`Tamnora escuchando en ${info.url} (workspace ${cwd})`);
191
+ if (!instance.ctx.authEnabled) {
207
192
  log('aviso: sin usuario configurado; el server no pide login. Crea uno: tamnora user --name <n> --password <p>');
208
193
  }
194
+ // Abrir la consola es lo esperable al arrancar. Solo cuando queda escuchando:
195
+ // si hubo EADDRINUSE ya hay una consola abierta sirviendo.
196
+ if (!values['no-open']) await openBrowser(info.url, { log: (line) => log(line) });
209
197
 
210
198
  return new Promise((resolve) => {
211
199
  let closing = false;
@@ -247,8 +235,9 @@ async function cmdStop() {
247
235
  }
248
236
 
249
237
  async function cmdReload(values) {
238
+ const previous = readRuntime();
250
239
  await cmdStop();
251
- for (let i = 0; i < 25 && pidAlive(readPid()); i++) {
240
+ for (let i = 0; i < 25 && previous?.pid && pidAlive(previous.pid); i++) {
252
241
  await new Promise((r) => setTimeout(r, 200));
253
242
  }
254
243
  return cmdStart(values);
@@ -308,20 +297,6 @@ function readPid() {
308
297
  }
309
298
  }
310
299
 
311
- function waitForRuntime(childPid, isDead, timeoutMs) {
312
- return new Promise((resolve) => {
313
- const start = Date.now();
314
- const tick = () => {
315
- if (isDead()) return resolve(null);
316
- const rt = readRuntime();
317
- if (rt && rt.pid && pidAlive(rt.pid) && (!childPid || rt.pid === childPid)) return resolve(rt);
318
- if (Date.now() - start > timeoutMs) return resolve(null);
319
- setTimeout(tick, 400);
320
- };
321
- tick();
322
- });
323
- }
324
-
325
300
  function printHelp() {
326
301
  console.log(`Uso: tamnora server [accion] [opciones]
327
302
 
@@ -341,5 +316,6 @@ Opciones:
341
316
  --host <addr> Interfaz (default: 127.0.0.1)
342
317
  --dir <path> Workspace del server
343
318
  --home <path> Casa de Tamnora (default: TAMNORA_HOME o datos del sistema)
319
+ --no-open No abrir la consola en el navegador al arrancar
344
320
  -y, --yes Auto-aprobar tools con permiso "ask"`);
345
321
  }