feat(status): improve status output
This commit is contained in:
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user