@bridge_gpt/mcp-server 0.2.31 → 0.2.33

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.
@@ -10,7 +10,8 @@
10
10
  * creates or initializes credential files. The mutation primitives — the
11
11
  * best-effort {@link upsertBapiCredential} and the fail-closed bootstrap-invite
12
12
  * pending-state operations ({@link prepareBootstrapPendingCredential},
13
- * {@link repointBootstrapPendingCredential}, {@link promoteBootstrapPendingCredential})
13
+ * {@link repointBootstrapPendingCredential}, {@link promoteBootstrapPendingCredential},
14
+ * {@link discardBootstrapPendingCredential})
14
15
  * — are the ONLY writers: they mutate the user-scoped primary store from explicit
15
16
  * install/migration code paths, preserve every other entry, and never touch
16
17
  * project/worktree config files (those remain secret-free). Every mutation runs
@@ -489,12 +490,40 @@ export async function durablyReplaceCredentialStoreJson(primaryPath, value, deps
489
490
  * durably saved but NOT yet redeemed. It is a separate top-level key from
490
491
  * `bapi:<repo>`, so the resolver never serves an unredeemed secret as a
491
492
  * credential, and a failed redemption never leaves a broken `bapi:<repo>` entry.
493
+ *
494
+ * A pending record has TWO shapes (BAPI-667), distinguished only by whether
495
+ * {@link BOOTSTRAP_PENDING_REPLAY_TOKEN_FIELD} is present:
496
+ *
497
+ * - ORDINARY INVITE — `key_secret` + invite fingerprint. The token stays off
498
+ * disk because the user holds it and can re-present it on a retry.
499
+ * - SELF-SERVE — the same two fields PLUS the minted invite token, because the
500
+ * token was never shown to the user and therefore cannot be re-presented.
501
+ *
502
+ * Both shapes are read by the same helpers; the ordinary shape is the historical
503
+ * one and remains fully valid, so records written before BAPI-667 keep working.
492
504
  */
493
505
  export const BOOTSTRAP_PENDING_TARGET_PREFIX = "bootstrap-pending:";
494
506
  /** Secret name holding the client-generated `key_secret` inside a pending record. */
495
507
  export const BOOTSTRAP_PENDING_SECRET_FIELD = "BAPI_API_KEY";
496
508
  /** Secret name holding the invite FINGERPRINT (a SHA-256 hex digest, not the token). */
497
509
  export const BOOTSTRAP_PENDING_FINGERPRINT_FIELD = "BOOTSTRAP_INVITE_FINGERPRINT";
510
+ /**
511
+ * Secret name holding the INTERNALLY MINTED self-serve invite token (BAPI-667).
512
+ *
513
+ * SELF-SERVE RECORDS ONLY. An ordinary invite (`--invite` / `BAPI_INVITE` / an
514
+ * invite pasted into the ordinary credential input) is supplied BY the user, so a
515
+ * retry can re-present it and the record needs only the one-way fingerprint. A
516
+ * self-serve token is minted internally and never shown, so a fingerprint alone
517
+ * makes replay impossible BY CONSTRUCTION: every retry would mint a fresh token,
518
+ * present a fresh fingerprint, and hard-fail against its own pending record.
519
+ * Storing the token is what makes the self-serve arm retryable at all.
520
+ *
521
+ * This adds no new exposure class: the record already holds the strictly more
522
+ * sensitive `key_secret` and is written 0600 through the same locked, fsync'd
523
+ * {@link durablyReplaceCredentialStoreJson} path. Like every other value here it
524
+ * is NEVER logged, printed, or placed in an error string.
525
+ */
526
+ export const BOOTSTRAP_PENDING_REPLAY_TOKEN_FIELD = "BOOTSTRAP_SELF_SERVE_INVITE";
498
527
  /** Pending target for a repo: `bootstrap-pending:<repo>`. */
499
528
  export function getBootstrapPendingTarget(repoName) {
500
529
  return `${BOOTSTRAP_PENDING_TARGET_PREFIX}${(repoName ?? "").trim()}`;
@@ -521,7 +550,19 @@ function hasExistingBapiKey(store, repoName) {
521
550
  const entry = store[getBapiTarget(repoName)];
522
551
  return (!!entry && typeof entry.BAPI_API_KEY === "string" && entry.BAPI_API_KEY.trim().length > 0);
523
552
  }
524
- /** Read a pending record whose fingerprint matches; `null` when absent/mismatched. */
553
+ /** Trimmed string value of a pending-record field, or `""` when absent/blank. */
554
+ function readPendingField(entry, field) {
555
+ const value = entry?.[field];
556
+ return typeof value === "string" ? value.trim() : "";
557
+ }
558
+ /**
559
+ * Read a pending record whose fingerprint matches; `null` when absent/mismatched.
560
+ *
561
+ * `replayToken` is present ONLY for a self-serve record (BAPI-667). An ordinary
562
+ * invite record predates that field and legitimately omits it, so its absence is
563
+ * NEVER a validity problem here — it just means "not resumable without the user
564
+ * re-presenting the token", which is exactly the ordinary-invite contract.
565
+ */
525
566
  function readMatchingPending(store, repoName, inviteFingerprint) {
526
567
  const entry = store[getBootstrapPendingTarget(repoName)];
527
568
  if (!entry)
@@ -532,7 +573,30 @@ function readMatchingPending(store, repoName, inviteFingerprint) {
532
573
  return null;
533
574
  if (fingerprint !== inviteFingerprint)
534
575
  return null;
535
- return { keySecret: secret };
576
+ const replayToken = readPendingField(entry, BOOTSTRAP_PENDING_REPLAY_TOKEN_FIELD);
577
+ return replayToken.length > 0 ? { keySecret: secret, replayToken } : { keySecret: secret };
578
+ }
579
+ /**
580
+ * Build the pending-record entry written by prepare / repoint.
581
+ *
582
+ * Sibling secret names on the existing entry are preserved, but the replay-token
583
+ * field is set EXPLICITLY or removed — never inherited. Inheriting it would let a
584
+ * stale token from an earlier record ride along with a new `key_secret`,
585
+ * producing a record whose two halves belong to different redemptions.
586
+ */
587
+ function buildPendingEntry(existing, keySecret, inviteFingerprint, replayToken) {
588
+ const next = {
589
+ ...(existing ?? {}),
590
+ [BOOTSTRAP_PENDING_SECRET_FIELD]: keySecret,
591
+ [BOOTSTRAP_PENDING_FINGERPRINT_FIELD]: inviteFingerprint,
592
+ };
593
+ if (replayToken && replayToken.length > 0) {
594
+ next[BOOTSTRAP_PENDING_REPLAY_TOKEN_FIELD] = replayToken;
595
+ }
596
+ else {
597
+ delete next[BOOTSTRAP_PENDING_REPLAY_TOKEN_FIELD];
598
+ }
599
+ return next;
536
600
  }
537
601
  /**
538
602
  * Does `bootstrap-pending:<repo>` hold a secret belonging to a DIFFERENT invite?
@@ -560,8 +624,29 @@ function hasConflictingPending(store, repoName, inviteFingerprint) {
560
624
  return false;
561
625
  return entry[BOOTSTRAP_PENDING_FINGERPRINT_FIELD] !== inviteFingerprint;
562
626
  }
563
- /** One wording for the conflict, so `prepare` and `repoint` cannot drift apart. */
564
- function pendingConflictError(target, primaryPath) {
627
+ /** Does the stored (conflicting) pending record carry usable self-serve replay material? */
628
+ function storedPendingIsSelfServe(store, repoName) {
629
+ const entry = store[getBootstrapPendingTarget(repoName)];
630
+ return readPendingField(entry, BOOTSTRAP_PENDING_REPLAY_TOKEN_FIELD).length > 0;
631
+ }
632
+ /**
633
+ * One wording for the conflict, so `prepare` and `repoint` cannot drift apart.
634
+ *
635
+ * MODE-AWARE (BAPI-667). A conflicting record that carries self-serve replay
636
+ * material is recoverable WITHOUT the user touching the store: re-running the
637
+ * self-serve flow resumes it. Telling that user to "remove the entry by hand" is
638
+ * the single worst instruction available — if its exchange already succeeded, the
639
+ * record is the only trace of a live admin key. A genuine cross-invite conflict
640
+ * (an ordinary, fingerprint-only record) keeps the original fail-closed guidance,
641
+ * because there the CLI genuinely cannot replay it.
642
+ */
643
+ function pendingConflictError(target, primaryPath, storedIsSelfServe) {
644
+ if (storedIsSelfServe) {
645
+ return (`A pending self-serve signup for a DIFFERENT invite already exists at ${target} in ` +
646
+ `${primaryPath}. It is the only proof that can replay that signup, so it will not be ` +
647
+ "overwritten. Re-run install-bridge and choose the email option — it will resume that " +
648
+ "signup automatically. Do NOT remove the entry by hand.");
649
+ }
565
650
  return (`A pending bootstrap-invite credential for a DIFFERENT invite already exists at ${target} ` +
566
651
  `in ${primaryPath}. It is the only proof that can replay that redemption, so it will not be ` +
567
652
  "overwritten. Complete that redemption first, or — only if you are certain its invite was " +
@@ -579,12 +664,20 @@ function pendingConflictError(target, primaryPath) {
579
664
  * A fresh record is written through {@link durablyReplaceCredentialStoreJson}, so
580
665
  * a failed write or fsync produces a failure result and NO success value — the
581
666
  * caller cannot proceed to the exchange with a secret that is not on disk.
667
+ *
668
+ * BAPI-667: an optional {@link PrepareBootstrapPendingParams.selfServeReplayToken}
669
+ * is persisted in the SAME durable write, making the record self-serve-resumable.
670
+ * Omitting it (every `--invite` caller) preserves the fingerprint-only record shape
671
+ * exactly as before.
582
672
  */
583
673
  export async function prepareBootstrapPendingCredential(params, deps) {
584
674
  const primaryPath = getPrimaryCredentialStorePath(deps);
585
675
  const repoName = (params.repoName ?? "").trim();
586
676
  const fingerprint = (params.inviteFingerprint ?? "").trim();
587
677
  const target = getBootstrapPendingTarget(repoName);
678
+ // `undefined` = ordinary invite (fingerprint-only). A supplied value is trimmed
679
+ // and validated below; its CONTENTS never reach an error string.
680
+ const replayToken = params.selfServeReplayToken === undefined ? undefined : params.selfServeReplayToken.trim();
588
681
  if (repoName.length === 0) {
589
682
  return {
590
683
  ok: false,
@@ -603,6 +696,15 @@ export async function prepareBootstrapPendingCredential(params, deps) {
603
696
  error: "Cannot prepare a bootstrap-invite credential: the invite fingerprint was empty.",
604
697
  };
605
698
  }
699
+ if (replayToken !== undefined && replayToken.length === 0) {
700
+ return {
701
+ ok: false,
702
+ path: primaryPath,
703
+ target,
704
+ kind: "invalid-replay-token",
705
+ error: "Cannot prepare a self-serve bootstrap credential: the supplied replay material was empty.",
706
+ };
707
+ }
606
708
  return withCredentialStoreLock(deps, async () => {
607
709
  const loaded = await loadStoreForMutation(deps);
608
710
  if (!loaded.ok) {
@@ -622,10 +724,24 @@ export async function prepareBootstrapPendingCredential(params, deps) {
622
724
  };
623
725
  }
624
726
  const existing = readMatchingPending(base, repoName, fingerprint);
625
- if (existing) {
727
+ if (existing && (replayToken === undefined || existing.replayToken === replayToken)) {
626
728
  // Reuse verbatim — this exact value is the replay proof. Do not regenerate.
627
729
  return { ok: true, path: primaryPath, target, keySecret: existing.keySecret, reused: true };
628
730
  }
731
+ if (existing) {
732
+ // Same invite + same repo, but the record predates (or lost) its replay
733
+ // material. Keep the `key_secret` — it is still the replay proof — and
734
+ // durably attach the token so the NEXT retry can resume without minting.
735
+ const upgraded = {
736
+ ...base,
737
+ [target]: buildPendingEntry(base[target], existing.keySecret, fingerprint, replayToken),
738
+ };
739
+ const rewritten = await durablyReplaceCredentialStoreJson(primaryPath, upgraded, deps);
740
+ if (!rewritten.ok) {
741
+ return { ok: false, path: primaryPath, target, kind: rewritten.kind, error: rewritten.error };
742
+ }
743
+ return { ok: true, path: primaryPath, target, keySecret: existing.keySecret, reused: true };
744
+ }
629
745
  // Not ours, but someone's: redeeming a second invite against a repo that
630
746
  // still holds a pending record for a first one would overwrite that record —
631
747
  // and if the first exchange already succeeded, its admin key becomes
@@ -636,17 +752,13 @@ export async function prepareBootstrapPendingCredential(params, deps) {
636
752
  path: primaryPath,
637
753
  target,
638
754
  kind: "pending-conflict",
639
- error: pendingConflictError(target, primaryPath),
755
+ error: pendingConflictError(target, primaryPath, storedPendingIsSelfServe(base, repoName)),
640
756
  };
641
757
  }
642
758
  const keySecret = params.generateKeySecret();
643
759
  const next = {
644
760
  ...base,
645
- [target]: {
646
- ...(base[target] ?? {}),
647
- [BOOTSTRAP_PENDING_SECRET_FIELD]: keySecret,
648
- [BOOTSTRAP_PENDING_FINGERPRINT_FIELD]: fingerprint,
649
- },
761
+ [target]: buildPendingEntry(base[target], keySecret, fingerprint, replayToken),
650
762
  };
651
763
  const written = await durablyReplaceCredentialStoreJson(primaryPath, next, deps);
652
764
  if (!written.ok) {
@@ -716,16 +828,16 @@ export async function repointBootstrapPendingCredential(params, deps) {
716
828
  path: primaryPath,
717
829
  target,
718
830
  kind: "pending-conflict",
719
- error: pendingConflictError(target, primaryPath),
831
+ error: pendingConflictError(target, primaryPath, storedPendingIsSelfServe(base, toRepo)),
720
832
  };
721
833
  }
722
834
  const next = { ...base };
723
835
  delete next[getBootstrapPendingTarget(fromRepo)];
724
- next[target] = {
725
- ...(destination ?? {}),
726
- [BOOTSTRAP_PENDING_SECRET_FIELD]: pending.keySecret,
727
- [BOOTSTRAP_PENDING_FINGERPRINT_FIELD]: fingerprint,
728
- };
836
+ // BAPI-667: the replay token moves WITH the `key_secret`. A rename that kept
837
+ // the secret but dropped the token would silently downgrade a resumable
838
+ // self-serve record into an unreplayable one at exactly the moment (a 409
839
+ // rename loop) a retry is most likely.
840
+ next[target] = buildPendingEntry(destination, pending.keySecret, fingerprint, pending.replayToken);
729
841
  const written = await durablyReplaceCredentialStoreJson(primaryPath, next, deps);
730
842
  if (!written.ok) {
731
843
  return { ok: false, path: primaryPath, target, kind: written.kind, error: written.error };
@@ -744,6 +856,11 @@ export async function repointBootstrapPendingCredential(params, deps) {
744
856
  *
745
857
  * A failed promotion leaves the pending record intact, so the same replay proof is
746
858
  * still available to a later run.
859
+ *
860
+ * BAPI-667: the pending record is removed WHOLE, so a self-serve replay token is
861
+ * discarded here rather than copied forward. Only the stored `key_secret` becomes
862
+ * `bapi:<repo>.BAPI_API_KEY` — the token has served its only purpose (replaying an
863
+ * unfinished exchange) and a completed redemption must not leave it on disk.
747
864
  */
748
865
  export async function promoteBootstrapPendingCredential(params, deps) {
749
866
  const primaryPath = getPrimaryCredentialStorePath(deps);
@@ -796,6 +913,173 @@ export async function promoteBootstrapPendingCredential(params, deps) {
796
913
  return { ok: true, path: primaryPath, target, action: hadKey ? "updated" : "created" };
797
914
  }, (error) => ({ ok: false, path: primaryPath, target, kind: "lock-error", error }));
798
915
  }
916
+ /**
917
+ * Look up the SELF-SERVE pending record for a repo (BAPI-667) — the read that
918
+ * makes a self-serve retry resume instead of minting a second workspace.
919
+ *
920
+ * SECRET-BEARING on `state: "resumable"`: the result carries the stored invite
921
+ * token AND the stored `key_secret`. Neither may be logged, printed, embedded in
922
+ * an error, or shown in a dry-run preview; they exist only to be re-POSTed to the
923
+ * exchange. Every failure variant is deliberately secret-free — it names the
924
+ * logical target and the store path and nothing else.
925
+ *
926
+ * States:
927
+ * - `none` — no record, or a record with nothing at stake (no usable secret and
928
+ * no replay material). The caller mints normally.
929
+ * - `resumable` — a complete self-serve record. The caller MUST reuse it and MUST
930
+ * NOT mint.
931
+ * - `pending-not-self-serve` — a real pending record with a `key_secret` but no
932
+ * replay material: an ordinary `--invite` redemption in flight. Not ours to
933
+ * touch.
934
+ * - `pending-malformed` — replay material present but the record is incomplete
935
+ * (missing `key_secret` or fingerprint). FAIL CLOSED: a half-written record may
936
+ * still correspond to a live admin key, so it must never be silently discarded
937
+ * or trigger a fresh mint.
938
+ * - store failures (`read-error` / `parse-error` / `lock-error`) — propagated so
939
+ * an unreadable store never masquerades as "no record, safe to mint".
940
+ */
941
+ export async function lookupSelfServeBootstrapPendingCredential(params, deps) {
942
+ const primaryPath = getPrimaryCredentialStorePath(deps);
943
+ const repoName = (params.repoName ?? "").trim();
944
+ const target = getBootstrapPendingTarget(repoName);
945
+ if (repoName.length === 0) {
946
+ return {
947
+ ok: false,
948
+ path: primaryPath,
949
+ target,
950
+ kind: "invalid-repo",
951
+ error: "Cannot look up a bootstrap-invite credential: a non-empty repo name is required.",
952
+ };
953
+ }
954
+ // Read under the SHARED LOCK, even though the store is replaced by an atomic
955
+ // rename and a lock-free read could never see a half-written file. The lock is
956
+ // about the read-then-act window, not the read: without it a second install
957
+ // running concurrently for the same repo could observe the pending record an
958
+ // instant before the first run promotes it, then replay an exchange for a
959
+ // redemption that has already completed. Serializing against prepare / repoint /
960
+ // promote makes the record either wholly present or wholly gone.
961
+ //
962
+ // A lock failure is therefore FAIL-CLOSED: it is reported rather than degraded
963
+ // into `state: "none"`, because "I could not check" must never be answered by
964
+ // minting a second workspace.
965
+ return withCredentialStoreLock(deps, async () => {
966
+ const loaded = await loadStoreForMutation(deps);
967
+ if (!loaded.ok) {
968
+ return { ok: false, path: primaryPath, target, kind: loaded.kind, error: loaded.error };
969
+ }
970
+ const entry = loaded.base[target];
971
+ const keySecret = readPendingField(entry, BOOTSTRAP_PENDING_SECRET_FIELD);
972
+ const fingerprint = readPendingField(entry, BOOTSTRAP_PENDING_FINGERPRINT_FIELD);
973
+ const replayToken = readPendingField(entry, BOOTSTRAP_PENDING_REPLAY_TOKEN_FIELD);
974
+ if (replayToken.length === 0) {
975
+ // No replay material: either nothing is stored, or an ordinary invite
976
+ // redemption owns this target. Only the latter is a conflict.
977
+ if (keySecret.length === 0) {
978
+ return { ok: true, state: "none", path: primaryPath, target };
979
+ }
980
+ return {
981
+ ok: false,
982
+ path: primaryPath,
983
+ target,
984
+ kind: "pending-not-self-serve",
985
+ error: `A pending bootstrap-invite credential already exists at ${target} in ${primaryPath}. ` +
986
+ "It belongs to an invite redemption, not a self-serve signup, so this run will not " +
987
+ "touch it. Finish that redemption by re-running install-bridge with the same invite.",
988
+ };
989
+ }
990
+ if (keySecret.length === 0 || fingerprint.length === 0) {
991
+ return {
992
+ ok: false,
993
+ path: primaryPath,
994
+ target,
995
+ kind: "pending-malformed",
996
+ error: `The pending self-serve record at ${target} in ${primaryPath} is incomplete, so it ` +
997
+ "cannot be replayed and will not be discarded automatically (it may correspond to a " +
998
+ "key that was already created). Ask your Bridge API operator to recover it.",
999
+ };
1000
+ }
1001
+ return {
1002
+ ok: true,
1003
+ state: "resumable",
1004
+ path: primaryPath,
1005
+ target,
1006
+ keySecret,
1007
+ replayToken,
1008
+ inviteFingerprint: fingerprint,
1009
+ };
1010
+ }, (error) => ({ ok: false, path: primaryPath, target, kind: "lock-error", error }));
1011
+ }
1012
+ /**
1013
+ * Discard a SELF-SERVE pending record (BAPI-667) — the consent-gated exit from a
1014
+ * signup whose stored invite the server has conclusively rejected (401).
1015
+ *
1016
+ * This is the ONLY destructive operation on a pending record other than promotion,
1017
+ * so it is guarded three ways: the record must exist, its fingerprint must match
1018
+ * what the caller expects, and it must carry self-serve replay material. Those
1019
+ * guards are what keep it from becoming the `--force` that BAPI-606 deliberately
1020
+ * refused to add — an ordinary invite's record can never be reached through here,
1021
+ * and neither can a self-serve record the caller has not actually replayed.
1022
+ *
1023
+ * Returns secret-free path/target metadata only.
1024
+ */
1025
+ export async function discardBootstrapPendingCredential(params, deps) {
1026
+ const primaryPath = getPrimaryCredentialStorePath(deps);
1027
+ const repoName = (params.repoName ?? "").trim();
1028
+ const fingerprint = (params.inviteFingerprint ?? "").trim();
1029
+ const target = getBootstrapPendingTarget(repoName);
1030
+ if (repoName.length === 0) {
1031
+ return {
1032
+ ok: false,
1033
+ path: primaryPath,
1034
+ target,
1035
+ kind: "invalid-repo",
1036
+ error: "Cannot discard a bootstrap-invite credential: a non-empty repo name is required.",
1037
+ };
1038
+ }
1039
+ if (fingerprint.length === 0) {
1040
+ return {
1041
+ ok: false,
1042
+ path: primaryPath,
1043
+ target,
1044
+ kind: "invalid-fingerprint",
1045
+ error: "Cannot discard a bootstrap-invite credential: the invite fingerprint was empty.",
1046
+ };
1047
+ }
1048
+ return withCredentialStoreLock(deps, async () => {
1049
+ const loaded = await loadStoreForMutation(deps);
1050
+ if (!loaded.ok) {
1051
+ return { ok: false, path: primaryPath, target, kind: loaded.kind, error: loaded.error };
1052
+ }
1053
+ const base = loaded.base;
1054
+ const pending = readMatchingPending(base, repoName, fingerprint);
1055
+ if (!pending) {
1056
+ return {
1057
+ ok: false,
1058
+ path: primaryPath,
1059
+ target,
1060
+ kind: "pending-missing",
1061
+ error: `No matching pending bootstrap-invite credential for ${target} in ${primaryPath}.`,
1062
+ };
1063
+ }
1064
+ if (!pending.replayToken) {
1065
+ return {
1066
+ ok: false,
1067
+ path: primaryPath,
1068
+ target,
1069
+ kind: "pending-not-self-serve",
1070
+ error: `The pending record at ${target} in ${primaryPath} carries no self-serve replay ` +
1071
+ "material, so it will not be discarded automatically.",
1072
+ };
1073
+ }
1074
+ const next = { ...base };
1075
+ delete next[target];
1076
+ const written = await durablyReplaceCredentialStoreJson(primaryPath, next, deps);
1077
+ if (!written.ok) {
1078
+ return { ok: false, path: primaryPath, target, kind: written.kind, error: written.error };
1079
+ }
1080
+ return { ok: true, path: primaryPath, target };
1081
+ }, (error) => ({ ok: false, path: primaryPath, target, kind: "lock-error", error }));
1082
+ }
799
1083
  /**
800
1084
  * Upsert `BAPI_API_KEY` for `bapi:<repoName>` into the user-scoped PRIMARY
801
1085
  * credential store, atomically (temp file + rename) and secret-safely.
package/build/doctor.js CHANGED
@@ -20,7 +20,7 @@ import os from "os";
20
20
  import path from "path";
21
21
  import { createDefaultStartTicketsDeps } from "./start-tickets.js";
22
22
  import { VERSION } from "./version.generated.js";
23
- import { collectInstallStatusChecks, formatInstallStatusReport, resolveInstallDoctorTarget, } from "./install-doctor.js";
23
+ import { collectInstallStatusChecks, formatInstallStatusReport, formatInstallStatusFallbackReport, resolveInstallDoctorTarget, } from "./install-doctor.js";
24
24
  import { parseDefaultOnEnvFlag } from "./env-flags.js";
25
25
  import { createBridgeApiUrls } from "./bridge-api-urls.js";
26
26
  import { probeToolSurface } from "./tool-surface-gating.js";
@@ -28,21 +28,37 @@ import { resolveBapiCredentials } from "./credential-store.js";
28
28
  import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, formatValidAgentNames, } from "./agent-registry.js";
29
29
  import { getDoctorPrereqDescriptors, probePrerequisite, } from "./start-tickets-prereqs.js";
30
30
  import { resolveProfiles } from "./mcp-profile.js";
31
+ /**
32
+ * The report/usage title (BAPI-669, U9b). `doctor` diagnoses the whole Bridge
33
+ * install — install status, prerequisites, launcher cache, and tool surface — so it
34
+ * is no longer titled as a start-tickets-only tool. Defined once and shared by
35
+ * {@link getDoctorUsage} and {@link buildDoctorReportHeader} so the two cannot drift.
36
+ */
37
+ export const DOCTOR_REPORT_TITLE = "bridge doctor — read-only diagnostics";
31
38
  /** User-facing usage text for the read-only `doctor` subcommand. */
32
39
  export function getDoctorUsage() {
33
40
  return [
34
41
  "Usage:",
35
42
  " npx -y @bridge_gpt/mcp-server doctor [--agent <name>]",
36
43
  "",
37
- "Read-only diagnostics for the start-tickets CLI. It only checks your",
38
- "environment and prints manual install instructions it does not install",
39
- "anything, modify your system, or start the MCP server.",
44
+ // BAPI-669 (U9b): `doctor` is the general Bridge diagnostic command, not a
45
+ // start-tickets-only one and its report leads with install status, so the
46
+ // usage text describes it in that order too.
47
+ `${DOCTOR_REPORT_TITLE}. It only checks your environment and prints manual`,
48
+ "install instructions — it does not install anything, modify your system, or",
49
+ "start the MCP server.",
40
50
  "",
41
51
  "Flags:",
42
52
  " --agent claude|cursor-agent Agent to include in the prerequisite check (default: claude)",
43
53
  " -h, --help Show this help",
44
54
  "",
45
- "Checks (for the current OS): the start-tickets preflight prerequisites plus",
55
+ "The report opens with an advisory 'Install status' section (easy-install done",
56
+ "criteria): repo identity, credential resolution, server connectivity,",
57
+ "bootstrap-field completeness, integration credentials, and repository-indexing",
58
+ "state. It performs read-only GETs only and never affects the exit code.",
59
+ "",
60
+ "After that come the start-tickets prerequisite checks (for the current OS):",
61
+ "the start-tickets preflight prerequisites plus",
46
62
  "uv, the selected agent's command, Bridge API credential resolution, and",
47
63
  "worktree MCP registration reachability. Credential resolution reports the",
48
64
  "source it would use (env vs. store target bapi:<repo>); it never reads or",
@@ -50,11 +66,6 @@ export function getDoctorUsage() {
50
66
  "migrate a credential, use /install-bridge or the `credentials` subcommand —",
51
67
  "doctor stays strictly read-only.",
52
68
  "",
53
- "The report also includes an advisory 'Install status' section (easy-install",
54
- "done criteria): repo identity, credential resolution, server connectivity,",
55
- "bootstrap-field completeness, and repository-indexing state. It performs",
56
- "read-only GETs only and never affects the exit code.",
57
- "",
58
69
  "It also includes an advisory 'MCP tool surface' section (BAPI-641): what",
59
70
  "dynamic capability gating would advertise for this repo. It performs at most",
60
71
  "one read-only GET to /jira/mcp/tool-surface (none under the kill switch) and",
@@ -155,20 +166,31 @@ export async function collectDoctorResults(deps, agentName) {
155
166
  return { ok: true, results };
156
167
  }
157
168
  /**
158
- * Render the doctor report: platform + selected agent header, one found/missing
159
- * line per prerequisite, the exact manual install hint for each missing one
160
- * (framed as manual instructions only), and the selected agent's auth note
161
- * (especially the `cursor-agent login` reminder). Pure formatting no probing.
169
+ * The BROAD report header: title plus the run's platform / agent / profile
170
+ * metadata (BAPI-669, U9b). Split out from the prerequisite body so `runDoctorCli`
171
+ * has a clear insertion point for the Install-status section between the two — the
172
+ * ordering change is the whole point, and it must not require rewriting the
173
+ * prerequisite formatting below.
162
174
  */
163
- export function formatDoctorReport(platform, agent, collection) {
175
+ export function buildDoctorReportHeader(platform, agent) {
164
176
  const activeGroups = Array.from(resolveProfiles(process.env.BRIDGE_MCP_PROFILE)).join(", ");
165
- const lines = [
166
- "start-tickets doctor (read-only diagnostics)",
177
+ return [
178
+ DOCTOR_REPORT_TITLE,
167
179
  `Platform: ${platform}`,
168
180
  `Selected agent: ${agent.name} (command: ${agent.command})`,
169
181
  `Active MCP Groups: \`${activeGroups}\``,
170
182
  "",
171
183
  ];
184
+ }
185
+ /**
186
+ * The start-tickets PREREQUISITE section: one found/missing line per prerequisite,
187
+ * the exact manual install hint for each missing one (framed as manual instructions
188
+ * only), and the selected agent's auth note (especially the `cursor-agent login`
189
+ * reminder). Pure formatting — no probing. Byte-identical to what
190
+ * {@link formatDoctorReport} has always rendered below its header.
191
+ */
192
+ export function formatDoctorPrereqSection(platform, collection) {
193
+ const lines = [];
172
194
  if (!collection.ok) {
173
195
  lines.push(`Platform '${platform}' is unsupported. start-tickets supports darwin, win32, and linux.`);
174
196
  return lines.join("\n");
@@ -193,6 +215,17 @@ export function formatDoctorReport(platform, agent, collection) {
193
215
  lines.push("For conductor ledger/native-module diagnostics, run: conductor doctor");
194
216
  return lines.join("\n");
195
217
  }
218
+ /**
219
+ * Header + prerequisite section, composed. Retained as the single-call rendering
220
+ * used by direct callers and tests; `runDoctorCli` composes the two halves itself so
221
+ * it can slot Install status between them (BAPI-669, U9c).
222
+ */
223
+ export function formatDoctorReport(platform, agent, collection) {
224
+ return [
225
+ ...buildDoctorReportHeader(platform, agent),
226
+ formatDoctorPrereqSection(platform, collection),
227
+ ].join("\n");
228
+ }
196
229
  // ---------------------------------------------------------------------------
197
230
  // Launcher-cache diagnostics (BAPI-451 W3, E-4/E-9) — strictly read-only.
198
231
  //
@@ -504,25 +537,20 @@ export async function runDoctorCli(argv, overrides = {}) {
504
537
  const deps = overrides.deps ?? createDefaultStartTicketsDeps();
505
538
  const agent = resolveAgentSpec(parsed.options.agentName) ?? resolveAgentSpec(DEFAULT_AGENT_NAME);
506
539
  const collection = await collectDoctorResults(deps, parsed.options.agentName);
507
- log(formatDoctorReport(deps.platform, agent, collection));
508
- // Strictly read-only launcher-cache diagnostics (BAPI-451). Best-effort: a probe
509
- // failure never changes the doctor exit code (cold-start readiness is advisory,
510
- // not a hard prerequisite). The exit code remains driven by required prereqs.
511
- try {
512
- const launcherDeps = {
513
- cwd: overrides.launcherProbe?.cwd ?? deps.cwd,
514
- readFile: overrides.launcherProbe?.readFile ?? ((p) => readFile(p, "utf-8")),
515
- probeNpxNoInstall: overrides.launcherProbe?.probeNpxNoInstall ?? probeNpxNoInstallDefault,
516
- };
517
- const launcherInspections = await inspectLauncherCache(launcherDeps);
518
- log(formatLauncherCacheReport(launcherInspections));
519
- }
520
- catch {
521
- /* launcher-cache diagnostics are advisory; never block the doctor report */
522
- }
540
+ // ---- Section order (BAPI-669, U9c) ----
541
+ // Broad header Install status → start-tickets prerequisites → launcher cache →
542
+ // MCP tool surface. Install status renders FIRST and UNCONDITIONALLY: it answers
543
+ // "is this project actually set up?", which is what someone running `doctor` after
544
+ // an install is asking. There is deliberately no "invocation context" flag to gate
545
+ // the ordering on — the report has a single caller and carries no context, so
546
+ // always-first is both simpler and equivalent. The `installStatus: false` override
547
+ // remains a dependency-injection-only seam for hermetic tests; a normal CLI
548
+ // invocation never takes it.
549
+ log(buildDoctorReportHeader(deps.platform, agent).join("\n"));
523
550
  // Advisory install-status section (easy-install done criteria). Read-only GETs
524
551
  // only; any failure degrades to WARN/SKIP lines inside the section, and any
525
- // unexpected throw is swallowed the exit code is never affected.
552
+ // unexpected throw renders the sanitized fallback rather than dropping the
553
+ // report's first section — the exit code is never affected either way.
526
554
  if (overrides.installStatus !== false) {
527
555
  try {
528
556
  // Reuse fs deps injected on `deps` (doctor's credential probes place them
@@ -543,9 +571,26 @@ export async function runDoctorCli(argv, overrides = {}) {
543
571
  log(formatInstallStatusReport(checks));
544
572
  }
545
573
  catch {
546
- /* install-status diagnostics are advisory; never block the doctor report */
574
+ // Advisory, but never silently absent: the first section always renders.
575
+ log(formatInstallStatusFallbackReport());
547
576
  }
548
577
  }
578
+ log(formatDoctorPrereqSection(deps.platform, collection));
579
+ // Strictly read-only launcher-cache diagnostics (BAPI-451). Best-effort: a probe
580
+ // failure never changes the doctor exit code (cold-start readiness is advisory,
581
+ // not a hard prerequisite). The exit code remains driven by required prereqs.
582
+ try {
583
+ const launcherDeps = {
584
+ cwd: overrides.launcherProbe?.cwd ?? deps.cwd,
585
+ readFile: overrides.launcherProbe?.readFile ?? ((p) => readFile(p, "utf-8")),
586
+ probeNpxNoInstall: overrides.launcherProbe?.probeNpxNoInstall ?? probeNpxNoInstallDefault,
587
+ };
588
+ const launcherInspections = await inspectLauncherCache(launcherDeps);
589
+ log(formatLauncherCacheReport(launcherInspections));
590
+ }
591
+ catch {
592
+ /* launcher-cache diagnostics are advisory; never block the doctor report */
593
+ }
549
594
  // Advisory MCP tool-surface capability section (BAPI-641). Strictly read-only:
550
595
  // one 500 ms GET (or none, under the kill switch). Any timeout, malformed
551
596
  // response, or unexpected throw degrades to a "fail-open to full surface" line