fix(status): improve diagnostics and output

This commit is contained in:
Peter Steinberger
2026-01-11 02:42:14 +01:00
parent 2e2f05a0e1
commit e824b3514b
18 changed files with 632 additions and 82 deletions

View File

@@ -33,6 +33,9 @@ export function buildAgentSystemPrompt(params: {
browserControlUrl?: string;
browserNoVncUrl?: string;
hostBrowserAllowed?: boolean;
allowedControlUrls?: string[];
allowedControlHosts?: string[];
allowedControlPorts?: number[];
elevated?: {
allowed: boolean;
defaultLevel: "on" | "off";

View File

@@ -431,4 +431,38 @@ describe("providers command", () => {
});
expect(disconnected.join("\n")).toMatch(/disconnected/i);
});
it("surfaces Signal runtime errors in providers status output", () => {
const lines = formatGatewayProvidersStatusLines({
signalAccounts: [
{
accountId: "default",
enabled: true,
configured: true,
running: false,
lastError: "signal-cli unreachable",
},
],
});
expect(lines.join("\n")).toMatch(/Warnings:/);
expect(lines.join("\n")).toMatch(/signal/i);
expect(lines.join("\n")).toMatch(/Provider error/i);
});
it("surfaces iMessage runtime errors in providers status output", () => {
const lines = formatGatewayProvidersStatusLines({
imessageAccounts: [
{
accountId: "default",
enabled: true,
configured: true,
running: false,
lastError: "imsg permission denied",
},
],
});
expect(lines.join("\n")).toMatch(/Warnings:/);
expect(lines.join("\n")).toMatch(/imessage/i);
expect(lines.join("\n")).toMatch(/Provider error/i);
});
});

View File

