feat: version 2.2.0 -- boton real de actualizacion (auto-update)

POST /api/updater/apply dispara scripts/apply-update.sh (backup +
git pull + migraciones + build + restart) en un proceso separado.
Apagado por defecto: solo se activa si el servidor define
UPDATE_APP_DIR/UPDATE_BACKEND_SERVICE/UPDATE_FRONTEND_SERVICE en su
backend/.env. Sin esa config, el panel se comporta igual que antes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-22 12:18:12 -05:00
parent 43fef6048e
commit fa4ddbe164
15 changed files with 249 additions and 12 deletions
+8
View File
@@ -33,4 +33,12 @@ export const env = {
jwtExpires: process.env.JWT_EXPIRES || '8h',
corsOrigin: process.env.CORS_ORIGIN || 'http://localhost:5196',
storageRoot: process.env.STORAGE_ROOT || './storage',
// Auto-actualizacion (boton "Actualizar" del panel Configuracion > Actualizaciones): opcional,
// deshabilitada por completo si UPDATE_APP_DIR no esta seteado en este servidor (ej. no se
// configura en el servidor de LinkedCorp, solo en instalaciones de clientes que lo pidan).
update: {
appDir: process.env.UPDATE_APP_DIR || null,
backendService: process.env.UPDATE_BACKEND_SERVICE || null,
frontendService: process.env.UPDATE_FRONTEND_SERVICE || null,
},
};
@@ -4,3 +4,7 @@ import * as service from './service.js';
export const getUpdateStatus = asyncHandler(async (req, res) => {
ok(res, await service.checkForUpdates());
});
export const applyUpdate = asyncHandler(async (req, res) => {
ok(res, await service.applyUpdate(req.currentUser.id));
});
+2 -1
View File
@@ -1,10 +1,11 @@
import { Router } from 'express';
import { requireAuth, requireRole, CAN_MANAGE_SETTINGS } from '../../middleware/auth.js';
import { getUpdateStatus } from './controller.js';
import { getUpdateStatus, applyUpdate } from './controller.js';
const router = Router();
router.use(requireAuth, requireRole(...CAN_MANAGE_SETTINGS));
router.get('/status', getUpdateStatus);
router.post('/apply', applyUpdate);
export default router;
+48 -1
View File
@@ -1,7 +1,11 @@
import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import * as repo from './repository.js';
import { env } from '../../config/env.js';
import { HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const VERSION_FILE = path.resolve(__dirname, '../../../../VERSION');
@@ -62,17 +66,22 @@ function getLocalMigrationNames() {
return fs.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql'));
}
function isApplyAvailable() {
return !!(env.update.appDir && env.update.backendService && env.update.frontendService);
}
export async function checkForUpdates() {
const currentVersion = getLocalVersion();
const changelogText = await repo.fetchChangelog();
const allEntries = parseChangelog(changelogText);
const applyAvailable = isApplyAvailable();
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 };
return { currentVersion, upToDate: true, latestVersion: currentVersion, pending: [], hasMigrations: false, applyAvailable };
}
let previousInChain = currentVersion;
@@ -98,5 +107,43 @@ export async function checkForUpdates() {
overallCriticality,
hasMigrations,
pending: pendingWithMeta,
applyAvailable,
};
}
let applyInProgress = false;
// Dispara el script externo (fuera de source/, ver scripts/apply-update.sh) que hace el
// fetch+reset+build+migracion+restart real. Se lanza "detached" porque el propio backend se
// reinicia al final — el request HTTP ya respondio "empezo" mucho antes de que eso pase.
export async function applyUpdate(actorId) {
if (!isApplyAvailable()) {
throw new HttpError(400, 'La auto-actualizacion no esta configurada en este servidor (falta UPDATE_APP_DIR en backend/.env)');
}
const status = await checkForUpdates();
if (status.upToDate) {
throw new HttpError(400, 'El sistema ya esta en la ultima version');
}
if (applyInProgress) {
throw new HttpError(409, 'Ya hay una actualizacion en curso');
}
applyInProgress = true;
const scriptPath = path.join(env.update.appDir, 'scripts', 'apply-update.sh');
const child = spawn('bash', [scriptPath], {
detached: true,
stdio: 'ignore',
env: {
...process.env,
APP_DIR: env.update.appDir,
BACKEND_SERVICE: env.update.backendService,
FRONTEND_SERVICE: env.update.frontendService,
},
});
child.unref();
child.on('exit', () => { applyInProgress = false; });
await logAudit({ userId: actorId, action: 'apply_update', entityType: 'updater', entityId: null, detail: { from: status.currentVersion, to: status.latestVersion } });
return { started: true, fromVersion: status.currentVersion, toVersion: status.latestVersion };
}