Files
openclaw/src/cli/update-cli/restart-helper.ts

173 lines
6.1 KiB
TypeScript

import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { DEFAULT_GATEWAY_PORT } from "../../config/paths.js";
import { quoteCmdScriptArg } from "../../daemon/cmd-argv.js";
import {
resolveGatewayLaunchAgentLabel,
resolveGatewaySystemdServiceName,
resolveGatewayWindowsTaskName,
} from "../../daemon/constants.js";
/**
* Shell-escape a string for embedding in single-quoted shell arguments.
* Replaces every `'` with `'\''` (end quote, escaped quote, resume quote).
* For batch scripts, validates against special characters instead.
*/
function shellEscape(value: string): string {
return value.replace(/'/g, "'\\''");
}
/** Validates a string is safe for embedding in a batch (cmd.exe) script. */
function isBatchSafe(value: string): boolean {
// Reject characters that have special meaning in batch: & | < > ^ % " ` $
return /^[A-Za-z0-9 _\-().]+$/.test(value);
}
function resolveSystemdUnit(env: NodeJS.ProcessEnv): string {
const override = env.OPENCLAW_SYSTEMD_UNIT?.trim();
if (override) {
return override.endsWith(".service") ? override : `${override}.service`;
}
return `${resolveGatewaySystemdServiceName(env.OPENCLAW_PROFILE)}.service`;
}
function resolveLaunchdLabel(env: NodeJS.ProcessEnv): string {
const override = env.OPENCLAW_LAUNCHD_LABEL?.trim();
if (override) {
return override;
}
return resolveGatewayLaunchAgentLabel(env.OPENCLAW_PROFILE);
}
function resolveWindowsTaskName(env: NodeJS.ProcessEnv): string {
const override = env.OPENCLAW_WINDOWS_TASK_NAME?.trim();
if (override) {
return override;
}
return resolveGatewayWindowsTaskName(env.OPENCLAW_PROFILE);
}
/**
* Prepares a standalone script to restart the gateway service.
* This script is written to a temporary directory and does not depend on
* the installed package files, ensuring restart capability even if the
* update process temporarily removes or corrupts installation files.
*/
export async function prepareRestartScript(
env: NodeJS.ProcessEnv = process.env,
gatewayPort: number = DEFAULT_GATEWAY_PORT,
): Promise<string | null> {
const tmpDir = os.tmpdir();
const timestamp = Date.now();
const platform = process.platform;
let scriptContent = "";
let filename = "";
try {
if (platform === "linux") {
const unitName = resolveSystemdUnit(env);
const escaped = shellEscape(unitName);
filename = `openclaw-restart-${timestamp}.sh`;
scriptContent = `#!/bin/sh
# Standalone restart script — survives parent process termination.
# Wait briefly to ensure file locks are released after update.
sleep 1
systemctl --user restart '${escaped}'
# Self-cleanup
rm -f "$0"
`;
} else if (platform === "darwin") {
const label = resolveLaunchdLabel(env);
const escaped = shellEscape(label);
// Fallback to 501 if getuid is not available (though it should be on macOS)
const uid = process.getuid ? process.getuid() : 501;
// Resolve HOME at generation time via env/process.env to match launchd.ts,
// and shell-escape the label in the plist filename to prevent injection.
const home = env.HOME?.trim() || process.env.HOME || os.homedir();
const plistPath = path.join(home, "Library", "LaunchAgents", `${label}.plist`);
const escapedPlistPath = shellEscape(plistPath);
filename = `openclaw-restart-${timestamp}.sh`;
scriptContent = `#!/bin/sh
# Standalone restart script — survives parent process termination.
# Wait briefly to ensure file locks are released after update.
sleep 1
# Try kickstart first (works when the service is still registered).
# If it fails (e.g. after bootout), re-register via bootstrap then kickstart.
if ! launchctl kickstart -k 'gui/${uid}/${escaped}' 2>/dev/null; then
launchctl bootstrap 'gui/${uid}' '${escapedPlistPath}' 2>/dev/null
launchctl kickstart -k 'gui/${uid}/${escaped}' 2>/dev/null || true
fi
# Self-cleanup
rm -f "$0"
`;
} else if (platform === "win32") {
const taskName = resolveWindowsTaskName(env);
if (!isBatchSafe(taskName)) {
return null;
}
const port =
Number.isFinite(gatewayPort) && gatewayPort > 0 ? gatewayPort : DEFAULT_GATEWAY_PORT;
filename = `openclaw-restart-${timestamp}.bat`;
scriptContent = `@echo off
REM Standalone restart script — survives parent process termination.
REM Wait briefly to ensure file locks are released after update.
timeout /t 2 /nobreak >nul
schtasks /End /TN "${taskName}"
REM Poll for gateway port release before rerun; force-kill listener if stuck.
set /a attempts=0
:wait_for_port_release
set /a attempts+=1
netstat -ano | findstr /R /C:":${port} .*LISTENING" >nul
if errorlevel 1 goto port_released
if %attempts% GEQ 10 goto force_kill_listener
timeout /t 1 /nobreak >nul
goto wait_for_port_release
:force_kill_listener
for /f "tokens=5" %%P in ('netstat -ano ^| findstr /R /C:":${port} .*LISTENING"') do (
taskkill /F /PID %%P >nul 2>&1
goto port_released
)
:port_released
schtasks /Run /TN "${taskName}"
REM Self-cleanup
del "%~f0"
`;
} else {
return null;
}
const scriptPath = path.join(tmpDir, filename);
await fs.writeFile(scriptPath, scriptContent, { mode: 0o755 });
return scriptPath;
} catch {
// If we can't write the script, we'll fall back to the standard restart method
return null;
}
}
/**
* Executes the prepared restart script as a **detached** process.
*
* The script must outlive the CLI process because the CLI itself is part
* of the service being restarted — `systemctl restart` / `launchctl
* kickstart -k` will terminate the current process tree. Using
* `spawn({ detached: true })` + `unref()` ensures the script survives
* the parent's exit.
*
* Resolves immediately after spawning; the script runs independently.
*/
export async function runRestartScript(scriptPath: string): Promise<void> {
const isWindows = process.platform === "win32";
const file = isWindows ? "cmd.exe" : "/bin/sh";
const args = isWindows ? ["/d", "/s", "/c", quoteCmdScriptArg(scriptPath)] : [scriptPath];
const child = spawn(file, args, {
detached: true,
stdio: "ignore",
});
child.unref();
}