Skip to content

Commit 3edc4ba

Browse files
committed
feat(update): auto-update semanal completo en Helios (patrón Keynest)
1 parent 87ef357 commit 3edc4ba

10 files changed

Lines changed: 273 additions & 7 deletions

File tree

‎app/src/components/UpdateRibbon.tsx‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useEffect, useState } from 'react';
22
import { useTranslation } from 'react-i18next';
33
import { RefreshCw } from 'lucide-react';
4-
import { apiFetch } from '@/data/api-client';
4+
import { apiFetch, apiPost } from '@/data/api-client';
55
import pkg from '../../package.json';
66

77
const CHECK_KEY = 'helios-last-update-check';
@@ -21,13 +21,14 @@ function compareSemver(a: string, b: string): number {
2121
/**
2222
* Ribbon de actualización (patrón app-auto-update: check semanal + aviso si hay
2323
* versión nueva en el repo público). Solo admin. GET anónima a GitHub releases
24-
* (el repo es público), 1 vez por semana vía localStorage; aplicar = redesplegar
25-
* en el servidor, así que el ribbon enlaza a la release.
24+
* (el repo es público), 1 vez por semana vía localStorage; aplicar ejecuta
25+
* helios-update.sh en el servidor (POST /api/update/apply, solo admin).
2626
*/
2727
export default function UpdateRibbon() {
2828
const { t } = useTranslation();
2929
const [state, setState] = useState<'idle' | 'checking' | 'uptodate' | 'available' | 'error'>('idle');
3030
const [latestVersion, setLatestVersion] = useState('');
31+
const [applying, setApplying] = useState(false);
3132

3233
useEffect(() => {
3334
let stale = false;
@@ -78,6 +79,18 @@ export default function UpdateRibbon() {
7879

7980
if (state !== 'available') return null;
8081

82+
const apply = async () => {
83+
if (applying) return;
84+
setApplying(true);
85+
try {
86+
await apiPost<{ ok: boolean }>('/api/update/apply');
87+
// El servidor se reinicia; la app se recarga con el build nuevo.
88+
setTimeout(() => window.location.reload(), 2500);
89+
} catch {
90+
setApplying(false);
91+
}
92+
};
93+
8194
return (
8295
<div
8396
role="status"
@@ -89,10 +102,18 @@ export default function UpdateRibbon() {
89102
href={`${REPO_URL}/releases`}
90103
target="_blank"
91104
rel="noreferrer"
92-
className="ml-auto flex h-8 shrink-0 items-center rounded-lg border border-amber-500/40 px-3 text-xs font-medium text-amber-500 transition-colors hover:bg-amber-500/15"
105+
className="hidden h-8 shrink-0 items-center rounded-lg border border-amber-500/40 px-3 text-xs font-medium text-amber-500 transition-colors hover:bg-amber-500/15 sm:flex"
93106
>
94107
{t('ajustes.about.viewRelease')}
95108
</a>
109+
<button
110+
type="button"
111+
onClick={() => void apply()}
112+
disabled={applying}
113+
className="ml-auto flex h-8 shrink-0 items-center rounded-lg border border-amber-500/40 bg-amber-500 px-3 text-xs font-medium text-white transition-colors hover:brightness-110 disabled:opacity-60"
114+
>
115+
{applying ? t('ajustes.about.applying') : t('ajustes.about.updateNow')}
116+
</button>
96117
</div>
97118
);
98119
}

‎app/src/i18n/locales/en/translation.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,8 @@
333333
"upToDate": "You're up to date — v{{version}} is the latest version",
334334
"updateAvailable": "New version available: v{{version}}",
335335
"viewRelease": "What's new",
336+
"updateNow": "Update now",
337+
"applying": "Updating…",
336338
"updateError": "Couldn't check for updates. Try again.",
337339
"made": "made at home"
338340
},

‎app/src/i18n/locales/es/translation.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,8 @@
333333
"upToDate": "Estás al día — v{{version}} es la última versión",
334334
"updateAvailable": "Nueva versión disponible: v{{version}}",
335335
"viewRelease": "Ver novedades",
336+
"updateNow": "Actualizar ahora",
337+
"applying": "Actualizando…",
336338
"updateError": "No se pudo comprobar la actualización. Inténtalo de nuevo.",
337339
"made": "hecho en casa"
338340
},

‎app/src/i18n/locales/zh-CN/translation.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,8 @@
331331
"upToDate": "已是最新版本 — v{{version}} 是最新版",
332332
"updateAvailable": "有新版本可用:v{{version}}",
333333
"viewRelease": "查看更新",
334+
"updateNow": "立即更新",
335+
"applying": "更新中…",
334336
"updateError": "无法检查更新。请重试。",
335337
"made": "在家制作"
336338
},

‎app/src/pages/Ajustes.tsx‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,20 @@ function AdminZone() {
176176
const [checking, setChecking] = useState(false);
177177
const [updateStatus, setUpdateStatus] = useState<'idle' | 'uptodate' | 'available' | 'error'>('idle');
178178
const [latestVersion, setLatestVersion] = useState('');
179+
const [applying, setApplying] = useState(false);
180+
181+
const applyUpdate = async () => {
182+
if (applying) return;
183+
setApplying(true);
184+
try {
185+
await apiPost<{ ok: boolean }>('/api/update/apply');
186+
// El servidor se reinicia; la app se recarga con el build nuevo.
187+
setTimeout(() => window.location.reload(), 2500);
188+
} catch {
189+
setApplying(false);
190+
setUpdateStatus('error');
191+
}
192+
};
179193

180194
const checkUpdates = async () => {
181195
setChecking(true);
@@ -257,9 +271,19 @@ function AdminZone() {
257271
</span>
258272
)}
259273
{updateStatus === 'available' && (
260-
<a href={`${REPO_URL}/releases`} target="_blank" rel="noreferrer" className="text-[10px] font-medium text-brand">
261-
{t('ajustes.about.updateAvailable', { version: latestVersion })}
262-
</a>
274+
<>
275+
<a href={`${REPO_URL}/releases`} target="_blank" rel="noreferrer" className="text-[10px] font-medium text-brand">
276+
{t('ajustes.about.updateAvailable', { version: latestVersion })}
277+
</a>
278+
<button
279+
type="button"
280+
onClick={() => void applyUpdate()}
281+
disabled={applying}
282+
className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-brand bg-brand/10 px-2.5 text-[11px] font-semibold text-brand transition-colors hover:bg-brand/20 disabled:opacity-60"
283+
>
284+
{applying ? t('ajustes.about.applying') : t('ajustes.about.updateNow')}
285+
</button>
286+
</>
263287
)}
264288
{updateStatus === 'error' && (
265289
<span role="alert" className="text-[10px] font-medium text-destructive">

‎deploy/helios-update.service‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[Unit]
2+
Description=Helios — aplicar actualización semanal (releases estables + checksums)
3+
After=network-online.target
4+
Wants=network-online.target
5+
6+
[Service]
7+
Type=oneshot
8+
ExecStart=/opt/helios/helios-update.sh
9+
# Auto-update de la app: corre como root porque necesita systemctl restart y
10+
# chown -R helios. Nada de eso toca datos (SQLite en /opt/helios/data).
11+
TimeoutStartSec=600

‎deploy/helios-update.sh‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
#!/bin/sh
2+
# helios-update.sh — aplica la última release ESTABLE de Helios.
3+
# Patrón app-auto-update (variante Node), layout PLANO del CT:
4+
# /opt/helios/{server,public,shared}
5+
# server/ → código (src/) + package.json + node_modules + .env (se conserva)
6+
# public/ → dist/ del frontend
7+
# shared/ → schemas compartidos
8+
# datos en $STATE_DIR (/opt/helios/data, o server/data) — NUNCA se tocan.
9+
# El marker /opt/helios/.release-id es la fuente de verdad de la versión
10+
# instalada (server/src/update.js la lee para /api/update/status).
11+
set -eu
12+
13+
APP=helios
14+
REPO=gnacho/helios
15+
ARCH="$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')"
16+
OPT_DIR=/opt/helios
17+
STATE_DIR=/opt/helios/data
18+
MARKER="$OPT_DIR/.release-id"
19+
ENV_FILE="$OPT_DIR/server/.env"
20+
SERVICE_NAME=helios
21+
TMP_DIR="$(mktemp -d)"
22+
trap 'rm -rf "$TMP_DIR"' EXIT INT TERM
23+
24+
log() { logger -t "$APP-update" "$@"; }
25+
26+
echo "STEP:detect"
27+
VER="$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" \
28+
| sed -n 's/.*"tag_name": *"\(v\?[0-9][^"]*\)".*/\1/p' | head -n1)"
29+
[ -n "$VER" ] || { log "no se pudo resolver release latest"; exit 4; }
30+
VER_NO_V="$(printf '%s' "$VER" | sed 's/^v//')"
31+
32+
# Marker semver real (fuente de verdad para /api/update/status).
33+
if [ -f "$MARKER" ] && [ "$(cat "$MARKER" 2>/dev/null || true)" = "$VER_NO_V" ]; then
34+
log "al día ($VER_NO_V)"; exit 0
35+
fi
36+
37+
echo "STEP:download"
38+
TARBALL="helios_${VER_NO_V}_linux_${ARCH}.tar.gz"
39+
BASE="https://github.com/$REPO/releases/download/$VER"
40+
curl -fL "$BASE/$TARBALL" -o "$TMP_DIR/app.tar.gz"
41+
curl -fL "$BASE/checksums.txt" -o "$TMP_DIR/checksums.txt"
42+
43+
echo "STEP:verify"
44+
expected="$(awk -v f="$TARBALL" '$0 ~ f {print $1; exit}' "$TMP_DIR/checksums.txt")"
45+
[ -n "$expected" ] || { log "checksums.txt sin entrada para $TARBALL"; exit 5; }
46+
got="$(sha256sum "$TMP_DIR/app.tar.gz" | awk '{print $1}')"
47+
[ "$expected" = "$got" ] || { log "SHA256 NO coincide ($TARBALL)"; exit 5; }
48+
49+
echo "STEP:extract"
50+
mkdir -p "$TMP_DIR/pkg"
51+
tar -xzf "$TMP_DIR/app.tar.gz" -C "$TMP_DIR/pkg"
52+
53+
echo "STEP:deploy"
54+
# Backup del estado actual antes de tocar nada.
55+
TS="$(date +%Y%m%d-%H%M%S)"
56+
[ -d "$OPT_DIR/server/src" ] && cp -a "$OPT_DIR/server/src" "$OPT_DIR/server/src.bak-$TS"
57+
[ -d "$OPT_DIR/shared" ] && cp -a "$OPT_DIR/shared" "$OPT_DIR/shared.bak-$TS"
58+
[ -d "$OPT_DIR/public" ] && cp -a "$OPT_DIR/public" "$OPT_DIR/public.bak-$TS"
59+
60+
# Código server: src/ nuevo (conserva .env y node_modules si no cambian).
61+
rm -rf "$OPT_DIR/server/src"
62+
cp -a "$TMP_DIR/pkg/server/src" "$OPT_DIR/server/src"
63+
[ -f "$TMP_DIR/pkg/server/package.json" ] && \
64+
install -m 0644 "$TMP_DIR/pkg/server/package.json" "$OPT_DIR/server/package.json"
65+
if [ -d "$TMP_DIR/pkg/server/node_modules" ]; then
66+
rm -rf "$OPT_DIR/server/node_modules"
67+
cp -a "$TMP_DIR/pkg/server/node_modules" "$OPT_DIR/server/node_modules"
68+
fi
69+
70+
# Shared + frontend.
71+
rm -rf "$OPT_DIR/shared"
72+
cp -a "$TMP_DIR/pkg/shared" "$OPT_DIR/shared"
73+
rm -rf "$OPT_DIR/public"
74+
mkdir -p "$OPT_DIR/public"
75+
cp -a "$TMP_DIR/pkg/dist/." "$OPT_DIR/public/"
76+
77+
chown -R "$APP:$APP" "$OPT_DIR/server" "$OPT_DIR/shared" "$OPT_DIR/public"
78+
chown "$APP:$APP" "$OPT_DIR/server/.env" 2>/dev/null || true
79+
80+
echo "STEP:restart"
81+
printf '%s' "$VER_NO_V" > "$MARKER"
82+
chmod 0644 "$MARKER"
83+
if [ "${SKIP_RESTART:-0}" != "1" ]; then
84+
systemctl restart "$SERVICE_NAME"
85+
fi
86+
log "actualizado a $VER_NO_V"
87+
88+
# Rollback manual (cada dir tiene su backup .bak-$TS):
89+
# rm -rf /opt/helios/server/src && mv /opt/helios/server/src.bak-$TS /opt/helios/server/src \
90+
# && rm -rf /opt/helios/shared && mv /opt/helios/shared.bak-$TS /opt/helios/shared \
91+
# && rm -rf /opt/helios/public && mv /opt/helios/public.bak-$TS /opt/helios/public \
92+
# && systemctl restart helios

‎deploy/helios-update.timer‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[Unit]
2+
Description=Helios — comprobar y aplicar actualizaciones (semanal)
3+
RefuseManualStart=no
4+
5+
[Timer]
6+
OnCalendar=weekly
7+
Persistent=true
8+
RandomizedDelaySec=4h
9+
10+
[Install]
11+
WantedBy=timers.target

‎server/src/index.js‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const auth = await import('./auth.js')
2727
const push = await import('./push.js')
2828
const { registerPushRoutes } = await import('./routes-push.js')
2929
const alerts = await import('./alerts.js')
30+
const { updateStatus, applyUpdate } = await import('./update.js')
3031
const schemas = (await import('../../shared/schemas.js')).createSchemas(z)
3132
const { dateSchema, loginSchema, registerSchema, profileSchema, passwordSchema, historyQuerySchema, auditQuerySchema, adminPasswordSchema, adminLanguageSchema, adminRoleSchema } = schemas
3233

@@ -115,6 +116,34 @@ app.get('/api/system/info', (c) => {
115116
})
116117
})
117118

119+
// --- Actualizaciones (solo admin): detecta la última release estable del repo
120+
// (releases/latest) y la aplica ejecutando helios-update.sh (deploy/). El apply
121+
// no toca datos (SQLite está en $DATA_DIR, fuera del release).
122+
app.get('/api/update/status', async (c) => {
123+
const session = auth.sessionIdFromCookie(db, c)
124+
if (!session) return c.json({ authenticated: false }, 401)
125+
const user = dbModule.getUserById(db, session.userId)
126+
if (!user || user.role !== 'admin') {
127+
return c.json({ error: 'solo administradores pueden consultar actualizaciones' }, 403)
128+
}
129+
return c.json(await updateStatus(db))
130+
})
131+
132+
app.post('/api/update/apply', async (c) => {
133+
const session = auth.sessionIdFromCookie(db, c)
134+
if (!session) return c.json({ authenticated: false }, 401)
135+
const user = dbModule.getUserById(db, session.userId)
136+
if (!user || user.role !== 'admin') {
137+
return c.json({ error: 'solo administradores pueden actualizar' }, 403)
138+
}
139+
const ok = await applyUpdate()
140+
if (!ok) return c.json({ error: 'el script de actualización falló' }, 500)
141+
// El script con SKIP_RESTART=1 deja el server vivo hasta aquí; se sale y
142+
// systemd (Restart=always) relanza con el código nuevo.
143+
setTimeout(() => process.exit(0), 1500)
144+
return c.json({ ok: true, restarting: true }, 202)
145+
})
146+
118147

119148
// Security headers middleware
120149
app.use('*', async (c, next) => {

‎server/src/update.js‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// update.js — estado y aplicación de actualizaciones (patrón Keynest,
2+
// skill app-auto-update): detecta la última release ESTABLE del repo
3+
// (releases/latest, tag v*) y, si hay versión nueva, la aplica ejecutando
4+
// helios-update.sh (deploy/, versionado en el repo: releases + checksums +
5+
// marker semver). El server NO se auto-aplica en runtime: el script hace el
6+
// deploy y, con SKIP_RESTART=1, el server sale y systemd (Restart=always)
7+
// relanza con el código nuevo.
8+
import { execFile } from 'node:child_process'
9+
import { readFileSync } from 'node:fs'
10+
import { kvGet, kvSet } from './db.js'
11+
12+
const REPO = process.env.GITHUB_REPO || 'gnacho/helios'
13+
const MARKER = process.env.RELEASE_MARKER || '/opt/helios/.release-id'
14+
const UPDATE_SCRIPT = process.env.UPDATE_SCRIPT || '/opt/helios/helios-update.sh'
15+
const CACHE_KEY = 'gh_latest_release'
16+
const CACHE_TTL = 5 * 60 * 1000
17+
18+
// Versión semver instalada (marker lo escribe helios-update.sh tras cada deploy).
19+
export function currentId() {
20+
try {
21+
return readFileSync(MARKER, 'utf8').trim()
22+
} catch {
23+
return ''
24+
}
25+
}
26+
27+
// Última release ESTABLE (releases/latest, tag v*), no la prerelease "latest"
28+
// de main. Caché en kv con TTL 5 min para no pegar a la API de GitHub en cada
29+
// llamada (rate-limit 60/h por IP sin token).
30+
async function latestId(db) {
31+
const cached = kvGet(db, CACHE_KEY)
32+
if (cached) {
33+
try {
34+
const c = JSON.parse(cached)
35+
if (Date.now() - c.at < CACHE_TTL) return c.id
36+
} catch { /* noop */ }
37+
}
38+
const res = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, {
39+
headers: { 'User-Agent': 'helios-updater', Accept: 'application/vnd.github+json' },
40+
signal: AbortSignal.timeout(10000),
41+
})
42+
if (!res.ok) return null
43+
const data = await res.json()
44+
const id = String(data.tag_name ?? '').replace(/^v/, '')
45+
kvSet(db, CACHE_KEY, JSON.stringify({ at: Date.now(), id }))
46+
return id
47+
}
48+
49+
// Comparación semver numérica: '0.10.0' > '0.9.0'; prefijos 'v' ignorados.
50+
function compareSemver(a, b) {
51+
const pa = String(a).replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0)
52+
const pb = String(b).replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0)
53+
for (let i = 0; i < 3; i++) {
54+
if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0)
55+
}
56+
return 0
57+
}
58+
59+
export async function updateStatus(db) {
60+
const current = currentId()
61+
const latest = await latestId(db).catch(() => null)
62+
const available = Boolean(latest && current && compareSemver(latest, current) > 0)
63+
return { current, latest, available }
64+
}
65+
66+
export function applyUpdate() {
67+
return new Promise((resolve) => {
68+
execFile(UPDATE_SCRIPT, { env: { ...process.env, SKIP_RESTART: '1' } }, (err) => {
69+
resolve(!err)
70+
})
71+
})
72+
}

0 commit comments

Comments
 (0)