@danieltmn/tamnora 0.9.4 → 0.9.6

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 (41) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +25 -10
  3. package/harness/packages/cli/src/commands/autostart.js +5 -1
  4. package/harness/packages/cli/src/commands/doctor.js +14 -0
  5. package/harness/packages/cli/src/commands/update.js +91 -21
  6. package/harness/packages/core/src/index.js +7 -1
  7. package/harness/packages/core/src/version.js +81 -7
  8. package/harness/packages/core/test/version.test.js +34 -0
  9. package/harness/packages/server/public/app.js +337 -24
  10. package/harness/packages/server/public/favicon.ico +0 -0
  11. package/harness/packages/server/public/icons/icon-128x128.png +0 -0
  12. package/harness/packages/server/public/icons/icon-144x144.png +0 -0
  13. package/harness/packages/server/public/icons/icon-152x152.png +0 -0
  14. package/harness/packages/server/public/icons/icon-192x192.png +0 -0
  15. package/harness/packages/server/public/icons/icon-384x384.png +0 -0
  16. package/harness/packages/server/public/icons/icon-512x512.png +0 -0
  17. package/harness/packages/server/public/icons/icon-72x72.png +0 -0
  18. package/harness/packages/server/public/icons/icon-96x96.png +0 -0
  19. package/harness/packages/server/public/icons/logo.png +0 -0
  20. package/harness/packages/server/public/icons/tamnora.ico +0 -0
  21. package/harness/packages/server/public/index.html +119 -30
  22. package/harness/packages/server/public/landing/index.html +8 -4
  23. package/harness/packages/server/public/logo.png +0 -0
  24. package/harness/packages/server/public/manifest.webmanifest +20 -0
  25. package/harness/packages/server/public/markdown.js +54 -0
  26. package/harness/packages/server/public/styles.css +1775 -1633
  27. package/harness/packages/server/src/lib/advisor.js +177 -0
  28. package/harness/packages/server/src/lib/agentRun.js +2 -1
  29. package/harness/packages/server/src/lib/ttsSettings.js +2 -2
  30. package/harness/packages/server/src/lib/update.js +82 -0
  31. package/harness/packages/server/src/routes/advisor.js +76 -0
  32. package/harness/packages/server/src/routes/chat.js +30 -0
  33. package/harness/packages/server/src/routes/tts.js +1 -1
  34. package/harness/packages/server/src/routes/update.js +43 -0
  35. package/harness/packages/server/src/server.js +26 -2
  36. package/harness/packages/server/src/static.js +1 -0
  37. package/harness/packages/server/test/advisor.test.js +205 -0
  38. package/harness/packages/server/test/markdown.test.js +43 -1
  39. package/harness/packages/server/test/update.test.js +94 -0
  40. package/harness/scripts/self-update.mjs +123 -0
  41. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,38 @@ 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.6] - 2026-10-08
9
+
10
+ ### Cambios
11
+
12
+ - fix(consola): borde inferior y padding inferior en la barra de pestañas
13
+ - feat(consola): iconos/logo (favicon, PWA, topbar) y composer estilo codex
14
+ - docs(website): voz, asesor y actualizacion con un click
15
+
16
+ ## [0.9.5] - 2026-10-08
17
+
18
+ ### Agregado
19
+
20
+ - **Actualizacion desde la consola**: `GET /api/update` consulta el registro npm
21
+ y un banner ofrece **actualizar con un click**; `POST /api/update` (solo
22
+ loopback) apaga el server, instala la version nueva con un script detached y lo
23
+ vuelve a levantar. Un click en la version del topbar fuerza el chequeo.
24
+ `tamnora update` detiene el server antes de instalar (Windows bloquea los
25
+ archivos), detecta drift entre instalaciones y lo reinicia; `tamnora doctor`
26
+ avisa si hay una version nueva.
27
+ - **Asesor**: una capa de comentario sobre el run activo que interpreta lo que
28
+ hizo el agente y, si aporta algo, alerta un problema real de codigo, explica un
29
+ proceso o da un consejo. No lee la respuesta del agente. Tarjeta en el chat,
30
+ evento `advice` y voz opcional. Ajustes en la pestana **Asesor** (`app.json` ->
31
+ `advisor`).
32
+ - **Voz: leer la respuesta final**: `tts.announce.answer` lee la respuesta del
33
+ agente limpiando bloques de codigo, tablas y marcas de markdown.
34
+
35
+ ### Corregido
36
+
37
+ - El asesor y la respuesta final ya no se descartan de la cola de audio cuando
38
+ quedan detras de una narracion larga (`maxAgeMs: 0`).
39
+
8
40
  ## [0.9.4] - 2026-10-07
