premanmcp 0.10.0 → 0.10.2
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 +16 -6
- package/bin/account.js +1 -1
- package/bin/api_tools.js +2 -2
- package/bin/connect.js +328 -44
- package/bin/hook.js +16 -4
- package/bin/integrations.js +41 -20
- package/bin/runner.js +110 -13
- package/bin/shared.js +53 -6
- package/dist/server.js +1 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -48,13 +48,19 @@ to go restart anything, in cheapest-first order:
|
|
|
48
48
|
1. **Self-test.** It starts the MCP server exactly as your agent will and calls
|
|
49
49
|
`preman_status` over stdio. That both completes the link and proves the whole chain —
|
|
50
50
|
launcher, package, key, backend. `--no-self-test` turns it off.
|
|
51
|
-
2. **
|
|
52
|
-
|
|
51
|
+
2. **Agent run**, which also proves your agent can load what was written. It opens your
|
|
52
|
+
agent interactively in a new terminal window — the session you go on to use — and falls
|
|
53
|
+
back to a headless run (`claude -p`, `cursor-agent -p`, `codex exec`) where no window can
|
|
54
|
+
be opened, such as CI or SSH. `--no-auto-checkin` turns it off, `PREMAN_NO_TERMINAL=1`
|
|
55
|
+
keeps it headless.
|
|
53
56
|
3. **Wait**, if neither is possible: restart your agent and it links on its first call.
|
|
54
57
|
|
|
55
58
|
A self-test that answers from an unexpected backend is reported with the file that
|
|
56
59
|
redirected it — a repo-local `preman-mcp.config.json` with `"PREMAN_CONFIG_OVERRIDE": true`
|
|
57
|
-
wins over the MCP config env, and otherwise only fills in what the env leaves unset.
|
|
60
|
+
wins over the MCP config env, and otherwise only fills in what the env leaves unset. When
|
|
61
|
+
that file overrides `PREMAN_BACKEND`, `connect` stops there rather than waiting: an agent
|
|
62
|
+
started in that directory reads the same file and checks in somewhere else, so it names the
|
|
63
|
+
file and tells you how to connect to either backend.
|
|
58
64
|
|
|
59
65
|
Once linked, `connect` finishes onboarding without handing you homework:
|
|
60
66
|
|
|
@@ -68,9 +74,13 @@ Once linked, `connect` finishes onboarding without handing you homework:
|
|
|
68
74
|
| Testing on push | Installs the git pre-push hook, so `git push` checks the endpoints you touched |
|
|
69
75
|
|
|
70
76
|
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.
|
|
77
|
+
project-local config, `--print` shows the config without writing it, `--yes` takes every
|
|
78
|
+
step's default without asking, `--no-guide` skips all of them, and `--no-runner` /
|
|
79
|
+
`--no-desktop` / `--no-integrations` / `--no-hook` skip one each.
|
|
80
|
+
|
|
81
|
+
`--yes` deliberately does *not* install the desktop app: that step's default is no, because
|
|
82
|
+
it downloads a hundred-odd megabytes and writes to `/Applications`. Run `install-desktop`
|
|
83
|
+
when you want it.
|
|
74
84
|
|
|
75
85
|
In CI or any non-interactive shell, run `connect --agent <name> --api-key pm_live_…`.
|
|
76
86
|
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();
|
|
@@ -479,6 +509,9 @@ export async function waitForConnection(
|
|
|
479
509
|
intervalMs = Number(process.env.PREMAN_CONNECT_POLL_MS) || 3000,
|
|
480
510
|
timeoutMs = Number(process.env.PREMAN_CONNECT_WAIT_MS) || 300000,
|
|
481
511
|
stopWhen = null,
|
|
512
|
+
// Called once per unsuccessful poll, so a wait measured in minutes can show
|
|
513
|
+
// that it is still a wait rather than a hang.
|
|
514
|
+
onPoll = null,
|
|
482
515
|
} = {}
|
|
483
516
|
) {
|
|
484
517
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -495,6 +528,7 @@ export async function waitForConnection(
|
|
|
495
528
|
});
|
|
496
529
|
if (status.ok && status.connected) return true;
|
|
497
530
|
if (stopWhen && stopWhen()) return false;
|
|
531
|
+
if (onPoll) onPoll();
|
|
498
532
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
499
533
|
}
|
|
500
534
|
} finally {
|
|
@@ -503,6 +537,22 @@ export async function waitForConnection(
|
|
|
503
537
|
return false;
|
|
504
538
|
}
|
|
505
539
|
|
|
540
|
+
/** A dot per poll, and the newline that closes the run of them. */
|
|
541
|
+
function pollTicker() {
|
|
542
|
+
let dots = 0;
|
|
543
|
+
return {
|
|
544
|
+
tick() {
|
|
545
|
+
dots += 1;
|
|
546
|
+
process.stdout.write(".");
|
|
547
|
+
},
|
|
548
|
+
end() {
|
|
549
|
+
if (!dots) return;
|
|
550
|
+
dots = 0;
|
|
551
|
+
process.stdout.write("\n");
|
|
552
|
+
},
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
506
556
|
// ── Auto check-in ───────────────────────────────────────────────────────
|
|
507
557
|
|
|
508
558
|
/**
|
|
@@ -514,7 +564,7 @@ export async function waitForConnection(
|
|
|
514
564
|
* prompting.
|
|
515
565
|
*/
|
|
516
566
|
export function headlessCheckIn(agent, serverName) {
|
|
517
|
-
const prompt =
|
|
567
|
+
const prompt = checkInPrompt(serverName);
|
|
518
568
|
if (agent.id === "cursor") return { bin: "cursor-agent", args: ["-p", prompt] };
|
|
519
569
|
if (agent.id === "claude_code") {
|
|
520
570
|
return { bin: "claude", args: ["-p", prompt, "--allowedTools", `mcp__${serverName}`] };
|
|
@@ -523,13 +573,122 @@ export function headlessCheckIn(agent, serverName) {
|
|
|
523
573
|
return null;
|
|
524
574
|
}
|
|
525
575
|
|
|
576
|
+
function checkInPrompt(serverName) {
|
|
577
|
+
return `Call the ${serverName} MCP tool preman_status and report the result.`;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* How to start each agent as the session the user actually works in.
|
|
582
|
+
*
|
|
583
|
+
* Print mode (`-p`, `codex exec`) answers once and exits: it is a batch call, not
|
|
584
|
+
* the agent anybody goes on to use, and its failures are invisible because
|
|
585
|
+
* nobody is looking at it. The interactive form is the same prompt without that
|
|
586
|
+
* flag — it needs a terminal of its own, which is what openInNewTerminal is for.
|
|
587
|
+
*/
|
|
588
|
+
export function interactiveCheckIn(agent, serverName) {
|
|
589
|
+
const prompt = checkInPrompt(serverName);
|
|
590
|
+
if (agent.id === "cursor") return { bin: "cursor-agent", args: [prompt] };
|
|
591
|
+
if (agent.id === "claude_code") {
|
|
592
|
+
return { bin: "claude", args: ["--allowedTools", `mcp__${serverName}`, prompt] };
|
|
593
|
+
}
|
|
594
|
+
if (agent.id === "codex") return { bin: "codex", args: [prompt] };
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** A command line for a spec, quoted for the shell the terminal will start. */
|
|
599
|
+
export function commandLine(spec) {
|
|
600
|
+
return [spec.bin, ...spec.args].map(shellQuote).join(" ");
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Escape a shell line into an AppleScript string literal. */
|
|
604
|
+
function appleScriptString(value) {
|
|
605
|
+
return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* The terminal emulator to open a window in, or null when we know of none.
|
|
610
|
+
*
|
|
611
|
+
* `sync` marks the launchers that hand the work to an app and return — those
|
|
612
|
+
* report their own failure through an exit code, which is worth waiting for.
|
|
613
|
+
* The rest *are* the window, so they are spawned detached and outlive us.
|
|
614
|
+
*/
|
|
615
|
+
function terminalLauncher(line) {
|
|
616
|
+
if (process.platform === "darwin") {
|
|
617
|
+
const iterm = existsSync("/Applications/iTerm.app");
|
|
618
|
+
const script = iterm
|
|
619
|
+
? `tell application "iTerm"\nactivate\nset w to (create window with default profile)\ntell current session of w to write text ${appleScriptString(line)}\nend tell`
|
|
620
|
+
: `tell application "Terminal"\nactivate\ndo script ${appleScriptString(line)}\nend tell`;
|
|
621
|
+
return { bin: "osascript", args: ["-e", script], label: iterm ? "iTerm" : "Terminal", sync: true };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (process.platform === "win32") {
|
|
625
|
+
return {
|
|
626
|
+
bin: "cmd.exe",
|
|
627
|
+
args: ["/c", "start", "cmd.exe", "/k", line],
|
|
628
|
+
label: "Command Prompt",
|
|
629
|
+
sync: true,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Keep the shell alive afterwards: an agent that exits immediately would
|
|
634
|
+
// otherwise take its own error message off the screen with it.
|
|
635
|
+
const body = `${line}; exec ${process.env.SHELL || "sh"}`;
|
|
636
|
+
const candidates = [
|
|
637
|
+
{ bin: "x-terminal-emulator", args: ["-e", "sh", "-c", body], label: "terminal" },
|
|
638
|
+
{ bin: "gnome-terminal", args: ["--", "sh", "-c", body], label: "GNOME Terminal" },
|
|
639
|
+
{ bin: "konsole", args: ["-e", "sh", "-c", body], label: "Konsole" },
|
|
640
|
+
{ bin: "xterm", args: ["-e", "sh", "-c", body], label: "xterm" },
|
|
641
|
+
];
|
|
642
|
+
return candidates.find((candidate) => onPath(candidate.bin)) || null;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Open `command` in a new terminal window, best effort.
|
|
647
|
+
*
|
|
648
|
+
* Same contract as openUrl: never throws, and declines rather than guessing when
|
|
649
|
+
* nobody is watching — `PREMAN_NO_TERMINAL` for an explicit no, and a non-TTY
|
|
650
|
+
* stdout for CI, piped output and test runs. Callers fall back to a headless run.
|
|
651
|
+
*/
|
|
652
|
+
export function openInNewTerminal(command, options = {}) {
|
|
653
|
+
const cwd = options?.cwd || process.cwd();
|
|
654
|
+
const optOut = (process.env.PREMAN_NO_TERMINAL || "").trim().toLowerCase();
|
|
655
|
+
if (optOut && !["0", "false", "no"].includes(optOut)) {
|
|
656
|
+
return { opened: false, reason: "PREMAN_NO_TERMINAL is set" };
|
|
657
|
+
}
|
|
658
|
+
if (!process.stdout.isTTY) return { opened: false, reason: "not running in a terminal" };
|
|
659
|
+
|
|
660
|
+
const launcher = terminalLauncher(`cd ${shellQuote(cwd)} && ${command}`);
|
|
661
|
+
if (!launcher) return { opened: false, reason: "no terminal emulator found" };
|
|
662
|
+
|
|
663
|
+
try {
|
|
664
|
+
if (launcher.sync) {
|
|
665
|
+
const done = spawnSync(launcher.bin, launcher.args, { stdio: "ignore", timeout: 20000 });
|
|
666
|
+
if (done.error) return { opened: false, reason: done.error.message };
|
|
667
|
+
if (done.status !== 0) {
|
|
668
|
+
return { opened: false, reason: `${launcher.bin} exited with code ${done.status}` };
|
|
669
|
+
}
|
|
670
|
+
} else {
|
|
671
|
+
const child = spawn(launcher.bin, launcher.args, { stdio: "ignore", detached: true });
|
|
672
|
+
child.unref();
|
|
673
|
+
}
|
|
674
|
+
return { opened: true, terminal: launcher.label };
|
|
675
|
+
} catch (error) {
|
|
676
|
+
return { opened: false, reason: error.message };
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
526
680
|
/**
|
|
527
681
|
* Finish the link ourselves instead of asking the user to go restart their agent.
|
|
528
682
|
*
|
|
529
683
|
* The config on disk is already correct at this point; all that is missing is
|
|
530
684
|
* one call from the agent, and telling someone to make it from another terminal
|
|
531
|
-
* is a dead end in the one terminal they are sitting in.
|
|
532
|
-
*
|
|
685
|
+
* is a dead end in the one terminal they are sitting in.
|
|
686
|
+
*
|
|
687
|
+
* So start the agent. Interactively, in a window of its own, because that is the
|
|
688
|
+
* session the user keeps: a print-mode run answers once, exits, and leaves them
|
|
689
|
+
* exactly where they started. Only when no window can be opened — CI, SSH, a
|
|
690
|
+
* machine with no terminal emulator — does this fall back to the headless run,
|
|
691
|
+
* which still finishes the link even though nobody sees it happen.
|
|
533
692
|
*
|
|
534
693
|
* Returns `ran: false` when the agent's binary is absent or will not start, and
|
|
535
694
|
* the caller falls back to the printed instructions.
|
|
@@ -547,8 +706,23 @@ export async function autoCheckIn(
|
|
|
547
706
|
),
|
|
548
707
|
serverName = "preman",
|
|
549
708
|
intervalMs = Number(process.env.PREMAN_CONNECT_POLL_MS) || 3000,
|
|
709
|
+
onLaunch = () => {},
|
|
710
|
+
onPoll = null,
|
|
550
711
|
} = {}
|
|
551
712
|
) {
|
|
713
|
+
const session = interactiveCheckIn(agent, serverName);
|
|
714
|
+
if (session && onPath(session.bin)) {
|
|
715
|
+
const { opened, terminal } = openInNewTerminal(commandLine(session), { cwd: process.cwd() });
|
|
716
|
+
if (opened) {
|
|
717
|
+
onLaunch({ mode: "interactive", bin: session.bin, terminal });
|
|
718
|
+
// Detached, so there is no exit to watch for and no output to quote: the
|
|
719
|
+
// agent is in front of the user now, and the deadline is all that bounds
|
|
720
|
+
// this. Whoever is watching the window can Ctrl+C out of the wait.
|
|
721
|
+
const connected = await waitForConnection(args, apiKey, { intervalMs, timeoutMs, onPoll });
|
|
722
|
+
return { ran: true, connected, command: session.bin, interactive: true, terminal };
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
552
726
|
const spec = headlessCheckIn(agent, serverName);
|
|
553
727
|
if (!spec) return { ran: false, connected: false, reason: "no headless mode" };
|
|
554
728
|
if (!onPath(spec.bin)) return { ran: false, connected: false, reason: `${spec.bin} is not on PATH` };
|
|
@@ -562,6 +736,7 @@ export async function autoCheckIn(
|
|
|
562
736
|
} catch (error) {
|
|
563
737
|
return { ran: false, connected: false, reason: error.message };
|
|
564
738
|
}
|
|
739
|
+
onLaunch({ mode: "headless", bin: spec.bin });
|
|
565
740
|
|
|
566
741
|
let spawnError = null;
|
|
567
742
|
let exitedAt = 0;
|
|
@@ -588,6 +763,7 @@ export async function autoCheckIn(
|
|
|
588
763
|
const connected = await waitForConnection(args, apiKey, {
|
|
589
764
|
intervalMs,
|
|
590
765
|
timeoutMs,
|
|
766
|
+
onPoll,
|
|
591
767
|
stopWhen: () => Boolean(exitedAt) && Date.now() - exitedAt > grace,
|
|
592
768
|
});
|
|
593
769
|
if (spawnError && !connected) {
|
|
@@ -878,8 +1054,17 @@ function nextStepsBlock(agent) {
|
|
|
878
1054
|
);
|
|
879
1055
|
}
|
|
880
1056
|
|
|
1057
|
+
/**
|
|
1058
|
+
* Ask, or take the step's own default when nobody can answer.
|
|
1059
|
+
*
|
|
1060
|
+
* `--yes` returns the default rather than a blanket yes, which matters for
|
|
1061
|
+
* exactly one step: the desktop app defaults to no because it downloads a
|
|
1062
|
+
* hundred-odd megabytes and writes to /Applications. A blanket yes made
|
|
1063
|
+
* `preman connect --yes` do that unattended, which is not what anyone means by
|
|
1064
|
+
* "do not ask me questions" -- they get `preman install-desktop` for that.
|
|
1065
|
+
*/
|
|
881
1066
|
async function confirm(question, { assumeYes = false, defaultYes = true } = {}) {
|
|
882
|
-
if (assumeYes) return
|
|
1067
|
+
if (assumeYes) return defaultYes;
|
|
883
1068
|
if (!process.stdin.isTTY) return false;
|
|
884
1069
|
const answer = (await promptText(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"}: `)).toLowerCase();
|
|
885
1070
|
if (answer === "") return defaultYes;
|
|
@@ -951,7 +1136,11 @@ async function discoverEndpoints(args, agent, serverName) {
|
|
|
951
1136
|
|
|
952
1137
|
const brief = await callPremanTool(args, "discover_endpoints_from_codebase", { base_path: "." });
|
|
953
1138
|
const spec = headlessDiscovery(agent, serverName, brief.instructions || []);
|
|
954
|
-
|
|
1139
|
+
const blocker = spec && onPath(spec.bin) ? agentBlocker(agent.id) : "";
|
|
1140
|
+
if (!spec || !onPath(spec.bin) || blocker) {
|
|
1141
|
+
// Say why before printing homework: "here is a brief" reads as PreMan not
|
|
1142
|
+
// working, when the actual answer is one login away.
|
|
1143
|
+
if (blocker) process.stdout.write(`${MARK.skip()} ${blocker}\n`);
|
|
955
1144
|
for (const line of brief.instructions || []) process.stdout.write(`${line}\n`);
|
|
956
1145
|
process.stdout.write(`\nHand this brief to ${agent.label}, then run:\n${MANUAL_STEPS}`);
|
|
957
1146
|
return before;
|
|
@@ -963,7 +1152,11 @@ async function discoverEndpoints(args, agent, serverName) {
|
|
|
963
1152
|
const after = await endpointCounts(args);
|
|
964
1153
|
|
|
965
1154
|
if (!after.registered) {
|
|
966
|
-
|
|
1155
|
+
// Both halves: the exit code says the run failed, the agent's own last line
|
|
1156
|
+
// says why, and either one alone has sent someone down the wrong path.
|
|
1157
|
+
const why =
|
|
1158
|
+
[outcome.reason, lastLine(outcome.output)].filter(Boolean).join(" · ") ||
|
|
1159
|
+
"it registered nothing";
|
|
967
1160
|
process.stdout.write(
|
|
968
1161
|
`${MARK.fail()} ${agent.label} did not register any endpoints (${why}).\n` +
|
|
969
1162
|
` Run it yourself: ${cliInvocation()} endpoints discover\n`
|
|
@@ -1069,10 +1262,28 @@ async function setUpRunner(args, agent, { assumeYes }) {
|
|
|
1069
1262
|
await registerRunner(args, { agent: agent.id, projectPath: process.cwd() });
|
|
1070
1263
|
}
|
|
1071
1264
|
const started = startBackground([]);
|
|
1265
|
+
// Claiming a runner that died two seconds later is worse than saying nothing:
|
|
1266
|
+
// the whole point of this step is that PreMan can act, and someone told it
|
|
1267
|
+
// can will wait for fixes that no device is listening for.
|
|
1268
|
+
const up = await confirmRunnerOnline(started);
|
|
1269
|
+
if (up.state === "exited") {
|
|
1270
|
+
process.stdout.write(
|
|
1271
|
+
`${MARK.fail()} The runner exited right after starting.\n` +
|
|
1272
|
+
(up.detail ? ` ${up.detail}\n` : "") +
|
|
1273
|
+
` Log: ${started.log} Retry: ${cliInvocation()} runner start --background\n`
|
|
1274
|
+
);
|
|
1275
|
+
return { state: "failed", detail: up.detail };
|
|
1276
|
+
}
|
|
1072
1277
|
process.stdout.write(
|
|
1073
|
-
`${MARK.ok()} Runner
|
|
1278
|
+
`${MARK.ok()} Runner ${up.state === "online" ? "online" : "starting"} (pid ${started.pid}) — PreMan can apply fixes on this machine.\n` +
|
|
1074
1279
|
` Log: ${started.log} Stop: ${cliInvocation()} runner stop\n`
|
|
1075
1280
|
);
|
|
1281
|
+
// Paired and online still cannot run anything if the agent it dispatches to
|
|
1282
|
+
// will not start, and every job would fail with the same opaque exit code.
|
|
1283
|
+
const blocker = agentBlocker(agent.id);
|
|
1284
|
+
if (blocker) {
|
|
1285
|
+
process.stdout.write(` ${MARK.skip()} Jobs will fail until you fix this: ${blocker}\n`);
|
|
1286
|
+
}
|
|
1076
1287
|
return { state: "running", pid: started.pid };
|
|
1077
1288
|
} catch (error) {
|
|
1078
1289
|
process.stdout.write(
|
|
@@ -1178,8 +1389,14 @@ async function connectIntegrations(args, apiKey, { assumeYes }) {
|
|
|
1178
1389
|
}
|
|
1179
1390
|
}
|
|
1180
1391
|
|
|
1181
|
-
/**
|
|
1182
|
-
|
|
1392
|
+
/**
|
|
1393
|
+
* Install the pre-push hook, so a push is what triggers the tests.
|
|
1394
|
+
*
|
|
1395
|
+
* Not a question: testing what you just changed before it ships is the product,
|
|
1396
|
+
* the hook cannot block a push, and `--no-hook` / `PREMAN_SKIP_HOOK=1` are both
|
|
1397
|
+
* still there. Asking only produced accounts that never tested anything.
|
|
1398
|
+
*/
|
|
1399
|
+
async function setUpPushTesting(args) {
|
|
1183
1400
|
if (args.has("--no-hook")) return { state: "skipped" };
|
|
1184
1401
|
const current = (() => {
|
|
1185
1402
|
try {
|
|
@@ -1192,14 +1409,12 @@ async function setUpPushTesting(args, { assumeYes }) {
|
|
|
1192
1409
|
process.stdout.write(`${MARK.skip()} Not a git repository — no push testing here.\n`);
|
|
1193
1410
|
return { state: "unavailable" };
|
|
1194
1411
|
}
|
|
1195
|
-
|
|
1412
|
+
// A hook whose invocation went stale is reinstalled rather than reported as on:
|
|
1413
|
+
// it is the case where PreMan looks connected and silently checks nothing.
|
|
1414
|
+
if (current.state === "installed" && current.current) {
|
|
1196
1415
|
process.stdout.write(`${MARK.ok()} Push testing already on.\n`);
|
|
1197
1416
|
return { state: "installed" };
|
|
1198
1417
|
}
|
|
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
1418
|
|
|
1204
1419
|
const result = installHook(args);
|
|
1205
1420
|
if (result.action === "conflict") {
|
|
@@ -1242,7 +1457,7 @@ async function guidedFirstRun(args, agent, apiKey, serverName) {
|
|
|
1242
1457
|
await connectIntegrations(args, apiKey, { assumeYes });
|
|
1243
1458
|
|
|
1244
1459
|
step("Testing on push");
|
|
1245
|
-
await setUpPushTesting(args
|
|
1460
|
+
await setUpPushTesting(args);
|
|
1246
1461
|
|
|
1247
1462
|
process.stdout.write(`\nDone. Watch it at ${frontendUrl(args)}\n`);
|
|
1248
1463
|
}
|
|
@@ -1273,6 +1488,16 @@ export async function preflight(args) {
|
|
|
1273
1488
|
);
|
|
1274
1489
|
}
|
|
1275
1490
|
|
|
1491
|
+
// Explains why every command below is spelled the long way, before someone
|
|
1492
|
+
// types `preman …` and gets another package's CLI answering.
|
|
1493
|
+
const premanOwner = pathPremanOwner();
|
|
1494
|
+
if (premanOwner && premanOwner !== "premanmcp") {
|
|
1495
|
+
notes.push(
|
|
1496
|
+
`\`preman\` on your PATH belongs to ${premanOwner}, not this CLI, so PreMan's own ` +
|
|
1497
|
+
"commands are written out as `npm exec -y premanmcp@latest -- …`."
|
|
1498
|
+
);
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1276
1501
|
try {
|
|
1277
1502
|
const resp = await fetch(new URL("health", `${backendUrl(args)}/`), {
|
|
1278
1503
|
signal: AbortSignal.timeout(4000),
|
|
@@ -1315,7 +1540,8 @@ Connect options:
|
|
|
1315
1540
|
--no-desktop Do not offer the desktop app
|
|
1316
1541
|
--no-integrations Do not check or offer GitHub / AWS / Slack
|
|
1317
1542
|
--no-hook Do not install the git pre-push hook
|
|
1318
|
-
--yes
|
|
1543
|
+
--yes Take every step's default without prompting
|
|
1544
|
+
(the desktop app defaults to no; install-desktop)
|
|
1319
1545
|
--print Print the config instead of writing it
|
|
1320
1546
|
`;
|
|
1321
1547
|
|
|
@@ -1443,20 +1669,52 @@ export async function connectCommand(commandArgs) {
|
|
|
1443
1669
|
await captureDispatchCredential(args, agent, apiKey);
|
|
1444
1670
|
}
|
|
1445
1671
|
|
|
1672
|
+
/**
|
|
1673
|
+
* A repo config that sends every agent started here to a backend the key we just
|
|
1674
|
+
* wrote was not issued for, or null.
|
|
1675
|
+
*
|
|
1676
|
+
* Only an override can do this: without the flag the MCP config's env wins, and
|
|
1677
|
+
* a self-test that disagrees with it is a transient, not a redirect. It matters
|
|
1678
|
+
* because it is the one failure waiting cannot fix — the agent starts in the
|
|
1679
|
+
* same directory, reads the same file, and checks in somewhere else forever.
|
|
1680
|
+
*/
|
|
1681
|
+
function backendRedirect(repo, status, serverConfig) {
|
|
1682
|
+
if (!repo?.override || !(repo.applied || []).includes("PREMAN_BACKEND")) return null;
|
|
1683
|
+
const trim = (url) => String(url || "").replace(/\/+$/, "");
|
|
1684
|
+
const actual = trim(status.backend_url);
|
|
1685
|
+
const wanted = trim(serverConfig?.env?.PREMAN_BACKEND);
|
|
1686
|
+
if (!actual || !wanted || actual === wanted) return null;
|
|
1687
|
+
return { path: repo.path, actual, wanted };
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1446
1690
|
/**
|
|
1447
1691
|
* Finish the link here, by whatever means work, in cheapest-first order.
|
|
1448
1692
|
*
|
|
1449
1693
|
* 1. Run the MCP server ourselves and call one tool. No agent, no tokens, a few
|
|
1450
1694
|
* seconds, and it proves the launcher/key/backend chain the agent will use.
|
|
1451
|
-
* 2. Failing that,
|
|
1452
|
-
*
|
|
1695
|
+
* 2. Failing that, start the agent — in its own window when there is a desktop
|
|
1696
|
+
* to open one on, headlessly otherwise — which also proves the agent can load
|
|
1697
|
+
* the config we wrote.
|
|
1453
1698
|
* 3. Failing that, ask them to restart it and wait, which is all this ever did.
|
|
1454
1699
|
*
|
|
1700
|
+
* Every diagnosis is printed the moment it is known rather than saved for the
|
|
1701
|
+
* end: the steps below are measured in minutes, and a note that explains what is
|
|
1702
|
+
* happening is worth nothing after it has stopped happening.
|
|
1703
|
+
*
|
|
1455
1704
|
* Returns whether the check-in landed, and prints the troubleshooting block
|
|
1456
1705
|
* itself when it did not.
|
|
1457
1706
|
*/
|
|
1458
1707
|
async function establishCheckIn(args, agent, apiKey, { serverName, written, serverConfig }) {
|
|
1459
1708
|
const notes = [];
|
|
1709
|
+
const ticker = pollTicker();
|
|
1710
|
+
const say = (text) => {
|
|
1711
|
+
ticker.end();
|
|
1712
|
+
process.stdout.write(text);
|
|
1713
|
+
};
|
|
1714
|
+
const note = (text) => {
|
|
1715
|
+
notes.push(text);
|
|
1716
|
+
say(`Note: ${text}\n`);
|
|
1717
|
+
};
|
|
1460
1718
|
|
|
1461
1719
|
if (!args.has("--no-self-test") && serverConfig && selfTestBudgetMs() > 0) {
|
|
1462
1720
|
process.stdout.write("\nChecking the connection…\n");
|
|
@@ -1466,55 +1724,81 @@ async function establishCheckIn(args, agent, apiKey, { serverName, written, serv
|
|
|
1466
1724
|
if (repo?.override && repo.applied?.length) {
|
|
1467
1725
|
// The one failure that reads as a bad key: a working server talking to a
|
|
1468
1726
|
// backend nobody chose. Name the file before it costs anyone an hour.
|
|
1469
|
-
|
|
1727
|
+
note(
|
|
1470
1728
|
`${repo.path} overrides ${repo.applied.join(", ")} for anything started in this directory, ` +
|
|
1471
1729
|
`so your agent will use ${status.backend_url}.`
|
|
1472
1730
|
);
|
|
1473
1731
|
}
|
|
1474
1732
|
if (test.ok && status.authenticated && (await waitForConnection(args, apiKey, { timeoutMs: 15000 }))) {
|
|
1475
|
-
for (const note of notes) process.stdout.write(`Note: ${note}\n`);
|
|
1476
1733
|
return true;
|
|
1477
1734
|
}
|
|
1478
|
-
|
|
1735
|
+
note(
|
|
1479
1736
|
test.ok
|
|
1480
1737
|
? `the MCP server answered from ${status.backend_url || "an unknown backend"} but was not authenticated`
|
|
1481
1738
|
: `the MCP server could not be started (${test.reason || "unknown"}${test.stderr ? `: ${test.stderr}` : ""})`
|
|
1482
1739
|
);
|
|
1483
|
-
}
|
|
1484
1740
|
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1741
|
+
const redirect = backendRedirect(repo, status, serverConfig);
|
|
1742
|
+
if (redirect) {
|
|
1743
|
+
const flag = `--agent ${agent.id.replace("_", "-")}`;
|
|
1744
|
+
say(
|
|
1745
|
+
`\nNot linked: ${redirect.path} forces PREMAN_BACKEND=${redirect.actual} for anything\n` +
|
|
1746
|
+
`started in this directory, so ${agent.label} cannot check in against ${redirect.wanted}.\n` +
|
|
1747
|
+
` - Run '${cliInvocation()} connect' from your own project instead of this directory.\n` +
|
|
1748
|
+
` - Or connect to that backend: ${cliInvocation()} connect ${flag} --backend ${redirect.actual}\n` +
|
|
1749
|
+
` (needs a key issued by it, and that API running).\n` +
|
|
1750
|
+
` - Config written to: ${written.path}\n`
|
|
1751
|
+
);
|
|
1752
|
+
return false;
|
|
1489
1753
|
}
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
const blocker = agentBlocker(agent.id);
|
|
1757
|
+
let launch = {};
|
|
1758
|
+
if (!args.has("--no-auto-checkin") && !blocker) {
|
|
1759
|
+
launch = await autoCheckIn(args, agent, apiKey, {
|
|
1760
|
+
serverName,
|
|
1761
|
+
onPoll: ticker.tick,
|
|
1762
|
+
onLaunch: ({ mode, terminal }) =>
|
|
1763
|
+
say(
|
|
1764
|
+
mode === "interactive"
|
|
1765
|
+
? `\nOpened a new ${terminal} window running ${agent.label}.\n`
|
|
1766
|
+
: `\nStarting ${agent.label} to finish the link…\n`
|
|
1767
|
+
),
|
|
1768
|
+
});
|
|
1769
|
+
if (launch.connected) {
|
|
1770
|
+
ticker.end();
|
|
1493
1771
|
return true;
|
|
1494
1772
|
}
|
|
1495
|
-
if (
|
|
1496
|
-
const why = lastLine(
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
);
|
|
1500
|
-
} else if (auto.reason) {
|
|
1501
|
-
process.stdout.write(`Could not run ${agent.label}: ${auto.reason}\n`);
|
|
1773
|
+
if (launch.ran && !launch.interactive) {
|
|
1774
|
+
const why = lastLine(launch.output);
|
|
1775
|
+
say(`${agent.label} ran but did not check in${why ? `: ${why}` : "."}\n`);
|
|
1776
|
+
} else if (!launch.ran && launch.reason) {
|
|
1777
|
+
say(`Could not run ${agent.label}: ${launch.reason}\n`);
|
|
1502
1778
|
}
|
|
1779
|
+
} else if (blocker) {
|
|
1780
|
+
// Starting an agent that cannot authenticate spends two minutes to learn
|
|
1781
|
+
// what one status probe already knows.
|
|
1782
|
+
say(`${MARK.skip()} ${blocker}\n`);
|
|
1783
|
+
notes.push(blocker);
|
|
1503
1784
|
}
|
|
1504
1785
|
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1786
|
+
say(
|
|
1787
|
+
launch.interactive
|
|
1788
|
+
? `\nAnswer it in the ${agent.label} window — it links on its first PreMan call.\n` +
|
|
1789
|
+
"Waiting for your agent to check in… (Ctrl+C to stop waiting)\n"
|
|
1790
|
+
: `\nRestart ${agent.label} and ask it: "run preman_status"\n` +
|
|
1791
|
+
"Waiting for your agent to check in… (Ctrl+C to stop waiting)\n"
|
|
1508
1792
|
);
|
|
1509
1793
|
|
|
1510
|
-
if (await waitForConnection(args, apiKey)) {
|
|
1511
|
-
|
|
1794
|
+
if (await waitForConnection(args, apiKey, { onPoll: ticker.tick })) {
|
|
1795
|
+
ticker.end();
|
|
1512
1796
|
return true;
|
|
1513
1797
|
}
|
|
1514
1798
|
|
|
1515
|
-
|
|
1799
|
+
say(
|
|
1516
1800
|
"No check-in yet. Troubleshooting:\n" +
|
|
1517
|
-
notes.map((
|
|
1801
|
+
notes.map((entry) => ` - ${entry}\n`).join("") +
|
|
1518
1802
|
` - ${agent.restartHint}\n` +
|
|
1519
1803
|
` - Config written to: ${written.path}\n` +
|
|
1520
1804
|
` - Then ask ${agent.label} to "run preman_status" — it links on its first PreMan call.\n` +
|
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";
|
|
@@ -603,6 +612,32 @@ function defaultLog(message) {
|
|
|
603
612
|
process.stdout.write(`[preman runner] ${new Date().toISOString()} ${message}\n`);
|
|
604
613
|
}
|
|
605
614
|
|
|
615
|
+
/**
|
|
616
|
+
* One heartbeat. Never throws.
|
|
617
|
+
*
|
|
618
|
+
* It runs on a timer, and a rejected fetch inside a timer callback has nobody to
|
|
619
|
+
* catch it — Node turns that unhandled rejection into process exit, so a single
|
|
620
|
+
* connect timeout to the backend used to take the whole daemon down mid-run and
|
|
621
|
+
* leave a machine that reads as paired and never runs anything again. A missed
|
|
622
|
+
* heartbeat only costs one TTL window: the next one puts this runner back online.
|
|
623
|
+
*/
|
|
624
|
+
export async function sendHeartbeat(args, state, { busy = false, log = () => {} } = {}) {
|
|
625
|
+
try {
|
|
626
|
+
// Reported honestly because the matcher prefers idle runners: a busy device
|
|
627
|
+
// claiming to be idle wins work it will only sit on until the lease expires.
|
|
628
|
+
const result = await callBackendJson(
|
|
629
|
+
args,
|
|
630
|
+
"POST",
|
|
631
|
+
"/workbench/coding-agent/local-runner/heartbeat",
|
|
632
|
+
{ token: state.runner_token, json: { state: busy ? "busy" : "idle" } }
|
|
633
|
+
);
|
|
634
|
+
return { revoked: result.status_code === 401 };
|
|
635
|
+
} catch (error) {
|
|
636
|
+
log(`heartbeat failed: ${error.message}`);
|
|
637
|
+
return { revoked: false };
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
606
641
|
/**
|
|
607
642
|
* Hold the stream and run what it leases, until stopped.
|
|
608
643
|
*
|
|
@@ -624,17 +659,12 @@ export async function runnerLoop(
|
|
|
624
659
|
`${state.backend_url || backendUrl(args)}/`
|
|
625
660
|
);
|
|
626
661
|
|
|
627
|
-
const heartbeat = setInterval(
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
const result = await callBackendJson(args, "POST", "/workbench/coding-agent/local-runner/heartbeat", {
|
|
631
|
-
token: state.runner_token,
|
|
632
|
-
json: { state: busy ? "busy" : "idle" },
|
|
633
|
-
});
|
|
634
|
-
if (result.status_code === 401) {
|
|
662
|
+
const heartbeat = setInterval(() => {
|
|
663
|
+
void sendHeartbeat(args, state, { busy, log }).then(({ revoked }) => {
|
|
664
|
+
if (!revoked) return;
|
|
635
665
|
log("runner registration is no longer active; stopping");
|
|
636
666
|
stopped = true;
|
|
637
|
-
}
|
|
667
|
+
});
|
|
638
668
|
}, HEARTBEAT_MS);
|
|
639
669
|
|
|
640
670
|
try {
|
|
@@ -739,9 +769,56 @@ Runner options:
|
|
|
739
769
|
--full-access Let the agent run commands, not just edit files
|
|
740
770
|
`;
|
|
741
771
|
|
|
772
|
+
function logSize() {
|
|
773
|
+
try {
|
|
774
|
+
return statSync(RUNNER_LOG_FILE).size;
|
|
775
|
+
} catch {
|
|
776
|
+
return 0;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** Whatever the daemon has logged since `offset`, exactly (the log is appended to). */
|
|
781
|
+
export function runnerLogSince(offset = 0) {
|
|
782
|
+
try {
|
|
783
|
+
const buffer = readFileSync(RUNNER_LOG_FILE);
|
|
784
|
+
return buffer.subarray(Math.min(Math.max(0, offset), buffer.length)).toString("utf8");
|
|
785
|
+
} catch {
|
|
786
|
+
return "";
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function tailLine(text, cap = 240) {
|
|
791
|
+
const lines = String(text || "")
|
|
792
|
+
.split("\n")
|
|
793
|
+
.map((line) => line.trim())
|
|
794
|
+
.filter(Boolean);
|
|
795
|
+
return lines.length ? lines[lines.length - 1].slice(0, cap) : "";
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Wait for a just-spawned daemon to prove it is up.
|
|
800
|
+
*
|
|
801
|
+
* A pid from `spawn` is not a running runner: registration can be rejected, the
|
|
802
|
+
* token can be dead, the stream can refuse the connection, and every one of those
|
|
803
|
+
* exits within a second or two of a "Runner running (pid …)" line nobody had any
|
|
804
|
+
* reason to doubt. The log offset is taken before the spawn so an earlier
|
|
805
|
+
* daemon's "connected" line cannot be mistaken for this one's.
|
|
806
|
+
*/
|
|
807
|
+
export async function confirmRunnerOnline({ pid, offset = 0, timeoutMs = 12_000, intervalMs = 250 } = {}) {
|
|
808
|
+
const deadline = Date.now() + timeoutMs;
|
|
809
|
+
for (;;) {
|
|
810
|
+
const fresh = runnerLogSince(offset);
|
|
811
|
+
if (/connected as runner/.test(fresh)) return { state: "online", detail: "" };
|
|
812
|
+
if (!runnerIsAlive(pid)) return { state: "exited", detail: tailLine(fresh) };
|
|
813
|
+
if (Date.now() >= deadline) return { state: "starting", detail: tailLine(fresh) };
|
|
814
|
+
await sleep(intervalMs);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
742
818
|
/** Start the daemon detached, so a terminal can be closed without killing it. */
|
|
743
819
|
export function startBackground(commandArgs) {
|
|
744
820
|
ensureDir();
|
|
821
|
+
const offset = logSize();
|
|
745
822
|
// 0600 like everything else under ~/.preman: nothing in here is a credential
|
|
746
823
|
// today, and that is not a property to leave depending on future log lines.
|
|
747
824
|
const log = openSync(RUNNER_LOG_FILE, "a", 0o600);
|
|
@@ -753,7 +830,7 @@ export function startBackground(commandArgs) {
|
|
|
753
830
|
);
|
|
754
831
|
child.unref();
|
|
755
832
|
writeFileSync(RUNNER_PID_FILE, `${child.pid}\n`, { mode: 0o600 });
|
|
756
|
-
return { pid: child.pid, log: RUNNER_LOG_FILE };
|
|
833
|
+
return { pid: child.pid, log: RUNNER_LOG_FILE, offset };
|
|
757
834
|
}
|
|
758
835
|
|
|
759
836
|
async function startForeground(args, commandArgs) {
|
|
@@ -786,6 +863,16 @@ async function startForeground(args, commandArgs) {
|
|
|
786
863
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
787
864
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
788
865
|
|
|
866
|
+
// A daemon that dies must not leave its pid file behind claiming otherwise.
|
|
867
|
+
// Nothing is swallowed: the reason is logged and the exit code says it failed.
|
|
868
|
+
const die = (kind) => (error) => {
|
|
869
|
+
log(`${kind}: ${error?.stack || error}`);
|
|
870
|
+
rmSync(RUNNER_PID_FILE, { force: true });
|
|
871
|
+
process.exit(1);
|
|
872
|
+
};
|
|
873
|
+
process.on("uncaughtException", die("uncaught exception"));
|
|
874
|
+
process.on("unhandledRejection", die("unhandled rejection"));
|
|
875
|
+
|
|
789
876
|
try {
|
|
790
877
|
const result = await runnerLoop(args, state, {
|
|
791
878
|
log,
|
|
@@ -827,10 +914,20 @@ export async function runnerCommand(commandArgs = []) {
|
|
|
827
914
|
return { state: "running", pid: readPid() };
|
|
828
915
|
}
|
|
829
916
|
const started = startBackground(commandArgs.filter((value) => value !== "start"));
|
|
917
|
+
const up = await confirmRunnerOnline(started);
|
|
918
|
+
if (up.state === "exited") {
|
|
919
|
+
rmSync(RUNNER_PID_FILE, { force: true });
|
|
920
|
+
process.stdout.write(
|
|
921
|
+
`Runner exited right after starting.\n` +
|
|
922
|
+
(up.detail ? ` ${up.detail}\n` : "") +
|
|
923
|
+
` log: ${started.log}\n`
|
|
924
|
+
);
|
|
925
|
+
return { ...started, state: "exited" };
|
|
926
|
+
}
|
|
830
927
|
process.stdout.write(
|
|
831
|
-
`Runner
|
|
928
|
+
`Runner ${up.state === "online" ? "online" : "starting"} (pid ${started.pid}).\n log: ${started.log}\n`
|
|
832
929
|
);
|
|
833
|
-
return started;
|
|
930
|
+
return { ...started, state: up.state };
|
|
834
931
|
}
|
|
835
932
|
await startForeground(args, commandArgs);
|
|
836
933
|
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.2",
|
|
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-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"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@modelcontextprotocol/ext-apps": "^0.1.0",
|