Files
Moltbot/src/cli/daemon-cli/lifecycle-core.ts
2026-02-17 09:18:40 +09:00

310 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}