@danieltmn/tamnora 0.8.0 → 0.9.1
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/CHANGELOG.md +89 -0
- package/harness/packages/cli/src/cli.js +1 -1
- package/harness/packages/cli/src/commands/bridge.js +180 -119
- package/harness/packages/cli/src/commands/server.js +26 -50
- package/harness/packages/cli/src/lib/daemon.js +92 -40
- package/harness/packages/cli/test/release.test.js +9 -0
- package/harness/packages/core/src/agent/modes.js +1 -1
- package/harness/packages/core/src/index.js +14 -1
- package/harness/packages/core/src/open.js +43 -0
- package/harness/packages/core/src/pid.js +35 -0
- package/harness/packages/core/src/tools/executors.js +16 -0
- package/harness/packages/core/src/tools/labels.js +2 -0
- package/harness/packages/core/src/tools/permissions.js +1 -0
- package/harness/packages/core/src/tools/schema.js +17 -0
- package/harness/packages/core/src/tts/edge.js +232 -0
- package/harness/packages/core/src/tunnel.js +180 -19
- package/harness/packages/core/test/open.test.js +19 -0
- package/harness/packages/core/test/tools.test.js +12 -0
- package/harness/packages/core/test/tts.test.js +41 -0
- package/harness/packages/server/public/app.js +4665 -3975
- package/harness/packages/server/public/index.html +62 -5
- package/harness/packages/server/public/reconcile.js +121 -0
- package/harness/packages/server/public/relayClient.js +170 -0
- package/harness/packages/server/public/runStream.js +207 -0
- package/harness/packages/server/public/styles.css +142 -5
- package/harness/packages/server/src/auth.js +29 -5
- package/harness/packages/server/src/lib/agentRun.js +77 -0
- package/harness/packages/server/src/lib/announcer.js +92 -0
- package/harness/packages/server/src/lib/approvals.js +25 -3
- package/harness/packages/server/src/lib/channels/telegram.js +85 -20
- package/harness/packages/server/src/lib/ffmpeg.js +48 -0
- package/harness/packages/server/src/lib/procs.js +3 -18
- package/harness/packages/server/src/lib/runManager.js +24 -2
- package/harness/packages/server/src/lib/sessionView.js +43 -0
- package/harness/packages/server/src/lib/tasks.js +4 -9
- package/harness/packages/server/src/lib/ttsSettings.js +42 -0
- package/harness/packages/server/src/lib/workspace.js +37 -1
- package/harness/packages/server/src/routes/chat.js +48 -47
- package/harness/packages/server/src/routes/events.js +35 -0
- package/harness/packages/server/src/routes/files.js +22 -26
- package/harness/packages/server/src/routes/folders.js +9 -17
- package/harness/packages/server/src/routes/git.js +6 -14
- package/harness/packages/server/src/routes/procs.js +14 -16
- package/harness/packages/server/src/routes/sessions.js +12 -19
- package/harness/packages/server/src/routes/shell.js +5 -9
- package/harness/packages/server/src/routes/state.js +32 -0
- package/harness/packages/server/src/routes/tts.js +82 -0
- package/harness/packages/server/src/server.js +21 -1
- package/harness/packages/server/test/announcer.test.js +51 -0
- package/harness/packages/server/test/reconcile.test.js +146 -0
- package/harness/packages/server/test/relay.test.js +219 -0
- package/harness/packages/server/test/runStream.test.js +245 -0
- package/harness/packages/server/test/security.test.js +252 -0
- package/harness/packages/server/test/server.test.js +511 -1
- package/harness/packages/server/test/telegram-voz.test.js +156 -0
- package/harness/packages/server/test/tts.test.js +133 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,95 @@ 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.1] - 2026-10-07
|
|
9
|
+
|
|
10
|
+
### Cambios
|
|
11
|
+
|
|
12
|
+
- fix(consola): no borrar el progreso del agente y permiso solo en el chat
|
|
13
|
+
|
|
14
|
+
## [0.9.0] - 2026-10-07
|
|
15
|
+
|
|
16
|
+
### Agregado
|
|
17
|
+
|
|
18
|
+
- **Voz (TTS)**: el agente **narra su progreso** con frases cortas armadas por
|
|
19
|
+
plantillas sobre los eventos del run (nada de leer la respuesta) y las adjunta
|
|
20
|
+
como `say`. La consola las reproduce y Telegram manda una **nota de voz** al
|
|
21
|
+
cerrar el turno. El cliente (`core/src/tts/edge.js`) usa el **WebSocket global
|
|
22
|
+
de Node** contra las voces de Edge Read Aloud: gratis, sin API key ni
|
|
23
|
+
dependencias. Ajustes en la pestaña Voz (`app.json` -> `tts`).
|
|
24
|
+
- **Tool `speak`**: el agente puede pedir leer un texto en voz alta (por ejemplo,
|
|
25
|
+
un resumen en audio cuando se lo pedís). La consola lo reproduce en la tarjeta
|
|
26
|
+
del tool y Telegram lo manda como nota de voz.
|
|
27
|
+
- **`GET /api/state`, la verdad del servidor**: un snapshot chico con
|
|
28
|
+
`running`/`awaiting` de cada sesion y los permisos pendientes, en un solo
|
|
29
|
+
request y **sin mensajes**. Antes la consola necesitaba dos o tres round trips
|
|
30
|
+
por ciclo, y sobre el tunel cada uno es caro y una oportunidad de fallar.
|
|
31
|
+
- **Reconciliador con la logica testeada** (`public/reconcile.js` +
|
|
32
|
+
`test/reconcile.test.js`): `diffState` es puro y decide enganchar, soltar,
|
|
33
|
+
recargar y que permisos son nuevos o resueltos. Los SSE pasaron a ser **atajos
|
|
34
|
+
de latencia**: si se caen se pierde inmediatez, no correctitud. Antes la misma
|
|
35
|
+
decision estaba repartida en el canal global, un sweep y el handler de
|
|
36
|
+
permisos, y se contradijian.
|
|
37
|
+
- **Cadencia adaptativa**: 5 s cuando hay un permiso esperando una decision tuya,
|
|
38
|
+
10 s con un run activo, 25 s idle.
|
|
39
|
+
- **Banner de permisos** (`#perm-banner`): no descartable, en el composer, con
|
|
40
|
+
`denegar` / `permitir una vez` / `autorizar todo`. Se puede decidir **desde
|
|
41
|
+
cualquier pestana o sesion**, sin estar enganchado al run.
|
|
42
|
+
- **Los argumentos de un permiso se leen en una linea**: `summarizeArgs` (puro,
|
|
43
|
+
en `reconcile.js`) muestra el comando de `bash`, la ruta y el tamano de `write`,
|
|
44
|
+
o la url de `fetch_url`. Antes se volcaba el JSON crudo y al pedir permiso para
|
|
45
|
+
escribir un archivo se veia medio archivo con los `\n` escapados.
|
|
46
|
+
- **Señales cuando la pestana esta en background**: `document.title` con `(⚠ n)`
|
|
47
|
+
y favicon con punto rojo. Con iOS mandando la app a background son las unicas
|
|
48
|
+
que quedan.
|
|
49
|
+
- **El server abre la consola en el navegador**: al quedar escuchando, `tamnora
|
|
50
|
+
server` abre la URL en el navegador predeterminado (tambien cuando ya estaba
|
|
51
|
+
corriendo). Se apaga con `--no-open`. New `core/src/open.js`; en Windows usa
|
|
52
|
+
`rundll32` para no pasar la URL por un shell.
|
|
53
|
+
- **SSE resumible** (`server/public/runStream.js`): el watchdog de silencio (45 s;
|
|
54
|
+
30 s para el canal global) detecta el stream colgado en el que
|
|
55
|
+
`reader.read()` espera para siempre —tipico en un corte de tunel— y reconecta
|
|
56
|
+
con backoff desde el ultimo cursor, sin repintar el pane.
|
|
57
|
+
- **Canal global de eventos** (`GET /api/events`): un SSE por pestana con
|
|
58
|
+
`run_start`/`run_end`, permisos y un `snapshot`. Sirve para enterarse al
|
|
59
|
+
instante de una tarea pedida desde otro dispositivo.
|
|
60
|
+
- **Varios clientes en la misma sesion**: al pedir una tarea mientras otro cliente
|
|
61
|
+
tiene el turno, el mensaje va a la cola visible en vez de desaparecer; un
|
|
62
|
+
cliente observador ahora dibuja el mensaje del usuario (se emitia antes de
|
|
63
|
+
persistirse).
|
|
64
|
+
|
|
65
|
+
### Corregido
|
|
66
|
+
|
|
67
|
+
- **Aprobar un permiso ya no lo hace reaparecer en bucle**: `decide()` se
|
|
68
|
+
tragaba el error del `POST` (`.catch(() => {})`). La UI se cerraba igual,
|
|
69
|
+
borraba el permiso local y el siguiente snapshot lo volvia a traer porque el
|
|
70
|
+
server seguia tenerlo pendiente: la tarjeta reaparecia una y otra vez sin
|
|
71
|
+
decir por que. Ahora el fallo **se avisa y se revierte**, y un 404 se
|
|
72
|
+
distingue como "ese permiso ya no aplicaba" en vez de dejarlo esperando.
|
|
73
|
+
- **Los permisos ya no se esconden**: eran estado invisible que solo existia
|
|
74
|
+
dentro del stream SSE de su sesion. Con el celular sin ese stream (o con el
|
|
75
|
+
tunel caido) la IA se quedaba esperando una decision que no aparecia en ningun
|
|
76
|
+
lado, y recargar la pagina dejaba el run bloqueado para siempre. Ahora son
|
|
77
|
+
estado de primera clase y se pueden resolver desde donde estes mirando.
|
|
78
|
+
- **Un permiso ya no desaparece al re-renderizar**: `renderPane` borra el pane
|
|
79
|
+
entero y los permisos no son mensajes, asi que la tarjeta se ve y al primer
|
|
80
|
+
re-render se perdia para siempre. Ahora se repintan.
|
|
81
|
+
- **Los fallos de red se ven**: cada camino era un `.catch(() => null)`, con lo
|
|
82
|
+
que un fallo se veía **exactamente igual** que "no hay nada pendiente" (y por eso
|
|
83
|
+
no se podia diagnosticar nada). Ahora hay backoff, `#sb-sync` con el motivo en
|
|
84
|
+
el tooltip y avisos flotantes.
|
|
85
|
+
- **Un error de la consola ya no se disfraza de corte de red**: si el handler de
|
|
86
|
+
un evento tiraba una excepcion, `streamSse` la propagaba como si fuera una
|
|
87
|
+
caida del transporte y se reintentaba en bucle — el sintoma era un
|
|
88
|
+
"reconectando..." perpetuo que no contaba nada. Ahora se propaga marcado como
|
|
89
|
+
error del handler, no se reintenta y se avisa con el mensaje real.
|
|
90
|
+
- **`tamnora server --stop` podia quedar colgado**: `close()` nunca destruia los
|
|
91
|
+
sockets SSE abiertos, asi que esperaba indefinidamente.
|
|
92
|
+
- **Cierre del server**: la base SQLite se cerraba antes de cortar las
|
|
93
|
+
conexiones, y un run abortado terminaba escribiendo sobre una base cerrada.
|
|
94
|
+
- Se mano `attachRun` sin reconexion: un corte de tunel dejaba el turno en
|
|
95
|
+
"pensando..." para siempre sin error que reintentar.
|
|
96
|
+
|
|
8
97
|
## [0.8.0] - 2026-10-06
|
|
9
98
|
|
|
10
99
|
### 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
|
-
|
|
10
|
-
|
|
5
|
+
writeApp,
|
|
6
|
+
readApp,
|
|
7
|
+
randomToken,
|
|
11
8
|
} from '../../../core/src/index.js';
|
|
12
9
|
import { parse, num } from '../lib/util.js';
|
|
13
|
-
import {
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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 = (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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 : ${
|
|
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(
|
|
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 ${
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
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
|
-
|
|
206
|
-
|
|
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(
|
|
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
|
}
|