premanmcp 0.10.4 → 0.10.5

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/README.md CHANGED
@@ -64,21 +64,28 @@ and then retries from your home directory, where the override cannot reach it
64
64
  first, then your agent. Agents you start in the overriding directory keep using its
65
65
  backend, which is the point of the file.
66
66
 
67
- Once linked, `connect` finishes onboarding without handing you homework:
67
+ Once linked, `connect` installs the git pre-push hook — so `git push` checks the endpoints
68
+ you touched — prints what is left, and stops. It asks nothing. Everything else it used to
69
+ run is its own command, because each one can fail on its own and none of them should hold
70
+ up a link that already worked:
68
71
 
69
- | Step | What happens |
70
- |------|--------------|
71
- | Endpoints | Runs your agent on the discovery brief, then reports `N endpoints · M runnable` |
72
- | First test | Generates and runs scenarios against the first runnable request |
73
- | Runner | Pairs this machine and starts `preman runner` in the background, so PreMan can apply fixes here instead of only describing them |
74
- | Desktop app | Offers the macOS app for watching runs and endpoints |
75
- | Integrations | Shows GitHub / AWS / Slack, opens what is missing, and picks the connection up when you finish in the browser or the desktop app |
76
- | Testing on push | Installs the git pre-push hook, so `git push` checks the endpoints you touched |
72
+ ```bash
73
+ preman endpoints discover # map this repo's endpoints
74
+ preman runner start --background # let PreMan apply fixes on this machine
75
+ preman github # or connect it in the dashboard
76
+ preman status # which of those are done
77
+ ```
78
+
79
+ `preman onboard` (or `setup`) is the prompted walk through all of it sign in, coding
80
+ agent, endpoints, runner, GitHub, AWS, Slack — one question per step, `b` to go back, and a
81
+ summary at the end. `connect --guide` runs the old full pass inside connect itself:
82
+ discovery, a first test, the runner, the desktop app and the integration prompts.
77
83
 
78
84
  Useful flags: `--agent cursor|claude-code|codex` skips the picker, `--project` writes
79
- project-local config, `--print` shows the config without writing it, `--yes` takes every
80
- step's default without asking, `--no-guide` skips all of them, and `--no-runner` /
81
- `--no-desktop` / `--no-integrations` / `--no-hook` skip one each.
85
+ project-local config, `--print` shows the config without writing it, `--no-hook` leaves
86
+ push testing alone, and `--no-guide` connects and nothing else. With `--guide`, `--yes`
87
+ takes every step's default without asking and `--no-runner` / `--no-desktop` /
88
+ `--no-integrations` skip one each.
82
89
 
83
90
  `--yes` deliberately does *not* install the desktop app: that step's default is no, because
84
91
  it downloads a hundred-odd megabytes and writes to `/Applications`. Run `install-desktop`
@@ -218,9 +225,26 @@ npm exec -y premanmcp@latest -- login # Create/login and generate a PreMan API
218
225
  npm exec -y premanmcp@latest -- install # Cursor-only installer (legacy)
219
226
  ```
220
227
 
228
+ ### Push testing
229
+
230
+ ```bash
231
+ preman hook install # Write the pre-push hook (connect does this too)
232
+ preman hook status # Installed? And does the command in it still answer?
233
+ preman hook uninstall # Remove it, restoring any hook it replaced
234
+ ```
235
+
236
+ The hook is generated shell that runs `preman verify --pre-push` and can only ever be
237
+ advisory: no backend, no credentials, a crash or a timeout all exit 0 with a notice. Before
238
+ writing it, `install` runs the command it is about to embed and requires an answer — a hook
239
+ holding a `preman` that belongs to another package prints `checks skipped` at every push and
240
+ looks installed forever. The embedded command is pinned to the version that wrote it rather
241
+ than `@latest`, so upgrading us never changes what your pushes run; re-run `hook install`
242
+ to move it. Set `PREMAN_HOOK_INVOCATION` to write a command of your own instead, and
243
+ `PREMAN_SKIP_HOOK=1` to silence the hook for a push.
244
+
221
245
  ### Runner
222
246
 
223
- `connect` sets this up for you; these are for managing it afterwards.
247
+ `connect --guide` sets this up for you; these are for managing it afterwards.
224
248
 
225
249
  ```bash
226
250
  preman runner status # Paired? Running?
package/bin/account.js CHANGED
@@ -11,6 +11,7 @@ import { existsSync, rmSync } from "node:fs";
11
11
  import os from "node:os";
12
12
 
13
13
  import { detectCandidates } from "./detect.js";
14
+ import { hookStatus } from "./hook.js";
14
15
  import {
15
16
  CREDENTIALS_FILE,
16
17
  backendUrl,
@@ -150,6 +151,39 @@ export async function doctorCommand(commandArgs = []) {
150
151
  )
151
152
  );
152
153
 
