premanmcp 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -11
- package/bin/account.js +1 -1
- package/bin/api_tools.js +2 -2
- package/bin/cli.js +5 -1
- package/bin/connect.js +732 -68
- package/bin/hook.js +16 -4
- package/bin/integrations.js +46 -25
- package/bin/runner.js +946 -0
- package/bin/shared.js +65 -6
- package/dist/server.js +30 -9
- package/package.json +3 -3
package/bin/connect.js
CHANGED
|
@@ -16,18 +16,31 @@ 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";
|
|
19
22
|
import {
|
|
23
|
+
confirmRunnerOnline,
|
|
24
|
+
readRunnerState,
|
|
25
|
+
registerRunner,
|
|
26
|
+
runnerIsAlive,
|
|
27
|
+
startBackground,
|
|
28
|
+
} from "./runner.js";
|
|
29
|
+
import {
|
|
30
|
+
apiKeyIsExplicit,
|
|
20
31
|
assertOk,
|
|
21
32
|
authenticateTerminal,
|
|
22
33
|
backendUrl,
|
|
23
34
|
buildServerConfig,
|
|
24
35
|
callBackendJson,
|
|
36
|
+
clearStoredCredentials,
|
|
25
37
|
cliInvocation,
|
|
26
38
|
frontendUrl,
|
|
27
39
|
hasKeyAvailable,
|
|
28
40
|
LAUNCHER_ARGS,
|
|
29
41
|
LAUNCHER_COMMAND,
|
|
30
42
|
makeArgs,
|
|
43
|
+
pathPremanOwner,
|
|
31
44
|
promptSecret,
|
|
32
45
|
promptText,
|
|
33
46
|
readJsonFile,
|
|
@@ -97,6 +110,29 @@ function onPath(binary) {
|
|
|
97
110
|
return probe.status === 0;
|
|
98
111
|
}
|
|
99
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
|
+
|
|
100
136
|
/** Best guess at which agent this machine actually uses, for the default pick. */
|
|
101
137
|
function detectAgents() {
|
|
102
138
|
const home = os.homedir();
|
|
@@ -389,19 +425,81 @@ export function verifyWrittenConfig(agent, { serverName, written }) {
|
|
|
389
425
|
|
|
390
426
|
// ── Pairing ─────────────────────────────────────────────────────────────
|
|
391
427
|
|
|
428
|
+
/**
|
|
429
|
+
* Mint the pair code, distinguishing "could not reach it" from "this key is dead".
|
|
430
|
+
*
|
|
431
|
+
* 401 is the backend saying the key is invalid or revoked, which is the one
|
|
432
|
+
* failure the caller must not shrug off: every other outcome still leaves a
|
|
433
|
+
* usable config, but writing a revoked key into an agent's config produces a
|
|
434
|
+
* setup that looks connected and fails on every call.
|
|
435
|
+
*/
|
|
392
436
|
async function startPairing(args, agent, apiKey) {
|
|
393
437
|
const result = await callBackendJson(args, "PUT", "/workbench/coding-agent", {
|
|
394
438
|
token: apiKey,
|
|
395
439
|
json: { agent: agent.id, project_path: process.cwd(), start_pairing: true },
|
|
396
440
|
});
|
|
441
|
+
if (result.status_code === 401) return { pairCode: "", stale: true };
|
|
397
442
|
if (!result.ok) {
|
|
398
443
|
process.stdout.write(
|
|
399
444
|
`Note: could not start pairing (${result.status_code}); the config still works, ` +
|
|
400
445
|
"your agent will link on its first PreMan call.\n"
|
|
401
446
|
);
|
|
402
|
-
return "";
|
|
447
|
+
return { pairCode: "", stale: false };
|
|
448
|
+
}
|
|
449
|
+
return { pairCode: String(result.pair_code || ""), stale: false };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* The --no-pair stand-in for the auth check pairing performs implicitly.
|
|
454
|
+
*
|
|
455
|
+
* Declining a pair code should not also decline noticing the key is dead,
|
|
456
|
+
* otherwise this flag writes exactly the broken config pairing would catch.
|
|
457
|
+
*/
|
|
458
|
+
async function verifyKey(args, apiKey) {
|
|
459
|
+
const result = await callBackendJson(args, "GET", "/auth/me", { token: apiKey });
|
|
460
|
+
return { pairCode: "", stale: result.status_code === 401 };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Whichever of the two ways to learn the backend still accepts this key. */
|
|
464
|
+
async function checkKeyAndPair(args, agent, apiKey) {
|
|
465
|
+
return args.has("--no-pair")
|
|
466
|
+
? verifyKey(args, apiKey)
|
|
467
|
+
: startPairing(args, agent, apiKey);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Replace credentials the backend has rejected, and return the new key.
|
|
472
|
+
*
|
|
473
|
+
* Only stored credentials can be replaced here: an explicit --api-key or
|
|
474
|
+
* PREMAN_API_KEY would just be re-read, so those say so and stop rather than
|
|
475
|
+
* looping. Refusing to continue is deliberate — the alternative is writing a
|
|
476
|
+
* key we know is dead into the agent's config.
|
|
477
|
+
*/
|
|
478
|
+
async function refreshStaleCredentials(args) {
|
|
479
|
+
if (apiKeyIsExplicit(args)) {
|
|
480
|
+
const source = args.value("--api-key", "") ? "--api-key" : "PREMAN_API_KEY";
|
|
481
|
+
throw new ConnectError(
|
|
482
|
+
`The PreMan API key from ${source} is invalid or revoked. Create a new one in the ` +
|
|
483
|
+
`dashboard, or drop ${source} and run '${cliInvocation()} connect' to sign in.`,
|
|
484
|
+
EXIT_USAGE
|
|
485
|
+
);
|
|
403
486
|
}
|
|
404
|
-
|
|
487
|
+
if (args.has("--skip-login") || !process.stdin.isTTY) {
|
|
488
|
+
throw new ConnectError(
|
|
489
|
+
"Saved PreMan credentials are invalid or revoked. Run " +
|
|
490
|
+
`'${cliInvocation()} logout', then connect again to sign in.`,
|
|
491
|
+
EXIT_USAGE
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
process.stdout.write(
|
|
496
|
+
"\nYour saved PreMan credentials are no longer valid — the key was revoked, or the\n" +
|
|
497
|
+
"account behind it was removed. Signing you in again.\n"
|
|
498
|
+
);
|
|
499
|
+
clearStoredCredentials();
|
|
500
|
+
await authenticateTerminal(args);
|
|
501
|
+
process.stdout.write("\n");
|
|
502
|
+
return resolveApiKey(args);
|
|
405
503
|
}
|
|
406
504
|
|
|
407
505
|
export async function waitForConnection(
|
|
@@ -487,13 +585,24 @@ export async function autoCheckIn(
|
|
|
487
585
|
|
|
488
586
|
let child;
|
|
489
587
|
try {
|
|
490
|
-
|
|
588
|
+
// Piped rather than ignored: an agent that runs and does not check in used to
|
|
589
|
+
// report exactly that and nothing else, which is the least useful sentence
|
|
590
|
+
// available. Its own last words usually name the cause.
|
|
591
|
+
child = spawn(spec.bin, spec.args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
491
592
|
} catch (error) {
|
|
492
593
|
return { ran: false, connected: false, reason: error.message };
|
|
493
594
|
}
|
|
494
595
|
|
|
495
596
|
let spawnError = null;
|
|
496
597
|
let exitedAt = 0;
|
|
598
|
+
let output = "";
|
|
599
|
+
const absorb = (chunk) => {
|
|
600
|
+
output = `${output}${chunk}`.slice(-4000);
|
|
601
|
+
};
|
|
602
|
+
child.stdout?.setEncoding("utf8");
|
|
603
|
+
child.stderr?.setEncoding("utf8");
|
|
604
|
+
child.stdout?.on("data", absorb);
|
|
605
|
+
child.stderr?.on("data", absorb);
|
|
497
606
|
child.on("error", (error) => {
|
|
498
607
|
spawnError = error;
|
|
499
608
|
exitedAt = exitedAt || Date.now();
|
|
@@ -512,14 +621,156 @@ export async function autoCheckIn(
|
|
|
512
621
|
stopWhen: () => Boolean(exitedAt) && Date.now() - exitedAt > grace,
|
|
513
622
|
});
|
|
514
623
|
if (spawnError && !connected) {
|
|
515
|
-
return { ran: false, connected: false, reason: spawnError.message };
|
|
624
|
+
return { ran: false, connected: false, reason: spawnError.message, output };
|
|
516
625
|
}
|
|
517
|
-
return { ran: true, connected, command: spec.bin };
|
|
626
|
+
return { ran: true, connected, command: spec.bin, output };
|
|
518
627
|
} finally {
|
|
519
628
|
if (child.exitCode === null && child.signalCode === null) child.kill();
|
|
520
629
|
}
|
|
521
630
|
}
|
|
522
631
|
|
|
632
|
+
/** The last non-empty line of an agent's output, for a one-line diagnosis. */
|
|
633
|
+
export function lastLine(output, cap = 200) {
|
|
634
|
+
const lines = String(output || "")
|
|
635
|
+
.split("\n")
|
|
636
|
+
.map((line) => line.trim())
|
|
637
|
+
.filter(Boolean);
|
|
638
|
+
return lines.length ? lines[lines.length - 1].slice(0, cap) : "";
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ── MCP self-test ───────────────────────────────────────────────────────
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* How long to give the self-test, and whether to run it at all.
|
|
645
|
+
*
|
|
646
|
+
* `PREMAN_SELFTEST_MS=0` turns it off for a whole process, which is what a test
|
|
647
|
+
* run wants: the launcher is `npm exec premanmcp@latest`, so every case that
|
|
648
|
+
* reaches this would otherwise go to the registry.
|
|
649
|
+
*/
|
|
650
|
+
export function selfTestBudgetMs() {
|
|
651
|
+
const raw = process.env.PREMAN_SELFTEST_MS;
|
|
652
|
+
if (raw === undefined || raw === "") return 60000;
|
|
653
|
+
const parsed = Number(raw);
|
|
654
|
+
return Number.isFinite(parsed) ? parsed : 60000;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Run the config we just wrote, exactly as the agent will, and call one tool.
|
|
659
|
+
*
|
|
660
|
+
* This is both the fastest way to finish the link and the only check that proves
|
|
661
|
+
* the whole chain — launcher, package, key, backend — rather than proving the
|
|
662
|
+
* file is on disk. It matters because the failure it catches is invisible
|
|
663
|
+
* otherwise: a repo-local `preman-mcp.config.json` (or a stale global env) can
|
|
664
|
+
* redirect the server to a backend the key was never issued for, and every
|
|
665
|
+
* symptom of that points at the key.
|
|
666
|
+
*
|
|
667
|
+
* Speaks the two JSON-RPC calls an MCP client makes on startup. Never throws.
|
|
668
|
+
*/
|
|
669
|
+
export async function mcpSelfTest(
|
|
670
|
+
serverConfig,
|
|
671
|
+
{ timeoutMs = selfTestBudgetMs(), cwd = process.cwd() } = {}
|
|
672
|
+
) {
|
|
673
|
+
if (timeoutMs <= 0) return { ok: false, reason: "self-test disabled" };
|
|
674
|
+
let child;
|
|
675
|
+
try {
|
|
676
|
+
child = spawn(serverConfig.command, serverConfig.args, {
|
|
677
|
+
cwd,
|
|
678
|
+
env: { ...process.env, ...serverConfig.env },
|
|
679
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
680
|
+
});
|
|
681
|
+
} catch (error) {
|
|
682
|
+
return { ok: false, reason: error.message };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
return new Promise((resolve) => {
|
|
686
|
+
let stdout = "";
|
|
687
|
+
let stderr = "";
|
|
688
|
+
let settled = false;
|
|
689
|
+
|
|
690
|
+
const finish = (result) => {
|
|
691
|
+
if (settled) return;
|
|
692
|
+
settled = true;
|
|
693
|
+
clearTimeout(timer);
|
|
694
|
+
if (child.exitCode === null && child.signalCode === null) child.kill();
|
|
695
|
+
resolve(result);
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
const timer = setTimeout(
|
|
699
|
+
() => finish({ ok: false, reason: `the MCP server did not answer within ${Math.round(timeoutMs / 1000)}s`, stderr: lastLine(stderr) }),
|
|
700
|
+
timeoutMs
|
|
701
|
+
);
|
|
702
|
+
|
|
703
|
+
const send = (message) => {
|
|
704
|
+
try {
|
|
705
|
+
child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
706
|
+
} catch (error) {
|
|
707
|
+
finish({ ok: false, reason: error.message });
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
child.on("error", (error) => finish({ ok: false, reason: error.message }));
|
|
712
|
+
child.on("exit", (code) =>
|
|
713
|
+
finish({
|
|
714
|
+
ok: false,
|
|
715
|
+
reason: `the MCP server exited with code ${code}`,
|
|
716
|
+
stderr: lastLine(stderr),
|
|
717
|
+
})
|
|
718
|
+
);
|
|
719
|
+
|
|
720
|
+
child.stdout.setEncoding("utf8");
|
|
721
|
+
child.stderr.setEncoding("utf8");
|
|
722
|
+
child.stderr.on("data", (chunk) => {
|
|
723
|
+
stderr = `${stderr}${chunk}`.slice(-4000);
|
|
724
|
+
});
|
|
725
|
+
child.stdout.on("data", (chunk) => {
|
|
726
|
+
stdout += chunk;
|
|
727
|
+
let newline = stdout.indexOf("\n");
|
|
728
|
+
while (newline !== -1) {
|
|
729
|
+
const line = stdout.slice(0, newline).trim();
|
|
730
|
+
stdout = stdout.slice(newline + 1);
|
|
731
|
+
newline = stdout.indexOf("\n");
|
|
732
|
+
if (!line.startsWith("{")) continue;
|
|
733
|
+
let message;
|
|
734
|
+
try {
|
|
735
|
+
message = JSON.parse(line);
|
|
736
|
+
} catch {
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
if (message.id === 1) {
|
|
740
|
+
send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
741
|
+
send({
|
|
742
|
+
jsonrpc: "2.0",
|
|
743
|
+
id: 2,
|
|
744
|
+
method: "tools/call",
|
|
745
|
+
params: { name: "preman_status", arguments: {} },
|
|
746
|
+
});
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
if (message.id !== 2) continue;
|
|
750
|
+
const text = message.result?.content?.[0]?.text;
|
|
751
|
+
let status = {};
|
|
752
|
+
try {
|
|
753
|
+
status = text ? JSON.parse(text) : {};
|
|
754
|
+
} catch {
|
|
755
|
+
status = { raw: text };
|
|
756
|
+
}
|
|
757
|
+
finish({ ok: !message.error, status, reason: message.error?.message || "" });
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
send({
|
|
762
|
+
jsonrpc: "2.0",
|
|
763
|
+
id: 1,
|
|
764
|
+
method: "initialize",
|
|
765
|
+
params: {
|
|
766
|
+
protocolVersion: "2024-11-05",
|
|
767
|
+
capabilities: {},
|
|
768
|
+
clientInfo: { name: "preman-connect-selftest", version: "1" },
|
|
769
|
+
},
|
|
770
|
+
});
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
|
|
523
774
|
// ── Dispatch credential (SCRUM-124) ─────────────────────────────────────
|
|
524
775
|
|
|
525
776
|
async function captureDispatchCredential(args, agent, apiKey, { prompt = true } = {}) {
|
|
@@ -651,79 +902,418 @@ function nextStepsBlock(agent) {
|
|
|
651
902
|
return (
|
|
652
903
|
"\nNext steps:\n" +
|
|
653
904
|
` 1. Restart ${agent.label}, then ask it: "run preman_status" to finish linking.\n` +
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
905
|
+
` 2. ${cliInvocation()} runner start --background # let PreMan run work here\n` +
|
|
906
|
+
` 3. ${cliInvocation()} endpoints discover # brief for your agent → endpoints.json\n` +
|
|
907
|
+
` 4. ${cliInvocation()} hook install # test affected endpoints on git push\n`
|
|
657
908
|
);
|
|
658
909
|
}
|
|
659
910
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
911
|
+
/**
|
|
912
|
+
* Ask, or take the step's own default when nobody can answer.
|
|
913
|
+
*
|
|
914
|
+
* `--yes` returns the default rather than a blanket yes, which matters for
|
|
915
|
+
* exactly one step: the desktop app defaults to no because it downloads a
|
|
916
|
+
* hundred-odd megabytes and writes to /Applications. A blanket yes made
|
|
917
|
+
* `preman connect --yes` do that unattended, which is not what anyone means by
|
|
918
|
+
* "do not ask me questions" -- they get `preman install-desktop` for that.
|
|
919
|
+
*/
|
|
920
|
+
async function confirm(question, { assumeYes = false, defaultYes = true } = {}) {
|
|
921
|
+
if (assumeYes) return defaultYes;
|
|
922
|
+
if (!process.stdin.isTTY) return false;
|
|
923
|
+
const answer = (await promptText(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"}: `)).toLowerCase();
|
|
924
|
+
if (answer === "") return defaultYes;
|
|
925
|
+
return answer.startsWith("y");
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function step(title) {
|
|
929
|
+
process.stdout.write(`\n${title}\n`);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/** What the account can see right now: registry rows, and the runnable subset. */
|
|
933
|
+
async function endpointCounts(args) {
|
|
934
|
+
const inventory = await callPremanTool(args, "get_endpoints", {
|
|
935
|
+
include_workbench: true,
|
|
936
|
+
limit: 200,
|
|
937
|
+
});
|
|
938
|
+
const runnable = inventory.workbench_requests || [];
|
|
939
|
+
return {
|
|
940
|
+
registered: (inventory.endpoints || []).length,
|
|
941
|
+
runnable: runnable.length,
|
|
942
|
+
first: runnable[0] || null,
|
|
943
|
+
};
|
|
663
944
|
}
|
|
664
945
|
|
|
665
946
|
/**
|
|
666
|
-
*
|
|
947
|
+
* The one-shot command that has an agent execute the discovery brief.
|
|
667
948
|
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
949
|
+
* Same shape as `headlessCheckIn`, with the read tools the walk needs added to
|
|
950
|
+
* Claude Code's allow-list: print mode refuses anything un-allowed instead of
|
|
951
|
+
* asking, so an agent given only the MCP server can call PreMan and read nothing.
|
|
952
|
+
*/
|
|
953
|
+
export function headlessDiscovery(agent, serverName, instructions = []) {
|
|
954
|
+
const prompt = [
|
|
955
|
+
`Register this project's HTTP endpoints in PreMan using the ${serverName} MCP tools.`,
|
|
956
|
+
"Work autonomously and do not ask for confirmation.",
|
|
957
|
+
"",
|
|
958
|
+
...instructions,
|
|
959
|
+
].join("\n");
|
|
960
|
+
if (agent.id === "cursor") return { bin: "cursor-agent", args: ["-p", prompt] };
|
|
961
|
+
if (agent.id === "claude_code") {
|
|
962
|
+
return {
|
|
963
|
+
bin: "claude",
|
|
964
|
+
args: ["-p", prompt, "--allowedTools", `mcp__${serverName},Read,Grep,Glob`],
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
if (agent.id === "codex") return { bin: "codex", args: ["exec", prompt] };
|
|
968
|
+
return null;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Get this project's endpoints into PreMan without handing anyone homework.
|
|
973
|
+
*
|
|
974
|
+
* Discovery is the agent's job — only it can read the repo — but "here is a brief,
|
|
975
|
+
* go paste it somewhere" is the step everybody dropped out on, and the commands
|
|
976
|
+
* printed after it referenced a file the user never had. So run the agent on the
|
|
977
|
+
* brief and report the two numbers that matter.
|
|
671
978
|
*
|
|
672
|
-
* Never throws:
|
|
979
|
+
* Never throws: a failed discovery must not fail the connect.
|
|
673
980
|
*/
|
|
674
|
-
async function
|
|
981
|
+
async function discoverEndpoints(args, agent, serverName) {
|
|
675
982
|
try {
|
|
676
|
-
const
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
if (runnable) {
|
|
684
|
-
const label = `${runnable.method || "GET"} ${runnable.url || ""}`.trim();
|
|
685
|
-
if (!(await confirm(`\nRun a first test against ${label}?`))) {
|
|
686
|
-
process.stdout.write(`Whenever you are ready: preman test ${runnable.id}\n`);
|
|
687
|
-
return;
|
|
688
|
-
}
|
|
689
|
-
process.stdout.write("Generating scenarios…\n");
|
|
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;
|
|
983
|
+
const before = await endpointCounts(args);
|
|
984
|
+
if (before.registered) {
|
|
985
|
+
process.stdout.write(
|
|
986
|
+
`${MARK.ok()} ${before.registered} endpoint(s) · ${before.runnable} runnable\n`
|
|
987
|
+
);
|
|
988
|
+
return before;
|
|
699
989
|
}
|
|
700
990
|
|
|
701
|
-
|
|
991
|
+
const brief = await callPremanTool(args, "discover_endpoints_from_codebase", { base_path: "." });
|
|
992
|
+
const spec = headlessDiscovery(agent, serverName, brief.instructions || []);
|
|
993
|
+
const blocker = spec && onPath(spec.bin) ? agentBlocker(agent.id) : "";
|
|
994
|
+
if (!spec || !onPath(spec.bin) || blocker) {
|
|
995
|
+
// Say why before printing homework: "here is a brief" reads as PreMan not
|
|
996
|
+
// working, when the actual answer is one login away.
|
|
997
|
+
if (blocker) process.stdout.write(`${MARK.skip()} ${blocker}\n`);
|
|
998
|
+
for (const line of brief.instructions || []) process.stdout.write(`${line}\n`);
|
|
999
|
+
process.stdout.write(`\nHand this brief to ${agent.label}, then run:\n${MANUAL_STEPS}`);
|
|
1000
|
+
return before;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
process.stdout.write(`Asking ${agent.label} to map this codebase…\n`);
|
|
1004
|
+
const timeoutMs = Number(process.env.PREMAN_DISCOVER_MS) || 600000;
|
|
1005
|
+
const outcome = await runAgentOnce(spec, timeoutMs);
|
|
1006
|
+
const after = await endpointCounts(args);
|
|
1007
|
+
|
|
1008
|
+
if (!after.registered) {
|
|
1009
|
+
// Both halves: the exit code says the run failed, the agent's own last line
|
|
1010
|
+
// says why, and either one alone has sent someone down the wrong path.
|
|
1011
|
+
const why =
|
|
1012
|
+
[outcome.reason, lastLine(outcome.output)].filter(Boolean).join(" · ") ||
|
|
1013
|
+
"it registered nothing";
|
|
702
1014
|
process.stdout.write(
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
" preman test <request-id> # generate + run your first scenarios\n"
|
|
1015
|
+
`${MARK.fail()} ${agent.label} did not register any endpoints (${why}).\n` +
|
|
1016
|
+
` Run it yourself: ${cliInvocation()} endpoints discover\n`
|
|
706
1017
|
);
|
|
707
|
-
return;
|
|
1018
|
+
return after;
|
|
708
1019
|
}
|
|
1020
|
+
process.stdout.write(
|
|
1021
|
+
`${MARK.ok()} ${after.registered} endpoint(s) · ${after.runnable} runnable\n`
|
|
1022
|
+
);
|
|
1023
|
+
return after;
|
|
1024
|
+
} catch (error) {
|
|
1025
|
+
process.stdout.write(`${MARK.fail()} Could not read your endpoints: ${error.message}\n`);
|
|
1026
|
+
return { registered: 0, runnable: 0, first: null };
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
709
1029
|
|
|
710
|
-
|
|
711
|
-
|
|
1030
|
+
/** Run one bounded, non-interactive agent invocation and keep its tail. */
|
|
1031
|
+
async function runAgentOnce(spec, timeoutMs) {
|
|
1032
|
+
return new Promise((resolve) => {
|
|
1033
|
+
let child;
|
|
1034
|
+
try {
|
|
1035
|
+
child = spawn(spec.bin, spec.args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
resolve({ ok: false, reason: error.message, output: "" });
|
|
712
1038
|
return;
|
|
713
1039
|
}
|
|
1040
|
+
let output = "";
|
|
1041
|
+
const absorb = (chunk) => {
|
|
1042
|
+
output = `${output}${chunk}`.slice(-8000);
|
|
1043
|
+
};
|
|
1044
|
+
child.stdout?.setEncoding("utf8");
|
|
1045
|
+
child.stderr?.setEncoding("utf8");
|
|
1046
|
+
child.stdout?.on("data", absorb);
|
|
1047
|
+
child.stderr?.on("data", absorb);
|
|
1048
|
+
|
|
1049
|
+
const timer = setTimeout(() => {
|
|
1050
|
+
child.kill("SIGTERM");
|
|
1051
|
+
resolve({ ok: false, reason: `timed out after ${Math.round(timeoutMs / 1000)}s`, output });
|
|
1052
|
+
}, timeoutMs);
|
|
1053
|
+
child.on("error", (error) => {
|
|
1054
|
+
clearTimeout(timer);
|
|
1055
|
+
resolve({ ok: false, reason: error.message, output });
|
|
1056
|
+
});
|
|
1057
|
+
child.on("close", (code) => {
|
|
1058
|
+
clearTimeout(timer);
|
|
1059
|
+
resolve({
|
|
1060
|
+
ok: code === 0,
|
|
1061
|
+
reason: code === 0 ? "" : `${spec.bin} exited with code ${code}`,
|
|
1062
|
+
output,
|
|
1063
|
+
});
|
|
1064
|
+
});
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/** Generate and run scenarios against the first runnable request, if they want it. */
|
|
1069
|
+
async function runFirstTest(args, runnable, { assumeYes }) {
|
|
1070
|
+
const label = `${runnable.method || "GET"} ${runnable.url || ""}`.trim();
|
|
1071
|
+
if (!(await confirm(`Run a first test against ${label}?`, { assumeYes }))) {
|
|
1072
|
+
process.stdout.write(`${MARK.skip()} Whenever you are ready: ${cliInvocation()} test ${runnable.id}\n`);
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
try {
|
|
1076
|
+
process.stdout.write("Generating scenarios…\n");
|
|
1077
|
+
const result = await callPremanTool(args, "generate_endpoint_tests", {
|
|
1078
|
+
target: runnable.id,
|
|
1079
|
+
run: true,
|
|
1080
|
+
allow_writes: false,
|
|
1081
|
+
max_cases: 10,
|
|
1082
|
+
});
|
|
1083
|
+
printTestSummary(result);
|
|
1084
|
+
} catch (error) {
|
|
1085
|
+
process.stdout.write(`${MARK.fail()} Could not run the first test: ${error.message}\n`);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Pair this machine as a runner and leave it running.
|
|
1091
|
+
*
|
|
1092
|
+
* Without this, everything PreMan finds later is advice: it can describe a fix
|
|
1093
|
+
* and cannot apply one. With it, the queue has somewhere to land.
|
|
1094
|
+
*/
|
|
1095
|
+
async function setUpRunner(args, agent, { assumeYes }) {
|
|
1096
|
+
if (args.has("--no-runner")) return { state: "skipped" };
|
|
1097
|
+
const existing = readRunnerState();
|
|
1098
|
+
if (existing && runnerIsAlive()) {
|
|
1099
|
+
process.stdout.write(`${MARK.ok()} Runner already running for ${existing.project_path}\n`);
|
|
1100
|
+
return { state: "running" };
|
|
1101
|
+
}
|
|
1102
|
+
if (
|
|
1103
|
+
!(await confirm(
|
|
1104
|
+
`Let PreMan run ${agent.label} here when it finds something to fix?`,
|
|
1105
|
+
{ assumeYes }
|
|
1106
|
+
))
|
|
1107
|
+
) {
|
|
1108
|
+
process.stdout.write(
|
|
1109
|
+
`${MARK.skip()} Skipped. Start it later: ${cliInvocation()} runner start --background\n`
|
|
1110
|
+
);
|
|
1111
|
+
return { state: "skipped" };
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
try {
|
|
1115
|
+
if (!existing || existing.agent !== agent.id || existing.project_path !== path.resolve(process.cwd())) {
|
|
1116
|
+
await registerRunner(args, { agent: agent.id, projectPath: process.cwd() });
|
|
1117
|
+
}
|
|
1118
|
+
const started = startBackground([]);
|
|
1119
|
+
// Claiming a runner that died two seconds later is worse than saying nothing:
|
|
1120
|
+
// the whole point of this step is that PreMan can act, and someone told it
|
|
1121
|
+
// can will wait for fixes that no device is listening for.
|
|
1122
|
+
const up = await confirmRunnerOnline(started);
|
|
1123
|
+
if (up.state === "exited") {
|
|
1124
|
+
process.stdout.write(
|
|
1125
|
+
`${MARK.fail()} The runner exited right after starting.\n` +
|
|
1126
|
+
(up.detail ? ` ${up.detail}\n` : "") +
|
|
1127
|
+
` Log: ${started.log} Retry: ${cliInvocation()} runner start --background\n`
|
|
1128
|
+
);
|
|
1129
|
+
return { state: "failed", detail: up.detail };
|
|
1130
|
+
}
|
|
1131
|
+
process.stdout.write(
|
|
1132
|
+
`${MARK.ok()} Runner ${up.state === "online" ? "online" : "starting"} (pid ${started.pid}) — PreMan can apply fixes on this machine.\n` +
|
|
1133
|
+
` Log: ${started.log} Stop: ${cliInvocation()} runner stop\n`
|
|
1134
|
+
);
|
|
1135
|
+
// Paired and online still cannot run anything if the agent it dispatches to
|
|
1136
|
+
// will not start, and every job would fail with the same opaque exit code.
|
|
1137
|
+
const blocker = agentBlocker(agent.id);
|
|
1138
|
+
if (blocker) {
|
|
1139
|
+
process.stdout.write(` ${MARK.skip()} Jobs will fail until you fix this: ${blocker}\n`);
|
|
1140
|
+
}
|
|
1141
|
+
return { state: "running", pid: started.pid };
|
|
1142
|
+
} catch (error) {
|
|
1143
|
+
process.stdout.write(
|
|
1144
|
+
`${MARK.fail()} Could not start the runner: ${error.message}\n` +
|
|
1145
|
+
` Retry later: ${cliInvocation()} runner start --background\n`
|
|
1146
|
+
);
|
|
1147
|
+
return { state: "failed", detail: error.message };
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
714
1150
|
|
|
715
|
-
|
|
716
|
-
|
|
1151
|
+
/** Offer the desktop app. Optional by design — the terminal flow is complete without it. */
|
|
1152
|
+
async function offerDesktop(args, { assumeYes }) {
|
|
1153
|
+
if (args.has("--no-desktop")) return { state: "skipped" };
|
|
1154
|
+
if (process.platform !== "darwin") return { state: "unsupported" };
|
|
1155
|
+
if (existsSync("/Applications/PreMan.app")) {
|
|
1156
|
+
process.stdout.write(`${MARK.ok()} PreMan desktop app already installed.\n`);
|
|
1157
|
+
return { state: "installed" };
|
|
1158
|
+
}
|
|
1159
|
+
// Default no, unlike every other step here: this one downloads a hundred-odd
|
|
1160
|
+
// megabytes and writes to /Applications, which nobody should get by pressing
|
|
1161
|
+
// Enter to move past a prompt.
|
|
1162
|
+
if (
|
|
1163
|
+
!(await confirm("Install the PreMan desktop app to watch runs and endpoints?", {
|
|
1164
|
+
assumeYes,
|
|
1165
|
+
defaultYes: false,
|
|
1166
|
+
}))
|
|
1167
|
+
) {
|
|
717
1168
|
process.stdout.write(
|
|
718
|
-
|
|
719
|
-
" preman endpoints setup --file endpoints.json\n" +
|
|
720
|
-
" preman test <request-id>\n"
|
|
1169
|
+
`${MARK.skip()} Skipped. Install later: ${cliInvocation()} install-desktop\n`
|
|
721
1170
|
);
|
|
1171
|
+
return { state: "skipped" };
|
|
1172
|
+
}
|
|
1173
|
+
try {
|
|
1174
|
+
return await installDesktopCommand([]);
|
|
722
1175
|
} catch (error) {
|
|
1176
|
+
process.stdout.write(`${MARK.fail()} Desktop install failed: ${error.message}\n`);
|
|
1177
|
+
return { state: "failed", detail: error.message };
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* Which of GitHub, AWS and Slack this account already has.
|
|
1183
|
+
*
|
|
1184
|
+
* One probe per provider because each owns its own list route; a provider whose
|
|
1185
|
+
* route errors is reported as unknown rather than missing, so a backend hiccup
|
|
1186
|
+
* does not send someone through an install they already did.
|
|
1187
|
+
*/
|
|
1188
|
+
export async function integrationStatus(args, apiKey) {
|
|
1189
|
+
const probe = async (method, route, pick) => {
|
|
1190
|
+
try {
|
|
1191
|
+
const result = await callBackendJson(args, method, route, { token: apiKey });
|
|
1192
|
+
if (!result.ok) return null;
|
|
1193
|
+
return (pick(result) || []).length;
|
|
1194
|
+
} catch {
|
|
1195
|
+
return null;
|
|
1196
|
+
}
|
|
1197
|
+
};
|
|
1198
|
+
const [github, aws, slack] = await Promise.all([
|
|
1199
|
+
probe("GET", "/integrations/github", (r) => r.list || r.integrations || r.repos),
|
|
1200
|
+
probe("GET", "/aws-links", (r) => r.links || r.list),
|
|
1201
|
+
probe("GET", "/slack/connections", (r) => r.connections || r.list),
|
|
1202
|
+
]);
|
|
1203
|
+
return { github, aws, slack };
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/**
|
|
1207
|
+
* Show what is connected, and offer to finish what is not.
|
|
1208
|
+
*
|
|
1209
|
+
* Each install lives in the customer's browser session, so every one of these
|
|
1210
|
+
* opens a URL and polls our own API until the other side reports the connection
|
|
1211
|
+
* exists — the terminal picks the result up on its own, whether they finished in
|
|
1212
|
+
* the browser or in the desktop app.
|
|
1213
|
+
*/
|
|
1214
|
+
async function connectIntegrations(args, apiKey, { assumeYes }) {
|
|
1215
|
+
if (args.has("--no-integrations")) return;
|
|
1216
|
+
const status = await integrationStatus(args, apiKey);
|
|
1217
|
+
const providers = [
|
|
1218
|
+
{ key: "github", label: "GitHub", question: "Connect GitHub?", run: () => githubCommand(args) },
|
|
1219
|
+
{ key: "aws", label: "AWS", question: "Connect AWS?", run: () => awsCommand(args) },
|
|
1220
|
+
{ key: "slack", label: "Slack", question: "Connect Slack?", run: () => slackCommand(args) },
|
|
1221
|
+
];
|
|
1222
|
+
|
|
1223
|
+
for (const provider of providers) {
|
|
1224
|
+
const count = status[provider.key];
|
|
1225
|
+
if (count === null) {
|
|
1226
|
+
process.stdout.write(`${MARK.skip()} ${provider.label} — could not check\n`);
|
|
1227
|
+
} else if (count > 0) {
|
|
1228
|
+
process.stdout.write(`${MARK.ok()} ${provider.label} connected\n`);
|
|
1229
|
+
} else {
|
|
1230
|
+
process.stdout.write(`${MARK.skip()} ${provider.label} not connected\n`);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
for (const provider of providers) {
|
|
1235
|
+
if (status[provider.key] !== 0) continue;
|
|
1236
|
+
if (!(await confirm(provider.question, { assumeYes, defaultYes: false }))) continue;
|
|
1237
|
+
try {
|
|
1238
|
+
await provider.run();
|
|
1239
|
+
} catch (error) {
|
|
1240
|
+
// One provider's failure must not cost the customer the ones that worked.
|
|
1241
|
+
process.stdout.write(`${MARK.fail()} Could not finish ${provider.label}: ${error.message}\n`);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/**
|
|
1247
|
+
* Install the pre-push hook, so a push is what triggers the tests.
|
|
1248
|
+
*
|
|
1249
|
+
* Not a question: testing what you just changed before it ships is the product,
|
|
1250
|
+
* the hook cannot block a push, and `--no-hook` / `PREMAN_SKIP_HOOK=1` are both
|
|
1251
|
+
* still there. Asking only produced accounts that never tested anything.
|
|
1252
|
+
*/
|
|
1253
|
+
async function setUpPushTesting(args) {
|
|
1254
|
+
if (args.has("--no-hook")) return { state: "skipped" };
|
|
1255
|
+
const current = (() => {
|
|
1256
|
+
try {
|
|
1257
|
+
return hookStatus();
|
|
1258
|
+
} catch {
|
|
1259
|
+
return null; // not a git repository
|
|
1260
|
+
}
|
|
1261
|
+
})();
|
|
1262
|
+
if (!current) {
|
|
1263
|
+
process.stdout.write(`${MARK.skip()} Not a git repository — no push testing here.\n`);
|
|
1264
|
+
return { state: "unavailable" };
|
|
1265
|
+
}
|
|
1266
|
+
// A hook whose invocation went stale is reinstalled rather than reported as on:
|
|
1267
|
+
// it is the case where PreMan looks connected and silently checks nothing.
|
|
1268
|
+
if (current.state === "installed" && current.current) {
|
|
1269
|
+
process.stdout.write(`${MARK.ok()} Push testing already on.\n`);
|
|
1270
|
+
return { state: "installed" };
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
const result = installHook(args);
|
|
1274
|
+
if (result.action === "conflict") {
|
|
723
1275
|
process.stdout.write(
|
|
724
|
-
|
|
1276
|
+
`${MARK.skip()} You already have a pre-push hook. Replace it: ${cliInvocation()} hook install --force\n`
|
|
725
1277
|
);
|
|
1278
|
+
return result;
|
|
726
1279
|
}
|
|
1280
|
+
process.stdout.write(
|
|
1281
|
+
`${MARK.ok()} Push testing on — \`git push\` now checks the endpoints you touched.\n` +
|
|
1282
|
+
" It never blocks a push; PREMAN_SKIP_HOOK=1 silences it.\n"
|
|
1283
|
+
);
|
|
1284
|
+
return result;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/**
|
|
1288
|
+
* Everything after the link, in one pass, with nothing left for the user to run.
|
|
1289
|
+
*
|
|
1290
|
+
* Order follows what a new account needs to see: what PreMan found, then where to
|
|
1291
|
+
* watch it, then who to tell, then when to run it.
|
|
1292
|
+
*/
|
|
1293
|
+
async function guidedFirstRun(args, agent, apiKey, serverName) {
|
|
1294
|
+
const assumeYes = args.has("--yes");
|
|
1295
|
+
|
|
1296
|
+
step("Endpoints");
|
|
1297
|
+
const counts = await discoverEndpoints(args, agent, serverName);
|
|
1298
|
+
|
|
1299
|
+
if (counts.first) {
|
|
1300
|
+
step("First test");
|
|
1301
|
+
await runFirstTest(args, counts.first, { assumeYes });
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
step("Runner");
|
|
1305
|
+
await setUpRunner(args, agent, { assumeYes });
|
|
1306
|
+
|
|
1307
|
+
step("Desktop app");
|
|
1308
|
+
await offerDesktop(args, { assumeYes });
|
|
1309
|
+
|
|
1310
|
+
step("Integrations");
|
|
1311
|
+
await connectIntegrations(args, apiKey, { assumeYes });
|
|
1312
|
+
|
|
1313
|
+
step("Testing on push");
|
|
1314
|
+
await setUpPushTesting(args);
|
|
1315
|
+
|
|
1316
|
+
process.stdout.write(`\nDone. Watch it at ${frontendUrl(args)}\n`);
|
|
727
1317
|
}
|
|
728
1318
|
|
|
729
1319
|
// ── Preflight ───────────────────────────────────────────────────────────
|
|
@@ -752,6 +1342,16 @@ export async function preflight(args) {
|
|
|
752
1342
|
);
|
|
753
1343
|
}
|
|
754
1344
|
|
|
1345
|
+
// Explains why every command below is spelled the long way, before someone
|
|
1346
|
+
// types `preman …` and gets another package's CLI answering.
|
|
1347
|
+
const premanOwner = pathPremanOwner();
|
|
1348
|
+
if (premanOwner && premanOwner !== "premanmcp") {
|
|
1349
|
+
notes.push(
|
|
1350
|
+
`\`preman\` on your PATH belongs to ${premanOwner}, not this CLI, so PreMan's own ` +
|
|
1351
|
+
"commands are written out as `npm exec -y premanmcp@latest -- …`."
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
755
1355
|
try {
|
|
756
1356
|
const resp = await fetch(new URL("health", `${backendUrl(args)}/`), {
|
|
757
1357
|
signal: AbortSignal.timeout(4000),
|
|
@@ -786,9 +1386,16 @@ Connect options:
|
|
|
786
1386
|
--skip-dispatch-credential Do not ask for a cloud-dispatch credential
|
|
787
1387
|
--skip-login Write config without interactive terminal auth
|
|
788
1388
|
--no-pair Do not mint a pair code
|
|
1389
|
+
--no-self-test Do not start the MCP server to finish the link
|
|
789
1390
|
--no-auto-checkin Do not run the agent to finish the link
|
|
790
1391
|
--no-wait Do not wait for the agent to check in
|
|
791
1392
|
--no-guide Skip the guided first run after connecting
|
|
1393
|
+
--no-runner Do not pair this machine as a job runner
|
|
1394
|
+
--no-desktop Do not offer the desktop app
|
|
1395
|
+
--no-integrations Do not check or offer GitHub / AWS / Slack
|
|
1396
|
+
--no-hook Do not install the git pre-push hook
|
|
1397
|
+
--yes Take every step's default without prompting
|
|
1398
|
+
(the desktop app defaults to no; install-desktop)
|
|
792
1399
|
--print Print the config instead of writing it
|
|
793
1400
|
`;
|
|
794
1401
|
|
|
@@ -853,11 +1460,19 @@ export async function connectCommand(commandArgs) {
|
|
|
853
1460
|
|
|
854
1461
|
if (!agent) agent = await promptAgentChoice(detectAgents());
|
|
855
1462
|
|
|
856
|
-
|
|
1463
|
+
let apiKey = resolveApiKey(args);
|
|
857
1464
|
|
|
858
1465
|
let pairCode = "";
|
|
859
|
-
if (apiKey
|
|
860
|
-
|
|
1466
|
+
if (apiKey) {
|
|
1467
|
+
let pairing = await checkKeyAndPair(args, agent, apiKey);
|
|
1468
|
+
if (pairing.stale) {
|
|
1469
|
+
apiKey = await refreshStaleCredentials(args);
|
|
1470
|
+
pairing = await checkKeyAndPair(args, agent, apiKey);
|
|
1471
|
+
if (pairing.stale) {
|
|
1472
|
+
throw new ConnectError("PreMan rejected a key it just issued. Try again shortly.");
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
pairCode = pairing.pairCode;
|
|
861
1476
|
}
|
|
862
1477
|
|
|
863
1478
|
const serverConfig = buildServerConfig(args, { pairCode });
|
|
@@ -894,38 +1509,83 @@ export async function connectCommand(commandArgs) {
|
|
|
894
1509
|
return;
|
|
895
1510
|
}
|
|
896
1511
|
|
|
897
|
-
if (!(await establishCheckIn(args, agent, apiKey, { serverName, written }))) {
|
|
1512
|
+
if (!(await establishCheckIn(args, agent, apiKey, { serverName, written, serverConfig }))) {
|
|
898
1513
|
// Still honour an explicitly-passed credential, but do not open a new prompt
|
|
899
1514
|
// on top of a connect that just told the user something went wrong.
|
|
900
1515
|
await captureDispatchCredential(args, agent, apiKey, { prompt: false });
|
|
901
1516
|
return;
|
|
902
1517
|
}
|
|
903
1518
|
|
|
904
|
-
process.stdout.write(
|
|
1519
|
+
process.stdout.write(`${MARK.ok()} Connected as ${agent.label}.\n`);
|
|
905
1520
|
if (!args.has("--no-guide")) {
|
|
906
|
-
await guidedFirstRun(args, agent);
|
|
1521
|
+
await guidedFirstRun(args, agent, apiKey, serverName);
|
|
907
1522
|
}
|
|
908
1523
|
await captureDispatchCredential(args, agent, apiKey);
|
|
909
1524
|
}
|
|
910
1525
|
|
|
911
1526
|
/**
|
|
912
|
-
*
|
|
1527
|
+
* Finish the link here, by whatever means work, in cheapest-first order.
|
|
1528
|
+
*
|
|
1529
|
+
* 1. Run the MCP server ourselves and call one tool. No agent, no tokens, a few
|
|
1530
|
+
* seconds, and it proves the launcher/key/backend chain the agent will use.
|
|
1531
|
+
* 2. Failing that, run the agent headlessly — which also proves the agent can
|
|
1532
|
+
* load the config we wrote.
|
|
1533
|
+
* 3. Failing that, ask them to restart it and wait, which is all this ever did.
|
|
913
1534
|
*
|
|
914
|
-
*
|
|
915
|
-
*
|
|
916
|
-
* prints the troubleshooting block itself when it did not.
|
|
1535
|
+
* Returns whether the check-in landed, and prints the troubleshooting block
|
|
1536
|
+
* itself when it did not.
|
|
917
1537
|
*/
|
|
918
|
-
async function establishCheckIn(args, agent, apiKey, { serverName, written }) {
|
|
919
|
-
|
|
1538
|
+
async function establishCheckIn(args, agent, apiKey, { serverName, written, serverConfig }) {
|
|
1539
|
+
const notes = [];
|
|
1540
|
+
|
|
1541
|
+
if (!args.has("--no-self-test") && serverConfig && selfTestBudgetMs() > 0) {
|
|
1542
|
+
process.stdout.write("\nChecking the connection…\n");
|
|
1543
|
+
const test = await mcpSelfTest(serverConfig);
|
|
1544
|
+
const status = test.status || {};
|
|
1545
|
+
const repo = status.config?.repo_config;
|
|
1546
|
+
if (repo?.override && repo.applied?.length) {
|
|
1547
|
+
// The one failure that reads as a bad key: a working server talking to a
|
|
1548
|
+
// backend nobody chose. Name the file before it costs anyone an hour.
|
|
1549
|
+
notes.push(
|
|
1550
|
+
`${repo.path} overrides ${repo.applied.join(", ")} for anything started in this directory, ` +
|
|
1551
|
+
`so your agent will use ${status.backend_url}.`
|
|
1552
|
+
);
|
|
1553
|
+
}
|
|
1554
|
+
if (test.ok && status.authenticated && (await waitForConnection(args, apiKey, { timeoutMs: 15000 }))) {
|
|
1555
|
+
for (const note of notes) process.stdout.write(`Note: ${note}\n`);
|
|
1556
|
+
return true;
|
|
1557
|
+
}
|
|
1558
|
+
notes.push(
|
|
1559
|
+
test.ok
|
|
1560
|
+
? `the MCP server answered from ${status.backend_url || "an unknown backend"} but was not authenticated`
|
|
1561
|
+
: `the MCP server could not be started (${test.reason || "unknown"}${test.stderr ? `: ${test.stderr}` : ""})`
|
|
1562
|
+
);
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
const blocker = agentBlocker(agent.id);
|
|
1566
|
+
if (!args.has("--no-auto-checkin") && !blocker) {
|
|
920
1567
|
const spec = headlessCheckIn(agent, serverName);
|
|
921
1568
|
if (spec && onPath(spec.bin)) {
|
|
922
1569
|
process.stdout.write(`\nStarting ${agent.label} to finish the link…\n`);
|
|
923
1570
|
}
|
|
924
1571
|
const auto = await autoCheckIn(args, agent, apiKey, { serverName });
|
|
925
|
-
if (auto.connected)
|
|
1572
|
+
if (auto.connected) {
|
|
1573
|
+
for (const note of notes) process.stdout.write(`Note: ${note}\n`);
|
|
1574
|
+
return true;
|
|
1575
|
+
}
|
|
926
1576
|
if (auto.ran) {
|
|
927
|
-
|
|
1577
|
+
const why = lastLine(auto.output);
|
|
1578
|
+
process.stdout.write(
|
|
1579
|
+
`${agent.label} ran but did not check in${why ? `: ${why}` : "."}\n`
|
|
1580
|
+
);
|
|
1581
|
+
} else if (auto.reason) {
|
|
1582
|
+
process.stdout.write(`Could not run ${agent.label}: ${auto.reason}\n`);
|
|
928
1583
|
}
|
|
1584
|
+
} else if (blocker) {
|
|
1585
|
+
// Starting an agent that cannot authenticate spends two minutes to learn
|
|
1586
|
+
// what one status probe already knows.
|
|
1587
|
+
process.stdout.write(`${MARK.skip()} ${blocker}\n`);
|
|
1588
|
+
notes.push(blocker);
|
|
929
1589
|
}
|
|
930
1590
|
|
|
931
1591
|
process.stdout.write(
|
|
@@ -933,10 +1593,14 @@ async function establishCheckIn(args, agent, apiKey, { serverName, written }) {
|
|
|
933
1593
|
"Waiting for your agent to check in… (Ctrl+C to stop waiting)\n"
|
|
934
1594
|
);
|
|
935
1595
|
|
|
936
|
-
if (await waitForConnection(args, apiKey))
|
|
1596
|
+
if (await waitForConnection(args, apiKey)) {
|
|
1597
|
+
for (const note of notes) process.stdout.write(`Note: ${note}\n`);
|
|
1598
|
+
return true;
|
|
1599
|
+
}
|
|
937
1600
|
|
|
938
1601
|
process.stdout.write(
|
|
939
1602
|
"No check-in yet. Troubleshooting:\n" +
|
|
1603
|
+
notes.map((note) => ` - ${note}\n`).join("") +
|
|
940
1604
|
` - ${agent.restartHint}\n` +
|
|
941
1605
|
` - Config written to: ${written.path}\n` +
|
|
942
1606
|
` - Then ask ${agent.label} to "run preman_status" — it links on its first PreMan call.\n` +
|