* CLI: add read-only SecretRef inspection * CLI: fix read-only SecretRef status regressions * CLI: preserve read-only SecretRef status fallbacks * Docs: document read-only channel inspection hook * CLI: preserve audit coverage for read-only SecretRefs * CLI: fix read-only status account selection * CLI: fix targeted gateway fallback analysis * CLI: fix Slack HTTP read-only inspection * CLI: align audit credential status checks * CLI: restore Telegram read-only fallback semantics
657 lines
21 KiB
TypeScript
657 lines
21 KiB
TypeScript
import fs from "node:fs";
|
||
import {
|
||
hasConfiguredUnavailableCredentialStatus,
|
||
hasResolvedCredentialValue,
|
||
} from "../../channels/account-snapshot-fields.js";
|
||
import {
|
||
buildChannelAccountSnapshot,
|
||
formatChannelAllowFrom,
|
||
resolveChannelAccountConfigured,
|
||
resolveChannelAccountEnabled,
|
||
} from "../../channels/account-summary.js";
|
||
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
|
||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||
import type {
|
||
ChannelAccountSnapshot,
|
||
ChannelId,
|
||
ChannelPlugin,
|
||
} from "../../channels/plugins/types.js";
|
||
import { inspectReadOnlyChannelAccount } from "../../channels/read-only-account-inspect.js";
|
||
import type { OpenClawConfig } from "../../config/config.js";
|
||
import { sha256HexPrefix } from "../../logging/redact-identifier.js";
|
||
import { formatTimeAgo } from "./format.js";
|
||
|
||
export type ChannelRow = {
|
||
id: ChannelId;
|
||
label: string;
|
||
enabled: boolean;
|
||
state: "ok" | "setup" | "warn" | "off";
|
||
detail: string;
|
||
};
|
||
|
||
type ChannelAccountRow = {
|
||
accountId: string;
|
||
account: unknown;
|
||
enabled: boolean;
|
||
configured: boolean;
|
||
snapshot: ChannelAccountSnapshot;
|
||
};
|
||
|
||
type ResolvedChannelAccountRowParams = {
|
||
plugin: ChannelPlugin;
|
||
cfg: OpenClawConfig;
|
||
sourceConfig: OpenClawConfig;
|
||
accountId: string;
|
||
};
|
||
|
||
const asRecord = (value: unknown): Record<string, unknown> =>
|
||
value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||
|
||
function summarizeSources(sources: Array<string | undefined>): {
|
||
label: string;
|
||
parts: string[];
|
||
} {
|
||
const counts = new Map<string, number>();
|
||
for (const s of sources) {
|
||
const key = s?.trim() ? s.trim() : "unknown";
|
||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||
}
|
||
const parts = [...counts.entries()]
|
||
.toSorted((a, b) => b[1] - a[1])
|
||
.map(([key, n]) => `${key}${n > 1 ? `×${n}` : ""}`);
|
||
const label = parts.length > 0 ? parts.join("+") : "unknown";
|
||
return { label, parts };
|
||
}
|
||
|
||
function existsSyncMaybe(p: string | undefined): boolean | null {
|
||
const path = p?.trim() || "";
|
||
if (!path) {
|
||
return null;
|
||
}
|
||
try {
|
||
return fs.existsSync(path);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function formatTokenHint(token: string, opts: { showSecrets: boolean }): string {
|
||
const t = token.trim();
|
||
if (!t) {
|
||
return "empty";
|
||
}
|
||
if (!opts.showSecrets) {
|
||
return `sha256:${sha256HexPrefix(t, 8)} · len ${t.length}`;
|
||
}
|
||
const head = t.slice(0, 4);
|
||
const tail = t.slice(-4);
|
||
if (t.length <= 10) {
|
||
return `${t} · len ${t.length}`;
|
||
}
|
||
return `${head}…${tail} · len ${t.length}`;
|
||
}
|
||
|
||
function inspectChannelAccount(plugin: ChannelPlugin, cfg: OpenClawConfig, accountId: string) {
|
||
return (
|
||
plugin.config.inspectAccount?.(cfg, accountId) ??
|
||
inspectReadOnlyChannelAccount({
|
||
channelId: plugin.id,
|
||
cfg,
|
||
accountId,
|
||
})
|
||
);
|
||
}
|
||
|
||
async function resolveChannelAccountRow(
|
||
params: ResolvedChannelAccountRowParams,
|
||
): Promise<ChannelAccountRow> {
|
||
const { plugin, cfg, sourceConfig, accountId } = params;
|
||
const sourceInspectedAccount = inspectChannelAccount(plugin, sourceConfig, accountId);
|
||
const resolvedInspectedAccount = inspectChannelAccount(plugin, cfg, accountId);
|
||
const resolvedInspection = resolvedInspectedAccount as {
|
||
enabled?: boolean;
|
||
configured?: boolean;
|
||
} | null;
|
||
const sourceInspection = sourceInspectedAccount as {
|
||
enabled?: boolean;
|
||
configured?: boolean;
|
||
} | null;
|
||
const resolvedAccount = resolvedInspectedAccount ?? plugin.config.resolveAccount(cfg, accountId);
|
||
const useSourceUnavailableAccount = Boolean(
|
||
sourceInspectedAccount &&
|
||
hasConfiguredUnavailableCredentialStatus(sourceInspectedAccount) &&
|
||
(!hasResolvedCredentialValue(resolvedAccount) ||
|
||
(sourceInspection?.configured === true && resolvedInspection?.configured === false)),
|
||
);
|
||
const account = useSourceUnavailableAccount ? sourceInspectedAccount : resolvedAccount;
|
||
const selectedInspection = useSourceUnavailableAccount ? sourceInspection : resolvedInspection;
|
||
const enabled =
|
||
selectedInspection?.enabled ?? resolveChannelAccountEnabled({ plugin, account, cfg });
|
||
const configured =
|
||
selectedInspection?.configured ??
|
||
(await resolveChannelAccountConfigured({
|
||
plugin,
|
||
account,
|
||
cfg,
|
||
readAccountConfiguredField: true,
|
||
}));
|
||
const snapshot = buildChannelAccountSnapshot({
|
||
plugin,
|
||
cfg,
|
||
accountId,
|
||
account,
|
||
enabled,
|
||
configured,
|
||
});
|
||
return { accountId, account, enabled, configured, snapshot };
|
||
}
|
||
|
||
const formatAccountLabel = (params: { accountId: string; name?: string }) => {
|
||
const base = params.accountId || "default";
|
||
if (params.name?.trim()) {
|
||
return `${base} (${params.name.trim()})`;
|
||
}
|
||
return base;
|
||
};
|
||
|
||
const buildAccountNotes = (params: {
|
||
plugin: ChannelPlugin;
|
||
cfg: OpenClawConfig;
|
||
entry: ChannelAccountRow;
|
||
}) => {
|
||
const { plugin, cfg, entry } = params;
|
||
const notes: string[] = [];
|
||
const snapshot = entry.snapshot;
|
||
if (snapshot.enabled === false) {
|
||
notes.push("disabled");
|
||
}
|
||
if (snapshot.dmPolicy) {
|
||
notes.push(`dm:${snapshot.dmPolicy}`);
|
||
}
|
||
if (snapshot.tokenSource && snapshot.tokenSource !== "none") {
|
||
notes.push(`token:${snapshot.tokenSource}`);
|
||
}
|
||
if (snapshot.botTokenSource && snapshot.botTokenSource !== "none") {
|
||
notes.push(`bot:${snapshot.botTokenSource}`);
|
||
}
|
||
if (snapshot.appTokenSource && snapshot.appTokenSource !== "none") {
|
||
notes.push(`app:${snapshot.appTokenSource}`);
|
||
}
|
||
if (snapshot.signingSecretSource && snapshot.signingSecretSource !== "none") {
|
||
notes.push(`signing:${snapshot.signingSecretSource}`);
|
||
}
|
||
if (hasConfiguredUnavailableCredentialStatus(entry.account)) {
|
||
notes.push("secret unavailable in this command path");
|
||
}
|
||
if (snapshot.baseUrl) {
|
||
notes.push(snapshot.baseUrl);
|
||
}
|
||
if (snapshot.port != null) {
|
||
notes.push(`port:${snapshot.port}`);
|
||
}
|
||
if (snapshot.cliPath) {
|
||
notes.push(`cli:${snapshot.cliPath}`);
|
||
}
|
||
if (snapshot.dbPath) {
|
||
notes.push(`db:${snapshot.dbPath}`);
|
||
}
|
||
|
||
const allowFrom =
|
||
plugin.config.resolveAllowFrom?.({ cfg, accountId: snapshot.accountId }) ?? snapshot.allowFrom;
|
||
if (allowFrom?.length) {
|
||
const formatted = formatChannelAllowFrom({
|
||
plugin,
|
||
cfg,
|
||
accountId: snapshot.accountId,
|
||
allowFrom,
|
||
}).slice(0, 3);
|
||
if (formatted.length > 0) {
|
||
notes.push(`allow:${formatted.join(",")}`);
|
||
}
|
||
}
|
||
|
||
return notes;
|
||
};
|
||
|
||
function resolveLinkFields(summary: unknown): {
|
||
linked: boolean | null;
|
||
authAgeMs: number | null;
|
||
selfE164: string | null;
|
||
} {
|
||
const rec = asRecord(summary);
|
||
const linked = typeof rec.linked === "boolean" ? rec.linked : null;
|
||
const authAgeMs = typeof rec.authAgeMs === "number" ? rec.authAgeMs : null;
|
||
const self = asRecord(rec.self);
|
||
const selfE164 = typeof self.e164 === "string" && self.e164.trim() ? self.e164.trim() : null;
|
||
return { linked, authAgeMs, selfE164 };
|
||
}
|
||
|
||
function collectMissingPaths(accounts: ChannelAccountRow[]): string[] {
|
||
const missing: string[] = [];
|
||
for (const entry of accounts) {
|
||
const accountRec = asRecord(entry.account);
|
||
const snapshotRec = asRecord(entry.snapshot);
|
||
for (const key of [
|
||
"tokenFile",
|
||
"botTokenFile",
|
||
"appTokenFile",
|
||
"cliPath",
|
||
"dbPath",
|
||
"authDir",
|
||
]) {
|
||
const raw =
|
||
(accountRec[key] as string | undefined) ?? (snapshotRec[key] as string | undefined);
|
||
const ok = existsSyncMaybe(raw);
|
||
if (ok === false) {
|
||
missing.push(String(raw));
|
||
}
|
||
}
|
||
}
|
||
return missing;
|
||
}
|
||
|
||
function summarizeTokenConfig(params: {
|
||
plugin: ChannelPlugin;
|
||
cfg: OpenClawConfig;
|
||
accounts: ChannelAccountRow[];
|
||
showSecrets: boolean;
|
||
}): { state: "ok" | "setup" | "warn" | null; detail: string | null } {
|
||
const enabled = params.accounts.filter((a) => a.enabled);
|
||
if (enabled.length === 0) {
|
||
return { state: null, detail: null };
|
||
}
|
||
|
||
const accountRecs = enabled.map((a) => asRecord(a.account));
|
||
const hasBotTokenField = accountRecs.some((r) => "botToken" in r);
|
||
const hasAppTokenField = accountRecs.some((r) => "appToken" in r);
|
||
const hasSigningSecretField = accountRecs.some(
|
||
(r) => "signingSecret" in r || "signingSecretSource" in r || "signingSecretStatus" in r,
|
||
);
|
||
const hasTokenField = accountRecs.some((r) => "token" in r);
|
||
|
||
if (!hasBotTokenField && !hasAppTokenField && !hasSigningSecretField && !hasTokenField) {
|
||
return { state: null, detail: null };
|
||
}
|
||
|
||
const accountIsHttpMode = (rec: Record<string, unknown>) =>
|
||
typeof rec.mode === "string" && rec.mode.trim() === "http";
|
||
const hasCredentialAvailable = (
|
||
rec: Record<string, unknown>,
|
||
valueKey: string,
|
||
statusKey: string,
|
||
) => {
|
||
const value = rec[valueKey];
|
||
if (typeof value === "string" && value.trim()) {
|
||
return true;
|
||
}
|
||
return rec[statusKey] === "available";
|
||
};
|
||
|
||
if (
|
||
hasBotTokenField &&
|
||
hasSigningSecretField &&
|
||
enabled.every((a) => accountIsHttpMode(asRecord(a.account)))
|
||
) {
|
||
const unavailable = enabled.filter((a) => hasConfiguredUnavailableCredentialStatus(a.account));
|
||
const ready = enabled.filter((a) => {
|
||
const rec = asRecord(a.account);
|
||
return (
|
||
hasCredentialAvailable(rec, "botToken", "botTokenStatus") &&
|
||
hasCredentialAvailable(rec, "signingSecret", "signingSecretStatus")
|
||
);
|
||
});
|
||
const partial = enabled.filter((a) => {
|
||
const rec = asRecord(a.account);
|
||
const hasBot = hasCredentialAvailable(rec, "botToken", "botTokenStatus");
|
||
const hasSigning = hasCredentialAvailable(rec, "signingSecret", "signingSecretStatus");
|
||
return (hasBot && !hasSigning) || (!hasBot && hasSigning);
|
||
});
|
||
|
||
if (unavailable.length > 0) {
|
||
return {
|
||
state: "warn",
|
||
detail: `configured http credentials unavailable in this command path · accounts ${unavailable.length}`,
|
||
};
|
||
}
|
||
|
||
if (partial.length > 0) {
|
||
return {
|
||
state: "warn",
|
||
detail: `partial credentials (need bot+signing) · accounts ${partial.length}`,
|
||
};
|
||
}
|
||
|
||
if (ready.length === 0) {
|
||
return { state: "setup", detail: "no credentials (need bot+signing)" };
|
||
}
|
||
|
||
const botSources = summarizeSources(ready.map((a) => a.snapshot.botTokenSource ?? "none"));
|
||
const signingSources = summarizeSources(
|
||
ready.map((a) => a.snapshot.signingSecretSource ?? "none"),
|
||
);
|
||
const sample = ready[0]?.account ? asRecord(ready[0].account) : {};
|
||
const botToken = typeof sample.botToken === "string" ? sample.botToken : "";
|
||
const signingSecret = typeof sample.signingSecret === "string" ? sample.signingSecret : "";
|
||
const botHint = botToken.trim()
|
||
? formatTokenHint(botToken, { showSecrets: params.showSecrets })
|
||
: "";
|
||
const signingHint = signingSecret.trim()
|
||
? formatTokenHint(signingSecret, { showSecrets: params.showSecrets })
|
||
: "";
|
||
const hint =
|
||
botHint || signingHint ? ` (bot ${botHint || "?"}, signing ${signingHint || "?"})` : "";
|
||
return {
|
||
state: "ok",
|
||
detail: `credentials ok (bot ${botSources.label}, signing ${signingSources.label})${hint} · accounts ${ready.length}/${enabled.length || 1}`,
|
||
};
|
||
}
|
||
|
||
if (hasBotTokenField && hasAppTokenField) {
|
||
const unavailable = enabled.filter((a) => hasConfiguredUnavailableCredentialStatus(a.account));
|
||
const ready = enabled.filter((a) => {
|
||
const rec = asRecord(a.account);
|
||
const bot = typeof rec.botToken === "string" ? rec.botToken.trim() : "";
|
||
const app = typeof rec.appToken === "string" ? rec.appToken.trim() : "";
|
||
return Boolean(bot) && Boolean(app);
|
||
});
|
||
const partial = enabled.filter((a) => {
|
||
const rec = asRecord(a.account);
|
||
const bot = typeof rec.botToken === "string" ? rec.botToken.trim() : "";
|
||
const app = typeof rec.appToken === "string" ? rec.appToken.trim() : "";
|
||
const hasBot = Boolean(bot);
|
||
const hasApp = Boolean(app);
|
||
return (hasBot && !hasApp) || (!hasBot && hasApp);
|
||
});
|
||
|
||
if (partial.length > 0) {
|
||
return {
|
||
state: "warn",
|
||
detail: `partial tokens (need bot+app) · accounts ${partial.length}`,
|
||
};
|
||
}
|
||
|
||
if (unavailable.length > 0) {
|
||
return {
|
||
state: "warn",
|
||
detail: `configured tokens unavailable in this command path · accounts ${unavailable.length}`,
|
||
};
|
||
}
|
||
|
||
if (ready.length === 0) {
|
||
return { state: "setup", detail: "no tokens (need bot+app)" };
|
||
}
|
||
|
||
const botSources = summarizeSources(ready.map((a) => a.snapshot.botTokenSource ?? "none"));
|
||
const appSources = summarizeSources(ready.map((a) => a.snapshot.appTokenSource ?? "none"));
|
||
|
||
const sample = ready[0]?.account ? asRecord(ready[0].account) : {};
|
||
const botToken = typeof sample.botToken === "string" ? sample.botToken : "";
|
||
const appToken = typeof sample.appToken === "string" ? sample.appToken : "";
|
||
const botHint = botToken.trim()
|
||
? formatTokenHint(botToken, { showSecrets: params.showSecrets })
|
||
: "";
|
||
const appHint = appToken.trim()
|
||
? formatTokenHint(appToken, { showSecrets: params.showSecrets })
|
||
: "";
|
||
|
||
const hint = botHint || appHint ? ` (bot ${botHint || "?"}, app ${appHint || "?"})` : "";
|
||
return {
|
||
state: "ok",
|
||
detail: `tokens ok (bot ${botSources.label}, app ${appSources.label})${hint} · accounts ${ready.length}/${enabled.length || 1}`,
|
||
};
|
||
}
|
||
|
||
if (hasBotTokenField) {
|
||
const unavailable = enabled.filter((a) => hasConfiguredUnavailableCredentialStatus(a.account));
|
||
const ready = enabled.filter((a) => {
|
||
const rec = asRecord(a.account);
|
||
const bot = typeof rec.botToken === "string" ? rec.botToken.trim() : "";
|
||
return Boolean(bot);
|
||
});
|
||
|
||
if (unavailable.length > 0) {
|
||
return {
|
||
state: "warn",
|
||
detail: `configured bot token unavailable in this command path · accounts ${unavailable.length}`,
|
||
};
|
||
}
|
||
|
||
if (ready.length === 0) {
|
||
return { state: "setup", detail: "no bot token" };
|
||
}
|
||
|
||
const sample = ready[0]?.account ? asRecord(ready[0].account) : {};
|
||
const botToken = typeof sample.botToken === "string" ? sample.botToken : "";
|
||
const botHint = botToken.trim()
|
||
? formatTokenHint(botToken, { showSecrets: params.showSecrets })
|
||
: "";
|
||
const hint = botHint ? ` (${botHint})` : "";
|
||
|
||
return {
|
||
state: "ok",
|
||
detail: `bot token config${hint} · accounts ${ready.length}/${enabled.length || 1}`,
|
||
};
|
||
}
|
||
|
||
const unavailable = enabled.filter((a) => hasConfiguredUnavailableCredentialStatus(a.account));
|
||
const ready = enabled.filter((a) => {
|
||
const rec = asRecord(a.account);
|
||
return typeof rec.token === "string" ? Boolean(rec.token.trim()) : false;
|
||
});
|
||
if (unavailable.length > 0) {
|
||
return {
|
||
state: "warn",
|
||
detail: `configured token unavailable in this command path · accounts ${unavailable.length}`,
|
||
};
|
||
}
|
||
if (ready.length === 0) {
|
||
return { state: "setup", detail: "no token" };
|
||
}
|
||
|
||
const sources = summarizeSources(ready.map((a) => a.snapshot.tokenSource));
|
||
const sample = ready[0]?.account ? asRecord(ready[0].account) : {};
|
||
const token = typeof sample.token === "string" ? sample.token : "";
|
||
const hint = token.trim()
|
||
? ` (${formatTokenHint(token, { showSecrets: params.showSecrets })})`
|
||
: "";
|
||
return {
|
||
state: "ok",
|
||
detail: `token ${sources.label}${hint} · accounts ${ready.length}/${enabled.length || 1}`,
|
||
};
|
||
}
|
||
|
||
// `status --all` channels table.
|
||
// Keep this generic: channel-specific rules belong in the channel plugin.
|
||
export async function buildChannelsTable(
|
||
cfg: OpenClawConfig,
|
||
opts?: { showSecrets?: boolean; sourceConfig?: OpenClawConfig },
|
||
): Promise<{
|
||
rows: ChannelRow[];
|
||
details: Array<{
|
||
title: string;
|
||
columns: string[];
|
||
rows: Array<Record<string, string>>;
|
||
}>;
|
||
}> {
|
||
const showSecrets = opts?.showSecrets === true;
|
||
const rows: ChannelRow[] = [];
|
||
const details: Array<{
|
||
title: string;
|
||
columns: string[];
|
||
rows: Array<Record<string, string>>;
|
||
}> = [];
|
||
|
||
for (const plugin of listChannelPlugins()) {
|
||
const accountIds = plugin.config.listAccountIds(cfg);
|
||
const defaultAccountId = resolveChannelDefaultAccountId({
|
||
plugin,
|
||
cfg,
|
||
accountIds,
|
||
});
|
||
const resolvedAccountIds = accountIds.length > 0 ? accountIds : [defaultAccountId];
|
||
|
||
const accounts: ChannelAccountRow[] = [];
|
||
const sourceConfig = opts?.sourceConfig ?? cfg;
|
||
for (const accountId of resolvedAccountIds) {
|
||
accounts.push(
|
||
await resolveChannelAccountRow({
|
||
plugin,
|
||
cfg,
|
||
sourceConfig,
|
||
accountId,
|
||
}),
|
||
);
|
||
}
|
||
|
||
const anyEnabled = accounts.some((a) => a.enabled);
|
||
const enabledAccounts = accounts.filter((a) => a.enabled);
|
||
const configuredAccounts = enabledAccounts.filter((a) => a.configured);
|
||
const unavailableConfiguredAccounts = enabledAccounts.filter((a) =>
|
||
hasConfiguredUnavailableCredentialStatus(a.account),
|
||
);
|
||
const defaultEntry = accounts.find((a) => a.accountId === defaultAccountId) ?? accounts[0];
|
||
|
||
const summary = plugin.status?.buildChannelSummary
|
||
? await plugin.status.buildChannelSummary({
|
||
account: defaultEntry?.account ?? {},
|
||
cfg,
|
||
defaultAccountId,
|
||
snapshot:
|
||
defaultEntry?.snapshot ?? ({ accountId: defaultAccountId } as ChannelAccountSnapshot),
|
||
})
|
||
: undefined;
|
||
|
||
const link = resolveLinkFields(summary);
|
||
const missingPaths = collectMissingPaths(enabledAccounts);
|
||
const tokenSummary = summarizeTokenConfig({
|
||
plugin,
|
||
cfg,
|
||
accounts,
|
||
showSecrets,
|
||
});
|
||
|
||
const issues = plugin.status?.collectStatusIssues
|
||
? plugin.status.collectStatusIssues(accounts.map((a) => a.snapshot))
|
||
: [];
|
||
|
||
const label = plugin.meta.label ?? plugin.id;
|
||
|
||
const state = (() => {
|
||
if (!anyEnabled) {
|
||
return "off";
|
||
}
|
||
if (missingPaths.length > 0) {
|
||
return "warn";
|
||
}
|
||
if (issues.length > 0) {
|
||
return "warn";
|
||
}
|
||
if (unavailableConfiguredAccounts.length > 0) {
|
||
return "warn";
|
||
}
|
||
if (link.linked === false) {
|
||
return "setup";
|
||
}
|
||
if (tokenSummary.state) {
|
||
return tokenSummary.state;
|
||
}
|
||
if (link.linked === true) {
|
||
return "ok";
|
||
}
|
||
if (configuredAccounts.length > 0) {
|
||
return "ok";
|
||
}
|
||
return "setup";
|
||
})();
|
||
|
||
const detail = (() => {
|
||
if (!anyEnabled) {
|
||
if (!defaultEntry) {
|
||
return "disabled";
|
||
}
|
||
return plugin.config.disabledReason?.(defaultEntry.account, cfg) ?? "disabled";
|
||
}
|
||
if (missingPaths.length > 0) {
|
||
return `missing file (${missingPaths[0]})`;
|
||
}
|
||
if (issues.length > 0) {
|
||
return issues[0]?.message ?? "misconfigured";
|
||
}
|
||
|
||
if (link.linked !== null) {
|
||
const base = link.linked ? "linked" : "not linked";
|
||
const extra: string[] = [];
|
||
if (link.linked && link.selfE164) {
|
||
extra.push(link.selfE164);
|
||
}
|
||
if (link.linked && link.authAgeMs != null && link.authAgeMs >= 0) {
|
||
extra.push(`auth ${formatTimeAgo(link.authAgeMs)}`);
|
||
}
|
||
if (accounts.length > 1 || plugin.meta.forceAccountBinding) {
|
||
extra.push(`accounts ${accounts.length || 1}`);
|
||
}
|
||
return extra.length > 0 ? `${base} · ${extra.join(" · ")}` : base;
|
||
}
|
||
|
||
if (unavailableConfiguredAccounts.length > 0) {
|
||
if (tokenSummary.detail?.includes("unavailable")) {
|
||
return tokenSummary.detail;
|
||
}
|
||
return `configured credentials unavailable in this command path · accounts ${unavailableConfiguredAccounts.length}`;
|
||
}
|
||
|
||
if (tokenSummary.detail) {
|
||
return tokenSummary.detail;
|
||
}
|
||
|
||
if (configuredAccounts.length > 0) {
|
||
const head = "configured";
|
||
if (accounts.length <= 1 && !plugin.meta.forceAccountBinding) {
|
||
return head;
|
||
}
|
||
return `${head} · accounts ${configuredAccounts.length}/${enabledAccounts.length || 1}`;
|
||
}
|
||
|
||
const reason =
|
||
defaultEntry && plugin.config.unconfiguredReason
|
||
? plugin.config.unconfiguredReason(defaultEntry.account, cfg)
|
||
: null;
|
||
return reason ?? "not configured";
|
||
})();
|
||
|
||
rows.push({
|
||
id: plugin.id,
|
||
label,
|
||
enabled: anyEnabled,
|
||
state,
|
||
detail,
|
||
});
|
||
|
||
if (configuredAccounts.length > 0) {
|
||
details.push({
|
||
title: `${label} accounts`,
|
||
columns: ["Account", "Status", "Notes"],
|
||
rows: configuredAccounts.map((entry) => {
|
||
const notes = buildAccountNotes({ plugin, cfg, entry });
|
||
return {
|
||
Account: formatAccountLabel({
|
||
accountId: entry.accountId,
|
||
name: entry.snapshot.name,
|
||
}),
|
||
Status:
|
||
entry.enabled && !hasConfiguredUnavailableCredentialStatus(entry.account)
|
||
? "OK"
|
||
: "WARN",
|
||
Notes: notes.join(" · "),
|
||
};
|
||
}),
|
||
});
|
||
}
|
||
}
|
||
|
||
return {
|
||
rows,
|
||
details,
|
||
};
|
||
}
|