feat(status): improve status output
This commit is contained in:
@@ -69,6 +69,7 @@
|
||||
- Vocabulary: "makeup" = "mac app".
|
||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||
- Never update the Carbon dependency.
|
||||
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
||||
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
- Agents/OpenCode Zen: update fallback models + defaults, keep legacy alias mappings. (#669) — thanks @magimetal.
|
||||
- Providers: unify group history context wrappers across providers with per-provider/per-account `historyLimit` overrides (fallback to `messages.groupChat.historyLimit`). Set `0` to disable. (#672) — thanks @steipete.
|
||||
- CLI: add `clawdbot update` (safe-ish git checkout update) + `--update` shorthand. (#673) — thanks @fm1randa.
|
||||
- CLI: improve `clawdbot status` (OS/update/gateway/daemon/agents/sessions) + add `status --all` for full read-only diagnosis with tables, log tails, and scan progress (OSC-9 + spinner).
|
||||
- Gateway: add OpenAI-compatible `/v1/chat/completions` HTTP endpoint (auth, SSE streaming, per-agent routing). (#680) — thanks @steipete.
|
||||
- Gateway/Heartbeat: optionally deliver heartbeat `Reasoning:` output (`agents.defaults.heartbeat.includeReasoning`). (#690)
|
||||
- Docker: allow optional home volume + extra bind mounts in `docker-setup.sh`. (#679) — thanks @gabriel-trigo.
|
||||
|
||||
@@ -8,7 +8,8 @@ read_when:
|
||||
Short guide to verify the WhatsApp Web / Baileys stack without guessing.
|
||||
|
||||
## Quick checks
|
||||
- `clawdbot status` — local summary: whether creds exist, auth age, session store path + recent sessions.
|
||||
- `clawdbot status` — local summary: gateway reachability/mode, update hint, creds/auth age, sessions + recent activity.
|
||||
- `clawdbot status --all` — full local diagnosis (read-only, color, safe to paste for debugging).
|
||||
- `clawdbot status --deep` — also probes the running Gateway (WhatsApp connect + Telegram + Discord APIs).
|
||||
- `clawdbot health --json` — asks the running Gateway for a full health snapshot (WS-only; no direct Baileys socket).
|
||||
- Send `/status` as a standalone message in WhatsApp/WebChat to get a status reply without invoking the agent.
|
||||
|
||||
@@ -206,6 +206,7 @@ Clawdbot extracts these and sends them as media alongside the text.
|
||||
|
||||
```bash
|
||||
clawdbot status # local status (creds, sessions, queued events)
|
||||
clawdbot status --all # full diagnosis (read-only, pasteable)
|
||||
clawdbot status --deep # also probes the running Gateway (WA connect + Telegram)
|
||||
clawdbot health --json # gateway health snapshot (WS)
|
||||
```
|
||||
|
||||
@@ -589,7 +589,6 @@ export async function getReplyFromConfig(
|
||||
(agentCfg?.elevatedDefault as ElevatedLevel | undefined) ??
|
||||
"on")
|
||||
: "off";
|
||||
const _providerKey = sessionCtx.Provider?.trim().toLowerCase();
|
||||
const resolvedBlockStreaming =
|
||||
opts?.disableBlockStreaming === true
|
||||
? "off"
|
||||
|
||||
@@ -1148,8 +1148,9 @@ ${theme.muted("Docs:")} ${formatDocsLink(
|
||||
|
||||
program
|
||||
.command("status")
|
||||
.description("Show web session health and recent session recipients")
|
||||
.description("Show local status (gateway, agents, sessions, auth)")
|
||||
.option("--json", "Output JSON instead of text", false)
|
||||
.option("--all", "Full diagnosis (read-only, pasteable)", false)
|
||||
.option("--usage", "Show provider usage/quota snapshots", false)
|
||||
.option(
|
||||
"--deep",
|
||||
@@ -1164,6 +1165,7 @@ ${theme.muted("Docs:")} ${formatDocsLink(
|
||||
`
|
||||
Examples:
|
||||
clawdbot status # show linked account + session store summary
|
||||
clawdbot status --all # full diagnosis (read-only)
|
||||
clawdbot status --json # machine-readable output
|
||||
clawdbot status --usage # show provider usage/quota snapshots
|
||||
clawdbot status --deep # run provider probes (WA + Telegram + Discord + Slack + Signal)
|
||||
@@ -1187,6 +1189,7 @@ Examples:
|
||||
await statusCommand(
|
||||
{
|
||||
json: Boolean(opts.json),
|
||||
all: Boolean(opts.all),
|
||||
deep: Boolean(opts.deep),
|
||||
usage: Boolean(opts.usage),
|
||||
timeoutMs: timeout,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { spinner } from "@clack/prompts";
|
||||
import { createOscProgressController, supportsOscProgress } from "osc-progress";
|
||||
|
||||
import { theme } from "../terminal/theme.js";
|
||||
|
||||
const DEFAULT_DELAY_MS = 300;
|
||||
@@ -47,8 +46,7 @@ export function createCliProgress(options: ProgressOptions): ProgressReporter {
|
||||
typeof options.delayMs === "number" ? options.delayMs : DEFAULT_DELAY_MS;
|
||||
const canOsc = supportsOscProgress(process.env, stream.isTTY);
|
||||
const allowSpinner =
|
||||
!canOsc &&
|
||||
(options.fallback === undefined || options.fallback === "spinner");
|
||||
options.fallback === undefined || options.fallback === "spinner";
|
||||
|
||||
let started = false;
|
||||
let label = options.label;
|
||||
@@ -77,7 +75,8 @@ export function createCliProgress(options: ProgressOptions): ProgressReporter {
|
||||
if (controller) {
|
||||
if (indeterminate) controller.setIndeterminate(label);
|
||||
else controller.setPercent(label, percent);
|
||||
} else if (spin) {
|
||||
}
|
||||
if (spin) {
|
||||
spin.message(theme.accent(label));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ import { isRich, theme } from "../terminal/theme.js";
|
||||
|
||||
const SEARCH_TOOL = "https://docs.clawd.bot/mcp.SearchClawdbot";
|
||||
const SEARCH_TIMEOUT_MS = 30_000;
|
||||
const RENDER_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_SNIPPET_MAX = 220;
|
||||
|
||||
type DocResult = {
|
||||
@@ -154,20 +153,6 @@ function renderRichResults(
|
||||
}
|
||||
|
||||
async function renderMarkdown(markdown: string, runtime: RuntimeEnv) {
|
||||
const width = process.stdout.columns ?? 0;
|
||||
const args = width > 0 ? ["--width", String(width)] : [];
|
||||
try {
|
||||
const res = await runTool("markdansi", args, {
|
||||
timeoutMs: RENDER_TIMEOUT_MS,
|
||||
input: markdown,
|
||||
});
|
||||
if (res.code === 0 && res.stdout.trim()) {
|
||||
runtime.log(res.stdout.trimEnd());
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to plain Markdown if renderer fails or cannot be installed.
|
||||
}
|
||||
runtime.log(markdown.trimEnd());
|
||||
}
|
||||
|
||||
|
||||
567
src/commands/status-all.ts
Normal file
567
src/commands/status-all.ts
Normal file
@@ -0,0 +1,567 @@
|
||||
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
||||
import {
|
||||
loadConfig,
|
||||
readConfigFileSnapshot,
|
||||
resolveGatewayPort,
|
||||
} from "../config/config.js";
|
||||
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
|
||||
import { resolveGatewayLogPaths } from "../daemon/launchd.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
||||
import { probeGateway } from "../gateway/probe.js";
|
||||
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
|
||||
import { resolveOsSummary } from "../infra/os-summary.js";
|
||||
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
|
||||
import { collectProvidersStatusIssues } from "../infra/providers-status-issues.js";
|
||||
import {
|
||||
readRestartSentinel,
|
||||
summarizeRestartSentinel,
|
||||
} from "../infra/restart-sentinel.js";
|
||||
import {
|
||||
checkUpdateStatus,
|
||||
compareSemverStrings,
|
||||
} from "../infra/update-check.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { renderTable } from "../terminal/table.js";
|
||||
import { isRich, theme } from "../terminal/theme.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { resolveControlUiLinks } from "./onboard-helpers.js";
|
||||
import { getAgentLocalStatuses } from "./status-all/agents.js";
|
||||
import {
|
||||
formatAge,
|
||||
formatDuration,
|
||||
redactSecrets,
|
||||
} from "./status-all/format.js";
|
||||
import {
|
||||
pickGatewaySelfPresence,
|
||||
readFileTailLines,
|
||||
} from "./status-all/gateway.js";
|
||||
import { buildProvidersTable } from "./status-all/providers.js";
|
||||
|
||||
export async function statusAllCommand(
|
||||
runtime: RuntimeEnv,
|
||||
opts?: { timeoutMs?: number },
|
||||
): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
const osSummary = resolveOsSummary();
|
||||
const snap = await readConfigFileSnapshot().catch(() => null);
|
||||
|
||||
const root = await resolveClawdbotPackageRoot({
|
||||
moduleUrl: import.meta.url,
|
||||
argv1: process.argv[1],
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
const update = await checkUpdateStatus({
|
||||
root,
|
||||
timeoutMs: 6500,
|
||||
fetchGit: true,
|
||||
includeRegistry: true,
|
||||
});
|
||||
|
||||
const connection = buildGatewayConnectionDetails({ config: cfg });
|
||||
const isRemoteMode = cfg.gateway?.mode === "remote";
|
||||
const remoteUrlRaw =
|
||||
typeof cfg.gateway?.remote?.url === "string"
|
||||
? cfg.gateway.remote.url.trim()
|
||||
: "";
|
||||
const remoteUrlMissing = isRemoteMode && !remoteUrlRaw;
|
||||
const gatewayMode = isRemoteMode ? "remote" : "local";
|
||||
|
||||
const resolveProbeAuth = (mode: "local" | "remote") => {
|
||||
const authToken = cfg.gateway?.auth?.token;
|
||||
const authPassword = cfg.gateway?.auth?.password;
|
||||
const remote = cfg.gateway?.remote;
|
||||
const token =
|
||||
mode === "remote"
|
||||
? typeof remote?.token === "string" && remote.token.trim()
|
||||
? remote.token.trim()
|
||||
: undefined
|
||||
: process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() ||
|
||||
(typeof authToken === "string" && authToken.trim()
|
||||
? authToken.trim()
|
||||
: undefined);
|
||||
const password =
|
||||
process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() ||
|
||||
(mode === "remote"
|
||||
? typeof remote?.password === "string" && remote.password.trim()
|
||||
? remote.password.trim()
|
||||
: undefined
|
||||
: typeof authPassword === "string" && authPassword.trim()
|
||||
? authPassword.trim()
|
||||
: undefined);
|
||||
return { token, password };
|
||||
};
|
||||
|
||||
const localFallbackAuth = resolveProbeAuth("local");
|
||||
const remoteAuth = resolveProbeAuth("remote");
|
||||
|
||||
const gatewayProbe = await probeGateway({
|
||||
url: connection.url,
|
||||
auth: remoteUrlMissing ? localFallbackAuth : remoteAuth,
|
||||
timeoutMs: Math.min(5000, opts?.timeoutMs ?? 10_000),
|
||||
}).catch(() => null);
|
||||
const gatewayReachable = gatewayProbe?.ok === true;
|
||||
const gatewaySelf = pickGatewaySelfPresence(gatewayProbe?.presence ?? null);
|
||||
|
||||
const daemon = await (async () => {
|
||||
try {
|
||||
const service = resolveGatewayService();
|
||||
const [loaded, runtimeInfo] = await Promise.all([
|
||||
service.isLoaded({ env: process.env }).catch(() => false),
|
||||
service.readRuntime(process.env).catch(() => undefined),
|
||||
]);
|
||||
return {
|
||||
label: service.label,
|
||||
loaded,
|
||||
loadedText: loaded ? service.loadedText : service.notLoadedText,
|
||||
runtime: runtimeInfo,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const agentStatus = await getAgentLocalStatuses(cfg);
|
||||
const providers = await buildProvidersTable(cfg);
|
||||
|
||||
const connectionDetailsForReport = (() => {
|
||||
if (!remoteUrlMissing) return connection.message;
|
||||
const bindMode = cfg.gateway?.bind ?? "loopback";
|
||||
const configPath = snap?.path?.trim()
|
||||
? snap.path.trim()
|
||||
: "(unknown config path)";
|
||||
return [
|
||||
"Gateway mode: remote",
|
||||
"Gateway target: (missing gateway.remote.url)",
|
||||
`Config: ${configPath}`,
|
||||
`Bind: ${bindMode}`,
|
||||
`Local fallback (used for probes): ${connection.url}`,
|
||||
"Fix: set gateway.remote.url, or set gateway.mode=local.",
|
||||
].join("\n");
|
||||
})();
|
||||
|
||||
const callOverrides = remoteUrlMissing
|
||||
? {
|
||||
url: connection.url,
|
||||
token: localFallbackAuth.token,
|
||||
password: localFallbackAuth.password,
|
||||
}
|
||||
: {};
|
||||
|
||||
const health = gatewayReachable
|
||||
? await callGateway<unknown>({
|
||||
method: "health",
|
||||
timeoutMs: Math.min(8000, opts?.timeoutMs ?? 10_000),
|
||||
...callOverrides,
|
||||
}).catch((err) => ({ error: String(err) }))
|
||||
: { error: gatewayProbe?.error ?? "gateway unreachable" };
|
||||
|
||||
const providersStatus = gatewayReachable
|
||||
? await callGateway<Record<string, unknown>>({
|
||||
method: "providers.status",
|
||||
params: { probe: false, timeoutMs: opts?.timeoutMs ?? 10_000 },
|
||||
timeoutMs: Math.min(8000, opts?.timeoutMs ?? 10_000),
|
||||
...callOverrides,
|
||||
}).catch(() => null)
|
||||
: null;
|
||||
const providerIssues = providersStatus
|
||||
? collectProvidersStatusIssues(providersStatus)
|
||||
: [];
|
||||
|
||||
const sentinel = await readRestartSentinel().catch(() => null);
|
||||
const lastErr = await readLastGatewayErrorLine(process.env).catch(() => null);
|
||||
const port = resolveGatewayPort(cfg);
|
||||
const portUsage = await inspectPortUsage(port).catch(() => null);
|
||||
|
||||
const defaultWorkspace =
|
||||
agentStatus.agents.find((a) => a.id === agentStatus.defaultId)
|
||||
?.workspaceDir ??
|
||||
agentStatus.agents[0]?.workspaceDir ??
|
||||
null;
|
||||
const skillStatus =
|
||||
defaultWorkspace != null
|
||||
? (() => {
|
||||
try {
|
||||
return buildWorkspaceSkillStatus(defaultWorkspace, { config: cfg });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: null;
|
||||
|
||||
const controlUiEnabled = cfg.gateway?.controlUi?.enabled ?? true;
|
||||
const dashboard = controlUiEnabled
|
||||
? resolveControlUiLinks({
|
||||
port,
|
||||
bind: cfg.gateway?.bind,
|
||||
basePath: cfg.gateway?.controlUi?.basePath,
|
||||
}).httpUrl
|
||||
: null;
|
||||
|
||||
const updateLine = (() => {
|
||||
if (update.installKind === "git" && update.git) {
|
||||
const parts: string[] = [];
|
||||
parts.push(update.git.branch ? `git ${update.git.branch}` : "git");
|
||||
if (update.git.upstream) parts.push(`↔ ${update.git.upstream}`);
|
||||
if (update.git.dirty) parts.push("dirty");
|
||||
if (update.git.behind != null && update.git.ahead != null) {
|
||||
if (update.git.behind === 0 && update.git.ahead === 0)
|
||||
parts.push("up to date");
|
||||
else if (update.git.behind > 0 && update.git.ahead === 0)
|
||||
parts.push(`behind ${update.git.behind}`);
|
||||
else if (update.git.behind === 0 && update.git.ahead > 0)
|
||||
parts.push(`ahead ${update.git.ahead}`);
|
||||
else
|
||||
parts.push(
|
||||
`diverged (ahead ${update.git.ahead}, behind ${update.git.behind})`,
|
||||
);
|
||||
}
|
||||
if (update.git.fetchOk === false) parts.push("fetch failed");
|
||||
if (update.deps?.status === "stale") parts.push("deps stale");
|
||||
if (update.deps?.status === "missing") parts.push("deps missing");
|
||||
return parts.join(" · ");
|
||||
}
|
||||
const parts: string[] = [];
|
||||
parts.push(
|
||||
update.packageManager !== "unknown" ? update.packageManager : "pkg",
|
||||
);
|
||||
const latest = update.registry?.latestVersion;
|
||||
if (latest) {
|
||||
const cmp = compareSemverStrings(VERSION, latest);
|
||||
if (cmp === 0) parts.push(`latest ${latest}`);
|
||||
else if (cmp != null && cmp < 0) parts.push(`update available ${latest}`);
|
||||
else parts.push(`latest ${latest}`);
|
||||
} else if (update.registry?.error) {
|
||||
parts.push("latest unknown");
|
||||
}
|
||||
if (update.deps?.status === "stale") parts.push("deps stale");
|
||||
if (update.deps?.status === "missing") parts.push("deps missing");
|
||||
return parts.join(" · ");
|
||||
})();
|
||||
|
||||
const gatewayTarget = remoteUrlMissing
|
||||
? `fallback ${connection.url}`
|
||||
: connection.url;
|
||||
const gatewayStatus = gatewayReachable
|
||||
? `reachable ${formatDuration(gatewayProbe?.connectLatencyMs)}`
|
||||
: gatewayProbe?.error
|
||||
? `unreachable (${gatewayProbe.error})`
|
||||
: "unreachable";
|
||||
const gatewaySelfLine =
|
||||
gatewaySelf?.host ||
|
||||
gatewaySelf?.ip ||
|
||||
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 aliveThresholdMs = 10 * 60_000;
|
||||
const aliveAgents = agentStatus.agents.filter(
|
||||
(a) => a.lastActiveAgeMs != null && a.lastActiveAgeMs <= aliveThresholdMs,
|
||||
).length;
|
||||
|
||||
const overviewRows = [
|
||||
{ Item: "Version", Value: VERSION },
|
||||
{ Item: "OS", Value: osSummary.label },
|
||||
{ Item: "Node", Value: process.versions.node },
|
||||
{
|
||||
Item: "Config",
|
||||
Value: snap?.path?.trim() ? snap.path.trim() : "(unknown config path)",
|
||||
},
|
||||
dashboard
|
||||
? { Item: "Dashboard", Value: dashboard }
|
||||
: { Item: "Dashboard", Value: "disabled" },
|
||||
{ Item: "Update", Value: updateLine },
|
||||
{
|
||||
Item: "Gateway",
|
||||
Value: `${gatewayMode}${remoteUrlMissing ? " (remote.url missing)" : ""} · ${gatewayTarget} (${connection.urlSource}) · ${gatewayStatus}`,
|
||||
},
|
||||
gatewaySelfLine
|
||||
? { Item: "Gateway self", Value: gatewaySelfLine }
|
||||
: { Item: "Gateway self", Value: "unknown" },
|
||||
daemon
|
||||
? {
|
||||
Item: "Daemon",
|
||||
Value: `${daemon.label} ${daemon.loadedText}${daemon.runtime?.status ? ` · ${daemon.runtime.status}` : ""}${daemon.runtime?.pid ? ` (pid ${daemon.runtime.pid})` : ""}`,
|
||||
}
|
||||
: { Item: "Daemon", Value: "unknown" },
|
||||
{
|
||||
Item: "Agents",
|
||||
Value: `${agentStatus.agents.length} total · ${agentStatus.bootstrapPendingCount} bootstrapping · ${aliveAgents} active · ${agentStatus.totalSessions} sessions`,
|
||||
},
|
||||
];
|
||||
|
||||
const rich = isRich();
|
||||
const heading = (text: string) => (rich ? theme.heading(text) : text);
|
||||
const ok = (text: string) => (rich ? theme.success(text) : text);
|
||||
const warn = (text: string) => (rich ? theme.warn(text) : text);
|
||||
const fail = (text: string) => (rich ? theme.error(text) : text);
|
||||
const muted = (text: string) => (rich ? theme.muted(text) : text);
|
||||
|
||||
const tableWidth = process.stdout.columns ?? 120;
|
||||
|
||||
const overview = renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Item", header: "Item", minWidth: 10 },
|
||||
{ key: "Value", header: "Value", flex: true, minWidth: 24 },
|
||||
],
|
||||
rows: overviewRows,
|
||||
});
|
||||
|
||||
const providerRows = providers.rows.map((row) => ({
|
||||
Provider: row.provider,
|
||||
Enabled: row.enabled ? ok("ON") : muted("OFF"),
|
||||
Configured: row.configured
|
||||
? ok("OK")
|
||||
: row.enabled
|
||||
? warn("WARN")
|
||||
: muted("OFF"),
|
||||
Detail: row.detail,
|
||||
}));
|
||||
|
||||
const providersTable = renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Provider", header: "Provider", minWidth: 10 },
|
||||
{ key: "Enabled", header: "Enabled", minWidth: 7 },
|
||||
{ key: "Configured", header: "Configured", minWidth: 10 },
|
||||
{ key: "Detail", header: "Detail", flex: true, minWidth: 28 },
|
||||
],
|
||||
rows: providerRows,
|
||||
});
|
||||
|
||||
const agentRows = agentStatus.agents.map((a) => ({
|
||||
Agent: a.name?.trim() ? `${a.id} (${a.name.trim()})` : a.id,
|
||||
Bootstrap:
|
||||
a.bootstrapPending === true
|
||||
? warn("PENDING")
|
||||
: a.bootstrapPending === false
|
||||
? ok("OK")
|
||||
: "unknown",
|
||||
Sessions: String(a.sessionsCount),
|
||||
Active:
|
||||
a.lastActiveAgeMs != null ? formatAge(a.lastActiveAgeMs) : "unknown",
|
||||
Store: a.sessionsPath,
|
||||
}));
|
||||
|
||||
const agentsTable = renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Agent", header: "Agent", minWidth: 12 },
|
||||
{ key: "Bootstrap", header: "Bootstrap", minWidth: 10 },
|
||||
{ key: "Sessions", header: "Sessions", align: "right", minWidth: 8 },
|
||||
{ key: "Active", header: "Active", minWidth: 10 },
|
||||
{ key: "Store", header: "Store", flex: true, minWidth: 34 },
|
||||
],
|
||||
rows: agentRows,
|
||||
});
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(heading("Clawdbot status --all"));
|
||||
lines.push("");
|
||||
lines.push(heading("Overview"));
|
||||
lines.push(overview.trimEnd());
|
||||
lines.push("");
|
||||
lines.push(heading("Providers"));
|
||||
lines.push(providersTable.trimEnd());
|
||||
for (const detail of providers.details) {
|
||||
lines.push("");
|
||||
lines.push(heading(detail.title));
|
||||
lines.push(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: detail.columns.map((c) => ({
|
||||
key: c,
|
||||
header: c,
|
||||
flex: c === "Notes",
|
||||
minWidth: c === "Notes" ? 28 : 10,
|
||||
})),
|
||||
rows: detail.rows.map((r) => ({
|
||||
...r,
|
||||
...(r.Status === "OK"
|
||||
? { Status: ok("OK") }
|
||||
: r.Status === "WARN"
|
||||
? { Status: warn("WARN") }
|
||||
: {}),
|
||||
})),
|
||||
}).trimEnd(),
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(heading("Agents"));
|
||||
lines.push(agentsTable.trimEnd());
|
||||
lines.push("");
|
||||
lines.push(heading("Diagnosis (read-only)"));
|
||||
|
||||
const emitCheck = (label: string, status: "ok" | "warn" | "fail") => {
|
||||
const icon =
|
||||
status === "ok" ? ok("✓") : status === "warn" ? warn("!") : fail("✗");
|
||||
const colored =
|
||||
status === "ok"
|
||||
? ok(label)
|
||||
: status === "warn"
|
||||
? warn(label)
|
||||
: fail(label);
|
||||
lines.push(`${icon} ${colored}`);
|
||||
};
|
||||
|
||||
lines.push("");
|
||||
lines.push(`${muted("Gateway connection details:")}`);
|
||||
for (const line of redactSecrets(connectionDetailsForReport)
|
||||
.split("\n")
|
||||
.map((l) => l.trimEnd())) {
|
||||
lines.push(` ${muted(line)}`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
if (snap) {
|
||||
const status = !snap.exists ? "fail" : snap.valid ? "ok" : "warn";
|
||||
emitCheck(`Config: ${snap.path ?? "(unknown)"}`, status);
|
||||
const issues = [...(snap.legacyIssues ?? []), ...(snap.issues ?? [])];
|
||||
const uniqueIssues = issues.filter(
|
||||
(issue, index) =>
|
||||
issues.findIndex(
|
||||
(x) => x.path === issue.path && x.message === issue.message,
|
||||
) === index,
|
||||
);
|
||||
for (const issue of uniqueIssues.slice(0, 12)) {
|
||||
lines.push(` - ${issue.path}: ${issue.message}`);
|
||||
}
|
||||
if (uniqueIssues.length > 12) {
|
||||
lines.push(` ${muted(`… +${uniqueIssues.length - 12} more`)}`);
|
||||
}
|
||||
} else {
|
||||
emitCheck("Config: read failed", "warn");
|
||||
}
|
||||
|
||||
if (remoteUrlMissing) {
|
||||
lines.push("");
|
||||
emitCheck(
|
||||
"Gateway remote mode misconfigured (gateway.remote.url missing)",
|
||||
"warn",
|
||||
);
|
||||
lines.push(
|
||||
` ${muted("Fix: set gateway.remote.url, or set gateway.mode=local.")}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (sentinel?.payload) {
|
||||
emitCheck("Restart sentinel present", "warn");
|
||||
lines.push(
|
||||
` ${muted(`${summarizeRestartSentinel(sentinel.payload)} · ${formatAge(Date.now() - sentinel.payload.ts)}`)}`,
|
||||
);
|
||||
} else {
|
||||
emitCheck("Restart sentinel: none", "ok");
|
||||
}
|
||||
|
||||
const lastErrClean = lastErr?.trim() ?? "";
|
||||
const isTrivialLastErr =
|
||||
lastErrClean.length < 8 || lastErrClean === "}" || lastErrClean === "{";
|
||||
if (lastErrClean && !isTrivialLastErr) {
|
||||
lines.push("");
|
||||
lines.push(`${muted("Gateway last log line:")}`);
|
||||
lines.push(` ${muted(redactSecrets(lastErrClean))}`);
|
||||
}
|
||||
|
||||
if (portUsage) {
|
||||
const portOk = portUsage.listeners.length === 0;
|
||||
emitCheck(`Port ${port}`, portOk ? "ok" : "warn");
|
||||
if (!portOk) {
|
||||
for (const line of formatPortDiagnostics(portUsage)) {
|
||||
lines.push(` ${muted(line)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (skillStatus) {
|
||||
const eligible = skillStatus.skills.filter((s) => s.eligible).length;
|
||||
const missing = skillStatus.skills.filter(
|
||||
(s) => s.eligible && Object.values(s.missing).some((arr) => arr.length),
|
||||
).length;
|
||||
emitCheck(
|
||||
`Skills (${eligible} eligible · ${missing} missing requirements)`,
|
||||
missing === 0 ? "ok" : "warn",
|
||||
);
|
||||
lines.push(` ${muted(skillStatus.workspaceDir)}`);
|
||||
}
|
||||
|
||||
const logPaths = (() => {
|
||||
try {
|
||||
return resolveGatewayLogPaths(process.env);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (logPaths) {
|
||||
const [stderrTail, stdoutTail] = await Promise.all([
|
||||
readFileTailLines(logPaths.stderrPath, 40).catch(() => []),
|
||||
readFileTailLines(logPaths.stdoutPath, 40).catch(() => []),
|
||||
]);
|
||||
if (stderrTail.length > 0 || stdoutTail.length > 0) {
|
||||
lines.push("");
|
||||
lines.push(`${muted(`Gateway logs (tail): ${logPaths.logDir}`)}`);
|
||||
lines.push(` ${muted(`# stderr: ${logPaths.stderrPath}`)}`);
|
||||
for (const line of stderrTail.map(redactSecrets)) {
|
||||
lines.push(` ${muted(line)}`);
|
||||
}
|
||||
lines.push(` ${muted(`# stdout: ${logPaths.stdoutPath}`)}`);
|
||||
for (const line of stdoutTail.map(redactSecrets)) {
|
||||
lines.push(` ${muted(line)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (providersStatus) {
|
||||
emitCheck(
|
||||
`Provider issues (${providerIssues.length || "none"})`,
|
||||
providerIssues.length === 0 ? "ok" : "warn",
|
||||
);
|
||||
for (const issue of providerIssues.slice(0, 12)) {
|
||||
const fixText = issue.fix ? ` · fix: ${issue.fix}` : "";
|
||||
lines.push(
|
||||
` - ${issue.provider}[${issue.accountId}] ${issue.kind}: ${issue.message}${fixText}`,
|
||||
);
|
||||
}
|
||||
if (providerIssues.length > 12) {
|
||||
lines.push(` ${muted(`… +${providerIssues.length - 12} more`)}`);
|
||||
}
|
||||
} else {
|
||||
emitCheck(
|
||||
`Provider issues skipped (gateway ${gatewayReachable ? "query failed" : "unreachable"})`,
|
||||
"warn",
|
||||
);
|
||||
}
|
||||
|
||||
const healthErr = (() => {
|
||||
if (!health || typeof health !== "object") return "";
|
||||
const record = health as Record<string, unknown>;
|
||||
if (!("error" in record)) return "";
|
||||
const value = record.error;
|
||||
if (!value) return "";
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return "[unserializable error]";
|
||||
}
|
||||
})();
|
||||
if (healthErr) {
|
||||
lines.push("");
|
||||
lines.push(`${muted("Gateway health:")}`);
|
||||
lines.push(` ${muted(redactSecrets(healthErr))}`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(muted("Pasteable debug report. Auth tokens redacted."));
|
||||
lines.push("");
|
||||
|
||||
runtime.log(lines.join("\n"));
|
||||
}
|
||||
77
src/commands/status-all/agents.ts
Normal file
77
src/commands/status-all/agents.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolveAgentWorkspaceDir } from "../../agents/agent-scope.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
|
||||
import { listAgentsForGateway } from "../../gateway/session-utils.js";
|
||||
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAgentLocalStatuses(cfg: ClawdbotConfig) {
|
||||
const agentList = listAgentsForGateway(cfg);
|
||||
const now = Date.now();
|
||||
|
||||
const agents = await Promise.all(
|
||||
agentList.agents.map(async (agent) => {
|
||||
const workspaceDir = (() => {
|
||||
try {
|
||||
return resolveAgentWorkspaceDir(cfg, agent.id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
const bootstrapPending =
|
||||
workspaceDir != null
|
||||
? await fileExists(path.join(workspaceDir, "BOOTSTRAP.md"))
|
||||
: null;
|
||||
const sessionsPath = resolveStorePath(cfg.session?.store, {
|
||||
agentId: agent.id,
|
||||
});
|
||||
const store = (() => {
|
||||
try {
|
||||
return loadSessionStore(sessionsPath);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
})();
|
||||
const updatedAt = Object.values(store).reduce(
|
||||
(max, entry) => Math.max(max, entry?.updatedAt ?? 0),
|
||||
0,
|
||||
);
|
||||
const lastUpdatedAt = updatedAt > 0 ? updatedAt : null;
|
||||
const lastActiveAgeMs = lastUpdatedAt ? now - lastUpdatedAt : null;
|
||||
const sessionsCount = Object.keys(store).filter(
|
||||
(k) => k !== "global" && k !== "unknown",
|
||||
).length;
|
||||
return {
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
workspaceDir,
|
||||
bootstrapPending,
|
||||
sessionsPath,
|
||||
sessionsCount,
|
||||
lastUpdatedAt,
|
||||
lastActiveAgeMs,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const totalSessions = agents.reduce((sum, a) => sum + a.sessionsCount, 0);
|
||||
const bootstrapPendingCount = agents.reduce(
|
||||
(sum, a) => sum + (a.bootstrapPending ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
return {
|
||||
defaultId: agentList.defaultId,
|
||||
agents,
|
||||
totalSessions,
|
||||
bootstrapPendingCount,
|
||||
};
|
||||
}
|
||||
28
src/commands/status-all/format.ts
Normal file
28
src/commands/status-all/format.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export const formatAge = (ms: number | null | undefined) => {
|
||||
if (!ms || ms < 0) return "unknown";
|
||||
const minutes = Math.round(ms / 60_000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 48) return `${hours}h ago`;
|
||||
const days = Math.round(hours / 24);
|
||||
return `${days}d ago`;
|
||||
};
|
||||
|
||||
export const formatDuration = (ms: number | null | undefined) => {
|
||||
if (ms == null || !Number.isFinite(ms)) return "unknown";
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
};
|
||||
|
||||
export function redactSecrets(text: string): string {
|
||||
if (!text) return text;
|
||||
let out = text;
|
||||
out = out.replace(
|
||||
/(\b(?:access[_-]?token|refresh[_-]?token|token|password|secret|api[_-]?key)\b\s*[:=]\s*)("?)([^"\\s]+)("?)/gi,
|
||||
"$1$2***$4",
|
||||
);
|
||||
out = out.replace(/\bBearer\s+[A-Za-z0-9._-]+\b/g, "Bearer ***");
|
||||
out = out.replace(/\bsk-[A-Za-z0-9]{10,}\b/g, "sk-***");
|
||||
return out;
|
||||
}
|
||||
33
src/commands/status-all/gateway.ts
Normal file
33
src/commands/status-all/gateway.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
export async function readFileTailLines(
|
||||
filePath: string,
|
||||
maxLines: number,
|
||||
): Promise<string[]> {
|
||||
const raw = await fs.readFile(filePath, "utf8").catch(() => "");
|
||||
if (!raw.trim()) return [];
|
||||
const lines = raw.replace(/\r/g, "").split("\n");
|
||||
const out = lines.slice(Math.max(0, lines.length - maxLines));
|
||||
return out
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line.trim().length > 0);
|
||||
}
|
||||
|
||||
export function pickGatewaySelfPresence(presence: unknown): {
|
||||
host?: string;
|
||||
ip?: string;
|
||||
version?: string;
|
||||
platform?: string;
|
||||
} | null {
|
||||
if (!Array.isArray(presence)) return null;
|
||||
const entries = presence as Array<Record<string, unknown>>;
|
||||
const self =
|
||||
entries.find((e) => e.mode === "gateway" && e.reason === "self") ?? null;
|
||||
if (!self) return null;
|
||||
return {
|
||||
host: typeof self.host === "string" ? self.host : undefined,
|
||||
ip: typeof self.ip === "string" ? self.ip : undefined,
|
||||
version: typeof self.version === "string" ? self.version : undefined,
|
||||
platform: typeof self.platform === "string" ? self.platform : undefined,
|
||||
};
|
||||
}
|
||||
211
src/commands/status-all/providers.ts
Normal file
211
src/commands/status-all/providers.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
listDiscordAccountIds,
|
||||
resolveDiscordAccount,
|
||||
} from "../../discord/accounts.js";
|
||||
import {
|
||||
listIMessageAccountIds,
|
||||
resolveIMessageAccount,
|
||||
} from "../../imessage/accounts.js";
|
||||
import { resolveMSTeamsCredentials } from "../../msteams/token.js";
|
||||
import {
|
||||
listSignalAccountIds,
|
||||
resolveSignalAccount,
|
||||
} from "../../signal/accounts.js";
|
||||
import {
|
||||
listSlackAccountIds,
|
||||
resolveSlackAccount,
|
||||
} from "../../slack/accounts.js";
|
||||
import {
|
||||
listTelegramAccountIds,
|
||||
resolveTelegramAccount,
|
||||
} from "../../telegram/accounts.js";
|
||||
import { normalizeE164 } from "../../utils.js";
|
||||
import {
|
||||
listWhatsAppAccountIds,
|
||||
resolveWhatsAppAccount,
|
||||
} from "../../web/accounts.js";
|
||||
import {
|
||||
getWebAuthAgeMs,
|
||||
readWebSelfId,
|
||||
webAuthExists,
|
||||
} from "../../web/session.js";
|
||||
import { formatAge } from "./format.js";
|
||||
|
||||
export type ProviderRow = {
|
||||
provider: string;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export async function buildProvidersTable(cfg: ClawdbotConfig): Promise<{
|
||||
rows: ProviderRow[];
|
||||
details: Array<{
|
||||
title: string;
|
||||
columns: string[];
|
||||
rows: Array<Record<string, string>>;
|
||||
}>;
|
||||
}> {
|
||||
const rows: ProviderRow[] = [];
|
||||
const details: Array<{
|
||||
title: string;
|
||||
columns: string[];
|
||||
rows: Array<Record<string, string>>;
|
||||
}> = [];
|
||||
|
||||
// WhatsApp
|
||||
const waEnabled = cfg.web?.enabled !== false;
|
||||
const waLinked = waEnabled ? await webAuthExists().catch(() => false) : false;
|
||||
const waAuthAgeMs = waLinked ? getWebAuthAgeMs() : null;
|
||||
const waSelf = waLinked ? readWebSelfId().e164 : undefined;
|
||||
const waAccounts = waLinked
|
||||
? listWhatsAppAccountIds(cfg).map((accountId) =>
|
||||
resolveWhatsAppAccount({ cfg, accountId }),
|
||||
)
|
||||
: [];
|
||||
rows.push({
|
||||
provider: "WhatsApp",
|
||||
enabled: waEnabled,
|
||||
configured: waLinked,
|
||||
detail: waEnabled
|
||||
? waLinked
|
||||
? `linked${waSelf ? ` ${waSelf}` : ""}${waAuthAgeMs ? ` · auth ${formatAge(waAuthAgeMs)}` : ""} · accounts ${waAccounts.length || 1}`
|
||||
: "not linked"
|
||||
: "disabled",
|
||||
});
|
||||
if (waLinked) {
|
||||
const waRows =
|
||||
waAccounts.length > 0 ? waAccounts : [resolveWhatsAppAccount({ cfg })];
|
||||
details.push({
|
||||
title: "WhatsApp accounts",
|
||||
columns: ["Account", "Status", "Notes"],
|
||||
rows: waRows.map((account) => {
|
||||
const allowFrom = (account.allowFrom ?? cfg.whatsapp?.allowFrom ?? [])
|
||||
.map(normalizeE164)
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
const dmPolicy =
|
||||
account.dmPolicy ?? cfg.whatsapp?.dmPolicy ?? "pairing";
|
||||
const notes: string[] = [];
|
||||
if (!account.enabled) notes.push("disabled");
|
||||
if (account.selfChatMode) notes.push("self-chat");
|
||||
notes.push(`dm:${dmPolicy}`);
|
||||
if (allowFrom.length) notes.push(`allow:${allowFrom.join(",")}`);
|
||||
return {
|
||||
Account: account.name?.trim()
|
||||
? `${account.accountId} (${account.name.trim()})`
|
||||
: account.accountId,
|
||||
Status: account.enabled ? "OK" : "WARN",
|
||||
Notes: notes.join(" · "),
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Telegram
|
||||
const tgEnabled = cfg.telegram?.enabled !== false;
|
||||
const tgAccounts = listTelegramAccountIds(cfg).map((accountId) =>
|
||||
resolveTelegramAccount({ cfg, accountId }),
|
||||
);
|
||||
const tgConfigured = tgAccounts.some((a) => Boolean(a.token?.trim()));
|
||||
rows.push({
|
||||
provider: "Telegram",
|
||||
enabled: tgEnabled,
|
||||
configured: tgEnabled && tgConfigured,
|
||||
detail: tgEnabled
|
||||
? tgConfigured
|
||||
? `accounts ${tgAccounts.filter((a) => a.token?.trim()).length}`
|
||||
: "not configured"
|
||||
: "disabled",
|
||||
});
|
||||
|
||||
// Discord
|
||||
const dcEnabled = cfg.discord?.enabled !== false;
|
||||
const dcAccounts = listDiscordAccountIds(cfg).map((accountId) =>
|
||||
resolveDiscordAccount({ cfg, accountId }),
|
||||
);
|
||||
const dcConfigured = dcAccounts.some((a) => Boolean(a.token?.trim()));
|
||||
rows.push({
|
||||
provider: "Discord",
|
||||
enabled: dcEnabled,
|
||||
configured: dcEnabled && dcConfigured,
|
||||
detail: dcEnabled
|
||||
? dcConfigured
|
||||
? `accounts ${dcAccounts.filter((a) => a.token?.trim()).length}`
|
||||
: "not configured"
|
||||
: "disabled",
|
||||
});
|
||||
|
||||
// Slack
|
||||
const slEnabled = cfg.slack?.enabled !== false;
|
||||
const slAccounts = listSlackAccountIds(cfg).map((accountId) =>
|
||||
resolveSlackAccount({ cfg, accountId }),
|
||||
);
|
||||
const slConfigured = slAccounts.some(
|
||||
(a) => Boolean(a.botToken?.trim()) && Boolean(a.appToken?.trim()),
|
||||
);
|
||||
rows.push({
|
||||
provider: "Slack",
|
||||
enabled: slEnabled,
|
||||
configured: slEnabled && slConfigured,
|
||||
detail: slEnabled
|
||||
? slConfigured
|
||||
? `accounts ${slAccounts.filter((a) => a.botToken?.trim() && a.appToken?.trim()).length}`
|
||||
: "not configured"
|
||||
: "disabled",
|
||||
});
|
||||
|
||||
// Signal
|
||||
const siEnabled = cfg.signal?.enabled !== false;
|
||||
const siAccounts = listSignalAccountIds(cfg).map((accountId) =>
|
||||
resolveSignalAccount({ cfg, accountId }),
|
||||
);
|
||||
const siConfigured = siAccounts.some((a) => a.configured);
|
||||
rows.push({
|
||||
provider: "Signal",
|
||||
enabled: siEnabled,
|
||||
configured: siEnabled && siConfigured,
|
||||
detail: siEnabled
|
||||
? siConfigured
|
||||
? `accounts ${siAccounts.filter((a) => a.configured).length}`
|
||||
: "not configured"
|
||||
: "disabled",
|
||||
});
|
||||
|
||||
// iMessage
|
||||
const imEnabled = cfg.imessage?.enabled !== false;
|
||||
const imAccounts = listIMessageAccountIds(cfg).map((accountId) =>
|
||||
resolveIMessageAccount({ cfg, accountId }),
|
||||
);
|
||||
const imConfigured = imAccounts.some((a) => a.configured);
|
||||
rows.push({
|
||||
provider: "iMessage",
|
||||
enabled: imEnabled,
|
||||
configured: imEnabled && imConfigured,
|
||||
detail: imEnabled
|
||||
? imConfigured
|
||||
? `accounts ${imAccounts.length}`
|
||||
: "not configured"
|
||||
: "disabled",
|
||||
});
|
||||
|
||||
// MS Teams
|
||||
const msEnabled = cfg.msteams?.enabled !== false;
|
||||
const msConfigured = Boolean(resolveMSTeamsCredentials(cfg.msteams));
|
||||
rows.push({
|
||||
provider: "MS Teams",
|
||||
enabled: msEnabled,
|
||||
configured: msEnabled && msConfigured,
|
||||
detail: msEnabled
|
||||
? msConfigured
|
||||
? "credentials present"
|
||||
: "not configured"
|
||||
: "disabled",
|
||||
});
|
||||
|
||||
return {
|
||||
rows,
|
||||
details,
|
||||
};
|
||||
}
|
||||
@@ -20,6 +20,17 @@ const mocks = vi.hoisted(() => ({
|
||||
getWebAuthAgeMs: vi.fn().mockReturnValue(5000),
|
||||
readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }),
|
||||
logWebSelfId: vi.fn(),
|
||||
probeGateway: vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
url: "ws://127.0.0.1:18789",
|
||||
connectLatencyMs: null,
|
||||
error: "timeout",
|
||||
close: null,
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../config/sessions.js", () => ({
|
||||
@@ -33,6 +44,52 @@ vi.mock("../web/session.js", () => ({
|
||||
readWebSelfId: mocks.readWebSelfId,
|
||||
logWebSelfId: mocks.logWebSelfId,
|
||||
}));
|
||||
vi.mock("../gateway/probe.js", () => ({
|
||||
probeGateway: mocks.probeGateway,
|
||||
}));
|
||||
vi.mock("../gateway/session-utils.js", () => ({
|
||||
listAgentsForGateway: () => ({
|
||||
defaultId: "main",
|
||||
mainKey: "agent:main:main",
|
||||
scope: "per-sender",
|
||||
agents: [{ id: "main", name: "Main" }],
|
||||
}),
|
||||
}));
|
||||
vi.mock("../infra/clawdbot-root.js", () => ({
|
||||
resolveClawdbotPackageRoot: vi.fn().mockResolvedValue("/tmp/clawdbot"),
|
||||
}));
|
||||
vi.mock("../infra/os-summary.js", () => ({
|
||||
resolveOsSummary: () => ({
|
||||
platform: "darwin",
|
||||
arch: "arm64",
|
||||
release: "23.0.0",
|
||||
label: "macos 14.0 (arm64)",
|
||||
}),
|
||||
}));
|
||||
vi.mock("../infra/update-check.js", () => ({
|
||||
checkUpdateStatus: vi.fn().mockResolvedValue({
|
||||
root: "/tmp/clawdbot",
|
||||
installKind: "git",
|
||||
packageManager: "pnpm",
|
||||
git: {
|
||||
root: "/tmp/clawdbot",
|
||||
branch: "main",
|
||||
upstream: "origin/main",
|
||||
dirty: false,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
fetchOk: true,
|
||||
},
|
||||
deps: {
|
||||
manager: "pnpm",
|
||||
status: "ok",
|
||||
lockfilePath: "/tmp/clawdbot/pnpm-lock.yaml",
|
||||
markerPath: "/tmp/clawdbot/node_modules/.modules.yaml",
|
||||
},
|
||||
registry: { latestVersion: "0.0.0" },
|
||||
}),
|
||||
compareSemverStrings: vi.fn(() => 0),
|
||||
}));
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
||||
import { lookupContextTokens } from "../agents/context.js";
|
||||
import {
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
@@ -5,24 +9,47 @@ import {
|
||||
DEFAULT_PROVIDER,
|
||||
} from "../agents/defaults.js";
|
||||
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
||||
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
||||
import { withProgress } from "../cli/progress.js";
|
||||
import { loadConfig, resolveGatewayPort } from "../config/config.js";
|
||||
import {
|
||||
loadConfig,
|
||||
readConfigFileSnapshot,
|
||||
resolveGatewayPort,
|
||||
} from "../config/config.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveMainSessionKey,
|
||||
resolveStorePath,
|
||||
type SessionEntry,
|
||||
} from "../config/sessions.js";
|
||||
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
||||
import { probeGateway } from "../gateway/probe.js";
|
||||
import { listAgentsForGateway } from "../gateway/session-utils.js";
|
||||
import { info } from "../globals.js";
|
||||
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
|
||||
import { resolveOsSummary } from "../infra/os-summary.js";
|
||||
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
|
||||
import { buildProviderSummary } from "../infra/provider-summary.js";
|
||||
import {
|
||||
formatUsageReportLines,
|
||||
loadProviderUsageSummary,
|
||||
} from "../infra/provider-usage.js";
|
||||
import { collectProvidersStatusIssues } from "../infra/providers-status-issues.js";
|
||||
import {
|
||||
readRestartSentinel,
|
||||
summarizeRestartSentinel,
|
||||
} from "../infra/restart-sentinel.js";
|
||||
import { peekSystemEvents } from "../infra/system-events.js";
|
||||
import {
|
||||
checkUpdateStatus,
|
||||
compareSemverStrings,
|
||||
type UpdateCheckResult,
|
||||
} from "../infra/update-check.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { resolveWhatsAppAccount } from "../web/accounts.js";
|
||||
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
|
||||
import {
|
||||
@@ -32,6 +59,7 @@ import {
|
||||
} from "../web/session.js";
|
||||
import type { HealthSummary } from "./health.js";
|
||||
import { resolveControlUiLinks } from "./onboard-helpers.js";
|
||||
import { statusAllCommand } from "./status-all.js";
|
||||
|
||||
export type SessionStatus = {
|
||||
key: string;
|
||||
@@ -172,6 +200,12 @@ const formatAge = (ms: number | null | undefined) => {
|
||||
return `${days}d ago`;
|
||||
};
|
||||
|
||||
const formatDuration = (ms: number | null | undefined) => {
|
||||
if (ms == null || !Number.isFinite(ms)) return "unknown";
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
};
|
||||
|
||||
const formatContextUsage = (
|
||||
total: number | null | undefined,
|
||||
contextTokens: number | null | undefined,
|
||||
@@ -236,6 +270,230 @@ async function getDaemonShortLine(): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
type AgentLocalStatus = {
|
||||
id: string;
|
||||
name?: string;
|
||||
workspaceDir: string | null;
|
||||
bootstrapPending: boolean | null;
|
||||
sessionsPath: string;
|
||||
sessionsCount: number;
|
||||
lastUpdatedAt: number | null;
|
||||
lastActiveAgeMs: number | null;
|
||||
};
|
||||
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getAgentLocalStatuses(): Promise<{
|
||||
defaultId: string;
|
||||
agents: AgentLocalStatus[];
|
||||
totalSessions: number;
|
||||
bootstrapPendingCount: number;
|
||||
}> {
|
||||
const cfg = loadConfig();
|
||||
const agentList = listAgentsForGateway(cfg);
|
||||
const now = Date.now();
|
||||
|
||||
const statuses: AgentLocalStatus[] = [];
|
||||
for (const agent of agentList.agents) {
|
||||
const agentId = agent.id;
|
||||
const workspaceDir = (() => {
|
||||
try {
|
||||
return resolveAgentWorkspaceDir(cfg, agentId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const bootstrapPath =
|
||||
workspaceDir != null ? path.join(workspaceDir, "BOOTSTRAP.md") : null;
|
||||
const bootstrapPending =
|
||||
bootstrapPath != null ? await fileExists(bootstrapPath) : null;
|
||||
|
||||
const sessionsPath = resolveStorePath(cfg.session?.store, { agentId });
|
||||
const store = (() => {
|
||||
try {
|
||||
return loadSessionStore(sessionsPath);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
})();
|
||||
const sessions = Object.entries(store)
|
||||
.filter(([key]) => key !== "global" && key !== "unknown")
|
||||
.map(([, entry]) => entry);
|
||||
const sessionsCount = sessions.length;
|
||||
const lastUpdatedAt = sessions.reduce(
|
||||
(max, e) => Math.max(max, e?.updatedAt ?? 0),
|
||||
0,
|
||||
);
|
||||
const resolvedLastUpdatedAt = lastUpdatedAt > 0 ? lastUpdatedAt : null;
|
||||
const lastActiveAgeMs = resolvedLastUpdatedAt
|
||||
? now - resolvedLastUpdatedAt
|
||||
: null;
|
||||
|
||||
statuses.push({
|
||||
id: agentId,
|
||||
name: agent.name,
|
||||
workspaceDir,
|
||||
bootstrapPending,
|
||||
sessionsPath,
|
||||
sessionsCount,
|
||||
lastUpdatedAt: resolvedLastUpdatedAt,
|
||||
lastActiveAgeMs,
|
||||
});
|
||||
}
|
||||
|
||||
const totalSessions = statuses.reduce((sum, s) => sum + s.sessionsCount, 0);
|
||||
const bootstrapPendingCount = statuses.reduce(
|
||||
(sum, s) => sum + (s.bootstrapPending ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
return {
|
||||
defaultId: agentList.defaultId,
|
||||
agents: statuses,
|
||||
totalSessions,
|
||||
bootstrapPendingCount,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveGatewayProbeAuth(cfg: ReturnType<typeof loadConfig>): {
|
||||
token?: string;
|
||||
password?: string;
|
||||
} {
|
||||
const isRemoteMode = cfg.gateway?.mode === "remote";
|
||||
const remote = isRemoteMode ? cfg.gateway?.remote : undefined;
|
||||
const authToken = cfg.gateway?.auth?.token;
|
||||
const authPassword = cfg.gateway?.auth?.password;
|
||||
const token = isRemoteMode
|
||||
? typeof remote?.token === "string" && remote.token.trim().length > 0
|
||||
? remote.token.trim()
|
||||
: undefined
|
||||
: process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() ||
|
||||
(typeof authToken === "string" && authToken.trim().length > 0
|
||||
? authToken.trim()
|
||||
: undefined);
|
||||
const password =
|
||||
process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() ||
|
||||
(isRemoteMode
|
||||
? typeof remote?.password === "string" &&
|
||||
remote.password.trim().length > 0
|
||||
? remote.password.trim()
|
||||
: undefined
|
||||
: typeof authPassword === "string" && authPassword.trim().length > 0
|
||||
? authPassword.trim()
|
||||
: undefined);
|
||||
return { token, password };
|
||||
}
|
||||
|
||||
function pickGatewaySelfPresence(presence: unknown): {
|
||||
host?: string;
|
||||
ip?: string;
|
||||
version?: string;
|
||||
platform?: string;
|
||||
} | null {
|
||||
if (!Array.isArray(presence)) return null;
|
||||
const entries = presence as Array<Record<string, unknown>>;
|
||||
const self =
|
||||
entries.find((e) => e.mode === "gateway" && e.reason === "self") ?? null;
|
||||
if (!self) return null;
|
||||
return {
|
||||
host: typeof self.host === "string" ? self.host : undefined,
|
||||
ip: typeof self.ip === "string" ? self.ip : undefined,
|
||||
version: typeof self.version === "string" ? self.version : undefined,
|
||||
platform: typeof self.platform === "string" ? self.platform : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function getUpdateCheckResult(params: {
|
||||
timeoutMs: number;
|
||||
fetchGit: boolean;
|
||||
includeRegistry: boolean;
|
||||
}): Promise<UpdateCheckResult> {
|
||||
const root = await resolveClawdbotPackageRoot({
|
||||
moduleUrl: import.meta.url,
|
||||
argv1: process.argv[1],
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
return await checkUpdateStatus({
|
||||
root,
|
||||
timeoutMs: params.timeoutMs,
|
||||
fetchGit: params.fetchGit,
|
||||
includeRegistry: params.includeRegistry,
|
||||
});
|
||||
}
|
||||
|
||||
function formatUpdateOneLiner(update: UpdateCheckResult): string {
|
||||
const parts: string[] = [];
|
||||
if (update.installKind === "git" && update.git) {
|
||||
const branch = update.git.branch ? `git ${update.git.branch}` : "git";
|
||||
parts.push(branch);
|
||||
if (update.git.upstream) parts.push(`↔ ${update.git.upstream}`);
|
||||
if (update.git.dirty === true) parts.push("dirty");
|
||||
if (update.git.behind != null && update.git.ahead != null) {
|
||||
if (update.git.behind === 0 && update.git.ahead === 0) {
|
||||
parts.push("up to date");
|
||||
} else if (update.git.behind > 0 && update.git.ahead === 0) {
|
||||
parts.push(`behind ${update.git.behind}`);
|
||||
} else if (update.git.behind === 0 && update.git.ahead > 0) {
|
||||
parts.push(`ahead ${update.git.ahead}`);
|
||||
} else if (update.git.behind > 0 && update.git.ahead > 0) {
|
||||
parts.push(
|
||||
`diverged (ahead ${update.git.ahead}, behind ${update.git.behind})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (update.git.fetchOk === false) parts.push("fetch failed");
|
||||
} else {
|
||||
parts.push(
|
||||
update.packageManager !== "unknown" ? update.packageManager : "pkg",
|
||||
);
|
||||
if (update.registry?.latestVersion) {
|
||||
const cmp = compareSemverStrings(VERSION, update.registry.latestVersion);
|
||||
if (cmp === 0) parts.push(`latest ${update.registry.latestVersion}`);
|
||||
else if (cmp != null && cmp < 0) {
|
||||
parts.push(`update available ${update.registry.latestVersion}`);
|
||||
} else {
|
||||
parts.push(`latest ${update.registry.latestVersion}`);
|
||||
}
|
||||
} else if (update.registry?.error) {
|
||||
parts.push("latest unknown");
|
||||
}
|
||||
}
|
||||
|
||||
if (update.deps) {
|
||||
if (update.deps.status === "ok") parts.push("deps ok");
|
||||
if (update.deps.status === "missing") parts.push("deps missing");
|
||||
if (update.deps.status === "stale") parts.push("deps stale");
|
||||
}
|
||||
return `Update: ${parts.join(" · ")}`;
|
||||
}
|
||||
|
||||
function formatCheckLine(params: {
|
||||
ok: boolean;
|
||||
label: string;
|
||||
detail?: string | null;
|
||||
warn?: boolean;
|
||||
}) {
|
||||
const symbol = params.ok
|
||||
? theme.success("\u2713")
|
||||
: params.warn
|
||||
? theme.warn("!")
|
||||
: theme.error("\u2717");
|
||||
const label = params.ok
|
||||
? theme.success(params.label)
|
||||
: params.warn
|
||||
? theme.warn(params.label)
|
||||
: theme.error(params.label);
|
||||
const detail = params.detail?.trim() ? ` ${theme.muted(params.detail)}` : "";
|
||||
return `${symbol} ${label}${detail}`;
|
||||
}
|
||||
|
||||
const buildFlags = (entry: SessionEntry): string[] => {
|
||||
const flags: string[] = [];
|
||||
const think = entry?.thinkingLevel;
|
||||
@@ -265,11 +523,101 @@ export async function statusCommand(
|
||||
usage?: boolean;
|
||||
timeoutMs?: number;
|
||||
verbose?: boolean;
|
||||
all?: boolean;
|
||||
},
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
const cfg = loadConfig();
|
||||
const summary = await getStatusSummary();
|
||||
if (opts.all && !opts.json) {
|
||||
await statusAllCommand(runtime, { timeoutMs: opts.timeoutMs });
|
||||
return;
|
||||
}
|
||||
|
||||
const scan = await withProgress(
|
||||
{
|
||||
label: "Scanning status…",
|
||||
total: 6,
|
||||
enabled: opts.json !== true,
|
||||
},
|
||||
async (progress) => {
|
||||
progress.setLabel("Loading config…");
|
||||
const cfg = loadConfig();
|
||||
const osSummary = resolveOsSummary();
|
||||
progress.tick();
|
||||
|
||||
progress.setLabel("Checking for updates…");
|
||||
const updateTimeoutMs = opts.all ? 6500 : 2500;
|
||||
const update = await getUpdateCheckResult({
|
||||
timeoutMs: updateTimeoutMs,
|
||||
fetchGit: true,
|
||||
includeRegistry: true,
|
||||
});
|
||||
progress.tick();
|
||||
|
||||
progress.setLabel("Resolving agents…");
|
||||
const agentStatus = await getAgentLocalStatuses();
|
||||
progress.tick();
|
||||
|
||||
progress.setLabel("Probing gateway…");
|
||||
const gatewayConnection = buildGatewayConnectionDetails();
|
||||
const isRemoteMode = cfg.gateway?.mode === "remote";
|
||||
const remoteUrlRaw =
|
||||
typeof cfg.gateway?.remote?.url === "string"
|
||||
? cfg.gateway.remote.url
|
||||
: "";
|
||||
const remoteUrlMissing = isRemoteMode && !remoteUrlRaw.trim();
|
||||
const gatewayMode = isRemoteMode ? "remote" : "local";
|
||||
const gatewayProbe = remoteUrlMissing
|
||||
? null
|
||||
: await probeGateway({
|
||||
url: gatewayConnection.url,
|
||||
auth: resolveGatewayProbeAuth(cfg),
|
||||
timeoutMs: Math.min(
|
||||
opts.all ? 5000 : 2500,
|
||||
opts.timeoutMs ?? 10_000,
|
||||
),
|
||||
}).catch(() => null);
|
||||
const gatewayReachable = gatewayProbe?.ok === true;
|
||||
const gatewaySelf = gatewayProbe?.presence
|
||||
? pickGatewaySelfPresence(gatewayProbe.presence)
|
||||
: null;
|
||||
progress.tick();
|
||||
|
||||
progress.setLabel("Reading sessions…");
|
||||
const summary = await getStatusSummary();
|
||||
progress.tick();
|
||||
|
||||
progress.setLabel("Rendering…");
|
||||
progress.tick();
|
||||
|
||||
return {
|
||||
cfg,
|
||||
osSummary,
|
||||
update,
|
||||
gatewayConnection,
|
||||
remoteUrlMissing,
|
||||
gatewayMode,
|
||||
gatewayProbe,
|
||||
gatewayReachable,
|
||||
gatewaySelf,
|
||||
agentStatus,
|
||||
summary,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
cfg,
|
||||
osSummary,
|
||||
update,
|
||||
gatewayConnection,
|
||||
remoteUrlMissing,
|
||||
gatewayMode,
|
||||
gatewayProbe,
|
||||
gatewayReachable,
|
||||
gatewaySelf,
|
||||
agentStatus,
|
||||
summary,
|
||||
} = scan;
|
||||
const usage = opts.usage
|
||||
? await withProgress(
|
||||
{
|
||||
@@ -299,7 +647,23 @@ export async function statusCommand(
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
health || usage ? { ...summary, health, usage } : summary,
|
||||
{
|
||||
...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,
|
||||
...(health || usage ? { health, usage } : {}),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
@@ -307,7 +671,7 @@ export async function statusCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.verbose) {
|
||||
if (opts.verbose || opts.all) {
|
||||
const details = buildGatewayConnectionDetails();
|
||||
runtime.log(info("Gateway connection:"));
|
||||
for (const line of details.message.split("\n")) {
|
||||
@@ -326,6 +690,50 @@ export async function statusCommand(
|
||||
});
|
||||
runtime.log(info(`Dashboard: ${links.httpUrl}`));
|
||||
}
|
||||
|
||||
runtime.log(info(`OS: ${osSummary.label} · node ${process.versions.node}`));
|
||||
runtime.log(info(formatUpdateOneLiner(update)));
|
||||
|
||||
const gatewayLine = (() => {
|
||||
const target = remoteUrlMissing
|
||||
? "(missing gateway.remote.url)"
|
||||
: gatewayConnection.url;
|
||||
const reach = remoteUrlMissing
|
||||
? "misconfigured (missing gateway.remote.url)"
|
||||
: gatewayReachable
|
||||
? `reachable (${formatDuration(gatewayProbe?.connectLatencyMs)})`
|
||||
: gatewayProbe?.error
|
||||
? `unreachable (${gatewayProbe.error})`
|
||||
: "unreachable";
|
||||
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 `Gateway: ${gatewayMode} · ${target} · ${reach}${suffix}`;
|
||||
})();
|
||||
runtime.log(info(gatewayLine));
|
||||
|
||||
const agentLine = (() => {
|
||||
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 `Agents: ${agentStatus.agents.length} · ${pending} · sessions ${agentStatus.totalSessions}${defSuffix}`;
|
||||
})();
|
||||
runtime.log(info(agentLine));
|
||||
|
||||
runtime.log(
|
||||
`Web session: ${summary.web.linked ? "linked" : "not linked"}${summary.web.linked ? ` (last refreshed ${formatAge(summary.web.authAgeMs)})` : ""}`,
|
||||
);
|
||||
@@ -342,6 +750,217 @@ export async function statusCommand(
|
||||
if (daemonLine) {
|
||||
runtime.log(info(daemonLine));
|
||||
}
|
||||
|
||||
if (opts.all) {
|
||||
runtime.log("");
|
||||
runtime.log(theme.heading("Diagnosis (read-only):"));
|
||||
|
||||
const snap = await readConfigFileSnapshot().catch(() => null);
|
||||
if (snap) {
|
||||
runtime.log(
|
||||
formatCheckLine({
|
||||
ok: Boolean(snap.exists && snap.valid),
|
||||
warn: Boolean(snap.exists && !snap.valid),
|
||||
label: `Config: ${snap.path ?? "(unknown)"}`,
|
||||
detail: snap.exists
|
||||
? snap.valid
|
||||
? "valid"
|
||||
: `invalid (${snap.issues.length} issues)`
|
||||
: "missing",
|
||||
}),
|
||||
);
|
||||
const issues = [...(snap.legacyIssues ?? []), ...(snap.issues ?? [])];
|
||||
const uniqueIssues = issues.filter(
|
||||
(issue, index) =>
|
||||
issues.findIndex(
|
||||
(x) => x.path === issue.path && x.message === issue.message,
|
||||
) === index,
|
||||
);
|
||||
for (const issue of uniqueIssues.slice(0, 12)) {
|
||||
runtime.log(` - ${issue.path}: ${issue.message}`);
|
||||
}
|
||||
if (uniqueIssues.length > 12) {
|
||||
runtime.log(theme.muted(` … +${uniqueIssues.length - 12} more`));
|
||||
}
|
||||
} else {
|
||||
runtime.log(
|
||||
formatCheckLine({
|
||||
ok: false,
|
||||
label: "Config: unknown",
|
||||
detail: "read failed",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const sentinel = await readRestartSentinel().catch(() => null);
|
||||
if (sentinel?.payload) {
|
||||
runtime.log(
|
||||
formatCheckLine({
|
||||
ok: true,
|
||||
label: "Restart sentinel",
|
||||
detail: `${summarizeRestartSentinel(sentinel.payload)} · ${formatAge(Date.now() - sentinel.payload.ts)}`,
|
||||
warn: true,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
runtime.log(
|
||||
formatCheckLine({
|
||||
ok: true,
|
||||
label: "Restart sentinel",
|
||||
detail: "none",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const lastErr = await readLastGatewayErrorLine(process.env).catch(
|
||||
() => null,
|
||||
);
|
||||
if (lastErr) {
|
||||
runtime.log(
|
||||
formatCheckLine({
|
||||
ok: true,
|
||||
warn: true,
|
||||
label: "Gateway last log line",
|
||||
detail: lastErr,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
runtime.log(
|
||||
formatCheckLine({
|
||||
ok: true,
|
||||
label: "Gateway last log line",
|
||||
detail: "none",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const port = resolveGatewayPort(cfg);
|
||||
const portUsage = await inspectPortUsage(port).catch(() => null);
|
||||
if (portUsage) {
|
||||
const ok = portUsage.listeners.length === 0;
|
||||
runtime.log(
|
||||
formatCheckLine({
|
||||
ok,
|
||||
warn: !ok,
|
||||
label: `Port ${port}`,
|
||||
detail: ok ? "free" : "in use",
|
||||
}),
|
||||
);
|
||||
if (!ok) {
|
||||
for (const line of formatPortDiagnostics(portUsage)) {
|
||||
runtime.log(` ${line}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultWorkspace =
|
||||
agentStatus.agents.find((a) => a.id === agentStatus.defaultId)
|
||||
?.workspaceDir ??
|
||||
agentStatus.agents[0]?.workspaceDir ??
|
||||
null;
|
||||
const skillStatus =
|
||||
defaultWorkspace != null
|
||||
? (() => {
|
||||
try {
|
||||
return buildWorkspaceSkillStatus(defaultWorkspace, {
|
||||
config: cfg,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: null;
|
||||
if (skillStatus) {
|
||||
const eligible = skillStatus.skills.filter((s) => s.eligible).length;
|
||||
const missing = skillStatus.skills.filter(
|
||||
(s) => s.eligible && Object.values(s.missing).some((arr) => arr.length),
|
||||
).length;
|
||||
runtime.log(
|
||||
formatCheckLine({
|
||||
ok: missing === 0,
|
||||
warn: missing > 0,
|
||||
label: "Skills",
|
||||
detail: `${eligible} eligible · ${missing} missing requirements · ${skillStatus.workspaceDir}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
runtime.log("");
|
||||
runtime.log(theme.heading("Agents:"));
|
||||
for (const agent of agentStatus.agents) {
|
||||
const name = agent.name ? ` (${agent.name})` : "";
|
||||
const bootstrap =
|
||||
agent.bootstrapPending === true
|
||||
? theme.warn("BOOTSTRAP.md pending")
|
||||
: agent.bootstrapPending === false
|
||||
? theme.success("bootstrapped")
|
||||
: theme.muted("bootstrap unknown");
|
||||
const active =
|
||||
agent.lastActiveAgeMs != null
|
||||
? formatAge(agent.lastActiveAgeMs)
|
||||
: "unknown";
|
||||
runtime.log(
|
||||
`- ${theme.info(agent.id)}${name} · ${bootstrap} · sessions ${agent.sessionsCount} · active ${active}`,
|
||||
);
|
||||
if (agent.workspaceDir)
|
||||
runtime.log(theme.muted(` workspace: ${agent.workspaceDir}`));
|
||||
runtime.log(theme.muted(` sessions: ${agent.sessionsPath}`));
|
||||
}
|
||||
|
||||
if (gatewayReachable) {
|
||||
const providersStatus = await callGateway<Record<string, unknown>>({
|
||||
method: "providers.status",
|
||||
params: { probe: false, timeoutMs: opts.timeoutMs ?? 10_000 },
|
||||
timeoutMs: Math.min(8000, opts.timeoutMs ?? 10_000),
|
||||
}).catch(() => null);
|
||||
if (providersStatus) {
|
||||
const issues = collectProvidersStatusIssues(providersStatus);
|
||||
runtime.log(
|
||||
formatCheckLine({
|
||||
ok: issues.length === 0,
|
||||
warn: issues.length > 0,
|
||||
label: "Provider config/runtime issues",
|
||||
detail: issues.length ? String(issues.length) : "none",
|
||||
}),
|
||||
);
|
||||
for (const issue of issues.slice(0, 8)) {
|
||||
runtime.log(
|
||||
` - ${issue.provider}[${issue.accountId}] ${issue.kind}: ${issue.message}`,
|
||||
);
|
||||
if (issue.fix) runtime.log(theme.muted(` fix: ${issue.fix}`));
|
||||
}
|
||||
if (issues.length > 8) {
|
||||
runtime.log(theme.muted(` … +${issues.length - 8} more`));
|
||||
}
|
||||
} else {
|
||||
runtime.log(
|
||||
formatCheckLine({
|
||||
ok: false,
|
||||
warn: true,
|
||||
label: "Provider config/runtime issues",
|
||||
detail: "skipped (gateway query failed)",
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
runtime.log(
|
||||
formatCheckLine({
|
||||
ok: false,
|
||||
warn: true,
|
||||
label: "Provider config/runtime issues",
|
||||
detail: "skipped (gateway unreachable)",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
runtime.log("");
|
||||
runtime.log(
|
||||
theme.muted(
|
||||
"Tip: This output is safe to paste for debugging (no tokens).",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
runtime.log("");
|
||||
if (health) {
|
||||
runtime.log(info("Gateway health: reachable"));
|
||||
|
||||
@@ -141,7 +141,9 @@ describe("buildGatewayConnectionDetails", () => {
|
||||
const details = buildGatewayConnectionDetails();
|
||||
|
||||
expect(details.url).toBe("ws://127.0.0.1:18789");
|
||||
expect(details.urlSource).toBe("local loopback");
|
||||
expect(details.urlSource).toBe(
|
||||
"missing gateway.remote.url (fallback local)",
|
||||
);
|
||||
expect(details.bindDetail).toBe("Bind: loopback");
|
||||
expect(details.remoteFallbackNote).toContain(
|
||||
"gateway.mode=remote but gateway.remote.url is missing",
|
||||
@@ -231,6 +233,18 @@ describe("callGateway error details", () => {
|
||||
expect(err?.message).toContain("Source: local loopback");
|
||||
expect(err?.message).toContain("Bind: loopback");
|
||||
});
|
||||
|
||||
it("fails fast when remote mode is missing remote url", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
gateway: { mode: "remote", bind: "loopback", remote: {} },
|
||||
});
|
||||
await expect(
|
||||
callGateway({
|
||||
method: "health",
|
||||
timeoutMs: 10,
|
||||
}),
|
||||
).rejects.toThrow("gateway remote mode misconfigured");
|
||||
});
|
||||
});
|
||||
|
||||
describe("callGateway password resolution", () => {
|
||||
|
||||
@@ -66,18 +66,20 @@ export function buildGatewayConnectionDetails(
|
||||
typeof remote?.url === "string" && remote.url.trim().length > 0
|
||||
? remote.url.trim()
|
||||
: undefined;
|
||||
const remoteMisconfigured = isRemoteMode && !urlOverride && !remoteUrl;
|
||||
const url = urlOverride || remoteUrl || localUrl;
|
||||
const urlSource = urlOverride
|
||||
? "cli --url"
|
||||
: remoteUrl
|
||||
? "config gateway.remote.url"
|
||||
: preferTailnet && tailnetIPv4
|
||||
? `local tailnet ${tailnetIPv4}`
|
||||
: "local loopback";
|
||||
const remoteFallbackNote =
|
||||
isRemoteMode && !urlOverride && !remoteUrl
|
||||
? "Note: gateway.mode=remote but gateway.remote.url is missing; using local URL."
|
||||
: undefined;
|
||||
: remoteMisconfigured
|
||||
? "missing gateway.remote.url (fallback local)"
|
||||
: preferTailnet && tailnetIPv4
|
||||
? `local tailnet ${tailnetIPv4}`
|
||||
: "local loopback";
|
||||
const remoteFallbackNote = remoteMisconfigured
|
||||
? "Warn: gateway.mode=remote but gateway.remote.url is missing; set gateway.remote.url or switch gateway.mode=local."
|
||||
: undefined;
|
||||
const bindDetail =
|
||||
!urlOverride && !remoteUrl ? `Bind: ${bindMode}` : undefined;
|
||||
const message = [
|
||||
@@ -106,11 +108,31 @@ export async function callGateway<T = unknown>(
|
||||
const config = loadConfig();
|
||||
const isRemoteMode = config.gateway?.mode === "remote";
|
||||
const remote = isRemoteMode ? config.gateway?.remote : undefined;
|
||||
const urlOverride =
|
||||
typeof opts.url === "string" && opts.url.trim().length > 0
|
||||
? opts.url.trim()
|
||||
: undefined;
|
||||
const remoteUrl =
|
||||
typeof remote?.url === "string" && remote.url.trim().length > 0
|
||||
? remote.url.trim()
|
||||
: undefined;
|
||||
if (isRemoteMode && !urlOverride && !remoteUrl) {
|
||||
const configPath =
|
||||
opts.configPath ??
|
||||
resolveConfigPath(process.env, resolveStateDir(process.env));
|
||||
throw new Error(
|
||||
[
|
||||
"gateway remote mode misconfigured: gateway.remote.url missing",
|
||||
`Config: ${configPath}`,
|
||||
"Fix: set gateway.remote.url, or set gateway.mode=local.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
const authToken = config.gateway?.auth?.token;
|
||||
const authPassword = config.gateway?.auth?.password;
|
||||
const connectionDetails = buildGatewayConnectionDetails({
|
||||
config,
|
||||
url: opts.url,
|
||||
url: urlOverride,
|
||||
...(opts.configPath ? { configPath: opts.configPath } : {}),
|
||||
});
|
||||
const url = connectionDetails.url;
|
||||
|
||||
31
src/infra/os-summary.ts
Normal file
31
src/infra/os-summary.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import os from "node:os";
|
||||
|
||||
export type OsSummary = {
|
||||
platform: NodeJS.Platform;
|
||||
arch: string;
|
||||
release: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
function safeTrim(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function macosVersion(): string {
|
||||
const res = spawnSync("sw_vers", ["-productVersion"], { encoding: "utf-8" });
|
||||
const out = safeTrim(res.stdout);
|
||||
return out || os.release();
|
||||
}
|
||||
|
||||
export function resolveOsSummary(): OsSummary {
|
||||
const platform = os.platform();
|
||||
const release = os.release();
|
||||
const arch = os.arch();
|
||||
const label = (() => {
|
||||
if (platform === "darwin") return `macos ${macosVersion()} (${arch})`;
|
||||
if (platform === "win32") return `windows ${release} (${arch})`;
|
||||
return `${platform} ${release} (${arch})`;
|
||||
})();
|
||||
return { platform, arch, release, label };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
listIMessageAccountIds,
|
||||
resolveIMessageAccount,
|
||||
} from "../imessage/accounts.js";
|
||||
import { resolveMSTeamsCredentials } from "../msteams/token.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
|
||||
import {
|
||||
listSignalAccountIds,
|
||||
@@ -297,6 +298,27 @@ export async function buildProviderSummary(
|
||||
}
|
||||
}
|
||||
|
||||
const msEnabled = effective.msteams?.enabled !== false;
|
||||
if (!msEnabled) {
|
||||
lines.push(tint("MS Teams: disabled", theme.muted));
|
||||
} else {
|
||||
const configured = Boolean(resolveMSTeamsCredentials(effective.msteams));
|
||||
lines.push(
|
||||
configured
|
||||
? tint("MS Teams: configured", theme.success)
|
||||
: tint("MS Teams: not configured", theme.muted),
|
||||
);
|
||||
if (configured && resolved.includeAllowFrom) {
|
||||
const allowFrom = (effective.msteams?.allowFrom ?? [])
|
||||
.map((val) => val.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 2);
|
||||
if (allowFrom.length > 0) {
|
||||
lines.push(accountLine("default", [`allow:${allowFrom.join(",")}`]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
|
||||
364
src/infra/update-check.ts
Normal file
364
src/infra/update-check.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { parseSemver } from "./runtime-guard.js";
|
||||
|
||||
export type PackageManager = "pnpm" | "bun" | "npm" | "unknown";
|
||||
|
||||
export type GitUpdateStatus = {
|
||||
root: string;
|
||||
branch: string | null;
|
||||
upstream: string | null;
|
||||
dirty: boolean | null;
|
||||
ahead: number | null;
|
||||
behind: number | null;
|
||||
fetchOk: boolean | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type DepsStatus = {
|
||||
manager: PackageManager;
|
||||
status: "ok" | "missing" | "stale" | "unknown";
|
||||
lockfilePath: string | null;
|
||||
markerPath: string | null;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type RegistryStatus = {
|
||||
latestVersion: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type UpdateCheckResult = {
|
||||
root: string | null;
|
||||
installKind: "git" | "package" | "unknown";
|
||||
packageManager: PackageManager;
|
||||
git?: GitUpdateStatus;
|
||||
deps?: DepsStatus;
|
||||
registry?: RegistryStatus;
|
||||
};
|
||||
|
||||
async function exists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function detectPackageManager(root: string): Promise<PackageManager> {
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(root, "package.json"), "utf-8");
|
||||
const parsed = JSON.parse(raw) as { packageManager?: string };
|
||||
const pm = parsed?.packageManager?.split("@")[0]?.trim();
|
||||
if (pm === "pnpm" || pm === "bun" || pm === "npm") return pm;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const files = await fs.readdir(root).catch((): string[] => []);
|
||||
if (files.includes("pnpm-lock.yaml")) return "pnpm";
|
||||
if (files.includes("bun.lockb")) return "bun";
|
||||
if (files.includes("package-lock.json")) return "npm";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
async function detectGitRoot(root: string): Promise<string | null> {
|
||||
const res = await runCommandWithTimeout(
|
||||
["git", "-C", root, "rev-parse", "--show-toplevel"],
|
||||
{ timeoutMs: 4000 },
|
||||
).catch(() => null);
|
||||
if (!res || res.code !== 0) return null;
|
||||
const top = res.stdout.trim();
|
||||
return top ? path.resolve(top) : null;
|
||||
}
|
||||
|
||||
export async function checkGitUpdateStatus(params: {
|
||||
root: string;
|
||||
timeoutMs?: number;
|
||||
fetch?: boolean;
|
||||
}): Promise<GitUpdateStatus> {
|
||||
const timeoutMs = params.timeoutMs ?? 6000;
|
||||
const root = path.resolve(params.root);
|
||||
|
||||
const base: GitUpdateStatus = {
|
||||
root,
|
||||
branch: null,
|
||||
upstream: null,
|
||||
dirty: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
fetchOk: null,
|
||||
};
|
||||
|
||||
const branchRes = await runCommandWithTimeout(
|
||||
["git", "-C", root, "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
{ timeoutMs },
|
||||
).catch(() => null);
|
||||
if (!branchRes || branchRes.code !== 0) {
|
||||
return { ...base, error: branchRes?.stderr?.trim() || "git unavailable" };
|
||||
}
|
||||
const branch = branchRes.stdout.trim() || null;
|
||||
|
||||
const upstreamRes = await runCommandWithTimeout(
|
||||
["git", "-C", root, "rev-parse", "--abbrev-ref", "@{upstream}"],
|
||||
{ timeoutMs },
|
||||
).catch(() => null);
|
||||
const upstream =
|
||||
upstreamRes && upstreamRes.code === 0 ? upstreamRes.stdout.trim() : null;
|
||||
|
||||
const dirtyRes = await runCommandWithTimeout(
|
||||
["git", "-C", root, "status", "--porcelain"],
|
||||
{ timeoutMs },
|
||||
).catch(() => null);
|
||||
const dirty =
|
||||
dirtyRes && dirtyRes.code === 0 ? dirtyRes.stdout.trim().length > 0 : null;
|
||||
|
||||
const fetchOk = params.fetch
|
||||
? await runCommandWithTimeout(
|
||||
["git", "-C", root, "fetch", "--quiet", "--prune"],
|
||||
{ timeoutMs },
|
||||
)
|
||||
.then((r) => r.code === 0)
|
||||
.catch(() => false)
|
||||
: null;
|
||||
|
||||
const counts =
|
||||
upstream && upstream.length > 0
|
||||
? await runCommandWithTimeout(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
root,
|
||||
"rev-list",
|
||||
"--left-right",
|
||||
"--count",
|
||||
`HEAD...${upstream}`,
|
||||
],
|
||||
{ timeoutMs },
|
||||
).catch(() => null)
|
||||
: null;
|
||||
|
||||
const parseCounts = (
|
||||
raw: string,
|
||||
): { ahead: number; behind: number } | null => {
|
||||
const parts = raw.trim().split(/\s+/);
|
||||
if (parts.length < 2) return null;
|
||||
const ahead = Number.parseInt(parts[0] ?? "", 10);
|
||||
const behind = Number.parseInt(parts[1] ?? "", 10);
|
||||
if (!Number.isFinite(ahead) || !Number.isFinite(behind)) return null;
|
||||
return { ahead, behind };
|
||||
};
|
||||
const parsed =
|
||||
counts && counts.code === 0 ? parseCounts(counts.stdout) : null;
|
||||
|
||||
return {
|
||||
root,
|
||||
branch,
|
||||
upstream,
|
||||
dirty,
|
||||
ahead: parsed?.ahead ?? null,
|
||||
behind: parsed?.behind ?? null,
|
||||
fetchOk,
|
||||
};
|
||||
}
|
||||
|
||||
async function statMtimeMs(p: string): Promise<number | null> {
|
||||
try {
|
||||
const st = await fs.stat(p);
|
||||
return st.mtimeMs;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDepsMarker(params: { root: string; manager: PackageManager }): {
|
||||
lockfilePath: string | null;
|
||||
markerPath: string | null;
|
||||
} {
|
||||
const root = params.root;
|
||||
if (params.manager === "pnpm") {
|
||||
return {
|
||||
lockfilePath: path.join(root, "pnpm-lock.yaml"),
|
||||
markerPath: path.join(root, "node_modules", ".modules.yaml"),
|
||||
};
|
||||
}
|
||||
if (params.manager === "bun") {
|
||||
return {
|
||||
lockfilePath: path.join(root, "bun.lockb"),
|
||||
markerPath: path.join(root, "node_modules"),
|
||||
};
|
||||
}
|
||||
if (params.manager === "npm") {
|
||||
return {
|
||||
lockfilePath: path.join(root, "package-lock.json"),
|
||||
markerPath: path.join(root, "node_modules"),
|
||||
};
|
||||
}
|
||||
return { lockfilePath: null, markerPath: null };
|
||||
}
|
||||
|
||||
export async function checkDepsStatus(params: {
|
||||
root: string;
|
||||
manager: PackageManager;
|
||||
}): Promise<DepsStatus> {
|
||||
const root = path.resolve(params.root);
|
||||
const { lockfilePath, markerPath } = resolveDepsMarker({
|
||||
root,
|
||||
manager: params.manager,
|
||||
});
|
||||
|
||||
if (!lockfilePath || !markerPath) {
|
||||
return {
|
||||
manager: params.manager,
|
||||
status: "unknown",
|
||||
lockfilePath,
|
||||
markerPath,
|
||||
reason: "unknown package manager",
|
||||
};
|
||||
}
|
||||
|
||||
const lockExists = await exists(lockfilePath);
|
||||
const markerExists = await exists(markerPath);
|
||||
if (!lockExists) {
|
||||
return {
|
||||
manager: params.manager,
|
||||
status: "unknown",
|
||||
lockfilePath,
|
||||
markerPath,
|
||||
reason: "lockfile missing",
|
||||
};
|
||||
}
|
||||
if (!markerExists) {
|
||||
return {
|
||||
manager: params.manager,
|
||||
status: "missing",
|
||||
lockfilePath,
|
||||
markerPath,
|
||||
reason: "node_modules marker missing",
|
||||
};
|
||||
}
|
||||
|
||||
const lockMtime = await statMtimeMs(lockfilePath);
|
||||
const markerMtime = await statMtimeMs(markerPath);
|
||||
if (!lockMtime || !markerMtime) {
|
||||
return {
|
||||
manager: params.manager,
|
||||
status: "unknown",
|
||||
lockfilePath,
|
||||
markerPath,
|
||||
};
|
||||
}
|
||||
if (lockMtime > markerMtime + 1000) {
|
||||
return {
|
||||
manager: params.manager,
|
||||
status: "stale",
|
||||
lockfilePath,
|
||||
markerPath,
|
||||
reason: "lockfile newer than install marker",
|
||||
};
|
||||
}
|
||||
return {
|
||||
manager: params.manager,
|
||||
status: "ok",
|
||||
lockfilePath,
|
||||
markerPath,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
): Promise<Response> {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), Math.max(250, timeoutMs));
|
||||
try {
|
||||
return await fetch(url, { signal: ctrl.signal });
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchNpmLatestVersion(params?: {
|
||||
timeoutMs?: number;
|
||||
}): Promise<RegistryStatus> {
|
||||
const timeoutMs = params?.timeoutMs ?? 3500;
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
"https://registry.npmjs.org/clawdbot/latest",
|
||||
timeoutMs,
|
||||
);
|
||||
if (!res.ok) {
|
||||
return { latestVersion: null, error: `HTTP ${res.status}` };
|
||||
}
|
||||
const json = (await res.json()) as { version?: unknown };
|
||||
const latestVersion =
|
||||
typeof json?.version === "string" ? json.version : null;
|
||||
return { latestVersion };
|
||||
} catch (err) {
|
||||
return { latestVersion: null, error: String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export function compareSemverStrings(
|
||||
a: string | null,
|
||||
b: string | null,
|
||||
): number | null {
|
||||
const pa = parseSemver(a);
|
||||
const pb = parseSemver(b);
|
||||
if (!pa || !pb) return null;
|
||||
if (pa.major !== pb.major) return pa.major < pb.major ? -1 : 1;
|
||||
if (pa.minor !== pb.minor) return pa.minor < pb.minor ? -1 : 1;
|
||||
if (pa.patch !== pb.patch) return pa.patch < pb.patch ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function checkUpdateStatus(params: {
|
||||
root: string | null;
|
||||
timeoutMs?: number;
|
||||
fetchGit?: boolean;
|
||||
includeRegistry?: boolean;
|
||||
}): Promise<UpdateCheckResult> {
|
||||
const timeoutMs = params.timeoutMs ?? 6000;
|
||||
const root = params.root ? path.resolve(params.root) : null;
|
||||
if (!root) {
|
||||
return {
|
||||
root: null,
|
||||
installKind: "unknown",
|
||||
packageManager: "unknown",
|
||||
registry: params.includeRegistry
|
||||
? await fetchNpmLatestVersion({ timeoutMs })
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const pm = await detectPackageManager(root);
|
||||
const gitRoot = await detectGitRoot(root);
|
||||
const isGit = gitRoot && path.resolve(gitRoot) === root;
|
||||
|
||||
const installKind: UpdateCheckResult["installKind"] = isGit
|
||||
? "git"
|
||||
: "package";
|
||||
const git = isGit
|
||||
? await checkGitUpdateStatus({
|
||||
root,
|
||||
timeoutMs,
|
||||
fetch: Boolean(params.fetchGit),
|
||||
})
|
||||
: undefined;
|
||||
const deps = await checkDepsStatus({ root, manager: pm });
|
||||
const registry = params.includeRegistry
|
||||
? await fetchNpmLatestVersion({ timeoutMs })
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
root,
|
||||
installKind,
|
||||
packageManager: pm,
|
||||
git,
|
||||
deps,
|
||||
registry,
|
||||
};
|
||||
}
|
||||
@@ -402,7 +402,11 @@ function isRichConsoleEnv(): boolean {
|
||||
}
|
||||
|
||||
function getColorForConsole(): ChalkInstance {
|
||||
if (process.env.NO_COLOR) return new Chalk({ level: 0 });
|
||||
const hasForceColor =
|
||||
typeof process.env.FORCE_COLOR === "string" &&
|
||||
process.env.FORCE_COLOR.trim().length > 0 &&
|
||||
process.env.FORCE_COLOR.trim() !== "0";
|
||||
if (process.env.NO_COLOR && !hasForceColor) return new Chalk({ level: 0 });
|
||||
const hasTty = Boolean(process.stdout.isTTY || process.stderr.isTTY);
|
||||
return hasTty || isRichConsoleEnv()
|
||||
? new Chalk({ level: 1 })
|
||||
|
||||
14
src/terminal/ansi.ts
Normal file
14
src/terminal/ansi.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
const ANSI_SGR_PATTERN = "\\x1b\\[[0-9;]*m";
|
||||
// OSC-8 hyperlinks: ESC ] 8 ; ; url ST ... ESC ] 8 ; ; ST
|
||||
const OSC8_PATTERN = "\\x1b\\]8;;.*?\\x1b\\\\|\\x1b\\]8;;\\x1b\\\\";
|
||||
|
||||
const ANSI_REGEX = new RegExp(ANSI_SGR_PATTERN, "g");
|
||||
const OSC8_REGEX = new RegExp(OSC8_PATTERN, "g");
|
||||
|
||||
export function stripAnsi(input: string): string {
|
||||
return input.replace(OSC8_REGEX, "").replace(ANSI_REGEX, "");
|
||||
}
|
||||
|
||||
export function visibleWidth(input: string): number {
|
||||
return Array.from(stripAnsi(input)).length;
|
||||
}
|
||||
19
src/terminal/table.test.ts
Normal file
19
src/terminal/table.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { renderTable } from "./table.js";
|
||||
|
||||
describe("renderTable", () => {
|
||||
it("prefers shrinking flex columns to avoid wrapping non-flex labels", () => {
|
||||
const out = renderTable({
|
||||
width: 40,
|
||||
columns: [
|
||||
{ key: "Item", header: "Item", minWidth: 10 },
|
||||
{ key: "Value", header: "Value", flex: true, minWidth: 24 },
|
||||
],
|
||||
rows: [{ Item: "Dashboard", Value: "http://127.0.0.1:18789/" }],
|
||||
});
|
||||
|
||||
expect(out).toContain("Dashboard");
|
||||
expect(out).toMatch(/│ Dashboard\s+│/);
|
||||
});
|
||||
});
|
||||
265
src/terminal/table.ts
Normal file
265
src/terminal/table.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { visibleWidth } from "./ansi.js";
|
||||
|
||||
type Align = "left" | "right" | "center";
|
||||
|
||||
export type TableColumn = {
|
||||
key: string;
|
||||
header: string;
|
||||
align?: Align;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
flex?: boolean;
|
||||
};
|
||||
|
||||
export type RenderTableOptions = {
|
||||
columns: TableColumn[];
|
||||
rows: Array<Record<string, string>>;
|
||||
width?: number;
|
||||
padding?: number;
|
||||
border?: "unicode" | "ascii" | "none";
|
||||
};
|
||||
|
||||
function repeat(ch: string, n: number): string {
|
||||
if (n <= 0) return "";
|
||||
return ch.repeat(n);
|
||||
}
|
||||
|
||||
function padCell(text: string, width: number, align: Align): string {
|
||||
const w = visibleWidth(text);
|
||||
if (w >= width) return text;
|
||||
const pad = width - w;
|
||||
if (align === "right") return `${repeat(" ", pad)}${text}`;
|
||||
if (align === "center") {
|
||||
const left = Math.floor(pad / 2);
|
||||
const right = pad - left;
|
||||
return `${repeat(" ", left)}${text}${repeat(" ", right)}`;
|
||||
}
|
||||
return `${text}${repeat(" ", pad)}`;
|
||||
}
|
||||
|
||||
function wrapLine(text: string, width: number): string[] {
|
||||
if (width <= 0) return [text];
|
||||
const words = text.split(/(\s+)/).filter(Boolean);
|
||||
const lines: string[] = [];
|
||||
let current = "";
|
||||
let currentWidth = 0;
|
||||
const push = (value: string) => lines.push(value.replace(/\s+$/, ""));
|
||||
|
||||
const flush = () => {
|
||||
if (current.trim().length === 0) return;
|
||||
push(current);
|
||||
current = "";
|
||||
currentWidth = 0;
|
||||
};
|
||||
|
||||
const breakLong = (word: string) => {
|
||||
const parts: string[] = [];
|
||||
let buf = "";
|
||||
let lastBreakAt = 0;
|
||||
const isBreakChar = (ch: string) =>
|
||||
ch === "/" || ch === "-" || ch === "_" || ch === ".";
|
||||
for (const ch of Array.from(word)) {
|
||||
const next = buf + ch;
|
||||
if (visibleWidth(next) > width && buf) {
|
||||
if (lastBreakAt > 0) {
|
||||
parts.push(buf.slice(0, lastBreakAt));
|
||||
buf = `${buf.slice(lastBreakAt)}${ch}`;
|
||||
lastBreakAt = 0;
|
||||
for (let i = 0; i < buf.length; i += 1) {
|
||||
const c = buf[i];
|
||||
if (c && isBreakChar(c)) lastBreakAt = i + 1;
|
||||
}
|
||||
} else {
|
||||
parts.push(buf);
|
||||
buf = ch;
|
||||
}
|
||||
} else {
|
||||
buf = next;
|
||||
if (isBreakChar(ch)) lastBreakAt = buf.length;
|
||||
}
|
||||
}
|
||||
if (buf) parts.push(buf);
|
||||
return parts;
|
||||
};
|
||||
|
||||
for (const token of words) {
|
||||
const tokenWidth = visibleWidth(token);
|
||||
const isSpace = /^\s+$/.test(token);
|
||||
|
||||
if (tokenWidth > width && !isSpace) {
|
||||
flush();
|
||||
for (const part of breakLong(token.replace(/^\s+/, ""))) {
|
||||
push(part);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
currentWidth + tokenWidth > width &&
|
||||
current.trim().length > 0 &&
|
||||
!isSpace
|
||||
) {
|
||||
flush();
|
||||
}
|
||||
|
||||
current += token;
|
||||
currentWidth = visibleWidth(current);
|
||||
}
|
||||
|
||||
flush();
|
||||
return lines.length ? lines : [""];
|
||||
}
|
||||
|
||||
function normalizeWidth(n: number | undefined): number | undefined {
|
||||
if (n == null) return undefined;
|
||||
if (!Number.isFinite(n) || n <= 0) return undefined;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
export function renderTable(opts: RenderTableOptions): string {
|
||||
const border = opts.border ?? "unicode";
|
||||
if (border === "none") {
|
||||
const columns = opts.columns;
|
||||
const header = columns.map((c) => c.header).join(" | ");
|
||||
const lines = [
|
||||
header,
|
||||
...opts.rows.map((r) => columns.map((c) => r[c.key] ?? "").join(" | ")),
|
||||
];
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
const padding = Math.max(0, opts.padding ?? 1);
|
||||
const columns = opts.columns;
|
||||
|
||||
const metrics = columns.map((c) => {
|
||||
const headerW = visibleWidth(c.header);
|
||||
const cellW = Math.max(
|
||||
0,
|
||||
...opts.rows.map((r) => visibleWidth(r[c.key] ?? "")),
|
||||
);
|
||||
return { headerW, cellW };
|
||||
});
|
||||
|
||||
const widths = columns.map((c, i) => {
|
||||
const m = metrics[i];
|
||||
const base = Math.max(m?.headerW ?? 0, m?.cellW ?? 0) + padding * 2;
|
||||
const capped = c.maxWidth ? Math.min(base, c.maxWidth) : base;
|
||||
return Math.max(c.minWidth ?? 3, capped);
|
||||
});
|
||||
|
||||
const maxWidth = normalizeWidth(opts.width);
|
||||
const sepCount = columns.length + 1;
|
||||
const total = widths.reduce((a, b) => a + b, 0) + sepCount;
|
||||
|
||||
const preferredMinWidths = columns.map((c, i) =>
|
||||
Math.max(c.minWidth ?? 3, (metrics[i]?.headerW ?? 0) + padding * 2, 3),
|
||||
);
|
||||
const absoluteMinWidths = columns.map((_c, i) =>
|
||||
Math.max((metrics[i]?.headerW ?? 0) + padding * 2, 3),
|
||||
);
|
||||
|
||||
if (maxWidth && total > maxWidth) {
|
||||
let over = total - maxWidth;
|
||||
|
||||
const flexOrder = columns
|
||||
.map((_c, i) => ({ i, w: widths[i] ?? 0 }))
|
||||
.filter(({ i }) => Boolean(columns[i]?.flex))
|
||||
.sort((a, b) => b.w - a.w)
|
||||
.map((x) => x.i);
|
||||
|
||||
const nonFlexOrder = columns
|
||||
.map((_c, i) => ({ i, w: widths[i] ?? 0 }))
|
||||
.filter(({ i }) => !columns[i]?.flex)
|
||||
.sort((a, b) => b.w - a.w)
|
||||
.map((x) => x.i);
|
||||
|
||||
const shrink = (order: number[], minWidths: number[]) => {
|
||||
while (over > 0) {
|
||||
let progressed = false;
|
||||
for (const i of order) {
|
||||
if ((widths[i] ?? 0) <= (minWidths[i] ?? 0)) continue;
|
||||
widths[i] = (widths[i] ?? 0) - 1;
|
||||
over -= 1;
|
||||
progressed = true;
|
||||
if (over <= 0) break;
|
||||
}
|
||||
if (!progressed) break;
|
||||
}
|
||||
};
|
||||
|
||||
// Prefer shrinking flex columns; only shrink non-flex if necessary.
|
||||
// If required to fit, allow flex columns to shrink below user minWidth
|
||||
// down to their absolute minimum (header + padding).
|
||||
shrink(flexOrder, preferredMinWidths);
|
||||
shrink(flexOrder, absoluteMinWidths);
|
||||
shrink(nonFlexOrder, preferredMinWidths);
|
||||
shrink(nonFlexOrder, absoluteMinWidths);
|
||||
}
|
||||
|
||||
const box =
|
||||
border === "ascii"
|
||||
? {
|
||||
tl: "+",
|
||||
tr: "+",
|
||||
bl: "+",
|
||||
br: "+",
|
||||
h: "-",
|
||||
v: "|",
|
||||
t: "+",
|
||||
ml: "+",
|
||||
m: "+",
|
||||
mr: "+",
|
||||
b: "+",
|
||||
}
|
||||
: {
|
||||
tl: "┌",
|
||||
tr: "┐",
|
||||
bl: "└",
|
||||
br: "┘",
|
||||
h: "─",
|
||||
v: "│",
|
||||
t: "┬",
|
||||
ml: "├",
|
||||
m: "┼",
|
||||
mr: "┤",
|
||||
b: "┴",
|
||||
};
|
||||
|
||||
const hLine = (left: string, mid: string, right: string) =>
|
||||
`${left}${widths.map((w) => repeat(box.h, w)).join(mid)}${right}`;
|
||||
|
||||
const contentWidthFor = (i: number) => Math.max(1, widths[i] - padding * 2);
|
||||
const padStr = repeat(" ", padding);
|
||||
|
||||
const renderRow = (record: Record<string, string>, isHeader = false) => {
|
||||
const cells = columns.map((c) =>
|
||||
isHeader ? c.header : (record[c.key] ?? ""),
|
||||
);
|
||||
const wrapped = cells.map((cell, i) => wrapLine(cell, contentWidthFor(i)));
|
||||
const height = Math.max(...wrapped.map((w) => w.length));
|
||||
const out: string[] = [];
|
||||
for (let li = 0; li < height; li += 1) {
|
||||
const parts = wrapped.map((lines, i) => {
|
||||
const raw = lines[li] ?? "";
|
||||
const aligned = padCell(
|
||||
raw,
|
||||
contentWidthFor(i),
|
||||
columns[i]?.align ?? "left",
|
||||
);
|
||||
return `${padStr}${aligned}${padStr}`;
|
||||
});
|
||||
out.push(`${box.v}${parts.join(box.v)}${box.v}`);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(hLine(box.tl, box.t, box.tr));
|
||||
lines.push(...renderRow({}, true));
|
||||
lines.push(hLine(box.ml, box.m, box.mr));
|
||||
for (const row of opts.rows) {
|
||||
lines.push(...renderRow(row, false));
|
||||
}
|
||||
lines.push(hLine(box.bl, box.b, box.br));
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
@@ -2,7 +2,13 @@ import chalk, { Chalk } from "chalk";
|
||||
|
||||
import { LOBSTER_PALETTE } from "./palette.js";
|
||||
|
||||
const baseChalk = process.env.NO_COLOR ? new Chalk({ level: 0 }) : chalk;
|
||||
const hasForceColor =
|
||||
typeof process.env.FORCE_COLOR === "string" &&
|
||||
process.env.FORCE_COLOR.trim().length > 0 &&
|
||||
process.env.FORCE_COLOR.trim() !== "0";
|
||||
|
||||
const baseChalk =
|
||||
process.env.NO_COLOR && !hasForceColor ? new Chalk({ level: 0 }) : chalk;
|
||||
|
||||
const hex = (value: string) => baseChalk.hex(value);
|
||||
|
||||
@@ -20,8 +26,7 @@ export const theme = {
|
||||
option: hex(LOBSTER_PALETTE.warn),
|
||||
} as const;
|
||||
|
||||
export const isRich = () =>
|
||||
Boolean(process.stdout.isTTY && baseChalk.level > 0);
|
||||
export const isRich = () => Boolean(baseChalk.level > 0);
|
||||
|
||||
export const colorize = (
|
||||
rich: boolean,
|
||||
|
||||
Reference in New Issue
Block a user