premanmcp 0.10.4 → 0.10.6

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 CHANGED
@@ -8,24 +8,37 @@
8
8
  * by construction rather than by configuration.
9
9
  */
10
10
 
11
- import { spawnSync } from "node:child_process";
11
+ import { spawn, spawnSync } from "node:child_process";
12
12
  import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
13
+ import os from "node:os";
13
14
  import path from "node:path";
15
+ import { fileURLToPath } from "node:url";
14
16
 
15
- import { cliInvocation, makeArgs } from "./shared.js";
17
+ import { cliInvocation, makeArgs, packageVersion } from "./shared.js";
16
18
  import { BLOCK_EXIT_CODE } from "./verify.js";
17
19
 
18
20
  export const HOOK_HELP = `
19
21
  Hook options:
20
22
  install Write the pre-push hook into this repository
21
23
  uninstall Remove PreMan's pre-push hook
22
- status Report whether the hook is installed
24
+ status Report whether the hook is installed, and still works
25
+ repair Rewrite a hook of ours whose command no longer answers
23
26
  --force Overwrite a foreign pre-push hook (a backup is kept)
27
+ PREMAN_HOOK_INVOCATION=<cmd> Use this command line instead of probing for one
28
+ PREMAN_NO_HOOK_REPAIR=1 Never repair a hook in the background
24
29
  `;
25
30
 
26
31
  const MARKER = "# >>> preman pre-push >>>";
27
32
  const END_MARKER = "# <<< preman pre-push <<<";
28
33
  const HOOK_TIMEOUT_SECONDS = 120;
34
+ const PROBE_TIMEOUT_MS = 45000;
35
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
36
+ const HEALTH_FILE = path.join(os.homedir(), ".preman", "hook-health.json");
37
+ // How long a healthy answer is believed for. The repair costs a probe, and a
38
+ // hook that worked an hour ago almost always still works, so this is the knob
39
+ // that keeps a background check off the critical path of every command.
40
+ const RECHECK_MS = 60 * 60 * 1000;
41
+ const HEALTH_TTL_MS = 30 * 24 * 60 * 60 * 1000;
29
42
 
