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/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
+ }
@@ -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(args, method, routePath, { json, token, query } = {}) {
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
- // npm exec rather than npx: same resolution, but it is the command every
374
- // install of npm ships, and it is what the rest of our instructions use.
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
+ }