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/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
- child = spawn(spec.bin, spec.args, { stdio: "ignore" });
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
- " 2. preman endpoints discover # brief for your agent → endpoints.json\n" +
655
- " 3. preman endpoints setup --file endpoints.json # register runnable requests\n" +
656
- " 4. preman test <request-id> # generate + run your first scenarios\n"
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
- const answer = (await promptText(`${question} [Y/n]: `)).toLowerCase();
662
- return answer === "" || answer.startsWith("y");
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
- * Carry a freshly linked agent to its first passing test.
933
+ * Get this project's endpoints into PreMan without handing anyone homework.
667
934
  *
668
- * Discovery itself belongs to the coding agent — the backend hands back a brief
669
- * for it to execute — so this either runs a test against what the account
670
- * already has, or prints that brief and the two commands that follow it.
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: onboarding help must not turn a successful connect into a failure.
940
+ * Never throws: a failed discovery must not fail the connect.
673
941
  */
674
- async function guidedFirstRun(args, agent) {
942
+ async function discoverEndpoints(args, agent, serverName) {
675
943
  try {
676
- const inventory = await callPremanTool(args, "get_endpoints", {
677
- include_workbench: true,
678
- limit: 50,
679
- });
680
- const runnable = (inventory.workbench_requests || [])[0];
681
- const registered = (inventory.endpoints || []).length;
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;
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
- if (registered) {
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
- `\nYou have ${registered} registered endpoint(s), but none are runnable yet:\n` +
704
- " preman endpoints setup --ids <id1,id2> # make them runnable\n" +
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
- if (!(await confirm("\nNo endpoints in PreMan yet. Print the discovery brief for your agent?"))) {
711
- process.stdout.write(`\nWhen you are ready:\n${MANUAL_STEPS}`);
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
- const brief = await callPremanTool(args, "discover_endpoints_from_codebase", { base_path: "." });
716
- for (const line of brief.instructions || []) process.stdout.write(`${line}\n`);
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
- `\nHand this brief to ${agent.label}, then run:\n` +
719
- " preman endpoints setup --file endpoints.json\n" +
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
- `Note: ${error.message}. Run \`preman endpoints list\` when you are ready.\n`
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
- const apiKey = resolveApiKey(args);
1383
+ let apiKey = resolveApiKey(args);
857
1384
 
858
1385
  let pairCode = "";
859
- if (apiKey && !args.has("--no-pair")) {
860
- pairCode = await startPairing(args, agent, apiKey);
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(`Connected as ${agent.label}.\n`);
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
- * Get the agent to make its first PreMan call, by whatever means work here.
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
- * Prefers running it for the user; falls back to asking them to restart it and
915
- * waiting, which is all this ever did. Returns whether the check-in landed, and
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) return true;
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
- process.stdout.write(`${agent.label} ran but did not check in.\n`);
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)) return true;
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` +