30
43
  function gitDir() {
31
44
  const result = spawnSync("git", ["rev-parse", "--git-dir"], { encoding: "utf8" });
@@ -39,7 +52,90 @@ function hookPath() {
39
52
  return path.join(gitDir(), "hooks", "pre-push");
40
53
  }
41
54
 
42
- function hookBody() {
55
+ /**
56
+ * The command lines a hook could carry, best first.
57
+ *
58
+ * A hook is written once and read at every push, so `@latest` quietly hands each
59
+ * push to whatever we ship next; the version that wrote the hook is the one that
60
+ * keeps running it, and `preman hook install` is how that moves. `@latest` is
61
+ * left only for the case where we cannot read our own manifest to know it.
62
+ *
63
+ * The bare `preman` form is offered first when it is ours — see `pathPremanOwner`
64
+ * — because it starts in milliseconds where npm exec does not.
65
+ */
66
+ export function hookInvocations() {
67
+ const version = packageVersion();
68
+ const pinned = `npm exec -y premanmcp@${version || "latest"} --`;
69
+ const preferred = cliInvocation();
70
+ return preferred.startsWith("npm exec") ? [pinned] : [preferred, pinned];
71
+ }
72
+
73
+ const probed = new Map();
74
+
75
+ /** An invocation the user pinned by hand, or "". */
76
+ function declaredInvocation() {
77
+ return String(process.env.PREMAN_HOOK_INVOCATION || "").trim();
78
+ }
79
+
80
+ /**
81
+ * Does this command line reach a PreMan CLI that knows `verify`, from a process
82
+ * that is not this one?
83
+ *
84
+ * This is the check whose absence produced `Unknown command: verify` on every
85
+ * push: the hook is generated shell run minutes or months later, so the only
86
+ * evidence that matters is a separate process answering. `help` is the cheapest
87
+ * subcommand that proves both halves — that something ran, and that it was us
88
+ * rather than another package's identically named `preman`.
89
+ *
90
+ * Memoized per command line, because a connect installs and then re-reads the
91
+ * hook, and npm exec is slow enough that paying twice shows.
92
+ */
93
+ export function invocationAnswers(invocation) {
94
+ // Declaring an invocation means "use this and stop asking", so the same answer
95
+ // has to hold when we later read it back out of a hook -- otherwise the escape
96
+ // hatch writes a hook that every status then calls broken.
97
+ if (invocation && invocation === declaredInvocation()) return true;
98
+ if (probed.has(invocation)) return probed.get(invocation);
99
+ let answered = false;
100
+ try {
101
+ const probe = spawnSync(`${invocation} help`, {
102
+ shell: true,
103
+ encoding: "utf8",
104
+ timeout: Number(process.env.PREMAN_HOOK_PROBE_MS) || PROBE_TIMEOUT_MS,
105
+ stdio: ["ignore", "pipe", "pipe"],
106
+ });
107
+ answered = probe.status === 0 && /\bverify\b/.test(String(probe.stdout || ""));
108
+ } catch {
109
+ answered = false;
110
+ }
111
+ probed.set(invocation, answered);
112
+ return answered;
113
+ }
114
+
115
+ /** Test seam: the probe above is memoized for the life of the process. */
116
+ export function resetInvocationProbe() {
117
+ probed.clear();
118
+ }
119
+
120
+ /**
121
+ * The invocation to write into a hook, or "" when nothing here can run PreMan.
122
+ *
123
+ * `PREMAN_HOOK_INVOCATION` is taken on trust: it exists for the setups we cannot
124
+ * probe our way to — a wrapper script, a monorepo runner, a pinned mirror.
125
+ */
126
+ export function provenInvocation() {
127
+ return (
128
+ declaredInvocation() || hookInvocations().find((candidate) => invocationAnswers(candidate)) || ""
129
+ );
130
+ }
131
+
132
+ /** The command line an already-written hook calls, or "". */
133
+ function embeddedInvocation(text) {
134
+ const match = /^\s*PREMAN_HOOK=1 (.+) verify --pre-push\b/m.exec(text);
135
+ return match ? match[1].trim() : "";
136
+ }
137
+
138
+ function hookBody(invocation) {
43
139
  // `exec` is deliberately absent: we want the wrapper to survive the CLI exiting
44
140
  // non-zero and still exit 0 itself.
45
141
  //
@@ -51,7 +147,7 @@ ${MARKER}
51
147
  # Advisory unless this repository opted into blocking: only exit code
52
148
  # ${BLOCK_EXIT_CODE} stops a push, so a crash or a timeout still lets it through.
53
149
  if [ -z "\${PREMAN_SKIP_HOOK}" ]; then
54
- PREMAN_HOOK=1 ${cliInvocation()} verify --pre-push --timeout ${HOOK_TIMEOUT_SECONDS}
150
+ PREMAN_HOOK=1 ${invocation} verify --pre-push --timeout ${HOOK_TIMEOUT_SECONDS}
55
151
  preman_status=$?
56
152
  if [ "$preman_status" -eq ${BLOCK_EXIT_CODE} ]; then
57
153
  exit ${BLOCK_EXIT_CODE}
@@ -71,16 +167,31 @@ function isOurHook(text) {
71
167
  return text.includes(MARKER);
72
168
  }
73
169
 
74
- export function installHook(args) {
170
+ export function installHook(args, { invocation = provenInvocation() } = {}) {
75
171
  const target = hookPath();
172
+
173
+ // A hook holding a command that does not run is worse than no hook: it is
174
+ // silent, it says "installed" in every status we print, and the only sign of
175
+ // it is one skipped line scrolling past a push nobody reads.
176
+ if (!invocation) {
177
+ return {
178
+ path: target,
179
+ action: "unproven",
180
+ detail:
181
+ "no way to run the PreMan CLI from a hook was found here" +
182
+ " -- install it globally (npm i -g premanmcp) or set PREMAN_HOOK_INVOCATION",
183
+ };
184
+ }
185
+
186
+ const body = hookBody(invocation);
76
187
  mkdirSync(path.dirname(target), { recursive: true });
77
188
 
78
189
  if (existsSync(target)) {
79
190
  const existing = readFileSync(target, "utf8");
80
191
  if (isOurHook(existing)) {
81
- writeFileSync(target, hookBody(), { mode: 0o755 });
192
+ writeFileSync(target, body, { mode: 0o755 });
82
193
  chmodSync(target, 0o755);
83
- return { path: target, action: "updated" };
194
+ return { path: target, action: "updated", invocation };
84
195
  }
85
196
  if (!args.has("--force")) {
86
197
  return {
@@ -91,14 +202,19 @@ export function installHook(args) {
91
202
  }
92
203
  const backup = `${target}.preman-backup`;
93
204
  writeFileSync(backup, existing, { mode: 0o755 });
94
- writeFileSync(target, hookBody(), { mode: 0o755 });
205
+ writeFileSync(target, body, { mode: 0o755 });
95
206
  chmodSync(target, 0o755);
96
- return { path: target, action: "replaced", detail: `previous hook saved to ${backup}` };
207
+ return {
208
+ path: target,
209
+ action: "replaced",
210
+ invocation,
211
+ detail: `previous hook saved to ${backup}`,
212
+ };
97
213
  }
98
214
 
99
- writeFileSync(target, hookBody(), { mode: 0o755 });
215
+ writeFileSync(target, body, { mode: 0o755 });
100
216
  chmodSync(target, 0o755);
101
- return { path: target, action: "installed" };
217
+ return { path: target, action: "installed", invocation };
102
218
  }
103
219
 
104
220
  export function uninstallHook() {
@@ -122,17 +238,124 @@ export function uninstallHook() {
122
238
  * `current` is what tells a caller an installed hook still needs rewriting.
123
239
  *
124
240
  * A hook is generated shell holding one invocation, and that invocation can go
125
- * stale — the machine gained a `preman` that is not ours, or lost the one that
126
- * was. "Installed" then means a file exists that calls the wrong thing on every
127
- * push, so anything that skips work when the hook is present has to be able to
128
- * tell the difference.
241
+ * stale — the machine gained a `preman` that is not ours, lost the one that was,
242
+ * or upgraded past the version the hook pins. "Installed" then means a file
243
+ * exists that calls the wrong thing on every push, so anything that skips work
244
+ * when the hook is present has to be able to tell the difference.
245
+ *
246
+ * `works` answers the harder question — does the command in the file still run
247
+ * PreMan — so callers ask for it: `preman hook status` and `preman doctor` do,
248
+ * connect does not. Both answers cost a probe for an installed hook, since
249
+ * "up to date" means "equal to what we would write now", and what we would write
250
+ * is whatever answers here. An absent or foreign hook costs nothing.
129
251
  */
130
- export function hookStatus() {
252
+ export function hookStatus({ probe = false } = {}) {
131
253
  const target = hookPath();
132
- if (!existsSync(target)) return { path: target, state: "absent", current: false };
254
+ const missing = { path: target, current: false, invocation: "", works: null };
255
+ if (!existsSync(target)) return { ...missing, state: "absent" };
133
256
  const existing = readFileSync(target, "utf8");
134
- if (!isOurHook(existing)) return { path: target, state: "foreign", current: false };
135
- return { path: target, state: "installed", current: existing === hookBody() };
257
+ if (!isOurHook(existing)) return { ...missing, state: "foreign" };
258
+
259
+ const invocation = embeddedInvocation(existing);
260
+ // Falling back to what the file already carries keeps a machine that can no
261
+ // longer prove any invocation -- offline, say -- from reporting a hook as out
262
+ // of date and sending its owner to a reinstall that would refuse to write.
263
+ const wanted = provenInvocation() || invocation;
264
+ return {
265
+ path: target,
266
+ state: "installed",
267
+ invocation,
268
+ current: Boolean(wanted) && existing === hookBody(wanted),
269
+ works: probe ? Boolean(invocation) && invocationAnswers(invocation) : null,
270
+ };
271
+ }
272
+
273
+ function readHookChecks() {
274
+ try {
275
+ const parsed = JSON.parse(readFileSync(HEALTH_FILE, "utf8"));
276
+ return parsed && typeof parsed === "object" ? parsed : {};
277
+ } catch {
278
+ return {};
279
+ }
280
+ }
281
+
282
+ /** Remember what a check found, so the next command does not pay for it again. */
283
+ function rememberHookCheck(cwd, now, fields) {
284
+ const checks = readHookChecks();
285
+ for (const [key, value] of Object.entries(checks)) {
286
+ if (now - Number(value?.at || 0) > HEALTH_TTL_MS) delete checks[key];
287
+ }
288
+ checks[cwd] = { at: now, ...fields };
289
+ try {
290
+ mkdirSync(path.dirname(HEALTH_FILE), { recursive: true });
291
+ writeFileSync(HEALTH_FILE, JSON.stringify(checks, null, 2) + "\n", { mode: 0o600 });
292
+ } catch {
293
+ // A cache we cannot write costs a probe next time and nothing else.
294
+ }
295
+ return { cwd, ...fields };
296
+ }
297
+
298
+ /**
299
+ * Put a hook of ours back into working order, without being asked.
300
+ *
301
+ * The failure this exists for is invisible: a hook written by a version that
302
+ * embedded the wrong command keeps printing one skipped line per push, and
303
+ * nothing re-runs `connect` after an upgrade to notice. So anything that proves
304
+ * PreMan is running here -- any command, the MCP server starting -- is taken as
305
+ * the moment to check.
306
+ *
307
+ * Deliberately narrow. It only ever rewrites a hook we wrote that no longer
308
+ * answers: an absent hook is not installed behind the user, a foreign hook is
309
+ * not touched, and a hook that works is left on whatever version it pins.
310
+ */
311
+ export function repairDeadHook({ cwd = process.cwd(), now = Date.now(), force = false } = {}) {
312
+ const remember = (fields) => rememberHookCheck(cwd, now, fields);
313
+ if (!force) {
314
+ const last = readHookChecks()[cwd];
315
+ if (last && now - Number(last.at || 0) < RECHECK_MS) return { cwd, action: "recent" };
316
+ }
317
+
318
+ let status;
319
+ try {
320
+ status = hookStatus({ probe: true });
321
+ } catch {
322
+ return remember({ action: "not-a-repository" });
323
+ }
324
+ if (status.state !== "installed") return remember({ action: status.state });
325
+ if (status.works) return remember({ action: "healthy", invocation: status.invocation });
326
+
327
+ const result = installHook(makeArgs([]));
328
+ return remember({
329
+ action: result.action === "updated" ? "repaired" : result.action,
330
+ invocation: result.invocation || "",
331
+ replaced: status.invocation,
332
+ });
333
+ }
334
+
335
+ /**
336
+ * Run the repair in a detached child, because every step of it is synchronous
337
+ * and one of them shells out to npm. Nothing that merely wants a healthy hook
338
+ * should wait on a probe, least of all the MCP server, whose stdout is a
339
+ * protocol -- hence stdio ignored rather than inherited.
340
+ */
341
+ export function scheduleHookRepair({ cwd = process.cwd(), now = Date.now() } = {}) {
342
+ if (process.env.PREMAN_HOOK) return { action: "skipped", reason: "running inside the hook" };
343
+ if (process.env.PREMAN_NO_HOOK_REPAIR) return { action: "skipped", reason: "disabled" };
344
+ const last = readHookChecks()[cwd];
345
+ if (last && now - Number(last.at || 0) < RECHECK_MS) {
346
+ return { action: "skipped", reason: "checked recently" };
347
+ }
348
+ try {
349
+ const child = spawn(process.execPath, [path.join(__dirname, "cli.js"), "hook", "repair"], {
350
+ cwd,
351
+ detached: true,
352
+ stdio: "ignore",
353
+ });
354
+ child.unref();
355
+ return { action: "spawned", pid: child.pid };
356
+ } catch {
357
+ return { action: "skipped", reason: "could not spawn" };
358
+ }
136
359
  }
137
360
 
138
361
  export async function hookCommand(commandArgs = []) {
@@ -141,19 +364,36 @@ export async function hookCommand(commandArgs = []) {
141
364
 
142
365
  if (sub === "install") {
143
366
  const result = installHook(args);
144
- if (result.action === "conflict") {
367
+ if (result.action === "conflict" || result.action === "unproven") {
145
368
  process.stdout.write(`Not installed: ${result.detail}\n ${result.path}\n`);
146
369
  return result;
147
370
  }
148
371
  process.stdout.write(
149
372
  `Pre-push hook ${result.action}: ${result.path}\n` +
150
373
  (result.detail ? ` ${result.detail}\n` : "") +
374
+ ` Runs: ${result.invocation} verify --pre-push\n` +
151
375
  `\nPreMan will now check affected endpoints before each push.\n` +
152
376
  `It never blocks a push -- set PREMAN_SKIP_HOOK=1 to silence it entirely.\n`
153
377
  );
154
378
  return result;
155
379
  }
156
380
 
381
+ if (sub === "repair") {
382
+ // Asked for by hand, "repair" means now: the hourly gate exists to keep this
383
+ // off the critical path of unrelated commands, not to refuse a person.
384
+ const result = repairDeadHook({ force: !args.has("--if-stale") });
385
+ const said = {
386
+ repaired: () => `Pre-push hook repaired: it now runs ${result.invocation}\n`,
387
+ healthy: () => "Pre-push hook already works here.\n",
388
+ recent: () => "Pre-push hook checked recently; nothing to do.\n",
389
+ absent: () => `No PreMan hook here. Install one: ${cliInvocation()} hook install\n`,
390
+ foreign: () => "The pre-push hook here is not ours; left alone.\n",
391
+ unproven: () => "Cannot repair: no way to run the PreMan CLI was found here.\n",
392
+ }[result.action];
393
+ process.stdout.write(said ? said() : `Pre-push hook: ${result.action}\n`);
394
+ return result;
395
+ }
396
+
157
397
  if (sub === "uninstall") {
158
398
  const result = uninstallHook();
159
399
  process.stdout.write(
@@ -163,13 +403,27 @@ export async function hookCommand(commandArgs = []) {
163
403
  }
164
404
 
165
405
  if (sub === "status") {
166
- const result = hookStatus();
406
+ const result = hookStatus({ probe: true });
167
407
  const label = {
168
408
  installed: "installed (PreMan)",
169
409
  foreign: "present, but not written by PreMan",
170
410
  absent: "not installed",
171
411
  }[result.state];
172
412
  process.stdout.write(`Pre-push hook: ${label}\n ${result.path}\n`);
413
+ if (result.state === "installed") {
414
+ process.stdout.write(` Runs: ${result.invocation || "(unreadable)"}\n`);
415
+ // "Installed" was never the question -- a hook that cannot reach the CLI
416
+ // prints one skipped line per push and is otherwise indistinguishable.
417
+ process.stdout.write(
418
+ result.works
419
+ ? " That command answers here.\n"
420
+ : ` That command does not answer here -- pushes are being skipped.\n` +
421
+ ` Repair it: ${cliInvocation()} hook install --force\n`
422
+ );
423
+ if (!result.current) {
424
+ process.stdout.write(` Out of date: ${cliInvocation()} hook install --force\n`);
425
+ }
426
+ }
173
427
  return result;
174
428
  }
175
429
 
@@ -26,6 +26,12 @@ import {
26
26
 
27
27
  const POLL_INTERVAL_MS = 3000;
28
28
  const POLL_TIMEOUT_MS = 300000;
29
+ // GitHub is the one install where waiting longer buys nothing. A CloudFormation
30
+ // stack genuinely takes minutes, but the App either redirects back seconds after
31
+ // the customer confirms or it never does — and five more minutes of dots turns a
32
+ // hand-off into an outage. PREMAN_GITHUB_POLL_MS shortens it, so a test of the
33
+ // hand-off does not have to spend three quarters of a minute reaching it.
34
+ const GITHUB_POLL_TIMEOUT_MS = 45000;
29
35
 
30
36
  /**
31
37
  * Colour only when someone is actually watching.
@@ -75,9 +81,13 @@ export class Unrecoverable extends Error {}
75
81
  * Returns the truthy value from ``check``, or null on timeout. Ordinary
76
82
  * exceptions are swallowed and retried; :class:`Unrecoverable` stops the wait.
77
83
  */
78
- async function waitFor(label, check, { hint = "", hintAfterMs = 60000 } = {}) {
84
+ async function waitFor(
85
+ label,
86
+ check,
87
+ { hint = "", hintAfterMs = 60000, timeoutMs = POLL_TIMEOUT_MS } = {}
88
+ ) {
79
89
  const startedAt = Date.now();
80
- const deadline = startedAt + POLL_TIMEOUT_MS;
90
+ const deadline = startedAt + timeoutMs;
81
91
  let hinted = false;
82
92
  process.stdout.write(`Waiting for ${label}`);
83
93
  while (Date.now() < deadline) {
@@ -232,6 +242,10 @@ export async function githubCommand(args) {
232
242
  present(url, "install the PreMan GitHub App");
233
243
  process.stdout.write("Pick the repositories PreMan may read.\n");
234
244
 
245
+ // The refresh answer is the diagnosis. A 409 means GitHub never called back;
246
+ // a success carrying no repositories means the App is installed and sharing
247
+ // nothing — two dead ends that are indistinguishable from the repository list.
248
+ let refresh = null;
235
249
  const done = await waitFor(
236
250
  "the installation",
237
251
  async () => {
@@ -239,7 +253,7 @@ export async function githubCommand(args) {
239
253
  // callback records the installation, and a refresh materialises the repos.
240
254
  // Polling the repo list alone waits for something that may never arrive on
241
255
  // its own.
242
- await callBackendJson(args, "POST", "/integrations/github/app/refresh", {
256
+ refresh = await callBackendJson(args, "POST", "/integrations/github/app/refresh", {
243
257
  token,
244
258
  json: {},
245
259
  });
@@ -249,24 +263,46 @@ export async function githubCommand(args) {
249
263
  return fresh.length ? fresh : null;
250
264
  },
251
265
  {
266
+ timeoutMs: Number(process.env.PREMAN_GITHUB_POLL_MS) || GITHUB_POLL_TIMEOUT_MS,
267
+ hintAfterMs: 20000,
252
268
  hint:
253
- "PreMan has not heard from GitHub yet. Finish the install in the browser tab —\n" +
254
- "pick at least one repository and confirm — and GitHub will send you back here.",
269
+ "Still nothing from GitHub. Picking at least one repository and confirming\n" +
270
+ "is what sends you back here.",
255
271
  }
256
272
  );
257
273
 
258
274
  if (!done) {
259
- process.stdout.write(
260
- `Timed out: GitHub never told PreMan about an installation.\n` +
261
- ` - Check that the App is installed: https://github.com/settings/installations\n` +
262
- ` - Then re-run '${cliInvocation()} github'.\n`
263
- );
275
+ process.stdout.write(githubHandOff(args, refresh));
264
276
  return;
265
277
  }
266
278
  connected(`GitHub connected: ${done.length} repository(ies).`);
267
279
  for (const repo of done.slice(0, 5)) process.stdout.write(` - ${repo.repo_url}\n`);
268
280
  }
269
281
 
282
+ /**
283
+ * Stop waiting, and name the dead end instead of the timeout.
284
+ *
285
+ * Holding the terminal for five minutes taught nobody anything: the install
286
+ * finishes in the browser whether this process is watching or not, and the two
287
+ * ways it can complete and still leave PreMan with nothing are both actionable.
288
+ */
289
+ function githubHandOff(args, refresh) {
290
+ const installed = Boolean(refresh?.ok) && Number(refresh.installations_refreshed || 0) > 0;
291
+ if (installed) {
292
+ return (
293
+ `The App is installed, but no repositories are shared with it.\n` +
294
+ ` - Add some: https://github.com/settings/installations\n` +
295
+ ` - Then re-run '${cliInvocation()} github'.\n`
296
+ );
297
+ }
298
+ return (
299
+ `Nothing from GitHub yet — no need to wait here.\n` +
300
+ ` - Finish the install in the browser; it records itself when you confirm.\n` +
301
+ ` - Check it: ${frontendUrl(args)} or https://github.com/settings/installations\n` +
302
+ ` - Then re-run '${cliInvocation()} github'.\n`
303
+ );
304
+ }
305
+
270
306
  // ---------------------------------------------------------------------------
271
307
  // Slack
272
308
  // ---------------------------------------------------------------------------
@@ -301,6 +337,17 @@ export async function slackCommand(args) {
301
337
  // The guided run
302
338
  // ---------------------------------------------------------------------------
303
339
 
340
+ /**
341
+ * Why a step that needs a connected agent cannot run on its own.
342
+ *
343
+ * Both of these drive the agent `connect` just linked, so skipping that step
344
+ * leaves them without one -- which is a thing to say plainly, with the command
345
+ * that does it later, rather than a stack trace about a missing id.
346
+ */
347
+ function needsAgent(command) {
348
+ return `no coding agent connected yet -- run '${cliInvocation()} connect', then '${cliInvocation()} ${command}'`;
349
+ }
350
+
304
351
  /** "yes" | "no" | "back" -- back only offered once there is somewhere to go. */
305
352
  async function askStep(question, { assumeYes, canGoBack }) {
306
353
  if (assumeYes) return "yes";
@@ -319,7 +366,10 @@ async function askStep(question, { assumeYes, canGoBack }) {
319
366
  * throws is reported and the run continues rather than unwinding the ones that
320
367
  * already worked.
321
368
  */
322
- export async function onboardCommand(commandArgs, { makeArgs, authenticateTerminal, connectCommand }) {
369
+ export async function onboardCommand(
370
+ commandArgs,
371
+ { makeArgs, authenticateTerminal, connectCommand, discoverEndpoints, runnerCommand }
372
+ ) {
323
373
  const args = makeArgs(commandArgs);
324
374
  const assumeYes = args.has("--yes");
325
375
 
@@ -328,11 +378,33 @@ export async function onboardCommand(commandArgs, { makeArgs, authenticateTermin
328
378
  const creds = await authenticateTerminal(args);
329
379
  connected(`Signed in as ${creds.user_email || "your account"}.`);
330
380
 
381
+ // Which agent the endpoints and runner steps drive. `connect` decided it, by
382
+ // detection or by asking, and this is the answer rather than a second prompt.
383
+ let linked = null;
384
+
331
385
  const steps = [
332
386
  {
333
387
  name: "coding agent",
334
388
  question: "Connect your coding agent?",
335
- run: () => connectCommand([...commandArgs, "--skip-login"]),
389
+ run: async () => {
390
+ linked = (await connectCommand([...commandArgs, "--skip-login"])) || null;
391
+ },
392
+ },
393
+ {
394
+ name: "endpoints",
395
+ question: "Map this repository's endpoints?",
396
+ run: () => {
397
+ if (!linked?.agent) throw new Error(needsAgent("endpoints discover"));
398
+ return discoverEndpoints(args, linked.agent, linked.serverName);
399
+ },
400
+ },
401
+ {
402
+ name: "runner",
403
+ question: "Let PreMan run your agent here when it finds something to fix?",
404
+ run: () => {
405
+ if (!linked?.agent) throw new Error(needsAgent("runner start --background"));
406
+ return runnerCommand(["start", "--background", "--agent", linked.agent.id]);
407
+ },
336
408
  },
337
409
  { name: "GitHub", question: "Connect GitHub?", run: () => githubCommand(args) },
338
410
  { name: "AWS logs", question: "Connect AWS?", run: () => awsCommand(args) },
@@ -392,7 +464,8 @@ export async function onboardCommand(commandArgs, { makeArgs, authenticateTermin
392
464
 
393
465
  export const INTEGRATIONS_HELP = `
394
466
  Setup options:
395
- preman onboard Sign in, then connect agent, GitHub, AWS and Slack
467
+ preman onboard Sign in, then agent, endpoints, runner, GitHub,
468
+ AWS and Slack, one prompt per step
396
469
  preman aws Connect an AWS account and stream a log group
397
470
  preman github Install the PreMan GitHub App
398
471
  preman slack Add PreMan to a Slack workspace