@@ -9,6 +9,7 @@ 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 { normalizeControlUiBasePath } from "../gateway/control-ui.js";
import { probeGateway } from "../gateway/probe.js";
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
import { resolveOsSummary } from "../infra/os-summary.js";
@@ -18,10 +19,12 @@ import {
readRestartSentinel,
summarizeRestartSentinel,
} from "../infra/restart-sentinel.js";
import { readTailscaleStatusJson } from "../infra/tailscale.js";
import {
checkUpdateStatus,
compareSemverStrings,
} from "../infra/update-check.js";
import { runExec } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { renderTable } from "../terminal/table.js";
import { isRich, theme } from "../terminal/theme.js";
@@ -46,12 +49,54 @@ export async function statusAllCommand(
opts?: { timeoutMs?: number },
): Promise<void> {
await withProgress(
{ label: "Scanning status --all…", indeterminate: true },
{ label: "Scanning status --all…", total: 11 },
async (progress) => {
progress.setLabel("Loading config…");
const cfg = loadConfig();
const osSummary = resolveOsSummary();
const snap = await readConfigFileSnapshot().catch(() => null);
progress.tick();
progress.setLabel("Checking Tailscale…");
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
const tailscale = await (async () => {
try {
const parsed = await readTailscaleStatusJson(runExec, {
timeoutMs: 1200,
});
const backendState =
typeof parsed.BackendState === "string"
? parsed.BackendState
: null;
const self =
typeof parsed.Self === "object" && parsed.Self !== null
? (parsed.Self as Record<string, unknown>)
: null;
const dnsNameRaw =
self && typeof self.DNSName === "string" ? self.DNSName : null;
const dnsName = dnsNameRaw ? dnsNameRaw.replace(/\.$/, "") : null;
const ips =
self && Array.isArray(self.TailscaleIPs)
? (self.TailscaleIPs as unknown[])
.filter((v) => typeof v === "string" && v.trim().length > 0)
.map((v) => (v as string).trim())
: [];
return { ok: true as const, backendState, dnsName, ips, error: null };
} catch (err) {
return {
ok: false as const,
backendState: null,
dnsName: null,
ips: [] as string[],
error: String(err),
};
}
})();
const tailscaleHttpsUrl =
tailscaleMode !== "off" && tailscale.dnsName
? `https://${tailscale.dnsName}${normalizeControlUiBasePath(cfg.gateway?.controlUi?.basePath)}`
: null;
progress.tick();
progress.setLabel("Checking for updates…");
const root = await resolveClawdbotPackageRoot({
@@ -65,6 +110,7 @@ export async function statusAllCommand(
fetchGit: true,
includeRegistry: true,
});
progress.tick();
progress.setLabel("Probing gateway…");
const connection = buildGatewayConnectionDetails({ config: cfg });
@@ -113,6 +159,7 @@ export async function statusAllCommand(
const gatewaySelf = pickGatewaySelfPresence(
gatewayProbe?.presence ?? null,
);
progress.tick();
progress.setLabel("Checking daemon…");
const daemon = await (async () => {
@@ -137,11 +184,14 @@ export async function statusAllCommand(
return null;
}
})();
progress.tick();
progress.setLabel("Scanning agents…");
const agentStatus = await getAgentLocalStatuses(cfg);
progress.tick();
progress.setLabel("Summarizing providers…");
const providers = await buildProvidersTable(cfg, { showSecrets: false });
progress.tick();
const connectionDetailsForReport = (() => {
if (!remoteUrlMissing) return connection.message;
@@ -187,6 +237,7 @@ export async function statusAllCommand(
const providerIssues = providersStatus
? collectProvidersStatusIssues(providersStatus)
: [];
progress.tick();
progress.setLabel("Checking local state…");
const sentinel = await readRestartSentinel().catch(() => null);
@@ -195,6 +246,7 @@ export async function statusAllCommand(
);
const port = resolveGatewayPort(cfg);
const portUsage = await inspectPortUsage(port).catch(() => null);
progress.tick();
const defaultWorkspace =
agentStatus.agents.find((a) => a.id === agentStatus.defaultId)
@@ -322,6 +374,15 @@ export async function statusAllCommand(
dashboard
? { Item: "Dashboard", Value: dashboard }
: { Item: "Dashboard", Value: "disabled" },
{
Item: "Tailscale",
Value:
tailscaleMode === "off"
? `off${tailscale.backendState ? ` · ${tailscale.backendState}` : ""}${tailscale.dnsName ? ` · ${tailscale.dnsName}` : ""}`
: tailscale.dnsName && tailscaleHttpsUrl
? `${tailscaleMode} · ${tailscale.backendState ?? "unknown"} · ${tailscale.dnsName} · ${tailscaleHttpsUrl}`
: `${tailscaleMode} · ${tailscale.backendState ?? "unknown"} · magicdns unknown`,
},
{ Item: "Update", Value: updateLine },
{
Item: "Gateway",
@@ -376,6 +437,46 @@ export async function statusAllCommand(
: theme.accentDim("SETUP"),
Detail: row.detail,
}));
const providerIssuesByProvider = (() => {
const map = new Map<string, typeof providerIssues>();
for (const issue of providerIssues) {
const key = issue.provider;
const list = map.get(key);
if (list) list.push(issue);
else map.set(key, [issue]);
}
return map;
})();
const providerKeyForLabel = (label: string) => {
switch (label) {
case "WhatsApp":
return "whatsapp";
case "Telegram":
return "telegram";
case "Discord":
return "discord";
case "Slack":
return "slack";
case "Signal":
return "signal";
case "iMessage":
return "imessage";
default:
return label.toLowerCase();
}
};
const providerRowsWithIssues = providerRows.map((row) => {
const providerKey = providerKeyForLabel(row.Provider);
const issues = providerIssuesByProvider.get(providerKey) ?? [];
if (issues.length === 0) return row;
const issue = issues[0];
const suffix = ` · ${warn(`gateway: ${String(issue.message).slice(0, 90)}`)}`;
return {
...row,
State: warn("WARN"),
Detail: `${row.Detail}${suffix}`,
};
});
const providersTable = renderTable({
width: tableWidth,
@@ -385,7 +486,7 @@ export async function statusAllCommand(
{ key: "State", header: "State", minWidth: 8 },
{ key: "Detail", header: "Detail", flex: true, minWidth: 28 },
],
rows: providerRows,
rows: providerRowsWithIssues,
});
const agentRows = agentStatus.agents.map((a) => ({
@@ -531,6 +632,31 @@ export async function statusAllCommand(
}
}
{
const backend = tailscale.backendState ?? "unknown";
const okBackend = backend === "Running";
const hasDns = Boolean(tailscale.dnsName);
const label =
tailscaleMode === "off"
? `Tailscale: off · ${backend}${tailscale.dnsName ? ` · ${tailscale.dnsName}` : ""}`
: `Tailscale: ${tailscaleMode} · ${backend}${tailscale.dnsName ? ` · ${tailscale.dnsName}` : ""}`;
emitCheck(
label,
okBackend && (tailscaleMode === "off" || hasDns) ? "ok" : "warn",
);
if (tailscale.error) {
lines.push(` ${muted(`error: ${tailscale.error}`)}`);
}
if (tailscale.ips.length > 0) {
lines.push(
` ${muted(`ips: ${tailscale.ips.slice(0, 3).join(", ")}${tailscale.ips.length > 3 ? "…" : ""}`)}`,
);
}
if (tailscaleHttpsUrl) {
lines.push(` ${muted(`https: ${tailscaleHttpsUrl}`)}`);
}
}
if (skillStatus) {
const eligible = skillStatus.skills.filter((s) => s.eligible).length;
const missing = skillStatus.skills.filter(
@@ -543,6 +669,7 @@ export async function statusAllCommand(
);
}
progress.setLabel("Reading logs…");
const logPaths = (() => {
try {
return resolveGatewayLogPaths(process.env);
@@ -575,6 +702,7 @@ export async function statusAllCommand(
}
}
}
progress.tick();
if (providersStatus) {
emitCheck(
@@ -623,6 +751,7 @@ export async function statusAllCommand(
progress.setLabel("Rendering…");
runtime.log(lines.join("\n"));
progress.tick();
},
);
}

View File

@@ -285,6 +285,8 @@ export async function buildProvidersTable(
);
const siEnabledAccounts = siAccounts.filter((a) => a.enabled);
const siConfiguredAccounts = siEnabledAccounts.filter((a) => a.configured);
const siSample = siConfiguredAccounts[0] ?? siEnabledAccounts[0] ?? null;
const siBaseUrl = siSample?.baseUrl?.trim() ? siSample.baseUrl.trim() : "";
rows.push({
provider: "Signal",
enabled: siEnabled,
@@ -295,7 +297,7 @@ export async function buildProvidersTable(
: "setup",
detail: siEnabled
? siConfiguredAccounts.length > 0
? `configured · accounts ${siConfiguredAccounts.length}/${siEnabledAccounts.length || 1}`
? `configured${siBaseUrl ? ` · baseUrl ${siBaseUrl}` : ""} · accounts ${siConfiguredAccounts.length}/${siEnabledAccounts.length || 1}`
: "default config (no overrides)"
: "disabled",
});
@@ -307,6 +309,9 @@ export async function buildProvidersTable(
);
const imEnabledAccounts = imAccounts.filter((a) => a.enabled);
const imConfiguredAccounts = imEnabledAccounts.filter((a) => a.configured);
const imSample = imEnabledAccounts[0] ?? null;
const imCliPath = imSample?.config?.cliPath?.trim() || "";
const imDbPath = imSample?.config?.dbPath?.trim() || "";
rows.push({
provider: "iMessage",
enabled: imEnabled,
@@ -317,7 +322,7 @@ export async function buildProvidersTable(
: "setup",
detail: imEnabled
? imConfiguredAccounts.length > 0
? `configured · accounts ${imConfiguredAccounts.length}/${imEnabledAccounts.length || 1}`
? `configured${imCliPath ? ` · cliPath ${imCliPath}` : ""}${imDbPath ? ` · dbPath ${imDbPath}` : ""} · accounts ${imConfiguredAccounts.length}/${imEnabledAccounts.length || 1}`
: "default config (no overrides)"
: "disabled",
});

View File

@@ -31,6 +31,7 @@ const mocks = vi.hoisted(() => ({
presence: null,
configSnapshot: null,
}),
callGateway: vi.fn().mockResolvedValue({}),
}));
vi.mock("../config/sessions.js", () => ({
@@ -47,6 +48,10 @@ vi.mock("../web/session.js", () => ({
vi.mock("../gateway/probe.js", () => ({
probeGateway: mocks.probeGateway,
}));
vi.mock("../gateway/call.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../gateway/call.js")>();
return { ...actual, callGateway: mocks.callGateway };
});
vi.mock("../gateway/session-utils.js", () => ({
listAgentsForGateway: () => ({
defaultId: "main",
@@ -175,4 +180,46 @@ describe("statusCommand", () => {
else process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
}
});
it("surfaces provider runtime errors from the gateway", async () => {
mocks.probeGateway.mockResolvedValueOnce({
ok: true,
url: "ws://127.0.0.1:18789",
connectLatencyMs: 10,
error: null,
close: null,
health: {},
status: {},
presence: [],
configSnapshot: null,
});
mocks.callGateway.mockResolvedValueOnce({
signalAccounts: [
{
accountId: "default",
enabled: true,
configured: true,
running: false,
lastError: "signal-cli unreachable",
},
],
imessageAccounts: [
{
accountId: "default",
enabled: true,
configured: true,
running: false,
lastError: "imessage permission denied",
},
],
});
(runtime.log as vi.Mock).mockClear();
await statusCommand({}, runtime as never);
const logs = (runtime.log as vi.Mock).mock.calls.map((c) => String(c[0]));
expect(logs.join("\n")).toMatch(/Signal/i);
expect(logs.join("\n")).toMatch(/iMessage/i);
expect(logs.join("\n")).toMatch(/gateway:/i);
expect(logs.join("\n")).toMatch(/WARN/);
});
});

View File

@@ -19,6 +19,7 @@ import {
} from "../config/sessions.js";
import { resolveGatewayService } from "../daemon/service.js";
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
import { normalizeControlUiBasePath } from "../gateway/control-ui.js";
import { probeGateway } from "../gateway/probe.js";
import { listAgentsForGateway } from "../gateway/session-utils.js";
import { info } from "../globals.js";
@@ -29,12 +30,15 @@ import {
formatUsageReportLines,
loadProviderUsageSummary,
} from "../infra/provider-usage.js";
import { collectProvidersStatusIssues } from "../infra/providers-status-issues.js";
import { peekSystemEvents } from "../infra/system-events.js";
import { getTailnetHostname } from "../infra/tailscale.js";
import {
checkUpdateStatus,
compareSemverStrings,
type UpdateCheckResult,
} from "../infra/update-check.js";
import { runExec } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
@@ -533,7 +537,7 @@ export async function statusCommand(
const scan = await withProgress(
{
label: "Scanning status…",
total: 7,
total: 9,
enabled: opts.json !== true,
},
async (progress) => {
@@ -542,6 +546,20 @@ export async function statusCommand(
const osSummary = resolveOsSummary();
progress.tick();
progress.setLabel("Checking Tailscale…");
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
const tailscaleDns =
tailscaleMode === "off"
? null
: await getTailnetHostname((cmd, args) =>
runExec(cmd, args, { timeoutMs: 1200, maxBuffer: 200_000 }),
).catch(() => null);
const tailscaleHttpsUrl =
tailscaleMode !== "off" && tailscaleDns
? `https://${tailscaleDns}${normalizeControlUiBasePath(cfg.gateway?.controlUi?.basePath)}`
: null;
progress.tick();
progress.setLabel("Checking for updates…");
const updateTimeoutMs = opts.all ? 6500 : 2500;
const update = await getUpdateCheckResult({
@@ -580,6 +598,25 @@ export async function statusCommand(
: null;
progress.tick();
progress.setLabel("Querying provider status…");
const providersStatus = gatewayReachable
? await callGateway<Record<string, unknown>>({
method: "providers.status",
params: {
probe: false,
timeoutMs: Math.min(8000, opts.timeoutMs ?? 10_000),
},
timeoutMs: Math.min(
opts.all ? 5000 : 2500,
opts.timeoutMs ?? 10_000,
),
}).catch(() => null)
: null;
const providerIssues = providersStatus
? collectProvidersStatusIssues(providersStatus)
: [];
progress.tick();
progress.setLabel("Summarizing providers…");
const providers = await buildProvidersTable(cfg, {
// Show token previews in regular status; keep `status --all` redacted.
@@ -598,6 +635,9 @@ export async function statusCommand(
return {
cfg,
osSummary,
tailscaleMode,
tailscaleDns,
tailscaleHttpsUrl,
update,
gatewayConnection,
remoteUrlMissing,
@@ -605,6 +645,7 @@ export async function statusCommand(
gatewayProbe,
gatewayReachable,
gatewaySelf,
providerIssues,
agentStatus,
providers,
summary,
@@ -615,6 +656,9 @@ export async function statusCommand(
const {
cfg,
osSummary,
tailscaleMode,
tailscaleDns,
tailscaleHttpsUrl,
update,
gatewayConnection,
remoteUrlMissing,
@@ -622,6 +666,7 @@ export async function statusCommand(
gatewayProbe,
gatewayReachable,
gatewaySelf,
providerIssues,
agentStatus,
providers,
summary,
@@ -769,6 +814,15 @@ export async function statusCommand(
const overviewRows = [
{ Item: "Dashboard", Value: dashboard },
{ Item: "OS", Value: `${osSummary.label} · node ${process.versions.node}` },
{
Item: "Tailscale",
Value:
tailscaleMode === "off"
? muted("off")
: tailscaleDns && tailscaleHttpsUrl
? `${tailscaleMode} · ${tailscaleDns} · ${tailscaleHttpsUrl}`
: warn(`${tailscaleMode} · magicdns unknown`),
},
{
Item: "Update",
Value: formatUpdateOneLiner(update).replace(/^Update:\s*/i, ""),
@@ -801,6 +855,34 @@ export async function statusCommand(
runtime.log("");
runtime.log(theme.heading("Providers"));
const providerIssuesByProvider = (() => {
const map = new Map<string, typeof providerIssues>();
for (const issue of providerIssues) {
const key = issue.provider;
const list = map.get(key);
if (list) list.push(issue);
else map.set(key, [issue]);
}
return map;
})();
const providerKeyForLabel = (label: string) => {
switch (label) {
case "WhatsApp":
return "whatsapp";
case "Telegram":
return "telegram";
case "Discord":
return "discord";
case "Slack":
return "slack";
case "Signal":
return "signal";
case "iMessage":
return "imessage";
default:
return label.toLowerCase();
}
};
runtime.log(
renderTable({
width: tableWidth,
@@ -810,19 +892,29 @@ export async function statusCommand(
{ key: "State", header: "State", minWidth: 8 },
{ key: "Detail", header: "Detail", flex: true, minWidth: 24 },
],
rows: providers.rows.map((row) => ({
Provider: row.provider,
Enabled: row.enabled ? ok("ON") : muted("OFF"),
State:
row.state === "ok"
? ok("OK")
: row.state === "warn"
? warn("WARN")
: row.state === "off"
? muted("OFF")
: theme.accentDim("SETUP"),
Detail: row.detail,
})),
rows: providers.rows.map((row) => {
const providerKey = providerKeyForLabel(row.provider);
const issues = providerIssuesByProvider.get(providerKey) ?? [];
const effectiveState =
row.state === "off" ? "off" : issues.length > 0 ? "warn" : row.state;
const issueSuffix =
issues.length > 0
? ` · ${warn(`gateway: ${shortenText(issues[0]?.message ?? "issue", 84)}`)}`
: "";
return {
Provider: row.provider,
Enabled: row.enabled ? ok("ON") : muted("OFF"),
State:
effectiveState === "ok"
? ok("OK")
: effectiveState === "warn"
? warn("WARN")
: effectiveState === "off"
? muted("OFF")
: theme.accentDim("SETUP"),
Detail: `${row.detail}${issueSuffix}`,
};
}),
}).trimEnd(),
);

View File

@@ -1,5 +1,11 @@
export type ProviderStatusIssue = {
provider: "discord" | "telegram" | "whatsapp";
provider:
| "discord"
| "telegram"
| "whatsapp"
| "slack"
| "signal"
| "imessage";
accountId: string;
kind: "intent" | "permissions" | "config" | "auth" | "runtime";
message: string;
@@ -40,12 +46,37 @@ type WhatsAppAccountStatus = {
lastError?: unknown;
};
type RuntimeAccountStatus = {
accountId?: unknown;
enabled?: unknown;
configured?: unknown;
running?: unknown;
lastError?: unknown;
};
function asString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0
? value.trim()
: undefined;
}
function formatValue(value: unknown): string | undefined {
const s = asString(value);
if (s) return s;
if (value == null) return undefined;
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function shorten(message: string, maxLen = 140): string {
const cleaned = message.replace(/\s+/g, " ").trim();
if (cleaned.length <= maxLen) return cleaned;
return `${cleaned.slice(0, Math.max(0, maxLen - 1))}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
@@ -191,6 +222,17 @@ function readWhatsAppAccountStatus(
};
}
function readRuntimeAccountStatus(value: unknown): RuntimeAccountStatus | null {
if (!isRecord(value)) return null;
return {
accountId: value.accountId,
enabled: value.enabled,
configured: value.configured,
running: value.running,
lastError: value.lastError,
};
}
export function collectProvidersStatusIssues(
payload: Record<string, unknown>,
): ProviderStatusIssue[] {
@@ -342,5 +384,68 @@ export function collectProvidersStatusIssues(
}
}
const slackAccountsRaw = payload.slackAccounts;
if (Array.isArray(slackAccountsRaw)) {
for (const entry of slackAccountsRaw) {
const account = readRuntimeAccountStatus(entry);
if (!account) continue;
const accountId = asString(account.accountId) ?? "default";
const enabled = account.enabled !== false;
const configured = account.configured === true;
if (!enabled || !configured) continue;
const lastError = formatValue(account.lastError);
if (!lastError) continue;
issues.push({
provider: "slack",
accountId,
kind: "runtime",
message: `Provider error: ${shorten(lastError)}`,
fix: "Check gateway logs (`clawdbot logs --follow`) and re-auth/restart if needed.",
});
}
}
const signalAccountsRaw = payload.signalAccounts;
if (Array.isArray(signalAccountsRaw)) {
for (const entry of signalAccountsRaw) {
const account = readRuntimeAccountStatus(entry);
if (!account) continue;
const accountId = asString(account.accountId) ?? "default";
const enabled = account.enabled !== false;
const configured = account.configured === true;
if (!enabled || !configured) continue;
const lastError = formatValue(account.lastError);
if (!lastError) continue;
issues.push({
provider: "signal",
accountId,
kind: "runtime",
message: `Provider error: ${shorten(lastError)}`,
fix: "Check gateway logs (`clawdbot logs --follow`) and verify signal CLI/service setup.",
});
}
}
const imessageAccountsRaw = payload.imessageAccounts;
if (Array.isArray(imessageAccountsRaw)) {
for (const entry of imessageAccountsRaw) {
const account = readRuntimeAccountStatus(entry);
if (!account) continue;
const accountId = asString(account.accountId) ?? "default";
const enabled = account.enabled !== false;
const configured = account.configured === true;
if (!enabled || !configured) continue;
const lastError = formatValue(account.lastError);
if (!lastError) continue;
issues.push({
provider: "imessage",
accountId,
kind: "runtime",
message: `Provider error: ${shorten(lastError)}`,
fix: "Check macOS permissions/TCC and gateway logs (`clawdbot logs --follow`).",
});
}
}
return issues;
}

View File

@@ -12,6 +12,16 @@ import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
import { ensureBinary } from "./binaries.js";
function parsePossiblyNoisyJsonObject(stdout: string): Record<string, unknown> {
const trimmed = stdout.trim();
const start = trimmed.indexOf("{");
const end = trimmed.lastIndexOf("}");
if (start >= 0 && end > start) {
return JSON.parse(trimmed.slice(start, end + 1)) as Record<string, unknown>;
}
return JSON.parse(trimmed) as Record<string, unknown>;
}
export async function getTailnetHostname(exec: typeof runExec = runExec) {
// Derive tailnet hostname (or IP fallback) from tailscale status JSON.
const candidates = [
@@ -24,9 +34,7 @@ export async function getTailnetHostname(exec: typeof runExec = runExec) {
if (candidate.startsWith("/") && !existsSync(candidate)) continue;
try {
const { stdout } = await exec(candidate, ["status", "--json"]);
const parsed = stdout
? (JSON.parse(stdout) as Record<string, unknown>)
: {};
const parsed = stdout ? parsePossiblyNoisyJsonObject(stdout) : {};
const self =
typeof parsed.Self === "object" && parsed.Self !== null
? (parsed.Self as Record<string, unknown>)
@@ -49,6 +57,17 @@ export async function getTailnetHostname(exec: typeof runExec = runExec) {
throw lastError ?? new Error("Could not determine Tailscale DNS or IP");
}
export async function readTailscaleStatusJson(
exec: typeof runExec = runExec,
opts?: { timeoutMs?: number },
): Promise<Record<string, unknown>> {
const { stdout } = await exec("tailscale", ["status", "--json"], {
timeoutMs: opts?.timeoutMs ?? 5000,
maxBuffer: 400_000,
});
return stdout ? parsePossiblyNoisyJsonObject(stdout) : {};
}
export async function ensureGoInstalled(
exec: typeof runExec = runExec,
prompt: typeof promptYesNo = promptYesNo,

View File

@@ -32,4 +32,56 @@ describe("renderTable", () => {
const firstLine = out.trimEnd().split("\n")[0] ?? "";
expect(visibleWidth(firstLine)).toBe(width);
});
it("wraps ANSI-colored cells without corrupting escape sequences", () => {
const out = renderTable({
width: 36,
columns: [
{ key: "K", header: "K", minWidth: 3 },
{ key: "V", header: "V", flex: true, minWidth: 10 },
],
rows: [
{
K: "X",
V: `\x1b[33m${"a".repeat(120)}\x1b[0m`,
},
],
});
const ESC = "\u001b";
for (let i = 0; i < out.length; i += 1) {
if (out[i] !== ESC) continue;
// SGR: ESC [ ... m
if (out[i + 1] === "[") {
let j = i + 2;
while (j < out.length) {
const ch = out[j];
if (ch === "m") break;
if (ch && ch >= "0" && ch <= "9") {
j += 1;
continue;
}
if (ch === ";") {
j += 1;
continue;
}
break;
}
expect(out[j]).toBe("m");
i = j;
continue;
}
// OSC-8: ESC ] 8 ; ; ... ST (ST = ESC \)
if (out[i + 1] === "]" && out.slice(i + 2, i + 5) === "8;;") {
const st = out.indexOf(`${ESC}\\`, i + 5);
expect(st).toBeGreaterThanOrEqual(0);
i = st + 1;
continue;
}
throw new Error(`Unexpected escape sequence at index ${i}`);
}
});
});

View File

@@ -39,74 +39,130 @@ function padCell(text: string, width: number, align: Align): string {
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;
};
// ANSI-aware wrapping: never split inside ANSI SGR/OSC-8 sequences.
// We don't attempt to re-open styling per line; terminals keep SGR state
// across newlines, so as long as we don't corrupt escape sequences we're safe.
const ESC = "\u001b";
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;
type Token = { kind: "ansi" | "char"; value: string };
const tokens: Token[] = [];
for (let i = 0; i < text.length; ) {
if (text[i] === ESC) {
// SGR: ESC [ ... m
if (text[i + 1] === "[") {
let j = i + 2;
while (j < text.length) {
const ch = text[j];
if (ch === "m") break;
if (ch && ch >= "0" && ch <= "9") {
j += 1;
continue;
}
} else {
parts.push(buf);
buf = ch;
if (ch === ";") {
j += 1;
continue;
}
break;
}
if (text[j] === "m") {
tokens.push({ kind: "ansi", value: text.slice(i, j + 1) });
i = j + 1;
continue;
}
}
// OSC-8 link open/close: ESC ] 8 ; ; ... ST (ST = ESC \)
if (text[i + 1] === "]" && text.slice(i + 2, i + 5) === "8;;") {
const st = text.indexOf(`${ESC}\\`, i + 5);
if (st >= 0) {
tokens.push({ kind: "ansi", value: text.slice(i, st + 2) });
i = st + 2;
continue;
}
} else {
buf = next;
if (isBreakChar(ch)) lastBreakAt = buf.length;
}
}
if (buf) parts.push(buf);
return parts;
const cp = text.codePointAt(i);
if (!cp) break;
const ch = String.fromCodePoint(cp);
tokens.push({ kind: "char", value: ch });
i += ch.length;
}
const lines: string[] = [];
const isBreakChar = (ch: string) =>
ch === " " ||
ch === "\t" ||
ch === "\n" ||
ch === "\r" ||
ch === "/" ||
ch === "-" ||
ch === "_" ||
ch === ".";
const isSpaceChar = (ch: string) => ch === " " || ch === "\t";
const buf: Token[] = [];
let bufVisible = 0;
let lastBreakIndex: number | null = null;
const bufToString = (slice?: Token[]) =>
(slice ?? buf).map((t) => t.value).join("");
const bufVisibleWidth = (slice: Token[]) =>
slice.reduce((acc, t) => acc + (t.kind === "char" ? 1 : 0), 0);
const pushLine = (value: string) => {
const cleaned = value.replace(/\s+$/, "");
if (cleaned.trim().length === 0) return;
lines.push(cleaned);
};
for (const token of words) {
const tokenWidth = visibleWidth(token);
const isSpace = /^\s+$/.test(token);
const flushAt = (breakAt: number | null) => {
if (buf.length === 0) return;
if (breakAt == null || breakAt <= 0) {
pushLine(bufToString());
buf.length = 0;
bufVisible = 0;
lastBreakIndex = null;
return;
}
if (tokenWidth > width && !isSpace) {
flush();
for (const part of breakLong(token.replace(/^\s+/, ""))) {
push(part);
}
const left = buf.slice(0, breakAt);
const rest = buf.slice(breakAt);
pushLine(bufToString(left));
while (
rest.length > 0 &&
rest[0]?.kind === "char" &&
isSpaceChar(rest[0].value)
) {
rest.shift();
}
buf.length = 0;
buf.push(...rest);
bufVisible = bufVisibleWidth(buf);
lastBreakIndex = null;
};
for (const token of tokens) {
if (token.kind === "ansi") {
buf.push(token);
continue;
}
if (
currentWidth + tokenWidth > width &&
current.trim().length > 0 &&
!isSpace
) {
flush();
const ch = token.value;
if (bufVisible + 1 > width && bufVisible > 0) {
flushAt(lastBreakIndex);
}
current += token;
currentWidth = visibleWidth(current);
buf.push(token);
bufVisible += 1;
if (isBreakChar(ch)) lastBreakIndex = buf.length;
}
flush();
flushAt(buf.length);
return lines.length ? lines : [""];
}