451 lines
15 KiB
TypeScript
451 lines
15 KiB
TypeScript
import { withProgress } from "../cli/progress.js";
|
|
import { resolveGatewayPort } from "../config/config.js";
|
|
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
|
import { info } from "../globals.js";
|
|
import { formatUsageReportLines, loadProviderUsageSummary } from "../infra/provider-usage.js";
|
|
import type { RuntimeEnv } from "../runtime.js";
|
|
import { runSecurityAudit } from "../security/audit.js";
|
|
import { renderTable } from "../terminal/table.js";
|
|
import { theme } from "../terminal/theme.js";
|
|
import { formatHealthChannelLines, type HealthSummary } from "./health.js";
|
|
import { resolveControlUiLinks } from "./onboard-helpers.js";
|
|
import { getDaemonStatusSummary } from "./status.daemon.js";
|
|
import {
|
|
formatAge,
|
|
formatDuration,
|
|
formatKTokens,
|
|
formatTokensCompact,
|
|
shortenText,
|
|
} from "./status.format.js";
|
|
import { resolveGatewayProbeAuth } from "./status.gateway-probe.js";
|
|
import { scanStatus } from "./status.scan.js";
|
|
import { formatUpdateOneLiner } from "./status.update.js";
|
|
import { formatGatewayAuthUsed } from "./status-all/format.js";
|
|
import { statusAllCommand } from "./status-all.js";
|
|
|
|
export async function statusCommand(
|
|
opts: {
|
|
json?: boolean;
|
|
deep?: boolean;
|
|
usage?: boolean;
|
|
timeoutMs?: number;
|
|
verbose?: boolean;
|
|
all?: boolean;
|
|
},
|
|
runtime: RuntimeEnv,
|
|
) {
|
|
if (opts.all && !opts.json) {
|
|
await statusAllCommand(runtime, { timeoutMs: opts.timeoutMs });
|
|
return;
|
|
}
|
|
|
|
const scan = await scanStatus(
|
|
{ json: opts.json, timeoutMs: opts.timeoutMs, all: opts.all },
|
|
runtime,
|
|
);
|
|
const {
|
|
cfg,
|
|
osSummary,
|
|
tailscaleMode,
|
|
tailscaleDns,
|
|
tailscaleHttpsUrl,
|
|
update,
|
|
gatewayConnection,
|
|
remoteUrlMissing,
|
|
gatewayMode,
|
|
gatewayProbe,
|
|
gatewayReachable,
|
|
gatewaySelf,
|
|
channelIssues,
|
|
agentStatus,
|
|
channels,
|
|
summary,
|
|
} = scan;
|
|
|
|
const securityAudit = await withProgress(
|
|
{
|
|
label: "Running security audit…",
|
|
indeterminate: true,
|
|
enabled: opts.json !== true,
|
|
},
|
|
async () =>
|
|
await runSecurityAudit({
|
|
config: cfg,
|
|
deep: false,
|
|
includeFilesystem: true,
|
|
includeChannelSecurity: true,
|
|
}),
|
|
);
|
|
|
|
const usage = opts.usage
|
|
? await withProgress(
|
|
{
|
|
label: "Fetching usage snapshot…",
|
|
indeterminate: true,
|
|
enabled: opts.json !== true,
|
|
},
|
|
async () => await loadProviderUsageSummary({ timeoutMs: opts.timeoutMs }),
|
|
)
|
|
: undefined;
|
|
const health: HealthSummary | undefined = opts.deep
|
|
? await withProgress(
|
|
{
|
|
label: "Checking gateway health…",
|
|
indeterminate: true,
|
|
enabled: opts.json !== true,
|
|
},
|
|
async () =>
|
|
await callGateway<HealthSummary>({
|
|
method: "health",
|
|
timeoutMs: opts.timeoutMs,
|
|
}),
|
|
)
|
|
: undefined;
|
|
|
|
if (opts.json) {
|
|
runtime.log(
|
|
JSON.stringify(
|
|
{
|
|
...summary,
|
|
os: osSummary,
|
|
update,
|
|
gateway: {
|
|
mode: gatewayMode,
|
|
url: gatewayConnection.url,
|
|
urlSource: gatewayConnection.urlSource,
|
|
misconfigured: remoteUrlMissing,
|
|
reachable: gatewayReachable,
|
|
connectLatencyMs: gatewayProbe?.connectLatencyMs ?? null,
|
|
self: gatewaySelf,
|
|
error: gatewayProbe?.error ?? null,
|
|
},
|
|
agents: agentStatus,
|
|
securityAudit,
|
|
...(health || usage ? { health, usage } : {}),
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const rich = true;
|
|
const muted = (value: string) => (rich ? theme.muted(value) : value);
|
|
const ok = (value: string) => (rich ? theme.success(value) : value);
|
|
const warn = (value: string) => (rich ? theme.warn(value) : value);
|
|
|
|
if (opts.verbose) {
|
|
const details = buildGatewayConnectionDetails();
|
|
runtime.log(info("Gateway connection:"));
|
|
for (const line of details.message.split("\n")) runtime.log(` ${line}`);
|
|
runtime.log("");
|
|
}
|
|
|
|
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
|
|
|
const dashboard = (() => {
|
|
const controlUiEnabled = cfg.gateway?.controlUi?.enabled ?? true;
|
|
if (!controlUiEnabled) return "disabled";
|
|
const links = resolveControlUiLinks({
|
|
port: resolveGatewayPort(cfg),
|
|
bind: cfg.gateway?.bind,
|
|
customBindHost: cfg.gateway?.customBindHost,
|
|
basePath: cfg.gateway?.controlUi?.basePath,
|
|
});
|
|
return links.httpUrl;
|
|
})();
|
|
|
|
const gatewayValue = (() => {
|
|
const target = remoteUrlMissing
|
|
? `fallback ${gatewayConnection.url}`
|
|
: `${gatewayConnection.url}${gatewayConnection.urlSource ? ` (${gatewayConnection.urlSource})` : ""}`;
|
|
const reach = remoteUrlMissing
|
|
? warn("misconfigured (remote.url missing)")
|
|
: gatewayReachable
|
|
? ok(`reachable ${formatDuration(gatewayProbe?.connectLatencyMs)}`)
|
|
: warn(gatewayProbe?.error ? `unreachable (${gatewayProbe.error})` : "unreachable");
|
|
const auth =
|
|
gatewayReachable && !remoteUrlMissing
|
|
? ` · auth ${formatGatewayAuthUsed(resolveGatewayProbeAuth(cfg))}`
|
|
: "";
|
|
const self =
|
|
gatewaySelf?.host || gatewaySelf?.version || gatewaySelf?.platform
|
|
? [
|
|
gatewaySelf?.host ? gatewaySelf.host : null,
|
|
gatewaySelf?.ip ? `(${gatewaySelf.ip})` : null,
|
|
gatewaySelf?.version ? `app ${gatewaySelf.version}` : null,
|
|
gatewaySelf?.platform ? gatewaySelf.platform : null,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
: null;
|
|
const suffix = self ? ` · ${self}` : "";
|
|
return `${gatewayMode} · ${target} · ${reach}${auth}${suffix}`;
|
|
})();
|
|
|
|
const agentsValue = (() => {
|
|
const pending =
|
|
agentStatus.bootstrapPendingCount > 0
|
|
? `${agentStatus.bootstrapPendingCount} bootstrapping`
|
|
: "no bootstraps";
|
|
const def = agentStatus.agents.find((a) => a.id === agentStatus.defaultId);
|
|
const defActive = def?.lastActiveAgeMs != null ? formatAge(def.lastActiveAgeMs) : "unknown";
|
|
const defSuffix = def ? ` · default ${def.id} active ${defActive}` : "";
|
|
return `${agentStatus.agents.length} · ${pending} · sessions ${agentStatus.totalSessions}${defSuffix}`;
|
|
})();
|
|
|
|
const daemon = await getDaemonStatusSummary();
|
|
const daemonValue = (() => {
|
|
if (daemon.installed === false) return `${daemon.label} not installed`;
|
|
const installedPrefix = daemon.installed === true ? "installed · " : "";
|
|
return `${daemon.label} ${installedPrefix}${daemon.loadedText}${daemon.runtimeShort ? ` · ${daemon.runtimeShort}` : ""}`;
|
|
})();
|
|
|
|
const defaults = summary.sessions.defaults;
|
|
const defaultCtx = defaults.contextTokens
|
|
? ` (${formatKTokens(defaults.contextTokens)} ctx)`
|
|
: "";
|
|
const eventsValue =
|
|
summary.queuedSystemEvents.length > 0 ? `${summary.queuedSystemEvents.length} queued` : "none";
|
|
|
|
const probesValue = health ? ok("enabled") : muted("skipped (use --deep)");
|
|
|
|
const overviewRows = [
|
|
{ Item: "Dashboard", Value: dashboard },
|
|
{ Item: "OS", Value: `${osSummary.label} · node ${process.versions.node}` },
|
|
{
|
|
Item: "Tailscale",
|
|
Value:
|
|
tailscaleMode === "off"
|
|
? muted("off")
|
|
: tailscaleDns && tailscaleHttpsUrl
|
|
? `${tailscaleMode} · ${tailscaleDns} · ${tailscaleHttpsUrl}`
|
|
: warn(`${tailscaleMode} · magicdns unknown`),
|
|
},
|
|
{
|
|
Item: "Update",
|
|
Value: formatUpdateOneLiner(update).replace(/^Update:\s*/i, ""),
|
|
},
|
|
{ Item: "Gateway", Value: gatewayValue },
|
|
{ Item: "Daemon", Value: daemonValue },
|
|
{ Item: "Agents", Value: agentsValue },
|
|
{ Item: "Probes", Value: probesValue },
|
|
{ Item: "Events", Value: eventsValue },
|
|
{ Item: "Heartbeat", Value: `${summary.heartbeatSeconds}s` },
|
|
{
|
|
Item: "Sessions",
|
|
Value: `${summary.sessions.count} active · default ${defaults.model ?? "unknown"}${defaultCtx} · store ${summary.sessions.path}`,
|
|
},
|
|
];
|
|
|
|
runtime.log(theme.heading("Clawdbot status"));
|
|
runtime.log("");
|
|
runtime.log(theme.heading("Overview"));
|
|
runtime.log(
|
|
renderTable({
|
|
width: tableWidth,
|
|
columns: [
|
|
{ key: "Item", header: "Item", minWidth: 12 },
|
|
{ key: "Value", header: "Value", flex: true, minWidth: 32 },
|
|
],
|
|
rows: overviewRows,
|
|
}).trimEnd(),
|
|
);
|
|
|
|
runtime.log("");
|
|
runtime.log(theme.heading("Security audit"));
|
|
const fmtSummary = (value: { critical: number; warn: number; info: number }) => {
|
|
const parts = [
|
|
theme.error(`${value.critical} critical`),
|
|
theme.warn(`${value.warn} warn`),
|
|
theme.muted(`${value.info} info`),
|
|
];
|
|
return parts.join(" · ");
|
|
};
|
|
runtime.log(theme.muted(`Summary: ${fmtSummary(securityAudit.summary)}`));
|
|
const importantFindings = securityAudit.findings.filter(
|
|
(f) => f.severity === "critical" || f.severity === "warn",
|
|
);
|
|
if (importantFindings.length === 0) {
|
|
runtime.log(theme.muted("No critical or warn findings detected."));
|
|
} else {
|
|
const severityLabel = (sev: "critical" | "warn" | "info") => {
|
|
if (sev === "critical") return theme.error("CRITICAL");
|
|
if (sev === "warn") return theme.warn("WARN");
|
|
return theme.muted("INFO");
|
|
};
|
|
const sevRank = (sev: "critical" | "warn" | "info") =>
|
|
sev === "critical" ? 0 : sev === "warn" ? 1 : 2;
|
|
const sorted = [...importantFindings].sort((a, b) => sevRank(a.severity) - sevRank(b.severity));
|
|
const shown = sorted.slice(0, 6);
|
|
for (const f of shown) {
|
|
runtime.log(` ${severityLabel(f.severity)} ${f.title}`);
|
|
runtime.log(` ${shortenText(f.detail.replaceAll("\n", " "), 160)}`);
|
|
if (f.remediation?.trim()) runtime.log(` ${theme.muted(`Fix: ${f.remediation.trim()}`)}`);
|
|
}
|
|
if (sorted.length > shown.length) {
|
|
runtime.log(theme.muted(`… +${sorted.length - shown.length} more`));
|
|
}
|
|
}
|
|
runtime.log(theme.muted("Full report: clawdbot security audit"));
|
|
runtime.log(theme.muted("Deep probe: clawdbot security audit --deep"));
|
|
|
|
runtime.log("");
|
|
runtime.log(theme.heading("Channels"));
|
|
const channelIssuesByChannel = (() => {
|
|
const map = new Map<string, typeof channelIssues>();
|
|
for (const issue of channelIssues) {
|
|
const key = issue.channel;
|
|
const list = map.get(key);
|
|
if (list) list.push(issue);
|
|
else map.set(key, [issue]);
|
|
}
|
|
return map;
|
|
})();
|
|
runtime.log(
|
|
renderTable({
|
|
width: tableWidth,
|
|
columns: [
|
|
{ key: "Channel", header: "Channel", minWidth: 10 },
|
|
{ key: "Enabled", header: "Enabled", minWidth: 7 },
|
|
{ key: "State", header: "State", minWidth: 8 },
|
|
{ key: "Detail", header: "Detail", flex: true, minWidth: 24 },
|
|
],
|
|
rows: channels.rows.map((row) => {
|
|
const issues = channelIssuesByChannel.get(row.id) ?? [];
|
|
const effectiveState = row.state === "off" ? "off" : issues.length > 0 ? "warn" : row.state;
|
|
const issueSuffix =
|
|
issues.length > 0
|
|
? ` · ${warn(`gateway: ${shortenText(issues[0]?.message ?? "issue", 84)}`)}`
|
|
: "";
|
|
return {
|
|
Channel: row.label,
|
|
Enabled: row.enabled ? ok("ON") : muted("OFF"),
|
|
State:
|
|
effectiveState === "ok"
|
|
? ok("OK")
|
|
: effectiveState === "warn"
|
|
? warn("WARN")
|
|
: effectiveState === "off"
|
|
? muted("OFF")
|
|
: theme.accentDim("SETUP"),
|
|
Detail: `${row.detail}${issueSuffix}`,
|
|
};
|
|
}),
|
|
}).trimEnd(),
|
|
);
|
|
|
|
runtime.log("");
|
|
runtime.log(theme.heading("Sessions"));
|
|
runtime.log(
|
|
renderTable({
|
|
width: tableWidth,
|
|
columns: [
|
|
{ key: "Key", header: "Key", minWidth: 20, flex: true },
|
|
{ key: "Kind", header: "Kind", minWidth: 6 },
|
|
{ key: "Age", header: "Age", minWidth: 9 },
|
|
{ key: "Model", header: "Model", minWidth: 14 },
|
|
{ key: "Tokens", header: "Tokens", minWidth: 16 },
|
|
],
|
|
rows:
|
|
summary.sessions.recent.length > 0
|
|
? summary.sessions.recent.map((sess) => ({
|
|
Key: shortenText(sess.key, 32),
|
|
Kind: sess.kind,
|
|
Age: sess.updatedAt ? formatAge(sess.age) : "no activity",
|
|
Model: sess.model ?? "unknown",
|
|
Tokens: formatTokensCompact(sess),
|
|
}))
|
|
: [
|
|
{
|
|
Key: muted("no sessions yet"),
|
|
Kind: "",
|
|
Age: "",
|
|
Model: "",
|
|
Tokens: "",
|
|
},
|
|
],
|
|
}).trimEnd(),
|
|
);
|
|
|
|
if (summary.queuedSystemEvents.length > 0) {
|
|
runtime.log("");
|
|
runtime.log(theme.heading("System events"));
|
|
runtime.log(
|
|
renderTable({
|
|
width: tableWidth,
|
|
columns: [{ key: "Event", header: "Event", flex: true, minWidth: 24 }],
|
|
rows: summary.queuedSystemEvents.slice(0, 5).map((event) => ({
|
|
Event: event,
|
|
})),
|
|
}).trimEnd(),
|
|
);
|
|
if (summary.queuedSystemEvents.length > 5) {
|
|
runtime.log(muted(`… +${summary.queuedSystemEvents.length - 5} more`));
|
|
}
|
|
}
|
|
|
|
if (health) {
|
|
runtime.log("");
|
|
runtime.log(theme.heading("Health"));
|
|
const rows: Array<Record<string, string>> = [];
|
|
rows.push({
|
|
Item: "Gateway",
|
|
Status: ok("reachable"),
|
|
Detail: `${health.durationMs}ms`,
|
|
});
|
|
|
|
for (const line of formatHealthChannelLines(health)) {
|
|
const colon = line.indexOf(":");
|
|
if (colon === -1) continue;
|
|
const item = line.slice(0, colon).trim();
|
|
const detail = line.slice(colon + 1).trim();
|
|
const normalized = detail.toLowerCase();
|
|
const status = (() => {
|
|
if (normalized.startsWith("ok")) return ok("OK");
|
|
if (normalized.startsWith("failed")) return warn("WARN");
|
|
if (normalized.startsWith("not configured")) return muted("OFF");
|
|
if (normalized.startsWith("configured")) return ok("OK");
|
|
if (normalized.startsWith("linked")) return ok("LINKED");
|
|
if (normalized.startsWith("not linked")) return warn("UNLINKED");
|
|
return warn("WARN");
|
|
})();
|
|
rows.push({ Item: item, Status: status, Detail: detail });
|
|
}
|
|
|
|
runtime.log(
|
|
renderTable({
|
|
width: tableWidth,
|
|
columns: [
|
|
{ key: "Item", header: "Item", minWidth: 10 },
|
|
{ key: "Status", header: "Status", minWidth: 8 },
|
|
{ key: "Detail", header: "Detail", flex: true, minWidth: 28 },
|
|
],
|
|
rows,
|
|
}).trimEnd(),
|
|
);
|
|
}
|
|
|
|
if (usage) {
|
|
runtime.log("");
|
|
runtime.log(theme.heading("Usage"));
|
|
for (const line of formatUsageReportLines(usage)) {
|
|
runtime.log(line);
|
|
}
|
|
}
|
|
|
|
runtime.log("");
|
|
runtime.log("FAQ: https://docs.clawd.bot/faq");
|
|
runtime.log("Troubleshooting: https://docs.clawd.bot/troubleshooting");
|
|
runtime.log("");
|
|
runtime.log("Next steps:");
|
|
runtime.log(" Need to share? clawdbot status --all");
|
|
runtime.log(" Need to debug live? clawdbot logs --follow");
|
|
if (gatewayReachable) {
|
|
runtime.log(" Need to test channels? clawdbot status --deep");
|
|
} else {
|
|
runtime.log(" Fix reachability first: clawdbot gateway status");
|
|
}
|
|
}
|