Compare commits

...

5 Commits

Author SHA1 Message Date
67c6babc7f fix(cras-install): negarse a actualizar Windows con un artefacto anterior a los arreglos
El 1.1.3 publicado en Gitea se construyo antes de los arreglos, y el panel lo
desplego igual. Ese instalador detiene la tarea y mata los procesos, corre el
bootstrap acotado a 20s —insuficiente para desempacar un onefile de ~250 MB con
el antivirus escaneando, asi que muere antes de escribir config\.version—,
re-registra la tarea como SYSTEM y la arranca sin comprobar que volviera. Y el
binario del mismo paquete lleva el runner.py que ignora --headless en Windows,
asi que como SYSTEM en la sesion 0 Qt no puede crear su plataforma y el agente
muere. El servidor se quedo sin agente viejo y sin agente nuevo.

La sonda que lo detecta ya existia, pero solo lo asentaba como paso informativo y
seguia adelante — y seguir adelante es lo que rompe el servidor. Ahora
checkWindowsArtifactUpdateSupport lanza 409 al ACTUALIZAR, nombrando la version y
la consecuencia; en instalacion limpia solo avisa, porque ahi no hay agente que
perder. Es una funcion aparte y no una comprobacion enterrada en installWindows:
es una politica con consecuencias, merece nombre y prueba propia.

Ademas, dos cosas para que el proximo fallo se explique solo:

- El crash log se busca en las CINCO ubicaciones posibles, no solo junto al
  ejecutable. runner.py tiene tres candidatos y los dos ultimos, para un proceso
  que corre como SYSTEM, caen bajo C:\Windows\System32\config\systemprofile —
  precisamente donde estaba la evidencia de este fallo, y por eso el error salio
  sin crash log. El mensaje dice ademas de cual se leyo.
- Se sondea la cuenta de la tarea despues de instalar y se asienta el cambio. El
  salto Administrator -> SYSTEM es lo que rompio este servidor y ocurria en
  silencio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:42:58 -06:00
c7f5bf53a1 Merge branch 'development' into fix/windows-actualizacion-silenciosa
La PR #24 aplasto el commit 9a4124d de esta rama en a895044, con otro hash, asi
que git vio dos historias para el mismo contenido y marco conflicto en
cras-install.ts, cras-verify.ts y cras-install.test.ts.

Comprobado antes de resolver: la version de development de los tres archivos es
IDENTICA byte a byte a la de 9a4124d, o sea que la PR se mezclo sin cambios de
revision. Los dos commits posteriores de esta rama (3572aa9 y 9b05361) son
refinamientos encima de ese mismo contenido, asi que resolver a favor de esta
rama no pierde nada.

Verificado despues del merge: 346 pruebas, typecheck limpio, y siguen presentes
alignWindowsTask, restartWindowsAgent, readWindowsCrashLog y la comparacion de
rutas compartida entre el instalador y Verificar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:15:31 -06:00
9b05361a00 refactor(cras-install): una sola comparacion de rutas de Windows, y hacer visible el destino
La pregunta "son la misma ruta?" estaba resuelta tres veces con tres criterios:
normalizeWindowsPath recortaba comillas multiples, probeWindowsAgentProcess una
sola, y cras-verify ninguna. Coincidian en los casos reales, pero este modulo ya
lleva escrito lo que cuesta esa duplicacion — probeLinuxElevation e inspectLinux
tuvieron copias paralelas y las dos pantallas acabaron diciendo cosas distintas
del mismo servidor. Ahora es sameWindowsPath() + windowsAgentExe(), usadas en los
tres sitios.

Lo que NO se hace, y queda documentado en el codigo: comparar por prefijo.
C:\Aduanasoft\CloudRestoreAS es prefijo de cadena de
C:\Aduanasoft\CloudRestoreAS-win, y esa combinacion existe en produccion.

Pruebas del par peligroso en las dos direcciones. Comprobado que MUERDEN:
sustituyendo la igualdad por startsWith, falla la comparacion a nivel de carpeta.
Las de rutas completas de .exe no fallan, y eso tambien es informacion — son
estructuralmente inmunes porque el caracter que difiere llega antes del final, asi
que el riesgo vive solo en la comparacion de directorios.

Y el formulario dice a que carpeta va a instalar. Antes la ruta solo aparecia
dentro del aviso de "instalar limpio", que se pinta unicamente si el panel ya
conoce la version instalada: en un servidor con ruta personalizada y version
desconocida el operador no la veia hasta que el run fallaba. Se distingue "ruta
registrada" de "por omision" — no si la capturo el agente o una persona, porque
ambas viven en la misma columna y el panel no puede saberlo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:10:07 -06:00
3572aa974b fix(cras-install): realinear la tarea de Windows desde el panel, y decir por que fallo
La actualizacion ya fallaba ruidosamente en vez de mentir, pero el arreglo no
podia llegar al servidor: el install.ps1 que se ejecuta viaja DENTRO del zip, y
el artefacto 1.1.3 se construyo antes de que el instalador aprendiera a
realinear la tarea. Verificado sobre el zip: trae -UpdateInPlace y cero
Sync-AgentTaskPath. Un instalador ya publicado no se arregla hacia atras.

- alignWindowsTask realinea la accion de la tarea por SSH tras instalar el
  binario, conservando disparador, principal, ajustes y argumentos, y asienta la
  ruta ANTERIOR y la nueva para que el cambio sea reversible si la ruta declarada
  estuviera mal. Si no se puede corregir, aborta con 409 nombrando ambas: seguir
  significaria arrancar a sabiendas el binario viejo. Funciona con cualquier
  artefacto ya distribuido.
- restartWindowsAgent rearranca y espera a que corra el binario del prefijo, no
  cualquier proceso con ese nombre. Vive en cras-install y no en
  cras-agent-control porque este es el modulo de abajo; al reves habria un ciclo.

Y el diagnostico deja de ser una tarea para el operador. El error de sello
ausente decia "revisa a que binario apunta el arranque automatico" cuando el
panel YA lo sabe, y la bitacora del run donde si estaba no la encontraba nadie
("no se donde verlo"). Ahora se sondea tarea y proceso ANTES de evaluar el sello
y el mensaje nombra a que apunta la tarea, desde donde corre el proceso, y la
cola de CloudRestoreAS-crash.log — que runner.py escribe precisamente para esto
y que nunca se leia. El banner de error apunta al run por numero.

