premanmcp 0.9.0 → 0.10.0
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 +45 -11
- package/bin/cli.js +5 -1
- package/bin/connect.js +645 -67
- package/bin/integrations.js +5 -5
- package/bin/runner.js +880 -0
- package/bin/shared.js +13 -1
- package/dist/server.js +29 -9
- package/package.json +1 -1
package/bin/connect.js
CHANGED
|
@@ -16,12 +16,18 @@ import os from "node:os";
|
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
|
|
18
18
|
import { callTool as callPremanTool, printTestSummary } from "./api_tools.js";
|
|
19
|
+
import { installDesktopCommand } from "./desktop.js";
|
|
20
|
+
import { installHook, hookStatus } from "./hook.js";
|
|
21
|
+
import { MARK, awsCommand, githubCommand, slackCommand } from "./integrations.js";
|
|
22
|
+
import { readRunnerState, registerRunner, runnerIsAlive, startBackground } from "./runner.js";
|
|
19
23
|
import {
|
|
24
|
+
apiKeyIsExplicit,
|
|
20
25
|
assertOk,
|
|
21
26
|
authenticateTerminal,
|
|
22
27
|
backendUrl,
|
|
23
28
|
buildServerConfig,
|
|
24
29
|
callBackendJson,
|
|
30
|
+
clearStoredCredentials,
|
|
25
31
|
cliInvocation,
|
|
26
32
|
frontendUrl,
|
|
27
33
|
hasKeyAvailable,
|
|
@@ -389,19 +395,81 @@ export function verifyWrittenConfig(agent, { serverName, written }) {
|
|
|
389
395
|
|
|
390
396
|
// ── Pairing ─────────────────────────────────────────────────────────────
|
|
391
397
|
|
|
398
|
+
/**
|
|
399
|
+
* Mint the pair code, distinguishing "could not reach it" from "this key is dead".
|
|
400
|
+
*
|
|
401
|
+
* 401 is the backend saying the key is invalid or revoked, which is the one
|
|
402
|
+
* failure the caller must not shrug off: every other outcome still leaves a
|
|
403
|
+
* usable config, but writing a revoked key into an agent's config produces a
|
|
404
|
+
* setup that looks connected and fails on every call.
|
|
405
|
+
*/
|
|
392
406
|
async function startPairing(args, agent, apiKey) {
|
|
393
407
|
const result = await callBackendJson(args, "PUT", "/workbench/coding-agent", {
|
|
394
408
|
token: apiKey,
|
|
395
409
|
json: { agent: agent.id, project_path: process.cwd(), start_pairing: true },
|
|
396
410
|
});
|
|
411
|
+
if (result.status_code === 401) return { pairCode: "", stale: true };
|
|
397
412
|
if (!result.ok) {
|
|
398
413
|
process.stdout.write(
|
|
399
414
|
`Note: could not start pairing (${result.status_code}); the config still works, ` +
|
|
400
415
|
"your agent will link on its first PreMan call.\n"
|
|
401
416
|
);
|
|
402
|
-
return "";
|
|
417
|
+
return { pairCode: "", stale: false };
|
|
403
418
|
}
|
|
404
|
-
return String(result.pair_code || "");
|
|
419
|
+
return { pairCode: String(result.pair_code || ""), stale: false };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The --no-pair stand-in for the auth check pairing performs implicitly.
|
|
424
|
+
*
|
|
425
|
+
* Declining a pair code should not also decline noticing the key is dead,
|
|
426
|
+
* otherwise this flag writes exactly the broken config pairing would catch.
|
|
427
|
+
*/
|
|
428
|
+
async function verifyKey(args, apiKey) {
|
|
429
|
+
const result = await callBackendJson(args, "GET", "/auth/me", { token: apiKey });
|
|
430
|
+
return { pairCode: "", stale: result.status_code === 401 };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Whichever of the two ways to learn the backend still accepts this key. */
|
|
434
|
+
async function checkKeyAndPair(args, agent, apiKey) {
|
|
435
|
+
return args.has("--no-pair")
|
|
436
|
+
? verifyKey(args, apiKey)
|
|
437
|
+
: startPairing(args, agent, apiKey);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Replace credentials the backend has rejected, and return the new key.
|
|
442
|
+
*
|
|
443
|
+
* Only stored credentials can be replaced here: an explicit --api-key or
|
|
444
|
+
* PREMAN_API_KEY would just be re-read, so those say so and stop rather than
|
|
445
|
+
* looping. Refusing to continue is deliberate — the alternative is writing a
|
|
446
|
+
* key we know is dead into the agent's config.
|
|
447
|
+
*/
|
|
448
|
+
async function refreshStaleCredentials(args) {
|
|
449
|
+
if (apiKeyIsExplicit(args)) {
|
|
450
|
+
const source = args.value("--api-key", "") ? "--api-key" : "PREMAN_API_KEY";
|
|
451
|
+
throw new ConnectError(
|
|
452
|
+
`The PreMan API key from ${source} is invalid or revoked. Create a new one in the ` +
|
|
453
|
+
`dashboard, or drop ${source} and run '${cliInvocation()} connect' to sign in.`,
|
|
454
|
+
EXIT_USAGE
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
if (args.has("--skip-login") || !process.stdin.isTTY) {
|
|
458
|
+
throw new ConnectError(
|
|
459
|
+
"Saved PreMan credentials are invalid or revoked. Run " +
|
|
460
|
+
`'${cliInvocation()} logout', then connect again to sign in.`,
|
|
461
|
+
EXIT_USAGE
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
process.stdout.write(
|
|
466
|
+
"\nYour saved PreMan credentials are no longer valid — the key was revoked, or the\n" +
|
|
467
|
+
"account behind it was removed. Signing you in again.\n"
|
|
468
|
+
);
|
|
469
|
+
clearStoredCredentials();
|
|
470
|
+
await authenticateTerminal(args);
|
|
471
|
+
process.stdout.write("\n");
|
|
472
|
+
return resolveApiKey(args);
|
|
405
473
|
}
|
|
406
474
|
|
|
407
475
|
export async function waitForConnection(
|
|
@@ -487,13 +555,24 @@ export async function autoCheckIn(
|
|
|
487
555
|
|
|
488
556
|
let child;
|
|
489
557
|
try {
|
|
490
|
-
|
|
558
|
+
// Piped rather than ignored: an agent that runs and does not check in used to
|
|
559
|
+
// report exactly that and nothing else, which is the least useful sentence
|
|
560
|
+
// available. Its own last words usually name the cause.
|
|
561
|
+
child = spawn(spec.bin, spec.args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
491
562
|
} catch (error) {
|
|
492
563
|
return { ran: false, connected: false, reason: error.message };
|
|
493
564
|
}
|
|
494
565
|
|
|
495
566
|
let spawnError = null;
|
|
496
567
|
let exitedAt = 0;
|
|
568
|
+
let output = "";
|
|
569
|
+
const absorb = (chunk) => {
|
|
570
|
+
output = `${output}${chunk}`.slice(-4000);
|
|
571
|
+
};
|
|
572
|
+
child.stdout?.setEncoding("utf8");
|
|
573
|
+
child.stderr?.setEncoding("utf8");
|
|
574
|
+
child.stdout?.on("data", absorb);
|
|
575
|
+
child.stderr?.on("data", absorb);
|
|
497
576
|
child.on("error", (error) => {
|
|
498
577
|
spawnError = error;
|
|
499
578
|
exitedAt = exitedAt || Date.now();
|
|
@@ -512,14 +591,156 @@ export async function autoCheckIn(
|
|
|
512
591
|
stopWhen: () => Boolean(exitedAt) && Date.now() - exitedAt > grace,
|
|
513
592
|
});
|
|
514
593
|
if (spawnError && !connected) {
|
|
515
|
-
return { ran: false, connected: false, reason: spawnError.message };
|
|
594
|
+
return { ran: false, connected: false, reason: spawnError.message, output };
|
|
516
595
|
}
|
|
517
|
-
return { ran: true, connected, command: spec.bin };
|
|
596
|
+
return { ran: true, connected, command: spec.bin, output };
|
|
518
597
|
} finally {
|
|
519
598
|
if (child.exitCode === null && child.signalCode === null) child.kill();
|
|
520
599
|
}
|
|
521
600
|
}
|
|
522
601
|
|
|
602
|
+
/** The last non-empty line of an agent's output, for a one-line diagnosis. */
|
|
603
|
+
export function lastLine(output, cap = 200) {
|
|
604
|
+
const lines = String(output || "")
|
|
605
|
+
.split("\n")
|
|
606
|
+
.map((line) => line.trim())
|
|
607
|
+
.filter(Boolean);
|
|
608
|
+
return lines.length ? lines[lines.length - 1].slice(0, cap) : "";
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ── MCP self-test ───────────────────────────────────────────────────────
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* How long to give the self-test, and whether to run it at all.
|
|
615
|
+
*
|
|
616
|
+
* `PREMAN_SELFTEST_MS=0` turns it off for a whole process, which is what a test
|
|
617
|
+
* run wants: the launcher is `npm exec premanmcp@latest`, so every case that
|
|
618
|
+
* reaches this would otherwise go to the registry.
|
|
619
|
+
*/
|
|
620
|
+
export function selfTestBudgetMs() {
|
|
621
|
+
const raw = process.env.PREMAN_SELFTEST_MS;
|
|
622
|
+
if (raw === undefined || raw === "") return 60000;
|
|
623
|
+
const parsed = Number(raw);
|
|
624
|
+
return Number.isFinite(parsed) ? parsed : 60000;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Run the config we just wrote, exactly as the agent will, and call one tool.
|
|
629
|
+
*
|
|
630
|
+
* This is both the fastest way to finish the link and the only check that proves
|
|
631
|
+
* the whole chain — launcher, package, key, backend — rather than proving the
|
|
632
|
+
* file is on disk. It matters because the failure it catches is invisible
|
|
633
|
+
* otherwise: a repo-local `preman-mcp.config.json` (or a stale global env) can
|
|
634
|
+
* redirect the server to a backend the key was never issued for, and every
|
|
635
|
+
* symptom of that points at the key.
|
|
636
|
+
*
|
|
637
|
+
* Speaks the two JSON-RPC calls an MCP client makes on startup. Never throws.
|
|
638
|
+
*/
|
|
639
|
+
export async function mcpSelfTest(
|
|
640
|
+
serverConfig,
|
|
641
|
+
{ timeoutMs = selfTestBudgetMs(), cwd = process.cwd() } = {}
|
|
642
|
+
) {
|
|
643
|
+
if (timeoutMs <= 0) return { ok: false, reason: "self-test disabled" };
|
|
644
|
+
let child;
|
|
645
|
+
try {
|
|
646
|
+
child = spawn(serverConfig.command, serverConfig.args, {
|
|
647
|
+
cwd,
|
|
648
|
+
env: { ...process.env, ...serverConfig.env },
|
|
649
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
650
|
+
});
|
|
651
|
+
} catch (error) {
|
|
652
|
+
return { ok: false, reason: error.message };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return new Promise((resolve) => {
|
|
656
|
+
let stdout = "";
|
|
657
|
+
let stderr = "";
|
|
658
|
+
let settled = false;
|
|
659
|
+
|
|
660
|
+
const finish = (result) => {
|
|
661
|
+
if (settled) return;
|
|
662
|
+
settled = true;
|
|
663
|
+
clearTimeout(timer);
|
|
664
|
+
if (child.exitCode === null && child.signalCode === null) child.kill();
|
|
665
|
+
resolve(result);
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
const timer = setTimeout(
|
|
669
|
+
() => finish({ ok: false, reason: `the MCP server did not answer within ${Math.round(timeoutMs / 1000)}s`, stderr: lastLine(stderr) }),
|
|
670
|
+
timeoutMs
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
const send = (message) => {
|
|
674
|
+
try {
|
|
675
|
+
child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
676
|
+
} catch (error) {
|
|
677
|
+
finish({ ok: false, reason: error.message });
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
child.on("error", (error) => finish({ ok: false, reason: error.message }));
|
|
682
|
+
child.on("exit", (code) =>
|
|
683
|
+
finish({
|
|
684
|
+
ok: false,
|
|
685
|
+
reason: `the MCP server exited with code ${code}`,
|
|
686
|
+
stderr: lastLine(stderr),
|
|
687
|
+
})
|
|
688
|
+
);
|
|
689
|
+
|
|
690
|
+
child.stdout.setEncoding("utf8");
|
|
691
|
+
child.stderr.setEncoding("utf8");
|
|
692
|
+
child.stderr.on("data", (chunk) => {
|
|
693
|
+
stderr = `${stderr}${chunk}`.slice(-4000);
|
|
694
|
+
});
|
|
695
|
+
child.stdout.on("data", (chunk) => {
|
|
696
|
+
stdout += chunk;
|
|
697
|
+
let newline = stdout.indexOf("\n");
|
|
698
|
+
while (newline !== -1) {
|
|
699
|
+
const line = stdout.slice(0, newline).trim();
|
|
700
|
+
stdout = stdout.slice(newline + 1);
|
|
701
|
+
newline = stdout.indexOf("\n");
|
|
702
|
+
if (!line.startsWith("{")) continue;
|
|
703
|
+
let message;
|
|
704
|
+
try {
|
|
705
|
+
message = JSON.parse(line);
|
|
706
|
+
} catch {
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
if (message.id === 1) {
|
|
710
|
+
send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
711
|
+
send({
|
|
712
|
+
jsonrpc: "2.0",
|
|
713
|
+
id: 2,
|
|
714
|
+
method: "tools/call",
|
|
715
|
+
params: { name: "preman_status", arguments: {} },
|
|
716
|
+
});
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
if (message.id !== 2) continue;
|
|
720
|
+
const text = message.result?.content?.[0]?.text;
|
|
721
|
+
let status = {};
|
|
722
|
+
try {
|
|
723
|
+
status = text ? JSON.parse(text) : {};
|
|
724
|
+
} catch {
|
|
725
|
+
status = { raw: text };
|
|
726
|
+
}
|
|
727
|
+
finish({ ok: !message.error, status, reason: message.error?.message || "" });
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
send({
|
|
732
|
+
jsonrpc: "2.0",
|
|
733
|
+
id: 1,
|
|
734
|
+
method: "initialize",
|
|
735
|
+
params: {
|
|
736
|
+
protocolVersion: "2024-11-05",
|
|
737
|
+
capabilities: {},
|
|
738
|
+
clientInfo: { name: "preman-connect-selftest", version: "1" },
|
|
739
|
+
},
|
|
740
|
+
});
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
523
744
|
// ── Dispatch credential (SCRUM-124) ─────────────────────────────────────
|
|
524
745
|
|
|
525
746
|
async function captureDispatchCredential(args, agent, apiKey, { prompt = true } = {}) {
|
|
@@ -651,79 +872,379 @@ function nextStepsBlock(agent) {
|
|
|
651
872
|
return (
|
|
652
873
|
"\nNext steps:\n" +
|
|
653
874
|
` 1. Restart ${agent.label}, then ask it: "run preman_status" to finish linking.\n` +
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
875
|
+
` 2. ${cliInvocation()} runner start --background # let PreMan run work here\n` +
|
|
876
|
+
` 3. ${cliInvocation()} endpoints discover # brief for your agent → endpoints.json\n` +
|
|
877
|
+
` 4. ${cliInvocation()} hook install # test affected endpoints on git push\n`
|
|
657
878
|
);
|
|
658
879
|
}
|
|
659
880
|
|
|
660
|
-
async function confirm(question) {
|
|
661
|
-
|
|
662
|
-
|
|
881
|
+
async function confirm(question, { assumeYes = false, defaultYes = true } = {}) {
|
|
882
|
+
if (assumeYes) return true;
|
|
883
|
+
if (!process.stdin.isTTY) return false;
|
|
884
|
+
const answer = (await promptText(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"}: `)).toLowerCase();
|
|
885
|
+
if (answer === "") return defaultYes;
|
|
886
|
+
return answer.startsWith("y");
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function step(title) {
|
|
890
|
+
process.stdout.write(`\n${title}\n`);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** What the account can see right now: registry rows, and the runnable subset. */
|
|
894
|
+
async function endpointCounts(args) {
|
|
895
|
+
const inventory = await callPremanTool(args, "get_endpoints", {
|
|
896
|
+
include_workbench: true,
|
|
897
|
+
limit: 200,
|
|
898
|
+
});
|
|
899
|
+
const runnable = inventory.workbench_requests || [];
|
|
900
|
+
return {
|
|
901
|
+
registered: (inventory.endpoints || []).length,
|
|
902
|
+
runnable: runnable.length,
|
|
903
|
+
first: runnable[0] || null,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* The one-shot command that has an agent execute the discovery brief.
|
|
909
|
+
*
|
|
910
|
+
* Same shape as `headlessCheckIn`, with the read tools the walk needs added to
|
|
911
|
+
* Claude Code's allow-list: print mode refuses anything un-allowed instead of
|
|
912
|
+
* asking, so an agent given only the MCP server can call PreMan and read nothing.
|
|
913
|
+
*/
|
|
914
|
+
export function headlessDiscovery(agent, serverName, instructions = []) {
|
|
915
|
+
const prompt = [
|
|
916
|
+
`Register this project's HTTP endpoints in PreMan using the ${serverName} MCP tools.`,
|
|
917
|
+
"Work autonomously and do not ask for confirmation.",
|
|
918
|
+
"",
|
|
919
|
+
...instructions,
|
|
920
|
+
].join("\n");
|
|
921
|
+
if (agent.id === "cursor") return { bin: "cursor-agent", args: ["-p", prompt] };
|
|
922
|
+
if (agent.id === "claude_code") {
|
|
923
|
+
return {
|
|
924
|
+
bin: "claude",
|
|
925
|
+
args: ["-p", prompt, "--allowedTools", `mcp__${serverName},Read,Grep,Glob`],
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
if (agent.id === "codex") return { bin: "codex", args: ["exec", prompt] };
|
|
929
|
+
return null;
|
|
663
930
|
}
|
|
664
931
|
|
|
665
932
|
/**
|
|
666
|
-
*
|
|
933
|
+
* Get this project's endpoints into PreMan without handing anyone homework.
|
|
667
934
|
*
|
|
668
|
-
* Discovery
|
|
669
|
-
*
|
|
670
|
-
*
|
|
935
|
+
* Discovery is the agent's job — only it can read the repo — but "here is a brief,
|
|
936
|
+
* go paste it somewhere" is the step everybody dropped out on, and the commands
|
|
937
|
+
* printed after it referenced a file the user never had. So run the agent on the
|
|
938
|
+
* brief and report the two numbers that matter.
|
|
671
939
|
*
|
|
672
|
-
* Never throws:
|
|
940
|
+
* Never throws: a failed discovery must not fail the connect.
|
|
673
941
|
*/
|
|
674
|
-
async function
|
|
942
|
+
async function discoverEndpoints(args, agent, serverName) {
|
|
675
943
|
try {
|
|
676
|
-
const
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
const result = await callPremanTool(args, "generate_endpoint_tests", {
|
|
691
|
-
target: runnable.id,
|
|
692
|
-
run: true,
|
|
693
|
-
allow_writes: false,
|
|
694
|
-
max_cases: 10,
|
|
695
|
-
});
|
|
696
|
-
printTestSummary(result);
|
|
697
|
-
process.stdout.write(`\nAdd your own: preman test ${runnable.id} --scenario "..."\n`);
|
|
698
|
-
return;
|
|
944
|
+
const before = await endpointCounts(args);
|
|
945
|
+
if (before.registered) {
|
|
946
|
+
process.stdout.write(
|
|
947
|
+
`${MARK.ok()} ${before.registered} endpoint(s) · ${before.runnable} runnable\n`
|
|
948
|
+
);
|
|
949
|
+
return before;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const brief = await callPremanTool(args, "discover_endpoints_from_codebase", { base_path: "." });
|
|
953
|
+
const spec = headlessDiscovery(agent, serverName, brief.instructions || []);
|
|
954
|
+
if (!spec || !onPath(spec.bin)) {
|
|
955
|
+
for (const line of brief.instructions || []) process.stdout.write(`${line}\n`);
|
|
956
|
+
process.stdout.write(`\nHand this brief to ${agent.label}, then run:\n${MANUAL_STEPS}`);
|
|
957
|
+
return before;
|
|
699
958
|
}
|
|
700
959
|
|
|
701
|
-
|
|
960
|
+
process.stdout.write(`Asking ${agent.label} to map this codebase…\n`);
|
|
961
|
+
const timeoutMs = Number(process.env.PREMAN_DISCOVER_MS) || 600000;
|
|
962
|
+
const outcome = await runAgentOnce(spec, timeoutMs);
|
|
963
|
+
const after = await endpointCounts(args);
|
|
964
|
+
|
|
965
|
+
if (!after.registered) {
|
|
966
|
+
const why = outcome.reason || lastLine(outcome.output) || "it registered nothing";
|
|
702
967
|
process.stdout.write(
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
" preman test <request-id> # generate + run your first scenarios\n"
|
|
968
|
+
`${MARK.fail()} ${agent.label} did not register any endpoints (${why}).\n` +
|
|
969
|
+
` Run it yourself: ${cliInvocation()} endpoints discover\n`
|
|
706
970
|
);
|
|
707
|
-
return;
|
|
971
|
+
return after;
|
|
708
972
|
}
|
|
973
|
+
process.stdout.write(
|
|
974
|
+
`${MARK.ok()} ${after.registered} endpoint(s) · ${after.runnable} runnable\n`
|
|
975
|
+
);
|
|
976
|
+
return after;
|
|
977
|
+
} catch (error) {
|
|
978
|
+
process.stdout.write(`${MARK.fail()} Could not read your endpoints: ${error.message}\n`);
|
|
979
|
+
return { registered: 0, runnable: 0, first: null };
|
|
980
|
+
}
|
|
981
|
+
}
|
|
709
982
|
|
|
710
|
-
|
|
711
|
-
|
|
983
|
+
/** Run one bounded, non-interactive agent invocation and keep its tail. */
|
|
984
|
+
async function runAgentOnce(spec, timeoutMs) {
|
|
985
|
+
return new Promise((resolve) => {
|
|
986
|
+
let child;
|
|
987
|
+
try {
|
|
988
|
+
child = spawn(spec.bin, spec.args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
989
|
+
} catch (error) {
|
|
990
|
+
resolve({ ok: false, reason: error.message, output: "" });
|
|
712
991
|
return;
|
|
713
992
|
}
|
|
993
|
+
let output = "";
|
|
994
|
+
const absorb = (chunk) => {
|
|
995
|
+
output = `${output}${chunk}`.slice(-8000);
|
|
996
|
+
};
|
|
997
|
+
child.stdout?.setEncoding("utf8");
|
|
998
|
+
child.stderr?.setEncoding("utf8");
|
|
999
|
+
child.stdout?.on("data", absorb);
|
|
1000
|
+
child.stderr?.on("data", absorb);
|
|
1001
|
+
|
|
1002
|
+
const timer = setTimeout(() => {
|
|
1003
|
+
child.kill("SIGTERM");
|
|
1004
|
+
resolve({ ok: false, reason: `timed out after ${Math.round(timeoutMs / 1000)}s`, output });
|
|
1005
|
+
}, timeoutMs);
|
|
1006
|
+
child.on("error", (error) => {
|
|
1007
|
+
clearTimeout(timer);
|
|
1008
|
+
resolve({ ok: false, reason: error.message, output });
|
|
1009
|
+
});
|
|
1010
|
+
child.on("close", (code) => {
|
|
1011
|
+
clearTimeout(timer);
|
|
1012
|
+
resolve({
|
|
1013
|
+
ok: code === 0,
|
|
1014
|
+
reason: code === 0 ? "" : `${spec.bin} exited with code ${code}`,
|
|
1015
|
+
output,
|
|
1016
|
+
});
|
|
1017
|
+
});
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
714
1020
|
|
|
715
|
-
|
|
716
|
-
|
|
1021
|
+
/** Generate and run scenarios against the first runnable request, if they want it. */
|
|
1022
|
+
async function runFirstTest(args, runnable, { assumeYes }) {
|
|
1023
|
+
const label = `${runnable.method || "GET"} ${runnable.url || ""}`.trim();
|
|
1024
|
+
if (!(await confirm(`Run a first test against ${label}?`, { assumeYes }))) {
|
|
1025
|
+
process.stdout.write(`${MARK.skip()} Whenever you are ready: ${cliInvocation()} test ${runnable.id}\n`);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
try {
|
|
1029
|
+
process.stdout.write("Generating scenarios…\n");
|
|
1030
|
+
const result = await callPremanTool(args, "generate_endpoint_tests", {
|
|
1031
|
+
target: runnable.id,
|
|
1032
|
+
run: true,
|
|
1033
|
+
allow_writes: false,
|
|
1034
|
+
max_cases: 10,
|
|
1035
|
+
});
|
|
1036
|
+
printTestSummary(result);
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
process.stdout.write(`${MARK.fail()} Could not run the first test: ${error.message}\n`);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Pair this machine as a runner and leave it running.
|
|
1044
|
+
*
|
|
1045
|
+
* Without this, everything PreMan finds later is advice: it can describe a fix
|
|
1046
|
+
* and cannot apply one. With it, the queue has somewhere to land.
|
|
1047
|
+
*/
|
|
1048
|
+
async function setUpRunner(args, agent, { assumeYes }) {
|
|
1049
|
+
if (args.has("--no-runner")) return { state: "skipped" };
|
|
1050
|
+
const existing = readRunnerState();
|
|
1051
|
+
if (existing && runnerIsAlive()) {
|
|
1052
|
+
process.stdout.write(`${MARK.ok()} Runner already running for ${existing.project_path}\n`);
|
|
1053
|
+
return { state: "running" };
|
|
1054
|
+
}
|
|
1055
|
+
if (
|
|
1056
|
+
!(await confirm(
|
|
1057
|
+
`Let PreMan run ${agent.label} here when it finds something to fix?`,
|
|
1058
|
+
{ assumeYes }
|
|
1059
|
+
))
|
|
1060
|
+
) {
|
|
1061
|
+
process.stdout.write(
|
|
1062
|
+
`${MARK.skip()} Skipped. Start it later: ${cliInvocation()} runner start --background\n`
|
|
1063
|
+
);
|
|
1064
|
+
return { state: "skipped" };
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
try {
|
|
1068
|
+
if (!existing || existing.agent !== agent.id || existing.project_path !== path.resolve(process.cwd())) {
|
|
1069
|
+
await registerRunner(args, { agent: agent.id, projectPath: process.cwd() });
|
|
1070
|
+
}
|
|
1071
|
+
const started = startBackground([]);
|
|
1072
|
+
process.stdout.write(
|
|
1073
|
+
`${MARK.ok()} Runner running (pid ${started.pid}) — PreMan can apply fixes on this machine.\n` +
|
|
1074
|
+
` Log: ${started.log} Stop: ${cliInvocation()} runner stop\n`
|
|
1075
|
+
);
|
|
1076
|
+
return { state: "running", pid: started.pid };
|
|
1077
|
+
} catch (error) {
|
|
717
1078
|
process.stdout.write(
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
" preman test <request-id>\n"
|
|
1079
|
+
`${MARK.fail()} Could not start the runner: ${error.message}\n` +
|
|
1080
|
+
` Retry later: ${cliInvocation()} runner start --background\n`
|
|
721
1081
|
);
|
|
1082
|
+
return { state: "failed", detail: error.message };
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/** Offer the desktop app. Optional by design — the terminal flow is complete without it. */
|
|
1087
|
+
async function offerDesktop(args, { assumeYes }) {
|
|
1088
|
+
if (args.has("--no-desktop")) return { state: "skipped" };
|
|
1089
|
+
if (process.platform !== "darwin") return { state: "unsupported" };
|
|
1090
|
+
if (existsSync("/Applications/PreMan.app")) {
|
|
1091
|
+
process.stdout.write(`${MARK.ok()} PreMan desktop app already installed.\n`);
|
|
1092
|
+
return { state: "installed" };
|
|
1093
|
+
}
|
|
1094
|
+
// Default no, unlike every other step here: this one downloads a hundred-odd
|
|
1095
|
+
// megabytes and writes to /Applications, which nobody should get by pressing
|
|
1096
|
+
// Enter to move past a prompt.
|
|
1097
|
+
if (
|
|
1098
|
+
!(await confirm("Install the PreMan desktop app to watch runs and endpoints?", {
|
|
1099
|
+
assumeYes,
|
|
1100
|
+
defaultYes: false,
|
|
1101
|
+
}))
|
|
1102
|
+
) {
|
|
1103
|
+
process.stdout.write(
|
|
1104
|
+
`${MARK.skip()} Skipped. Install later: ${cliInvocation()} install-desktop\n`
|
|
1105
|
+
);
|
|
1106
|
+
return { state: "skipped" };
|
|
1107
|
+
}
|
|
1108
|
+
try {
|
|
1109
|
+
return await installDesktopCommand([]);
|
|
722
1110
|
} catch (error) {
|
|
1111
|
+
process.stdout.write(`${MARK.fail()} Desktop install failed: ${error.message}\n`);
|
|
1112
|
+
return { state: "failed", detail: error.message };
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Which of GitHub, AWS and Slack this account already has.
|
|
1118
|
+
*
|
|
1119
|
+
* One probe per provider because each owns its own list route; a provider whose
|
|
1120
|
+
* route errors is reported as unknown rather than missing, so a backend hiccup
|
|
1121
|
+
* does not send someone through an install they already did.
|
|
1122
|
+
*/
|
|
1123
|
+
export async function integrationStatus(args, apiKey) {
|
|
1124
|
+
const probe = async (method, route, pick) => {
|
|
1125
|
+
try {
|
|
1126
|
+
const result = await callBackendJson(args, method, route, { token: apiKey });
|
|
1127
|
+
if (!result.ok) return null;
|
|
1128
|
+
return (pick(result) || []).length;
|
|
1129
|
+
} catch {
|
|
1130
|
+
return null;
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1133
|
+
const [github, aws, slack] = await Promise.all([
|
|
1134
|
+
probe("GET", "/integrations/github", (r) => r.list || r.integrations || r.repos),
|
|
1135
|
+
probe("GET", "/aws-links", (r) => r.links || r.list),
|
|
1136
|
+
probe("GET", "/slack/connections", (r) => r.connections || r.list),
|
|
1137
|
+
]);
|
|
1138
|
+
return { github, aws, slack };
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Show what is connected, and offer to finish what is not.
|
|
1143
|
+
*
|
|
1144
|
+
* Each install lives in the customer's browser session, so every one of these
|
|
1145
|
+
* opens a URL and polls our own API until the other side reports the connection
|
|
1146
|
+
* exists — the terminal picks the result up on its own, whether they finished in
|
|
1147
|
+
* the browser or in the desktop app.
|
|
1148
|
+
*/
|
|
1149
|
+
async function connectIntegrations(args, apiKey, { assumeYes }) {
|
|
1150
|
+
if (args.has("--no-integrations")) return;
|
|
1151
|
+
const status = await integrationStatus(args, apiKey);
|
|
1152
|
+
const providers = [
|
|
1153
|
+
{ key: "github", label: "GitHub", question: "Connect GitHub?", run: () => githubCommand(args) },
|
|
1154
|
+
{ key: "aws", label: "AWS", question: "Connect AWS?", run: () => awsCommand(args) },
|
|
1155
|
+
{ key: "slack", label: "Slack", question: "Connect Slack?", run: () => slackCommand(args) },
|
|
1156
|
+
];
|
|
1157
|
+
|
|
1158
|
+
for (const provider of providers) {
|
|
1159
|
+
const count = status[provider.key];
|
|
1160
|
+
if (count === null) {
|
|
1161
|
+
process.stdout.write(`${MARK.skip()} ${provider.label} — could not check\n`);
|
|
1162
|
+
} else if (count > 0) {
|
|
1163
|
+
process.stdout.write(`${MARK.ok()} ${provider.label} connected\n`);
|
|
1164
|
+
} else {
|
|
1165
|
+
process.stdout.write(`${MARK.skip()} ${provider.label} not connected\n`);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
for (const provider of providers) {
|
|
1170
|
+
if (status[provider.key] !== 0) continue;
|
|
1171
|
+
if (!(await confirm(provider.question, { assumeYes, defaultYes: false }))) continue;
|
|
1172
|
+
try {
|
|
1173
|
+
await provider.run();
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
// One provider's failure must not cost the customer the ones that worked.
|
|
1176
|
+
process.stdout.write(`${MARK.fail()} Could not finish ${provider.label}: ${error.message}\n`);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/** Install the pre-push hook, so a push is what triggers the tests. */
|
|
1182
|
+
async function setUpPushTesting(args, { assumeYes }) {
|
|
1183
|
+
if (args.has("--no-hook")) return { state: "skipped" };
|
|
1184
|
+
const current = (() => {
|
|
1185
|
+
try {
|
|
1186
|
+
return hookStatus();
|
|
1187
|
+
} catch {
|
|
1188
|
+
return null; // not a git repository
|
|
1189
|
+
}
|
|
1190
|
+
})();
|
|
1191
|
+
if (!current) {
|
|
1192
|
+
process.stdout.write(`${MARK.skip()} Not a git repository — no push testing here.\n`);
|
|
1193
|
+
return { state: "unavailable" };
|
|
1194
|
+
}
|
|
1195
|
+
if (current.state === "installed") {
|
|
1196
|
+
process.stdout.write(`${MARK.ok()} Push testing already on.\n`);
|
|
1197
|
+
return { state: "installed" };
|
|
1198
|
+
}
|
|
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
|
+
|
|
1204
|
+
const result = installHook(args);
|
|
1205
|
+
if (result.action === "conflict") {
|
|
723
1206
|
process.stdout.write(
|
|
724
|
-
|
|
1207
|
+
`${MARK.skip()} You already have a pre-push hook. Replace it: ${cliInvocation()} hook install --force\n`
|
|
725
1208
|
);
|
|
1209
|
+
return result;
|
|
1210
|
+
}
|
|
1211
|
+
process.stdout.write(
|
|
1212
|
+
`${MARK.ok()} Push testing on — \`git push\` now checks the endpoints you touched.\n` +
|
|
1213
|
+
" It never blocks a push; PREMAN_SKIP_HOOK=1 silences it.\n"
|
|
1214
|
+
);
|
|
1215
|
+
return result;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* Everything after the link, in one pass, with nothing left for the user to run.
|
|
1220
|
+
*
|
|
1221
|
+
* Order follows what a new account needs to see: what PreMan found, then where to
|
|
1222
|
+
* watch it, then who to tell, then when to run it.
|
|
1223
|
+
*/
|
|
1224
|
+
async function guidedFirstRun(args, agent, apiKey, serverName) {
|
|
1225
|
+
const assumeYes = args.has("--yes");
|
|
1226
|
+
|
|
1227
|
+
step("Endpoints");
|
|
1228
|
+
const counts = await discoverEndpoints(args, agent, serverName);
|
|
1229
|
+
|
|
1230
|
+
if (counts.first) {
|
|
1231
|
+
step("First test");
|
|
1232
|
+
await runFirstTest(args, counts.first, { assumeYes });
|
|
726
1233
|
}
|
|
1234
|
+
|
|
1235
|
+
step("Runner");
|
|
1236
|
+
await setUpRunner(args, agent, { assumeYes });
|
|
1237
|
+
|
|
1238
|
+
step("Desktop app");
|
|
1239
|
+
await offerDesktop(args, { assumeYes });
|
|
1240
|
+
|
|
1241
|
+
step("Integrations");
|
|
1242
|
+
await connectIntegrations(args, apiKey, { assumeYes });
|
|
1243
|
+
|
|
1244
|
+
step("Testing on push");
|
|
1245
|
+
await setUpPushTesting(args, { assumeYes });
|
|
1246
|
+
|
|
1247
|
+
process.stdout.write(`\nDone. Watch it at ${frontendUrl(args)}\n`);
|
|
727
1248
|
}
|
|
728
1249
|
|
|
729
1250
|
// ── Preflight ───────────────────────────────────────────────────────────
|
|
@@ -786,9 +1307,15 @@ Connect options:
|
|
|
786
1307
|
--skip-dispatch-credential Do not ask for a cloud-dispatch credential
|
|
787
1308
|
--skip-login Write config without interactive terminal auth
|
|
788
1309
|
--no-pair Do not mint a pair code
|
|
1310
|
+
--no-self-test Do not start the MCP server to finish the link
|
|
789
1311
|
--no-auto-checkin Do not run the agent to finish the link
|
|
790
1312
|
--no-wait Do not wait for the agent to check in
|
|
791
1313
|
--no-guide Skip the guided first run after connecting
|
|
1314
|
+
--no-runner Do not pair this machine as a job runner
|
|
1315
|
+
--no-desktop Do not offer the desktop app
|
|
1316
|
+
--no-integrations Do not check or offer GitHub / AWS / Slack
|
|
1317
|
+
--no-hook Do not install the git pre-push hook
|
|
1318
|
+
--yes Accept every optional step without prompting
|
|
792
1319
|
--print Print the config instead of writing it
|
|
793
1320
|
`;
|
|
794
1321
|
|
|
@@ -853,11 +1380,19 @@ export async function connectCommand(commandArgs) {
|
|
|
853
1380
|
|
|
854
1381
|
if (!agent) agent = await promptAgentChoice(detectAgents());
|
|
855
1382
|
|
|
856
|
-
|
|
1383
|
+
let apiKey = resolveApiKey(args);
|
|
857
1384
|
|
|
858
1385
|
let pairCode = "";
|
|
859
|
-
if (apiKey
|
|
860
|
-
|
|
1386
|
+
if (apiKey) {
|
|
1387
|
+
let pairing = await checkKeyAndPair(args, agent, apiKey);
|
|
1388
|
+
if (pairing.stale) {
|
|
1389
|
+
apiKey = await refreshStaleCredentials(args);
|
|
1390
|
+
pairing = await checkKeyAndPair(args, agent, apiKey);
|
|
1391
|
+
if (pairing.stale) {
|
|
1392
|
+
throw new ConnectError("PreMan rejected a key it just issued. Try again shortly.");
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
pairCode = pairing.pairCode;
|
|
861
1396
|
}
|
|
862
1397
|
|
|
863
1398
|
const serverConfig = buildServerConfig(args, { pairCode });
|
|
@@ -894,37 +1429,76 @@ export async function connectCommand(commandArgs) {
|
|
|
894
1429
|
return;
|
|
895
1430
|
}
|
|
896
1431
|
|
|
897
|
-
if (!(await establishCheckIn(args, agent, apiKey, { serverName, written }))) {
|
|
1432
|
+
if (!(await establishCheckIn(args, agent, apiKey, { serverName, written, serverConfig }))) {
|
|
898
1433
|
// Still honour an explicitly-passed credential, but do not open a new prompt
|
|
899
1434
|
// on top of a connect that just told the user something went wrong.
|
|
900
1435
|
await captureDispatchCredential(args, agent, apiKey, { prompt: false });
|
|
901
1436
|
return;
|
|
902
1437
|
}
|
|
903
1438
|
|
|
904
|
-
process.stdout.write(
|
|
1439
|
+
process.stdout.write(`${MARK.ok()} Connected as ${agent.label}.\n`);
|
|
905
1440
|
if (!args.has("--no-guide")) {
|
|
906
|
-
await guidedFirstRun(args, agent);
|
|
1441
|
+
await guidedFirstRun(args, agent, apiKey, serverName);
|
|
907
1442
|
}
|
|
908
1443
|
await captureDispatchCredential(args, agent, apiKey);
|
|
909
1444
|
}
|
|
910
1445
|
|
|
911
1446
|
/**
|
|
912
|
-
*
|
|
1447
|
+
* Finish the link here, by whatever means work, in cheapest-first order.
|
|
1448
|
+
*
|
|
1449
|
+
* 1. Run the MCP server ourselves and call one tool. No agent, no tokens, a few
|
|
1450
|
+
* seconds, and it proves the launcher/key/backend chain the agent will use.
|
|
1451
|
+
* 2. Failing that, run the agent headlessly — which also proves the agent can
|
|
1452
|
+
* load the config we wrote.
|
|
1453
|
+
* 3. Failing that, ask them to restart it and wait, which is all this ever did.
|
|
913
1454
|
*
|
|
914
|
-
*
|
|
915
|
-
*
|
|
916
|
-
* prints the troubleshooting block itself when it did not.
|
|
1455
|
+
* Returns whether the check-in landed, and prints the troubleshooting block
|
|
1456
|
+
* itself when it did not.
|
|
917
1457
|
*/
|
|
918
|
-
async function establishCheckIn(args, agent, apiKey, { serverName, written }) {
|
|
1458
|
+
async function establishCheckIn(args, agent, apiKey, { serverName, written, serverConfig }) {
|
|
1459
|
+
const notes = [];
|
|
1460
|
+
|
|
1461
|
+
if (!args.has("--no-self-test") && serverConfig && selfTestBudgetMs() > 0) {
|
|
1462
|
+
process.stdout.write("\nChecking the connection…\n");
|
|
1463
|
+
const test = await mcpSelfTest(serverConfig);
|
|
1464
|
+
const status = test.status || {};
|
|
1465
|
+
const repo = status.config?.repo_config;
|
|
1466
|
+
if (repo?.override && repo.applied?.length) {
|
|
1467
|
+
// The one failure that reads as a bad key: a working server talking to a
|
|
1468
|
+
// backend nobody chose. Name the file before it costs anyone an hour.
|
|
1469
|
+
notes.push(
|
|
1470
|
+
`${repo.path} overrides ${repo.applied.join(", ")} for anything started in this directory, ` +
|
|
1471
|
+
`so your agent will use ${status.backend_url}.`
|
|
1472
|
+
);
|
|
1473
|
+
}
|
|
1474
|
+
if (test.ok && status.authenticated && (await waitForConnection(args, apiKey, { timeoutMs: 15000 }))) {
|
|
1475
|
+
for (const note of notes) process.stdout.write(`Note: ${note}\n`);
|
|
1476
|
+
return true;
|
|
1477
|
+
}
|
|
1478
|
+
notes.push(
|
|
1479
|
+
test.ok
|
|
1480
|
+
? `the MCP server answered from ${status.backend_url || "an unknown backend"} but was not authenticated`
|
|
1481
|
+
: `the MCP server could not be started (${test.reason || "unknown"}${test.stderr ? `: ${test.stderr}` : ""})`
|
|
1482
|
+
);
|
|
1483
|
+
}
|
|
1484
|
+
|
|
919
1485
|
if (!args.has("--no-auto-checkin")) {
|
|
920
1486
|
const spec = headlessCheckIn(agent, serverName);
|
|
921
1487
|
if (spec && onPath(spec.bin)) {
|
|
922
1488
|
process.stdout.write(`\nStarting ${agent.label} to finish the link…\n`);
|
|
923
1489
|
}
|
|
924
1490
|
const auto = await autoCheckIn(args, agent, apiKey, { serverName });
|
|
925
|
-
if (auto.connected)
|
|
1491
|
+
if (auto.connected) {
|
|
1492
|
+
for (const note of notes) process.stdout.write(`Note: ${note}\n`);
|
|
1493
|
+
return true;
|
|
1494
|
+
}
|
|
926
1495
|
if (auto.ran) {
|
|
927
|
-
|
|
1496
|
+
const why = lastLine(auto.output);
|
|
1497
|
+
process.stdout.write(
|
|
1498
|
+
`${agent.label} ran but did not check in${why ? `: ${why}` : "."}\n`
|
|
1499
|
+
);
|
|
1500
|
+
} else if (auto.reason) {
|
|
1501
|
+
process.stdout.write(`Could not run ${agent.label}: ${auto.reason}\n`);
|
|
928
1502
|
}
|
|
929
1503
|
}
|
|
930
1504
|
|
|
@@ -933,10 +1507,14 @@ async function establishCheckIn(args, agent, apiKey, { serverName, written }) {
|
|
|
933
1507
|
"Waiting for your agent to check in… (Ctrl+C to stop waiting)\n"
|
|
934
1508
|
);
|
|
935
1509
|
|
|
936
|
-
if (await waitForConnection(args, apiKey))
|
|
1510
|
+
if (await waitForConnection(args, apiKey)) {
|
|
1511
|
+
for (const note of notes) process.stdout.write(`Note: ${note}\n`);
|
|
1512
|
+
return true;
|
|
1513
|
+
}
|
|
937
1514
|
|
|
938
1515
|
process.stdout.write(
|
|
939
1516
|
"No check-in yet. Troubleshooting:\n" +
|
|
1517
|
+
notes.map((note) => ` - ${note}\n`).join("") +
|
|
940
1518
|
` - ${agent.restartHint}\n` +
|
|
941
1519
|
` - Config written to: ${written.path}\n` +
|
|
942
1520
|
` - Then ask ${agent.label} to "run preman_status" — it links on its first PreMan call.\n` +
|