fix/windows-actualizacion-silenciosa #27

Closed
hreyes wants to merge 7 commits from fix/windows-actualizacion-silenciosa into development
3 changed files with 353 additions and 53 deletions
Showing only changes of commit 9a4124d636 - Show all commits

View File

@@ -28,7 +28,9 @@ import {
execRemote,
probeInPlaceUpdate,
probeLinuxElevation,
probeWindowsAgentProcess,
probeWindowsElevation,
probeWindowsTask,
psEncoded,
shQuote,
verifyWindowsDeployment,
@@ -419,25 +421,91 @@ describe('probeWindowsElevation', () => {
});
});
/**
* Sondas de Windows que miran la RUTA, no el nombre.
*
* Son la respuesta al fallo silencioso: todo el camino identificaba al agente por NOMBRE, mientras
* 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.
*/
describe('sondas de ruta en Windows', () => {
function psFake(stdout: string) {
return fakeSftp(() => ({ code: 0, stdout }));
}
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({
exists: true,
execute: 'C:\\Otra\\CloudRestoreAS.exe',
principal: 'SYSTEM'
});
});
it('probeWindowsTask sin tarea registrada', async () => {
const { sftp } = psFake('no||');
const r = await probeWindowsTask(sftp as never);
expect(r.exists).toBe(false);
expect(r.execute).toBe('');
});
it('probeWindowsAgentProcess distingue la instalación propia de otra carpeta', async () => {
const { sftp } = psFake('corriendo|C:\\Otra\\CloudRestoreAS.exe');
const r = await probeWindowsAgentProcess(sftp as never, 'C:\\Declarada');
expect(r.running).toBe(true);
expect(r.fromPrefix).toBe(false);
expect(r.paths).toEqual(['C:\\Otra\\CloudRestoreAS.exe']);
});
it('probeWindowsAgentProcess reconoce la propia sin importar mayúsculas', async () => {
// NTFS no distingue caso: comparar sensible reportaría "corre desde otra carpeta" sobre la
// MISMA ruta y haría fallar una actualización correcta.
const { sftp } = psFake('corriendo|c:\\declarada\\cloudrestoreas.exe');
expect((await probeWindowsAgentProcess(sftp as never, 'C:\\Declarada')).fromPrefix).toBe(true);
});
it('vivo con la ruta ilegible no se afirma que sea ajeno', async () => {
// Un proceso de SYSTEM no expone .Path a una cuenta sin elevación. `paths` vacío significa
// "no sé desde dónde corre", y el llamador no debe tratarlo como fallo.
const { sftp } = psFake('corriendo|');
const r = await probeWindowsAgentProcess(sftp as never, 'C:\\Declarada');
expect(r.running).toBe(true);
expect(r.paths).toEqual([]);
expect(r.fromPrefix).toBe(false);
});
});
/**
* Verificación del despliegue en Windows.
*
* El defecto que cubre: se daba por buena cualquier tarea con un `State` no vacío. `Ready` es una
* tarea REGISTRADA QUE NO ESTÁ CORRIENDO —exactamente lo que se ve cuando el agente arrancó y
* murió a los pocos segundos—, así que una actualización que dejaba el servidor sin restaurador
* se asentaba como completada y nadie se enteraba hasta que alguien pulsaba Verificar.
* Dos defectos, y el segundo es el que dejaba pasar la actualización que no hacía nada:
*
* 1. Se daba por buena cualquier tarea con un `State` no vacío. `Ready` es una tarea REGISTRADA QUE
* NO ESTÁ CORRIENDO — lo que se ve cuando el agente arrancó y murió a los pocos segundos.
* 2. Un sello `config\.version` ausente se toleraba SIEMPRE. Como los agentes anteriores a 1.1.1
* no lo escribían, al actualizar uno de esos no se comprobaba ninguna versión.
*/
describe('verifyWindowsDeployment', () => {
const RELEASE = { version: '1.1.2' } as never;
const PREFIJO = 'C:\\CRAS';
/** Responde a cada script de PowerShell según lo que pregunte. */
function fakeWindows(opts: { stamp: string; taskState: string; running: boolean }) {
function fakeWindows(opts: {
stamp: string;
taskState: string;
running: boolean;
/** Desde dónde corre. Por omisión, el propio prefijo. */
procPath?: string;
}) {
return fakeSftp((command) => {
const encoded = command.split('-EncodedCommand ')[1] ?? '';
const script = Buffer.from(encoded, 'base64').toString('utf16le');
if (script.includes('config\\.version')) return { code: 0, stdout: opts.stamp };
if (script.includes('Get-Process')) {
return { code: 0, stdout: opts.running ? 'corriendo:1' : 'detenido' };
if (!opts.running) return { code: 0, stdout: 'detenido' };
const ruta = opts.procPath ?? `${PREFIJO}\\CloudRestoreAS.exe`;
return { code: 0, stdout: `corriendo|${ruta}` };
}
if (script.includes('Get-ScheduledTask')) return { code: 0, stdout: opts.taskState };
return { code: 1, stdout: '' };
@@ -475,6 +543,37 @@ describe('verifyWindowsDeployment', () => {
verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', 'C:\\CRAS')
).rejects.toThrow(/reporta la versión 1\.1\.1/);
});
it('ACTUALIZANDO, un sello ausente es un fallo', async () => {
// El caso real: un agente 1.1.0 no escribía config\.version, así que el sello llegaba vacío,
// el vacío se toleraba y el run terminaba en verde sobre un servidor sin actualizar.
const { sftp } = fakeWindows({ stamp: '', taskState: 'Running', running: true });
await expect(
verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', PREFIJO, false, true)
).rejects.toThrow(/no escribió config\\\.version/);
});
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 });
await expect(
verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', PREFIJO, false, false)
).resolves.toBeUndefined();
});
it('un agente corriendo desde OTRA carpeta no cuenta como desplegado', async () => {
// Exactamente el fallo silencioso: se reemplazó el binario de C:\CRAS y el arranque
// automático levantó el de otra ruta. Antes pasaba por bueno.
const { sftp } = fakeWindows({
stamp: '1.1.2',
taskState: 'Running',
running: true,
procPath: 'D:\\Vieja\\CloudRestoreAS.exe'
});
await expect(
verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', PREFIJO, false, true)
).rejects.toThrow(/D:\\Vieja\\CloudRestoreAS\.exe/);
});
});
/**

View File

@@ -1100,6 +1100,96 @@ export async function probeWindowsElevation(sftp: SftpClient): Promise<WindowsPr
}
}
export interface WindowsProcessProbe {
running: boolean;
/** Rutas de los ejecutables vivos. Vacío si corren pero no se pudo leer su ruta. */
paths: string[];
/** Alguno corre el binario del prefijo indicado. */
fromPrefix: boolean;
}
/**
* Procesos del agente en el destino, con la RUTA de su ejecutable.
*
* El nombre no distingue instalaciones: `Get-Process -Name CloudRestoreAS` responde igual para el
* agente que acabamos de poner y para uno viejo que sigue vivo en otra carpeta.
*
* Si la ruta no es legible se devuelve `paths` vacío y `fromPrefix` en false, y el llamador NO debe
* concluir que es de otra instalación: un proceso de SYSTEM no expone su ruta a una cuenta sin
* elevación, y tratar eso como fallo rompería actualizaciones correctas.
*/
export async function probeWindowsAgentProcess(
sftp: SftpClient,
prefix: string
): Promise<WindowsProcessProbe> {
const probe = await execRemote(
sftp,
psEncoded(
"$p = @(Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue); " +
"if ($p.Count -eq 0) { Write-Output 'detenido'; return }; " +
'$rutas = @(); ' +
'foreach ($x in $p) { try { if ($x.Path) { $rutas += $x.Path } } catch { } }; ' +
"Write-Output ('corriendo|' + ($rutas -join ';'))"
)
);
const out = probe.stdout.trim();
if (!out.startsWith('corriendo')) {
return { running: false, paths: [], fromPrefix: false };
}
const joined = out.split('|')[1] ?? '';
const paths = joined.split(';').map((p) => p.trim()).filter(Boolean);
const wanted = `${prefix.replace(/\\+$/, '')}\\CloudRestoreAS.exe`.toLowerCase();
return {
running: true,
paths,
fromPrefix: paths.some((p) => p.replace(/^"|"$/g, '').toLowerCase() === wanted)
};
}
export interface WindowsTaskProbe {
exists: boolean;
/** Ruta del ejecutable en la acción de la tarea. Es DÓNDE arranca el agente de verdad. */
execute: string;
/** Cuenta con la que corre. `SYSTEM` es lo que obliga a elevar para pararla o arrancarla. */
principal: string;
}
/**
* Interroga la tarea programada del agente.
*
* Existe porque todo el camino de Windows identificaba al agente por NOMBRE mientras que lo único
* que se actualiza se identifica por RUTA. `Start-ScheduledTask` ejecuta la ruta registrada en la
* acción de la tarea, no la que acabamos de instalar: en cuanto las dos no coinciden se reemplaza
* un binario y se arranca otro, sin que nada falle.
*
* Las propiedades se leen por reflexión porque no toda acción de una tarea es de tipo Exec (una
* ComHandler no tiene `Execute`).
*/
export async function probeWindowsTask(sftp: SftpClient): Promise<WindowsTaskProbe> {
const probe = await execRemote(
sftp,
psEncoded(
"$t = Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue; " +
"if (-not $t) { Write-Output 'no||'; return }; " +
"$exec = ''; " +
'foreach ($a in @($t.Actions)) { ' +
"$pr = $a.PSObject.Properties['Execute']; " +
"if ($pr -and $pr.Value) { $exec = ([string]$pr.Value).Trim('\"'); break } }; " +
"$user = ''; " +
"try { if ($t.Principal) { $user = [string]$t.Principal.UserId } } catch { }; " +
"Write-Output ('si|' + $exec + '|' + $user)"
)
);
const [flag, execute, principal] = probe.stdout.trim().split('|');
return {
exists: flag === 'si',
execute: (execute ?? '').trim(),
principal: (principal ?? '').trim()
};
}
async function installWindows(
sftp: SftpClient,
runId: number,
@@ -1116,36 +1206,66 @@ async function installWindows(
const privileged = await probeWindowsElevation(sftp);
const autostart = request.autostart ?? 'service';
// La elevación hace falta para MANDAR sobre la tarea, que corre como SYSTEM: registrarla en
// una instalación nueva, o pararla y arrancarla en una actualización. Se comprueba si existe
// en lugar de deducirlo del modo, porque un servidor puede tener la tarea puesta aunque esta
// corrida pida otro arranque, y ahí igual hay que poder detenerla.
const taskProbe = await execRemote(
sftp,
psEncoded(
"if (Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue) " +
'{"si"} else {"no"}'
)
// La tarea programada se interroga a fondo, no solo si existe. Su acción dice DÓNDE vive
// realmente el agente, y su principal dice si hace falta elevación para tocarla. Sin ese dato
// se dio por buena una actualización que reemplazó el binario de una carpeta y arrancó el de
// otra: el run terminaba en verde y el servidor seguía con la versión anterior.
const task = await probeWindowsTask(sftp);
await appendInstallStep(
runId,
'arranque-automatico-actual',
true,
task.exists
? `tarea CloudRestoreAS ejecuta ${task.execute || '(sin acción)'}` +
(task.principal ? ` como ${task.principal}` : '')
: 'no hay tarea CloudRestoreAS registrada'
);
const taskExists = taskProbe.stdout.trim() === 'si';
const needsElevation = autostart === 'service' || taskExists;
if (needsElevation && privileged.elevation !== 'admin') {
// Cuando el agente no reporta su ruta —nada anterior a 1.1.1 lo hace— la de la tarea es una
// fuente REAL del destino, no una suposición. Preferirla al default evita el caso en que el
// panel apuntaba a C:\Aduanasoft\CloudRestoreAS mientras el agente vivía en otra carpeta.
let targetPath = installPath;
if (!reportedInstallPath && task.execute) {
const fromTask = task.execute.replace(/\\[^\\]*$/, '');
if (fromTask && isSafeInstallPath(fromTask, 'windows')) {
targetPath = fromTask;
await appendInstallStep(
runId,
'ruta-desde-la-tarea',
true,
`el agente no reporta su ruta; se usa la de la tarea: ${targetPath}`
);
}
}
// La elevación hace falta para MANDAR sobre la tarea: registrarla en una instalación nueva, o
// pararla y arrancarla cuando corre como SYSTEM. Que la tarea EXISTA no basta —si corre con la
// propia cuenta SSH no hay nada que elevar—, y tratarlo así bloqueaba de entrada cualquier
// actualización sobre un servidor ya instalado.
const taskNeedsAdmin = task.exists && /system|S-1-5-18/i.test(task.principal);
const needsElevation = autostart === 'service' || taskNeedsAdmin;
if (needsElevation && privileged.elevation === 'limitado') {
throw new InstallError(
409,
`No se puede operar la tarea programada de ${target.name}: ${privileged.detail}`
);
}
// Con el token filtrado por UAC no se rechaza por adelantado: se intenta y se reporta lo que
// responda el servidor. La sonda mide `IsInRole`, que en una sesión de OpenSSH puede decir que
// no aun cuando la operación sí pase; adivinarlo bloqueaba actualizaciones que funcionaban.
if (needsElevation && privileged.elevation === 'token-filtrado') {
await appendInstallStep(runId, 'aviso-elevacion', true, privileged.detail);
}
await appendInstallStep(
runId,
'precondiciones',
true,
`${systemEvidence}, privilegios=${privileged.label}` +
(taskExists ? ', tarea CloudRestoreAS ya registrada' : '')
`${systemEvidence}, privilegios=${privileged.label}`
);
if (request.mode === 'update') {
await assertExistingInstall(sftp, 'windows', installPath, reportedInstallPath);
await assertExistingInstall(sftp, 'windows', targetPath, reportedInstallPath);
}
// Se usa una carpeta bajo el TEMP del usuario SSH, no C:\Windows\Temp.
@@ -1235,11 +1355,34 @@ async function installWindows(
);
}
// Actualizar va SIEMPRE por -UpdateInPlace, sea cual sea el arranque configurado: no
// vuelve a registrar la tarea (así no pisa ajustes que el operador haya hecho sobre ella),
// se salta el bootstrap —una segunda instancia purgaría el Temp\ de la que está viva— y
// trae respaldo con reversión automática si el binario nuevo no levanta.
const inPlaceUpdate = request.mode === 'update';
// Actualizar va por -UpdateInPlace, sea cual sea el arranque configurado: no vuelve a
// registrar la tarea (así no pisa ajustes que el operador haya hecho sobre ella), se salta
// el bootstrap —una segunda instancia purgaría el Temp\ de la que está viva— y trae respaldo
// con reversión automática si el binario nuevo no levanta.
//
// Pero solo si el instalador DEL ARTEFACTO lo declara: el parámetro existe desde 1.1.3, e
// install.ps1 usa [CmdletBinding()], así que pasárselo a uno anterior falla con
// NamedParameterNotFound SIN ejecutar una sola línea. Se le pregunta a PowerShell por el
// `param()` del propio script en vez de mantener una tabla de versiones aquí.
const installerPs1 = `${remoteDir}\\CloudRestoreAS\\install.ps1`;
const supportsProbe = await execRemote(
sftp,
psEncoded(
`if ((Get-Command '${installerPs1}').Parameters.ContainsKey('UpdateInPlace')) ` +
"{'si'} else {'no'}"
)
);
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 flag = inPlaceUpdate
? ' -UpdateInPlace'
: autostart === 'none'
@@ -1252,7 +1395,7 @@ async function installWindows(
sftp,
psEncoded(
`& '${remoteDir}\\CloudRestoreAS\\install.ps1'${flag} ` +
`-Prefix '${installPath}' -PanelEnvFile '${remoteEnv}'; exit $LASTEXITCODE`
`-Prefix '${targetPath}' -PanelEnvFile '${remoteEnv}'; exit $LASTEXITCODE`
),
INSTALL_EXEC_TIMEOUT_MS
);
@@ -1275,7 +1418,7 @@ async function installWindows(
}
await verifyWindowsDeployment(
sftp, runId, release, autostart, installPath, inPlaceUpdate
sftp, runId, release, autostart, targetPath, inPlaceUpdate, request.mode === 'update'
);
} finally {
await cleanupStaging(
@@ -1295,7 +1438,8 @@ export async function verifyWindowsDeployment(
release: CrasRelease,
autostart: AutostartMode,
prefix: string,
inPlaceUpdate = false
inPlaceUpdate = false,
isUpdate = false
): Promise<void> {
// El sello config\.version lo escribe el bootstrap del binario.
//
@@ -1306,7 +1450,10 @@ export async function verifyWindowsDeployment(
// desempacar un onefile de ~270 MB con Defender escaneando cada archivo se pasa de largo del
// margen. Se sondea hasta que coincida, o hasta agotar los intentos.
let deployed = '';
const stampAttempts = inPlaceUpdate ? 15 : 3;
// Margen generoso: desempacar un onefile de ~270 MB con Defender escaneando cada archivo se
// pasa de largo de los 30s que había antes, y agotar el margen ahora es un FALLO en una
// actualización, así que quedarse corto convertiría un despliegue bueno en un error.
const stampAttempts = isUpdate || inPlaceUpdate ? 45 : 3;
for (let attempt = 0; attempt < stampAttempts; attempt += 1) {
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 2000));
const stamp = await execRemote(
@@ -1330,6 +1477,24 @@ export async function verifyWindowsDeployment(
: '')
);
}
// En una ACTUALIZACIÓN, un sello ausente es un fallo, no un aviso.
//
// Esto es lo que dejaba pasar una actualización que no hacía nada. Los agentes anteriores a
// 1.1.1 no escribían config\.version, así que `deployed` llegaba vacío, el vacío se toleraba y
// el paso se asentaba como "se creará al arrancar": en la única combinación que importa
// —actualizar un agente viejo— no se comprobaba ninguna versión. El binario nuevo SÍ lo escribe
// en ensure_runtime_layout(), así que si tras todo el margen sigue sin aparecer, lo que corre
// 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) {
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.`
);
}
await appendInstallStep(
runId,
'verificar-version',
@@ -1341,14 +1506,21 @@ export async function verifyWindowsDeployment(
// salía aquí cuando el arranque no era 'service', así que una actualización que dejaba el
// servidor sin agente se asentaba como completada y nadie se enteraba hasta que alguien
// pulsaba Verificar. install.ps1 ya garantiza el rearranque; esto lo confirma desde fuera.
const proc = await execRemote(
sftp,
psEncoded(
"$p = Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue; " +
'if ($p) { "corriendo:" + $p.Count } else { "detenido" }'
)
);
const running = proc.stdout.trim().startsWith('corriendo');
const proc = await probeWindowsAgentProcess(sftp, prefix);
const running = proc.running;
// Corriendo, sí, pero ¿el binario de ESTA instalación? Preguntar solo por el nombre respondía
// "hay un proceso llamado CloudRestoreAS", que el agente viejo —nunca detenido, arrancado por
// una tarea que apuntaba a otra carpeta— satisfacía igual de bien.
if (running && proc.paths.length > 0 && !proc.fromPrefix) {
throw new InstallError(
502,
`Hay un agente corriendo desde ${proc.paths.join(', ')}, no desde ${prefix}. Se instaló ` +
'el binario nuevo en una carpeta y el arranque automático levanta el de otra, así que ' +
'la actualización no surtió efecto. Revisa a qué ejecutable apunta la tarea ' +
'CloudRestoreAS en el servidor.'
);
}
if (autostart !== 'service') {
// Sin tarea que consultar, el proceso es lo único que hay. En 'desktop' el agente arranca
@@ -1358,7 +1530,9 @@ export async function verifyWindowsDeployment(
runId,
'verificar-proceso',
running || autostart === 'desktop',
running ? 'CloudRestoreAS en ejecución' : 'CloudRestoreAS no está en ejecución'
running
? `CloudRestoreAS en ejecución${proc.paths.length ? ` desde ${proc.paths.join(', ')}` : ''}`
: 'CloudRestoreAS no está en ejecución'
);
return;
}
@@ -1391,7 +1565,8 @@ export async function verifyWindowsDeployment(
runId,
'verificar-servicio',
true,
`tarea CloudRestoreAS: ${state}, proceso en ejecución`
`tarea CloudRestoreAS: ${state}, proceso en ejecución` +
(proc.paths.length ? ` desde ${proc.paths.join(', ')}` : ' (ruta no legible)')
);
}

View File

@@ -20,6 +20,8 @@ import { getRestoreTargetSsh, type RestoreTargetSsh } from './controldesk-pg';
import {
execRemote,
probeLinuxElevation,
probeWindowsAgentProcess,
probeWindowsTask,
psEncoded,
readUnitProps,
shQuote,
@@ -821,19 +823,43 @@ async function inspectWindows(
)
);
// El proceso vivo es la señal más directa de que está trabajando.
const proc = await execRemote(
sftp,
psEncoded(
"$p = Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue; " +
'if ($p) { "corriendo:" + $p.Count } else { "detenido" }'
),
CHECK_TIMEOUT_MS
);
const procOut = proc.stdout.trim();
const running = procOut.startsWith('corriendo');
// A qué ejecutable apunta el arranque automático. Es un chequeo aparte porque una tarea que
// 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();
if (taskProbe.exists) {
checks.push(
check(
'ruta_arranque',
'El arranque apunta a la instalación',
taskAlineada ? 'ok' : 'fail',
taskAlineada
? taskProbe.execute || 'sin acción declarada'
: `la tarea ejecuta ${taskProbe.execute}, no ${esperado}`
)
);
}
// El proceso vivo es la señal más directa de que está trabajando — pero tiene que ser el de
// ESTA instalación. Se comparte la sonda con el instalador para que las dos pantallas no puedan
// contradecirse sobre el mismo servidor.
const proc = await probeWindowsAgentProcess(sftp, prefix);
const running = proc.running;
const detalleProc = !proc.running
? 'detenido'
: proc.paths.length === 0
? 'corriendo (ruta no legible)'
: `corriendo desde ${proc.paths.join(', ')}`;
checks.push(
check('proceso', 'Proceso en ejecución', running ? 'ok' : 'warn', procOut || 'desconocido')
check(
'proceso',
'Proceso en ejecución',
!proc.running ? 'warn' : proc.paths.length && !proc.fromPrefix ? 'fail' : 'ok',
detalleProc
)
);
if (!installed) {