154
+ // An installed hook whose command no longer answers is the one failure here
155
+ // that reports itself as success everywhere else: `git push` prints a single
156
+ // skipped line and nothing else ever mentions it.
157
+ const hook = (() => {
158
+ try {
159
+ return hookStatus({ probe: true });
160
+ } catch {
161
+ return null; // not a git repository
162
+ }
163
+ })();
164
+ if (hook && hook.state === "installed") {
165
+ results.push(
166
+ line(
167
+ "push hook",
168
+ hook.works,
169
+ hook.works
170
+ ? `${hook.invocation}${hook.current ? "" : ` — out of date, run \`${cliInvocation()} hook install --force\``}`
171
+ : `${hook.invocation || "unreadable"} does not answer — run \`${cliInvocation()} hook install --force\``
172
+ )
173
+ );
174
+ if (!hook.works) failures += 1;
175
+ } else if (hook) {
176
+ results.push(
177
+ line(
178
+ "push hook",
179
+ null,
180
+ hook.state === "foreign"
181
+ ? "another tool owns pre-push here"
182
+ : `not installed — run \`${cliInvocation()} hook install\``
183
+ )
184
+ );
185
+ }
186
+
153
187
  if (status?.ok) {
154
188
  const integrations = status.integrations || {};
155
189
  for (const [name, section] of Object.entries(integrations)) {
package/bin/cli.js CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  CONNECT_HELP,
22
22
  DISPATCH_HELP,
23
23
  connectCommand,
24
+ discoverEndpoints,
24
25
  dispatchCommand,
25
26
  writeCursorConfig,
26
27
  } from "./connect.js";
@@ -84,7 +85,7 @@ function printHelp() {
84
85
  ["runner start|status|stop", "Run PreMan's queued agent work on this machine"],
85
86
  ["doctor", "Diagnose credentials, backend, target, integrations"],
86
87
  ["install-desktop", "Download and install the PreMan desktop app"],
87
- ["onboard", "Sign in, then connect agent, GitHub, AWS, Slack"],
88
+ ["onboard", "Sign in, then agent, endpoints, runner, GitHub, AWS, Slack"],
88
89
  ["connect [options]", "Pick a coding agent and connect it"],
89
90
  ["dispatch [options]", "Let PreMan start agent runs for you"],
90
91
  ["aws | github | slack", "Connect one integration on its own"],
@@ -240,7 +241,13 @@ async function main() {
240
241
  } else if (command === "onboard" || command === "setup") {
241
242
  // makeArgs/authenticateTerminal/connectCommand are injected rather than
242
243
  // imported there, so integrations.js stays free of a cycle back into the CLI.
243
- await onboardCommand(commandArgs, { makeArgs, authenticateTerminal, connectCommand });
244
+ await onboardCommand(commandArgs, {
245
+ makeArgs,
246
+ authenticateTerminal,
247
+ connectCommand,
248
+ discoverEndpoints,
249
+ runnerCommand,
250
+ });
244
251
  } else if (command === "aws") {
245
252
  await awsCommand(makeArgs(commandArgs));
246
253
  } else if (command === "github") {
package/bin/connect.js CHANGED
@@ -21,6 +21,7 @@ import { installHook, hookStatus } from "./hook.js";
21
21
  import { MARK, awsCommand, githubCommand, slackCommand } from "./integrations.js";
22
22
  import {
23
23
  confirmRunnerOnline,
24
+ pairingIsLive,
24
25
  readRunnerState,
25
26
  registerRunner,
26
27
  runnerIsAlive,
@@ -1060,6 +1061,26 @@ function nextStepsBlock(agent) {
1060
1061
  );
1061
1062
  }
1062
1063
 
1064
+ /**
1065
+ * What is left once the link is done, as commands rather than as questions.
1066
+ *
1067
+ * Connect used to run all of these itself, which bought a prompt per step and a
1068
+ * wait as long as the slowest broken one — and a link that had already succeeded
1069
+ * ended on a failed runner and a five-minute GitHub poll. Each is one command, so
1070
+ * this lists them and gets out of the way.
1071
+ */
1072
+ function whatIsLeftBlock(args) {
1073
+ const cli = cliInvocation();
1074
+ return (
1075
+ "\nWhat is left:\n" +
1076
+ ` ${cli} endpoints discover # map this repo's endpoints\n` +
1077
+ ` ${cli} runner start --background # let PreMan apply fixes on this machine\n` +
1078
+ ` ${cli} github # connect GitHub, or do it in the dashboard\n` +
1079
+ `\n${cli} status reports your endpoints, last push and integrations.\n` +
1080
+ `Watch runs at ${frontendUrl(args)}\n`
1081
+ );
1082
+ }
1083
+
1063
1084
  /**
1064
1085
  * Ask, or take the step's own default when nobody can answer.
1065
1086
  *
@@ -1130,7 +1151,7 @@ export function headlessDiscovery(agent, serverName, instructions = []) {
1130
1151
  *
1131
1152
  * Never throws: a failed discovery must not fail the connect.
1132
1153
  */
1133
- async function discoverEndpoints(args, agent, serverName, blockedHere = "") {
1154
+ export async function discoverEndpoints(args, agent, serverName, blockedHere = "") {
1134
1155
  try {
1135
1156
  const before = await endpointCounts(args);
1136
1157
  if (before.registered) {
@@ -1241,6 +1262,24 @@ async function runFirstTest(args, runnable, { assumeYes }) {
1241
1262
  }
1242
1263
  }
1243
1264
 
1265
+ /**
1266
+ * Whether a stored pairing can still be used, rather than merely matching.
1267
+ *
1268
+ * Matching agent and path only prove the state was written for this project. The
1269
+ * token behind it can be revoked, replaced by another device, or left over from an
1270
+ * API key that no longer exists — and reusing one of those starts a daemon that
1271
+ * 401s on its first stream and exits, which reads as "the runner cannot start
1272
+ * here" when the pairing is the only thing that needed replacing.
1273
+ *
1274
+ * A backend we cannot reach answers "usable": re-pairing is for a token the
1275
+ * backend rejected, not for a network that was briefly down.
1276
+ */
1277
+ export async function runnerPairingIsUsable(args, existing, agentId, { cwd = process.cwd() } = {}) {
1278
+ if (!existing || existing.agent !== agentId) return false;
1279
+ if (path.resolve(String(existing.project_path || "")) !== path.resolve(cwd)) return false;
1280
+ return pairingIsLive(args, existing);
1281
+ }
1282
+
1244
1283
  /**
1245
1284
  * Pair this machine as a runner and leave it running.
1246
1285
  *
@@ -1267,7 +1306,7 @@ async function setUpRunner(args, agent, { assumeYes }) {
1267
1306
  }
1268
1307
 
1269
1308
  try {
1270
- if (!existing || existing.agent !== agent.id || existing.project_path !== path.resolve(process.cwd())) {
1309
+ if (!(await runnerPairingIsUsable(args, existing, agent.id))) {
1271
1310
  await registerRunner(args, { agent: agent.id, projectPath: process.cwd() });
1272
1311
  }
1273
1312
  const started = startBackground([]);
@@ -1409,7 +1448,7 @@ async function setUpPushTesting(args) {
1409
1448
  if (args.has("--no-hook")) return { state: "skipped" };
1410
1449
  const current = (() => {
1411
1450
  try {
1412
- return hookStatus();
1451
+ return hookStatus({ probe: true });
1413
1452
  } catch {
1414
1453
  return null; // not a git repository
1415
1454
  }
@@ -1418,9 +1457,10 @@ async function setUpPushTesting(args) {
1418
1457
  process.stdout.write(`${MARK.skip()} Not a git repository — no push testing here.\n`);
1419
1458
  return { state: "unavailable" };
1420
1459
  }
1421
- // A hook whose invocation went stale is reinstalled rather than reported as on:
1422
- // it is the case where PreMan looks connected and silently checks nothing.
1423
- if (current.state === "installed" && current.current) {
1460
+ // A hook whose invocation went stale, or no longer answers, is reinstalled
1461
+ // rather than reported as on: it is the case where PreMan looks connected and
1462
+ // silently checks nothing.
1463
+ if (current.state === "installed" && current.current && current.works) {
1424
1464
  process.stdout.write(`${MARK.ok()} Push testing already on.\n`);
1425
1465
  return { state: "installed" };
1426
1466
  }
@@ -1432,6 +1472,13 @@ async function setUpPushTesting(args) {
1432
1472
  );
1433
1473
  return result;
1434
1474
  }
1475
+ if (result.action === "unproven") {
1476
+ process.stdout.write(
1477
+ `${MARK.skip()} No push testing: ${result.detail}\n` +
1478
+ ` Then: ${cliInvocation()} hook install\n`
1479
+ );
1480
+ return result;
1481
+ }
1435
1482
  process.stdout.write(
1436
1483
  `${MARK.ok()} Push testing on — \`git push\` now checks the endpoints you touched.\n` +
1437
1484
  " It never blocks a push; PREMAN_SKIP_HOOK=1 silences it.\n"
@@ -1440,10 +1487,12 @@ async function setUpPushTesting(args) {
1440
1487
  }
1441
1488
 
1442
1489
  /**
1443
- * Everything after the link, in one pass, with nothing left for the user to run.
1490
+ * Everything after the link, in one pass, for whoever asks for it with `--guide`.
1444
1491
  *
1445
1492
  * Order follows what a new account needs to see: what PreMan found, then where to
1446
- * watch it, then who to tell, then when to run it.
1493
+ * watch it, then who to tell, then when to run it. It is opt-in because running
1494
+ * all of it unasked is what turned a finished connect into a screen of prompts,
1495
+ * a dead runner and a five-minute wait on an integration nobody had asked for.
1447
1496
  */
1448
1497
  async function guidedFirstRun(args, agent, apiKey, serverName, { blockedHere = "" } = {}) {
1449
1498
  const assumeYes = args.has("--yes");
@@ -1544,10 +1593,12 @@ Connect options:
1544
1593
  --no-self-test Do not start the MCP server to finish the link
1545
1594
  --no-auto-checkin Do not run the agent to finish the link
1546
1595
  --no-wait Do not wait for the agent to check in
1547
- --no-guide Skip the guided first run after connecting
1548
- --no-runner Do not pair this machine as a job runner
1549
- --no-desktop Do not offer the desktop app
1550
- --no-integrations Do not check or offer GitHub / AWS / Slack
1596
+ --guide Also run discovery, the runner, the desktop app
1597
+ and the integration prompts after connecting
1598
+ --no-guide Connect only: no push hook, no closing summary
1599
+ --no-runner With --guide, do not pair this machine as a runner
1600
+ --no-desktop With --guide, do not offer the desktop app
1601
+ --no-integrations With --guide, do not offer GitHub / AWS / Slack
1551
1602
  --no-hook Do not install the git pre-push hook
1552
1603
  --yes Take every step's default without prompting
1553
1604
  (the desktop app defaults to no; install-desktop)
@@ -1670,18 +1721,36 @@ export async function connectCommand(commandArgs) {
1670
1721
  serverConfig,
1671
1722
  projectInstall,
1672
1723
  });
1724
+ // The agent goes back to the caller because `onboard` runs steps after this one
1725
+ // that need to know which agent to drive, and asking twice is a question we
1726
+ // already have the answer to.
1727
+ const connected = { agent, serverName, linked: checkIn.linked };
1673
1728
  if (!checkIn.linked) {
1674
1729
  // Still honour an explicitly-passed credential, but do not open a new prompt
1675
1730
  // on top of a connect that just told the user something went wrong.
1676
1731
  await captureDispatchCredential(args, agent, apiKey, { prompt: false });
1677
- return;
1732
+ return connected;
1678
1733
  }
1679
1734
 
1680
1735
  process.stdout.write(`${MARK.ok()} Connected as ${agent.label}.\n`);
1681
- if (!args.has("--no-guide")) {
1736
+
1737
+ // Connecting is one job. Running discovery, pairing a runner, offering the
1738
+ // desktop app and installing three integrations is five more, each with its
1739
+ // own prompt and its own way to fail -- and a connect that ends in a failed
1740
+ // runner and a five-minute GitHub poll reads as a broken product rather than a
1741
+ // finished link. The push hook stays because it is the only one that is not a
1742
+ // question: it is what makes PreMan run at all, and it costs a file write.
1743
+ if (args.has("--guide") && !args.has("--no-guide")) {
1682
1744
  await guidedFirstRun(args, agent, apiKey, serverName, { blockedHere: checkIn.blockedHere });
1745
+ await captureDispatchCredential(args, agent, apiKey);
1746
+ return connected;
1683
1747
  }
1684
- await captureDispatchCredential(args, agent, apiKey);
1748
+ if (!args.has("--no-guide")) {
1749
+ await setUpPushTesting(args);
1750
+ process.stdout.write(whatIsLeftBlock(args));
1751
+ }
1752
+ await captureDispatchCredential(args, agent, apiKey, { prompt: false });
1753
+ return connected;
1685
1754
  }
1686
1755
 
1687
1756
  /**
package/bin/hook.js CHANGED
@@ -12,20 +12,22 @@ import { spawnSync } from "node:child_process";
12
12
  import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
13
13
  import path from "node:path";
14
14
 
15
- import { cliInvocation, makeArgs } from "./shared.js";
15
+ import { cliInvocation, makeArgs, packageVersion } from "./shared.js";
16
16
  import { BLOCK_EXIT_CODE } from "./verify.js";
17
17
 
18
18
  export const HOOK_HELP = `
19
19
  Hook options:
20
20
  install Write the pre-push hook into this repository
21
21
  uninstall Remove PreMan's pre-push hook
22
- status Report whether the hook is installed
22
+ status Report whether the hook is installed, and still works
23
23
  --force Overwrite a foreign pre-push hook (a backup is kept)
24
+ PREMAN_HOOK_INVOCATION=<cmd> Use this command line instead of probing for one
24
25
  `;
25
26
 
26
27
  const MARKER = "# >>> preman pre-push >>>";
27
28
  const END_MARKER = "# <<< preman pre-push <<<";
28
29
  const HOOK_TIMEOUT_SECONDS = 120;
30
+ const PROBE_TIMEOUT_MS = 45000;
29
31
 
30
32
  function gitDir() {
31
33
  const result = spawnSync("git", ["rev-parse", "--git-dir"], { encoding: "utf8" });
@@ -39,7 +41,90 @@ function hookPath() {
39
41
  return path.join(gitDir(), "hooks", "pre-push");
40
42
  }
41
43
 
42
- function hookBody() {
44
+ /**
45
+ * The command lines a hook could carry, best first.
46
+ *
47
+ * A hook is written once and read at every push, so `@latest` quietly hands each
48
+ * push to whatever we ship next; the version that wrote the hook is the one that
49
+ * keeps running it, and `preman hook install` is how that moves. `@latest` is
50
+ * left only for the case where we cannot read our own manifest to know it.
51
+ *
52
+ * The bare `preman` form is offered first when it is ours — see `pathPremanOwner`
53
+ * — because it starts in milliseconds where npm exec does not.
54
+ */
55
+ export function hookInvocations() {
56
+ const version = packageVersion();
57
+ const pinned = `npm exec -y premanmcp@${version || "latest"} --`;
58
+ const preferred = cliInvocation();
59
+ return preferred.startsWith("npm exec") ? [pinned] : [preferred, pinned];
60
+ }
61
+
62
+ const probed = new Map();
63
+
64
+ /** An invocation the user pinned by hand, or "". */
65
+ function declaredInvocation() {
66
+ return String(process.env.PREMAN_HOOK_INVOCATION || "").trim();
67
+ }
68
+
69
+ /**
70
+ * Does this command line reach a PreMan CLI that knows `verify`, from a process
71
+ * that is not this one?
72
+ *
73
+ * This is the check whose absence produced `Unknown command: verify` on every
74
+ * push: the hook is generated shell run minutes or months later, so the only
75
+ * evidence that matters is a separate process answering. `help` is the cheapest
76
+ * subcommand that proves both halves — that something ran, and that it was us
77
+ * rather than another package's identically named `preman`.
78
+ *
79
+ * Memoized per command line, because a connect installs and then re-reads the
80
+ * hook, and npm exec is slow enough that paying twice shows.
81
+ */
82
+ export function invocationAnswers(invocation) {
83
+ // Declaring an invocation means "use this and stop asking", so the same answer
84
+ // has to hold when we later read it back out of a hook -- otherwise the escape
85
+ // hatch writes a hook that every status then calls broken.
86
+ if (invocation && invocation === declaredInvocation()) return true;
87
+ if (probed.has(invocation)) return probed.get(invocation);
88
+ let answered = false;
89
+ try {
90
+ const probe = spawnSync(`${invocation} help`, {
91
+ shell: true,
92
+ encoding: "utf8",
93
+ timeout: Number(process.env.PREMAN_HOOK_PROBE_MS) || PROBE_TIMEOUT_MS,
94
+ stdio: ["ignore", "pipe", "pipe"],
95
+ });
96
+ answered = probe.status === 0 && /\bverify\b/.test(String(probe.stdout || ""));
97
+ } catch {
98
+ answered = false;
99
+ }
100
+ probed.set(invocation, answered);
101
+ return answered;
102
+ }
103
+
104
+ /** Test seam: the probe above is memoized for the life of the process. */
105
+ export function resetInvocationProbe() {
106
+ probed.clear();
107
+ }
108
+
109
+ /**
110
+ * The invocation to write into a hook, or "" when nothing here can run PreMan.
111
+ *
112
+ * `PREMAN_HOOK_INVOCATION` is taken on trust: it exists for the setups we cannot
113
+ * probe our way to — a wrapper script, a monorepo runner, a pinned mirror.
114
+ */
115
+ export function provenInvocation() {
116
+ return (
117
+ declaredInvocation() || hookInvocations().find((candidate) => invocationAnswers(candidate)) || ""
118
+ );
119
+ }
120
+
121
+ /** The command line an already-written hook calls, or "". */
122
+ function embeddedInvocation(text) {
123
+ const match = /^\s*PREMAN_HOOK=1 (.+) verify --pre-push\b/m.exec(text);
124
+ return match ? match[1].trim() : "";
125
+ }
126
+
127
+ function hookBody(invocation) {
43
128
  // `exec` is deliberately absent: we want the wrapper to survive the CLI exiting
44
129
  // non-zero and still exit 0 itself.
45
130
  //
@@ -51,7 +136,7 @@ ${MARKER}
51
136
  # Advisory unless this repository opted into blocking: only exit code
52
137
  # ${BLOCK_EXIT_CODE} stops a push, so a crash or a timeout still lets it through.
53
138
  if [ -z "\${PREMAN_SKIP_HOOK}" ]; then
54
- PREMAN_HOOK=1 ${cliInvocation()} verify --pre-push --timeout ${HOOK_TIMEOUT_SECONDS}
139
+ PREMAN_HOOK=1 ${invocation} verify --pre-push --timeout ${HOOK_TIMEOUT_SECONDS}
55
140
  preman_status=$?
56
141
  if [ "$preman_status" -eq ${BLOCK_EXIT_CODE} ]; then
57
142
  exit ${BLOCK_EXIT_CODE}
@@ -71,16 +156,31 @@ function isOurHook(text) {
71
156
  return text.includes(MARKER);
72
157
  }
73
158
 
74
- export function installHook(args) {
159
+ export function installHook(args, { invocation = provenInvocation() } = {}) {
75
160
  const target = hookPath();
161
+
162
+ // A hook holding a command that does not run is worse than no hook: it is
163
+ // silent, it says "installed" in every status we print, and the only sign of
164
+ // it is one skipped line scrolling past a push nobody reads.
165
+ if (!invocation) {
166
+ return {
167
+ path: target,
168
+ action: "unproven",
169
+ detail:
170
+ "no way to run the PreMan CLI from a hook was found here" +
171
+ " -- install it globally (npm i -g premanmcp) or set PREMAN_HOOK_INVOCATION",
172
+ };
173
+ }
174
+
175
+ const body = hookBody(invocation);
76
176
  mkdirSync(path.dirname(target), { recursive: true });
77
177
 
78
178
  if (existsSync(target)) {
79
179
  const existing = readFileSync(target, "utf8");
80
180
  if (isOurHook(existing)) {
81
- writeFileSync(target, hookBody(), { mode: 0o755 });
181
+ writeFileSync(target, body, { mode: 0o755 });
82
182
  chmodSync(target, 0o755);
83
- return { path: target, action: "updated" };
183
+ return { path: target, action: "updated", invocation };
84
184
  }
85
185
  if (!args.has("--force")) {
86
186
  return {
@@ -91,14 +191,19 @@ export function installHook(args) {
91
191
  }
92
192
  const backup = `${target}.preman-backup`;
93
193
  writeFileSync(backup, existing, { mode: 0o755 });
94
- writeFileSync(target, hookBody(), { mode: 0o755 });
194
+ writeFileSync(target, body, { mode: 0o755 });
95
195
  chmodSync(target, 0o755);
96
- return { path: target, action: "replaced", detail: `previous hook saved to ${backup}` };
196
+ return {
197
+ path: target,
198
+ action: "replaced",
199
+ invocation,
200
+ detail: `previous hook saved to ${backup}`,
201
+ };
97
202
  }
98
203
 
99
- writeFileSync(target, hookBody(), { mode: 0o755 });
204
+ writeFileSync(target, body, { mode: 0o755 });
100
205
  chmodSync(target, 0o755);
101
- return { path: target, action: "installed" };
206
+ return { path: target, action: "installed", invocation };
102
207
  }
103
208
 
104
209
  export function uninstallHook() {
@@ -122,17 +227,36 @@ export function uninstallHook() {
122
227
  * `current` is what tells a caller an installed hook still needs rewriting.
123
228
  *
124
229
  * 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.
230
+ * stale — the machine gained a `preman` that is not ours, lost the one that was,
231
+ * or upgraded past the version the hook pins. "Installed" then means a file
232
+ * exists that calls the wrong thing on every push, so anything that skips work
233
+ * when the hook is present has to be able to tell the difference.
234
+ *
235
+ * `works` answers the harder question — does the command in the file still run
236
+ * PreMan — so callers ask for it: `preman hook status` and `preman doctor` do,
237
+ * connect does not. Both answers cost a probe for an installed hook, since
238
+ * "up to date" means "equal to what we would write now", and what we would write
239
+ * is whatever answers here. An absent or foreign hook costs nothing.
129
240
  */
130
- export function hookStatus() {
241
+ export function hookStatus({ probe = false } = {}) {
131
242
  const target = hookPath();
132
- if (!existsSync(target)) return { path: target, state: "absent", current: false };
243
+ const missing = { path: target, current: false, invocation: "", works: null };
244
+ if (!existsSync(target)) return { ...missing, state: "absent" };
133
245
  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() };
246
+ if (!isOurHook(existing)) return { ...missing, state: "foreign" };
247
+
248
+ const invocation = embeddedInvocation(existing);
249
+ // Falling back to what the file already carries keeps a machine that can no
250
+ // longer prove any invocation -- offline, say -- from reporting a hook as out
251
+ // of date and sending its owner to a reinstall that would refuse to write.
252
+ const wanted = provenInvocation() || invocation;
253
+ return {
254
+ path: target,
255
+ state: "installed",
256
+ invocation,
257
+ current: Boolean(wanted) && existing === hookBody(wanted),
258
+ works: probe ? Boolean(invocation) && invocationAnswers(invocation) : null,
259
+ };
136
260
  }
137
261
 
138
262
  export async function hookCommand(commandArgs = []) {
@@ -141,13 +265,14 @@ export async function hookCommand(commandArgs = []) {
141
265
 
142
266
  if (sub === "install") {
143
267
  const result = installHook(args);
144
- if (result.action === "conflict") {
268
+ if (result.action === "conflict" || result.action === "unproven") {
145
269
  process.stdout.write(`Not installed: ${result.detail}\n ${result.path}\n`);
146
270
  return result;
147
271
  }
148
272
  process.stdout.write(
149
273
  `Pre-push hook ${result.action}: ${result.path}\n` +
150
274
  (result.detail ? ` ${result.detail}\n` : "") +
275
+ ` Runs: ${result.invocation} verify --pre-push\n` +
151
276
  `\nPreMan will now check affected endpoints before each push.\n` +
152
277
  `It never blocks a push -- set PREMAN_SKIP_HOOK=1 to silence it entirely.\n`
153
278
  );
@@ -163,13 +288,27 @@ export async function hookCommand(commandArgs = []) {
163
288
  }
164
289
 
165
290
  if (sub === "status") {
166
- const result = hookStatus();
291
+ const result = hookStatus({ probe: true });
167
292
  const label = {
168
293
  installed: "installed (PreMan)",
169
294
  foreign: "present, but not written by PreMan",
170
295
  absent: "not installed",
171
296
  }[result.state];
172
297
  process.stdout.write(`Pre-push hook: ${label}\n ${result.path}\n`);
298
+ if (result.state === "installed") {
299
+ process.stdout.write(` Runs: ${result.invocation || "(unreadable)"}\n`);
300
+ // "Installed" was never the question -- a hook that cannot reach the CLI
301
+ // prints one skipped line per push and is otherwise indistinguishable.
302
+ process.stdout.write(
303
+ result.works
304
+ ? " That command answers here.\n"
305
+ : ` That command does not answer here -- pushes are being skipped.\n` +
306
+ ` Repair it: ${cliInvocation()} hook install --force\n`
307
+ );
308
+ if (!result.current) {
309
+ process.stdout.write(` Out of date: ${cliInvocation()} hook install --force\n`);
310
+ }
311
+ }
173
312
  return result;
174
313
  }
175
314
 
@@ -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
package/bin/runner.js CHANGED
@@ -608,6 +608,12 @@ export async function executeJob(args, state, job, { log = () => {}, fullAccess
608
608
 
609
609
  // ── Event loop ──────────────────────────────────────────────────────────
610
610
 
611
+ /** The re-pairing command for this runner. `register` without --agent exits 2. */
612
+ function repairCommand(state) {
613
+ const agent = String(state?.agent || "claude_code").replace(/_/g, "-");
614
+ return `${cliInvocation()} runner register --agent ${agent}`;
615
+ }
616
+
611
617
  function defaultLog(message) {
612
618
  process.stdout.write(`[preman runner] ${new Date().toISOString()} ${message}\n`);
613
619
  }
@@ -682,7 +688,7 @@ export async function runnerLoop(
682
688
  }
683
689
 
684
690
  if (response.status === 401) {
685
- log("runner token was revoked; re-pair with `preman runner register`");
691
+ log(`runner token was revoked; re-pair with \`${repairCommand(state)}\``);
686
692
  clearRunnerState();
687
693
  return { stopped: true, jobsRun, reason: "revoked" };
688
694
  }
@@ -729,7 +735,7 @@ export async function runnerLoop(
729
735
  }
730
736
 
731
737
  if (terminal === "revoked") {
732
- log("runner was revoked; re-pair with `preman runner register`");
738
+ log(`runner was revoked; re-pair with \`${repairCommand(state)}\``);
733
739
  clearRunnerState();
734
740
  return { stopped: true, jobsRun, reason: "revoked" };
735
741
  }
@@ -815,6 +821,19 @@ export async function confirmRunnerOnline({ pid, offset = 0, timeoutMs = 12_000,
815
821
  }
816
822
  }
817
823
 
824
+ /**
825
+ * Does the backend still honour this pairing's token?
826
+ *
827
+ * One heartbeat, which is the only call that can answer it. A backend we cannot
828
+ * reach answers yes: re-pairing is for a token that was rejected, not for a
829
+ * network that blinked, and registering again would spend a pair code to fix
830
+ * nothing.
831
+ */
832
+ export async function pairingIsLive(args, state) {
833
+ const { revoked } = await sendHeartbeat(args, state, { log: () => {} });
834
+ return !revoked;
835
+ }
836
+
818
837
  /** Start the daemon detached, so a terminal can be closed without killing it. */
819
838
  export function startBackground(commandArgs) {
820
839
  ensureDir();
@@ -844,6 +863,14 @@ async function startForeground(args, commandArgs) {
844
863
  );
845
864
  }
846
865
  state = await registerRunner(args, { agent, projectPath: args.value("--path", process.cwd()) });
866
+ } else if (!(await pairingIsLive(args, state))) {
867
+ // A stored pairing is not a working one. The backend revokes a runner when
868
+ // another device takes over, when the key behind it is deleted, or when this
869
+ // machine was last seen going offline -- and starting on one of those dies on
870
+ // the first stream call with a 401, which reads as "the runner cannot run
871
+ // here" when the token was the only thing that needed replacing.
872
+ defaultLog("stored pairing was rejected; registering this machine again");
873
+ state = await registerRunner(args, { agent: state.agent, projectPath: state.project_path });
847
874
  }
848
875
 
849
876
  const log = defaultLog;
package/bin/shared.js CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  import os from "node:os";
21
21
  import path from "node:path";
22
22
  import { createInterface } from "node:readline/promises";
23
+ import { fileURLToPath } from "node:url";
23
24
 
24
25
  export const DEFAULT_BACKEND = "https://api.preman.live";
25
26
  export const DEFAULT_FRONTEND = "https://app.preman.live";
@@ -38,7 +39,40 @@ export const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json");
38
39
  let _invocation = null;
39
40
 
40
41
  /**
41
- * The package that owns the first `preman` on PATH, or "".
42
+ * A `preman` that exists only for this process, and cannot be written down.
43
+ *
44
+ * `npm exec` prepends its cache's `node_modules/.bin` to the child's PATH, and
45
+ * this package declares a `preman` bin — so inside our own documented launcher,
46
+ * `which preman` finds *us*. Believing it produced a git hook calling `preman
47
+ * verify`, which worked in that one process and answered "Unknown command:
48
+ * verify" at every later `git push`, because the PATH entry was gone.
49
+ *
50
+ * A durable global install (`npm i -g premanmcp`) is not under an `_npx` cache,
51
+ * so it still counts.
52
+ */
53
+ function isEphemeralBin(realPath) {
54
+ return realPath.split(path.sep).includes("_npx");
55
+ }
56
+
57
+ /** The package owning a resolved bin, by the nearest manifest above it, or "". */
58
+ function owningPackage(realPath) {
59
+ // Walk up from the real file, not the symlink: a bin is a link into the
60
+ // package directory, and only that directory's manifest names the owner.
61
+ let dir = path.dirname(realPath);
62
+ for (let depth = 0; depth < 5; depth += 1) {
63
+ const manifest = path.join(dir, "package.json");
64
+ if (existsSync(manifest)) {
65
+ return String(JSON.parse(readFileSync(manifest, "utf8")).name || "");
66
+ }
67
+ const parent = path.dirname(dir);
68
+ if (parent === dir) break;
69
+ dir = parent;
70
+ }
71
+ return "";
72
+ }
73
+
74
+ /**
75
+ * The package that owns the `preman` a *later* process would find, or "".
42
76
  *
43
77
  * `preman` is not ours by name alone: `preman-sdk` publishes a bin called
44
78
  * exactly that, and whichever package lost the race still resolves. Assuming it
@@ -46,34 +80,36 @@ let _invocation = null;
46
80
  * "Unknown command: verify" and turns every push into a silent
47
81
  * "[preman] checks skipped", the worst possible failure for a tool whose whole
48
82
  * job is running checks on push.
83
+ *
84
+ * Every match is considered, not just the first: an ephemeral hit shadowing a
85
+ * real global install must not decide the answer for either of them.
49
86
  */
50
87
  export function pathPremanOwner() {
51
- const probe = process.platform === "win32" ? "where" : "which";
52
- let resolved = "";
88
+ const onWindows = process.platform === "win32";
89
+ let candidates = [];
53
90
  try {
54
- const found = spawnSync(probe, ["preman"], { stdio: "pipe", encoding: "utf8" });
91
+ const found = spawnSync(onWindows ? "where" : "which", onWindows ? ["preman"] : ["-a", "preman"], {
92
+ stdio: "pipe",
93
+ encoding: "utf8",
94
+ });
55
95
  if (found.status !== 0) return "";
56
- resolved = String(found.stdout || "").split("\n")[0].trim();
96
+ candidates = String(found.stdout || "")
97
+ .split("\n")
98
+ .map((line) => line.trim())
99
+ .filter(Boolean);
57
100
  } catch {
58
101
  return "";
59
102
  }
60
- if (!resolved) return "";
61
103
 
62
- // Walk up from the real file, not the symlink: a bin is a link into the
63
- // package directory, and only that directory's manifest names the owner.
64
- try {
65
- let dir = path.dirname(realpathSync(resolved));
66
- for (let depth = 0; depth < 5; depth += 1) {
67
- const manifest = path.join(dir, "package.json");
68
- if (existsSync(manifest)) {
69
- return String(JSON.parse(readFileSync(manifest, "utf8")).name || "");
70
- }
71
- const parent = path.dirname(dir);
72
- if (parent === dir) break;
73
- dir = parent;
104
+ for (const candidate of candidates) {
105
+ try {
106
+ const realPath = realpathSync(candidate);
107
+ if (isEphemeralBin(realPath)) continue;
108
+ const owner = owningPackage(realPath);
109
+ if (owner) return owner;
110
+ } catch {
111
+ // A dangling link or an unreadable manifest: try the next match.
74
112
  }
75
- } catch {
76
- return "";
77
113
  }
78
114
  return "";
79
115
  }
@@ -83,6 +119,16 @@ export function cliInvocation() {
83
119
  pathPremanOwner() === "premanmcp" ? "preman" : "npm exec -y premanmcp@latest --");
84
120
  }
85
121
 
122
+ /** The version of the package this process is running from, or "". */
123
+ export function packageVersion() {
124
+ try {
125
+ const here = path.dirname(fileURLToPath(import.meta.url));
126
+ return String(JSON.parse(readFileSync(path.join(here, "..", "package.json"), "utf8")).version || "");
127
+ } catch {
128
+ return "";
129
+ }
130
+ }
131
+
86
132
  /** Test seam: `cliInvocation` memoizes a PATH probe for the life of the process. */
87
133
  export function resetCliInvocation() {
88
134
  _invocation = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "premanmcp",
3
- "version": "0.10.4",
3
+ "version": "0.10.5",
4
4
  "description": "Turn APIs into agent-callable MCP tools with auth, testing, and audit logs",
5
5
  "type": "module",
6
6
  "bin": {