premanmcp 0.10.4 → 0.10.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/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
  }
@@ -759,8 +765,9 @@ async function goOffline(args, state, log = defaultLog) {
759
765
 
760
766
  export const RUNNER_HELP = `
761
767
  Runner options:
762
- runner start [--background] Hold the job stream and run queued work here
763
- runner register [--agent a] Pair this machine as a runner (connect does this)
768
+ runner start [--background] Hold the job stream and run queued work here,
769
+ pairing this machine first if it is not paired
770
+ runner register [--agent a] Pair without starting (start does this for you)
764
771
  runner status Report whether the local runner is running
765
772
  runner stop Stop the background runner and go offline
766
773
  --agent <name> claude-code | cursor | codex
@@ -815,6 +822,19 @@ export async function confirmRunnerOnline({ pid, offset = 0, timeoutMs = 12_000,
815
822
  }
816
823
  }
817
824
 
825
+ /**
826
+ * Does the backend still honour this pairing's token?
827
+ *
828
+ * One heartbeat, which is the only call that can answer it. A backend we cannot
829
+ * reach answers yes: re-pairing is for a token that was rejected, not for a
830
+ * network that blinked, and registering again would spend a pair code to fix
831
+ * nothing.
832
+ */
833
+ export async function pairingIsLive(args, state) {
834
+ const { revoked } = await sendHeartbeat(args, state, { log: () => {} });
835
+ return !revoked;
836
+ }
837
+
818
838
  /** Start the daemon detached, so a terminal can be closed without killing it. */
819
839
  export function startBackground(commandArgs) {
820
840
  ensureDir();
@@ -839,11 +859,21 @@ async function startForeground(args, commandArgs) {
839
859
  const agent = normalizeAgentId(args.value("--agent", "")) || "";
840
860
  if (!agent) {
841
861
  throw new RunnerError(
842
- `This machine is not paired as a runner yet. Run '${cliInvocation()} runner register --agent claude-code'.`,
862
+ "This machine is not paired as a runner yet, and which agent to pair as could not be" +
863
+ ` worked out here. Say so directly: '${cliInvocation()} runner start --background` +
864
+ " --agent cursor|claude-code|codex'.",
843
865
  2
844
866
  );
845
867
  }
846
868
  state = await registerRunner(args, { agent, projectPath: args.value("--path", process.cwd()) });
869
+ } else if (!(await pairingIsLive(args, state))) {
870
+ // A stored pairing is not a working one. The backend revokes a runner when
871
+ // another device takes over, when the key behind it is deleted, or when this
872
+ // machine was last seen going offline -- and starting on one of those dies on
873
+ // the first stream call with a 401, which reads as "the runner cannot run
874
+ // here" when the token was the only thing that needed replacing.
875
+ defaultLog("stored pairing was rejected; registering this machine again");
876
+ state = await registerRunner(args, { agent: state.agent, projectPath: state.project_path });
847
877
  }
848
878
 
849
879
  const log = defaultLog;
@@ -888,9 +918,22 @@ async function startForeground(args, commandArgs) {
888
918
  }
889
919
  }
890
920
 