9
41
 
10
42
  ### Cambios
package/README.md CHANGED
@@ -10,8 +10,14 @@ con TUI, consola web, tools locales, MCP y evaluacion de modelos.
10
10
  Cerebras, Perplexity) y Ollama.
11
11
  - **Navegador headless**: tools `screenshot` y `console_errors` (renderiza JS y
12
12
  saca una captura PNG o lista los errores de consola) y `preview_url`.
13
- - **Voz**: el agente narra su progreso y puede mandar audio bajo pedido
14
- (`speak`, `/audioresumen`).
13
+ - **Voz**: el agente narra su progreso, puede **leer la respuesta final** (sin
14
+ bloques de codigo ni tablas) y mandar audio bajo pedido (`speak`,
15
+ `/audioresumen`).
16
+ - **Asesor**: una capa de comentario sobre el run que interpreta lo que hizo el
17
+ agente y, si aporta algo, avisa de un problema en el codigo, explica un proceso
18
+ o da un consejo (con tarjeta en el chat y voz opcional). No lee la respuesta.
19
+ - **Actualizar desde la consola**: la consola avisa cuando hay una version nueva
20
+ y la instala con un click (el server se reinicia solo).
15
21
 
16
22
  ## Instalacion
17
23
 
@@ -56,8 +62,8 @@ tamnora server --reload # reinicia aplicando config/codigo
56
62
  tamnora server --stop
57
63
  tamnora server --status
58
64
  tamnora server --logs --follow
59
- tamnora doctor # verifica Node, casa, puerto y base
60
- tamnora update # busca/instala una version nueva
65
+ tamnora doctor # verifica Node, casa, puerto, base y version nueva
66
+ tamnora update # busca/instala una version nueva (reinicia el server)
61
67
  tamnora autostart install # arranque automatico del server