startOnWindows deja de esperar por nombre y usa la misma sonda de ruta, para que
Verificar no pueda contradecir al instalador.

PowerShell generado validado con PowerShell real: los seis scripts parsean, y el
de realineacion ejercitado contra una tarea simulada reapunta la accion Exec,
conserva los argumentos y respeta las acciones que no son Exec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:57:16 -06:00
a89504456e fix(cras-install): dejar de dar por buena una actualizacion que no surtio efecto (#24)
El run de Windows terminaba en verde sobre un servidor que seguia con la version
anterior. La verificacion no podia detectarlo:

- El sello config\.version ausente se toleraba SIEMPRE. Los agentes anteriores a
  1.1.1 no lo escribian, asi que al actualizar uno de esos `deployed` llegaba
  vacio y no se comprobaba ninguna version — justo la combinacion que importa.
  Ahora, en una ACTUALIZACION, un sello ausente es un fallo: el binario nuevo lo
  escribe en ensure_runtime_layout(), asi que su ausencia significa que lo que
  corre no es el que se instalo. La tolerancia se queda solo en instalacion nueva.
- `Get-Process -Name` responde "hay un proceso con ese nombre", no "corre el
  binario que instale". Se compara la RUTA del ejecutable; si no es legible no se
  concluye que sea ajeno, porque un proceso de SYSTEM no la expone sin elevacion.
- Se interroga la tarea programada a fondo: su accion dice DONDE arranca el
  agente de verdad y se asienta en la bitacora (el dato que habria explicado esto
  en diez segundos), y cuando el agente no reporta su ruta —nada anterior a 1.1.1
  lo hace— se usa la de la tarea en vez del default.

Y dos correcciones de lo anterior:

- needsElevation exigia admin en cuanto la tarea EXISTIA, bloqueando de entrada
  toda actualizacion sobre un servidor ya instalado. Ahora depende de que la
  tarea corra como SYSTEM, que es lo que de verdad obliga a elevar; y el token
  filtrado por UAC deja de rechazarse por adelantado: se intenta y se reporta lo
  que responda el servidor.
- -UpdateInPlace se pasaba siempre, pero solo existe desde 1.1.3 y install.ps1
  usa [CmdletBinding()], asi que con un artefacto anterior fallaba con
  NamedParameterNotFound sin ejecutar una linea. Se le pregunta a PowerShell por
  el param() del propio artefacto en vez de mantener una tabla de versiones.

Verificar comparte las dos sondas nuevas para que no pueda contradecir al
instalador, y gana un chequeo de "el arranque apunta a la instalacion".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Reviewed-on: #24
Co-authored-by: hreyes <hreyes@aduanasoft.com.mx>
Co-committed-by: hreyes <hreyes@aduanasoft.com.mx>
2026-07-31 16:29:42 +00:00
5 changed files with 680 additions and 44 deletions

View File

@@ -16,6 +16,7 @@ import { getRestoreTargetSsh } from './controldesk-pg';
import {
execRemote,
probeLinuxElevation,
probeWindowsAgentProcess,
probeWindowsElevation,
psEncoded,
shQuote,
@@ -23,7 +24,11 @@ import {
} from './cras-install';
import { probeRemoteSystem } from './cras-verify';
import { listCrasTargetInventory } from './cras-releases';
import { effectiveInstallPath, DEFAULT_INSTALL_PATHS } from '$lib/cras-version';
import {
effectiveInstallPath,
DEFAULT_INSTALL_PATHS,
type CrasPlatform
} from '$lib/cras-version';
import type { ApiErrorStatus } from './api-error';
import { logger } from './logger';
@@ -65,7 +70,7 @@ async function waitAlive(check: () => Promise<boolean>): Promise<boolean> {
}
}
async function startOnWindows(sftp: SftpClient): Promise<StartAgentOutcome> {
async function startOnWindows(sftp: SftpClient, prefix: string): Promise<StartAgentOutcome> {
const privileged = await probeWindowsElevation(sftp);
if (privileged.elevation !== 'admin') {
throw new AgentControlError(
@@ -96,38 +101,51 @@ async function startOnWindows(sftp: SftpClient): Promise<StartAgentOutcome> {
);
}
// Se espera con la MISMA sonda que usa el instalador, que mira la ruta del ejecutable y no solo
// el nombre. Con la comprobación por nombre, un agente viejo corriendo desde otra carpeta hacía
// pasar por bueno un arranque que no ocurrió.
let ultima = await probeWindowsAgentProcess(sftp, prefix);
const alive = await waitAlive(async () => {
const proc = await execRemote(
sftp,
psEncoded(
"if (Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue) " +
'{"si"} else {"no"}'
)
);
return proc.stdout.trim() === 'si';
ultima = await probeWindowsAgentProcess(sftp, prefix);
// `paths` vacío con el proceso vivo es "no pude ver desde dónde corre" (un proceso de SYSTEM
// no expone su ruta sin elevación), y eso se acepta: no se afirma que sea de otra carpeta.
return ultima.fromPrefix || (ultima.running && ultima.paths.length === 0);
});
return alive
? { ok: true, detail: 'Tarea CloudRestoreAS arrancada y proceso en ejecución.' }
: {
ok: false,
detail:
'Se pidió el arranque de la tarea, pero el proceso no apareció. Revisa ' +
'config\\logs en el servidor: el agente está fallando al iniciar.'
};
if (alive) {
return {
ok: true,
detail:
'Tarea CloudRestoreAS arrancada y proceso en ejecución' +
(ultima.paths.length ? ` desde ${ultima.paths.join(', ')}.` : '.')
};
}
return {
ok: false,
detail: ultima.running
? `Hay un agente corriendo desde ${ultima.paths.join(', ')}, no desde ${prefix}: el ` +
'arranque automático apunta a otra instalación.'
: 'Se pidió el arranque de la tarea, pero el proceso no apareció. Revisa ' +
`${prefix}\\config\\logs en el servidor: el agente está fallando al iniciar.`
};
}
/**
* Ruta donde vive el agente, para poder reconocer su proceso.
*
* El ancla `^` del patrón no es opcional: sin ella, el `sh -c` que corre el propio `pgrep` lleva
* la ruta en su línea de comandos y haría match consigo mismo, reportando vivo un agente que
* nunca arrancó. Es el mismo motivo por el que install.sh ancla su patrón.
* Hace falta en las dos plataformas y por el mismo motivo: sin ella solo se puede preguntar por el
* NOMBRE del proceso, que no distingue el agente que nos interesa de otro corriendo desde otra
* carpeta. En Linux, además, el ancla `^` del patrón de `pgrep` no es opcional: sin ella el `sh -c`
* que corre el propio pgrep lleva la ruta en su línea de comandos y haría match consigo mismo,
* reportando vivo un agente que nunca arrancó.
*/
async function resolveLinuxPrefix(restoreTargetId: number): Promise<string> {
async function resolvePrefix(restoreTargetId: number, platform: CrasPlatform): Promise<string> {
const inventory = await listCrasTargetInventory();
const row = inventory.find((t) => t.restore_target_id === restoreTargetId);
return effectiveInstallPath(row?.reported_install_path ?? null, 'linux') ?? DEFAULT_INSTALL_PATHS.linux;
return (
effectiveInstallPath(row?.reported_install_path ?? null, platform) ??
DEFAULT_INSTALL_PATHS[platform]
);
}
async function startOnLinux(
@@ -203,11 +221,11 @@ export async function startCrasAgent(restoreTargetId: number): Promise<StartAgen
const system = await probeRemoteSystem(sftp);
if (system.verdict === 'windows') {
return await startOnWindows(sftp);
return await startOnWindows(sftp, await resolvePrefix(restoreTargetId, 'windows'));
}
if (system.verdict === 'linux') {
const privileged = await probeLinuxElevation(sftp, target.ssh_password);
const prefix = await resolveLinuxPrefix(restoreTargetId);
const prefix = await resolvePrefix(restoreTargetId, 'linux');
return await startOnLinux(sftp, privileged, prefix);
}
throw new AgentControlError(

View File

@@ -28,9 +28,13 @@ import {
execRemote,
probeInPlaceUpdate,
probeLinuxElevation,
alignWindowsTask,
checkWindowsArtifactUpdateSupport,
normalizeWindowsPath,
probeWindowsAgentProcess,
probeWindowsElevation,
probeWindowsTask,
sameWindowsPath,
psEncoded,
shQuote,
verifyWindowsDeployment,
@@ -428,11 +432,110 @@ describe('probeWindowsElevation', () => {
* que lo único que se actualiza se identifica por RUTA. En cuanto las dos no coincidían se
* reemplazaba un binario y se arrancaba otro, y ni el instalador ni el panel se enteraban.
*/
/**
* Rutas de instalación personalizadas.
*
* `C:\Aduanasoft\CloudRestoreAS-win` es el peor caso posible y existe en producción: la ruta por
* omisión `C:\Aduanasoft\CloudRestoreAS` es **prefijo de cadena** de ella. Cualquier comparación
* hecha con `startsWith` daría por iguales dos instalaciones distintas, y el resultado sería
* exactamente el fallo que se estuvo persiguiendo: actualizar una carpeta y arrancar la otra.
*
* Las pruebas de más abajo usaban nombres sin relación entre sí (`C:\Declarada` contra `C:\Otra`),
* así que un `startsWith` mal puesto las pasaba todas.
*/
describe('comparación de rutas de Windows', () => {
const DEFECTO = 'C:\\Aduanasoft\\CloudRestoreAS';
const PERSONALIZADA = 'C:\\Aduanasoft\\CloudRestoreAS-win';
it('NO confunde la ruta por omisión con una que la tiene como prefijo', () => {
expect(sameWindowsPath(DEFECTO, PERSONALIZADA)).toBe(false);
// Y en el otro sentido: da igual cuál sea la instalada.
expect(sameWindowsPath(PERSONALIZADA, DEFECTO)).toBe(false);
expect(
sameWindowsPath(`${DEFECTO}\\CloudRestoreAS.exe`, `${PERSONALIZADA}\\CloudRestoreAS.exe`)
).toBe(false);
});
it('normaliza comillas, barras finales y mayúsculas', () => {
// La acción de una tarea guarda la ruta entrecomillada; una ruta capturada a mano suele
// traer barra final; y NTFS no distingue caso.
expect(sameWindowsPath(`"${PERSONALIZADA}"`, PERSONALIZADA)).toBe(true);
expect(sameWindowsPath(`${PERSONALIZADA}\\`, PERSONALIZADA)).toBe(true);
expect(sameWindowsPath(PERSONALIZADA.toUpperCase(), PERSONALIZADA)).toBe(true);
expect(normalizeWindowsPath(' ""C:\\X\\"" ')).toBe('c:\\x');
});
it('una ruta vacía no es igual a nada, ni a otra vacía', () => {
// Si lo fuera, un `execute` sin leer pasaría por "ya está alineada" y no se corregiría nada.
expect(sameWindowsPath('', '')).toBe(false);
expect(sameWindowsPath('', PERSONALIZADA)).toBe(false);
});
});
/**
* Artefactos anteriores a los arreglos de actualización.
*
* El caso real: se publicó un 1.1.3 construido antes de los arreglos, y el panel lo desplegó. Ese
* instalador mata el agente, cambia la tarea a SYSTEM y no comprueba que vuelva; y su binario ignora
* `--headless` en Windows, así que como SYSTEM en la sesión 0 no levanta. El servidor se quedó sin
* agente viejo y sin agente nuevo.
*/
describe('checkWindowsArtifactUpdateSupport', () => {
it('ACTUALIZAR con un instalador antiguo se rechaza en vez de romper el servidor', () => {
try {
checkWindowsArtifactUpdateSupport('update', false, '1.1.3', 'Principal');
throw new Error('se esperaba que lanzara');
} catch (e) {
const err = e as InstallError;
expect(err).toBeInstanceOf(InstallError);
// 409 y no 502: es un conflicto de estado —este artefacto no sirve para esto—, no una
// avería del servidor destino.
expect(err.status).toBe(409);
expect(err.message).toContain('1.1.3');
expect(err.message).toContain('Principal');
// El mensaje tiene que decir la CONSECUENCIA, no solo que falta un parámetro.
expect(err.message).toMatch(/SIN agente/);
}
});
it('INSTALAR limpio con el mismo artefacto solo avisa', () => {
// Ahí no hay agente en marcha que perder, así que negarse solo estorbaría.
const aviso = checkWindowsArtifactUpdateSupport('install', false, '1.1.3', 'Principal');
expect(aviso).toContain('1.1.3');
expect(aviso).toMatch(/Se instala igual/);
});
it('un artefacto con los arreglos no dice nada, en ninguno de los dos modos', () => {
expect(checkWindowsArtifactUpdateSupport('update', true, '1.1.4', 'Principal')).toBe('');
expect(checkWindowsArtifactUpdateSupport('install', true, '1.1.4', 'Principal')).toBe('');
});
});
describe('sondas de ruta en Windows', () => {
const DEFECTO = 'C:\\Aduanasoft\\CloudRestoreAS';
const PERSONALIZADA = 'C:\\Aduanasoft\\CloudRestoreAS-win';
function psFake(stdout: string) {
return fakeSftp(() => ({ code: 0, stdout }));
}
it('el proceso de la ruta por omisión no cuenta como el de la personalizada', async () => {
const { sftp } = psFake(`corriendo|${DEFECTO}\\CloudRestoreAS.exe`);
const r = await probeWindowsAgentProcess(sftp as never, PERSONALIZADA);
expect(r.running).toBe(true);
expect(r.fromPrefix).toBe(false);
});
it('ni al contrario: el de la personalizada no cuenta como el de la de omisión', async () => {
const { sftp } = psFake(`corriendo|${PERSONALIZADA}\\CloudRestoreAS.exe`);
expect((await probeWindowsAgentProcess(sftp as never, DEFECTO)).fromPrefix).toBe(false);
});
it('y el de la personalizada sí cuenta cuando esa es la instalación', async () => {
const { sftp } = psFake(`corriendo|${PERSONALIZADA}\\CloudRestoreAS.exe`);
expect((await probeWindowsAgentProcess(sftp as never, PERSONALIZADA)).fromPrefix).toBe(true);
});
it('probeWindowsTask saca la ruta y la cuenta de la tarea', async () => {
const { sftp } = psFake('si|C:\\Otra\\CloudRestoreAS.exe|SYSTEM');
expect(await probeWindowsTask(sftp as never)).toEqual({
@@ -475,6 +578,89 @@ describe('sondas de ruta en Windows', () => {
});
});
/**
* Realineación de la tarea programada DESDE EL PANEL.
*
* Vive aquí y no solo en install.ps1 porque un instalador ya publicado no se arregla hacia atrás:
* el script que se ejecuta en el servidor viaja DENTRO del artefacto, y el de 1.1.3 salió antes de
* que install.ps1 aprendiera a realinear la tarea. Sin esto, actualizar a 1.1.3 reemplaza el
* binario de una carpeta y `Start-ScheduledTask` levanta el de otra.
*/
describe('alignWindowsTask', () => {
const DESTINO = 'C:\\Aduanasoft\\CloudRestoreAS-win';
const ESPERADO = `${DESTINO}\\CloudRestoreAS.exe`;
function psFake(stdout: string) {
return fakeSftp(() => ({ code: 0, stdout }));
}
it('reapunta una tarea que ejecuta otro binario', async () => {
const { sftp, commands } = psFake('ok');
const cambio = await alignWindowsTask(
sftp as never,
1,
'C:\\Aduanasoft\\CloudRestoreAS\\CloudRestoreAS.exe',
DESTINO
);
expect(cambio).toBe(true);
const script = Buffer.from(
commands[0].split('-EncodedCommand ')[1] ?? '',
'base64'
).toString('utf16le');
expect(script).toContain(ESPERADO);
expect(script).toContain('Set-ScheduledTask');
// Se conservan los argumentos: reregistrar desde cero perdería ajustes del operador.
expect(script).toContain('Arguments');
// `$args` es una variable automática de PowerShell; asignarla sería un error en ejecución.
expect(script).not.toMatch(/\$args\s*=/);
});
it('no toca una tarea que ya apunta bien', async () => {
const { sftp, commands } = psFake('ok');
expect(await alignWindowsTask(sftp as never, 1, ESPERADO, DESTINO)).toBe(false);
expect(commands).toHaveLength(0);
});
it('ignora comillas y mayúsculas al comparar', async () => {
// La acción de la tarea guarda la ruta entrecomillada y NTFS no distingue caso: comparar en
// crudo reescribiría tareas correctas en cada actualización.
const { sftp, commands } = psFake('ok');
const cambio = await alignWindowsTask(
sftp as never,
1,
`"${ESPERADO.toLowerCase()}"`,
DESTINO
);
expect(cambio).toBe(false);
expect(commands).toHaveLength(0);
});
it('reapunta cuando la tarea usa la ruta por omisión y la instalación es la personalizada', async () => {
// El servidor real: instalado en ...-win, tarea apuntando a la carpeta por omisión. Con una
// comparación por prefijo esto se daría por alineado y no se corregiría nada.
const { sftp, commands } = psFake('ok');
const cambio = await alignWindowsTask(
sftp as never,
1,
'C:\\Aduanasoft\\CloudRestoreAS\\CloudRestoreAS.exe',
'C:\\Aduanasoft\\CloudRestoreAS-win'
);
expect(cambio).toBe(true);
expect(commands).toHaveLength(1);
});
it('si no se puede corregir, falla nombrando las dos rutas', async () => {
const { sftp } = psFake('error|Acceso denegado');
await expect(
alignWindowsTask(sftp as never, 1, 'D:\\Vieja\\CloudRestoreAS.exe', DESTINO)
).rejects.toThrow(/D:\\Vieja\\CloudRestoreAS\.exe/);
const { sftp: sftp2 } = psFake('error|Acceso denegado');
await expect(
alignWindowsTask(sftp2 as never, 1, 'D:\\Vieja\\CloudRestoreAS.exe', DESTINO)
).rejects.toThrow(new RegExp(ESPERADO.replace(/\\/g, '\\\\')));
});
});
/**
* Verificación del despliegue en Windows.
*
@@ -497,16 +683,33 @@ describe('verifyWindowsDeployment', () => {
running: boolean;
/** Desde dónde corre. Por omisión, el propio prefijo. */
procPath?: string;
/** Ruta que la tarea tiene registrada en su acción. */
taskExecute?: string;
crashLog?: string;
/** De cuál de las rutas candidatas se leyó el crash log. */
crashFrom?: string;
}) {
return fakeSftp((command) => {
const encoded = command.split('-EncodedCommand ')[1] ?? '';
const script = Buffer.from(encoded, 'base64').toString('utf16le');
if (script.includes('crash.log')) {
// El lector responde `DE|<ruta>` y luego el contenido, para poder decir de dónde salió.
if (!opts.crashLog) return { code: 0, stdout: '' };
const de = opts.crashFrom ?? `${PREFIJO}\\CloudRestoreAS-crash.log`;
return { code: 0, stdout: `DE|${de}\n${opts.crashLog}` };
}
if (script.includes('config\\.version')) return { code: 0, stdout: opts.stamp };
if (script.includes('Get-Process')) {
if (!opts.running) return { code: 0, stdout: 'detenido' };
const ruta = opts.procPath ?? `${PREFIJO}\\CloudRestoreAS.exe`;
return { code: 0, stdout: `corriendo|${ruta}` };
}
// La sonda completa de la tarea se distingue de la consulta de estado por que pregunta
// también por el principal.
if (script.includes('Principal')) {
const exec = opts.taskExecute ?? `${PREFIJO}\\CloudRestoreAS.exe`;
return { code: 0, stdout: `si|${exec}|SYSTEM` };
}
if (script.includes('Get-ScheduledTask')) return { code: 0, stdout: opts.taskState };
return { code: 1, stdout: '' };
});
@@ -553,6 +756,91 @@ describe('verifyWindowsDeployment', () => {
).rejects.toThrow(/no escribió config\\\.version/);
});
/**
* Corre la verificación esperando que falle y devuelve el mensaje. Lanza si NO falla, para que
* una comprobación que dejara de detectar el problema no pase como verde con mensaje vacío.
*/
async function mensajeDeFallo(
...args: Parameters<typeof verifyWindowsDeployment>
): Promise<string> {
try {
await verifyWindowsDeployment(...args);
} catch (e) {
return e instanceof Error ? e.message : String(e);
}
throw new Error('se esperaba que verifyWindowsDeployment fallara, y no falló');
}
it('el error del sello trae el diagnóstico hecho, no una tarea que investigar', async () => {
// El operador lee el mensaje, no la bitácora del run ("no sé dónde verlo"). El panel ya
// tiene a mano a qué apunta la tarea, desde dónde corre el proceso y el crash log: mandarlo
// a averiguarlo era pedirle algo que aquí ya se sabía.
const { sftp } = fakeWindows({
stamp: '',
taskState: 'Running',
running: true,
taskExecute: 'C:\\Aduanasoft\\CloudRestoreAS\\CloudRestoreAS.exe',
procPath: 'C:\\Aduanasoft\\CloudRestoreAS\\CloudRestoreAS.exe',
crashLog: 'ImportError: no se pudo cargar el driver ODBC'
});
const mensaje = await mensajeDeFallo(
sftp as never,
1,
RELEASE,
'service',
PREFIJO,
false,
true
);
expect(mensaje).toContain('C:\\Aduanasoft\\CloudRestoreAS\\CloudRestoreAS.exe');
expect(mensaje).toMatch(/arranque automático ejecuta/);
expect(mensaje).toMatch(/hay un proceso corriendo desde/);
expect(mensaje).toContain('ImportError');
});
it('el crash log se encuentra también en el perfil de SYSTEM', async () => {
// Es el caso que importa: un agente lanzado por una tarea que corre como SYSTEM no escribe
// junto al ejecutable, sino bajo C:\Windows\System32\config\systemprofile. Mirar solo la
// primera ubicación dejaba el arranque fallido sin explicación.
const desde =
'C:\\Windows\\System32\\config\\systemprofile\\AppData\\Local\\CloudRestoreAS\\crash.log';
const { sftp } = fakeWindows({
stamp: '',
taskState: 'Ready',
running: false,
crashLog: 'RuntimeError: could not load the Qt platform plugin "windows"',
crashFrom: desde
});
const mensaje = await mensajeDeFallo(
sftp as never,
1,
RELEASE,
'service',
PREFIJO,
false,
true
);
// Dice QUÉ pasó y DE DÓNDE lo sacó, para no tener que buscarlo a ciegas en el servidor.
expect(mensaje).toContain('Qt platform plugin');
expect(mensaje).toContain(desde);
});
it('sin crash log el mensaje no se rompe', async () => {
const { sftp } = fakeWindows({ stamp: '', taskState: 'Running', running: false });
const mensaje = await mensajeDeFallo(
sftp as never,
1,
RELEASE,
'service',
PREFIJO,
false,
true
);
expect(mensaje).toMatch(/no hay ningún proceso CloudRestoreAS corriendo/);
expect(mensaje).not.toContain('Último crash');
});
it('INSTALANDO limpio, un sello ausente sigue siendo aceptable', async () => {
// Ahí sí es legítimo: el bootstrap puede no haber alcanzado a escribirlo todavía.
const { sftp } = fakeWindows({ stamp: '', taskState: 'Running', running: true });

View File

@@ -1100,6 +1100,45 @@ export async function probeWindowsElevation(sftp: SftpClient): Promise<WindowsPr
}
}
/**
* Comparación de rutas de Windows. Una sola implementación, a propósito.
*
* La pregunta "¿son la misma ruta?" estaba resuelta en tres sitios con tres criterios ligeramente
* distintos (una recortaba comillas múltiples, otra una sola, la tercera ninguna). Coincidían en los
* casos reales, pero este módulo ya lleva escrito lo que cuesta esa duplicación: `probeLinuxElevation`
* e `inspectLinux` tenían copias paralelas y las dos pantallas acabaron diciendo cosas distintas del
* mismo servidor.
*
* Lo que hay que normalizar y por qué:
* - **Comillas**: la acción de una tarea programada guarda la ruta entrecomillada.
* - **Barras finales**: una ruta capturada a mano suele traerlas.
* - **Mayúsculas**: NTFS no las distingue, así que comparar sensible al caso reporta dos rutas
* distintas donde el sistema de archivos ve una sola.
*
* Lo que NO se hace es comparar por prefijo. `C:\Aduanasoft\CloudRestoreAS` es prefijo de cadena de
* `C:\Aduanasoft\CloudRestoreAS-win`, así que un `startsWith` daría por iguales dos instalaciones
* distintas — y esa combinación existe en producción.
*/
export function normalizeWindowsPath(value: string): string {
return String(value ?? '')
.trim()
.replace(/^"+|"+$/g, '')
.replace(/\\+$/, '')
.toLowerCase();
}
/** ¿Las dos rutas apuntan al mismo archivo o carpeta? Igualdad exacta tras normalizar. */
export function sameWindowsPath(a: string, b: string): boolean {
const na = normalizeWindowsPath(a);
const nb = normalizeWindowsPath(b);
return na !== '' && na === nb;
}
/** Ruta del ejecutable del agente dentro de una carpeta de instalación. */
export function windowsAgentExe(prefix: string): string {
return `${String(prefix ?? '').trim().replace(/\\+$/, '')}\\CloudRestoreAS.exe`;
}
export interface WindowsProcessProbe {
running: boolean;
/** Rutas de los ejecutables vivos. Vacío si corren pero no se pudo leer su ruta. */
@@ -1139,11 +1178,11 @@ export async function probeWindowsAgentProcess(
}
const joined = out.split('|')[1] ?? '';
const paths = joined.split(';').map((p) => p.trim()).filter(Boolean);
const wanted = `${prefix.replace(/\\+$/, '')}\\CloudRestoreAS.exe`.toLowerCase();
const wanted = windowsAgentExe(prefix);
return {
running: true,
paths,
fromPrefix: paths.some((p) => p.replace(/^"|"$/g, '').toLowerCase() === wanted)
fromPrefix: paths.some((p) => sameWindowsPath(p, wanted))
};
}
@@ -1190,6 +1229,207 @@ export async function probeWindowsTask(sftp: SftpClient): Promise<WindowsTaskPro
};
}
/**
* ¿Se puede desplegar este artefacto en Windows sin romper el servidor?
*
* Que su `install.ps1` no declare `-UpdateInPlace` significa que el paquete se construyó ANTES de los
* arreglos de actualización, y usarlo no es "una actualización peor": es dejar el servidor sin agente.
* Ese instalador detiene la tarea y mata los procesos, corre el bootstrap acotado a 20 s —insuficiente
* para desempacar un onefile de ~250 MB con el antivirus escaneando, así que muere antes de escribir
* `config\.version`—, re-registra la tarea como SYSTEM y la arranca sin comprobar que volviera. Y el
* binario del mismo paquete lleva el `runner.py` que ignora `--headless` en Windows, así que como
* SYSTEM en la sesión 0 Qt no puede crear su plataforma y el agente muere. Sin agente viejo y sin
* agente nuevo.
*
* Lanza 409 al ACTUALIZAR: negarse y decir por qué es estrictamente mejor que romperlo y explicarlo
* después. En una instalación NUEVA devuelve el aviso para asentarlo, porque ahí no hay agente que
* perder y negarse solo estorbaría.
*
* Es una función aparte, y no una comprobación dentro de `installWindows`, porque es una política con
* consecuencias: merece nombre propio y prueba propia.
*/
export function checkWindowsArtifactUpdateSupport(
mode: InstallMode,
supportsInPlace: boolean,
version: string,
targetName: string
): string {
if (supportsInPlace) return '';
const aviso =
`El artefacto ${version} trae un install.ps1 anterior a los arreglos de actualización ` +
'(no declara -UpdateInPlace).';
if (mode === 'update') {
throw new InstallError(
409,
`${aviso} Actualizar ${targetName} con él dejaría el servidor SIN agente: ese instalador ` +
'detiene el que está corriendo, cambia la tarea programada a SYSTEM y no comprueba ' +
'que vuelva a arrancar — y su binario no sabe correr headless en Windows, así que ' +
'como SYSTEM no levanta. Publica una versión construida con los arreglos y ' +
'actualiza a esa.'
);
}
return `${aviso} Se instala igual: no hay un agente en marcha que perder.`;
}
/**
* Alinea la acción de la tarea programada con el binario que se acaba de instalar.
*
* Esto lo hace el PANEL y no solo el instalador porque un instalador ya publicado no se puede
* arreglar hacia atrás: `install.ps1` aprendió a realinear la tarea después de que saliera 1.1.3,
* y el script que se ejecuta en el servidor es el que viaja DENTRO del artefacto. Haciéndolo desde
* aquí funciona con cualquier versión ya distribuida.
*
* Devuelve true si tuvo que cambiar algo. Reapunta solo la acción y conserva disparador, principal,
* ajustes y argumentos: volver a registrar la tarea desde cero perdería los ajustes que el operador
* le haya hecho.
*/
export async function alignWindowsTask(
sftp: SftpClient,
runId: number,
taskExecute: string,
targetPath: string
): Promise<boolean> {
if (!taskExecute) return false;
const wanted = windowsAgentExe(targetPath);
if (sameWindowsPath(taskExecute, wanted)) return false;
// `$argumentos` y no `$args`: `$args` es una variable automática de PowerShell y asignarla
// dentro de un bloque de script es un error.
const result = await execRemote(
sftp,
psEncoded(
"$t = Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue; " +
"if (-not $t) { Write-Output 'sin-tarea'; exit 0 }; " +
'$acciones = @(); ' +
'foreach ($a in @($t.Actions)) { ' +
" $pe = $a.PSObject.Properties['Execute']; " +
' if (-not $pe) { $acciones += $a; continue }; ' +
" $pa = $a.PSObject.Properties['Arguments']; " +
" $argumentos = if ($pa -and $pa.Value) { [string]$pa.Value } else { '' }; " +
' if ($argumentos) { $acciones += New-ScheduledTaskAction ' +
`-Execute '${wanted}' -Argument $argumentos -WorkingDirectory '${targetPath}' } ` +
'else { $acciones += New-ScheduledTaskAction ' +
`-Execute '${wanted}' -WorkingDirectory '${targetPath}' } }; ` +
"try { Set-ScheduledTask -TaskName 'CloudRestoreAS' -Action $acciones " +
"-ErrorAction Stop | Out-Null; Write-Output 'ok' } " +
"catch { Write-Output ('error|' + $_.Exception.Message) }"
)
);
const out = result.stdout.trim();
if (out === 'ok') {
// Se asienta la ruta ANTERIOR además de la nueva: si el operador declaró mal la ruta de
// instalación, este es el dato con el que se deshace el cambio a mano.
await appendInstallStep(
runId,
'realinear-arranque',
true,
`la tarea ejecutaba ${taskExecute}; ahora ejecuta ${wanted}`
);
return true;
}
// No se sigue adelante: arrancar a sabiendas el binario de otra carpeta es justo el fallo
// silencioso que se está persiguiendo.
throw new InstallError(
409,
`La tarea programada de ${targetPath} ejecuta ${taskExecute}, no el binario que se acaba de ` +
`instalar (${wanted}), y no se pudo corregir` +
(out.startsWith('error|') ? `: ${truncate(out.slice(6))}` : ' (no hay tarea registrada)') +
'. Sin eso, el arranque automático seguiría levantando la versión anterior.'
);
}
/**
* Reinicia el agente en Windows y espera a que corra el binario de `prefix`.
*
* Vive en este módulo y no en `cras-agent-control` porque este es el de más abajo: al revés habría
* una dependencia circular. `startCrasAgent` lo reutiliza en lugar de tener su propia copia.
*/
export async function restartWindowsAgent(
sftp: SftpClient,
prefix: string,
timeoutMs = 60_000
): Promise<WindowsProcessProbe> {
const exe = windowsAgentExe(prefix);
await execRemote(
sftp,
psEncoded(
"$t = Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue; " +
"if ($t) { Stop-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue }; " +
"Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue | " +
'Stop-Process -Force -ErrorAction SilentlyContinue; ' +
'Start-Sleep -Seconds 3; ' +
// Si hay tarea se arranca por ella (respeta su principal y su entorno); si no, el
// proceso suelto. `--headless` hace que el binario elija el plugin Qt offscreen.
"if ($t) { Start-ScheduledTask -TaskName 'CloudRestoreAS' } " +
`else { Start-Process -FilePath '${exe}' ` +
`-ArgumentList '--start-engine','--headless' -WorkingDirectory '${prefix}' ` +
'-WindowStyle Hidden }'
),
INSTALL_EXEC_TIMEOUT_MS
);
const deadline = Date.now() + timeoutMs;
let probe = await probeWindowsAgentProcess(sftp, prefix);
while (Date.now() < deadline) {
// `paths` vacío con el proceso vivo significa "no pude ver desde dónde corre" (un proceso de
// SYSTEM no expone su ruta sin elevación): se acepta, no se insiste hasta agotar el margen.
if (probe.fromPrefix || (probe.running && probe.paths.length === 0)) return probe;
await new Promise((resolve) => setTimeout(resolve, 2000));
probe = await probeWindowsAgentProcess(sftp, prefix);
}
return probe;
}
/**
* Cola del crash log que el agente escribe cuando no consigue arrancar.
*
* Se buscan **las tres** ubicaciones que usa `runner.py` (`_crash_log_targets()`), en su mismo orden
* de preferencia: junto al ejecutable, `%LOCALAPPDATA%\CloudRestoreAS\crash.log` y
* `%TEMP%\CloudRestoreAS-crash.log`.
*
* Mirar solo junto al ejecutable no bastaba, y era justo el caso que interesa: un agente lanzado por
* una tarea que corre como SYSTEM resuelve las otras dos bajo
* `C:\Windows\System32\config\systemprofile\`, y ahí es donde quedó la evidencia del arranque que
* fallaba. El operador recibía "no arrancó" en lugar de "no arrancó porque X".
*
* Las variables se expanden EN EL DESTINO y en la sesión del usuario SSH, así que si el agente corre
* como SYSTEM su `%LOCALAPPDATA%` no es el mismo: por eso se añade explícitamente el del perfil de
* SYSTEM en vez de confiar en la expansión.
*/
async function readWindowsCrashLog(
sftp: SftpClient,
prefix: string
): Promise<{ text: string; from: string }> {
const candidatos = [
`${prefix.replace(/\\+$/, '')}\\CloudRestoreAS-crash.log`,
'$env:LOCALAPPDATA\\CloudRestoreAS\\crash.log',
'$env:TEMP\\CloudRestoreAS-crash.log',
// El perfil de SYSTEM, que es donde caen los dos anteriores cuando el agente lo lanza la
// tarea programada como SYSTEM y no la sesión SSH.
'C:\\Windows\\System32\\config\\systemprofile\\AppData\\Local\\CloudRestoreAS\\crash.log',
'C:\\Windows\\Temp\\CloudRestoreAS-crash.log'
];
const result = await execRemote(
sftp,
psEncoded(
`$rutas = @("${candidatos.join('","')}"); ` +
'foreach ($r in $rutas) { ' +
'if (Test-Path -LiteralPath $r) { ' +
"Write-Output ('DE|' + $r); " +
'(Get-Content -LiteralPath $r -Tail 20) -join [Environment]::NewLine; break } }'
)
);
const salida = result.stdout.trim();
if (!salida.startsWith('DE|')) return { text: '', from: '' };
const [cabecera, ...resto] = salida.split('\n');
return { text: resto.join('\n').trim(), from: cabecera.slice(3).trim() };
}
async function installWindows(
sftp: SftpClient,
runId: number,
@@ -1373,16 +1613,18 @@ async function installWindows(
)
);
const supportsInPlace = supportsProbe.stdout.trim() === 'si';
const inPlaceUpdate = request.mode === 'update' && supportsInPlace;
if (request.mode === 'update' && !supportsInPlace) {
await appendInstallStep(
runId,
'instalador-sin-update-in-place',
true,
`${release.version} trae un install.ps1 que no soporta -UpdateInPlace; se instala ` +
'con el modo de arranque normal'
);
const avisoArtefacto = checkWindowsArtifactUpdateSupport(
request.mode,
supportsInPlace,
release.version,
target.name
);
if (avisoArtefacto) {
await appendInstallStep(runId, 'instalador-antiguo', true, avisoArtefacto);
}
const inPlaceUpdate = request.mode === 'update' && supportsInPlace;
const flag = inPlaceUpdate
? ' -UpdateInPlace'
: autostart === 'none'
@@ -1417,6 +1659,37 @@ async function installWindows(
);
}
// El instalador puede haber cambiado la CUENTA con la que corre la tarea sin decir nada:
// `-Service` la re-registra como SYSTEM. Ese salto de una cuenta con escritorio a SYSTEM en la
// sesión 0 es lo que impide arrancar a un binario que no sabe caer a Qt offscreen, así que
// cuando pasa hay que dejarlo asentado en vez de que se descubra a base de diagnóstico.
const taskDespues = await probeWindowsTask(sftp);
if (task.exists && taskDespues.exists && task.principal !== taskDespues.principal) {
await appendInstallStep(
runId,
'cambio-de-cuenta-de-la-tarea',
true,
`el instalador cambió la cuenta de la tarea: ${task.principal || '(sin declarar)'}` +
`${taskDespues.principal || '(sin declarar)'}`
);
}
// El binario nuevo ya está en targetPath. Ahora hay que asegurar que el arranque automático
// apunte AHÍ: el install.ps1 de los artefactos publicados hasta 1.1.3 no sabe realinear la
// tarea, y sin eso `Start-ScheduledTask` levanta el binario de la carpeta anterior — la
// actualización termina en verde sin haber cambiado nada.
if (await alignWindowsTask(sftp, runId, taskDespues.execute || task.execute, targetPath)) {
const proc = await restartWindowsAgent(sftp, targetPath);
await appendInstallStep(
runId,
'rearrancar-agente',
proc.running,
proc.running
? `corriendo desde ${proc.paths.join(', ') || '(ruta no legible)'}`
: 'el agente no volvió a arrancar tras realinear la tarea'
);
}
await verifyWindowsDeployment(
sftp, runId, release, autostart, targetPath, inPlaceUpdate, request.mode === 'update'
);
@@ -1487,12 +1760,32 @@ export async function verifyWindowsDeployment(
// no es el binario que acabamos de instalar. La tolerancia se queda solo en instalación nueva,
// que es donde sí es legítima.
if (!deployed && isUpdate) {
// El mensaje trae el diagnóstico ya hecho. El panel tiene a mano a qué apunta la tarea,
// desde dónde corre el proceso y el crash log del agente; mandar al operador a "revisar el
// arranque automático" le pedía averiguar algo que aquí ya se sabe — y la bitácora del run,
// donde sí estaba, no la encuentra nadie.
const [task, proc, crash] = await Promise.all([
probeWindowsTask(sftp),
probeWindowsAgentProcess(sftp, prefix),
readWindowsCrashLog(sftp, prefix)
]);
const pistas = [
task.exists
? `el arranque automático ejecuta ${task.execute || '(sin acción)'}`
: 'no hay tarea CloudRestoreAS registrada, así que nadie levanta el agente',
proc.running
? `hay un proceso corriendo desde ${proc.paths.join(', ') || '(ruta no legible)'}`
: 'no hay ningún proceso CloudRestoreAS corriendo'
];
throw new InstallError(
502,
`El agente de ${prefix} no escribió config\\.version tras la actualización. La versión ` +
`nueva lo escribe al arrancar, así que lo que está corriendo no es ${release.version}. ` +
'Revisa a qué binario apunta el arranque automático del servidor y los logs en ' +
`${prefix}\\config\\logs.`
`${pistas.join('; ')}.` +
(crash.text ? ` Último crash (${crash.from}): ${truncate(crash.text)}` : '') +
` Logs en ${prefix}\\config\\logs.`
);
}
await appendInstallStep(

View File

@@ -24,8 +24,10 @@ import {
probeWindowsTask,
psEncoded,
readUnitProps,
sameWindowsPath,
shQuote,
unitUserOrRoot
unitUserOrRoot,
windowsAgentExe
} from './cras-install';
import { listCrasTargetInventory } from './cras-releases';
import { DEFAULT_INSTALL_PATHS, effectiveInstallPath, type CrasPlatform } from '$lib/cras-version';
@@ -827,9 +829,10 @@ async function inspectWindows(
// apunta a otra carpeta hace que actualizar no surta efecto sin que nada falle: se reemplaza el
// binario de una y se arranca el de otra. Aquí se ve, en vez de descubrirse después.
const taskProbe = await probeWindowsTask(sftp);
const esperado = `${prefix.replace(/\\+$/, '')}\\CloudRestoreAS.exe`;
const taskAlineada =
!taskProbe.execute || taskProbe.execute.toLowerCase() === esperado.toLowerCase();
const esperado = windowsAgentExe(prefix);
// Misma comparación que usa el instalador. Tenerla duplicada aquí con otro criterio dejaba a las
// dos pantallas capaces de discrepar sobre el mismo servidor.
const taskAlineada = !taskProbe.execute || sameWindowsPath(taskProbe.execute, esperado);
if (taskProbe.exists) {
checks.push(
check(

View File

@@ -5,7 +5,7 @@
import type { SubmitFunction } from '@sveltejs/kit';
import AppShell from '$lib/components/AppShell.svelte';
import Spinner from '$lib/components/Spinner.svelte';
import { effectiveInstallPath, platformLabel } from '$lib/cras-version';
import { effectiveInstallPath, isDefaultInstallPath, platformLabel } from '$lib/cras-version';
import {
INSTALL_STEP_LABEL,
currentPhaseLabel,
@@ -463,7 +463,18 @@
{#if showFormBanner && form?.error}
<div class="flex items-start gap-2 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
<span class="material-icons-outlined text-base">error_outline</span>
<p class="min-w-0 flex-1">{form.error}</p>
<div class="min-w-0 flex-1">
<p>{form.error}</p>
<!-- Los pasos del run SÍ se cargan al fallar, pero viven dentro del modal de
instalación y el operador no daba con ellos. Se nombra el run y dónde está,
en vez de dar por hecho que se encuentra. -->
{#if progressRunId}
<p class="mt-1 text-xs text-red-700">
Detalle paso a paso en el run #{progressRunId}, en el recuadro de
progreso del formulario de instalación.
</p>
{/if}
</div>
<button
type="button"
aria-label="Cerrar aviso"
@@ -1335,6 +1346,29 @@
{/if}
</div>
<!-- Ruta de destino, siempre visible.
Antes solo aparecía dentro del aviso de "instalar limpio", que se pinta únicamente
si el panel ya conoce la versión instalada. En un servidor con ruta personalizada
y versión desconocida —el caso que costó dos rondas de diagnóstico— el operador no
veía a qué carpeta iba a instalar hasta que el run fallaba. -->
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs">
<p class="text-slate-700">
Se instalará en <code class="font-mono text-slate-900">{targetInstallPath}</code>
</p>
<p class="mt-0.5 text-slate-500">
{#if installTarget?.reported_install_path}
Ruta registrada para {installTarget.name}. Se cambia con el lápiz que está
junto al nombre del servidor, en la tabla.
{:else if installTarget?.platform && isDefaultInstallPath(targetInstallPath, installTarget.platform)}
Ruta por omisión: este servidor no tiene ninguna registrada. Si el agente
vive en otra carpeta, captúrala primero con el lápiz de la tabla instalar
aquí crearía una segunda instalación.
{:else}
Sin ruta registrada para este servidor.
{/if}
</p>
</div>
<div>
<label for="autostart" class="block text-sm font-medium text-gray-700">
Arranque automático