premanmcp 0.10.3 → 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) {
1154
+ export async function discoverEndpoints(args, agent, serverName, blockedHere = "") {
1134
1155
  try {
1135
1156
  const before = await endpointCounts(args);
1136
1157
  if (before.registered) {
@@ -1142,7 +1163,10 @@ async function discoverEndpoints(args, agent, serverName) {
1142
1163
 
1143
1164
  const brief = await callPremanTool(args, "discover_endpoints_from_codebase", { base_path: "." });
1144
1165
  const spec = headlessDiscovery(agent, serverName, brief.instructions || []);
1145
- const blocker = spec && onPath(spec.bin) ? agentBlocker(agent.id) : "";
1166
+ // Discovery has to read *this* repo, so it cannot be moved out of a directory
1167
+ // that redirects it the way the check-in was — it can only be skipped, before
1168
+ // it spends ten minutes proving what the self-test already found out.
1169
+ const blocker = blockedHere || (spec && onPath(spec.bin) ? agentBlocker(agent.id) : "");
1146
1170
  if (!spec || !onPath(spec.bin) || blocker) {
1147
1171
  // Say why before printing homework: "here is a brief" reads as PreMan not
1148
1172
  // working, when the actual answer is one login away.
@@ -1238,6 +1262,24 @@ async function runFirstTest(args, runnable, { assumeYes }) {
1238
1262
  }
1239
1263
  }
1240
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
+
1241
1283
  /**
1242
1284
  * Pair this machine as a runner and leave it running.
1243
1285
  *
@@ -1264,7 +1306,7 @@ async function setUpRunner(args, agent, { assumeYes }) {
1264
1306
  }
1265
1307
 
1266
1308
  try {
1267
- if (!existing || existing.agent !== agent.id || existing.project_path !== path.resolve(process.cwd())) {
1309
+ if (!(await runnerPairingIsUsable(args, existing, agent.id))) {
1268
1310
  await registerRunner(args, { agent: agent.id, projectPath: process.cwd() });
1269
1311
  }
1270
1312
  const started = startBackground([]);
@@ -1406,7 +1448,7 @@ async function setUpPushTesting(args) {
1406
1448
  if (args.has("--no-hook")) return { state: "skipped" };
1407
1449
  const current = (() => {
1408
1450
  try {
1409
- return hookStatus();
1451
+ return hookStatus({ probe: true });
1410
1452
  } catch {
1411
1453
  return null; // not a git repository
1412
1454
  }
@@ -1415,9 +1457,10 @@ async function setUpPushTesting(args) {
1415
1457
  process.stdout.write(`${MARK.skip()} Not a git repository — no push testing here.\n`);
1416
1458
  return { state: "unavailable" };
1417
1459
  }
1418
- // A hook whose invocation went stale is reinstalled rather than reported as on:
1419
- // it is the case where PreMan looks connected and silently checks nothing.
1420
- 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) {
1421
1464
  process.stdout.write(`${MARK.ok()} Push testing already on.\n`);
1422
1465
  return { state: "installed" };
1423
1466
  }
@@ -1429,6 +1472,13 @@ async function setUpPushTesting(args) {
1429
1472
  );
1430
1473
  return result;
1431
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
+ }
1432
1482
  process.stdout.write(
1433
1483
  `${MARK.ok()} Push testing on — \`git push\` now checks the endpoints you touched.\n` +
1434
1484
  " It never blocks a push; PREMAN_SKIP_HOOK=1 silences it.\n"
@@ -1437,16 +1487,18 @@ async function setUpPushTesting(args) {
1437
1487
  }
1438
1488
 
1439
1489
  /**
1440
- * 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`.
1441
1491
  *
1442
1492
  * Order follows what a new account needs to see: what PreMan found, then where to
1443
- * 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.
1444
1496
  */
1445
- async function guidedFirstRun(args, agent, apiKey, serverName) {
1497
+ async function guidedFirstRun(args, agent, apiKey, serverName, { blockedHere = "" } = {}) {
1446
1498
  const assumeYes = args.has("--yes");
1447
1499
 
1448
1500
  step("Endpoints");
1449
- const counts = await discoverEndpoints(args, agent, serverName);
1501
+ const counts = await discoverEndpoints(args, agent, serverName, blockedHere);
1450
1502
 
1451
1503
  if (counts.first) {
1452
1504
  step("First test");
@@ -1541,10 +1593,12 @@ Connect options:
1541
1593
  --no-self-test Do not start the MCP server to finish the link
1542
1594
  --no-auto-checkin Do not run the agent to finish the link
1543
1595
  --no-wait Do not wait for the agent to check in
1544
- --no-guide Skip the guided first run after connecting
1545
- --no-runner Do not pair this machine as a job runner
1546
- --no-desktop Do not offer the desktop app
1547
- --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
1548
1602
  --no-hook Do not install the git pre-push hook
1549
1603
  --yes Take every step's default without prompting
1550
1604
  (the desktop app defaults to no; install-desktop)
@@ -1661,25 +1715,42 @@ export async function connectCommand(commandArgs) {
1661
1715
  return;
1662
1716
  }
1663
1717
 
1664
- if (
1665
- !(await establishCheckIn(args, agent, apiKey, {
1666
- serverName,
1667
- written,
1668
- serverConfig,
1669
- projectInstall,
1670
- }))
1671
- ) {
1718
+ const checkIn = await establishCheckIn(args, agent, apiKey, {
1719
+ serverName,
1720
+ written,
1721
+ serverConfig,
1722
+ projectInstall,
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 };
1728
+ if (!checkIn.linked) {
1672
1729
  // Still honour an explicitly-passed credential, but do not open a new prompt
1673
1730
  // on top of a connect that just told the user something went wrong.
1674
1731
  await captureDispatchCredential(args, agent, apiKey, { prompt: false });
1675
- return;
1732
+ return connected;
1676
1733
  }
1677
1734
 
1678
1735
  process.stdout.write(`${MARK.ok()} Connected as ${agent.label}.\n`);
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")) {
1744
+ await guidedFirstRun(args, agent, apiKey, serverName, { blockedHere: checkIn.blockedHere });
1745
+ await captureDispatchCredential(args, agent, apiKey);
1746
+ return connected;
1747
+ }
1679
1748
  if (!args.has("--no-guide")) {
1680
- await guidedFirstRun(args, agent, apiKey, serverName);
1749
+ await setUpPushTesting(args);
1750
+ process.stdout.write(whatIsLeftBlock(args));
1681
1751
  }
1682
- await captureDispatchCredential(args, agent, apiKey);
1752
+ await captureDispatchCredential(args, agent, apiKey, { prompt: false });
1753
+ return connected;
1683
1754
  }
1684
1755
 
1685
1756
  /**
@@ -1744,8 +1815,10 @@ export function neutralCwd(candidates = [os.homedir(), os.tmpdir()]) {
1744
1815
  * end: the steps below are measured in minutes, and a note that explains what is
1745
1816
  * happening is worth nothing after it has stopped happening.
1746
1817
  *
1747
- * Returns whether the check-in landed, and prints the troubleshooting block
1748
- * itself when it did not.
1818
+ * Returns whether the check-in landed and, when this directory redirects it, why
1819
+ * nothing started here can use PreMan — the caller has its own agent to run once
1820
+ * this is done. Prints the troubleshooting block itself when the link did not
1821
+ * land.
1749
1822
  */
1750
1823
  async function establishCheckIn(
1751
1824
  args,
@@ -1754,9 +1827,11 @@ async function establishCheckIn(
1754
1827
  { serverName, written, serverConfig, projectInstall = false }
1755
1828
  ) {
1756
1829
  const notes = [];
1757
- // Set only when this directory would redirect the agent, and then it is where
1758
- // the agent gets started instead.
1830
+ // Both set only when this directory redirects the agent: where it can be
1831
+ // started instead, and why an agent left standing here cannot use PreMan at all.
1759
1832
  let elsewhere = null;
1833
+ let blockedHere = "";
1834
+ const done = (linked) => ({ linked, blockedHere });
1760
1835
  const ticker = pollTicker();
1761
1836
  const say = (text) => {
1762
1837
  ticker.end();
@@ -1781,7 +1856,7 @@ async function establishCheckIn(
1781
1856
  );
1782
1857
  }
1783
1858
  if (test.ok && status.authenticated && (await waitForConnection(args, apiKey, { timeoutMs: 15000 }))) {
1784
- return true;
1859
+ return done(true);
1785
1860
  }
1786
1861
  note(
1787
1862
  test.ok
@@ -1806,6 +1881,12 @@ async function establishCheckIn(
1806
1881
  `directory forces: ${cliInvocation()} connect ${flag} --backend ${redirect.actual} ` +
1807
1882
  `(needs a key issued by it, and that API running).`
1808
1883
  );
1884
+ // Linking is not the only thing this directory breaks: whatever runs here
1885
+ // next gets the same redirected, unauthenticated server, and the callers
1886
+ // that would start an agent in it need to hear so.
1887
+ blockedHere =
1888
+ `${redirect.path} sends ${agent.label} to ${redirect.actual} in this directory, ` +
1889
+ `where this key is not valid.`;
1809
1890
 
1810
1891
  // The config we wrote is correct; the directory around it is not. Since the
1811
1892
  // server only reads the file it is standing in, everything this does next
@@ -1813,7 +1894,7 @@ async function establishCheckIn(
1813
1894
  const outside = neutralCwd();
1814
1895
  if (!outside) {
1815
1896
  notLinked(`Run '${cliInvocation()} connect' from your own project instead of this directory.`);
1816
- return false;
1897
+ return done(false);
1817
1898
  }
1818
1899
 
1819
1900
  say(
@@ -1834,7 +1915,7 @@ async function establishCheckIn(
1834
1915
  retry.status?.authenticated &&
1835
1916
  (await waitForConnection(args, apiKey, { timeoutMs: 15000 }))
1836
1917
  ) {
1837
- return true;
1918
+ return done(true);
1838
1919
  }
1839
1920
 
1840
1921
  if (projectInstall) {
@@ -1845,7 +1926,7 @@ async function establishCheckIn(
1845
1926
  `This --project config exists only here; started anywhere else, ` +
1846
1927
  `${agent.label} has no ${serverName} server to call.`
1847
1928
  );
1848
- return false;
1929
+ return done(false);
1849
1930
  }
1850
1931
  elsewhere = outside;
1851
1932
  }
@@ -1867,7 +1948,7 @@ async function establishCheckIn(
1867
1948
  });
1868
1949
  if (launch.connected) {
1869
1950
  ticker.end();
1870
- return true;
1951
+ return done(true);
1871
1952
  }
1872
1953
  if (launch.ran && !launch.interactive) {
1873
1954
  const why = lastLine(launch.output);
@@ -1896,7 +1977,7 @@ async function establishCheckIn(
1896
1977
 
1897
1978
  if (await waitForConnection(args, apiKey, { onPoll: ticker.tick })) {
1898
1979
  ticker.end();
1899
- return true;
1980
+ return done(true);
1900
1981
  }
1901
1982
 
1902
1983
  say(
@@ -1907,5 +1988,5 @@ async function establishCheckIn(
1907
1988
  ` - Then ask ${agent.label} to "run preman_status" — it links on its first PreMan call.\n` +
1908
1989
  ` - Then: ${cliInvocation()} connect --agent ${agent.id.replace("_", "-")}\n`
1909
1990
  );
1910
- return false;
1991
+ return done(false);
1911
1992
  }
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.3",
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": {