premanmcp 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/status.js ADDED
@@ -0,0 +1,210 @@
1
+ /**
2
+ * `preman status` — endpoint health in the terminal.
3
+ *
4
+ * One request to GET /cli/status, rendered as a table. The backend degrades
5
+ * individual sections rather than failing, so this renderer must treat every
6
+ * section as possibly empty.
7
+ */
8
+
9
+ import { backendUrl, callBackendJson, cliInvocation, makeArgs, resolveApiKey } from "./shared.js";
10
+
11
+ export const STATUS_HELP = `
12
+ Status options:
13
+ --workspace <id> Workspace to report on. Defaults to your primary workspace
14
+ --json Print the raw JSON payload instead of a table
15
+ --no-color Disable ANSI colour
16
+ `;
17
+
18
+ // Honour the conventions people already expect from CLIs, so status can be piped
19
+ // into a file or a CI log without escape codes landing in it.
20
+ function makePaint(enabled) {
21
+ const wrap = (code) => (text) => (enabled ? `\u001b[${code}m${text}\u001b[0m` : String(text));
22
+ return {
23
+ green: wrap("32"),
24
+ red: wrap("31"),
25
+ yellow: wrap("33"),
26
+ blue: wrap("36"),
27
+ dim: wrap("2"),
28
+ bold: wrap("1"),
29
+ };
30
+ }
31
+
32
+ function colourEnabled(args) {
33
+ if (args.has("--no-color") || args.has("--no-colour")) return false;
34
+ if (process.env.NO_COLOR) return false;
35
+ if (process.env.FORCE_COLOR) return true;
36
+ return Boolean(process.stdout.isTTY);
37
+ }
38
+
39
+ function relativeTime(iso) {
40
+ if (!iso) return "";
41
+ const then = Date.parse(iso);
42
+ if (Number.isNaN(then)) return "";
43
+ const seconds = Math.max(0, Math.round((Date.now() - then) / 1000));
44
+ if (seconds < 60) return `${seconds}s ago`;
45
+ if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`;
46
+ if (seconds < 86400) return `${Math.round(seconds / 3600)}h ago`;
47
+ return `${Math.round(seconds / 86400)}d ago`;
48
+ }
49
+
50
+ function truncate(value, width) {
51
+ const text = String(value ?? "");
52
+ return text.length <= width ? text : `${text.slice(0, width - 1)}…`;
53
+ }
54
+
55
+ const MAX_TARGET_WIDTH = 46;
56
+
57
+ /** Column widths are measured across every row we will print, so the failing and
58
+ * healthy blocks line up with each other rather than each aligning internally. */
59
+ function endpointColumns(groups) {
60
+ const all = groups.flat();
61
+ return {
62
+ method: Math.max(3, ...all.map((e) => String(e.method || "GET").length)),
63
+ target: Math.min(
64
+ MAX_TARGET_WIDTH,
65
+ Math.max(8, ...all.map((e) => String(e.url || e.name || "").length))
66
+ ),
67
+ };
68
+ }
69
+
70
+ function renderEndpointRows(entries, { symbol, paint, colour, columns }) {
71
+ return entries.map((entry) => {
72
+ const method = String(entry.method || "GET").padEnd(columns.method);
73
+ const target = truncate(entry.url || entry.name || "", columns.target).padEnd(columns.target);
74
+ const code = entry.status_code ? String(entry.status_code) : "";
75
+ const trailing = [code, entry.error ? truncate(entry.error, 38) : "", relativeTime(entry.last_run_at)]
76
+ .filter(Boolean)
77
+ .join(" ");
78
+ return ` ${colour(symbol)} ${paint.dim(method)} ${target} ${paint.dim(trailing)}`.trimEnd();
79
+ });
80
+ }
81
+
82
+ export function renderStatus(payload, { colour = false } = {}) {
83
+ const paint = makePaint(colour);
84
+ const lines = [];
85
+
86
+ const workspace = payload.workspace || {};
87
+ lines.push(`${paint.bold("PreMan")} ${paint.dim("·")} ${workspace.name || "workspace"}`);
88
+ lines.push("");
89
+
90
+ const endpoints = payload.endpoints || {};
91
+ const healthy = Number(endpoints.healthy || 0);
92
+ const failing = Number(endpoints.failing || 0);
93
+ const untested = Number(endpoints.untested || 0);
94
+ const total = Number(endpoints.total || 0);
95
+
96
+ if (total === 0) {
97
+ lines.push(paint.dim("No saved endpoints yet."));
98
+ lines.push(paint.dim(`Run \`${cliInvocation()} endpoints discover\` to scan this repo.`));
99
+ } else {
100
+ const summary = [
101
+ failing ? paint.red(`${failing} failing`) : null,
102
+ healthy ? paint.green(`${healthy} healthy`) : null,
103
+ untested ? paint.yellow(`${untested} untested`) : null,
104
+ ]
105
+ .filter(Boolean)
106
+ .join(paint.dim(" · "));
107
+ lines.push(`${paint.bold("Endpoints")} ${summary} ${paint.dim(`(${total} total)`)}`);
108
+
109
+ const failingExamples = endpoints.failing_examples || [];
110
+ const healthyExamples = endpoints.healthy_examples || [];
111
+ const columns = endpointColumns([failingExamples, healthyExamples]);
112
+ if (failingExamples.length) {
113
+ lines.push("");
114
+ lines.push(
115
+ ...renderEndpointRows(failingExamples, { symbol: "✗", paint, colour: paint.red, columns })
116
+ );
117
+ }
118
+ if (healthyExamples.length) {
119
+ lines.push("");
120
+ lines.push(
121
+ ...renderEndpointRows(healthyExamples, { symbol: "✓", paint, colour: paint.green, columns })
122
+ );
123
+ }
124
+ }
125
+
126
+ const fixed = payload.recently_fixed || [];
127
+ if (fixed.length) {
128
+ lines.push("");
129
+ lines.push(`${paint.bold("Recently fixed")} ${paint.dim("(7d)")}`);
130
+ for (const entry of fixed) {
131
+ const label = truncate(entry.title || entry.source_kind || "fix", 40).padEnd(40);
132
+ const pr = entry.pr_url ? paint.blue(entry.pr_url) : paint.dim("no PR recorded");
133
+ lines.push(` ${paint.green("✓")} ${label} ${pr} ${paint.dim(relativeTime(entry.resolved_at))}`);
134
+ }
135
+ }
136
+
137
+ const push = payload.last_push;
138
+ lines.push("");
139
+ if (push) {
140
+ const verdict = push.verdict || push.status || "unknown";
141
+ const paintVerdict =
142
+ verdict === "impact_detected" ? paint.red : verdict === "no_impact" ? paint.green : paint.yellow;
143
+ lines.push(
144
+ `${paint.bold("Last push")} ${push.repo || ""} ${paint.dim(`${push.branch || ""}@${push.commit || ""}`)} ` +
145
+ `${paintVerdict(verdict)} ${paint.dim(relativeTime(push.finished_at))}`
146
+ );
147
+ } else {
148
+ lines.push(`${paint.bold("Last push")} ${paint.dim("no simulations yet")}`);
149
+ }
150
+
151
+ const observed = payload.observed_prod || {};
152
+ const routes = observed.routes || [];
153
+ if (routes.length) {
154
+ lines.push("");
155
+ lines.push(`${paint.bold("Production")} ${paint.dim("(24h)")}`);
156
+ const shown = routes.slice(0, 5);
157
+ const columns = endpointColumns([shown.map((r) => ({ method: r.method, url: r.route }))]);
158
+ const callsWidth = Math.max(...shown.map((r) => `${r.observations || 0} calls`.length));
159
+ for (const route of shown) {
160
+ const method = String(route.method || "GET").padEnd(columns.method);
161
+ const target = truncate(route.route, columns.target).padEnd(columns.target);
162
+ const errors = Number(route.errors || 0);
163
+ const errText = errors ? paint.red(`${errors} errors`) : paint.dim("clean");
164
+ const calls = `${route.observations || 0} calls`.padStart(callsWidth);
165
+ lines.push(` ${paint.dim(method)} ${target} ${paint.dim(calls)} ${errText}`);
166
+ }
167
+ } else if (observed.hint) {
168
+ lines.push("");
169
+ lines.push(`${paint.bold("Production")} ${paint.dim(observed.hint)}`);
170
+ }
171
+
172
+ const integrations = payload.integrations || {};
173
+ const mark = (section) => (section && section.connected ? paint.green("✓") : paint.dim("–"));
174
+ lines.push("");
175
+ lines.push(
176
+ `${paint.bold("Integrations")} github ${mark(integrations.github)} ` +
177
+ `logs ${mark(integrations.logs)} slack ${mark(integrations.slack)}`
178
+ );
179
+
180
+ return `${lines.join("\n")}\n`;
181
+ }
182
+
183
+ export async function statusCommand(commandArgs = []) {
184
+ const args = makeArgs(commandArgs);
185
+ const token = resolveApiKey(args);
186
+ if (!token) {
187
+ throw new Error(
188
+ `no PreMan API key found. Run \`${cliInvocation()} login\` first, or pass --api-key.`
189
+ );
190
+ }
191
+
192
+ const result = await callBackendJson(args, "GET", "/cli/status", {
193
+ token,
194
+ headers: { "x-workspace-id": args.value("--workspace", "") },
195
+ });
196
+
197
+ if (!result.ok) {
198
+ const detail = result.detail || result.raw || "request failed";
199
+ throw new Error(`could not read status from ${backendUrl(args)}: ${result.status_code} ${detail}`);
200
+ }
201
+
202
+ const { status_code, ok, ...payload } = result;
203
+ if (args.has("--json")) {
204
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
205
+ return payload;
206
+ }
207
+
208
+ process.stdout.write(renderStatus(payload, { colour: colourEnabled(args) }));
209
+ return payload;
210
+ }