fix: version 3.0.1 -- Actualizador espera a backend y frontend antes de recargar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-23 11:59:48 -05:00
parent e65111a7f4
commit 959c365d5c
20 changed files with 262 additions and 86 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog - project.linkedcorp.net
## [3.0.1] - 2026-10-23
### Fixed
- El diálogo de actualización permanece abierto con la fase del proceso y solo recarga al confirmar
que el script terminó y que backend y frontend sirven la versión nueva. El frontend expone
`version.json` sin caché; se fuerza la actualización del Service Worker antes de recargar.
- Se toleran los cortes durante reinicios y se muestra error o espera agotada tras 10 minutos,
con opción de reintentar la comprobación o recargar.
## [3.0.0] - 2026-10-23
### Security
- Se restringe la asignación de superadmin, la escritura de consultores y la búsqueda/tareas
+5 -1
View File
@@ -1,6 +1,6 @@
# ESTADO — project.linkedcorp.net
Ultima actualizacion: 2026-10-23 (v3.0.0 en desarrollo, sin desplegar)
Ultima actualizacion: 2026-10-23 (v3.0.1 en desarrollo, sin desplegar)
Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.1.0). MVP fase 1 operativo +
autorregistro/aprobacion/SMTP.
@@ -25,6 +25,10 @@ autorregistro/aprobacion/SMTP.
## Como retomar
Actualizador v3.0.1: el script publica `logs/update.status`; el panel espera su fin y comprueba
las versiones activas de backend y `version.json` del frontend antes de activar el Service Worker
y recargar. Pendiente verificar el flujo completo en un despliegue autorizado; no se tocó producción.
1. Leer primero el orquestador: `/home/mgonzalez/desarrollo/project-md-templates/leeme_primero.txt`.
2. Leer `AGENTS.md`, `SPEC.md`, `PRODUCCION.md` y este archivo.
3. Backend dev: `cd backend && npm install && npm run dev` (puerto 4019). Requiere `backend/.env`
+1 -1
View File
@@ -1 +1 @@
3.0.0
3.0.1
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "project-linkedcorp-backend",
"version": "3.0.0",
"version": "3.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "project-linkedcorp-backend",
"version": "3.0.0",
"version": "3.0.1",
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
"version": "3.0.0",
"version": "3.0.1",
"private": true,
"type": "module",
"main": "src/server.js",
@@ -5,6 +5,11 @@ export const getUpdateStatus = asyncHandler(async (req, res) => {
ok(res, await service.checkForUpdates());
});
export const getApplyProgress = asyncHandler(async (req, res) => {
res.set('Cache-Control', 'no-store');
ok(res, service.getApplyProgress());
});
export const applyUpdate = asyncHandler(async (req, res) => {
ok(res, await service.applyUpdate(req.currentUser.id));
});
+2 -1
View File
@@ -1,11 +1,12 @@
import { Router } from 'express';
import { requireAuth, requireRole, CAN_MANAGE_SETTINGS } from '../../middleware/auth.js';
import { getUpdateStatus, applyUpdate } from './controller.js';
import { getUpdateStatus, getApplyProgress, applyUpdate } from './controller.js';
const router = Router();
router.use(requireAuth, requireRole(...CAN_MANAGE_SETTINGS));
router.get('/status', getUpdateStatus);
router.get('/progress', getApplyProgress);
router.post('/apply', applyUpdate);
export default router;
+21 -1
View File
@@ -10,6 +10,8 @@ import { logAudit } from '../../utils/audit.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const VERSION_FILE = path.resolve(__dirname, '../../../../VERSION');
const MIGRATIONS_DIR = path.resolve(__dirname, '../../../database/migrations');
// Fijada al arrancar: leer VERSION en cada request adelanta falsamente el fin tras git reset.
const runningVersion = fs.readFileSync(VERSION_FILE, 'utf8').trim();
function parseVersion(v) {
const [major, minor, patch] = v.trim().split('.').map(Number);
@@ -70,6 +72,21 @@ function isApplyAvailable() {
return !!(env.update.appDir && env.update.backendService && env.update.frontendService);
}
export function getApplyProgress() {
let state = 'idle';
let phase = 'idle';
let targetVersion = null;
if (isApplyAvailable()) {
try {
const fields = fs.readFileSync(path.join(env.update.appDir, 'logs', 'update.status'), 'utf8').trim().split('|');
if (fields.length === 3) [state, phase, targetVersion] = fields;
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
}
return { runningVersion, state, phase, targetVersion };
}
export async function checkForUpdates() {
const currentVersion = getLocalVersion();
const changelogText = await repo.fetchChangelog();
@@ -128,8 +145,10 @@ export async function applyUpdate(actorId) {
throw new HttpError(409, 'Ya hay una actualizacion en curso');
}
applyInProgress = true;
const scriptPath = path.join(env.update.appDir, 'scripts', 'apply-update.sh');
// La copia externa debe corresponder al código actual antes de hacer git reset en source/.
fs.copyFileSync(path.resolve(__dirname, '../../../../scripts/apply-update.sh'), scriptPath);
applyInProgress = true;
const child = spawn('bash', [scriptPath], {
detached: true,
stdio: 'ignore',
@@ -138,6 +157,7 @@ export async function applyUpdate(actorId) {
APP_DIR: env.update.appDir,
BACKEND_SERVICE: env.update.backendService,
FRONTEND_SERVICE: env.update.frontendService,
TARGET_VERSION: status.latestVersion,
},
});
child.unref();
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "project-linkedcorp-frontend",
"version": "3.0.0",
"version": "3.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "project-linkedcorp-frontend",
"version": "3.0.0",
"version": "3.0.1",
"dependencies": {
"dompurify": "^3.4.16",
"highlight.js": "^11.12.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
"version": "3.0.0",
"version": "3.0.1",
"type": "module",
"scripts": {
"dev": "vite",
+4
View File
@@ -12,6 +12,10 @@
"source": "@(sw.js|registerSW.js|workbox-*.js)",
"headers": [{ "key": "Cache-Control", "value": "no-cache" }]
},
{
"source": "version.json",
"headers": [{ "key": "Cache-Control", "value": "no-store" }]
},
{
"source": "assets/**",
"headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
+20
View File
@@ -93,6 +93,16 @@ export const messages = {
updater_apply_started: 'Actualización iniciada. El sistema se reiniciará en unos minutos.',
updater_applying_hint: 'Actualización en curso — no cierres esta pestaña. El sistema se reiniciará solo al terminar.',
updater_apply_timeout: 'La actualización está tardando más de lo esperado. Revisa el estado en unos minutos o verifica el servidor.',
updater_apply_failed: 'La actualización falló. Revisa el registro del actualizador en el servidor.',
updater_phase_backup: 'Respaldando la base de datos…',
updater_phase_source: 'Descargando el código nuevo…',
updater_phase_backend: 'Instalando backend y aplicando migraciones…',
updater_phase_frontend: 'Instalando y compilando frontend…',
updater_phase_publish: 'Publicando frontend…',
updater_phase_restart: 'Reiniciando servicios…',
updater_phase_verify: 'Comprobando ambas versiones…',
updater_retry_check: 'Reintentar comprobación',
updater_reload: 'Recargar página',
updater_apply_not_configured: 'La auto-actualización no está habilitada en este servidor. Pide a tu administrador que aplique esta actualización por SSH.',
updater_login_popup_title: 'Hay una actualización pendiente',
updater_login_popup_msg: 'El sistema tiene una versión nueva disponible:',
@@ -602,6 +612,16 @@ export const messages = {
updater_apply_started: 'Update started. The system will restart in a few minutes.',
updater_applying_hint: 'Update in progress — do not close this tab. The system will restart on its own when done.',
updater_apply_timeout: 'The update is taking longer than expected. Check back in a few minutes or verify the server.',
updater_apply_failed: 'The update failed. Check the updater log on the server.',
updater_phase_backup: 'Backing up the database…',
updater_phase_source: 'Downloading the new code…',
updater_phase_backend: 'Installing backend and applying migrations…',
updater_phase_frontend: 'Installing and building frontend…',
updater_phase_publish: 'Publishing frontend…',
updater_phase_restart: 'Restarting services…',
updater_phase_verify: 'Checking both versions…',
updater_retry_check: 'Retry check',
updater_reload: 'Reload page',
updater_apply_not_configured: 'Auto-update is not enabled on this server. Ask your administrator to apply this update via SSH.',
updater_login_popup_title: 'There is a pending update',
updater_login_popup_msg: 'A new version of the system is available:',
+2 -27
View File
@@ -1,38 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { registerSW } from 'virtual:pwa-register';
import { registerAppServiceWorker } from './services/serviceWorker.js';
import App from './app/App.jsx';
import { installGlobalErrorReporter } from './utils/errorReporter.js';
import './styles/tokens.css';
import './styles/app.css';
installGlobalErrorReporter();
// autoUpdate: toma la version nueva del service worker sola en cuanto esta lista, sin pedirle
// nada al usuario (no hay riesgo de mostrar una version vieja de la UI por mucho tiempo).
registerSW({
immediate: true,
// Ademas de al cargar, revisa si hay version nueva cada 30 min y al volver a la pestaña --
// asi una pestaña abierta todo el dia no se queda en la version anterior.
onRegisteredSW(_url, registration) {
if (!registration) return;
const check = () => registration.update().catch(() => {});
setInterval(check, 30 * 60 * 1000);
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') check(); });
},
});
// skipWaiting+clientsClaim (activados por autoUpdate) hacen que el service worker nuevo tome
// control solo, pero eso NO refresca la pagina ya abierta -- el usuario seguia viendo JS/CSS
// viejo hasta un F5 manual. Con esto, en cuanto el nuevo SW toma control, se refresca sola una
// vez (la bandera evita un loop si el evento dispara mas de una vez).
if ('serviceWorker' in navigator) {
let reloaded = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (reloaded) return;
reloaded = true;
window.location.reload();
});
}
registerAppServiceWorker();
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
@@ -1,58 +1,100 @@
import { useEffect, useRef, useState } from 'react';
import { RefreshCw, Download } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
import { getUpdateStatus, applyUpdate } from '../../services/updater.js';
import { useAsync } from '../../hooks/useAsync.js';
import { getUpdateStatus, applyUpdate, getApplyProgress, getFrontendVersion } from '../../services/updater.js';
import { activateUpdateAndReload } from '../../services/serviceWorker.js';
import { useModalFocus } from '../../hooks/useModalFocus.js';
import { Button } from '../../components/Button.jsx';
import { Badge } from '../../components/Badge.jsx';
import { InfoBar } from '../../components/InfoBar.jsx';
const CRITICALITY_COLOR = { alta: 'danger', media: 'warning', baja: 'success' };
const POLL_INTERVAL_MS = 5000;
const POLL_MAX_ATTEMPTS = 60; // ~5 minutos tope
const POLL_TIMEOUT_MS = 10 * 60 * 1000;
const PHASE_KEYS = {
backup: 'updater_phase_backup', source: 'updater_phase_source', backend: 'updater_phase_backend',
frontend: 'updater_phase_frontend', publish: 'updater_phase_publish', restart: 'updater_phase_restart',
complete: 'updater_phase_verify',
};
export default function UpdaterPanel() {
const { t, openDialog, pushToast } = useUi();
const { data: status, loading, error, reload, setData } = useAsync(getUpdateStatus, []);
const runAction = useAction();
const { t, openDialog } = useUi();
const { data: status, loading, error, reload } = useAsync(getUpdateStatus, []);
const [applying, setApplying] = useState(false);
const [progressPct, setProgressPct] = useState(0);
const [phase, setPhase] = useState('backup');
const [applyError, setApplyError] = useState(null);
const mountedRef = useRef(true);
const pollingRef = useRef(false);
const modalRef = useModalFocus(applying);
// Setup explicito (no solo el cleanup): con React.StrictMode en dev el efecto se dispara
// mount->cleanup->mount de una, y si el setup no reactiva el flag queda en false para siempre
// aunque el componente SI este montado -- pollUntilUpToDate nunca correria.
// aunque el componente SI este montado -- el sondeo nunca correria.
useEffect(() => {
mountedRef.current = true;
return () => { mountedRef.current = false; };
}, []);
// El servidor se reinicia a mitad de la actualizacion (backend y frontend), asi que durante
// ese rato las llamadas van a fallar por un momento -- se ignoran y se sigue insistiendo. El
// porcentaje es una estimacion sobre el tope de espera (no hay progreso real del script), pero
// alcanza para que la persona sepa que sigue avanzando y no quede mirando una pantalla muerta.
// Al confirmar que ya esta al dia, se recarga la pestaña sola para traer el build nuevo.
async function pollUntilUpToDate() {
for (let i = 0; i < POLL_MAX_ATTEMPTS && mountedRef.current; i++) {
// El estado del script confirma el final; ambas versiones comprueban lo que realmente se sirve.
async function pollUntilReady(targetVersion, startedAt) {
if (pollingRef.current) return;
pollingRef.current = true;
setApplying(true);
setApplyError(null);
try {
while (mountedRef.current && Date.now() - startedAt < POLL_TIMEOUT_MS) {
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
if (!mountedRef.current) return;
setProgressPct(Math.min(95, Math.round(((i + 1) / POLL_MAX_ATTEMPTS) * 100)));
setProgressPct(Math.min(95, Math.round(((Date.now() - startedAt) / POLL_TIMEOUT_MS) * 100)));
try {
const result = await getUpdateStatus();
if (!mountedRef.current) return;
setData(result);
if (result.upToDate) {
const progress = await getApplyProgress();
if (progress.targetVersion !== targetVersion) continue;
setPhase(progress.phase);
if (progress.state === 'failed') {
setApplyError(t('updater_apply_failed'));
return;
}
if (progress.state === 'done' && progress.runningVersion === targetVersion) {
const frontendVersion = await getFrontendVersion();
if (frontendVersion !== targetVersion) continue;
setProgressPct(100);
await new Promise((resolve) => setTimeout(resolve, 600));
window.location.reload();
await activateUpdateAndReload();
return;
}
} catch {
// backend reiniciandose -- se ignora y se reintenta en el proximo ciclo
// Se toleran cortes del backend/frontend mientras reinician los servicios.
}
}
if (mountedRef.current) {
setApplying(false);
pushToast({ tone: 'warning', message: t('updater_apply_timeout') });
if (mountedRef.current) setApplyError(t('updater_apply_timeout'));
} finally {
pollingRef.current = false;
}
}
useEffect(() => {
const pending = sessionStorage.getItem('plc_update_pending');
if (!pending) return;
try {
const { targetVersion, startedAt } = JSON.parse(pending);
if (targetVersion && Number.isFinite(startedAt)) pollUntilReady(targetVersion, startedAt);
} catch { sessionStorage.removeItem('plc_update_pending'); }
// Solo al montar: recupera la espera si la pestaña fue recargada.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function startApply() {
setApplying(true);
setProgressPct(0);
setPhase('backup');
setApplyError(null);
try {
const result = await applyUpdate();
const pending = { targetVersion: result.toVersion, startedAt: Date.now() };
sessionStorage.setItem('plc_update_pending', JSON.stringify(pending));
pollUntilReady(pending.targetVersion, pending.startedAt);
} catch (err) {
setApplyError(err.message);
}
}
@@ -65,12 +107,7 @@ export default function UpdaterPanel() {
{ label: t('common_cancel'), variant: 'standard' },
{
label: t('updater_apply_confirm_ok'), variant: 'danger',
onClick: () => runAction(async () => {
setProgressPct(0);
setApplying(true);
await applyUpdate();
pushToast({ tone: 'success', message: t('updater_apply_started') });
}).then(pollUntilUpToDate).catch(() => setApplying(false)),
onClick: () => { void startApply(); },
},
],
});
@@ -79,13 +116,31 @@ export default function UpdaterPanel() {
return (
<div>
{applying && (
<div className="update-overlay" role="alert" aria-live="assertive">
<div className="update-spinner-wrap">
<div className="update-overlay">
<div className="dialog-surface update-dialog" role="dialog" aria-modal="true" aria-label={t('updater_applying')} tabIndex={-1} ref={modalRef}>
<div className="dialog-stripe" style={{ background: 'rgb(var(--accent))' }} />
<div className="dialog-body">
<div className="dialog-title">{t('updater_applying')}</div>
<div className="update-spinner-wrap" aria-hidden="true">
<div className="update-spinner-ring" />
<div className="update-spinner-center">
<div className="update-spinner-label">{t('updater_applying')}</div>
<div className="update-spinner-pct">{progressPct}%</div>
<div className="update-spinner-center"><div className="update-spinner-pct">{progressPct}%</div></div>
</div>
<div role="status" aria-live="polite">{t(PHASE_KEYS[phase] || 'updater_phase_verify')}</div>
{applyError ? <InfoBar tone="error" dismissible={false}>{applyError}</InfoBar>
: <div className="text-muted">{t('updater_applying_hint')}</div>}
</div>
{applyError && <div className="dialog-actions">
<Button variant="standard" onClick={() => window.location.reload()}>{t('updater_reload')}</Button>
<Button variant="accent" onClick={() => {
const pending = JSON.parse(sessionStorage.getItem('plc_update_pending') || 'null');
if (pending) {
pending.startedAt = Date.now();
sessionStorage.setItem('plc_update_pending', JSON.stringify(pending));
pollUntilReady(pending.targetVersion, pending.startedAt);
}
else { setApplying(false); reload(); }
}}>{t('updater_retry_check')}</Button>
</div>}
</div>
</div>
)}
@@ -102,8 +157,6 @@ export default function UpdaterPanel() {
{status && <span className="text-muted" style={{ fontSize: '.78rem' }}>{t('updater_current_version')} v{status.currentVersion}</span>}
</div>
{applying && <InfoBar tone="warning" dismissible={false}>{t('updater_applying_hint')}</InfoBar>}
{loading && <div className="text-muted">{t('common_loading')}</div>}
{error && <InfoBar tone="error" dismissible={false}>{error}</InfoBar>}
+3 -2
View File
@@ -11,7 +11,7 @@ function getToken() {
return localStorage.getItem('plc_token');
}
async function request(method, path, { body, isForm, extraHeaders } = {}) {
async function request(method, path, { body, isForm, extraHeaders, signal } = {}) {
const headers = { ...extraHeaders };
const token = getToken();
// Nunca operar con un token vencido: se corta la sesion aqui mismo, sin enviar la peticion.
@@ -26,6 +26,7 @@ async function request(method, path, { body, isForm, extraHeaders } = {}) {
method,
headers,
body: isForm ? body : body !== undefined ? JSON.stringify(body) : undefined,
signal,
});
// El servidor renueva el token de desbloqueo de Contraseñas en cada request valido (ver vault/middleware.js).
@@ -51,7 +52,7 @@ async function request(method, path, { body, isForm, extraHeaders } = {}) {
}
export const api = {
get: (path, extraHeaders) => request('GET', path, { extraHeaders }),
get: (path, extraHeaders, options) => request('GET', path, { extraHeaders, ...options }),
post: (path, body, extraHeaders) => request('POST', path, { body, extraHeaders }),
put: (path, body, extraHeaders) => request('PUT', path, { body, extraHeaders }),
patch: (path, body) => request('PATCH', path, { body }),
+42
View File
@@ -0,0 +1,42 @@
import { registerSW } from 'virtual:pwa-register';
let updateSW;
let suppressControllerReload = false;
export function registerAppServiceWorker() {
updateSW = registerSW({
immediate: true,
onRegisteredSW(_url, registration) {
if (!registration) return;
const check = () => registration.update().catch(() => {});
setInterval(check, 30 * 60 * 1000);
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') check(); });
},
});
if ('serviceWorker' in navigator) {
let reloaded = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (reloaded || suppressControllerReload || sessionStorage.getItem('plc_update_pending')) return;
reloaded = true;
window.location.reload();
});
}
}
export async function activateUpdateAndReload() {
suppressControllerReload = true;
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.getRegistration();
await registration?.update();
await updateSW?.(true); // skipWaiting de vite-plugin-pwa
} catch {
// Una navegación de red con cache-busting sigue siendo posible sin SW.
}
}
sessionStorage.removeItem('plc_update_pending');
const url = new URL(window.location.href);
url.searchParams.set('_updated', Date.now().toString());
window.location.replace(url.href);
}
+9
View File
@@ -2,3 +2,12 @@ import { api } from './api.js';
export const getUpdateStatus = () => api.get('/updater/status');
export const applyUpdate = () => api.post('/updater/apply');
export const getApplyProgress = () => api.get('/updater/progress', undefined, { signal: AbortSignal.timeout(8000) });
export async function getFrontendVersion() {
const response = await fetch(`/version.json?update=${Date.now()}`, {
cache: 'no-store', signal: AbortSignal.timeout(8000),
});
if (!response.ok) throw new Error(`Frontend HTTP ${response.status}`);
return (await response.json()).version;
}
+5 -3
View File
@@ -514,17 +514,19 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
adonde ir. z-index por encima de dialog-overlay/detail-popup-overlay a proposito. */
.update-overlay {
position: fixed; inset: 0; background: rgb(0 0 0 / .7); z-index: 300;
display: flex; align-items: center; justify-content: center;
display: flex; align-items: center; justify-content: center; padding: 24px;
}
.update-dialog { width: min(100%, 420px); text-align: center; }
.update-dialog .dialog-body { display: flex; flex-direction: column; align-items: center; gap: 14px; }
.update-spinner-wrap { position: relative; width: 150px; height: 150px; flex: none; }
.update-spinner-ring {
position: absolute; inset: 0; border-radius: 999px;
border: 6px solid rgb(255 255 255 / .2); border-top-color: rgb(var(--accent));
border: 6px solid rgb(var(--line)); border-top-color: rgb(var(--accent));
animation: update-spin 0.9s linear infinite;
}
.update-spinner-center {
position: absolute; inset: 0; display: flex; flex-direction: column; gap: 2px;
align-items: center; justify-content: center; text-align: center; color: #fff;
align-items: center; justify-content: center; text-align: center; color: rgb(var(--body));
}
.update-spinner-label { font-size: .78rem; font-weight: 600; opacity: .85; }
.update-spinner-pct { font-size: 1.7rem; font-weight: 700; }
+10
View File
@@ -1,10 +1,19 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
import { readFileSync } from 'node:fs';
const { version } = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
export default defineConfig({
plugins: [
react(),
{
name: 'frontend-version',
generateBundle() {
this.emitFile({ type: 'asset', fileName: 'version.json', source: JSON.stringify({ version }) });
},
},
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'favicon.png', 'apple-touch-icon.png'],
@@ -25,6 +34,7 @@ export default defineConfig({
],
},
workbox: {
globIgnores: ['**/version.json'],
// El service worker nunca debe interceptar /api — siempre a la red, nunca cache de
// respuestas viejas (datos de proyectos/actividades desactualizados seria peor que no
// tener PWA). navigateFallback (para que recargar una ruta como /proyectos/5 sirva el
+22
View File
@@ -19,11 +19,20 @@ set -euo pipefail
: "${APP_DIR:?falta APP_DIR}"
: "${BACKEND_SERVICE:?falta BACKEND_SERVICE}"
: "${FRONTEND_SERVICE:?falta FRONTEND_SERVICE}"
: "${TARGET_VERSION:?falta TARGET_VERSION}"
LOG="$APP_DIR/logs/update.log"
mkdir -p "$APP_DIR/logs" "$APP_DIR/backups"
exec >>"$LOG" 2>&1
PHASE=backup
write_status() {
printf '%s|%s|%s\n' "$1" "$PHASE" "$TARGET_VERSION" > "$APP_DIR/logs/update.status.tmp"
mv "$APP_DIR/logs/update.status.tmp" "$APP_DIR/logs/update.status"
}
trap 'write_status failed' ERR
write_status running
echo ""
echo "=== update start $(date -Iseconds) ==="
@@ -32,18 +41,25 @@ mysqldump --single-transaction -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASSWORD" "$DB
> "$APP_DIR/backups/pre_update_$(date +%Y%m%d_%H%M%S).sql"
echo "-- git fetch + reset --"
PHASE=source
write_status running
cd "$APP_DIR/source"
git fetch origin main
git reset --hard origin/main
NEW_VERSION="$(cat VERSION)"
echo "version objetivo: $NEW_VERSION"
[[ "$NEW_VERSION" == "$TARGET_VERSION" ]] || { echo "version remota distinta de la solicitada: $TARGET_VERSION"; false; }
echo "-- backend: npm install + migraciones --"
PHASE=backend
write_status running
cd "$APP_DIR/source/backend"
npm install --omit=dev
node src/scripts/migrate-db.js
echo "-- frontend: npm install + build --"
PHASE=frontend
write_status running
cd "$APP_DIR/source/frontend"
# --include=dev es necesario a la fuerza: este script hereda NODE_ENV=production del backend
# (via EnvironmentFile), y con eso npm salta devDependencies por defecto -- incluido vite, que
@@ -53,10 +69,14 @@ npm install --include=dev
npm run build
echo "-- publicando build de frontend --"
PHASE=publish
write_status running
rm -rf "$APP_DIR/frontend/dist"
cp -r dist "$APP_DIR/frontend/dist"
echo "-- reiniciando servicios --"
PHASE=restart
write_status running
# Dos llamadas separadas a proposito: el sudoers NOPASSWD (ver DEPLOY_WEB.md) whitelistea cada
# "systemctl restart <servicio>" por separado -- un solo "systemctl restart back front" junto no
# matchea ninguna de las dos reglas, sudo pide contraseña, falla en este contexto sin terminal, y
@@ -65,4 +85,6 @@ echo "-- reiniciando servicios --"
sudo systemctl restart "$BACKEND_SERVICE"
sudo systemctl restart "$FRONTEND_SERVICE"
PHASE=complete
write_status done
echo "=== update done $(date -Iseconds) -- v$NEW_VERSION ==="