premanmcp 0.10.0 → 0.10.1
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 +7 -3
- package/bin/account.js +1 -1
- package/bin/api_tools.js +2 -2
- package/bin/connect.js +101 -15
- package/bin/hook.js +16 -4
- package/bin/integrations.js +41 -20
- package/bin/runner.js +70 -4
- package/bin/shared.js +53 -6
- package/dist/server.js +1 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -68,9 +68,13 @@ Once linked, `connect` finishes onboarding without handing you homework:
|
|
|
68
68
|
| Testing on push | Installs the git pre-push hook, so `git push` checks the endpoints you touched |
|
|
69
69
|
|
|
70
70
|
Useful flags: `--agent cursor|claude-code|codex` skips the picker, `--project` writes
|
|
71
|
-
project-local config, `--print` shows the config without writing it, `--yes`
|
|
72
|
-
|
|
73
|
-
`--no-integrations` / `--no-hook` skip one each.
|
|
71
|
+
project-local config, `--print` shows the config without writing it, `--yes` takes every
|
|
72
|
+
step's default without asking, `--no-guide` skips all of them, and `--no-runner` /
|
|
73
|
+
`--no-desktop` / `--no-integrations` / `--no-hook` skip one each.
|
|
74
|
+
|
|
75
|
+
`--yes` deliberately does *not* install the desktop app: that step's default is no, because
|
|
76
|
+
it downloads a hundred-odd megabytes and writes to `/Applications`. Run `install-desktop`
|
|
77
|
+
when you want it.
|
|
74
78
|
|
|
75
79
|
In CI or any non-interactive shell, run `connect --agent <name> --api-key pm_live_…`.
|
|
76
80
|
Without `--agent` there is nothing to prompt on, so `connect` prints ready-to-paste
|
package/bin/account.js
CHANGED
|
@@ -178,7 +178,7 @@ export async function watchCommand(commandArgs = []) {
|
|
|
178
178
|
const integrationId = args.value("--integration", positional[1] || "");
|
|
179
179
|
if (!runId || !integrationId) {
|
|
180
180
|
throw new Error(
|
|
181
|
-
|
|
181
|
+
`usage: watch <run-id> <integration-id> (both are shown by \`${cliInvocation()} status\`)`
|
|
182
182
|
);
|
|
183
183
|
}
|
|
184
184
|
|
package/bin/api_tools.js
CHANGED
|
@@ -45,7 +45,7 @@ export async function callTool(args, tool, toolArguments) {
|
|
|
45
45
|
const token = resolveApiKey(args);
|
|
46
46
|
if (!token) {
|
|
47
47
|
throw new CliError(
|
|
48
|
-
|
|
48
|
+
`No PreMan API key. Run \`${cliInvocation()} login\` or pass --api-key pm_live_...`,
|
|
49
49
|
2,
|
|
50
50
|
);
|
|
51
51
|
}
|
|
@@ -110,7 +110,7 @@ export async function endpointsCommand(commandArgs) {
|
|
|
110
110
|
if (args.has("--json-out")) return printJson(result);
|
|
111
111
|
for (const line of result.instructions || []) process.stdout.write(`${line}\n`);
|
|
112
112
|
process.stdout.write(
|
|
113
|
-
|
|
113
|
+
`\nHand this brief to your coding agent, then run \`${cliInvocation()} endpoints setup --file endpoints.json\`.\n`,
|
|
114
114
|
);
|
|
115
115
|
return undefined;
|
|
116
116
|
}
|
package/bin/connect.js
CHANGED
|
@@ -19,7 +19,13 @@ import { callTool as callPremanTool, printTestSummary } from "./api_tools.js";
|
|
|
19
19
|
import { installDesktopCommand } from "./desktop.js";
|
|
20
20
|
import { installHook, hookStatus } from "./hook.js";
|
|
21
21
|
import { MARK, awsCommand, githubCommand, slackCommand } from "./integrations.js";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
confirmRunnerOnline,
|
|
24
|
+
readRunnerState,
|
|
25
|
+
registerRunner,
|
|
26
|
+
runnerIsAlive,
|
|
27
|
+
startBackground,
|
|
28
|
+
} from "./runner.js";
|
|
23
29
|
import {
|
|
24
30
|
apiKeyIsExplicit,
|
|
25
31
|
assertOk,
|
|
@@ -34,6 +40,7 @@ import {
|
|
|
34
40
|
LAUNCHER_ARGS,
|
|
35
41
|
LAUNCHER_COMMAND,
|
|
36
42
|
makeArgs,
|
|
43
|
+
pathPremanOwner,
|
|
37
44
|
promptSecret,
|
|
38
45
|
promptText,
|
|
39
46
|
readJsonFile,
|
|
@@ -103,6 +110,29 @@ function onPath(binary) {
|
|
|
103
110
|
return probe.status === 0;
|
|
104
111
|
}
|
|
105
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Why this agent's CLI cannot run unattended right now, or "".
|
|
115
|
+
*
|
|
116
|
+
* Installed is not the same as usable. `cursor-agent` sits on PATH and exits 1
|
|
117
|
+
* on every `-p` run until someone signs in, which surfaced as "Cursor did not
|
|
118
|
+
* register any endpoints" and sent people looking at PreMan for an hour. Only
|
|
119
|
+
* Cursor is probed because only its CLI has a cheap non-interactive status
|
|
120
|
+
* subcommand; the others are diagnosed from their own output when they fail.
|
|
121
|
+
*/
|
|
122
|
+
export function agentBlocker(agentId) {
|
|
123
|
+
if (agentId !== "cursor") return "";
|
|
124
|
+
const probe = spawnSync("cursor-agent", ["status"], {
|
|
125
|
+
encoding: "utf8",
|
|
126
|
+
timeout: 15000,
|
|
127
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
128
|
+
});
|
|
129
|
+
const text = `${probe.stdout || ""}${probe.stderr || ""}`;
|
|
130
|
+
if (/not logged in|not authenticated|no active session/i.test(text)) {
|
|
131
|
+
return "cursor-agent is installed but not signed in — run `cursor-agent login`";
|
|
132
|
+
}
|
|
133
|
+
return "";
|
|
134
|
+
}
|
|
135
|
+
|
|
106
136
|
/** Best guess at which agent this machine actually uses, for the default pick. */
|
|
107
137
|
function detectAgents() {
|
|
108
138
|
const home = os.homedir();
|
|
@@ -878,8 +908,17 @@ function nextStepsBlock(agent) {
|
|
|
878
908
|
);
|
|
879
909
|
}
|
|
880
910
|
|
|
911
|
+
/**
|
|
912
|
+
* Ask, or take the step's own default when nobody can answer.
|
|
913
|
+
*
|
|
914
|
+
* `--yes` returns the default rather than a blanket yes, which matters for
|
|
915
|
+
* exactly one step: the desktop app defaults to no because it downloads a
|
|
916
|
+
* hundred-odd megabytes and writes to /Applications. A blanket yes made
|
|
917
|
+
* `preman connect --yes` do that unattended, which is not what anyone means by
|
|
918
|
+
* "do not ask me questions" -- they get `preman install-desktop` for that.
|
|
919
|
+
*/
|
|
881
920
|
async function confirm(question, { assumeYes = false, defaultYes = true } = {}) {
|
|
882
|
-
if (assumeYes) return
|
|
921
|
+
if (assumeYes) return defaultYes;
|
|
883
922
|
if (!process.stdin.isTTY) return false;
|
|
884
923
|
const answer = (await promptText(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"}: `)).toLowerCase();
|
|
885
924
|
if (answer === "") return defaultYes;
|
|
@@ -951,7 +990,11 @@ async function discoverEndpoints(args, agent, serverName) {
|
|
|
951
990
|
|
|
952
991
|
const brief = await callPremanTool(args, "discover_endpoints_from_codebase", { base_path: "." });
|
|
953
992
|
const spec = headlessDiscovery(agent, serverName, brief.instructions || []);
|
|
954
|
-
|
|
993
|
+
const blocker = spec && onPath(spec.bin) ? agentBlocker(agent.id) : "";
|
|
994
|
+
if (!spec || !onPath(spec.bin) || blocker) {
|
|
995
|
+
// Say why before printing homework: "here is a brief" reads as PreMan not
|
|
996
|
+
// working, when the actual answer is one login away.
|
|
997
|
+
if (blocker) process.stdout.write(`${MARK.skip()} ${blocker}\n`);
|
|
955
998
|
for (const line of brief.instructions || []) process.stdout.write(`${line}\n`);
|
|
956
999
|
process.stdout.write(`\nHand this brief to ${agent.label}, then run:\n${MANUAL_STEPS}`);
|
|
957
1000
|
return before;
|
|
@@ -963,7 +1006,11 @@ async function discoverEndpoints(args, agent, serverName) {
|
|
|
963
1006
|
const after = await endpointCounts(args);
|
|
964
1007
|
|
|
965
1008
|
if (!after.registered) {
|
|
966
|
-
|
|
1009
|
+
// Both halves: the exit code says the run failed, the agent's own last line
|
|
1010
|
+
// says why, and either one alone has sent someone down the wrong path.
|
|
1011
|
+
const why =
|
|
1012
|
+
[outcome.reason, lastLine(outcome.output)].filter(Boolean).join(" · ") ||
|
|
1013
|
+
"it registered nothing";
|
|
967
1014
|
process.stdout.write(
|
|
968
1015
|
`${MARK.fail()} ${agent.label} did not register any endpoints (${why}).\n` +
|
|
969
1016
|
` Run it yourself: ${cliInvocation()} endpoints discover\n`
|
|
@@ -1069,10 +1116,28 @@ async function setUpRunner(args, agent, { assumeYes }) {
|
|
|
1069
1116
|
await registerRunner(args, { agent: agent.id, projectPath: process.cwd() });
|
|
1070
1117
|
}
|
|
1071
1118
|
const started = startBackground([]);
|
|
1119
|
+
// Claiming a runner that died two seconds later is worse than saying nothing:
|
|
1120
|
+
// the whole point of this step is that PreMan can act, and someone told it
|
|
1121
|
+
// can will wait for fixes that no device is listening for.
|
|
1122
|
+
const up = await confirmRunnerOnline(started);
|
|
1123
|
+
if (up.state === "exited") {
|
|
1124
|
+
process.stdout.write(
|
|
1125
|
+
`${MARK.fail()} The runner exited right after starting.\n` +
|
|
1126
|
+
(up.detail ? ` ${up.detail}\n` : "") +
|
|
1127
|
+
` Log: ${started.log} Retry: ${cliInvocation()} runner start --background\n`
|
|
1128
|
+
);
|
|
1129
|
+
return { state: "failed", detail: up.detail };
|
|
1130
|
+
}
|
|
1072
1131
|
process.stdout.write(
|
|
1073
|
-
`${MARK.ok()} Runner
|
|
1132
|
+
`${MARK.ok()} Runner ${up.state === "online" ? "online" : "starting"} (pid ${started.pid}) — PreMan can apply fixes on this machine.\n` +
|
|
1074
1133
|
` Log: ${started.log} Stop: ${cliInvocation()} runner stop\n`
|
|
1075
1134
|
);
|
|
1135
|
+
// Paired and online still cannot run anything if the agent it dispatches to
|
|
1136
|
+
// will not start, and every job would fail with the same opaque exit code.
|
|
1137
|
+
const blocker = agentBlocker(agent.id);
|
|
1138
|
+
if (blocker) {
|
|
1139
|
+
process.stdout.write(` ${MARK.skip()} Jobs will fail until you fix this: ${blocker}\n`);
|
|
1140
|
+
}
|
|
1076
1141
|
return { state: "running", pid: started.pid };
|
|
1077
1142
|
} catch (error) {
|
|
1078
1143
|
process.stdout.write(
|
|
@@ -1178,8 +1243,14 @@ async function connectIntegrations(args, apiKey, { assumeYes }) {
|
|
|
1178
1243
|
}
|
|
1179
1244
|
}
|
|
1180
1245
|
|
|
1181
|
-
/**
|
|
1182
|
-
|
|
1246
|
+
/**
|
|
1247
|
+
* Install the pre-push hook, so a push is what triggers the tests.
|
|
1248
|
+
*
|
|
1249
|
+
* Not a question: testing what you just changed before it ships is the product,
|
|
1250
|
+
* the hook cannot block a push, and `--no-hook` / `PREMAN_SKIP_HOOK=1` are both
|
|
1251
|
+
* still there. Asking only produced accounts that never tested anything.
|
|
1252
|
+
*/
|
|
1253
|
+
async function setUpPushTesting(args) {
|
|
1183
1254
|
if (args.has("--no-hook")) return { state: "skipped" };
|
|
1184
1255
|
const current = (() => {
|
|
1185
1256
|
try {
|
|
@@ -1192,14 +1263,12 @@ async function setUpPushTesting(args, { assumeYes }) {
|
|
|
1192
1263
|
process.stdout.write(`${MARK.skip()} Not a git repository — no push testing here.\n`);
|
|
1193
1264
|
return { state: "unavailable" };
|
|
1194
1265
|
}
|
|
1195
|
-
|
|
1266
|
+
// A hook whose invocation went stale is reinstalled rather than reported as on:
|
|
1267
|
+
// it is the case where PreMan looks connected and silently checks nothing.
|
|
1268
|
+
if (current.state === "installed" && current.current) {
|
|
1196
1269
|
process.stdout.write(`${MARK.ok()} Push testing already on.\n`);
|
|
1197
1270
|
return { state: "installed" };
|
|
1198
1271
|
}
|
|
1199
|
-
if (!(await confirm("Test the endpoints you touched on every git push?", { assumeYes }))) {
|
|
1200
|
-
process.stdout.write(`${MARK.skip()} Skipped. Turn it on: ${cliInvocation()} hook install\n`);
|
|
1201
|
-
return { state: "skipped" };
|
|
1202
|
-
}
|
|
1203
1272
|
|
|
1204
1273
|
const result = installHook(args);
|
|
1205
1274
|
if (result.action === "conflict") {
|
|
@@ -1242,7 +1311,7 @@ async function guidedFirstRun(args, agent, apiKey, serverName) {
|
|
|
1242
1311
|
await connectIntegrations(args, apiKey, { assumeYes });
|
|
1243
1312
|
|
|
1244
1313
|
step("Testing on push");
|
|
1245
|
-
await setUpPushTesting(args
|
|
1314
|
+
await setUpPushTesting(args);
|
|
1246
1315
|
|
|
1247
1316
|
process.stdout.write(`\nDone. Watch it at ${frontendUrl(args)}\n`);
|
|
1248
1317
|
}
|
|
@@ -1273,6 +1342,16 @@ export async function preflight(args) {
|
|
|
1273
1342
|
);
|
|
1274
1343
|
}
|
|
1275
1344
|
|
|
1345
|
+
// Explains why every command below is spelled the long way, before someone
|
|
1346
|
+
// types `preman …` and gets another package's CLI answering.
|
|
1347
|
+
const premanOwner = pathPremanOwner();
|
|
1348
|
+
if (premanOwner && premanOwner !== "premanmcp") {
|
|
1349
|
+
notes.push(
|
|
1350
|
+
`\`preman\` on your PATH belongs to ${premanOwner}, not this CLI, so PreMan's own ` +
|
|
1351
|
+
"commands are written out as `npm exec -y premanmcp@latest -- …`."
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1276
1355
|
try {
|
|
1277
1356
|
const resp = await fetch(new URL("health", `${backendUrl(args)}/`), {
|
|
1278
1357
|
signal: AbortSignal.timeout(4000),
|
|
@@ -1315,7 +1394,8 @@ Connect options:
|
|
|
1315
1394
|
--no-desktop Do not offer the desktop app
|
|
1316
1395
|
--no-integrations Do not check or offer GitHub / AWS / Slack
|
|
1317
1396
|
--no-hook Do not install the git pre-push hook
|
|
1318
|
-
--yes
|
|
1397
|
+
--yes Take every step's default without prompting
|
|
1398
|
+
(the desktop app defaults to no; install-desktop)
|
|
1319
1399
|
--print Print the config instead of writing it
|
|
1320
1400
|
`;
|
|
1321
1401
|
|
|
@@ -1482,7 +1562,8 @@ async function establishCheckIn(args, agent, apiKey, { serverName, written, serv
|
|
|
1482
1562
|
);
|
|
1483
1563
|
}
|
|
1484
1564
|
|
|
1485
|
-
|
|
1565
|
+
const blocker = agentBlocker(agent.id);
|
|
1566
|
+
if (!args.has("--no-auto-checkin") && !blocker) {
|
|
1486
1567
|
const spec = headlessCheckIn(agent, serverName);
|
|
1487
1568
|
if (spec && onPath(spec.bin)) {
|
|
1488
1569
|
process.stdout.write(`\nStarting ${agent.label} to finish the link…\n`);
|
|
@@ -1500,6 +1581,11 @@ async function establishCheckIn(args, agent, apiKey, { serverName, written, serv
|
|
|
1500
1581
|
} else if (auto.reason) {
|
|
1501
1582
|
process.stdout.write(`Could not run ${agent.label}: ${auto.reason}\n`);
|
|
1502
1583
|
}
|
|
1584
|
+
} else if (blocker) {
|
|
1585
|
+
// Starting an agent that cannot authenticate spends two minutes to learn
|
|
1586
|
+
// what one status probe already knows.
|
|
1587
|
+
process.stdout.write(`${MARK.skip()} ${blocker}\n`);
|
|
1588
|
+
notes.push(blocker);
|
|
1503
1589
|
}
|
|
1504
1590
|
|
|
1505
1591
|
process.stdout.write(
|
package/bin/hook.js
CHANGED
|
@@ -56,8 +56,10 @@ if [ -z "\${PREMAN_SKIP_HOOK}" ]; then
|
|
|
56
56
|
if [ "$preman_status" -eq ${BLOCK_EXIT_CODE} ]; then
|
|
57
57
|
exit ${BLOCK_EXIT_CODE}
|
|
58
58
|
fi
|
|
59
|
-
if [ "$preman_status" -
|
|
60
|
-
printf '[preman] checks skipped
|
|
59
|
+
if [ "$preman_status" -eq 127 ]; then
|
|
60
|
+
printf '[preman] the PreMan CLI is not on PATH; push checks skipped\\n' >&2
|
|
61
|
+
elif [ "$preman_status" -ne 0 ]; then
|
|
62
|
+
printf '[preman] checks skipped (advisory, exit %s)\\n' "$preman_status" >&2
|
|
61
63
|
fi
|
|
62
64
|
fi
|
|
63
65
|
${END_MARKER}
|
|
@@ -116,11 +118,21 @@ export function uninstallHook() {
|
|
|
116
118
|
return { path: target, action: "removed" };
|
|
117
119
|
}
|
|
118
120
|
|
|
121
|
+
/**
|
|
122
|
+
* `current` is what tells a caller an installed hook still needs rewriting.
|
|
123
|
+
*
|
|
124
|
+
* 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.
|
|
129
|
+
*/
|
|
119
130
|
export function hookStatus() {
|
|
120
131
|
const target = hookPath();
|
|
121
|
-
if (!existsSync(target)) return { path: target, state: "absent" };
|
|
132
|
+
if (!existsSync(target)) return { path: target, state: "absent", current: false };
|
|
122
133
|
const existing = readFileSync(target, "utf8");
|
|
123
|
-
return { path: target, state:
|
|
134
|
+
if (!isOurHook(existing)) return { path: target, state: "foreign", current: false };
|
|
135
|
+
return { path: target, state: "installed", current: existing === hookBody() };
|
|
124
136
|
}
|
|
125
137
|
|
|
126
138
|
export async function hookCommand(commandArgs = []) {
|
package/bin/integrations.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import {
|
|
18
18
|
assertOk,
|
|
19
19
|
callBackendJson,
|
|
20
|
+
cliInvocation,
|
|
20
21
|
frontendUrl,
|
|
21
22
|
openUrl,
|
|
22
23
|
promptText,
|
|
@@ -74,8 +75,10 @@ export class Unrecoverable extends Error {}
|
|
|
74
75
|
* Returns the truthy value from ``check``, or null on timeout. Ordinary
|
|
75
76
|
* exceptions are swallowed and retried; :class:`Unrecoverable` stops the wait.
|
|
76
77
|
*/
|
|
77
|
-
async function waitFor(label, check) {
|
|
78
|
-
const
|
|
78
|
+
async function waitFor(label, check, { hint = "", hintAfterMs = 60000 } = {}) {
|
|
79
|
+
const startedAt = Date.now();
|
|
80
|
+
const deadline = startedAt + POLL_TIMEOUT_MS;
|
|
81
|
+
let hinted = false;
|
|
79
82
|
process.stdout.write(`Waiting for ${label}`);
|
|
80
83
|
while (Date.now() < deadline) {
|
|
81
84
|
try {
|
|
@@ -91,6 +94,12 @@ async function waitFor(label, check) {
|
|
|
91
94
|
}
|
|
92
95
|
/* keep waiting; the customer is elsewhere */
|
|
93
96
|
}
|
|
97
|
+
// A minute of dots is indistinguishable from a hang, and the reason this
|
|
98
|
+
// waits forever is usually something the customer can act on.
|
|
99
|
+
if (hint && !hinted && Date.now() - startedAt > hintAfterMs) {
|
|
100
|
+
hinted = true;
|
|
101
|
+
process.stdout.write(`\n${hint}\nStill waiting`);
|
|
102
|
+
}
|
|
94
103
|
process.stdout.write(".");
|
|
95
104
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
96
105
|
}
|
|
@@ -101,7 +110,7 @@ async function waitFor(label, check) {
|
|
|
101
110
|
function requireKey(args) {
|
|
102
111
|
const apiKey = resolveApiKey(args);
|
|
103
112
|
if (!apiKey) {
|
|
104
|
-
throw new Error(
|
|
113
|
+
throw new Error(`Not signed in. Run '${cliInvocation()} login' first.`);
|
|
105
114
|
}
|
|
106
115
|
return apiKey;
|
|
107
116
|
}
|
|
@@ -156,7 +165,7 @@ export async function awsCommand(args) {
|
|
|
156
165
|
}
|
|
157
166
|
|
|
158
167
|
if (!verified) {
|
|
159
|
-
process.stdout.write(
|
|
168
|
+
process.stdout.write(`Timed out. Re-run '${cliInvocation()} aws' once the stack finishes.\n`);
|
|
160
169
|
return;
|
|
161
170
|
}
|
|
162
171
|
connected(`AWS connected: ${verified.role_arn}`);
|
|
@@ -223,23 +232,35 @@ export async function githubCommand(args) {
|
|
|
223
232
|
present(url, "install the PreMan GitHub App");
|
|
224
233
|
process.stdout.write("Pick the repositories PreMan may read.\n");
|
|
225
234
|
|
|
226
|
-
const done = await waitFor(
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
235
|
+
const done = await waitFor(
|
|
236
|
+
"the installation",
|
|
237
|
+
async () => {
|
|
238
|
+
// Installing the App and having repositories appear are two events: the
|
|
239
|
+
// callback records the installation, and a refresh materialises the repos.
|
|
240
|
+
// Polling the repo list alone waits for something that may never arrive on
|
|
241
|
+
// its own.
|
|
242
|
+
await callBackendJson(args, "POST", "/integrations/github/app/refresh", {
|
|
243
|
+
token,
|
|
244
|
+
json: {},
|
|
245
|
+
});
|
|
246
|
+
// Compare against what existed before, so a user who already had repos
|
|
247
|
+
// connected is not told they are done the moment polling starts.
|
|
248
|
+
const fresh = (await listRepos()).filter((r) => !seen.has(r.id));
|
|
249
|
+
return fresh.length ? fresh : null;
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
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.",
|
|
255
|
+
}
|
|
256
|
+
);
|
|
240
257
|
|
|
241
258
|
if (!done) {
|
|
242
|
-
process.stdout.write(
|
|
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
|
+
);
|
|
243
264
|
return;
|
|
244
265
|
}
|
|
245
266
|
connected(`GitHub connected: ${done.length} repository(ies).`);
|
|
@@ -270,7 +291,7 @@ export async function slackCommand(args) {
|
|
|
270
291
|
});
|
|
271
292
|
|
|
272
293
|
if (!done) {
|
|
273
|
-
process.stdout.write(
|
|
294
|
+
process.stdout.write(`Timed out. Re-run '${cliInvocation()} slack' if the install finished.\n`);
|
|
274
295
|
return;
|
|
275
296
|
}
|
|
276
297
|
connected(`Slack connected: ${done[0].team_name || done[0].id}`);
|
package/bin/runner.js
CHANGED
|
@@ -24,7 +24,16 @@
|
|
|
24
24
|
|
|
25
25
|
import { spawn, spawnSync } from "node:child_process";
|
|
26
26
|
import { createHash } from "node:crypto";
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
chmodSync,
|
|
29
|
+
existsSync,
|
|
30
|
+
mkdirSync,
|
|
31
|
+
openSync,
|
|
32
|
+
readFileSync,
|
|
33
|
+
rmSync,
|
|
34
|
+
statSync,
|
|
35
|
+
writeFileSync,
|
|
36
|
+
} from "node:fs";
|
|
28
37
|
import os from "node:os";
|
|
29
38
|
import path from "node:path";
|
|
30
39
|
import { fileURLToPath } from "node:url";
|
|
@@ -739,9 +748,56 @@ Runner options:
|
|
|
739
748
|
--full-access Let the agent run commands, not just edit files
|
|
740
749
|
`;
|
|
741
750
|
|
|
751
|
+
function logSize() {
|
|
752
|
+
try {
|
|
753
|
+
return statSync(RUNNER_LOG_FILE).size;
|
|
754
|
+
} catch {
|
|
755
|
+
return 0;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** Whatever the daemon has logged since `offset`, exactly (the log is appended to). */
|
|
760
|
+
export function runnerLogSince(offset = 0) {
|
|
761
|
+
try {
|
|
762
|
+
const buffer = readFileSync(RUNNER_LOG_FILE);
|
|
763
|
+
return buffer.subarray(Math.min(Math.max(0, offset), buffer.length)).toString("utf8");
|
|
764
|
+
} catch {
|
|
765
|
+
return "";
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function tailLine(text, cap = 240) {
|
|
770
|
+
const lines = String(text || "")
|
|
771
|
+
.split("\n")
|
|
772
|
+
.map((line) => line.trim())
|
|
773
|
+
.filter(Boolean);
|
|
774
|
+
return lines.length ? lines[lines.length - 1].slice(0, cap) : "";
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Wait for a just-spawned daemon to prove it is up.
|
|
779
|
+
*
|
|
780
|
+
* A pid from `spawn` is not a running runner: registration can be rejected, the
|
|
781
|
+
* token can be dead, the stream can refuse the connection, and every one of those
|
|
782
|
+
* exits within a second or two of a "Runner running (pid …)" line nobody had any
|
|
783
|
+
* reason to doubt. The log offset is taken before the spawn so an earlier
|
|
784
|
+
* daemon's "connected" line cannot be mistaken for this one's.
|
|
785
|
+
*/
|
|
786
|
+
export async function confirmRunnerOnline({ pid, offset = 0, timeoutMs = 12_000, intervalMs = 250 } = {}) {
|
|
787
|
+
const deadline = Date.now() + timeoutMs;
|
|
788
|
+
for (;;) {
|
|
789
|
+
const fresh = runnerLogSince(offset);
|
|
790
|
+
if (/connected as runner/.test(fresh)) return { state: "online", detail: "" };
|
|
791
|
+
if (!runnerIsAlive(pid)) return { state: "exited", detail: tailLine(fresh) };
|
|
792
|
+
if (Date.now() >= deadline) return { state: "starting", detail: tailLine(fresh) };
|
|
793
|
+
await sleep(intervalMs);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
742
797
|
/** Start the daemon detached, so a terminal can be closed without killing it. */
|
|
743
798
|
export function startBackground(commandArgs) {
|
|
744
799
|
ensureDir();
|
|
800
|
+
const offset = logSize();
|
|
745
801
|
// 0600 like everything else under ~/.preman: nothing in here is a credential
|
|
746
802
|
// today, and that is not a property to leave depending on future log lines.
|
|
747
803
|
const log = openSync(RUNNER_LOG_FILE, "a", 0o600);
|
|
@@ -753,7 +809,7 @@ export function startBackground(commandArgs) {
|
|
|
753
809
|
);
|
|
754
810
|
child.unref();
|
|
755
811
|
writeFileSync(RUNNER_PID_FILE, `${child.pid}\n`, { mode: 0o600 });
|
|
756
|
-
return { pid: child.pid, log: RUNNER_LOG_FILE };
|
|
812
|
+
return { pid: child.pid, log: RUNNER_LOG_FILE, offset };
|
|
757
813
|
}
|
|
758
814
|
|
|
759
815
|
async function startForeground(args, commandArgs) {
|
|
@@ -827,10 +883,20 @@ export async function runnerCommand(commandArgs = []) {
|
|
|
827
883
|
return { state: "running", pid: readPid() };
|
|
828
884
|
}
|
|
829
885
|
const started = startBackground(commandArgs.filter((value) => value !== "start"));
|
|
886
|
+
const up = await confirmRunnerOnline(started);
|
|
887
|
+
if (up.state === "exited") {
|
|
888
|
+
rmSync(RUNNER_PID_FILE, { force: true });
|
|
889
|
+
process.stdout.write(
|
|
890
|
+
`Runner exited right after starting.\n` +
|
|
891
|
+
(up.detail ? ` ${up.detail}\n` : "") +
|
|
892
|
+
` log: ${started.log}\n`
|
|
893
|
+
);
|
|
894
|
+
return { ...started, state: "exited" };
|
|
895
|
+
}
|
|
830
896
|
process.stdout.write(
|
|
831
|
-
`Runner
|
|
897
|
+
`Runner ${up.state === "online" ? "online" : "starting"} (pid ${started.pid}).\n log: ${started.log}\n`
|
|
832
898
|
);
|
|
833
|
-
return started;
|
|
899
|
+
return { ...started, state: up.state };
|
|
834
900
|
}
|
|
835
901
|
await startForeground(args, commandArgs);
|
|
836
902
|
return { state: "stopped" };
|
package/bin/shared.js
CHANGED
|
@@ -8,7 +8,15 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { spawn, spawnSync } from "node:child_process";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
chmodSync,
|
|
13
|
+
existsSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
realpathSync,
|
|
16
|
+
rmSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
} from "node:fs";
|
|
12
20
|
import os from "node:os";
|
|
13
21
|
import path from "node:path";
|
|
14
22
|
import { createInterface } from "node:readline/promises";
|
|
@@ -29,16 +37,55 @@ export const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json");
|
|
|
29
37
|
*/
|
|
30
38
|
let _invocation = null;
|
|
31
39
|
|
|
32
|
-
|
|
33
|
-
|
|
40
|
+
/**
|
|
41
|
+
* The package that owns the first `preman` on PATH, or "".
|
|
42
|
+
*
|
|
43
|
+
* `preman` is not ours by name alone: `preman-sdk` publishes a bin called
|
|
44
|
+
* exactly that, and whichever package lost the race still resolves. Assuming it
|
|
45
|
+
* is us writes hints and a git hook that call the other CLI — which answers
|
|
46
|
+
* "Unknown command: verify" and turns every push into a silent
|
|
47
|
+
* "[preman] checks skipped", the worst possible failure for a tool whose whole
|
|
48
|
+
* job is running checks on push.
|
|
49
|
+
*/
|
|
50
|
+
export function pathPremanOwner() {
|
|
34
51
|
const probe = process.platform === "win32" ? "where" : "which";
|
|
52
|
+
let resolved = "";
|
|
35
53
|
try {
|
|
36
54
|
const found = spawnSync(probe, ["preman"], { stdio: "pipe", encoding: "utf8" });
|
|
37
|
-
|
|
55
|
+
if (found.status !== 0) return "";
|
|
56
|
+
resolved = String(found.stdout || "").split("\n")[0].trim();
|
|
38
57
|
} catch {
|
|
39
|
-
|
|
58
|
+
return "";
|
|
40
59
|
}
|
|
41
|
-
|
|
60
|
+
if (!resolved) return "";
|
|
61
|
+
|
|
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;
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
return "";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function cliInvocation() {
|
|
82
|
+
return (_invocation ||=
|
|
83
|
+
pathPremanOwner() === "premanmcp" ? "preman" : "npm exec -y premanmcp@latest --");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Test seam: `cliInvocation` memoizes a PATH probe for the life of the process. */
|
|
87
|
+
export function resetCliInvocation() {
|
|
88
|
+
_invocation = null;
|
|
42
89
|
}
|
|
43
90
|
|
|
44
91
|
export function makeArgs(commandArgs = []) {
|
package/dist/server.js
CHANGED
|
@@ -1043,6 +1043,7 @@ export function createServer() {
|
|
|
1043
1043
|
backend_url: BACKEND_URL,
|
|
1044
1044
|
frontend_base_url: FRONTEND_BASE,
|
|
1045
1045
|
endpoints_page_url: buildAgentDashboardUrl("/endpoints"),
|
|
1046
|
+
config: configSource(),
|
|
1046
1047
|
message: "Not authenticated. Run `npm exec -y premanmcp@latest -- login`, or use user_auth_* then preman_create_api_key, or run preman_login.",
|
|
1047
1048
|
}),
|
|
1048
1049
|
}],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "premanmcp",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"description": "Turn APIs into agent-callable MCP tools with auth, testing, and audit logs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
"dev": "tsx src/server.ts",
|
|
14
14
|
"open-mcp-preview": "node scripts/emit-mcp-preview.mjs",
|
|
15
15
|
"open-mcp-preview-in-cursor": "node scripts/open-cursor-preview.mjs",
|
|
16
|
-
"test": "npm run test:connect && npm run test:node",
|
|
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-onboard.mjs scripts/smoke-local-detect.mjs scripts/smoke-prepush-hook.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=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-verify-prepush.mjs scripts/smoke-push-diff.mjs scripts/smoke-progress-reporter.mjs scripts/smoke-verify-plan.mjs scripts/smoke-install-desktop.mjs"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@modelcontextprotocol/ext-apps": "^0.1.0",
|