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:
@@ -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());
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user