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/README.md +60 -14
- package/bin/account.js +34 -0
- package/bin/api_tools.js +1 -1
- package/bin/cli.js +23 -5
- package/bin/connect.js +130 -15
- package/bin/hook.js +276 -22
- package/bin/integrations.js +86 -13
- package/bin/runner.js +51 -8
- package/bin/shared.js +66 -20
- package/dist/server.js +25 -0
- package/package.json +2 -2
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
52
|
-
let
|
|
88
|
+
const onWindows = process.platform === "win32";
|
|
89
|
+
let candidates = [];
|
|
53
90
|
try {
|
|
54
|
-
const found = spawnSync(
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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.
|
|
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=
|
|
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",
|