310 lines
8.5 KiB
TypeScript
310 lines
8.5 KiB
TypeScript
import { loadConfig } from "../../config/config.js";
|
||
import { resolveIsNixMode } from "../../config/paths.js";
|
||
import { checkTokenDrift } from "../../daemon/service-audit.js";
|
||
import type { GatewayService } from "../../daemon/service.js";
|
||
import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js";
|
||
import { isSystemdUserServiceAvailable } from "../../daemon/systemd.js";
|
||
import { isWSL } from "../../infra/wsl.js";
|
||
import { defaultRuntime } from "../../runtime.js";
|
||
import {
|
||
buildDaemonServiceSnapshot,
|
||
createNullWriter,
|
||
type DaemonAction,
|
||
emitDaemonActionJson,
|
||
} from "./response.js";
|
||
|
||
type DaemonLifecycleOptions = {
|
||
json?: boolean;
|
||
};
|
||
|
||
async function maybeAugmentSystemdHints(hints: string[]): Promise<string[]> {
|
||
if (process.platform !== "linux") {
|
||
return hints;
|
||
}
|
||
const systemdAvailable = await isSystemdUserServiceAvailable().catch(() => false);
|
||
if (systemdAvailable) {
|
||
return hints;
|
||
}
|
||
return [...hints, ...renderSystemdUnavailableHints({ wsl: await isWSL() })];
|
||
}
|
||
|
||
function createActionIO(params: { action: DaemonAction; json: boolean }) {
|
||
const stdout = params.json ? createNullWriter() : process.stdout;
|
||
const emit = (payload: {
|
||
ok: boolean;
|
||
result?: string;
|
||
message?: string;
|
||
error?: string;
|
||
hints?: string[];
|
||
warnings?: string[];
|
||
service?: {
|
||
label: string;
|
||
loaded: boolean;
|
||
loadedText: string;
|
||
notLoadedText: string;
|
||
};
|
||
}) => {
|
||
if (!params.json) {
|
||
return;
|
||
}
|
||
emitDaemonActionJson({ action: params.action, ...payload });
|
||
};
|
||
const fail = (message: string, hints?: string[]) => {
|
||
if (params.json) {
|
||
emit({ ok: false, error: message, hints });
|
||
} else {
|
||
defaultRuntime.error(message);
|
||
}
|
||
defaultRuntime.exit(1);
|
||
};
|
||
return { stdout, emit, fail };
|
||
}
|
||
|
||
async function handleServiceNotLoaded(params: {
|
||
serviceNoun: string;
|
||
service: GatewayService;
|
||
loaded: boolean;
|
||
renderStartHints: () => string[];
|
||
json: boolean;
|
||
emit: ReturnType<typeof createActionIO>["emit"];
|
||
}) {
|
||
const hints = await maybeAugmentSystemdHints(params.renderStartHints());
|
||
params.emit({
|
||
ok: true,
|
||
result: "not-loaded",
|
||
message: `${params.serviceNoun} service ${params.service.notLoadedText}.`,
|
||
hints,
|
||
service: buildDaemonServiceSnapshot(params.service, params.loaded),
|
||
});
|
||
if (!params.json) {
|
||
defaultRuntime.log(`${params.serviceNoun} service ${params.service.notLoadedText}.`);
|
||
for (const hint of hints) {
|
||
defaultRuntime.log(`Start with: ${hint}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
export async function runServiceUninstall(params: {
|
||
serviceNoun: string;
|
||
service: GatewayService;
|
||
opts?: DaemonLifecycleOptions;
|
||
stopBeforeUninstall: boolean;
|
||
assertNotLoadedAfterUninstall: boolean;
|
||
}) {
|
||
const json = Boolean(params.opts?.json);
|
||
const { stdout, emit, fail } = createActionIO({ action: "uninstall", json });
|
||
|
||
if (resolveIsNixMode(process.env)) {
|
||
fail("Nix mode detected; service uninstall is disabled.");
|
||
return;
|
||
}
|
||
|
||
let loaded = false;
|
||
try {
|
||
loaded = await params.service.isLoaded({ env: process.env });
|
||
} catch {
|
||
loaded = false;
|
||
}
|
||
if (loaded && params.stopBeforeUninstall) {
|
||
try {
|
||
await params.service.stop({ env: process.env, stdout });
|
||
} catch {
|
||
// Best-effort stop; final loaded check gates success when enabled.
|
||
}
|
||
}
|
||
try {
|
||
await params.service.uninstall({ env: process.env, stdout });
|
||
} catch (err) {
|
||
fail(`${params.serviceNoun} uninstall failed: ${String(err)}`);
|
||
return;
|
||
}
|
||
|
||
loaded = false;
|
||
try {
|
||
loaded = await params.service.isLoaded({ env: process.env });
|
||
} catch {
|
||
loaded = false;
|
||
}
|
||
if (loaded && params.assertNotLoadedAfterUninstall) {
|
||
fail(`${params.serviceNoun} service still loaded after uninstall.`);
|
||
return;
|
||
}
|
||
emit({
|
||
ok: true,
|
||
result: "uninstalled",
|
||
service: buildDaemonServiceSnapshot(params.service, loaded),
|
||
});
|
||
}
|
||
|
||
export async function runServiceStart(params: {
|
||
serviceNoun: string;
|
||
service: GatewayService;
|
||
renderStartHints: () => string[];
|
||
opts?: DaemonLifecycleOptions;
|
||
}) {
|
||
const json = Boolean(params.opts?.json);
|
||
const { stdout, emit, fail } = createActionIO({ action: "start", json });
|
||
|
||
let loaded = false;
|
||
try {
|
||
loaded = await params.service.isLoaded({ env: process.env });
|
||
} catch (err) {
|
||
fail(`${params.serviceNoun} service check failed: ${String(err)}`);
|
||
return;
|
||
}
|
||
if (!loaded) {
|
||
await handleServiceNotLoaded({
|
||
serviceNoun: params.serviceNoun,
|
||
service: params.service,
|
||
loaded,
|
||
renderStartHints: params.renderStartHints,
|
||
json,
|
||
emit,
|
||
});
|
||
return;
|
||
}
|
||
try {
|
||
await params.service.restart({ env: process.env, stdout });
|
||
} catch (err) {
|
||
const hints = params.renderStartHints();
|
||
fail(`${params.serviceNoun} start failed: ${String(err)}`, hints);
|
||
return;
|
||
}
|
||
|
||
let started = true;
|
||
try {
|
||
started = await params.service.isLoaded({ env: process.env });
|
||
} catch {
|
||
started = true;
|
||
}
|
||
emit({
|
||
ok: true,
|
||
result: "started",
|
||
service: buildDaemonServiceSnapshot(params.service, started),
|
||
});
|
||
}
|
||
|
||
export async function runServiceStop(params: {
|
||
serviceNoun: string;
|
||
service: GatewayService;
|
||
opts?: DaemonLifecycleOptions;
|
||
}) {
|
||
const json = Boolean(params.opts?.json);
|
||
const { stdout, emit, fail } = createActionIO({ action: "stop", json });
|
||
|
||
let loaded = false;
|
||
try {
|
||
loaded = await params.service.isLoaded({ env: process.env });
|
||
} catch (err) {
|
||
fail(`${params.serviceNoun} service check failed: ${String(err)}`);
|
||
return;
|
||
}
|
||
if (!loaded) {
|
||
emit({
|
||
ok: true,
|
||
result: "not-loaded",
|
||
message: `${params.serviceNoun} service ${params.service.notLoadedText}.`,
|
||
service: buildDaemonServiceSnapshot(params.service, loaded),
|
||
});
|
||
if (!json) {
|
||
defaultRuntime.log(`${params.serviceNoun} service ${params.service.notLoadedText}.`);
|
||
}
|
||
return;
|
||
}
|
||
try {
|
||
await params.service.stop({ env: process.env, stdout });
|
||
} catch (err) {
|
||
fail(`${params.serviceNoun} stop failed: ${String(err)}`);
|
||
return;
|
||
}
|
||
|
||
let stopped = false;
|
||
try {
|
||
stopped = await params.service.isLoaded({ env: process.env });
|
||
} catch {
|
||
stopped = false;
|
||
}
|
||
emit({
|
||
ok: true,
|
||
result: "stopped",
|
||
service: buildDaemonServiceSnapshot(params.service, stopped),
|
||
});
|
||
}
|
||
|
||
export async function runServiceRestart(params: {
|
||
serviceNoun: string;
|
||
service: GatewayService;
|
||
renderStartHints: () => string[];
|
||
opts?: DaemonLifecycleOptions;
|
||
}): Promise<boolean> {
|
||
const json = Boolean(params.opts?.json);
|
||
const { stdout, emit, fail } = createActionIO({ action: "restart", json });
|
||
|
||
let loaded = false;
|
||
try {
|
||
loaded = await params.service.isLoaded({ env: process.env });
|
||
} catch (err) {
|
||
fail(`${params.serviceNoun} service check failed: ${String(err)}`);
|
||
return false;
|
||
}
|
||
if (!loaded) {
|
||
await handleServiceNotLoaded({
|
||
serviceNoun: params.serviceNoun,
|
||
service: params.service,
|
||
loaded,
|
||
renderStartHints: params.renderStartHints,
|
||
json,
|
||
emit,
|
||
});
|
||
return false;
|
||
}
|
||
|
||
// Check for token drift before restart (service token vs config token)
|
||
const warnings: string[] = [];
|
||
try {
|
||
const command = await params.service.readCommand(process.env);
|
||
const serviceToken = command?.environment?.OPENCLAW_GATEWAY_TOKEN;
|
||
const cfg = loadConfig();
|
||
const configToken =
|
||
cfg.gateway?.auth?.token ||
|
||
process.env.OPENCLAW_GATEWAY_TOKEN ||
|
||
process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||
const driftIssue = checkTokenDrift({ serviceToken, configToken });
|
||
if (driftIssue) {
|
||
const warning = driftIssue.detail
|
||
? `${driftIssue.message} ${driftIssue.detail}`
|
||
: driftIssue.message;
|
||
warnings.push(warning);
|
||
if (!json) {
|
||
defaultRuntime.log(`\n⚠️ ${driftIssue.message}`);
|
||
if (driftIssue.detail) {
|
||
defaultRuntime.log(` ${driftIssue.detail}\n`);
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
// Non-fatal: token drift check is best-effort
|
||
}
|
||
|
||
try {
|
||
await params.service.restart({ env: process.env, stdout });
|
||
let restarted = true;
|
||
try {
|
||
restarted = await params.service.isLoaded({ env: process.env });
|
||
} catch {
|
||
restarted = true;
|
||
}
|
||
emit({
|
||
ok: true,
|
||
result: "restarted",
|
||
service: buildDaemonServiceSnapshot(params.service, restarted),
|
||
warnings: warnings.length ? warnings : undefined,
|
||
});
|
||
return true;
|
||
} catch (err) {
|
||
const hints = params.renderStartHints();
|
||
fail(`${params.serviceNoun} restart failed: ${String(err)}`, hints);
|
||
return false;
|
||
}
|
||
}
|