@danieltmn/tamnora 0.23.0 → 0.23.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 CHANGED
@@ -5,6 +5,12 @@ 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.23.1] - 2026-10-28
9
+
10
+ ### Cambios
11
+
12
+ - fix(spawn): evita el warning DEP0190 y hace puntual el stop del bridge
13
+
8
14
  ## [0.23.0] - 2026-10-28
9
15
 
10
16
  ### Cambios
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  // node:sqlite sigue siendo "experimental" en Node 22, que avisa aunque ande. El
3
- // aviso no le aporta nada al usuario, asi que se filtra aca (lo que no sea un
4
- // warning de Node se imprime igual).
3
+ // aviso no le aporta nada al usuario, asi que se filtra aca. Se quita el
4
+ // impresor default de Node para que lo que si se imprime (lo que no sea
5
+ // ExperimentalWarning) salga una sola vez, no dos.
6
+ process.removeAllListeners('warning');
5
7
  process.on('warning', (warning) => {
6
8
  if (warning.name !== 'ExperimentalWarning') console.warn(warning.stack ?? warning);
7
9
  });
@@ -11,7 +11,7 @@ import {
11
11
  tailLines,
12
12
  bridgeLogPath,
13
13
  } from '../lib/daemon.js';
14
- import { startTunnel, probeTailscale, resetTailscaleServe } from '../lib/tunnel.js';
14
+ import { startTunnel, probeTailscale, stopTailscaleServe } from '../lib/tunnel.js';
15
15
  import { qrTerminal } from '../lib/qr.js';
16
16
 
