feat: panel de Actualizaciones en Configuracion (solo informativo)

Compara la version instalada contra el CHANGELOG.md de main en Gitea (lectura anonima por HTTPS,
sin token) y muestra cada version pendiente con su criticidad -- derivada del digito que cambio
(1ro=Alta, 2do=Media, 3ro=Baja, sin etiquetar nada a mano, regla ya documentada en
VERSIONADO.md), una descripcion breve, y si trae migraciones de base de datos nuevas. Banner en
rojo avisando que la actualizacion requiere reiniciar el sistema.

No instala nada automaticamente -- solo informa. Aplicar la actualizacion (git pull, build,
migrar, reiniciar servicios) sigue siendo manual, a criterio del administrador de cada
instalacion, tal cual DEPLOY_WEB.md ya documenta.

Nuevo GET /api/updater/status (solo superadmin). Requiere que el repo en Gitea sea publico con
vista anonima habilitada -- documentado en DEPLOY_WEB.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-16 17:11:58 -05:00
parent 95e3a0199d
commit 59aadc6a35
15 changed files with 273 additions and 4 deletions
+2
View File
@@ -20,6 +20,7 @@ import settingsRoutes from './modules/settings/routes.js';
import companyRoutes from './modules/company/routes.js';
import recurringTasksRoutes from './modules/recurring-tasks/routes.js';
import searchRoutes from './modules/search/routes.js';
import updaterRoutes from './modules/updater/routes.js';
export const app = express();
@@ -44,6 +45,7 @@ app.use('/api/settings', settingsRoutes);
app.use('/api/company', companyRoutes);
app.use('/api/recurring-tasks', recurringTasksRoutes);
app.use('/api/search', searchRoutes);
app.use('/api/updater', updaterRoutes);
// Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no
// interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas
// especifico registrado despues (ver ESTADO.md, deuda tecnica).
@@ -0,0 +1,6 @@
import { ok, asyncHandler } from '../../utils/http.js';
import * as service from './service.js';
export const getUpdateStatus = asyncHandler(async (req, res) => {
ok(res, await service.checkForUpdates());
});
+26
View File
@@ -0,0 +1,26 @@
// Lee el repo en Gitea de forma anonima (repo publico + vista anonima habilitada en el servidor
// de Gitea) — el servidor de un cliente instalado en otro lado NO necesita ningun token ni
// credencial para esto, solo poder llegar por HTTPS a Gitea.
const GITEA_BASE = process.env.GITEA_BASE_URL || 'http://gitea.linkedcorp.net';
const GITEA_OWNER = process.env.GITEA_OWNER || 'mgonzalez';
const GITEA_REPO = process.env.GITEA_REPO || 'project.linkedcorp.net';
const BRANCH = 'main';
async function getRaw(path) {
const url = `${GITEA_BASE}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/raw/${path}?ref=${BRANCH}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Gitea respondio ${res.status} al leer ${path}`);
return res.text();
}
export async function fetchChangelog() {
return getRaw('CHANGELOG.md');
}
export async function fetchRemoteMigrationNames() {
const url = `${GITEA_BASE}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/contents/backend/database/migrations?ref=${BRANCH}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Gitea respondio ${res.status} al listar migraciones`);
const entries = await res.json();
return entries.filter((e) => e.type === 'file').map((e) => e.name);
}
+10
View File
@@ -0,0 +1,10 @@
import { Router } from 'express';
import { requireAuth, requireRole, CAN_MANAGE_SETTINGS } from '../../middleware/auth.js';
import { getUpdateStatus } from './controller.js';
const router = Router();
router.use(requireAuth, requireRole(...CAN_MANAGE_SETTINGS));
router.get('/status', getUpdateStatus);
export default router;
+102
View File
@@ -0,0 +1,102 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as repo from './repository.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const VERSION_FILE = path.resolve(__dirname, '../../../../VERSION');
const MIGRATIONS_DIR = path.resolve(__dirname, '../../../database/migrations');
function parseVersion(v) {
const [major, minor, patch] = v.trim().split('.').map(Number);
return { major, minor, patch };
}
function compareVersions(a, b) {
const va = parseVersion(a);
const vb = parseVersion(b);
if (va.major !== vb.major) return va.major - vb.major;
if (va.minor !== vb.minor) return va.minor - vb.minor;
return va.patch - vb.patch;
}
// Regla del proyecto (VERSIONADO.md): cambia el 1er digito = alta, 2do = media, 3er = baja.
// Sin etiquetar nada a mano — se deriva solo de que numero cambio entre las dos versiones.
function classifyBump(fromVersion, toVersion) {
const a = parseVersion(fromVersion);
const b = parseVersion(toVersion);
if (b.major !== a.major) return 'alta';
if (b.minor !== a.minor) return 'media';
return 'baja';
}
// Se parte el texto en la propia delimitacion "## [" (en vez de una sola regex con lookahead de
// fin-de-texto): con la bandera `m`, `$` matchea fin de CUALQUIER linea, asi que un lookahead
// `\s*$` para "fin del changelog completo" en realidad cortaba en la primera linea en blanco
// (el cuerpo quedaba vacio siempre). Partiendo por bloque no hay esa ambiguedad.
function parseChangelog(text) {
const entries = [];
const blocks = text.split(/\n(?=## \[)/);
for (const block of blocks) {
const match = block.match(/^## \[(\d+\.\d+\.\d+)\] - (\d{4}-\d{2}-\d{2})\s*\n([\s\S]*)$/);
if (match) entries.push({ version: match[1], date: match[2], body: match[3].trim() });
}
return entries;
}
// Resumen corto: saca los encabezados "### Algo" y las vinietas "- ", deja solo el texto, truncado.
function summarize(body, maxLength = 260) {
const lines = body
.split('\n')
.map((l) => l.trim().replace(/^-\s+/, ''))
.filter((l) => l && !l.startsWith('###'));
const joined = lines.join(' ').replace(/\s+/g, ' ').trim();
return joined.length > maxLength ? `${joined.slice(0, maxLength).trim()}…` : joined;
}
function getLocalVersion() {
return fs.readFileSync(VERSION_FILE, 'utf8').trim();
}
function getLocalMigrationNames() {
return fs.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql'));
}
export async function checkForUpdates() {
const currentVersion = getLocalVersion();
const changelogText = await repo.fetchChangelog();
const allEntries = parseChangelog(changelogText);
const pending = allEntries
.filter((e) => compareVersions(e.version, currentVersion) > 0)
.sort((a, b) => compareVersions(a.version, b.version));
if (pending.length === 0) {
return { currentVersion, upToDate: true, latestVersion: currentVersion, pending: [], hasMigrations: false };
}
let previousInChain = currentVersion;
const pendingWithMeta = pending.map((e) => {
const criticality = classifyBump(previousInChain, e.version);
previousInChain = e.version;
return { version: e.version, date: e.date, criticality, summary: summarize(e.body) };
});
const localMigrations = new Set(getLocalMigrationNames());
const remoteMigrations = await repo.fetchRemoteMigrationNames();
const hasMigrations = remoteMigrations.some((name) => !localMigrations.has(name));
const latestVersion = pending[pending.length - 1].version;
const overallCriticality = pendingWithMeta.some((e) => e.criticality === 'alta')
? 'alta'
: pendingWithMeta.some((e) => e.criticality === 'media') ? 'media' : 'baja';
return {
currentVersion,
upToDate: false,
latestVersion,
overallCriticality,
hasMigrations,
pending: pendingWithMeta,
};
}