@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.
- package/README.md +53 -21
- package/build/connect-github-api.js +103 -0
- package/build/connect-github.js +325 -39
- package/build/credential-store.js +302 -18
- package/build/doctor.js +80 -35
- package/build/index.js +31 -18
- package/build/install-bridge.js +1477 -440
- package/build/install-doctor.js +37 -4
- package/build/readme.generated.js +1 -1
- package/build/start-tickets.js +12 -1
- package/build/version.generated.js +1 -1
- package/docs/install/github-app.md +96 -8
- package/package.json +4 -3
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
564
|
-
function
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
"
|
|
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
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
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
|
|
175
|
+
export function buildDoctorReportHeader(platform, agent) {
|
|
164
176
|
const activeGroups = Array.from(resolveProfiles(process.env.BRIDGE_MCP_PROFILE)).join(", ");
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
|
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
|
-
|
|
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
|