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/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
+ }
@@ -224,8 +224,13 @@ export async function awsCommand(args) {
224
224
  export async function githubCommand(args) {
225
225
  const token = requireKey(args);
226
226
 
227
- const before = await callBackendJson(args, "GET", "/integrations/github", { token });
228
- const seen = new Set((before.integrations || before.repos || []).map((r) => r.id));
227
+ // This route answers with a bare array; callBackendJson exposes it as `list`.
228
+ const listRepos = async () => {
229
+ const resp = await callBackendJson(args, "GET", "/integrations/github", { token });
230
+ return resp.list || resp.integrations || resp.repos || [];
231
+ };
232
+
233
+ const seen = new Set((await listRepos()).map((r) => r.id));
229
234
 
230
235
  const started = await callBackendJson(args, "POST", "/integrations/github/app/install", {
231
236
  token,
@@ -239,11 +244,17 @@ export async function githubCommand(args) {
239
244
  process.stdout.write("Pick the repositories PreMan may read.\n");
240
245
 
241
246
  const done = await waitFor("the installation", async () => {
242
- const now = await callBackendJson(args, "GET", "/integrations/github", { token });
243
- const rows = now.integrations || now.repos || [];
247
+ // Installing the App and having repositories appear are two events: the
248
+ // callback records the installation, and a refresh materialises the repos.
249
+ // Polling the repo list alone waits for something that may never arrive on
250
+ // its own.
251
+ await callBackendJson(args, "POST", "/integrations/github/app/refresh", {
252
+ token,
253
+ json: {},
254
+ });
244
255
  // Compare against what existed before, so a user who already had repos
245
256
  // connected is not told they are done the moment polling starts.
246
- const fresh = rows.filter((r) => !seen.has(r.id));
257
+ const fresh = (await listRepos()).filter((r) => !seen.has(r.id));
247
258
  return fresh.length ? fresh : null;
248
259
  });
249
260
 
@@ -289,10 +300,14 @@ export async function slackCommand(args) {
289
300
  // The guided run
290
301
  // ---------------------------------------------------------------------------
291
302
 
292
- async function askYes(question, { assumeYes }) {
293
- if (assumeYes) return true;
294
- const answer = (await promptText(`${question} [Y/n]: `)).trim().toLowerCase();
295
- return answer === "" || answer === "y" || answer === "yes";
303
+ /** "yes" | "no" | "back" -- back only offered once there is somewhere to go. */
304
+ async function askStep(question, { assumeYes, canGoBack }) {
305
+ if (assumeYes) return "yes";
306
+ const hint = canGoBack ? "[Y/n/b]" : "[Y/n]";
307
+ const answer = (await promptText(`${question} ${hint}: `)).trim().toLowerCase();
308
+ if (canGoBack && (answer === "b" || answer === "back")) return "back";
309
+ if (answer === "" || answer === "y" || answer === "yes") return "yes";
310
+ return "no";
296
311
  }
297
312
 
298
313
  /**
@@ -323,32 +338,54 @@ export async function onboardCommand(commandArgs, { makeArgs, authenticateTermin
323
338
  { name: "Slack", question: "Connect Slack?", run: () => slackCommand(args) },
324
339
  ];
325
340
 
326
- const done = [];
327
- const skipped = [];
328
- const failed = [];
329
-
330
- for (const step of steps) {
341
+ // Outcome per step rather than three lists, so revisiting a step replaces its
342
+ // result instead of recording it twice.
343
+ const outcome = new Map();
344
+
345
+ // Indexed rather than for..of so "back" can move the cursor. Re-running a
346
+ // step is safe: every one of these is idempotent on the backend -- an AWS
347
+ // grant returns the existing link, and an App install that already happened
348
+ // is detected rather than duplicated.
349
+ let i = 0;
350
+ while (i < steps.length) {
351
+ const step = steps[i];
331
352
  process.stdout.write(`\n── ${step.name} ──\n`);
332
- if (!(await askYes(step.question, { assumeYes }))) {
333
- skipped.push(step.name);
353
+
354
+ const choice = await askStep(step.question, { assumeYes, canGoBack: i > 0 });
355
+ if (choice === "back") {
356
+ // Drop the result we are about to redo, or the summary would report the
357
+ // stale outcome of a step the customer chose to revisit.
358
+ outcome.delete(steps[i - 1].name);
359
+ i -= 1;
334
360
  continue;
335
361
  }
362
+ if (choice === "no") {
363
+ outcome.set(step.name, { state: "skipped" });
364
+ i += 1;
365
+ continue;
366
+ }
367
+
336
368
  try {
337
369
  await step.run();
338
- done.push(step.name);
370
+ outcome.set(step.name, { state: "done" });
339
371
  } catch (err) {
340
372
  // Report and carry on: a failed Slack install must not cost the customer
341
373
  // the AWS connection they just finished.
342
- failed.push(`${step.name}: ${err.message}`);
374
+ outcome.set(step.name, { state: "failed", detail: err.message });
343
375
  process.stdout.write(`Could not finish ${step.name}: ${err.message}\n`);
344
376
  }
377
+ i += 1;
345
378
  }
346
379
 
347
380
  process.stdout.write("\n── done ──\n");
348
381
  // One line per step, marked, so the outcome is scannable rather than prose.
349
- for (const name of done) process.stdout.write(`${MARK.ok()} ${name}\n`);
350
- for (const name of skipped) process.stdout.write(`${MARK.skip()} ${name} (skipped)\n`);
351
- for (const failure of failed) process.stdout.write(`${MARK.fail()} ${failure}\n`);
382
+ for (const step of steps) {
383
+ const result = outcome.get(step.name);
384
+ if (!result) continue;
385
+ if (result.state === "done") process.stdout.write(`${MARK.ok()} ${step.name}\n`);
386
+ else if (result.state === "skipped") process.stdout.write(`${MARK.skip()} ${step.name} (skipped)\n`);
387
+ else process.stdout.write(`${MARK.fail()} ${step.name}: ${result.detail}\n`);
388
+ }
352
389
  process.stdout.write(`\nOpen ${frontendUrl(args)} to see your logs and endpoints.\n`);
353
390
  }
354
391
 
@@ -360,6 +397,7 @@ Setup options:
360
397
  preman slack Add PreMan to a Slack workspace
361
398
 
362
399
  --yes Accept every step without prompting (onboard)
400
+ b at any onboard prompt Go back to the previous step
363
401
  --account <id> AWS account id, skips the prompt
364
402
  --region <region> AWS region for log groups. Defaults to us-east-1
365
403
  --project-id <id> PreMan project to attach the log connector to
@@ -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
@@ -7,6 +7,7 @@
7
7
  * over a module-level argv, so a command can pass its own slice.
8
8
  */
9
9
 
10
+ import { spawnSync } from "node:child_process";
10
11
  import { chmodSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
11
12
  import os from "node:os";
12
13
  import path from "node:path";
@@ -18,6 +19,28 @@ export const CREDENTIALS_DIR = path.join(os.homedir(), ".preman");
18
19
  export const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json");
19
20
 
20
21
  /** Wrap a raw argv slice in the positional lookup the CLI has always used. */
22
+ /**
23
+ * How to tell this user to invoke us.
24
+ *
25
+ * The help used to say "preman connect" unconditionally, which is only true
26
+ * after a global install. Anyone reading it straight out of
27
+ * `npm exec premanmcp -- --help` -- the way the docs tell them to run it --
28
+ * copied a command that answers "command not found".
29
+ */
30
+ let _invocation = null;
31
+
32
+ export function cliInvocation() {
33
+ if (_invocation) return _invocation;
34
+ const probe = process.platform === "win32" ? "where" : "which";
35
+ try {
36
+ const found = spawnSync(probe, ["preman"], { stdio: "pipe", encoding: "utf8" });
37
+ _invocation = found.status === 0 && found.stdout.trim() ? "preman" : null;
38
+ } catch {
39
+ _invocation = null;
40
+ }
41
+ return (_invocation ||= "npm exec -y premanmcp@latest --");
42
+ }
43
+
21
44
  export function makeArgs(commandArgs = []) {
22
45
  return {
23
46
  raw: commandArgs,
@@ -148,7 +171,12 @@ export async function promptPasswordTwice() {
148
171
  return password;
149
172
  }
150
173
 
151
- 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
+ ) {
152
180
  const url = new URL(routePath.replace(/^\/+/, ""), `${backendUrl(args)}/`);
153
181
  if (query) {
154
182
  for (const [key, value] of Object.entries(query)) {
@@ -160,6 +188,11 @@ export async function callBackendJson(args, method, routePath, { json, token, qu
160
188
  const hasBody = json !== undefined && json !== null;
161
189
  if (hasBody) headers["Content-Type"] = "application/json";
162
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
+ }
163
196
 
164
197
  const resp = await fetch(url, {
165
198
  method,
@@ -173,6 +206,12 @@ export async function callBackendJson(args, method, routePath, { json, token, qu
173
206
  } catch {
174
207
  body = { raw: text };
175
208
  }
209
+ // Some routes answer with a bare array. Spreading one into this object turns
210
+ // it into {0:…,1:…} and quietly loses the list, so it gets its own key.
211
+ if (Array.isArray(body)) {
212
+ return { status_code: resp.status, ok: resp.ok, list: body };
213
+ }
214
+
176
215
  return {
177
216
  status_code: resp.status,
178
217
  ok: resp.ok,
@@ -331,6 +370,16 @@ export function resolveApiKey(args) {
331
370
  * Matches the backend's install snippets (`build_install_snippets`) so the
332
371
  * copy-paste path and this writer cannot drift.
333
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
+
334
383
  export function buildServerConfig(args, { pairCode = "" } = {}) {
335
384
  const env = {
336
385
  PREMAN_BACKEND: backendUrl(args),
@@ -341,11 +390,8 @@ export function buildServerConfig(args, { pairCode = "" } = {}) {
341
390
  if (pairCode) env.PREMAN_PAIR_CODE = pairCode;
342
391
 
343
392
  return {
344
- // npm exec rather than npx: same resolution, but it is the command every
345
- // install of npm ships, and it is what the rest of our instructions use.
346
- // The trailing "--" stops npm from claiming flags meant for the server.
347
- command: "npm",
348
- args: ["exec", "-y", "premanmcp@latest", "--"],
393
+ command: LAUNCHER_COMMAND,
394
+ args: [...LAUNCHER_ARGS],
349
395
  env,
350
396
  };
351
397
  }