@danieltmn/tamnora 0.9.4 → 0.9.5
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 +24 -0
- package/README.md +25 -10
- package/harness/packages/cli/src/commands/doctor.js +14 -0
- package/harness/packages/cli/src/commands/update.js +91 -21
- package/harness/packages/core/src/index.js +7 -1
- package/harness/packages/core/src/version.js +81 -7
- package/harness/packages/core/test/version.test.js +34 -0
- package/harness/packages/server/public/app.js +215 -8
- package/harness/packages/server/public/index.html +57 -3
- package/harness/packages/server/public/markdown.js +54 -0
- package/harness/packages/server/public/styles.css +37 -0
- package/harness/packages/server/src/lib/advisor.js +177 -0
- package/harness/packages/server/src/lib/agentRun.js +2 -1
- package/harness/packages/server/src/lib/ttsSettings.js +2 -2
- package/harness/packages/server/src/lib/update.js +82 -0
- package/harness/packages/server/src/routes/advisor.js +76 -0
- package/harness/packages/server/src/routes/chat.js +30 -0
- package/harness/packages/server/src/routes/tts.js +1 -1
- package/harness/packages/server/src/routes/update.js +43 -0
- package/harness/packages/server/src/server.js +26 -2
- package/harness/packages/server/test/advisor.test.js +205 -0
- package/harness/packages/server/test/markdown.test.js +43 -1
- package/harness/packages/server/test/update.test.js +94 -0
- package/harness/scripts/self-update.mjs +123 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,30 @@ 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.5] - 2026-10-08
|
|
9
|
+
|
|
10
|
+
### Agregado
|
|
11
|
+
|
|
12
|
+
- **Actualizacion desde la consola**: `GET /api/update` consulta el registro npm
|
|
13
|
+
y un banner ofrece **actualizar con un click**; `POST /api/update` (solo
|
|
14
|
+
loopback) apaga el server, instala la version nueva con un script detached y lo
|
|
15
|
+
vuelve a levantar. Un click en la version del topbar fuerza el chequeo.
|
|
16
|
+
`tamnora update` detiene el server antes de instalar (Windows bloquea los
|
|
17
|
+
archivos), detecta drift entre instalaciones y lo reinicia; `tamnora doctor`
|
|
18
|
+
avisa si hay una version nueva.
|
|
19
|
+
- **Asesor**: una capa de comentario sobre el run activo que interpreta lo que
|
|
20
|
+
hizo el agente y, si aporta algo, alerta un problema real de codigo, explica un
|
|
21
|
+
proceso o da un consejo. No lee la respuesta del agente. Tarjeta en el chat,
|
|
22
|
+
evento `advice` y voz opcional. Ajustes en la pestana **Asesor** (`app.json` ->
|
|
23
|
+
`advisor`).
|
|
24
|
+
- **Voz: leer la respuesta final**: `tts.announce.answer` lee la respuesta del
|
|
25
|
+
agente limpiando bloques de codigo, tablas y marcas de markdown.
|
|
26
|
+
|
|
27
|
+
### Corregido
|
|
28
|
+
|
|
29
|
+
- El asesor y la respuesta final ya no se descartan de la cola de audio cuando
|
|
30
|
+
quedan detras de una narracion larga (`maxAgeMs: 0`).
|
|
31
|
+
|
|
8
32
|
## [0.9.4] - 2026-10-07
|
|
9
33
|
|
|
10
34
|
### 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
|
|
14
|
-
(`speak`,
|
|
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
|
|
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.
|
|
73
|
-
>
|
|
74
|
-
>
|
|
75
|
-
>
|
|
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
|
|
101
|
-
|
|
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.
|
|
@@ -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
|
|
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 = {
|
|
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(
|
|
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 =
|
|
40
|
-
} catch {
|
|
41
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
76
|
-
|
|
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 {
|
|
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
|
|
9
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
+
});
|