Address review feedback - when --json mode is used, the drift warning was completely suppressed. Now it's included in the warnings array of the DaemonActionResponse so programmatic consumers can surface it.
309 lines
8.4 KiB
TypeScript
309 lines
8.4 KiB
TypeScript
import type { GatewayService } from "../../daemon/service.js";
|
||
import { loadConfig } from "../../config/config.js";
|
||
import { resolveIsNixMode } from "../../config/paths.js";
|
||
import { checkTokenDrift } from "../../daemon/service-audit.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[];
|
||
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;
|
||
}
|
||
}
|