17
17
  const OPTIONS = {
@@ -193,10 +193,10 @@ async function cmdStop() {
193
193
  return 0;
194
194
  }
195
195
  try {
196
- await resetTailscaleServe();
196
+ await stopTailscaleServe();
197
197
  stopped.push('tailscale serve');
198
198
  } catch (error) {
199
- console.log(`No pude ejecutar tailscale serve reset: ${error.message}`);
199
+ console.log(`No pude detener tailscale serve: ${error.message}`);
200
200
  }
201
201
  // Mata el supervisor si quedo vivo.
202
202
  if (state.pid && pidAlive(state.pid)) {
@@ -16,8 +16,15 @@ const OPTIONS = {
16
16
  help: { type: 'boolean', short: 'h' },
17
17
  };
18
18
 
19
+ // Con `shell: true` (npm.cmd en Windows) Node concatena los args sin escapar
20
+ // (warning DEP0190): se manda un unico comando con args internos ya validados.
21
+ function npmSpawn(args, extra = {}) {
22
+ const [cmd, cmdArgs] = IS_WIN ? [[NPM, ...args].join(' '), []] : [NPM, args];
23
+ return spawnSync(cmd, cmdArgs, { windowsHide: true, shell: IS_WIN, timeout: 180000, ...extra });
24
+ }
25
+
19
26
  function npm(args) {
20
- return spawnSync(NPM, args, { encoding: 'utf8', windowsHide: true, shell: IS_WIN, timeout: 180000 });
27
+ return npmSpawn(args, { encoding: 'utf8' });
21
28
  }
22
29
 
23
30
  /** Version del paquete instalado globalmente (la que resuelve `npm ls -g`). */
@@ -78,6 +85,10 @@ export async function updateCommand(argv) {
78
85
  }
79
86
 
80
87
  const tag = typeof values.tag === 'string' && values.tag ? values.tag : 'latest';
88
+ if (!/^[\w.-]+$/.test(tag)) {
89
+ console.error(`Tag invalido: ${tag}`);
90
+ return 1;
91
+ }
81
92
  const spec = `${PACKAGE}@${tag}`;
82
93
  const version = readPackageVersion();
83
94
  console.log(`Version actual: ${version}`);
@@ -115,7 +126,7 @@ export async function updateCommand(argv) {
115
126
  }
116
127
 
117
128
  console.log(`Actualizando a ${spec}...`);
118
- const result = spawnSync(NPM, ['i', '-g', spec], { stdio: 'inherit', windowsHide: true, shell: IS_WIN });
129
+ const result = npmSpawn(['i', '-g', spec], { stdio: 'inherit' });
119
130
  if (result.status !== 0) {
120
131
  console.error(`La actualizacion fallo. Proba a mano: npm i -g ${spec}`);
121
132
  if (wasRunning) restartServer(wasRunning);
@@ -0,0 +1,27 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { mkdtemp, writeFile } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const BIN = fileURLToPath(new URL('../bin/tamnora.mjs', import.meta.url));
10
+
11
+ // El bin filtra el ExperimentalWarning de node:sqlite, pero no debe duplicar el
12
+ // resto: sin quitar el impresor default de Node, cada warning salia dos veces.
13
+ // El probe difiere el warning para que salte con el handler del bin ya instalado.
14
+ test('el bin imprime cada warning una sola vez', async () => {
15
+ const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-bin-'));
16
+ const probe = path.join(dir, 'probe.cjs');
17
+ await writeFile(probe, `setTimeout(() => process.emitWarning('aviso de prueba', 'CustomWarning'), 100);`);
18
+ const res = spawnSync(process.execPath, ['--require', probe, BIN, '--help'], {
19
+ encoding: 'utf8',
20
+ windowsHide: true,
21
+ timeout: 30000,
22
+ });
23
+ const out = `${res.stdout}\n${res.stderr}`;
24
+ const count = out.split('aviso de prueba').length - 1;
25
+ assert.equal(count, 1, `se esperaba 1 impresion del warning, hubo ${count}`);
26
+ assert.equal(res.status, 0);
27
+ });
@@ -1,8 +1,26 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { parseTunnelmoleUrls, parseTailscaleStatus, startTunnel, tailscalePreviewUrl } from '../src/lib/tunnel.js';
3
+ import { EventEmitter } from 'node:events';
4
+ import {
5
+ parseTunnelmoleUrls,
6
+ parseTailscaleStatus,
7
+ startTunnel,
8
+ startTunnelmole,
9
+ stopTailscaleServe,
10
+ tailscalePreviewUrl,
11
+ } from '../src/lib/tunnel.js';
4
12
  import { qrTerminal } from '../src/lib/qr.js';
5
13
 
14
+ function fakeChild(exitCode = 0) {
15
+ const child = new EventEmitter();
16
+ child.stdout = new EventEmitter();
17
+ child.stderr = new EventEmitter();
18
+ child.pid = 0;
19
+ child.killed = false;
20
+ setImmediate(() => child.emit('exit', exitCode));
21
+ return child;
22
+ }
23
+
6
24
  test('parseTunnelmoleUrls extracts the https URL', () => {
7
25
  const out = parseTunnelmoleUrls('Listening at https://abc-123.tunnelmole.net (forwarding to 4517)');
8
26
  assert.equal(out.https, 'https://abc-123.tunnelmole.net');
@@ -38,6 +56,36 @@ test('startTunnel con provider none no abre nada', async () => {
38
56
  assert.equal(tun.url, '');
39
57
  });
40
58
 
59
+ test('stopTailscaleServe apaga solo el handler 443 y lanza tailscale sin shell', async () => {
60
+ const calls = [];
61
+ const spawnImpl = (cmd, args, options) => {
62
+ calls.push({ cmd, args, options });
63
+ return fakeChild();
64
+ };
65
+ await stopTailscaleServe({ spawnImpl });
66
+ assert.equal(calls.length, 1);
67
+ assert.equal(calls[0].cmd, 'tailscale');
68
+ assert.deepEqual(calls[0].args, ['serve', '--https=443', 'off']);
69
+ assert.equal(calls[0].options.shell, undefined);
70
+ });
71
+
72
+ test(
73
+ 'startTunnelmole con shell manda un unico comando (Node no concatena args)',
74
+ { skip: process.platform !== 'win32' },
75
+ async () => {
76
+ const calls = [];
77
+ const spawnImpl = (cmd, args, options) => {
78
+ calls.push({ cmd, args, options });
79
+ return fakeChild(1);
80
+ };
81
+ await assert.rejects(() => startTunnelmole(4517, { spawnImpl }), /termino/);
82
+ assert.equal(calls.length, 1);
83
+ assert.equal(calls[0].cmd, 'npx --yes tunnelmole 4517');
84
+ assert.deepEqual(calls[0].args, []);
85
+ assert.equal(calls[0].options.shell, true);
86
+ }
87
+ );
88
+
41
89
  test('tailscalePreviewUrl usa el 8443 y omite el 443', () => {
42
90
  assert.equal(tailscalePreviewUrl('pc.tailnet.ts.net'), 'https://pc.tailnet.ts.net:8443');
43
91
  assert.equal(tailscalePreviewUrl('pc.tailnet.ts.net', 9000), 'https://pc.tailnet.ts.net:9000');
@@ -150,7 +150,7 @@ export {
150
150
  parseTunnelmoleUrls,
151
151
  parseTailscaleStatus,
152
152
  probeTailscale,
153
- resetTailscaleServe,
153
+ stopTailscaleServe,
154
154
  startTailscaleServe,
155
155
  startTailscalePreview,
156
156
  startTunnelmole,
@@ -71,14 +71,19 @@ function spawnTunnel(bin, args, opts = {}) {
71
71
  const parse = opts.parse || (() => ({ https: '', http: '' }));
72
72
  const timeoutMs = opts.timeoutMs || 90000;
73
73
  const label = opts.label || bin;
74
+ const useShell = process.platform === 'win32';
74
75
  return new Promise((resolve, reject) => {
75
76
  let child;
76
77
  try {
77
- child = spawn(bin, args, {
78
+ // En Windows `npx` es un shim .cmd y exige shell; ahi se manda un unico
79
+ // comando (ver shellCommandLine): con array de args Node concatena sin
80
+ // escapar y emite el warning DEP0190.
81
+ const [cmd, cmdArgs] = useShell ? [shellCommandLine(bin, args), []] : [bin, args];
82
+ child = (opts.spawnImpl || spawn)(cmd, cmdArgs, {
78
83
  stdio: ['ignore', 'pipe', 'pipe'],
79
84
  windowsHide: true,
80
- shell: process.platform === 'win32',
81
- detached: process.platform !== 'win32',
85
+ shell: useShell,
86
+ detached: !useShell,
82
87
  });
83
88
  } catch (err) {
84
89
  reject(new Error(`no se pudo iniciar ${label}: ${err.message}`));
@@ -138,15 +143,29 @@ export function startTunnelmole(port, opts = {}) {
138
143
  * tailnet reenviando a loopback, asi que no hay exposicion publica y las
139
144
  * peticiones siguen siendo "locales" para el server. Proveedor recomendado.
140
145
  */
146
+ // Con `shell: true` Node concatena los args sin escapar (warning DEP0190): se
147
+ // arma un unico comando con args validados contra metacaracteres del shell.
148
+ const SAFE_SHELL_ARG = /^[^&|<>^%!"\r\n]+$/;
149
+
150
+ function shellCommandLine(bin, args) {
151
+ for (const arg of args) {
152
+ if (!SAFE_SHELL_ARG.test(String(arg))) {
153
+ throw new Error(`argumento no permitido para ${bin}: "${arg}"`);
154
+ }
155
+ }
156
+ return [bin, ...args].join(' ');
157
+ }
158
+
141
159
  function runTailscale(args, opts = {}) {
142
160
  const timeoutMs = opts.timeoutMs || 15000;
143
161
  return new Promise((resolve, reject) => {
144
162
  let child;
145
163
  try {
146
- child = spawn('tailscale', args, {
164
+ // `tailscale` es un ejecutable real (.exe en Windows): resuelve por PATH
165
+ // sin shell, que ademas evita el warning DEP0190 de Node.
166
+ child = (opts.spawnImpl || spawn)('tailscale', args, {
147
167
  stdio: ['ignore', 'pipe', 'pipe'],
148
168
  windowsHide: true,
149
- shell: process.platform === 'win32',
150
169
  });
151
170
  } catch (err) {
152
171
  reject(err);
@@ -241,7 +260,7 @@ export async function startTailscaleServe(port, opts = {}) {
241
260
  pid: 0,
242
261
  child: null,
243
262
  probeUrl: `${url}/api/health`,
244
- stop: () => runTailscale(['serve', 'reset']).catch(() => {}),
263
+ stop: () => stopTailscaleServe().catch(() => {}),
245
264
  };
246
265
  }
247
266
 
@@ -279,9 +298,13 @@ export async function probeTailscale() {
279
298
  return parseTailscaleStatus(stdout);
280
299
  }
281
300
 
282
- /** Quita toda la configuracion de `tailscale serve` de este nodo. */
283
- export function resetTailscaleServe() {
284
- return runTailscale(['serve', 'reset'], { timeoutMs: 30000 });
301
+ /**
302
+ * Apaga el handler `serve` de la consola (https 443) de forma puntual: un
303
+ * `serve reset` borraria tambien los otros handlers (p.ej. el preview en 8443).
304
+ */
305
+ export function stopTailscaleServe(opts = {}) {
306
+ const httpsPort = Number(opts.httpsPort) || 443;
307
+ return runTailscale(['serve', `--https=${httpsPort}`, 'off'], { timeoutMs: 30000, spawnImpl: opts.spawnImpl });
285
308
  }
286
309
 
287
310
  /**
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  // node:sqlite sigue siendo "experimental" en Node 22, que avisa aunque ande. El
3
- // aviso no le aporta nada al usuario, asi que se filtra aca (lo que no sea un
4
- // warning de Node se imprime igual).
3
+ // aviso no le aporta nada al usuario, asi que se filtra aca. Se quita el
4
+ // impresor default de Node para que lo que si se imprime (lo que no sea
5
+ // ExperimentalWarning) salga una sola vez, no dos.
6
+ process.removeAllListeners('warning');
5
7
  process.on('warning', (warning) => {
6
8
  if (warning.name !== 'ExperimentalWarning') console.warn(warning.stack ?? warning);
7
9
  });
@@ -75,7 +75,7 @@
75
75
  </head>
76
76
  <body>
77
77
  <header class="topbar">
78
- <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.23.0</span></div>
78
+ <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.23.1</span></div>
79
79
  <div class="settings">
80
80
  <button id="new-session-top" type="button" class="btn topbar-new" title="nueva sesión" aria-label="nueva sesión">
81
81
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
@@ -76,7 +76,10 @@ export function createProcManager({ allow } = {}) {
76
76
  throw new Error(`Argumento no permitido con shell en Windows: "${bad}".`);
77
77
  }
78
78
  }
79
- const child = spawn(bin, list, {
79
+ // Con `shell: true` y array de args Node concatena sin escapar (warning
80
+ // DEP0190): se manda un unico comando (los args ya pasaron SAFE_SHELL_ARG).
81
+ const [cmd, cmdArgs] = shell ? [`${bin} ${list.join(' ')}`, []] : [bin, list];
82
+ const child = spawn(cmd, cmdArgs, {
80
83
  cwd,
81
84
  stdio: ['ignore', 'pipe', 'pipe'],
82
85
  windowsHide: true,
@@ -29,6 +29,23 @@ test('en Windows los args no admiten metacaracteres del shell', { skip: process.
29
29
  }
30
30
  });
31
31
 
32
+ test('un comando bare con args no dispara el warning DEP0190 de Node', { skip: process.platform !== 'win32' }, async () => {
33
+ const cwd = await workspace();
34
+ const procs = createProcManager({ allow: ['node'] });
35
+ const warnings = [];
36
+ const onWarn = (warning) => warnings.push(warning);
37
+ process.on('warning', onWarn);
38
+ let proc;
39
+ try {
40
+ proc = procs.start({ cwd, command: 'node', args: ['-e', 'process.exit(0)'] });
41
+ await new Promise((resolve) => setTimeout(resolve, 100));
42
+ } finally {
43
+ process.removeListener('warning', onWarn);
44
+ if (proc) procs.stop(proc.id);
45
+ }
46
+ assert.deepEqual(warnings.filter((w) => w.code === 'DEP0190'), []);
47
+ });
48
+
32
49
  test('procs arranca y detiene un proceso permitido', async () => {
33
50
  const cwd = await workspace();
34
51
  const procs = createProcManager({ allow: ['node'] });
@@ -77,8 +77,11 @@ async function waitDead(p, ms = 15000) {
77
77
  return true;
78
78
  }
79
79
 
80
+ // Con `shell: true` (npm.cmd en Windows) Node concatena los args sin escapar
81
+ // (warning DEP0190): se manda un unico comando con args internos.
80
82
  function runNpm(args) {
81
- return spawnSync(NPM, args, { encoding: 'utf8', windowsHide: true, shell: IS_WIN, timeout: 180000 });
83
+ const [cmd, cmdArgs] = IS_WIN ? [[NPM, ...args].join(' '), []] : [NPM, args];
84
+ return spawnSync(cmd, cmdArgs, { encoding: 'utf8', windowsHide: true, shell: IS_WIN, timeout: 180000 });
82
85
  }
83
86
 
84
87
  async function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.23.0",
3
+ "version": "0.23.1",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",