premanmcp 0.7.1 → 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/account.js +223 -0
- package/bin/changed.js +175 -0
- package/bin/cli.js +32 -2
- package/bin/connect.js +19 -6
- package/bin/desktop.js +214 -0
- package/bin/detect.js +378 -0
- package/bin/hook.js +165 -0
- package/bin/progress.js +110 -0
- package/bin/shared.js +23 -6
- package/bin/status.js +210 -0
- package/bin/verify.js +701 -0
- package/package.json +4 -2
package/bin/hook.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `preman hook install|uninstall|status` — the git pre-push entry point.
|
|
3
|
+
*
|
|
4
|
+
* The single most important property here: **PreMan must never block a push.**
|
|
5
|
+
* No backend, no credentials, no detectable local target, a crash, or a timeout
|
|
6
|
+
* all exit 0 with a notice. A tool that stops `git push` when its own
|
|
7
|
+
* infrastructure is down gets uninstalled the same day, so the hook is advisory
|
|
8
|
+
* by construction rather than by configuration.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
|
|
15
|
+
import { cliInvocation, makeArgs } from "./shared.js";
|
|
16
|
+
import { BLOCK_EXIT_CODE } from "./verify.js";
|
|
17
|
+
|
|
18
|
+
export const HOOK_HELP = `
|
|
19
|
+
Hook options:
|
|
20
|
+
install Write the pre-push hook into this repository
|
|
21
|
+
uninstall Remove PreMan's pre-push hook
|
|
22
|
+
status Report whether the hook is installed
|
|
23
|
+
--force Overwrite a foreign pre-push hook (a backup is kept)
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
const MARKER = "# >>> preman pre-push >>>";
|
|
27
|
+
const END_MARKER = "# <<< preman pre-push <<<";
|
|
28
|
+
const HOOK_TIMEOUT_SECONDS = 120;
|
|
29
|
+
|
|
30
|
+
function gitDir() {
|
|
31
|
+
const result = spawnSync("git", ["rev-parse", "--git-dir"], { encoding: "utf8" });
|
|
32
|
+
if (result.status !== 0) {
|
|
33
|
+
throw new Error("not inside a git repository");
|
|
34
|
+
}
|
|
35
|
+
return path.resolve(result.stdout.trim());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hookPath() {
|
|
39
|
+
return path.join(gitDir(), "hooks", "pre-push");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hookBody() {
|
|
43
|
+
// `exec` is deliberately absent: we want the wrapper to survive the CLI exiting
|
|
44
|
+
// non-zero and still exit 0 itself.
|
|
45
|
+
//
|
|
46
|
+
// Git's ref payload arrives on this script's stdin and is what tells PreMan
|
|
47
|
+
// which commits are actually being pushed, so it is piped straight through.
|
|
48
|
+
return `#!/bin/sh
|
|
49
|
+
${MARKER}
|
|
50
|
+
# Managed by PreMan. Runs the local endpoint suite before the push leaves.
|
|
51
|
+
# Advisory unless this repository opted into blocking: only exit code
|
|
52
|
+
# ${BLOCK_EXIT_CODE} stops a push, so a crash or a timeout still lets it through.
|
|
53
|
+
if [ -z "\${PREMAN_SKIP_HOOK}" ]; then
|
|
54
|
+
PREMAN_HOOK=1 ${cliInvocation()} verify --pre-push --timeout ${HOOK_TIMEOUT_SECONDS}
|
|
55
|
+
preman_status=$?
|
|
56
|
+
if [ "$preman_status" -eq ${BLOCK_EXIT_CODE} ]; then
|
|
57
|
+
exit ${BLOCK_EXIT_CODE}
|
|
58
|
+
fi
|
|
59
|
+
if [ "$preman_status" -ne 0 ]; then
|
|
60
|
+
printf '[preman] checks skipped (%s)\\n' "advisory" >&2
|
|
61
|
+
fi
|
|
62
|
+
fi
|
|
63
|
+
${END_MARKER}
|
|
64
|
+
exit 0
|
|
65
|
+
`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isOurHook(text) {
|
|
69
|
+
return text.includes(MARKER);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function installHook(args) {
|
|
73
|
+
const target = hookPath();
|
|
74
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
75
|
+
|
|
76
|
+
if (existsSync(target)) {
|
|
77
|
+
const existing = readFileSync(target, "utf8");
|
|
78
|
+
if (isOurHook(existing)) {
|
|
79
|
+
writeFileSync(target, hookBody(), { mode: 0o755 });
|
|
80
|
+
chmodSync(target, 0o755);
|
|
81
|
+
return { path: target, action: "updated" };
|
|
82
|
+
}
|
|
83
|
+
if (!args.has("--force")) {
|
|
84
|
+
return {
|
|
85
|
+
path: target,
|
|
86
|
+
action: "conflict",
|
|
87
|
+
detail: "a pre-push hook already exists; re-run with --force to replace it",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const backup = `${target}.preman-backup`;
|
|
91
|
+
writeFileSync(backup, existing, { mode: 0o755 });
|
|
92
|
+
writeFileSync(target, hookBody(), { mode: 0o755 });
|
|
93
|
+
chmodSync(target, 0o755);
|
|
94
|
+
return { path: target, action: "replaced", detail: `previous hook saved to ${backup}` };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
writeFileSync(target, hookBody(), { mode: 0o755 });
|
|
98
|
+
chmodSync(target, 0o755);
|
|
99
|
+
return { path: target, action: "installed" };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function uninstallHook() {
|
|
103
|
+
const target = hookPath();
|
|
104
|
+
if (!existsSync(target)) return { path: target, action: "absent" };
|
|
105
|
+
const existing = readFileSync(target, "utf8");
|
|
106
|
+
if (!isOurHook(existing)) {
|
|
107
|
+
return { path: target, action: "foreign", detail: "left in place; PreMan did not write it" };
|
|
108
|
+
}
|
|
109
|
+
unlinkSync(target);
|
|
110
|
+
const backup = `${target}.preman-backup`;
|
|
111
|
+
if (existsSync(backup)) {
|
|
112
|
+
writeFileSync(target, readFileSync(backup, "utf8"), { mode: 0o755 });
|
|
113
|
+
unlinkSync(backup);
|
|
114
|
+
return { path: target, action: "restored", detail: "previous hook restored from backup" };
|
|
115
|
+
}
|
|
116
|
+
return { path: target, action: "removed" };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function hookStatus() {
|
|
120
|
+
const target = hookPath();
|
|
121
|
+
if (!existsSync(target)) return { path: target, state: "absent" };
|
|
122
|
+
const existing = readFileSync(target, "utf8");
|
|
123
|
+
return { path: target, state: isOurHook(existing) ? "installed" : "foreign" };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function hookCommand(commandArgs = []) {
|
|
127
|
+
const sub = commandArgs.find((value) => !value.startsWith("-")) || "status";
|
|
128
|
+
const args = makeArgs(commandArgs);
|
|
129
|
+
|
|
130
|
+
if (sub === "install") {
|
|
131
|
+
const result = installHook(args);
|
|
132
|
+
if (result.action === "conflict") {
|
|
133
|
+
process.stdout.write(`Not installed: ${result.detail}\n ${result.path}\n`);
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
process.stdout.write(
|
|
137
|
+
`Pre-push hook ${result.action}: ${result.path}\n` +
|
|
138
|
+
(result.detail ? ` ${result.detail}\n` : "") +
|
|
139
|
+
`\nPreMan will now check affected endpoints before each push.\n` +
|
|
140
|
+
`It never blocks a push -- set PREMAN_SKIP_HOOK=1 to silence it entirely.\n`
|
|
141
|
+
);
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (sub === "uninstall") {
|
|
146
|
+
const result = uninstallHook();
|
|
147
|
+
process.stdout.write(
|
|
148
|
+
`Pre-push hook ${result.action}: ${result.path}\n` + (result.detail ? ` ${result.detail}\n` : "")
|
|
149
|
+
);
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (sub === "status") {
|
|
154
|
+
const result = hookStatus();
|
|
155
|
+
const label = {
|
|
156
|
+
installed: "installed (PreMan)",
|
|
157
|
+
foreign: "present, but not written by PreMan",
|
|
158
|
+
absent: "not installed",
|
|
159
|
+
}[result.state];
|
|
160
|
+
process.stdout.write(`Pre-push hook: ${label}\n ${result.path}\n`);
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
throw new Error(`unknown hook subcommand: ${sub}${HOOK_HELP}`);
|
|
165
|
+
}
|
package/bin/progress.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live terminal rendering for concurrent work.
|
|
3
|
+
*
|
|
4
|
+
* A pre-push run is the moment the developer is watching, so silence for the
|
|
5
|
+
* length of a 120-second budget reads as a hang. This renders one line per
|
|
6
|
+
* worker, updated in place, with finished results scrolling above them.
|
|
7
|
+
*
|
|
8
|
+
* Everything degrades to plain sequential lines when stdout is not a TTY. A
|
|
9
|
+
* hook's output is routinely piped, captured by a GUI git client, or read in
|
|
10
|
+
* CI, and cursor-movement escapes in a log file are worse than no animation.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const FRAMES = ["\u280b", "\u2819", "\u2839", "\u2838", "\u283c", "\u2834", "\u2826", "\u2827", "\u2807", "\u280f"];
|
|
14
|
+
const FRAME_MS = 80;
|
|
15
|
+
|
|
16
|
+
const CLEAR_LINE = "\u001b[2K";
|
|
17
|
+
const HIDE_CURSOR = "\u001b[?25l";
|
|
18
|
+
const SHOW_CURSOR = "\u001b[?25h";
|
|
19
|
+
|
|
20
|
+
const GREEN = "\u001b[32m";
|
|
21
|
+
const RED = "\u001b[31m";
|
|
22
|
+
const YELLOW = "\u001b[33m";
|
|
23
|
+
const DIM = "\u001b[2m";
|
|
24
|
+
const RESET = "\u001b[0m";
|
|
25
|
+
|
|
26
|
+
function truncate(text, width) {
|
|
27
|
+
const value = String(text ?? "");
|
|
28
|
+
return value.length <= width ? value : `${value.slice(0, Math.max(0, width - 1))}\u2026`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createReporter({ stream = process.stdout, live } = {}) {
|
|
32
|
+
const isLive = live ?? Boolean(stream.isTTY);
|
|
33
|
+
const slots = new Map();
|
|
34
|
+
let rendered = 0;
|
|
35
|
+
let frame = 0;
|
|
36
|
+
let timer = null;
|
|
37
|
+
const colour = (code, text) => (isLive ? `${code}${text}${RESET}` : text);
|
|
38
|
+
|
|
39
|
+
function width() {
|
|
40
|
+
return Math.max(40, Math.min(stream.columns || 100, 140));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function clearLive() {
|
|
44
|
+
if (!isLive || rendered === 0) return;
|
|
45
|
+
stream.write(`\u001b[${rendered}A`);
|
|
46
|
+
for (let i = 0; i < rendered; i += 1) stream.write(`${CLEAR_LINE}\n`);
|
|
47
|
+
stream.write(`\u001b[${rendered}A`);
|
|
48
|
+
rendered = 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function drawLive() {
|
|
52
|
+
if (!isLive || slots.size === 0) return;
|
|
53
|
+
const spin = FRAMES[frame % FRAMES.length];
|
|
54
|
+
for (const label of slots.values()) {
|
|
55
|
+
stream.write(`${CLEAR_LINE} ${colour(YELLOW, spin)} ${truncate(label, width() - 6)}\n`);
|
|
56
|
+
}
|
|
57
|
+
rendered = slots.size;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function repaint() {
|
|
61
|
+
clearLive();
|
|
62
|
+
drawLive();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
isLive,
|
|
67
|
+
|
|
68
|
+
/** Print a line that stays in the scrollback, above the live region. */
|
|
69
|
+
line(text) {
|
|
70
|
+
clearLive();
|
|
71
|
+
stream.write(`${text}\n`);
|
|
72
|
+
drawLive();
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
/** Claim a worker line. `slot` is any stable key for that worker. */
|
|
76
|
+
busy(slot, label) {
|
|
77
|
+
slots.set(slot, label);
|
|
78
|
+
if (!isLive) return;
|
|
79
|
+
if (timer === null) {
|
|
80
|
+
stream.write(HIDE_CURSOR);
|
|
81
|
+
timer = setInterval(() => {
|
|
82
|
+
frame += 1;
|
|
83
|
+
repaint();
|
|
84
|
+
}, FRAME_MS);
|
|
85
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
86
|
+
}
|
|
87
|
+
repaint();
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
/** Release a worker line and record its outcome in the scrollback. */
|
|
91
|
+
done(slot, { ok, skipped, label, detail }) {
|
|
92
|
+
slots.delete(slot);
|
|
93
|
+
const mark = skipped ? colour(DIM, "\u00b7") : ok ? colour(GREEN, "\u2713") : colour(RED, "\u2717");
|
|
94
|
+
const tail = detail ? ` ${colour(DIM, detail)}` : "";
|
|
95
|
+
clearLive();
|
|
96
|
+
stream.write(` ${mark} ${truncate(label, width() - 12)}${tail}\n`);
|
|
97
|
+
drawLive();
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
stop() {
|
|
101
|
+
if (timer !== null) {
|
|
102
|
+
clearInterval(timer);
|
|
103
|
+
timer = null;
|
|
104
|
+
}
|
|
105
|
+
clearLive();
|
|
106
|
+
slots.clear();
|
|
107
|
+
if (isLive) stream.write(SHOW_CURSOR);
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
package/bin/shared.js
CHANGED
|
@@ -171,7 +171,12 @@ export async function promptPasswordTwice() {
|
|
|
171
171
|
return password;
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
export async function callBackendJson(
|
|
174
|
+
export async function callBackendJson(
|
|
175
|
+
args,
|
|
176
|
+
method,
|
|
177
|
+
routePath,
|
|
178
|
+
{ json, token, query, headers: extraHeaders } = {}
|
|
179
|
+
) {
|
|
175
180
|
const url = new URL(routePath.replace(/^\/+/, ""), `${backendUrl(args)}/`);
|
|
176
181
|
if (query) {
|
|
177
182
|
for (const [key, value] of Object.entries(query)) {
|
|
@@ -183,6 +188,11 @@ export async function callBackendJson(args, method, routePath, { json, token, qu
|
|
|
183
188
|
const hasBody = json !== undefined && json !== null;
|
|
184
189
|
if (hasBody) headers["Content-Type"] = "application/json";
|
|
185
190
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
191
|
+
if (extraHeaders) {
|
|
192
|
+
for (const [key, value] of Object.entries(extraHeaders)) {
|
|
193
|
+
if (value != null && value !== "") headers[key] = String(value);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
186
196
|
|
|
187
197
|
const resp = await fetch(url, {
|
|
188
198
|
method,
|
|
@@ -360,6 +370,16 @@ export function resolveApiKey(args) {
|
|
|
360
370
|
* Matches the backend's install snippets (`build_install_snippets`) so the
|
|
361
371
|
* copy-paste path and this writer cannot drift.
|
|
362
372
|
*/
|
|
373
|
+
// npm exec rather than npx: same resolution, but it is the command every install
|
|
374
|
+
// of npm ships, and it is what the rest of our instructions use. The trailing "--"
|
|
375
|
+
// stops npm from claiming flags meant for the server.
|
|
376
|
+
//
|
|
377
|
+
// Exported so the post-write verifier checks for the launcher we actually emit.
|
|
378
|
+
// These drifted once already -- every writer emitted "npm" while the verifier
|
|
379
|
+
// demanded "npx", so every connect printed a mismatch warning.
|
|
380
|
+
export const LAUNCHER_COMMAND = "npm";
|
|
381
|
+
export const LAUNCHER_ARGS = ["exec", "-y", "premanmcp@latest", "--"];
|
|
382
|
+
|
|
363
383
|
export function buildServerConfig(args, { pairCode = "" } = {}) {
|
|
364
384
|
const env = {
|
|
365
385
|
PREMAN_BACKEND: backendUrl(args),
|
|
@@ -370,11 +390,8 @@ export function buildServerConfig(args, { pairCode = "" } = {}) {
|
|
|
370
390
|
if (pairCode) env.PREMAN_PAIR_CODE = pairCode;
|
|
371
391
|
|
|
372
392
|
return {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
// The trailing "--" stops npm from claiming flags meant for the server.
|
|
376
|
-
command: "npm",
|
|
377
|
-
args: ["exec", "-y", "premanmcp@latest", "--"],
|
|
393
|
+
command: LAUNCHER_COMMAND,
|
|
394
|
+
args: [...LAUNCHER_ARGS],
|
|
378
395
|
env,
|
|
379
396
|
};
|
|
380
397
|
}
|
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
|
+
}
|