62
68
  ```
63
69
 
@@ -69,10 +75,14 @@ Tamnora (override con `--home` o `TAMNORA_HOME`):
69
75
  - macOS: `~/Library/Application Support/tamnora`
70
76
  - Linux: `~/.config/tamnora`
71
77
 
72
- > **Actualizar**: en `0.4.1+` alcanza con `tamnora update` (o `--yes`). Si venis de
73
- > **`0.4.0` o anterior**, `tamnora update` fallaba en Windows, asi que hace una vez
74
- > `npm i -g @danieltmn/tamnora@latest` (agrega `--prefer-online` si el cache de npm
75
- > devuelve la version vieja) o corre con `npx @danieltmn/tamnora@latest`.
78
+ > **Actualizar**: en `0.9.5+` la consola avisa cuando hay una version nueva y la
79
+ > instala con un click (el server se reinicia solo); un click en la version del
80
+ > topbar fuerza el chequeo. Por CLI, `tamnora update --yes` ahora **detiene el
81
+ > server antes de instalar** (en Windows libera los archivos) y lo reinicia. Si
82
+ > venis de **`0.8.0` o anterior** y "dice que actualizo pero sigue igual", hace una
83
+ > vez: `tamnora server --stop`, `npm i -g @danieltmn/tamnora@latest` y
84
+ > `tamnora server --start`. Verifica que `where tamnora` coincida con
85
+ > `npm root -g` (si no, hay dos instalaciones y el update toca la otra).
76
86
 
77
87
  ### Consola web
78
88
 
@@ -97,8 +107,13 @@ menu "mas" (los tres puntitos). En el chat:
97
107
  - **Runs durables**: cerrar el celular o cortar la red **no cancela** el turno; al
98
108
  volver, la consola **reconecta** y reconstruye el progreso. Para cortarlo se usa
99
109
  el boton de detener (o `POST /api/sessions/:id/stop`).
100
- - **Audio**: un unico reproductor encola la narracion, el tool `speak` y
101
- `/audioresumen`, y reproduce de a uno con pausa y detener.
110
+ - **Audio**: un unico reproductor encola la narracion, el tool `speak`, la
111
+ respuesta final y los consejos del asesor, y reproduce de a uno con pausa y
112
+ detener (Ajustes -> Voz).
113
+ - **Asesor**: en Ajustes -> Asesor se activa una capa que comenta el turno
114
+ (problemas de codigo / explicaciones / consejos) con proveedor y modelo activos.
115
+ - **Actualizar**: cuando npm tiene una version nueva aparece un banner para
116
+ actualizar con un click; el server se reinicia solo.
102
117
  - **Comandos**: el input muestra un menu al escribir `/` (`/new`, `/init`, `/goal`,
103
118
  `/resumen`, `/audioresumen`, `/compact`, `/clear`, `/cwd`, `/kill`, `/help`).
104
119
  - **Capturas**: el tool `screenshot` muestra la imagen inline en su tarjeta.
@@ -1,10 +1,13 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
5
6
  import { homeDir } from '../../../core/src/index.js';
6
7
  import { parse } from '../lib/util.js';
7
8
 
9
+ const ICON_PATH = fileURLToPath(new URL('../../../server/public/icons/tamnora.ico', import.meta.url));
10
+
8
11
  const OPTIONS = { home: { type: 'string' }, help: { type: 'boolean', short: 'h' } };
9
12
 
10
13
  function autostartPath() {
@@ -76,6 +79,7 @@ export async function autostartCommand(argv) {
76
79
  `$sc.TargetPath = ${psQuote(wscript)};`,
77
80
  `$sc.Arguments = ${psQuote(`"${vbsPath}"`)};`,
78
81
  `$sc.WorkingDirectory = ${psQuote(workdir)};`,
82
+ existsSync(ICON_PATH) ? `$sc.IconLocation = ${psQuote(ICON_PATH)};` : '',
79
83
  '$sc.WindowStyle = 1;',
80
84
  '$sc.Save();',
81
85
  ].join(' ');
@@ -7,6 +7,9 @@ import {
7
7
  databasePath,
8
8
  serverLogPath,
9
9
  Store,
10
+ readPackageVersion,
11
+ compareVersions,
12
+ fetchLatestVersion,
10
13
  } from '../../../core/src/index.js';
11
14
  import { parse } from '../lib/util.js';
12
15
  import { pidAlive, readRuntime } from '../lib/daemon.js';
@@ -52,6 +55,17 @@ export async function doctorCommand(argv) {
52
55
  checks.push(['base de datos', dbOk, dbOk ? '' : 'se creara al primer uso']);
53
56
  checks.push(['proveedor activo', Boolean(provider), provider || 'elegi uno en la consola (npm: tamnora serve)']);
54
57
 
58
+ const current = readPackageVersion();
59
+ let versionHint = '';
60
+ try {
61
+ const latest = await fetchLatestVersion();
62
+ versionHint =
63
+ compareVersions(latest, current) > 0 ? `hay v${latest}: corre tamnora update --yes` : 'al dia';
64
+ } catch (error) {
65
+ versionHint = `no pude verificar (${error?.message ?? error})`;
66
+ }
67
+ checks.push([`version (v${current})`, true, versionHint]);
68
+
55
69
  if (serverLive) checks.push([`server (pid ${rt.pid})`, true, rt.url || '']);
56
70
  checks.push([`logs`, true, serverLogPath()]);
57
71
 
@@ -1,16 +1,26 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { readPackageVersion } from '../lib/core.js';
2
+ import path from 'node:path';
3
+ import { fetchLatestVersion, compareVersions, packageRoot, readPackageVersion, readApp } from '../lib/core.js';
3
4
  import { parse } from '../lib/util.js';
5
+ import { readRuntime, pidAlive, stopRunningServer, spawnDetached, portHint } from '../lib/daemon.js';
4
6
 
5
7
  const PACKAGE = '@danieltmn/tamnora';
6
8
  const IS_WIN = process.platform === 'win32';
7
9
  const NPM = IS_WIN ? 'npm.cmd' : 'npm';
8
- const OPTIONS = { yes: { type: 'boolean', short: 'y' }, check: { type: 'boolean' }, tag: { type: 'string' }, help: { type: 'boolean', short: 'h' } };
10
+ const OPTIONS = {
11
+ yes: { type: 'boolean', short: 'y' },
12
+ check: { type: 'boolean' },
13
+ restart: { type: 'boolean' },
14
+ 'no-restart': { type: 'boolean' },
15
+ tag: { type: 'string' },
16
+ help: { type: 'boolean', short: 'h' },
17
+ };
9
18
 
10
19
  function npm(args) {
11
- return spawnSync(NPM, args, { encoding: 'utf8', windowsHide: true, shell: IS_WIN });
20
+ return spawnSync(NPM, args, { encoding: 'utf8', windowsHide: true, shell: IS_WIN, timeout: 180000 });
12
21
  }
13
22
 
23
+ /** Version del paquete instalado globalmente (la que resuelve `npm ls -g`). */
14
24
  function installedVersion() {
15
25
  const out = npm(['ls', '-g', PACKAGE, '--json', '--depth=0']);
16
26
  try {
@@ -21,10 +31,49 @@ function installedVersion() {
21
31
  }
22
32
  }
23
33
 
34
+ function globalRoot() {
35
+ const out = npm(['root', '-g']);
36
+ return out.status === 0 ? String(out.stdout || '').trim() : '';
37
+ }
38
+
39
+ const norm = (p) => path.resolve(String(p || '')).toLowerCase();
40
+
41
+ /**
42
+ * Detiene el server gestionado (runtime.json) para que Windows libere los
43
+ * archivos bloqueados; sin esto `npm i -g` falla o queda a medias.
44
+ */
45
+ async function stopServerIfRunning() {
46
+ const rt = readRuntime();
47
+ if (!rt || !pidAlive(rt.pid)) return null;
48
+ console.log(`Deteniendo el server (pid ${rt.pid}) para poder reemplazar los archivos...`);
49
+ stopRunningServer();
50
+ for (let i = 0; i < 25 && pidAlive(rt.pid); i++) await new Promise((r) => setTimeout(r, 200));
51
+ return rt;
52
+ }
53
+
54
+ function restartServer(rt) {
55
+ try {
56
+ const bin = process.argv[1];
57
+ const args = [bin, 'server', '--stream', '--no-open'];
58
+ if (rt?.port) args.push('--port', String(rt.port));
59
+ if (rt?.host) args.push('--host', rt.host);
60
+ if (rt?.cwd) args.push('--dir', rt.cwd);
61
+ const { child } = spawnDetached(args, { envFlag: 'TAMNORA_DETACHED' });
62
+ console.log(`Server reiniciado en segundo plano (pid ${child.pid}).`);
63
+ } catch (error) {
64
+ console.error(`No pude reiniciar el server solo: ${error?.message ?? error}`);
65
+ console.error('Reinicialo a mano: tamnora server --start');
66
+ }
67
+ }
68
+
24
69
  export async function updateCommand(argv) {
25
70
  const { values } = parse(argv, OPTIONS);
26
71
  if (values.help) {
27
- console.log('Uso: tamnora update [--check] [--yes]\n\nBusca una version nueva en npm y actualiza la instalacion global.');
72
+ console.log(
73
+ 'Uso: tamnora update [--check] [--yes] [--no-restart]\n\n' +
74
+ 'Busca una version nueva en npm y actualiza la instalacion global.\n' +
75
+ 'Detiene el server antes de instalar (en Windows libera los archivos) y lo reinicia al terminar.'
76
+ );
28
77
  return 0;
29
78
  }
30
79
 
@@ -33,24 +82,20 @@ export async function updateCommand(argv) {
33
82
  const version = readPackageVersion();
34
83
  console.log(`Version actual: ${version}`);
35
84
 
36
- const view = npm(['view', spec, 'version', '--json']);
37
85
  let latest = '';
38
86
  try {
39
- latest = String(JSON.parse(view.stdout)).trim();
40
- } catch {
41
- latest = String(view.stdout || '').trim().replace(/"/g, '');
42
- }
43
- if (view.status !== 0 || !/^\d+\.\d+\.\d+/.test(latest)) {
44
- console.error(`No se pudo consultar npm (${tag}): ${(view.stderr || '').trim() || 'revisa tu conexion'}`);
87
+ latest = await fetchLatestVersion({ pkg: PACKAGE, tag });
88
+ } catch (error) {
89
+ console.error(`No se pudo consultar npm (${tag}): ${error?.message ?? error}`);
45
90
  return 1;
46
91
  }
47
92
  console.log(`Disponible (${tag}): ${latest}`);
48
- if (latest === version) {
93
+ if (compareVersions(latest, version) <= 0) {
49
94
  console.log('Ya estas en la ultima version.');
50
95
  return 0;
51
96
  }
52
97
  if (values.check) {
53
- console.log(`Hay una version nueva. Corre: tamnora update --yes`);
98
+ console.log('Hay una version nueva. Corre: tamnora update --yes');
54
99
  return 0;
55
100
  }
56
101
  if (!values.yes) {
@@ -60,21 +105,46 @@ export async function updateCommand(argv) {
60
105
  return 0;
61
106
  }
62
107
 
108
+ const wasRunning = await stopServerIfRunning();
109
+ if (!wasRunning) {
110
+ // Un server no gestionado (ej. `pnpm serve` en primer plano) tambien
111
+ // bloquea los archivos. Avisamos en vez de matar un proceso ajeno.
112
+ const app = readApp();
113
+ const hint = app?.port ? portHint(app.port) : '';
114
+ if (hint) console.warn(`aviso: algo escucha en el puerto ${app.port} (${hint}); cerralo si la instalacion falla.`);
115
+ }
116
+
63
117
  console.log(`Actualizando a ${spec}...`);
64
- const result = spawnSync(NPM, ['i', '-g', spec], {
65
- stdio: 'inherit',
66
- windowsHide: true,
67
- shell: IS_WIN,
68
- });
118
+ const result = spawnSync(NPM, ['i', '-g', spec], { stdio: 'inherit', windowsHide: true, shell: IS_WIN });
69
119
  if (result.status !== 0) {
70
120
  console.error(`La actualizacion fallo. Proba a mano: npm i -g ${spec}`);
121
+ if (wasRunning) restartServer(wasRunning);
71
122
  return 1;
72
123
  }
124
+
73
125
  const installed = installedVersion() || readPackageVersion();
74
126
  console.log(`Listo. Instalado: v${installed} (pediste ${latest}).`);
75
- if (installed && installed !== latest) {
76
- console.log('Ojo: la version instalada no coincide con la publicada. Cerra procesos que usen la carpeta y reintenta.');
127
+
128
+ // Drift: npm instala en un prefix distinto al de la copia que esta corriendo.
129
+ const runningRoot = packageRoot();
130
+ const expectedRoot = globalRoot();
131
+ const expectedPkg = expectedRoot ? path.join(expectedRoot, ...PACKAGE.split('/')) : '';
132
+ if (runningRoot && expectedPkg && norm(runningRoot) !== norm(expectedPkg)) {
133
+ console.warn('Ojo: la copia global quedo en un lugar distinto al de esta instalacion.');
134
+ console.warn(` instalada: ${expectedPkg}`);
135
+ console.warn(` en uso: ${runningRoot}`);
136
+ console.warn(' puede que el update no afecte la copia que corre; revisa cual resuelve "tamnora".');
137
+ }
138
+ if (installed && compareVersions(installed, latest) < 0) {
139
+ console.warn('Ojo: la version instalada no coincide con la publicada. Cerra procesos que usen la carpeta y reintenta.');
140
+ }
141
+
142
+ if (values['no-restart']) {
143
+ console.log('Reinicia el server cuando quieras: tamnora server --reload');
144
+ } else if (wasRunning || values.restart) {
145
+ restartServer(wasRunning || { port: readApp().port });
146
+ } else {
147
+ console.log('Reinicia el server: tamnora server --reload');
77
148
  }
78
- console.log('Reinicia el server: tamnora server --reload');
79
149
  return 0;
80
150
  }
@@ -111,4 +111,10 @@ export { pidAlive, killPidTree } from './pid.js';
111
111
  export { openBrowser, openerFor } from './open.js';
112
112
  export { detectProcs, packageManager, DEFAULT_PROCESS_ALLOW, staticServerChip } from './procs/detect.js';
113
113
  export { killPort } from './procs/kill.js';
114
- export { readPackageVersion } from './version.js';
114
+ export {
115
+ ROOT_PACKAGE_NAME,
116
+ readPackageVersion,
117
+ packageRoot,
118
+ compareVersions,
119
+ fetchLatestVersion,
120
+ } from './version.js';
@@ -2,19 +2,20 @@ import { readFileSync } from 'node:fs';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import path from 'node:path';
4
4
 
5
- const ROOT_PACKAGE_NAME = '@danieltmn/tamnora';
5
+ export const ROOT_PACKAGE_NAME = '@danieltmn/tamnora';
6
+
7
+ const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
6
8
 
7
9
  /**
8
- * Resolve the published package version by walking up from `startUrl` until a
9
- * package.json named `@danieltmn/tamnora` is found. Robust to file depth so
10
- * callers don't have to count `../` levels.
10
+ * Resolve the harness package dir (where its package.json lives) by walking up
11
+ * from `startUrl`. Robust to file depth so callers don't count `../`.
11
12
  */
12
- export function readPackageVersion(startUrl = import.meta.url) {
13
+ export function packageRoot(startUrl = import.meta.url) {
13
14
  let dir = path.dirname(fileURLToPath(startUrl));
14
15
  for (let i = 0; i < 10; i++) {
15
16
  try {
16
17
  const pkg = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'));
17
- if (pkg.name === ROOT_PACKAGE_NAME && pkg.version) return pkg.version;
18
+ if (pkg.name === ROOT_PACKAGE_NAME) return dir;
18
19
  } catch {
19
20
  /* no hay package.json acá: seguir subiendo */
20
21
  }
@@ -22,5 +23,78 @@ export function readPackageVersion(startUrl = import.meta.url) {
22
23
  if (parent === dir) break;
23
24
  dir = parent;
24
25
  }
25
- return '0.0.0';
26
+ return null;
27
+ }
28
+
29
+ /** Version publicada del harness (`0.0.0` si no se puede leer). */
30
+ export function readPackageVersion(startUrl = import.meta.url) {
31
+ const root = packageRoot(startUrl);
32
+ if (!root) return '0.0.0';
33
+ try {
34
+ const pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'));
35
+ return pkg.version || '0.0.0';
36
+ } catch {
37
+ return '0.0.0';
38
+ }
39
+ }
40
+
41
+ function parseVersion(value) {
42
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(
43
+ String(value ?? '').trim()
44
+ );
45
+ if (!match) return null;
46
+ return { nums: [Number(match[1]), Number(match[2]), Number(match[3])], pre: match[4] ?? '' };
47
+ }
48
+
49
+ /**
50
+ * Semver minimo: devuelve -1 / 0 / 1. Una version sin prerelease ordena por
51
+ * encima de una con prerelease (1.0.0 > 1.0.0-beta). Si alguna no se entiende,
52
+ * las trata como iguales (no queremos gritar "actualizacion disponible" por un
53
+ * formato raro).
54
+ */
55
+ export function compareVersions(a, b) {
56
+ const va = parseVersion(a);
57
+ const vb = parseVersion(b);
58
+ if (!va || !vb) return 0;
59
+ for (let i = 0; i < 3; i++) {
60
+ if (va.nums[i] > vb.nums[i]) return 1;
61
+ if (va.nums[i] < vb.nums[i]) return -1;
62
+ }
63
+ if (va.pre === vb.pre) return 0;
64
+ if (!va.pre) return 1;
65
+ if (!vb.pre) return -1;
66
+ return va.pre < vb.pre ? -1 : 1;
67
+ }
68
+
69
+ /**
70
+ * Ultima version publicada en el registro npm, sin depender de `npm` en el
71
+ * PATH. `fetchImpl` es inyectable para tests.
72
+ */
73
+ export async function fetchLatestVersion({
74
+ pkg = ROOT_PACKAGE_NAME,
75
+ tag = 'latest',
76
+ registry = DEFAULT_REGISTRY,
77
+ fetchImpl = globalThis.fetch,
78
+ timeoutMs = 8000,
79
+ } = {}) {
80
+ if (typeof fetchImpl !== 'function') throw new Error('fetch no disponible');
81
+ const controller = new AbortController();
82
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
83
+ timer.unref?.();
84
+ try {
85
+ const res = await fetchImpl(`${registry}/${pkg}`, {
86
+ headers: { accept: 'application/json' },
87
+ signal: controller.signal,
88
+ });
89
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
90
+ const data = await res.json();
91
+ const distTags = data?.['dist-tags'] ?? {};
92
+ const version = distTags[tag] || distTags.latest;
93
+ if (!version || !/^\d+\.\d+\.\d+/.test(String(version))) {
94
+ throw new Error('respuesta sin version valida');
95
+ }
96
+ return String(version);
97
+ } finally {
98
+ clearTimeout(timer);
99
+ }
26
100
  }
@@ -0,0 +1,34 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { compareVersions, fetchLatestVersion } from '../src/index.js';
4
+
5
+ test('compareVersions ordena semver (incluye prerelease)', () => {
6
+ assert.equal(compareVersions('1.2.3', '1.2.3'), 0);
7
+ assert.equal(compareVersions('1.2.4', '1.2.3'), 1);
8
+ assert.equal(compareVersions('1.3.0', '1.2.9'), 1);
9
+ assert.equal(compareVersions('2.0.0', '1.9.9'), 1);
10
+ assert.equal(compareVersions('1.0.0', '1.0.0-beta'), 1);
11
+ assert.equal(compareVersions('1.0.0-beta', '1.0.0'), -1);
12
+ assert.equal(compareVersions('v0.9.4', '0.9.4'), 0);
13
+ // Formato desconocido: no afirmamos que hay algo nuevo.
14
+ assert.equal(compareVersions('base', '1.0.0'), 0);
15
+ });
16
+
17
+ test('fetchLatestVersion resuelve el tag desde el registro', async () => {
18
+ const fetchImpl = async (url) => {
19
+ assert.match(String(url), /registry\.npmjs\.org\/@danieltmn\/tamnora$/);
20
+ return { ok: true, json: async () => ({ 'dist-tags': { latest: '9.9.9' } }) };
21
+ };
22
+ assert.equal(await fetchLatestVersion({ fetchImpl }), '9.9.9');
23
+ });
24
+
25
+ test('fetchLatestVersion falla claro con HTTP malo o sin version', async () => {
26
+ await assert.rejects(
27
+ fetchLatestVersion({ fetchImpl: async () => ({ ok: false, status: 500 }) }),
28
+ /HTTP 500/
29
+ );
30
+ await assert.rejects(
31
+ fetchLatestVersion({ fetchImpl: async () => ({ ok: true, json: async () => ({}) }) }),
32
+ /version/
33
+ );
34
+ });