891
- export async function runnerCommand(commandArgs = []) {
921
+ export async function runnerCommand(commandArgs = [], { resolveAgent = null } = {}) {
892
922
  const sub = commandArgs.find((value) => !value.startsWith("-")) || "status";
893
- const args = makeArgs(commandArgs);
923
+ let args = makeArgs(commandArgs);
924
+
925
+ // Pairing needs an agent id, and the only place that can be asked for one is
926
+ // here: `--background` hands the work to a detached child with no terminal, so
927
+ // a question deferred to it is a question nobody ever sees.
928
+ if ((sub === "register" || sub === "start") && resolveAgent && !args.value("--agent", "")) {
929
+ if (sub === "register" || !readRunnerState()) {
930
+ const resolved = await resolveAgent();
931
+ if (resolved) {
932
+ commandArgs = [...commandArgs, "--agent", resolved];
933
+ args = makeArgs(commandArgs);
934
+ }
935
+ }
936
+ }
894
937
 
895
938
  if (sub === "register") {
896
939
  const agent = normalizeAgentId(args.value("--agent", ""));
@@ -939,7 +982,7 @@ export async function runnerCommand(commandArgs = []) {
939
982
  const alive = runnerIsAlive(pid);
940
983
  if (!state) {
941
984
  process.stdout.write(
942
- `Local runner: not paired\n Pair it: ${cliInvocation()} runner register --agent claude-code\n`
985
+ `Local runner: not paired\n Start it: ${cliInvocation()} runner start --background\n`
943
986
  );
944
987
  return { state: "unpaired" };
945
988
  }
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/dist/server.js CHANGED
@@ -3,10 +3,12 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
3
3
  import { z } from "zod";
4
4
  import { registerUserAuthFlowTools } from "./user_auth_flow.js";
5
5
  import { shareAuthFlowToUi } from "./auth_flow_ui.js";
6
+ import { spawn } from "node:child_process";
6
7
  import { existsSync, readFileSync } from "node:fs";
7
8
  import fs from "node:fs/promises";
8
9
  import path from "node:path";
9
10
  import os from "node:os";
11
+ import { fileURLToPath } from "node:url";
10
12
  import { randomUUID } from "node:crypto";
11
13
  import { MCP_PREVIEW_RESOURCE_URI, RESOURCE_URI_META_KEY, buildConversionPanelHtml, writeMcpPreviewFile, } from "./mcp-preview-panel.js";
12
14
  let REPO_CONFIG = null;
@@ -1444,11 +1446,34 @@ export function createServer() {
1444
1446
  return server;
1445
1447
  }
1446
1448
  // ── Start ─────────────────────────────────────────────────────────────
1449
+ /**
1450
+ * A pre-push hook PreMan wrote can stop working — the machine gained a `preman`
1451
+ * belonging to another package, or lost the one it pinned — and the only symptom
1452
+ * is one skipped line per push. This process starting is proof PreMan runs here,
1453
+ * so it is a good moment to check. Detached with stdio ignored: the check shells
1454
+ * out to npm, and this process's stdout is the MCP protocol.
1455
+ */
1456
+ function repairPushHookInBackground() {
1457
+ try {
1458
+ const cli = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "bin", "cli.js");
1459
+ if (!existsSync(cli))
1460
+ return;
1461
+ const child = spawn(process.execPath, [cli, "hook", "repair", "--if-stale"], {
1462
+ detached: true,
1463
+ stdio: "ignore",
1464
+ });
1465
+ child.unref();
1466
+ }
1467
+ catch {
1468
+ // Never worth failing a server start over.
1469
+ }
1470
+ }
1447
1471
  async function main() {
1448
1472
  await initAuth();
1449
1473
  const server = createServer();
1450
1474
  const transport = new StdioServerTransport();
1451
1475
  await server.connect(transport);
1476
+ repairPushHookInBackground();
1452
1477
  // Starting the configured MCP server is itself proof that the coding agent
1453
1478
  // is live. Previously the workbench remained in `connecting` until someone
1454
1479
  // explicitly asked the agent to call `preman_status`, even when this process
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "premanmcp",
3
- "version": "0.10.4",
3
+ "version": "0.10.6",
4
4
  "description": "Turn APIs into agent-callable MCP tools with auth, testing, and audit logs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,7 @@
15
15
  "open-mcp-preview-in-cursor": "node scripts/open-cursor-preview.mjs",
16
16
  "test": "npm run build && npm run test:connect && npm run test:node",
17
17
  "test:connect": "node scripts/smoke-connect.mjs",
18
- "test:node": "node --test --test-timeout=30000 scripts/smoke-launcher-config.mjs scripts/smoke-runner.mjs scripts/smoke-repo-config.mjs scripts/smoke-onboard.mjs scripts/smoke-local-detect.mjs scripts/smoke-prepush-hook.mjs scripts/smoke-cli-identity.mjs scripts/smoke-runner-heartbeat.mjs scripts/smoke-verify-prepush.mjs scripts/smoke-push-diff.mjs scripts/smoke-progress-reporter.mjs scripts/smoke-verify-plan.mjs scripts/smoke-install-desktop.mjs"
18
+ "test:node": "node --test --test-timeout=90000 scripts/smoke-launcher-config.mjs scripts/smoke-runner.mjs scripts/smoke-repo-config.mjs scripts/smoke-onboard.mjs scripts/smoke-local-detect.mjs scripts/smoke-prepush-hook.mjs scripts/smoke-cli-identity.mjs scripts/smoke-runner-heartbeat.mjs scripts/smoke-verify-prepush.mjs scripts/smoke-push-diff.mjs scripts/smoke-progress-reporter.mjs scripts/smoke-verify-plan.mjs scripts/smoke-install-desktop.mjs scripts/smoke-api-tools.mjs scripts/smoke-bin-scope.mjs"
19
19
  },
20
20
  "dependencies": {
21
21
  "@modelcontextprotocol/ext-apps": "^0.1.0",