@bridge_gpt/mcp-server 0.2.31 → 0.2.32
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/credential-store.js +302 -18
- package/build/doctor.js +80 -35
- package/build/index.js +17 -16
- 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/package.json +3 -2
package/build/install-bridge.js
CHANGED
|
@@ -19,11 +19,17 @@
|
|
|
19
19
|
* (R5). Halt on failure — so a bad key never lands in the config and
|
|
20
20
|
* traps later retries behind overwrite-consent.
|
|
21
21
|
* Step 3 Write the per-host MCP config (.mcp.json / .cursor/mcp.json /
|
|
22
|
-
* .vscode/mcp.json)
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
22
|
+
* .vscode/mcp.json), read-merge-write so unrelated servers survive,
|
|
23
|
+
* pinning the launcher to the exact running version. A VALID, UNTRACKED
|
|
24
|
+
* (freshly-gitignored) config receives REAL values (BAPI_REPO_NAME /
|
|
25
|
+
* BAPI_API_KEY / BAPI_BASE_URL / BAPI_DOCS_DIR). A git-TRACKED config
|
|
26
|
+
* receives the real key only after an explicit default-No TTY consent —
|
|
27
|
+
* otherwise (interactive decline or any non-TTY run) the SECRET-FREE
|
|
28
|
+
* entry (no BAPI_API_KEY; the server self-resolves it) so the key is
|
|
29
|
+
* never committed on the next push. An INVALID (present-but-unparseable)
|
|
30
|
+
* config is left byte-identical and reported with manual-merge
|
|
31
|
+
* instructions (BAPI-666). Detected Windsurf / Codex (global configs we
|
|
32
|
+
* never write into automatically) get printed manual instructions.
|
|
27
33
|
* Step 4 Persist the routing credential to the user-scoped store
|
|
28
34
|
* (`~/.config/bridge/credentials.json`, target `bapi:<repo>`) via
|
|
29
35
|
* `upsertBapiCredential`. Non-blocking (fail-open) — mirrors Stage 6.
|
|
@@ -87,13 +93,24 @@
|
|
|
87
93
|
* that can replay a redemption, so a successful exchange with a failed local write
|
|
88
94
|
* would leave an unrecoverable admin key and a permanently spent invite.
|
|
89
95
|
*
|
|
96
|
+
* SELF-SERVE REPLAY (BAPI-667) — the self-serve arm is the one place the CLI both
|
|
97
|
+
* creates AND holds the invite token, so it is the one place a retry cannot ask the
|
|
98
|
+
* user to re-present it. Its pending record therefore additionally stores the minted
|
|
99
|
+
* token, and a self-serve run LOOKS THAT RECORD UP BEFORE MINTING: a resumable
|
|
100
|
+
* record short-circuits the mint entirely and re-drives the same exchange. A fresh
|
|
101
|
+
* mint is reachable only after the replayed token draws a conclusive 401 AND the
|
|
102
|
+
* user explicitly consents to discard the stored record.
|
|
103
|
+
*
|
|
90
104
|
* SECRET DISCIPLINE: THREE secrets now — the API key, the bootstrap-invite token,
|
|
91
105
|
* and the client-generated `key_secret`. NONE is ever printed or logged — not in
|
|
92
106
|
* stdout, stderr, error messages, or --dry-run output (they are redacted to
|
|
93
107
|
* `<REDACTED>`). The ONLY places a secret is durably written are the per-host MCP
|
|
94
108
|
* config (Step 3, gitignored) and the user-scoped credential store (Step 4 / the
|
|
95
|
-
* pending record).
|
|
96
|
-
* only a SHA-256 fingerprint of it, to key the pending record.
|
|
109
|
+
* pending record). A USER-SUPPLIED bootstrap-invite token is never written to disk —
|
|
110
|
+
* only a SHA-256 fingerprint of it, to key the pending record. An INTERNALLY MINTED
|
|
111
|
+
* self-serve token is the sole exception (BAPI-667): it is stored inside the same
|
|
112
|
+
* 0600, fsync'd pending record as the `key_secret`, purely so the signup can be
|
|
113
|
+
* replayed, and it is discarded when that record is promoted.
|
|
97
114
|
*/
|
|
98
115
|
import { readFile, writeFile, mkdir, stat, rename, chmod, unlink, open } from "fs/promises";
|
|
99
116
|
import { spawn } from "child_process";
|
|
@@ -109,7 +126,7 @@ import { provisionHostTarget, createDefaultVendorProcessDeps, } from "./mcp-host
|
|
|
109
126
|
import { writeMcpInstallState } from "./mcp-install-state.js";
|
|
110
127
|
import { ensureGitignored as ensureGitignoredShared, } from "./git-ignore-utils.js";
|
|
111
128
|
import { resolveStartTicketsRepoName } from "./start-tickets-repo.js";
|
|
112
|
-
import { upsertBapiCredential, getPrimaryCredentialStorePath, prepareBootstrapPendingCredential, repointBootstrapPendingCredential, promoteBootstrapPendingCredential, resolveBapiCredentials, } from "./credential-store.js";
|
|
129
|
+
import { upsertBapiCredential, getPrimaryCredentialStorePath, prepareBootstrapPendingCredential, repointBootstrapPendingCredential, promoteBootstrapPendingCredential, lookupSelfServeBootstrapPendingCredential, discardBootstrapPendingCredential, getBootstrapPendingTarget, resolveBapiCredentials, } from "./credential-store.js";
|
|
113
130
|
// BAPI-631: the optional GitHub connect offer reuses the standalone command's flow and
|
|
114
131
|
// API primitives verbatim — no duplicated polling, browser, or picker logic here.
|
|
115
132
|
import { fetchGithubConfigurationState } from "./connect-github-api.js";
|
|
@@ -185,10 +202,29 @@ export const INSTALL_BRIDGE_AGENT_PROMPT = "Execute the /install-bridge command
|
|
|
185
202
|
"was already configured and zero changes were made, without a fabricated applied count.";
|
|
186
203
|
/** Default base URL when `BAPI_BASE_URL` is unset (mirrors index.ts). */
|
|
187
204
|
export const DEFAULT_BAPI_BASE_URL = "https://bridgegpt-api.com";
|
|
205
|
+
/**
|
|
206
|
+
* The Bridge web setup page for an ALREADY-normalized base URL (BAPI-669, U8).
|
|
207
|
+
*
|
|
208
|
+
* Every message that used to name a page without an address ("Security page",
|
|
209
|
+
* "setup UI", "Get Started page") now interpolates this, so the guidance is
|
|
210
|
+
* actionable on the hosted default and on a custom deployment alike. Deliberately
|
|
211
|
+
* NOT a per-deployment web-path discovery mechanism — that was rejected; `<base>/setup`
|
|
212
|
+
* is truthful for both. It carries only a URL and never a credential.
|
|
213
|
+
*/
|
|
214
|
+
export function buildInstallBridgeSetupUrl(baseUrl) {
|
|
215
|
+
return `${baseUrl.replace(/\/+$/, "")}/setup`;
|
|
216
|
+
}
|
|
188
217
|
/** Default docs dir when `BAPI_DOCS_DIR` is unset (mirrors index.ts). */
|
|
189
218
|
export const DEFAULT_BAPI_DOCS_DIR = "docs/tmp";
|
|
190
|
-
/**
|
|
191
|
-
|
|
219
|
+
/**
|
|
220
|
+
* User-facing usage text.
|
|
221
|
+
*
|
|
222
|
+
* `baseUrl` (BAPI-669, U8) makes the API-key guidance name a real address. The CLI
|
|
223
|
+
* passes the run's VALIDATED, normalized base URL; direct callers (and the parser's
|
|
224
|
+
* `help` result) get the hosted default.
|
|
225
|
+
*/
|
|
226
|
+
export function getInstallBridgeUsage(baseUrl = DEFAULT_BAPI_BASE_URL) {
|
|
227
|
+
const setupUrl = buildInstallBridgeSetupUrl(baseUrl);
|
|
192
228
|
return [
|
|
193
229
|
"Usage:",
|
|
194
230
|
" npx -y @bridge_gpt/mcp-server@latest install-bridge [flags]",
|
|
@@ -204,17 +240,28 @@ export function getInstallBridgeUsage() {
|
|
|
204
240
|
"step). So not every run applies config fields. Indexing is never asked about —",
|
|
205
241
|
"it starts automatically once the repository reaches full parse readiness.",
|
|
206
242
|
"",
|
|
243
|
+
"Your API key is written to a project MCP config only when that file is safe: a",
|
|
244
|
+
"valid, git-ignored config gets the real key, but a config already TRACKED by git",
|
|
245
|
+
"gets it only after an explicit default-No confirmation (and never at all in a",
|
|
246
|
+
"non-interactive run) — otherwise a secret-free entry is written and the server",
|
|
247
|
+
"resolves your key from the credential store at runtime. A config file that",
|
|
248
|
+
"cannot be parsed is left untouched, with manual-merge instructions printed.",
|
|
249
|
+
"",
|
|
207
250
|
"Run it bare — `install-bridge` with no flags — in a terminal and it asks",
|
|
208
|
-
`\`${INSTALL_BRIDGE_KEY_SELECTOR_PROMPT
|
|
209
|
-
|
|
210
|
-
"
|
|
211
|
-
"
|
|
212
|
-
"
|
|
251
|
+
`\`${INSTALL_BRIDGE_KEY_SELECTOR_PROMPT}\` first, with three numbered choices:`,
|
|
252
|
+
...INSTALL_BRIDGE_ONBOARDING_CHOICES.map((choice) => ` ${choice}`),
|
|
253
|
+
"There is NO default — pressing Enter selects nothing; you must type 1, 2, or 3",
|
|
254
|
+
"(one blank or invalid answer re-prompts once, then exits with guidance). Option 1",
|
|
255
|
+
"is the existing-key flow below, option 2 is the bootstrap-invite flow, and option 3",
|
|
256
|
+
"prompts for an email and creates a brand-new Bridge workspace for you (the",
|
|
257
|
+
"self-serve flow). That question is asked ONLY for a bare interactive run: passing",
|
|
258
|
+
"ANY flag, setting BAPI_API_KEY, or running without an interactive terminal keeps",
|
|
259
|
+
"the existing deterministic behavior and no prompt.",
|
|
213
260
|
"",
|
|
214
261
|
"Inputs (the only two irreducible ones):",
|
|
215
262
|
" --api-key <key> Bridge API key OR bootstrap invite. Falls back to the",
|
|
216
263
|
" BAPI_API_KEY env var, then an interactive (no-echo) prompt.",
|
|
217
|
-
|
|
264
|
+
` Generate a key at ${setupUrl} (Security page) — this`,
|
|
218
265
|
" command consumes a key, it does not create one. A value",
|
|
219
266
|
" detected as a bootstrap invite (bapi_inv_…), from any of the",
|
|
220
267
|
" three sources above, is instead redeemed to CREATE a new",
|
|
@@ -232,7 +279,7 @@ export function getInstallBridgeUsage() {
|
|
|
232
279
|
" non-interactive). In the existing-key flow it MUST match the",
|
|
233
280
|
" server-side repo registration (it keys the credential store",
|
|
234
281
|
" as bapi:<repo>). In either new-project flow (--email,",
|
|
235
|
-
" --invite, or
|
|
282
|
+
" --invite, or chooser option 2 or 3 above) it",
|
|
236
283
|
" instead NAMES the project this run creates, so you are asked",
|
|
237
284
|
" to name a new project rather than match an existing one; the",
|
|
238
285
|
" name must be globally unique.",
|
|
@@ -243,14 +290,22 @@ export function getInstallBridgeUsage() {
|
|
|
243
290
|
" It requests a fresh workspace for that email, then creates",
|
|
244
291
|
" the project and mints your own admin API key in one command.",
|
|
245
292
|
" Falls back to the BAPI_SIGNUP_EMAIL env var, then a visible",
|
|
246
|
-
" interactive prompt — which is also what
|
|
247
|
-
"
|
|
293
|
+
" interactive prompt — which is also what chooser option 3 on a",
|
|
294
|
+
" bare run reaches. The email is NOT a",
|
|
248
295
|
" secret (it is shown as you type), but it is never printed to",
|
|
249
296
|
" a log. Mutually",
|
|
250
297
|
" exclusive with --api-key and --invite. No email verification",
|
|
251
298
|
" is performed and no message is sent to the address — it only",
|
|
252
299
|
" labels the new workspace.",
|
|
253
300
|
"",
|
|
301
|
+
" RESUMABLE: a self-serve run that fails mid-protocol saves its",
|
|
302
|
+
" signup state under bootstrap-pending:<repo> in the credential",
|
|
303
|
+
" store. Re-running the self-serve flow RESUMES that attempt —",
|
|
304
|
+
" it does not sign up again — so a retry never creates a second",
|
|
305
|
+
" workspace. Never copy, display, or hand-remove that record; if",
|
|
306
|
+
" the saved invite has genuinely expired the CLI asks before",
|
|
307
|
+
" discarding it.",
|
|
308
|
+
"",
|
|
254
309
|
"Bootstrap-invite onboarding (no web UI, no pre-existing key):",
|
|
255
310
|
" --invite [token] Redeem a bootstrap invite you were already given: creates",
|
|
256
311
|
" the project and mints your own admin API key in one command.",
|
|
@@ -289,7 +344,10 @@ export function getInstallBridgeUsage() {
|
|
|
289
344
|
" launches nothing.",
|
|
290
345
|
" --force Overwrite an existing real BAPI_API_KEY in a",
|
|
291
346
|
" host config (or in the credential store) without",
|
|
292
|
-
" prompting.",
|
|
347
|
+
" prompting. It does NOT bypass the git-tracked",
|
|
348
|
+
" config safeguard below: --force authorizes",
|
|
349
|
+
" replacing a credential, not disclosing your key",
|
|
350
|
+
" into version control.",
|
|
293
351
|
" --dry-run Preview every step (scaffold targets, config",
|
|
294
352
|
" files + keys with the key REDACTED, ping",
|
|
295
353
|
" target, credential target, and the consent-gated",
|
|
@@ -564,6 +622,25 @@ export function promptSecretViaReadline(promptText, input = process.stdin, outpu
|
|
|
564
622
|
muted = true;
|
|
565
623
|
});
|
|
566
624
|
}
|
|
625
|
+
/**
|
|
626
|
+
* The deferral command printed with the GitHub offer (BAPI-669, U6). Declining is
|
|
627
|
+
* cheap precisely because this exists — the connection is a standalone command.
|
|
628
|
+
*/
|
|
629
|
+
export const INSTALL_BRIDGE_CONNECT_GITHUB_COMMAND = "npx -y @bridge_gpt/mcp-server connect-github";
|
|
630
|
+
/**
|
|
631
|
+
* The `Step 4b` label + purpose + deferral lines printed immediately before the
|
|
632
|
+
* GitHub prompt (BAPI-669, U6). Shared with {@link buildLaunchStepPreview} so the
|
|
633
|
+
* live run and the `--dry-run` preview can never advertise different behavior.
|
|
634
|
+
*/
|
|
635
|
+
export const INSTALL_BRIDGE_GITHUB_OFFER_CONTEXT = [
|
|
636
|
+
"",
|
|
637
|
+
"Step 4b — optional: connect GitHub.",
|
|
638
|
+
" This installs the Bridge GitHub App so pull requests and code review work. It opens",
|
|
639
|
+
" github.com in your browser; no GitHub credential is shared with Bridge.",
|
|
640
|
+
` You can do this later instead: ${INSTALL_BRIDGE_CONNECT_GITHUB_COMMAND}`,
|
|
641
|
+
];
|
|
642
|
+
/** Default-No GitHub offer prompt (BAPI-669, U6 — flipped from the old `(Y/n)`). */
|
|
643
|
+
export const INSTALL_BRIDGE_GITHUB_CONNECT_PROMPT = "Connect GitHub? [y/N]: ";
|
|
567
644
|
/**
|
|
568
645
|
* Offer to connect GitHub, if it is not already connected (BAPI-631).
|
|
569
646
|
*
|
|
@@ -574,7 +651,7 @@ export function promptSecretViaReadline(promptText, input = process.stdin, outpu
|
|
|
574
651
|
* Reuses the shared connect-github flow rather than duplicating the API, polling,
|
|
575
652
|
* browser, or picker logic — there is exactly one implementation of that handshake.
|
|
576
653
|
*/
|
|
577
|
-
async function offerGithubConnection(repoName, deps, log) {
|
|
654
|
+
export async function offerGithubConnection(repoName, baseUrl, deps, log) {
|
|
578
655
|
// No prompt surface → no offer. Never assume consent on a non-interactive run.
|
|
579
656
|
if (!deps.isTTY || !deps.promptLine)
|
|
580
657
|
return;
|
|
@@ -595,8 +672,11 @@ async function offerGithubConnection(repoName, deps, log) {
|
|
|
595
672
|
if (!cred.ok)
|
|
596
673
|
return;
|
|
597
674
|
const api = {
|
|
675
|
+
// BAPI-668 (R9-2): the ALREADY-VALIDATED base URL is passed in. This helper
|
|
676
|
+
// deliberately no longer re-derives it from `deps.env` — normalization has
|
|
677
|
+
// exactly one point, at the CLI entry boundary.
|
|
598
678
|
fetch: deps.fetch,
|
|
599
|
-
baseUrl
|
|
679
|
+
baseUrl,
|
|
600
680
|
apiKey: cred.credentials.apiKey,
|
|
601
681
|
};
|
|
602
682
|
const state = await fetchGithubConfigurationState(api, repoName);
|
|
@@ -608,8 +688,14 @@ async function offerGithubConnection(repoName, deps, log) {
|
|
|
608
688
|
log(" note: could not read GitHub configuration status; skipping the GitHub offer.");
|
|
609
689
|
return;
|
|
610
690
|
}
|
|
611
|
-
|
|
612
|
-
|
|
691
|
+
// BAPI-669 (U6): explain the action BEFORE asking, and default to No. Opening a
|
|
692
|
+
// browser is a side effect the user should opt IN to, so a bare Enter now skips.
|
|
693
|
+
for (const line of INSTALL_BRIDGE_GITHUB_OFFER_CONTEXT)
|
|
694
|
+
log(line);
|
|
695
|
+
const answer = (await deps.promptLine(INSTALL_BRIDGE_GITHUB_CONNECT_PROMPT)).trim().toLowerCase();
|
|
696
|
+
// Only an explicit yes proceeds — blank input, EOF, "n", and anything malformed
|
|
697
|
+
// all skip without opening a browser.
|
|
698
|
+
if (answer !== "y" && answer !== "yes")
|
|
613
699
|
return; // declining is a normal outcome
|
|
614
700
|
const connectDeps = createDefaultConnectGithubDeps();
|
|
615
701
|
const code = await runGithubConnectionFlow(connectDeps, api, repoName);
|
|
@@ -738,11 +824,20 @@ export function createDefaultInstallBridgeDeps() {
|
|
|
738
824
|
prepareBootstrapPending: prepareBootstrapPendingCredential,
|
|
739
825
|
repointBootstrapPending: repointBootstrapPendingCredential,
|
|
740
826
|
promoteBootstrapPending: promoteBootstrapPendingCredential,
|
|
827
|
+
lookupSelfServeBootstrapPending: lookupSelfServeBootstrapPendingCredential,
|
|
828
|
+
discardBootstrapPending: discardBootstrapPendingCredential,
|
|
741
829
|
buildShellCommand: buildGenericAgentShellCommand,
|
|
742
830
|
spawnTerminalTab: getDefaultSpawnTerminalTabForPlatform(process.platform),
|
|
743
831
|
startTicketsDeps: createDefaultStartTicketsDeps(),
|
|
744
832
|
log: (m) => console.log(m),
|
|
745
833
|
errorLog: (m) => console.error(m),
|
|
834
|
+
// Debug-only sink (BAPI-666): gated on BAPI_INSTALL_DEBUG so it is silent on a
|
|
835
|
+
// normal install and never pollutes stdout/stderr. Emits to stderr (never
|
|
836
|
+
// stdout) so any diagnostic can never be mistaken for machine-readable output.
|
|
837
|
+
debugLog: (m) => {
|
|
838
|
+
if (process.env.BAPI_INSTALL_DEBUG)
|
|
839
|
+
console.error(m);
|
|
840
|
+
},
|
|
746
841
|
};
|
|
747
842
|
}
|
|
748
843
|
/**
|
|
@@ -750,6 +845,10 @@ export function createDefaultInstallBridgeDeps() {
|
|
|
750
845
|
* interactive no-echo prompt. Fails (secret-free) when none is available and
|
|
751
846
|
* stdin is non-interactive.
|
|
752
847
|
*
|
|
848
|
+
* Returns WHICH source won alongside the value (BAPI-668, R11-1), so a rejected
|
|
849
|
+
* credential can name where it came from. The source is a safe label; the value
|
|
850
|
+
* is never printed anywhere.
|
|
851
|
+
*
|
|
753
852
|
* Despite the name, this is CREDENTIAL-AGNOSTIC (BAPI-661): the returned value may
|
|
754
853
|
* be a full Bridge API key or a bootstrap-invite token (`bapi_inv_…`) — the caller
|
|
755
854
|
* classifies it with {@link classifyEnteredCredential} immediately after this
|
|
@@ -759,27 +858,29 @@ export function createDefaultInstallBridgeDeps() {
|
|
|
759
858
|
*/
|
|
760
859
|
export async function resolveApiKey(options, deps) {
|
|
761
860
|
if (typeof options.apiKey === "string" && options.apiKey.trim().length > 0) {
|
|
762
|
-
return { ok: true, value: options.apiKey.trim() };
|
|
861
|
+
return { ok: true, value: options.apiKey.trim(), source: "flag" };
|
|
763
862
|
}
|
|
764
863
|
const fromEnv = deps.env.BAPI_API_KEY;
|
|
765
864
|
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
|
|
766
|
-
return { ok: true, value: fromEnv.trim() };
|
|
865
|
+
return { ok: true, value: fromEnv.trim(), source: "env" };
|
|
767
866
|
}
|
|
768
867
|
if (deps.isTTY && deps.promptSecret) {
|
|
769
868
|
const entered = (await deps.promptSecret("Bridge API key or invite (input hidden): ")).trim();
|
|
770
869
|
if (entered.length > 0) {
|
|
771
|
-
return { ok: true, value: entered };
|
|
870
|
+
return { ok: true, value: entered, source: "prompt" };
|
|
772
871
|
}
|
|
872
|
+
// BAPI-667: the old copy ended with "try the hidden prompt again", which was
|
|
873
|
+
// simply false — the process exits here, so there is no prompt left to retry —
|
|
874
|
+
// and it named no route usable by someone who has no key at all.
|
|
773
875
|
return {
|
|
774
876
|
ok: false,
|
|
775
|
-
error:
|
|
776
|
-
"variable, or try the hidden prompt again.",
|
|
877
|
+
error: `No Bridge API key or invite entered. ${INSTALL_BRIDGE_ENTRY_ROUTES_ADVICE}`,
|
|
777
878
|
};
|
|
778
879
|
}
|
|
779
880
|
return {
|
|
780
881
|
ok: false,
|
|
781
|
-
error: "A Bridge API key or invite is required
|
|
782
|
-
|
|
882
|
+
error: "A Bridge API key or invite is required (no interactive terminal is available to prompt " +
|
|
883
|
+
`for it). ${INSTALL_BRIDGE_ENTRY_ROUTES_ADVICE}`,
|
|
783
884
|
};
|
|
784
885
|
}
|
|
785
886
|
/**
|
|
@@ -884,8 +985,40 @@ export function resolveInstallBridgeOnboardingBranch(options, env) {
|
|
|
884
985
|
return { kind: "need-key", method: "self-serve" };
|
|
885
986
|
return { kind: "have-key" };
|
|
886
987
|
}
|
|
887
|
-
/**
|
|
888
|
-
|
|
988
|
+
/**
|
|
989
|
+
* The bare-TTY onboarding chooser (BAPI-667), replacing the BAPI-626/661 Y/n
|
|
990
|
+
* question. The old prompt defaulted a bare Enter to "yes" and dropped the newest
|
|
991
|
+
* users — the ones with no key, no invite, and no account — into a hidden API-key
|
|
992
|
+
* prompt they could not answer, then exited. The self-serve path built for exactly
|
|
993
|
+
* that user was never named. The chooser names all three routes and has NO default,
|
|
994
|
+
* so Enter can no longer route anyone into a prompt they cannot satisfy.
|
|
995
|
+
*
|
|
996
|
+
* Interaction contract mirrors the BAPI-663 tool picker: render the heading and the
|
|
997
|
+
* numbered options once, accept ONE strict line.
|
|
998
|
+
*/
|
|
999
|
+
export const INSTALL_BRIDGE_KEY_SELECTOR_PROMPT = "How would you like to connect to Bridge API?";
|
|
1000
|
+
/** The chooser's numbered options, in render order. Index + 1 is the accepted token. */
|
|
1001
|
+
export const INSTALL_BRIDGE_ONBOARDING_CHOICES = [
|
|
1002
|
+
"1. I have a Bridge API key",
|
|
1003
|
+
"2. I have an invite token",
|
|
1004
|
+
"3. I'm new — set me up with just my email",
|
|
1005
|
+
];
|
|
1006
|
+
/** The single-line answer prompt drawn after the options (no bracketed default). */
|
|
1007
|
+
export const INSTALL_BRIDGE_ONBOARDING_CHOICE_PROMPT = "Enter 1, 2, or 3: ";
|
|
1008
|
+
/** The one hint line printed after a blank/invalid answer, before the single re-prompt. */
|
|
1009
|
+
export const INSTALL_BRIDGE_ONBOARDING_CHOICE_HINT = "Enter 1, 2, or 3.";
|
|
1010
|
+
/**
|
|
1011
|
+
* The one place the "no usable credential" copy lives (BAPI-667, U1-3).
|
|
1012
|
+
*
|
|
1013
|
+
* Centralized because the chooser failure and {@link resolveApiKey}'s two failure
|
|
1014
|
+
* branches previously drifted: only one of them mentioned self-serve, and one
|
|
1015
|
+
* advised "try the hidden prompt again" — false, because the process has already
|
|
1016
|
+
* exited by the time the user reads it. All three now name the SAME three routes.
|
|
1017
|
+
*/
|
|
1018
|
+
export const INSTALL_BRIDGE_ENTRY_ROUTES_ADVICE = "Re-run install-bridge and choose an option: pass --api-key <key> if you have a Bridge API " +
|
|
1019
|
+
"key (or set BAPI_API_KEY), --invite if you were sent an invite token, or --email <addr> " +
|
|
1020
|
+
"to sign up with just an email — on a bare interactive run, re-run and choose option 3 to " +
|
|
1021
|
+
"sign up with just an email.";
|
|
889
1022
|
/**
|
|
890
1023
|
* Interactive wrapper around {@link resolveInstallBridgeOnboardingBranch}.
|
|
891
1024
|
*
|
|
@@ -906,6 +1039,13 @@ export const INSTALL_BRIDGE_KEY_SELECTOR_PROMPT = "Do you have a Bridge API key
|
|
|
906
1039
|
* A prompt failure becomes a typed, secret-free failure rather than an exception:
|
|
907
1040
|
* the caller turns it into an exit code, and terminal/internal error text never
|
|
908
1041
|
* reaches the user.
|
|
1042
|
+
*
|
|
1043
|
+
* BAPI-667 replaced the Y/n question with a three-option chooser. Two properties
|
|
1044
|
+
* are load-bearing: there is NO default (a bare Enter can no longer route a
|
|
1045
|
+
* brand-new user into the hidden key prompt), and option 3 names the self-serve
|
|
1046
|
+
* route explicitly so it is discoverable without reading `--help`. Validation is
|
|
1047
|
+
* bounded to ONE re-prompt — an unanswerable prompt that loops is the same
|
|
1048
|
+
* dead-end in a different costume.
|
|
909
1049
|
*/
|
|
910
1050
|
export async function resolveInstallBridgeOnboardingBranchForRun(options, deps, argv) {
|
|
911
1051
|
const branch = resolveInstallBridgeOnboardingBranch(options, deps.env);
|
|
@@ -919,30 +1059,39 @@ export async function resolveInstallBridgeOnboardingBranchForRun(options, deps,
|
|
|
919
1059
|
}
|
|
920
1060
|
const promptLine = deps.promptLine;
|
|
921
1061
|
try {
|
|
922
|
-
//
|
|
923
|
-
//
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
1062
|
+
// Render the heading + options EXACTLY once; a re-prompt after an invalid
|
|
1063
|
+
// answer re-asks the question without redrawing the list (BAPI-663 contract).
|
|
1064
|
+
deps.log(INSTALL_BRIDGE_KEY_SELECTOR_PROMPT);
|
|
1065
|
+
for (const choice of INSTALL_BRIDGE_ONBOARDING_CHOICES)
|
|
1066
|
+
deps.log(choice);
|
|
1067
|
+
// Bounded to two reads total: the first answer, then ONE re-prompt after a
|
|
1068
|
+
// single hint line. EOF resolves as an empty answer (promptLineViaReadline
|
|
1069
|
+
// resolves "" on close), so it costs one re-prompt and then fails cleanly —
|
|
1070
|
+
// it can never hang or silently select a branch.
|
|
1071
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1072
|
+
const answer = (await promptLine(INSTALL_BRIDGE_ONBOARDING_CHOICE_PROMPT)).trim();
|
|
1073
|
+
// Option 1 stays credential-agnostic: a `bapi_inv_…` value pasted into the
|
|
1074
|
+
// hidden key prompt is still reclassified downstream (BAPI-661).
|
|
1075
|
+
if (answer === "1")
|
|
928
1076
|
return { ok: true, branch: { kind: "have-key" } };
|
|
1077
|
+
if (answer === "2") {
|
|
1078
|
+
return { ok: true, branch: { kind: "need-key", method: "bootstrap-invite" } };
|
|
929
1079
|
}
|
|
930
|
-
if (answer === "
|
|
1080
|
+
if (answer === "3")
|
|
931
1081
|
return { ok: true, branch: { kind: "need-key", method: "self-serve" } };
|
|
932
|
-
|
|
933
|
-
|
|
1082
|
+
if (attempt === 0)
|
|
1083
|
+
deps.log(INSTALL_BRIDGE_ONBOARDING_CHOICE_HINT);
|
|
934
1084
|
}
|
|
935
1085
|
return {
|
|
936
1086
|
ok: false,
|
|
937
|
-
error:
|
|
1087
|
+
error: `No option was selected. ${INSTALL_BRIDGE_ENTRY_ROUTES_ADVICE}`,
|
|
938
1088
|
};
|
|
939
1089
|
}
|
|
940
1090
|
catch {
|
|
941
1091
|
// Secret-free by construction: the caught value is never surfaced.
|
|
942
1092
|
return {
|
|
943
1093
|
ok: false,
|
|
944
|
-
error:
|
|
945
|
-
"Bridge API key, or --email <addr> to create a new Bridge workspace.",
|
|
1094
|
+
error: `Could not read your answer from the terminal. ${INSTALL_BRIDGE_ENTRY_ROUTES_ADVICE}`,
|
|
946
1095
|
};
|
|
947
1096
|
}
|
|
948
1097
|
}
|
|
@@ -1194,12 +1343,17 @@ export function resolveInstallBridgeLaunchDecision(selectedPlatforms, explicitAg
|
|
|
1194
1343
|
* The human-facing tool label for a launch agent, derived by scanning the host
|
|
1195
1344
|
* registry's {@link HostTargetDefinition.launchAgent} metadata rather than keeping a
|
|
1196
1345
|
* duplicate agent→label map (BAPI-657). Returns e.g. `"Claude Code"` for `"claude"`
|
|
1197
|
-
* and `"Cursor"` for `"cursor-agent"
|
|
1198
|
-
*
|
|
1346
|
+
* and `"Cursor"` for `"cursor-agent"`.
|
|
1347
|
+
*
|
|
1348
|
+
* BAPI-669 (U3): the fallback is NEUTRAL rather than the raw agent binary name. This
|
|
1349
|
+
* function's output is user-facing copy ("open a session in …"), so an unregistered
|
|
1350
|
+
* agent must degrade to generic prose instead of leaking a binary name into a
|
|
1351
|
+
* sentence that reads as a product name. Never expected for a registered agent.
|
|
1199
1352
|
*/
|
|
1353
|
+
export const UNKNOWN_LAUNCH_TOOL_LABEL = "your AI coding tool";
|
|
1200
1354
|
export function toolLabelForLaunchAgent(agent) {
|
|
1201
1355
|
const target = allHostTargets().find((t) => t.launchAgent === agent);
|
|
1202
|
-
return target?.label ??
|
|
1356
|
+
return target?.label ?? UNKNOWN_LAUNCH_TOOL_LABEL;
|
|
1203
1357
|
}
|
|
1204
1358
|
/**
|
|
1205
1359
|
* Interactive numbered chooser for when MORE THAN ONE launchable tool was selected
|
|
@@ -1424,58 +1578,244 @@ export function buildInstallBridgeServerEntry(cwd, repoName, apiKey, baseUrl, do
|
|
|
1424
1578
|
};
|
|
1425
1579
|
return { command: entry.command, args: entry.args, env };
|
|
1426
1580
|
}
|
|
1427
|
-
/**
|
|
1581
|
+
/**
|
|
1582
|
+
* Build the per-host `bridge-api` MCP entry WITHOUT an API key (BAPI-666). Same
|
|
1583
|
+
* version-pinned launcher and non-secret env (repo name, base URL, docs dir,
|
|
1584
|
+
* project root) as {@link buildInstallBridgeServerEntry}, but `BAPI_API_KEY` is
|
|
1585
|
+
* OMITTED ENTIRELY — never set to `YOUR_API_KEY` or `<REDACTED>`. A placeholder
|
|
1586
|
+
* key literal in the config would OVERRIDE credential-store resolution at runtime,
|
|
1587
|
+
* so the key is left absent and the server self-resolves it from the environment
|
|
1588
|
+
* or the user-scoped store. This is the safe entry for a git-tracked target the
|
|
1589
|
+
* user declined (or could not be prompted) to write a real key into.
|
|
1590
|
+
*/
|
|
1591
|
+
export function buildInstallBridgeSecretFreeServerEntry(cwd, repoName, baseUrl, docsDir) {
|
|
1592
|
+
const entry = buildBridgeApiEntry(cwd);
|
|
1593
|
+
const env = {
|
|
1594
|
+
...entry.env,
|
|
1595
|
+
BAPI_REPO_NAME: repoName,
|
|
1596
|
+
BAPI_BASE_URL: baseUrl,
|
|
1597
|
+
BAPI_DOCS_DIR: docsDir,
|
|
1598
|
+
};
|
|
1599
|
+
// buildBridgeApiEntry is already secret-free; delete defensively so a future
|
|
1600
|
+
// change to the scaffold entry can never smuggle a key into the secret-free path.
|
|
1601
|
+
delete env.BAPI_API_KEY;
|
|
1602
|
+
return { command: entry.command, args: entry.args, env };
|
|
1603
|
+
}
|
|
1604
|
+
/**
|
|
1605
|
+
* Fixed, secret-free credential-store guidance (BAPI-666). Names the resolved
|
|
1606
|
+
* user-scoped credentials path and the safe target id `bapi:<repoName>` so a user
|
|
1607
|
+
* whose config did not receive the real key knows exactly where the server WILL
|
|
1608
|
+
* resolve it from at runtime. Carries no API key, invite, or `key_secret` value.
|
|
1609
|
+
*/
|
|
1610
|
+
function formatCredentialStoreGuidance(repoName, credentialStorePath) {
|
|
1611
|
+
return [
|
|
1612
|
+
" The bridge-api server resolves your key at runtime from the BAPI_API_KEY environment",
|
|
1613
|
+
` variable or the user-scoped credential store (${credentialStorePath}, target`,
|
|
1614
|
+
` bapi:${repoName}), so it stays out of this file entirely.`,
|
|
1615
|
+
];
|
|
1616
|
+
}
|
|
1617
|
+
/**
|
|
1618
|
+
* Render the secret-free `bridge-api` entry as a MERGE-ME snippet for one host
|
|
1619
|
+
* target (BAPI-666). Uses the target's real root key (`mcpServers` for Claude /
|
|
1620
|
+
* Cursor, `servers` for VS Code) and the secret-free entry ONLY, so the API key
|
|
1621
|
+
* can never enter the generated snippet. The snippet is for MERGING into the
|
|
1622
|
+
* existing file — never for replacing it.
|
|
1623
|
+
*/
|
|
1624
|
+
function formatSecretFreeManualMerge(relPath, topLevelKey, secretFreeEntry) {
|
|
1625
|
+
const snippet = JSON.stringify({ [topLevelKey]: { "bridge-api": secretFreeEntry } }, null, 2);
|
|
1626
|
+
return [
|
|
1627
|
+
` To configure ${relPath} by hand, MERGE this secret-free entry into the existing`,
|
|
1628
|
+
" file (do not replace the file):",
|
|
1629
|
+
snippet,
|
|
1630
|
+
];
|
|
1631
|
+
}
|
|
1632
|
+
/**
|
|
1633
|
+
* Fixed, secret-free wording for a present-but-unparseable host config (BAPI-666).
|
|
1634
|
+
* Names only the relative path and states it was left untouched — never the raw
|
|
1635
|
+
* content or the caught parser/filesystem exception text.
|
|
1636
|
+
*/
|
|
1637
|
+
function formatInvalidConfigNotice(relPath) {
|
|
1638
|
+
return ` ${relPath} could not be parsed safely — it was left untouched.`;
|
|
1639
|
+
}
|
|
1640
|
+
/**
|
|
1641
|
+
* Read + classify an existing host config into `absent | invalid | parsed`
|
|
1642
|
+
* (BAPI-666). Only an `ENOENT` read failure is `absent`; every other read error,
|
|
1643
|
+
* JSON parse failure (comments, trailing commas, truncation), and a `null` /
|
|
1644
|
+
* array / non-object JSON root is `invalid` so an unreadable-but-present file is
|
|
1645
|
+
* never overwritten as though it were missing.
|
|
1646
|
+
*/
|
|
1428
1647
|
async function readHostConfig(deps, fullPath) {
|
|
1429
1648
|
let raw;
|
|
1430
1649
|
try {
|
|
1431
1650
|
raw = await deps.readFile(fullPath);
|
|
1432
1651
|
}
|
|
1433
|
-
catch {
|
|
1434
|
-
|
|
1435
|
-
|
|
1652
|
+
catch (err) {
|
|
1653
|
+
// A missing file is the only benign, freshly-writable state. Any other read
|
|
1654
|
+
// failure (permissions, I/O) is treated as `invalid` so we never clobber a
|
|
1655
|
+
// present-but-unreadable file. Do NOT carry the exception text forward.
|
|
1656
|
+
if (err?.code === "ENOENT")
|
|
1657
|
+
return { state: "absent" };
|
|
1658
|
+
return { state: "invalid" };
|
|
1659
|
+
}
|
|
1660
|
+
let parsed;
|
|
1436
1661
|
try {
|
|
1437
|
-
|
|
1438
|
-
return parsed && typeof parsed === "object" ? parsed : null;
|
|
1662
|
+
parsed = JSON.parse(raw);
|
|
1439
1663
|
}
|
|
1440
1664
|
catch {
|
|
1441
|
-
return
|
|
1665
|
+
return { state: "invalid" };
|
|
1666
|
+
}
|
|
1667
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1668
|
+
return { state: "invalid" };
|
|
1442
1669
|
}
|
|
1670
|
+
return { state: "parsed", config: parsed };
|
|
1671
|
+
}
|
|
1672
|
+
/** Narrow an unknown value to a plain (non-array) record, else `undefined`. */
|
|
1673
|
+
function asRecord(value) {
|
|
1674
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
1675
|
+
? value
|
|
1676
|
+
: undefined;
|
|
1443
1677
|
}
|
|
1444
1678
|
/**
|
|
1445
1679
|
* Detect whether any resolved host config already carries a real (non-placeholder)
|
|
1446
1680
|
* `BAPI_API_KEY` for the `bridge-api` server — the trigger for overwrite consent.
|
|
1681
|
+
* An `invalid` (present-but-unsafe) config conservatively returns `true` so the
|
|
1682
|
+
* overwrite-consent gate assumes a real key MAY be present rather than silently
|
|
1683
|
+
* replacing a file we could not inspect (BAPI-666).
|
|
1447
1684
|
*/
|
|
1448
1685
|
async function detectExistingRealKey(deps, targets) {
|
|
1449
1686
|
for (const target of targets) {
|
|
1450
|
-
const
|
|
1451
|
-
|
|
1452
|
-
|
|
1687
|
+
const result = await readHostConfig(deps, path.join(deps.cwd, target.relPath));
|
|
1688
|
+
if (result.state === "invalid")
|
|
1689
|
+
return true;
|
|
1690
|
+
if (result.state === "absent")
|
|
1691
|
+
continue;
|
|
1692
|
+
const topLevel = asRecord(result.config[target.topLevelKey]);
|
|
1693
|
+
const entry = asRecord(topLevel?.["bridge-api"]);
|
|
1694
|
+
const env = asRecord(entry?.["env"]);
|
|
1695
|
+
if (env && !isPlaceholderApiKey(env["BAPI_API_KEY"])) {
|
|
1453
1696
|
return true;
|
|
1454
1697
|
}
|
|
1455
1698
|
}
|
|
1456
1699
|
return false;
|
|
1457
1700
|
}
|
|
1701
|
+
/** The fixed, secret-free hard warning shown for a git-tracked project config (BAPI-666). */
|
|
1702
|
+
function trackedConfigWarning(relPath) {
|
|
1703
|
+
return `${relPath} is tracked by git — writing your API key here would commit it on your next push`;
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* Read-only probe: is `relPath` already tracked by git in the project (BAPI-666)?
|
|
1707
|
+
* Runs `git ls-files --error-unmatch -- <relPath>` through the injected, list-based
|
|
1708
|
+
* command seam (never a shell string) with `cwd = deps.cwd`. ONLY exit code 0 means
|
|
1709
|
+
* tracked; every other outcome — a non-zero exit (untracked / gitignored), a non-git
|
|
1710
|
+
* directory, or a thrown command failure — is treated as NOT tracked, preserving the
|
|
1711
|
+
* required fail-open behavior (a probe failure must never block the install). Emits at
|
|
1712
|
+
* most a fixed debug-level line (safe relative path + exit classification) and never
|
|
1713
|
+
* the command's stdout/stderr, exception text, env, or config content.
|
|
1714
|
+
*/
|
|
1715
|
+
async function isTrackedProjectConfig(deps, relPath) {
|
|
1716
|
+
try {
|
|
1717
|
+
const result = await deps.startTicketsDeps.runCommand("git", ["ls-files", "--error-unmatch", "--", relPath], { cwd: deps.cwd });
|
|
1718
|
+
if (result.exitCode === 0)
|
|
1719
|
+
return true;
|
|
1720
|
+
deps.debugLog(`install-bridge: git-tracked probe for ${relPath} → not tracked (exit ${result.exitCode})`);
|
|
1721
|
+
return false;
|
|
1722
|
+
}
|
|
1723
|
+
catch {
|
|
1724
|
+
deps.debugLog(`install-bridge: git-tracked probe for ${relPath} → not tracked (probe failed)`);
|
|
1725
|
+
return false;
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
/**
|
|
1729
|
+
* Interactively ask whether to write the REAL API key into a git-tracked config
|
|
1730
|
+
* (BAPI-666). Prints the required hard warning, then a default-No y/N prompt.
|
|
1731
|
+
* Returns `true` ONLY for an explicit `y`/`yes` (trimmed, case-insensitive). A
|
|
1732
|
+
* missing prompt seam, blank / negative / malformed answer, or a prompt exception
|
|
1733
|
+
* all DECLINE. Independent of --force: --force authorizes credential replacement,
|
|
1734
|
+
* not disclosure of the key into version control.
|
|
1735
|
+
*/
|
|
1736
|
+
async function requestTrackedConfigConsent(deps, relPath) {
|
|
1737
|
+
deps.errorLog(trackedConfigWarning(relPath));
|
|
1738
|
+
if (!deps.promptLine)
|
|
1739
|
+
return false;
|
|
1740
|
+
let answer;
|
|
1741
|
+
try {
|
|
1742
|
+
answer = await deps.promptLine(`Write your API key into ${relPath} anyway? (y/N) `);
|
|
1743
|
+
}
|
|
1744
|
+
catch {
|
|
1745
|
+
return false;
|
|
1746
|
+
}
|
|
1747
|
+
const normalized = (answer ?? "").trim().toLowerCase();
|
|
1748
|
+
return normalized === "y" || normalized === "yes";
|
|
1749
|
+
}
|
|
1458
1750
|
/**
|
|
1459
1751
|
* Write the `bridge-api` entry into each host config via read-merge-write,
|
|
1460
|
-
* preserving unrelated servers and top-level keys.
|
|
1461
|
-
*
|
|
1752
|
+
* preserving unrelated servers and top-level keys (BAPI-666). Per target:
|
|
1753
|
+
*
|
|
1754
|
+
* - `invalid` (present but unparseable/unsafe): leave the file byte-identical (no
|
|
1755
|
+
* mkdir, no write), emit a loud secret-free notice + manual-merge instructions,
|
|
1756
|
+
* and record it under `skipped`. This is non-fatal — remaining targets continue.
|
|
1757
|
+
* - tracked by git: write a REAL key only after explicit TTY consent; otherwise
|
|
1758
|
+
* (interactive decline OR any non-TTY run) write the secret-free entry and print
|
|
1759
|
+
* the credential-store guidance + `git rm --cached` remediation. Tracked-file
|
|
1760
|
+
* consent is evaluated only AFTER the invalid check, so a to-be-skipped file
|
|
1761
|
+
* never receives a misleading write-consent prompt.
|
|
1762
|
+
* - untracked (the common freshly-gitignored case): write the real key.
|
|
1462
1763
|
*/
|
|
1463
|
-
async function writeHostConfigs(deps, targets,
|
|
1764
|
+
async function writeHostConfigs(deps, targets, entries, trackedState, ctx) {
|
|
1464
1765
|
const written = [];
|
|
1766
|
+
const skipped = [];
|
|
1465
1767
|
for (const target of targets) {
|
|
1466
1768
|
const fullPath = path.join(deps.cwd, target.relPath);
|
|
1467
|
-
const
|
|
1468
|
-
|
|
1469
|
-
|
|
1769
|
+
const read = await readHostConfig(deps, fullPath);
|
|
1770
|
+
// (Step 5.16) Invalid state is evaluated BEFORE any tracked-file consent so a
|
|
1771
|
+
// file that will be skipped never triggers a misleading write-consent prompt.
|
|
1772
|
+
if (read.state === "invalid") {
|
|
1773
|
+
deps.errorLog(formatInvalidConfigNotice(target.relPath));
|
|
1774
|
+
for (const line of formatSecretFreeManualMerge(target.relPath, target.topLevelKey, entries.secretFree)) {
|
|
1775
|
+
deps.errorLog(line);
|
|
1776
|
+
}
|
|
1777
|
+
skipped.push({ relPath: target.relPath, reason: "invalid" });
|
|
1778
|
+
continue;
|
|
1470
1779
|
}
|
|
1471
|
-
|
|
1780
|
+
// Choose the entry variant. A real key only lands in an untracked target, or a
|
|
1781
|
+
// tracked target the user EXPLICITLY consented to on a TTY.
|
|
1782
|
+
const tracked = trackedState.get(target.relPath) ?? false;
|
|
1783
|
+
let entry = entries.real;
|
|
1784
|
+
let mode = "real-key";
|
|
1785
|
+
if (tracked) {
|
|
1786
|
+
const interactive = deps.isTTY && Boolean(deps.promptLine);
|
|
1787
|
+
const consented = interactive ? await requestTrackedConfigConsent(deps, target.relPath) : false;
|
|
1788
|
+
if (consented) {
|
|
1789
|
+
entry = entries.real;
|
|
1790
|
+
mode = "real-key";
|
|
1791
|
+
}
|
|
1792
|
+
else {
|
|
1793
|
+
entry = entries.secretFree;
|
|
1794
|
+
mode = "secret-free";
|
|
1795
|
+
// A non-TTY run never prompts; surface the same hard warning up front
|
|
1796
|
+
// (requestTrackedConfigConsent already printed it on the interactive path).
|
|
1797
|
+
if (!interactive)
|
|
1798
|
+
deps.errorLog(trackedConfigWarning(target.relPath));
|
|
1799
|
+
for (const line of formatCredentialStoreGuidance(ctx.repoName, ctx.credentialStorePath)) {
|
|
1800
|
+
deps.errorLog(line);
|
|
1801
|
+
}
|
|
1802
|
+
deps.errorLog(` To stop tracking it: git rm --cached -- ${target.relPath}`);
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
// Build the document. Preserve everything on a `parsed` config; start fresh only
|
|
1806
|
+
// for `absent` — decided AFTER the safety branches above so an absent file is
|
|
1807
|
+
// never initialized before its tracked-state consent is resolved.
|
|
1808
|
+
const config = read.state === "parsed" ? read.config : {};
|
|
1809
|
+
const topLevel = asRecord(config[target.topLevelKey]) ?? {};
|
|
1810
|
+
topLevel["bridge-api"] = entry;
|
|
1811
|
+
config[target.topLevelKey] = topLevel;
|
|
1472
1812
|
await deps.mkdir(path.dirname(fullPath), { recursive: true });
|
|
1473
|
-
await deps.writeFile(fullPath, JSON.stringify(
|
|
1813
|
+
await deps.writeFile(fullPath, JSON.stringify(config, null, 2) + "\n", {
|
|
1474
1814
|
encoding: "utf-8",
|
|
1475
1815
|
});
|
|
1476
|
-
written.push(target.relPath);
|
|
1816
|
+
written.push({ relPath: target.relPath, mode });
|
|
1477
1817
|
}
|
|
1478
|
-
return written;
|
|
1818
|
+
return { written, skipped };
|
|
1479
1819
|
}
|
|
1480
1820
|
/**
|
|
1481
1821
|
* Provision the selected GLOBAL (Codex, Copilot CLI) and MANUAL (Windsurf)
|
|
@@ -1484,9 +1824,15 @@ async function writeHostConfigs(deps, targets, entry) {
|
|
|
1484
1824
|
* config paths are never added to the repository .gitignore. Returns secret-free
|
|
1485
1825
|
* log lines and whether Codex was auto-provisioned (so the legacy Codex manual
|
|
1486
1826
|
* hint can be suppressed).
|
|
1827
|
+
*
|
|
1828
|
+
* `needKey` (BAPI-669, R8) is supplied only in a need-key run. When any selected
|
|
1829
|
+
* target ends up `manual-required` — Windsurf's always-manual path, or a Codex
|
|
1830
|
+
* config that declined an automatic merge — the credential-store line is appended
|
|
1831
|
+
* ONCE for the whole batch, so selecting both does not print it twice.
|
|
1487
1832
|
*/
|
|
1488
|
-
async function provisionSelectedGlobalTargets(deps, platforms, entry) {
|
|
1833
|
+
async function provisionSelectedGlobalTargets(deps, platforms, entry, needKey) {
|
|
1489
1834
|
const logLines = [];
|
|
1835
|
+
let anyManualRequired = false;
|
|
1490
1836
|
const provisionDeps = {
|
|
1491
1837
|
fs: {
|
|
1492
1838
|
readFile: deps.readFile,
|
|
@@ -1516,6 +1862,7 @@ async function provisionSelectedGlobalTargets(deps, platforms, entry) {
|
|
|
1516
1862
|
logLines.push(` configured ${target.label} (${outcome.displayPath})`);
|
|
1517
1863
|
break;
|
|
1518
1864
|
case "manual-required":
|
|
1865
|
+
anyManualRequired = true;
|
|
1519
1866
|
logLines.push(` ${target.label}: add the bridge-api MCP server manually to ${outcome.displayPath} ` +
|
|
1520
1867
|
"(the API key is redacted in printed instructions).");
|
|
1521
1868
|
break;
|
|
@@ -1527,6 +1874,9 @@ async function provisionSelectedGlobalTargets(deps, platforms, entry) {
|
|
|
1527
1874
|
break;
|
|
1528
1875
|
}
|
|
1529
1876
|
}
|
|
1877
|
+
if (needKey && anyManualRequired) {
|
|
1878
|
+
logLines.push(` ${formatNeedKeyCredentialStoreLine(needKey.repoName, needKey.credentialStorePath)}`);
|
|
1879
|
+
}
|
|
1530
1880
|
return logLines;
|
|
1531
1881
|
}
|
|
1532
1882
|
/** Build the `/jira/ping` URL exactly like the MCP `ping` tool / buildGetUrl. */
|
|
@@ -1544,13 +1894,30 @@ export function buildPingUrl(baseUrl, repoName) {
|
|
|
1544
1894
|
*/
|
|
1545
1895
|
const CONNECTIVITY_ACCESS_DENIED_FALLBACK = "The Bridge API denied access to this repository (HTTP 403). Verify the repo_name and that this " +
|
|
1546
1896
|
"credential is authorized for it.";
|
|
1897
|
+
/**
|
|
1898
|
+
* Source attribution appended to a 401 (BAPI-668, R11-2). Names WHERE the
|
|
1899
|
+
* rejected credential came from — the source is safe to print, the value is not —
|
|
1900
|
+
* so a user with a stale key exported in a shell profile is not left re-running
|
|
1901
|
+
* the same failing command. A prompt-sourced (or unattributed) key keeps today's
|
|
1902
|
+
* message byte-for-byte.
|
|
1903
|
+
*/
|
|
1904
|
+
const CONNECTIVITY_KEY_SOURCE_ATTRIBUTION = {
|
|
1905
|
+
env: " (this key came from the BAPI_API_KEY environment variable — unset it to be prompted for a " +
|
|
1906
|
+
"different one)",
|
|
1907
|
+
flag: " (this key came from the --api-key flag — omit it to be prompted for a different one)",
|
|
1908
|
+
prompt: "",
|
|
1909
|
+
};
|
|
1547
1910
|
/**
|
|
1548
1911
|
* Verify connectivity. Distinguishes a rejected/expired credential (401) from a
|
|
1549
1912
|
* repository-specific access denial (403, BAPI-661) from an unknown repo /
|
|
1550
1913
|
* not-found (404) where the response allows. The key is sent in the `X-API-Key`
|
|
1551
1914
|
* header and NEVER appears in any returned message.
|
|
1915
|
+
*
|
|
1916
|
+
* `apiKeySource` (BAPI-668) is supplied only on the ORDINARY API-key path. A
|
|
1917
|
+
* bootstrap-minted key is verified WITHOUT it, because attributing a
|
|
1918
|
+
* server-minted credential to `--api-key` or `BAPI_API_KEY` would be simply false.
|
|
1552
1919
|
*/
|
|
1553
|
-
export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
|
|
1920
|
+
export async function verifyConnectivity(deps, baseUrl, repoName, apiKey, apiKeySource) {
|
|
1554
1921
|
const url = buildPingUrl(baseUrl, repoName);
|
|
1555
1922
|
let resp;
|
|
1556
1923
|
try {
|
|
@@ -1574,11 +1941,16 @@ export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
|
|
|
1574
1941
|
if (resp.ok)
|
|
1575
1942
|
return { ok: true };
|
|
1576
1943
|
if (resp.status === 401) {
|
|
1944
|
+
// BAPI-669 (U8): the remediation names an ADDRESS, derived from the same
|
|
1945
|
+
// already-normalized `baseUrl` this function was handed — never a bare page name,
|
|
1946
|
+
// and never a re-read of BAPI_BASE_URL.
|
|
1947
|
+
const setupUrl = buildInstallBridgeSetupUrl(baseUrl);
|
|
1577
1948
|
return {
|
|
1578
1949
|
ok: false,
|
|
1579
1950
|
message: `The Bridge API rejected the credential (HTTP ${resp.status}). The API key may be invalid or expired ` +
|
|
1580
|
-
|
|
1581
|
-
"permission error.)"
|
|
1951
|
+
`— generate a fresh one at ${setupUrl} (Security page). (An expired token can also surface as a ` +
|
|
1952
|
+
"permission error.)" +
|
|
1953
|
+
(apiKeySource ? CONNECTIVITY_KEY_SOURCE_ATTRIBUTION[apiKeySource] : ""),
|
|
1582
1954
|
};
|
|
1583
1955
|
}
|
|
1584
1956
|
if (resp.status === 403) {
|
|
@@ -1680,10 +2052,15 @@ export function generateBootstrapKeySecret(randomBytes) {
|
|
|
1680
2052
|
return randomBytes(BOOTSTRAP_KEY_SECRET_BYTES).toString("base64url");
|
|
1681
2053
|
}
|
|
1682
2054
|
/**
|
|
1683
|
-
* SHA-256 hex digest of the bootstrap-invite token — the
|
|
1684
|
-
* pending record
|
|
1685
|
-
*
|
|
1686
|
-
*
|
|
2055
|
+
* SHA-256 hex digest of the bootstrap-invite token — the identity guard on every
|
|
2056
|
+
* pending record, so a retry can recognize "same invite, same repo" and reuse the
|
|
2057
|
+
* exact pending `key_secret` (the replay proof).
|
|
2058
|
+
*
|
|
2059
|
+
* For a USER-SUPPLIED invite the fingerprint is the only trace on disk. BAPI-667
|
|
2060
|
+
* additionally stores the token itself for an INTERNALLY MINTED self-serve invite
|
|
2061
|
+
* (which the user was never shown and cannot re-present) — the fingerprint remains
|
|
2062
|
+
* the identity guard in both shapes, and prepare / repoint / promote / discard all
|
|
2063
|
+
* still match on it.
|
|
1687
2064
|
*/
|
|
1688
2065
|
export function fingerprintBootstrapInvite(token) {
|
|
1689
2066
|
return createHash("sha256").update(token, "utf-8").digest("hex");
|
|
@@ -1712,12 +2089,15 @@ export async function exchangeBootstrapInvite(deps, baseUrl, token, repoName, ke
|
|
|
1712
2089
|
// The message may contain the request (some fetch impls echo it), so it is
|
|
1713
2090
|
// deliberately NOT interpolated here.
|
|
1714
2091
|
void err;
|
|
2092
|
+
// MODE-NEUTRAL (BAPI-667). This function cannot tell a `--invite` run from a
|
|
2093
|
+
// self-serve one, and the two need OPPOSITE retry instructions ("re-present the
|
|
2094
|
+
// same invite" vs. "choose the email option"), so it states only the coarse
|
|
2095
|
+
// fact. The caller appends {@link buildBootstrapRetryAdvice}.
|
|
1715
2096
|
return {
|
|
1716
2097
|
ok: false,
|
|
1717
2098
|
kind: "failed",
|
|
1718
2099
|
message: `Could not reach the Bridge API at ${baseUrl} to redeem the bootstrap invite. Check ` +
|
|
1719
|
-
"BAPI_BASE_URL and your network
|
|
1720
|
-
"re-run will reuse the same locally-stored secret.",
|
|
2100
|
+
"BAPI_BASE_URL and your network. The invite has not been used.",
|
|
1721
2101
|
};
|
|
1722
2102
|
}
|
|
1723
2103
|
if (resp.ok) {
|
|
@@ -1882,6 +2262,76 @@ export const BOOTSTRAP_INVITE_LOST_SECRET_MESSAGE = [
|
|
|
1882
2262
|
export const BOOTSTRAP_INVITE_REJECTED_MESSAGE = "The Bridge API rejected the bootstrap invite (HTTP 401). The invite is invalid, expired, or " +
|
|
1883
2263
|
"revoked — ask your Bridge API operator for a new one. (Your locally-stored secret was sent " +
|
|
1884
2264
|
"unchanged, so this is not a lost-secret problem.)";
|
|
2265
|
+
/**
|
|
2266
|
+
* The 401 explanation for a REPLAYED SELF-SERVE token (BAPI-667). Unlike the two
|
|
2267
|
+
* messages above this is not a dead end: the token was minted by this CLI, so it
|
|
2268
|
+
* can mint another one — but only after the user explicitly consents to discard
|
|
2269
|
+
* the expired record, because that record is the only replay proof for a signup
|
|
2270
|
+
* whose exchange may in fact have succeeded.
|
|
2271
|
+
*
|
|
2272
|
+
* Sanitized by construction: it names no token, no fingerprint, and no secret.
|
|
2273
|
+
*/
|
|
2274
|
+
export const BOOTSTRAP_SELF_SERVE_EXPIRED_MESSAGE = "The Bridge API rejected the stored signup invite (HTTP 401) — it is invalid, expired, or " +
|
|
2275
|
+
"revoked. Your saved signup attempt cannot be resumed with it.";
|
|
2276
|
+
/**
|
|
2277
|
+
* Mode-aware retry guidance (BAPI-667, R4-5), formatted in ONE place so a
|
|
2278
|
+
* self-serve user is never told to "re-run with the same bootstrap invite" — a
|
|
2279
|
+
* token they were never shown and cannot re-present.
|
|
2280
|
+
*
|
|
2281
|
+
* Secret-free by construction: it interpolates nothing.
|
|
2282
|
+
*/
|
|
2283
|
+
export function buildBootstrapRetryAdvice(selfServeSignupMode) {
|
|
2284
|
+
return selfServeSignupMode
|
|
2285
|
+
? "Re-run install-bridge and choose the email option — your previous attempt will resume."
|
|
2286
|
+
: "Re-run install-bridge with the same bootstrap invite — the redemption will replay and " +
|
|
2287
|
+
"return the same key.";
|
|
2288
|
+
}
|
|
2289
|
+
/**
|
|
2290
|
+
* Shared consent gate for a `credential-conflict` from `prepareBootstrapPending`
|
|
2291
|
+
* (BAPI-667).
|
|
2292
|
+
*
|
|
2293
|
+
* An existing `bapi:<repo>` credential is never clobbered without consent:
|
|
2294
|
+
* `--force`, else an interactive prompt, else a hard non-interactive failure —
|
|
2295
|
+
* always BEFORE the exchange, so nothing is consumed on either branch.
|
|
2296
|
+
*
|
|
2297
|
+
* This gate lives in ONE place because the self-serve and ordinary-invite arms
|
|
2298
|
+
* now prepare their own pending records (self-serve must additionally persist its
|
|
2299
|
+
* replay token in the same durable write). While the gate sat inline in whichever
|
|
2300
|
+
* arm happened to own the single shared call, gating that call behind
|
|
2301
|
+
* `if (!selfServeSignupMode)` silently dropped the prompt from the self-serve arm
|
|
2302
|
+
* — an `--email` / option-3 run against an already-provisioned repo hard-failed on
|
|
2303
|
+
* a TTY without ever offering the overwrite. Routing both arms through this helper
|
|
2304
|
+
* makes that drift impossible.
|
|
2305
|
+
*
|
|
2306
|
+
* `grantConsent` runs before the retry so the caller can record consent for its own
|
|
2307
|
+
* later repoint/promote writes. `retry` re-issues the CALLER'S prepare params with
|
|
2308
|
+
* `allowOverwriteExistingCredential: true`, so the self-serve arm keeps its
|
|
2309
|
+
* `selfServeReplayToken` on the retried write.
|
|
2310
|
+
*
|
|
2311
|
+
* Returns the result untouched when it is not a credential-conflict, the retried
|
|
2312
|
+
* result after consent, or `null` when the user declined or could not be asked —
|
|
2313
|
+
* in which case the reason is already logged and the caller returns non-zero.
|
|
2314
|
+
*
|
|
2315
|
+
* Secret-free: it names only the logical credential target.
|
|
2316
|
+
*/
|
|
2317
|
+
export async function resolveCredentialConflictConsent(prepared, ctx) {
|
|
2318
|
+
if (prepared.ok || prepared.kind !== "credential-conflict")
|
|
2319
|
+
return prepared;
|
|
2320
|
+
if (!ctx.isTTY || !ctx.promptLine) {
|
|
2321
|
+
ctx.errorLog(`Error: ${prepared.error} Re-run with --force to overwrite it (refusing to overwrite a ` +
|
|
2322
|
+
"credential non-interactively without consent).");
|
|
2323
|
+
return null;
|
|
2324
|
+
}
|
|
2325
|
+
const answer = (await ctx.promptLine(`The credential store already holds a key for ${prepared.target}. Overwrite it? [y/N]: `))
|
|
2326
|
+
.trim()
|
|
2327
|
+
.toLowerCase();
|
|
2328
|
+
if (answer !== "y" && answer !== "yes") {
|
|
2329
|
+
ctx.errorLog("Aborted: existing credential left unchanged (re-run with --force to overwrite).");
|
|
2330
|
+
return null;
|
|
2331
|
+
}
|
|
2332
|
+
ctx.grantConsent();
|
|
2333
|
+
return ctx.retry();
|
|
2334
|
+
}
|
|
1885
2335
|
/**
|
|
1886
2336
|
* Render the --dry-run preview lines. Every secret is ALWAYS redacted — the
|
|
1887
2337
|
* spawnCommand, config preview, and (in bootstrap-invite mode) the exchange body
|
|
@@ -1904,7 +2354,9 @@ export function buildDryRunPreview(plan) {
|
|
|
1904
2354
|
"",
|
|
1905
2355
|
"Step 1 — scaffold (runInit): commands, agents, pipelines, .bridge/config, secret-free MCP placeholders.",
|
|
1906
2356
|
`Step 2 — connectivity ping (before any durable key write): GET ${plan.pingUrl} (X-API-Key: ${REDACTED_API_KEY})`,
|
|
1907
|
-
"Step 3 — write per-host MCP config (read-merge-write, launcher version-pinned
|
|
2357
|
+
"Step 3 — write per-host MCP config (read-merge-write, launcher version-pinned;",
|
|
2358
|
+
" a git-tracked config needs default-No consent for the real key, else a",
|
|
2359
|
+
" secret-free entry; an unparseable config is skipped and left untouched):",
|
|
1908
2360
|
...plan.configTargets.map((t) => ` ${t}: BAPI_REPO_NAME=${plan.repoName}, BAPI_API_KEY=${REDACTED_API_KEY}, BAPI_BASE_URL=${plan.baseUrl}, BAPI_DOCS_DIR=${plan.docsDir}`),
|
|
1909
2361
|
...(plan.manualEditors.length > 0
|
|
1910
2362
|
? [` ${plan.manualEditors.join(" + ")}: detected (global config) — manual setup instructions would be printed.`]
|
|
@@ -1948,9 +2400,16 @@ function buildLaunchStepPreview(plan) {
|
|
|
1948
2400
|
// BAPI-631: described, never performed in --dry-run — a preview must not open a
|
|
1949
2401
|
// browser or reach the network. It is also strictly optional, so it carries no step
|
|
1950
2402
|
// number of its own and never changes the 5-step count.
|
|
2403
|
+
//
|
|
2404
|
+
// BAPI-669 (U6): the quoted prompt and the purpose/deferral context below MUST
|
|
2405
|
+
// stay in step with the live offer — the preview advertising the old `(Y/n)`
|
|
2406
|
+
// default while the run asked `[y/N]` is exactly the drift this fixes.
|
|
1951
2407
|
"Step 4b — optional GitHub connect (SKIPPED in --dry-run): read GitHub's configured state",
|
|
1952
2408
|
" via the install manifest and, only when it is unconfigured and the terminal is",
|
|
1953
|
-
|
|
2409
|
+
` interactive, offer '${INSTALL_BRIDGE_GITHUB_CONNECT_PROMPT.replace(/:\s*$/, "")}' before the agent session starts.`,
|
|
2410
|
+
" It installs the Bridge GitHub App so pull requests and code review work, opens",
|
|
2411
|
+
" github.com in your browser, and shares no GitHub credential with Bridge; it",
|
|
2412
|
+
` defaults to No and can be run later instead: ${INSTALL_BRIDGE_CONNECT_GITHUB_COMMAND}`,
|
|
1954
2413
|
];
|
|
1955
2414
|
if (plan.launch.kind === "spawn") {
|
|
1956
2415
|
return [
|
|
@@ -2001,9 +2460,12 @@ function buildBootstrapDryRunPreview(plan) {
|
|
|
2001
2460
|
const selfServeStep = plan.selfServeSignup
|
|
2002
2461
|
? [
|
|
2003
2462
|
"Step 2·pre — self-serve signup (PREVIEWED, SKIPPED in --dry-run): no Bridge workspace",
|
|
2004
|
-
" signup is requested, no mint call is made,
|
|
2005
|
-
|
|
2006
|
-
"
|
|
2463
|
+
" signup is requested, no mint call is made, no email is sent or transmitted, and",
|
|
2464
|
+
` ${pendingTarget} is neither read nor written;`,
|
|
2465
|
+
" a real run would first check that record and RESUME a saved attempt if one",
|
|
2466
|
+
" exists, otherwise request a fresh workspace for your email and durably store the",
|
|
2467
|
+
" returned invite token alongside the key_secret — which then feeds the SAME",
|
|
2468
|
+
" redemption protocol below.",
|
|
2007
2469
|
]
|
|
2008
2470
|
: [];
|
|
2009
2471
|
return [
|
|
@@ -2021,7 +2483,9 @@ function buildBootstrapDryRunPreview(plan) {
|
|
|
2021
2483
|
` POST ${plan.exchangeUrl}`,
|
|
2022
2484
|
` body: {"token": "${REDACTED_API_KEY}", "repo_name": "${plan.repoName}", "key_secret": "${REDACTED_API_KEY}"}`,
|
|
2023
2485
|
`Step 2c — connectivity ping with the newly-minted key (before any durable key write): GET ${plan.pingUrl} (X-API-Key: ${REDACTED_API_KEY})`,
|
|
2024
|
-
"Step 3 — write per-host MCP config (read-merge-write, launcher version-pinned
|
|
2486
|
+
"Step 3 — write per-host MCP config (read-merge-write, launcher version-pinned;",
|
|
2487
|
+
" a git-tracked config needs default-No consent for the real key, else a",
|
|
2488
|
+
" secret-free entry; an unparseable config is skipped and left untouched):",
|
|
2025
2489
|
...plan.configTargets.map((t) => ` ${t}: BAPI_REPO_NAME=${plan.repoName}, BAPI_API_KEY=${REDACTED_API_KEY}, BAPI_BASE_URL=${plan.baseUrl}, BAPI_DOCS_DIR=${plan.docsDir}`),
|
|
2026
2490
|
...(plan.manualEditors.length > 0
|
|
2027
2491
|
? [` ${plan.manualEditors.join(" + ")}: detected (global config) — manual setup instructions would be printed.`]
|
|
@@ -2064,12 +2528,29 @@ export function manualEditorNames(editors) {
|
|
|
2064
2528
|
names.push("Codex");
|
|
2065
2529
|
return names;
|
|
2066
2530
|
}
|
|
2531
|
+
/**
|
|
2532
|
+
* Where the key a need-key run just MINTED actually lives (BAPI-669, R8).
|
|
2533
|
+
*
|
|
2534
|
+
* A manual-setup path tells the user to paste `BAPI_API_KEY` into a global config —
|
|
2535
|
+
* but an invite / self-serve user has never seen their key, because the CLI minted
|
|
2536
|
+
* it and stored it. Without this line the instruction is unfollowable.
|
|
2537
|
+
*
|
|
2538
|
+
* SECRET DISCIPLINE (hard constraint): this formatter accepts NO key material. Its
|
|
2539
|
+
* parameters are a repo name and a filesystem path, both printable; it must never be
|
|
2540
|
+
* handed `apiKey`, `inviteToken`, `keySecret`, a signup email, or a credential record.
|
|
2541
|
+
*/
|
|
2542
|
+
export function formatNeedKeyCredentialStoreLine(repoName, credentialStorePath) {
|
|
2543
|
+
return `Your API key is stored at ${credentialStorePath} under "bapi:${repoName}" — copy BAPI_API_KEY from there.`;
|
|
2544
|
+
}
|
|
2067
2545
|
/**
|
|
2068
2546
|
* Build the manual-instruction block for the DETECTED global-config editors only
|
|
2069
2547
|
* (key always redacted). Returns `null` when neither Windsurf nor Codex is
|
|
2070
2548
|
* detected, so the caller prints nothing for users who don't use them.
|
|
2549
|
+
*
|
|
2550
|
+
* `needKey` (BAPI-669, R8) is supplied only for a bootstrap-invite, reclassified-invite,
|
|
2551
|
+
* or self-serve run — the three flows where the key was minted rather than supplied.
|
|
2071
2552
|
*/
|
|
2072
|
-
function buildManualHostInstructions(entry, editors) {
|
|
2553
|
+
function buildManualHostInstructions(entry, editors, needKey) {
|
|
2073
2554
|
if (!editors.windsurf && !editors.codex)
|
|
2074
2555
|
return null;
|
|
2075
2556
|
const redactedEnv = { ...entry.env, BAPI_API_KEY: REDACTED_API_KEY };
|
|
@@ -2085,9 +2566,252 @@ function buildManualHostInstructions(entry, editors) {
|
|
|
2085
2566
|
if (editors.codex) {
|
|
2086
2567
|
lines.push("", " Codex → ~/.codex/config.toml (add an [mcp_servers.bridge-api] table with the", " same command/args/env shown above, BAPI_API_KEY set to your key).");
|
|
2087
2568
|
}
|
|
2569
|
+
if (needKey) {
|
|
2570
|
+
lines.push("", ` ${formatNeedKeyCredentialStoreLine(needKey.repoName, needKey.credentialStorePath)}`);
|
|
2571
|
+
}
|
|
2088
2572
|
return lines.join("\n");
|
|
2089
2573
|
}
|
|
2090
2574
|
// ---------------------------------------------------------------------------
|
|
2575
|
+
// Secret-safe failure classification + reporting (BAPI-668)
|
|
2576
|
+
// ---------------------------------------------------------------------------
|
|
2577
|
+
/**
|
|
2578
|
+
* The single diagnosis pointer every install failure block ends with. Defined
|
|
2579
|
+
* ONCE and reused by the Step 1–5 catch, the `index.ts` dispatch catch, the
|
|
2580
|
+
* tests, and the feature documentation, so the command text cannot drift.
|
|
2581
|
+
*/
|
|
2582
|
+
export const INSTALL_BRIDGE_DOCTOR_COMMAND = "npx -y @bridge_gpt/mcp-server doctor";
|
|
2583
|
+
/**
|
|
2584
|
+
* The canonical pointer LINE (BAPI-669, U9a). One syntax — no quotes, no alternate
|
|
2585
|
+
* punctuation — shared by the Step 1–5 catch and by every operational fatal in the
|
|
2586
|
+
* ping / exchange / network / self-serve regions, so a user sees the same sentence
|
|
2587
|
+
* wherever an install dies. It must appear EXACTLY ONCE per fatal exit.
|
|
2588
|
+
*/
|
|
2589
|
+
export const INSTALL_BRIDGE_DOCTOR_POINTER = `Diagnose with: ${INSTALL_BRIDGE_DOCTOR_COMMAND}`;
|
|
2590
|
+
/**
|
|
2591
|
+
* The classified cause used whenever the thrown value carries no `code` we
|
|
2592
|
+
* recognize. Deliberately fixed and free-form-free: it names the opt-in debug
|
|
2593
|
+
* switch instead of echoing anything about the caught value.
|
|
2594
|
+
*/
|
|
2595
|
+
export const INSTALL_BRIDGE_UNCLASSIFIED_CAUSE = "unexpected error (run with BAPI_INSTALL_DEBUG=1 for details)";
|
|
2596
|
+
/** Fixed last-resort step label for the `index.ts` install-bridge dispatch catch. */
|
|
2597
|
+
export const INSTALL_BRIDGE_DISPATCH_STEP_LABEL = "install-bridge dispatch";
|
|
2598
|
+
/** Labels prefixing the OPT-IN raw diagnostics (never emitted by default). */
|
|
2599
|
+
export const INSTALL_BRIDGE_DEBUG_MESSAGE_LABEL = "debug (BAPI_INSTALL_DEBUG) raw message:";
|
|
2600
|
+
export const INSTALL_BRIDGE_DEBUG_STACK_LABEL = "debug (BAPI_INSTALL_DEBUG) raw stack:";
|
|
2601
|
+
/**
|
|
2602
|
+
* Closed filesystem-code → cause map. A `Map` (not an object literal) so a
|
|
2603
|
+
* hostile `code` such as `"constructor"` or `"__proto__"` can never resolve
|
|
2604
|
+
* through the prototype chain into a bogus "cause".
|
|
2605
|
+
*/
|
|
2606
|
+
const INSTALL_BRIDGE_FILESYSTEM_CAUSES = new Map([
|
|
2607
|
+
["EACCES", "permission denied"],
|
|
2608
|
+
["EPERM", "permission denied"],
|
|
2609
|
+
["ENOSPC", "no space left on device"],
|
|
2610
|
+
["EROFS", "read-only file system"],
|
|
2611
|
+
]);
|
|
2612
|
+
/** Closed allowlist of standard network codes that all collapse to one cause. */
|
|
2613
|
+
const INSTALL_BRIDGE_NETWORK_CODES = new Set([
|
|
2614
|
+
"ECONNREFUSED",
|
|
2615
|
+
"ECONNRESET",
|
|
2616
|
+
"ETIMEDOUT",
|
|
2617
|
+
"ENETUNREACH",
|
|
2618
|
+
"EHOSTUNREACH",
|
|
2619
|
+
"ENOTFOUND",
|
|
2620
|
+
"EAI_AGAIN",
|
|
2621
|
+
]);
|
|
2622
|
+
/**
|
|
2623
|
+
* Read `error.code` without trusting the value at all: a non-object, a throwing
|
|
2624
|
+
* getter, and a non-string `code` all degrade to `undefined` rather than
|
|
2625
|
+
* escaping. Nothing else on the caught value is ever touched.
|
|
2626
|
+
*/
|
|
2627
|
+
function extractInstallBridgeErrorCode(error) {
|
|
2628
|
+
try {
|
|
2629
|
+
if (typeof error !== "object" || error === null)
|
|
2630
|
+
return undefined;
|
|
2631
|
+
const code = error.code;
|
|
2632
|
+
return typeof code === "string" ? code : undefined;
|
|
2633
|
+
}
|
|
2634
|
+
catch {
|
|
2635
|
+
return undefined;
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
/**
|
|
2639
|
+
* Map a caught value to a SHORT, FIXED cause label (BAPI-668, R5-1).
|
|
2640
|
+
*
|
|
2641
|
+
* SECRET DISCIPLINE — safe by SUPPRESSION, not by redaction: the normal path
|
|
2642
|
+
* reads ONLY a safely-guarded string `code`. It never reads `error.message`,
|
|
2643
|
+
* never calls `String(error)`, and never serializes the caught object, because a
|
|
2644
|
+
* free-form message can embed a secret this module does not hold in scope and
|
|
2645
|
+
* therefore could not scrub (the exact case `secret-safety.test.ts` guards).
|
|
2646
|
+
* Never pass an API key, invite token, or `key_secret` through here.
|
|
2647
|
+
*/
|
|
2648
|
+
export function classifyInstallBridgeFailure(error) {
|
|
2649
|
+
const code = extractInstallBridgeErrorCode(error);
|
|
2650
|
+
if (code === undefined)
|
|
2651
|
+
return INSTALL_BRIDGE_UNCLASSIFIED_CAUSE;
|
|
2652
|
+
const filesystemCause = INSTALL_BRIDGE_FILESYSTEM_CAUSES.get(code);
|
|
2653
|
+
if (filesystemCause !== undefined)
|
|
2654
|
+
return filesystemCause;
|
|
2655
|
+
if (INSTALL_BRIDGE_NETWORK_CODES.has(code))
|
|
2656
|
+
return "network error";
|
|
2657
|
+
return INSTALL_BRIDGE_UNCLASSIFIED_CAUSE;
|
|
2658
|
+
}
|
|
2659
|
+
/** Read one debug-only field defensively; a hostile getter degrades to `undefined`. */
|
|
2660
|
+
function readInstallBridgeDebugValue(read) {
|
|
2661
|
+
try {
|
|
2662
|
+
const value = read();
|
|
2663
|
+
if (typeof value === "string")
|
|
2664
|
+
return value;
|
|
2665
|
+
if (value === undefined || value === null)
|
|
2666
|
+
return undefined;
|
|
2667
|
+
return String(value);
|
|
2668
|
+
}
|
|
2669
|
+
catch {
|
|
2670
|
+
return undefined;
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
/**
|
|
2674
|
+
* Build the fixed, secret-free failure block for an unexpected install failure
|
|
2675
|
+
* (BAPI-668, R5-1/R5-3). Pure — the caller decides which sink receives the lines.
|
|
2676
|
+
*
|
|
2677
|
+
* Default output is exactly: the failed step label, the classified cause, the
|
|
2678
|
+
* doctor pointer, and (only when the caller supplies it) the resume advice.
|
|
2679
|
+
*
|
|
2680
|
+
* SECRET DISCIPLINE — as with {@link classifyInstallBridgeFailure}, the default
|
|
2681
|
+
* path is safe by SUPPRESSION: it never reads `message`/`stack`, never stringifies
|
|
2682
|
+
* the caught value, and never accepts a credential in its context. The raw message
|
|
2683
|
+
* and full stack are OPT-IN diagnostics that appear only when `debugEnabled` is
|
|
2684
|
+
* true (i.e. the user set `BAPI_INSTALL_DEBUG`), and even that extraction is
|
|
2685
|
+
* guarded so a hostile thrown value cannot make the last-resort formatter throw.
|
|
2686
|
+
*/
|
|
2687
|
+
export function buildInstallBridgeFailureLines(error, context) {
|
|
2688
|
+
const lines = [
|
|
2689
|
+
`Error: install-bridge failed at: ${context.step}`,
|
|
2690
|
+
` cause: ${classifyInstallBridgeFailure(error)}`,
|
|
2691
|
+
` ${INSTALL_BRIDGE_DOCTOR_POINTER}`,
|
|
2692
|
+
];
|
|
2693
|
+
if (context.resumeAdvice)
|
|
2694
|
+
lines.push(` ${context.resumeAdvice}`);
|
|
2695
|
+
if (context.debugEnabled) {
|
|
2696
|
+
const isError = error instanceof Error;
|
|
2697
|
+
const rawMessage = isError
|
|
2698
|
+
? readInstallBridgeDebugValue(() => error.message)
|
|
2699
|
+
: readInstallBridgeDebugValue(() => String(error));
|
|
2700
|
+
const rawStack = isError
|
|
2701
|
+
? readInstallBridgeDebugValue(() => error.stack)
|
|
2702
|
+
: undefined;
|
|
2703
|
+
lines.push(` ${INSTALL_BRIDGE_DEBUG_MESSAGE_LABEL} ${rawMessage ?? "<unavailable>"}`);
|
|
2704
|
+
if (rawStack)
|
|
2705
|
+
lines.push(` ${INSTALL_BRIDGE_DEBUG_STACK_LABEL} ${rawStack}`);
|
|
2706
|
+
}
|
|
2707
|
+
return lines;
|
|
2708
|
+
}
|
|
2709
|
+
// ---------------------------------------------------------------------------
|
|
2710
|
+
// Base-URL normalization + validation (BAPI-668, R9)
|
|
2711
|
+
// ---------------------------------------------------------------------------
|
|
2712
|
+
/** The ONE user-facing rejection for a malformed/non-http(s) `BAPI_BASE_URL`. */
|
|
2713
|
+
function buildInstallBridgeBaseUrlError(value) {
|
|
2714
|
+
return `BAPI_BASE_URL must be an absolute http(s) URL (got: "${value}")`;
|
|
2715
|
+
}
|
|
2716
|
+
/**
|
|
2717
|
+
* Normalize and validate `BAPI_BASE_URL` ONCE, at the CLI entry boundary (R9-1).
|
|
2718
|
+
*
|
|
2719
|
+
* `new URL()` alone does NOT restrict the scheme — it happily accepts
|
|
2720
|
+
* `ftp://x.com`, `javascript:alert(1)`, and `foo:bar` — so the protocol is checked
|
|
2721
|
+
* explicitly. An ABSENT variable defaults; an explicitly EMPTY or whitespace-only
|
|
2722
|
+
* one is REJECTED rather than silently defaulting, because "exported but blank" is
|
|
2723
|
+
* a misconfiguration, not an opt-out.
|
|
2724
|
+
*
|
|
2725
|
+
* The value is a URL, not a secret, so echoing it in the error is intentional.
|
|
2726
|
+
*/
|
|
2727
|
+
export function resolveInstallBridgeBaseUrl(env) {
|
|
2728
|
+
const supplied = env.BAPI_BASE_URL;
|
|
2729
|
+
// A non-string (absent property, or an explicit `undefined`) is "unset" and
|
|
2730
|
+
// defaults; any supplied string — including "" and " " — must validate.
|
|
2731
|
+
const candidate = typeof supplied === "string" ? supplied.trim() : DEFAULT_BAPI_BASE_URL;
|
|
2732
|
+
if (candidate.length === 0)
|
|
2733
|
+
return { ok: false, error: buildInstallBridgeBaseUrlError("") };
|
|
2734
|
+
let parsed;
|
|
2735
|
+
try {
|
|
2736
|
+
parsed = new URL(candidate);
|
|
2737
|
+
}
|
|
2738
|
+
catch {
|
|
2739
|
+
return { ok: false, error: buildInstallBridgeBaseUrlError(candidate) };
|
|
2740
|
+
}
|
|
2741
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
2742
|
+
return { ok: false, error: buildInstallBridgeBaseUrlError(candidate) };
|
|
2743
|
+
}
|
|
2744
|
+
// The TRIMMED candidate is returned, not `parsed.toString()`: the latter would
|
|
2745
|
+
// append a path slash ("https://x.com" → "https://x.com/") and change every
|
|
2746
|
+
// downstream URL the existing tests and consumers already pin.
|
|
2747
|
+
return { ok: true, value: candidate };
|
|
2748
|
+
}
|
|
2749
|
+
// ---------------------------------------------------------------------------
|
|
2750
|
+
// Printed Step 1–5 labels (BAPI-668, R5-1)
|
|
2751
|
+
// ---------------------------------------------------------------------------
|
|
2752
|
+
/**
|
|
2753
|
+
* Every printed Step 1–5 label, defined once. `runInstallBridgeCli` uses each
|
|
2754
|
+
* constant BOTH when logging the step AND when recording the current step, so a
|
|
2755
|
+
* failure can never be attributed to a label the user did not just see.
|
|
2756
|
+
*/
|
|
2757
|
+
export const INSTALL_BRIDGE_STEP_LABELS = {
|
|
2758
|
+
scaffold: "Step 1/5 — scaffolding project (commands, agents, pipelines, config placeholders)…",
|
|
2759
|
+
selfServeMint: "Step 2/5 — requesting Bridge self-serve setup…",
|
|
2760
|
+
inviteRedeem: "Step 2/5 — redeeming the bootstrap invite…",
|
|
2761
|
+
verifyConnectivity: "Step 2/5 — verifying connectivity…",
|
|
2762
|
+
writeHostConfigs: "Step 3/5 — writing per-host MCP config…",
|
|
2763
|
+
promoteCredential: "Step 4/5 — promoting the bootstrap credential…",
|
|
2764
|
+
persistCredential: "Step 4/5 — persisting routing credential…",
|
|
2765
|
+
};
|
|
2766
|
+
/**
|
|
2767
|
+
* The Step 5 label, which names the agent being opened. Same rule: log == record.
|
|
2768
|
+
* BAPI-669 (U3): it prints the host tool's DISPLAY label ("Claude Code"), never the
|
|
2769
|
+
* raw agent binary name ("claude") — the user picked a tool, not a binary.
|
|
2770
|
+
*/
|
|
2771
|
+
export function buildInstallBridgeLaunchStepLabel(agent) {
|
|
2772
|
+
return `Step 5/5 — opening a ${toolLabelForLaunchAgent(agent)} session for /install-bridge configuration + concise capability report…`;
|
|
2773
|
+
}
|
|
2774
|
+
// ---------------------------------------------------------------------------
|
|
2775
|
+
// Working-directory banner + project-root gate (BAPI-669, U2)
|
|
2776
|
+
//
|
|
2777
|
+
// This gate is READ-ONLY by construction: it prints where the install is aimed,
|
|
2778
|
+
// probes a single path through the injected `deps.stat` seam, and (on a TTY only)
|
|
2779
|
+
// asks one question. It must stay AHEAD of onboarding selection, credential
|
|
2780
|
+
// resolution, repository resolution, tool selection, every network call, `runInit`,
|
|
2781
|
+
// launch materialization, and every write — so a decline can abort with nothing
|
|
2782
|
+
// minted, exchanged, or written.
|
|
2783
|
+
//
|
|
2784
|
+
// Declining is a user-initiated CANCEL, not a failure: it exits 0. A non-TTY run
|
|
2785
|
+
// warns and continues, preserving automation compatibility (legitimate non-git
|
|
2786
|
+
// installs exist, so blocking outright was rejected).
|
|
2787
|
+
// ---------------------------------------------------------------------------
|
|
2788
|
+
/** Prefix of the always-printed "where am I installing" banner. */
|
|
2789
|
+
export const INSTALL_BRIDGE_CWD_BANNER_PREFIX = "Installing Bridge into: ";
|
|
2790
|
+
/** The shared no-`.git` finding, reused by both the TTY prompt and the non-TTY warning. */
|
|
2791
|
+
export const INSTALL_BRIDGE_NO_GIT_WARNING = "This doesn't look like a project root (no .git found)";
|
|
2792
|
+
/** Default-No confirmation shown only on a TTY when `.git` is absent. */
|
|
2793
|
+
export const INSTALL_BRIDGE_NO_GIT_PROMPT = `${INSTALL_BRIDGE_NO_GIT_WARNING} — continue? [y/N]: `;
|
|
2794
|
+
/** Printed on decline. Paired with exit code 0 — a cancel is not an error. */
|
|
2795
|
+
export const INSTALL_BRIDGE_NO_GIT_ABORT = "Aborted — run install-bridge from your project root, or re-run and confirm to continue.";
|
|
2796
|
+
/** The non-TTY form: same finding, stated as a warning, then the run continues. */
|
|
2797
|
+
export const INSTALL_BRIDGE_NO_GIT_NONINTERACTIVE_WARNING = `Warning: ${INSTALL_BRIDGE_NO_GIT_WARNING} — continuing (non-interactive).`;
|
|
2798
|
+
/**
|
|
2799
|
+
* Existence probe for `<cwd>/.git`, through the injected `stat` boundary. A git
|
|
2800
|
+
* worktree's `.git` is a FILE rather than a directory, so only existence is
|
|
2801
|
+
* checked — never the entry type. Any probe failure is treated as "absent"; this
|
|
2802
|
+
* is a hint for a warning, never an authorization decision. Repository-name
|
|
2803
|
+
* inference is untouched by it.
|
|
2804
|
+
*/
|
|
2805
|
+
async function hasProjectRootMarker(deps) {
|
|
2806
|
+
try {
|
|
2807
|
+
await deps.stat(path.join(deps.cwd, ".git"));
|
|
2808
|
+
return true;
|
|
2809
|
+
}
|
|
2810
|
+
catch {
|
|
2811
|
+
return false;
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
// ---------------------------------------------------------------------------
|
|
2091
2815
|
// CLI entry
|
|
2092
2816
|
// ---------------------------------------------------------------------------
|
|
2093
2817
|
/**
|
|
@@ -2104,18 +2828,90 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
2104
2828
|
deps.resolveRepoViaServer = (baseUrl, apiKey) => resolveRepoViaServer(deps.fetch, baseUrl, apiKey);
|
|
2105
2829
|
}
|
|
2106
2830
|
const { log, errorLog } = deps;
|
|
2831
|
+
/**
|
|
2832
|
+
* Emit an OPERATIONAL fatal (BAPI-669, U9a): one or more already-composed,
|
|
2833
|
+
* secret-free error lines, then the canonical doctor pointer exactly once.
|
|
2834
|
+
*
|
|
2835
|
+
* Scope is deliberately narrow — the ping / exchange / network / self-serve
|
|
2836
|
+
* failures, i.e. the ones where the machine's state is the open question. It is
|
|
2837
|
+
* NOT used for user-directed cancellations (the missing-`.git` exit-0 abort, a
|
|
2838
|
+
* declined overwrite, a declined signup discard) or for nonfatal warnings (the
|
|
2839
|
+
* Step 5 terminal-spawn failure), where "run doctor" is noise rather than help.
|
|
2840
|
+
*/
|
|
2841
|
+
const reportFatal = (...lines) => {
|
|
2842
|
+
for (const line of lines)
|
|
2843
|
+
errorLog(line);
|
|
2844
|
+
errorLog(` ${INSTALL_BRIDGE_DOCTOR_POINTER}`);
|
|
2845
|
+
};
|
|
2846
|
+
/** {@link reportFatal} plus the exit code, for the common `return fatal(…)` shape. */
|
|
2847
|
+
const fatal = (...lines) => {
|
|
2848
|
+
reportFatal(...lines);
|
|
2849
|
+
return 1;
|
|
2850
|
+
};
|
|
2851
|
+
// ---- BAPI-668 (R9): normalize + validate BAPI_BASE_URL ONCE, here ----
|
|
2852
|
+
// Resolved BEFORE the parser purely because it is pure and side-effect-free, so
|
|
2853
|
+
// help / parser-error usage text can name the user's REAL base URL (BAPI-669 U8)
|
|
2854
|
+
// instead of the hosted default. Error PRECEDENCE is deliberately unchanged: the
|
|
2855
|
+
// `!ok` return still happens after the argument-error return below, so a run with
|
|
2856
|
+
// both a bad flag and a bad BAPI_BASE_URL still reports the argument error first.
|
|
2857
|
+
const baseUrlResult = resolveInstallBridgeBaseUrl(deps.env);
|
|
2858
|
+
// A rejected value cannot be interpolated into guidance, so usage falls back to
|
|
2859
|
+
// the documented default — the run is about to fail on it anyway.
|
|
2860
|
+
const usageBaseUrl = baseUrlResult.ok ? baseUrlResult.value : DEFAULT_BAPI_BASE_URL;
|
|
2107
2861
|
const parsed = parseInstallBridgeArgs(argv);
|
|
2108
2862
|
if (parsed.status === "help") {
|
|
2109
|
-
log(
|
|
2863
|
+
log(getInstallBridgeUsage(usageBaseUrl));
|
|
2110
2864
|
return 0;
|
|
2111
2865
|
}
|
|
2112
2866
|
if (parsed.status === "error") {
|
|
2113
2867
|
errorLog(`Error: ${parsed.message}`);
|
|
2114
2868
|
errorLog("");
|
|
2115
|
-
errorLog(getInstallBridgeUsage());
|
|
2869
|
+
errorLog(getInstallBridgeUsage(usageBaseUrl));
|
|
2116
2870
|
return 1;
|
|
2117
2871
|
}
|
|
2118
2872
|
const options = parsed.options;
|
|
2873
|
+
// Deliberately still ahead of the onboarding branch, any prompt, any network
|
|
2874
|
+
// call, and the --dry-run preview: a scheme-less value (the classic error) used
|
|
2875
|
+
// to throw ERR_INVALID_URL deep inside `buildPingUrl` — even in --dry-run — often
|
|
2876
|
+
// after the user had already answered a repo prompt. Failing here means no prompt
|
|
2877
|
+
// is shown and no side effect occurs.
|
|
2878
|
+
if (!baseUrlResult.ok) {
|
|
2879
|
+
errorLog(baseUrlResult.error);
|
|
2880
|
+
return 1;
|
|
2881
|
+
}
|
|
2882
|
+
const baseUrl = baseUrlResult.value;
|
|
2883
|
+
/** `<base>/setup` — the one web surface every guidance string points at (U8). */
|
|
2884
|
+
const setupUrl = buildInstallBridgeSetupUrl(baseUrl);
|
|
2885
|
+
// Replace the RUN-LOCAL env with a shallow copy carrying the normalized value so
|
|
2886
|
+
// every downstream `deps.env` consumer observes it. The caller-owned object (and
|
|
2887
|
+
// `process.env`) is never mutated.
|
|
2888
|
+
deps.env = { ...deps.env, BAPI_BASE_URL: baseUrl };
|
|
2889
|
+
// ---- BAPI-669 (U2): working-directory banner + project-root gate ----
|
|
2890
|
+
// See the section comment on `hasProjectRootMarker`: read-only, and positioned
|
|
2891
|
+
// ahead of ALL credential and configuration work so a decline costs nothing.
|
|
2892
|
+
log(`${INSTALL_BRIDGE_CWD_BANNER_PREFIX}${deps.cwd}`);
|
|
2893
|
+
if (!(await hasProjectRootMarker(deps))) {
|
|
2894
|
+
if (deps.isTTY && deps.promptLine) {
|
|
2895
|
+
let answer = "";
|
|
2896
|
+
try {
|
|
2897
|
+
answer = (await deps.promptLine(INSTALL_BRIDGE_NO_GIT_PROMPT)).trim().toLowerCase();
|
|
2898
|
+
}
|
|
2899
|
+
catch {
|
|
2900
|
+
// A prompt that cannot be read is NOT confirmation. Never infer it — the
|
|
2901
|
+
// default is No, so a malformed or failed read takes the abort path.
|
|
2902
|
+
answer = "";
|
|
2903
|
+
}
|
|
2904
|
+
if (answer !== "y" && answer !== "yes") {
|
|
2905
|
+
log(INSTALL_BRIDGE_NO_GIT_ABORT);
|
|
2906
|
+
// Exit 0: the user cancelled, and nothing was resolved, written, or spent.
|
|
2907
|
+
return 0;
|
|
2908
|
+
}
|
|
2909
|
+
}
|
|
2910
|
+
else {
|
|
2911
|
+
// Automation compatibility: warn, never prompt, never abort.
|
|
2912
|
+
errorLog(INSTALL_BRIDGE_NO_GIT_NONINTERACTIVE_WARNING);
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2119
2915
|
// Onboarding branch: have-key vs. a need-key method (`bootstrap-invite` = redeem
|
|
2120
2916
|
// a pre-issued invite, `self-serve` = mint one from an email, BAPI-618). BOTH
|
|
2121
2917
|
// need-key methods share the downstream redemption protocol, so
|
|
@@ -2149,6 +2945,11 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
2149
2945
|
let apiKey = "";
|
|
2150
2946
|
let inviteToken = "";
|
|
2151
2947
|
let signupEmail = "";
|
|
2948
|
+
// BAPI-668 (R11-1): retained ONLY while the resolved value stays on the ordinary
|
|
2949
|
+
// API-key path. A value reclassified as a bootstrap invite, and either explicit
|
|
2950
|
+
// need-key method, leave it undefined — those paths never run the ordinary
|
|
2951
|
+
// key-authentication ping, so there is no source to attribute.
|
|
2952
|
+
let apiKeySource;
|
|
2152
2953
|
if (selfServeSignupMode) {
|
|
2153
2954
|
const emailResult = await resolveSignupEmail(options, deps);
|
|
2154
2955
|
if (!emailResult.ok) {
|
|
@@ -2181,14 +2982,17 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
2181
2982
|
apiKey = "";
|
|
2182
2983
|
bootstrapInviteMode = true;
|
|
2183
2984
|
selfServeSignupMode = false;
|
|
2985
|
+
// Deliberately NOT retained: the redemption path mints its own key, so any
|
|
2986
|
+
// flag/env attribution on its later ping would name the wrong credential.
|
|
2987
|
+
apiKeySource = undefined;
|
|
2184
2988
|
}
|
|
2185
2989
|
else {
|
|
2186
2990
|
apiKey = keyResult.value;
|
|
2991
|
+
apiKeySource = keyResult.source;
|
|
2187
2992
|
}
|
|
2188
2993
|
}
|
|
2189
|
-
// baseUrl
|
|
2190
|
-
//
|
|
2191
|
-
const baseUrl = deps.env.BAPI_BASE_URL ?? DEFAULT_BAPI_BASE_URL;
|
|
2994
|
+
// `baseUrl` was resolved and validated at the entry boundary above (BAPI-668),
|
|
2995
|
+
// well before the have-key branch's server-resolution request needs it.
|
|
2192
2996
|
const docsDir = deps.env.BAPI_DOCS_DIR ?? DEFAULT_BAPI_DOCS_DIR;
|
|
2193
2997
|
// ---- Resolve the repository name ----
|
|
2194
2998
|
// Need-key (invite or self-serve): choose-a-name for the project this run is
|
|
@@ -2235,7 +3039,7 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
2235
3039
|
// (BAPI-661) WITH a visible signal that the fallback is happening, so a
|
|
2236
3040
|
// guessed name is never confirmed silently as if it had been verified.
|
|
2237
3041
|
log("Couldn't auto-resolve your repo from the key; falling back to a guessed name — confirm " +
|
|
2238
|
-
|
|
3042
|
+
`it matches ${setupUrl} (setup UI).`);
|
|
2239
3043
|
const repoResult = await resolveRepoName(options, deps, "existing-registration");
|
|
2240
3044
|
if (!repoResult.ok) {
|
|
2241
3045
|
errorLog(`Error: ${repoResult.error}`);
|
|
@@ -2410,386 +3214,619 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
2410
3214
|
return 1;
|
|
2411
3215
|
}
|
|
2412
3216
|
}
|
|
2413
|
-
// ----
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
//
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
3217
|
+
// ---- BAPI-668 (R5-1): the guarded Step 1–5 execution region ----
|
|
3218
|
+
// Everything ABOVE this point — argument parsing, base-URL validation, input
|
|
3219
|
+
// resolution, the dry-run preview, and every deliberate pre-execution validation
|
|
3220
|
+
// return — stays OUTSIDE the catch: those failures are already explained, and
|
|
3221
|
+
// folding them into a generic block would make them worse, not safer. Everything
|
|
3222
|
+
// from the Step 1 scaffold through the terminal Step 5 returns is inside, so no
|
|
3223
|
+
// fs/permission/disk failure can print a raw Node stack. Every explicitly handled
|
|
3224
|
+
// branch inside (fatal gitignore protection, fail-open credential persistence,
|
|
3225
|
+
// advisory install-state persistence, the GitHub offer, prewarm warnings, and the
|
|
3226
|
+
// nonfatal Step 5 spawn failure) keeps its own behavior — the catch only ever sees
|
|
3227
|
+
// an exception that ESCAPED all of them.
|
|
3228
|
+
let currentStep = INSTALL_BRIDGE_STEP_LABELS.scaffold;
|
|
3229
|
+
let bootstrapExchangeSucceeded = false;
|
|
3230
|
+
try {
|
|
3231
|
+
// ---- Step 1 — scaffold (secret-free placeholders only) ----
|
|
3232
|
+
currentStep = INSTALL_BRIDGE_STEP_LABELS.scaffold;
|
|
3233
|
+
log(currentStep);
|
|
3234
|
+
await deps.runInit(deps.cwd);
|
|
3235
|
+
// ---- Step 2 — mint (invite mode) or verify (normal mode) the credential ----
|
|
3236
|
+
let inviteFingerprint = "";
|
|
3237
|
+
if (bootstrapInviteMode) {
|
|
3238
|
+
// ---- Self-serve resume (BAPI-667) — runs BEFORE any mint ----
|
|
3239
|
+
// Every self-serve run used to mint a FRESH invite, so a retry after any
|
|
3240
|
+
// mid-protocol failure presented a new fingerprint against its own pending
|
|
3241
|
+
// record and hard-failed with `pending-conflict` whose only advice was to
|
|
3242
|
+
// delete the record by hand — potentially destroying the sole trace of a live
|
|
3243
|
+
// admin key. It also littered a new tenant row server-side per attempt.
|
|
3244
|
+
//
|
|
3245
|
+
// So the stored record is consulted FIRST. A resumable record supplies the
|
|
3246
|
+
// token, the fingerprint, and the `key_secret`, and the mint endpoint is never
|
|
3247
|
+
// called at all. This is the whole of R4-2, and it is what makes the normal
|
|
3248
|
+
// retry path free of orphan tenants.
|
|
3249
|
+
let resumedSelfServeReplay = false;
|
|
3250
|
+
let keySecret = "";
|
|
3251
|
+
let reusedPendingSecret = false;
|
|
3252
|
+
if (selfServeSignupMode) {
|
|
3253
|
+
const lookup = await deps.lookupSelfServeBootstrapPending({ repoName }, credentialWriteDeps);
|
|
3254
|
+
if (!lookup.ok) {
|
|
3255
|
+
// Fail BEFORE the mint endpoint is called and leave the stored record
|
|
3256
|
+
// untouched — an ordinary-invite record, a malformed record, and an
|
|
3257
|
+
// unreadable store are all states where minting again would compound the
|
|
3258
|
+
// problem rather than recover from it.
|
|
3259
|
+
return fatal(`Error: ${lookup.error}`);
|
|
2436
3260
|
}
|
|
2437
|
-
|
|
2438
|
-
|
|
3261
|
+
if (lookup.state === "resumable") {
|
|
3262
|
+
// SECRETS: assigned, never logged. The single line below names only the repo.
|
|
3263
|
+
inviteToken = lookup.replayToken;
|
|
3264
|
+
inviteFingerprint = lookup.inviteFingerprint;
|
|
3265
|
+
keySecret = lookup.keySecret;
|
|
3266
|
+
resumedSelfServeReplay = true;
|
|
3267
|
+
// The stored secret IS the replay proof, so a 401 here is a rejected
|
|
3268
|
+
// invite, not a lost secret (mirrors `prepared.reused`).
|
|
3269
|
+
reusedPendingSecret = true;
|
|
3270
|
+
log(` resuming your previous signup attempt for ${repoName}`);
|
|
2439
3271
|
}
|
|
2440
|
-
return 1;
|
|
2441
3272
|
}
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
let prepared = await deps.prepareBootstrapPending({
|
|
2470
|
-
repoName,
|
|
2471
|
-
inviteFingerprint,
|
|
2472
|
-
generateKeySecret: () => generateBootstrapKeySecret(deps.randomBytes),
|
|
2473
|
-
allowOverwriteExistingCredential: overwriteConsent,
|
|
2474
|
-
}, credentialWriteDeps);
|
|
2475
|
-
// An existing bapi:<repo> credential is never clobbered without consent. The
|
|
2476
|
-
// gate mirrors the host-config gate above: --force, else an interactive prompt,
|
|
2477
|
-
// else a hard non-interactive failure — all BEFORE the exchange.
|
|
2478
|
-
if (!prepared.ok && prepared.kind === "credential-conflict") {
|
|
2479
|
-
if (deps.isTTY && deps.promptLine) {
|
|
2480
|
-
const answer = (await deps.promptLine(`The credential store already holds a key for ${prepared.target}. Overwrite it? [y/N]: `)).trim().toLowerCase();
|
|
2481
|
-
if (answer !== "y" && answer !== "yes") {
|
|
2482
|
-
errorLog("Aborted: existing credential left unchanged (re-run with --force to overwrite).");
|
|
2483
|
-
return 1;
|
|
3273
|
+
/**
|
|
3274
|
+
* Mint a self-serve invite and durably store it WITH a fresh `key_secret`.
|
|
3275
|
+
*
|
|
3276
|
+
* Shared by the initial signup and the consented refresh below — the only two
|
|
3277
|
+
* places a self-serve token is ever created. The ordering inside is the same
|
|
3278
|
+
* load-bearing protocol described below: mint the token, durably store the
|
|
3279
|
+
* token plus the `key_secret`, and only THEN exchange. Logs its own
|
|
3280
|
+
* (secret-free) failure and returns false; the caller exits non-zero.
|
|
3281
|
+
*/
|
|
3282
|
+
const mintAndPrepareSelfServe = async () => {
|
|
3283
|
+
const mint = await mintSelfServeInvite(deps, baseUrl, signupEmail);
|
|
3284
|
+
if (!mint.ok) {
|
|
3285
|
+
if (mint.category === "rate-limited") {
|
|
3286
|
+
// BAPI-668 (R12-1): the limiter is a single GLOBAL per-hour signup bucket,
|
|
3287
|
+
// not a per-IP/per-email one — so "temporarily rate limited" read as "you
|
|
3288
|
+
// did something wrong" when nothing on the user's machine is at fault.
|
|
3289
|
+
// Message-only: the return value and the caller's exit code are unchanged.
|
|
3290
|
+
reportFatal("Error: Bridge's self-serve signup capacity for this hour is exhausted (this is a " +
|
|
3291
|
+
"global service limit, not a problem with your machine). Try again in about an hour.");
|
|
3292
|
+
}
|
|
3293
|
+
else if (mint.category === "invalid") {
|
|
3294
|
+
reportFatal("Error: Self-serve setup could not be requested. Check the email value and try again.");
|
|
3295
|
+
}
|
|
3296
|
+
else {
|
|
3297
|
+
reportFatal("Error: Unable to complete self-serve setup. Check connectivity and retry.");
|
|
3298
|
+
}
|
|
3299
|
+
return false;
|
|
2484
3300
|
}
|
|
2485
|
-
|
|
2486
|
-
|
|
3301
|
+
// Treated identically to a manually supplied invite token from here on.
|
|
3302
|
+
inviteToken = mint.token;
|
|
3303
|
+
inviteFingerprint = fingerprintBootstrapInvite(inviteToken);
|
|
3304
|
+
const prepareSelfServePending = (allowOverwrite) => deps.prepareBootstrapPending({
|
|
2487
3305
|
repoName,
|
|
2488
3306
|
inviteFingerprint,
|
|
2489
3307
|
generateKeySecret: () => generateBootstrapKeySecret(deps.randomBytes),
|
|
2490
|
-
allowOverwriteExistingCredential:
|
|
3308
|
+
allowOverwriteExistingCredential: allowOverwrite,
|
|
3309
|
+
// R4-1: the minted token rides the SAME durable write as the key_secret.
|
|
3310
|
+
// It is stored ONLY here — a user-supplied invite stays fingerprint-only.
|
|
3311
|
+
selfServeReplayToken: inviteToken,
|
|
2491
3312
|
}, credentialWriteDeps);
|
|
3313
|
+
// An existing bapi:<repo> credential is never clobbered without consent —
|
|
3314
|
+
// the self-serve arm uses the SAME gate as the ordinary-invite arm below.
|
|
3315
|
+
const prep = await resolveCredentialConflictConsent(await prepareSelfServePending(overwriteConsent), {
|
|
3316
|
+
isTTY: deps.isTTY,
|
|
3317
|
+
promptLine: deps.promptLine,
|
|
3318
|
+
errorLog,
|
|
3319
|
+
grantConsent: () => {
|
|
3320
|
+
overwriteConsent = true;
|
|
3321
|
+
},
|
|
3322
|
+
retry: () => prepareSelfServePending(true),
|
|
3323
|
+
});
|
|
3324
|
+
if (prep === null)
|
|
3325
|
+
return false;
|
|
3326
|
+
if (!prep.ok) {
|
|
3327
|
+
reportFatal(`Error: could not durably store the signup credential (${prep.kind}). ${prep.error} ` +
|
|
3328
|
+
"No workspace has been set up — fix the problem and re-run.");
|
|
3329
|
+
return false;
|
|
3330
|
+
}
|
|
3331
|
+
keySecret = prep.keySecret;
|
|
3332
|
+
reusedPendingSecret = prep.reused;
|
|
3333
|
+
log(` saved the pending credential for ${prep.target} (fsynced before the exchange)`);
|
|
3334
|
+
return true;
|
|
3335
|
+
};
|
|
3336
|
+
// ---- Self-serve signup (BAPI-618): mint the invite token from the email ----
|
|
3337
|
+
// This is the ONE step that precedes the unchanged BAPI-606 redemption below.
|
|
3338
|
+
// It sits AFTER the --dry-run return above, so a preview never mints, never
|
|
3339
|
+
// sends the email, and never writes a pending secret. On failure we return
|
|
3340
|
+
// BEFORE the exchange, so nothing is consumed. The email and the token are
|
|
3341
|
+
// never logged. SKIPPED ENTIRELY when the run resumed a stored attempt.
|
|
3342
|
+
if (selfServeSignupMode && !resumedSelfServeReplay) {
|
|
3343
|
+
currentStep = INSTALL_BRIDGE_STEP_LABELS.selfServeMint;
|
|
3344
|
+
log(currentStep);
|
|
3345
|
+
if (!(await mintAndPrepareSelfServe()))
|
|
3346
|
+
return 1;
|
|
2492
3347
|
}
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
//
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
3348
|
+
// ===================================================================
|
|
3349
|
+
// LOAD-BEARING ORDERING — NOT DEFENSIVE POLISH. DO NOT "TIDY" THIS.
|
|
3350
|
+
//
|
|
3351
|
+
// The key_secret is generated HERE, fsynced to a pending record HERE, and
|
|
3352
|
+
// only THEN sent to the exchange. That order is the protocol:
|
|
3353
|
+
//
|
|
3354
|
+
// * The locally-stored key_secret is the ONLY proof that can replay a
|
|
3355
|
+
// redemption. The server's replay branch matches on (repo_name, bcrypt of
|
|
3356
|
+
// key_secret); it never returns a recoverable credential.
|
|
3357
|
+
// * So a successful exchange followed by a failed local write = an
|
|
3358
|
+
// unrecoverable admin key AND a permanently spent invite. The user cannot
|
|
3359
|
+
// re-run (a new secret gets a correct 401) and cannot re-mint (the repo
|
|
3360
|
+
// name is now globally taken by the project they just created); only an
|
|
3361
|
+
// operator can dig them out.
|
|
3362
|
+
//
|
|
3363
|
+
// Therefore the write is FAIL-CLOSED: if it does not land (including its
|
|
3364
|
+
// fsync), we abort with a non-zero exit and NEVER call fetch. Nothing is
|
|
3365
|
+
// consumed. Moving this persist after the exchange — the "natural" order —
|
|
3366
|
+
// reintroduces exactly the bug above. (Contrast Step 4 in normal mode, which
|
|
3367
|
+
// is deliberately fail-open: there, the key already exists server-side and a
|
|
3368
|
+
// persist failure only degrades start-tickets routing.)
|
|
3369
|
+
//
|
|
3370
|
+
// BAPI-667: the self-serve arm satisfies this same ordering inside
|
|
3371
|
+
// `mintAndPrepareSelfServe` (or has already loaded a durable record via the
|
|
3372
|
+
// resume lookup), so the block below is the ORDINARY-INVITE preparation only.
|
|
3373
|
+
// ===================================================================
|
|
3374
|
+
if (!selfServeSignupMode) {
|
|
3375
|
+
inviteFingerprint = fingerprintBootstrapInvite(inviteToken);
|
|
3376
|
+
currentStep = INSTALL_BRIDGE_STEP_LABELS.inviteRedeem;
|
|
3377
|
+
log(currentStep);
|
|
3378
|
+
const prepareInvitePending = (allowOverwrite) => deps.prepareBootstrapPending({
|
|
3379
|
+
repoName,
|
|
3380
|
+
inviteFingerprint,
|
|
3381
|
+
generateKeySecret: () => generateBootstrapKeySecret(deps.randomBytes),
|
|
3382
|
+
allowOverwriteExistingCredential: allowOverwrite,
|
|
3383
|
+
// Deliberately NO `selfServeReplayToken`: a user-supplied invite is
|
|
3384
|
+
// re-presentable by the user, so it stays fingerprint-only on disk.
|
|
3385
|
+
}, credentialWriteDeps);
|
|
3386
|
+
// An existing bapi:<repo> credential is never clobbered without consent. The
|
|
3387
|
+
// gate mirrors the host-config gate above: --force, else an interactive prompt,
|
|
3388
|
+
// else a hard non-interactive failure — all BEFORE the exchange.
|
|
3389
|
+
const prepared = await resolveCredentialConflictConsent(await prepareInvitePending(overwriteConsent), {
|
|
3390
|
+
isTTY: deps.isTTY,
|
|
3391
|
+
promptLine: deps.promptLine,
|
|
3392
|
+
errorLog,
|
|
3393
|
+
grantConsent: () => {
|
|
3394
|
+
overwriteConsent = true;
|
|
3395
|
+
},
|
|
3396
|
+
retry: () => prepareInvitePending(true),
|
|
3397
|
+
});
|
|
3398
|
+
if (prepared === null)
|
|
3399
|
+
return 1;
|
|
3400
|
+
// A pending record left by a DIFFERENT invite is that redemption's only replay
|
|
3401
|
+
// proof, so it is never overwritten. BAPI-667 removed the unconditional
|
|
3402
|
+
// "re-running will not clear the conflict" suffix that used to be appended
|
|
3403
|
+
// here: it is FALSE for a stored self-serve record, which a re-run resumes
|
|
3404
|
+
// automatically. The credential store's own mode-aware message is now the
|
|
3405
|
+
// single source of truth for what the user should do.
|
|
3406
|
+
if (!prepared.ok && prepared.kind === "pending-conflict") {
|
|
3407
|
+
errorLog(`Error: ${prepared.error}`);
|
|
3408
|
+
return 1;
|
|
3409
|
+
}
|
|
3410
|
+
if (!prepared.ok) {
|
|
3411
|
+
// Fail closed: the exchange has NOT been called, so the invite is unspent.
|
|
3412
|
+
errorLog(`Error: could not durably store the bootstrap credential (${prepared.kind}). ${prepared.error} ` +
|
|
3413
|
+
"The bootstrap invite has NOT been used — fix the problem and re-run.");
|
|
3414
|
+
return 1;
|
|
3415
|
+
}
|
|
3416
|
+
keySecret = prepared.keySecret;
|
|
3417
|
+
reusedPendingSecret = prepared.reused;
|
|
3418
|
+
log(` saved the pending credential for ${prepared.target} (fsynced before the exchange)`);
|
|
2534
3419
|
}
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
3420
|
+
// The exchange REPLACES the pre-flight ping: in invite mode there is no key to
|
|
3421
|
+
// ping with — this call is what mints it.
|
|
3422
|
+
//
|
|
3423
|
+
// The outer loop runs at most TWICE: once for the initial (or resumed)
|
|
3424
|
+
// exchange, and — only after an explicit consent to discard an expired
|
|
3425
|
+
// self-serve record — once more with a freshly minted token (R4-3).
|
|
3426
|
+
let exchange;
|
|
3427
|
+
let selfServeRefreshed = false;
|
|
3428
|
+
for (;;) {
|
|
3429
|
+
exchange = await exchangeBootstrapInvite(deps, baseUrl, inviteToken, repoName, keySecret);
|
|
3430
|
+
// 409 repo_name_taken: the server ROLLED BACK, so the invite is NOT consumed and
|
|
3431
|
+
// the SAME token can be retried under a new name — with the SAME pending secret,
|
|
3432
|
+
// re-pointed to that name.
|
|
3433
|
+
while (!exchange.ok && exchange.kind === "repo-name-taken") {
|
|
3434
|
+
if (!deps.isTTY || !deps.promptLine) {
|
|
3435
|
+
errorLog(`Error: ${exchange.message} Re-run with a different --repo (the bootstrap invite has NOT ` +
|
|
3436
|
+
"been used).");
|
|
3437
|
+
return 1;
|
|
3438
|
+
}
|
|
3439
|
+
errorLog(exchange.message);
|
|
3440
|
+
const answer = (await deps.promptLine("Choose a different repo name: ")).trim();
|
|
3441
|
+
const validated = validateRepoName(answer);
|
|
3442
|
+
if (!validated.ok) {
|
|
3443
|
+
errorLog(`Error: invalid repo name — ${validated.error}.`);
|
|
3444
|
+
return 1;
|
|
3445
|
+
}
|
|
3446
|
+
const nextRepo = validated.value;
|
|
3447
|
+
const repointed = await deps.repointBootstrapPending({
|
|
3448
|
+
fromRepoName: repoName,
|
|
3449
|
+
toRepoName: nextRepo,
|
|
3450
|
+
inviteFingerprint,
|
|
3451
|
+
allowOverwriteExistingCredential: overwriteConsent,
|
|
3452
|
+
}, credentialWriteDeps);
|
|
3453
|
+
if (!repointed.ok) {
|
|
3454
|
+
errorLog(`Error: could not re-point the pending bootstrap credential to '${nextRepo}' ` +
|
|
3455
|
+
`(${repointed.kind}). ${repointed.error} The bootstrap invite has NOT been used.`);
|
|
3456
|
+
return 1;
|
|
3457
|
+
}
|
|
3458
|
+
repoName = nextRepo;
|
|
3459
|
+
// Same token, same key_secret — never regenerated inside the rename loop.
|
|
3460
|
+
exchange = await exchangeBootstrapInvite(deps, baseUrl, inviteToken, repoName, keySecret);
|
|
3461
|
+
}
|
|
3462
|
+
if (exchange.ok)
|
|
3463
|
+
break;
|
|
3464
|
+
// ---- Consent-gated self-serve recovery (BAPI-667, R4-3) ----
|
|
3465
|
+
// ONLY a REPLAYED self-serve token that the server CONCLUSIVELY rejected (401)
|
|
3466
|
+
// may be discarded and re-minted. Every other failure — network error, timeout,
|
|
3467
|
+
// 5xx, malformed response, a freshly minted token's first 401, and any ordinary
|
|
3468
|
+
// --invite 401 — leaves the record untouched, because in those cases the stored
|
|
3469
|
+
// record may still be the only proof of a live admin key.
|
|
3470
|
+
if (exchange.kind === "invalid-invite" &&
|
|
3471
|
+
resumedSelfServeReplay &&
|
|
3472
|
+
!selfServeRefreshed) {
|
|
3473
|
+
if (!deps.isTTY || !deps.promptLine) {
|
|
3474
|
+
return fatal(BOOTSTRAP_SELF_SERVE_EXPIRED_MESSAGE, `The saved attempt at ${getBootstrapPendingTarget(repoName)} in ${credentialStorePath} ` +
|
|
3475
|
+
"has been left untouched. Re-run install-bridge on an interactive terminal to " +
|
|
3476
|
+
"discard it and start a fresh signup.");
|
|
3477
|
+
}
|
|
3478
|
+
errorLog(BOOTSTRAP_SELF_SERVE_EXPIRED_MESSAGE);
|
|
3479
|
+
let consented = false;
|
|
3480
|
+
try {
|
|
3481
|
+
const answer = (await deps.promptLine(`Discard the saved signup at ${getBootstrapPendingTarget(repoName)} in ` +
|
|
3482
|
+
`${credentialStorePath} and start a fresh one? [y/N]: `)).trim().toLowerCase();
|
|
3483
|
+
consented = answer === "y" || answer === "yes";
|
|
3484
|
+
}
|
|
3485
|
+
catch {
|
|
3486
|
+
// A prompt that cannot be read is NOT consent. Never infer it.
|
|
3487
|
+
consented = false;
|
|
3488
|
+
}
|
|
3489
|
+
if (!consented) {
|
|
3490
|
+
errorLog("Aborted: the saved signup attempt was left unchanged (nothing was discarded).");
|
|
3491
|
+
return 1;
|
|
3492
|
+
}
|
|
3493
|
+
const discarded = await deps.discardBootstrapPending({ repoName, inviteFingerprint }, credentialWriteDeps);
|
|
3494
|
+
if (!discarded.ok) {
|
|
3495
|
+
// Stop WITHOUT minting: a fresh mint on top of a record we failed to
|
|
3496
|
+
// remove would strand the old record and its (possibly live) key.
|
|
3497
|
+
return fatal(`Error: could not discard the saved signup (${discarded.kind}). ${discarded.error}`);
|
|
3498
|
+
}
|
|
3499
|
+
log("Requesting a fresh Bridge self-serve setup…");
|
|
3500
|
+
if (!(await mintAndPrepareSelfServe()))
|
|
3501
|
+
return 1;
|
|
3502
|
+
selfServeRefreshed = true;
|
|
3503
|
+
// The refreshed token is brand new, so a 401 on it is no longer a
|
|
3504
|
+
// replay rejection and must not re-enter this branch.
|
|
3505
|
+
resumedSelfServeReplay = false;
|
|
3506
|
+
continue;
|
|
3507
|
+
}
|
|
3508
|
+
// The pending record is deliberately LEFT INTACT: it is the replay proof for a
|
|
3509
|
+
// retry (network failure / transient error), and destroying it would be the
|
|
3510
|
+
// unrecoverable case above. Every arm here is a terminal exchange failure —
|
|
3511
|
+
// invalid invite, network error, unreadable/unexpected response — so each
|
|
3512
|
+
// carries the doctor pointer exactly once (BAPI-669, U9a).
|
|
3513
|
+
return fatal(exchange.kind === "invalid-invite"
|
|
3514
|
+
? reusedPendingSecret
|
|
3515
|
+
? BOOTSTRAP_INVITE_REJECTED_MESSAGE
|
|
3516
|
+
: BOOTSTRAP_INVITE_LOST_SECRET_MESSAGE
|
|
3517
|
+
: // Transient/coarse failure: the exchange result is mode-neutral, so the
|
|
3518
|
+
// actionable retry instruction is appended here where the mode is known.
|
|
3519
|
+
`Error: ${exchange.message} ${buildBootstrapRetryAdvice(selfServeSignupMode)}`);
|
|
2546
3520
|
}
|
|
2547
|
-
|
|
2548
|
-
//
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
3521
|
+
// BAPI-668 (R5-2): the ONE orchestration transition that says "the invite is
|
|
3522
|
+
// spent and the key exists server-side". From here a failure is RESUMABLE, so
|
|
3523
|
+
// this same flag gates both the promotion path below and the catch block's
|
|
3524
|
+
// resume guidance — the two can never disagree about post-exchange state.
|
|
3525
|
+
bootstrapExchangeSucceeded = true;
|
|
3526
|
+
// The server-returned name is authoritative from here on: config, ping,
|
|
3527
|
+
// credential target, and the spawned session all key off it.
|
|
3528
|
+
if (exchange.repoName !== repoName) {
|
|
3529
|
+
const repointed = await deps.repointBootstrapPending({
|
|
3530
|
+
fromRepoName: repoName,
|
|
3531
|
+
toRepoName: exchange.repoName,
|
|
3532
|
+
inviteFingerprint,
|
|
3533
|
+
allowOverwriteExistingCredential: overwriteConsent,
|
|
3534
|
+
}, credentialWriteDeps);
|
|
3535
|
+
if (!repointed.ok) {
|
|
3536
|
+
return fatal(`Error: the project was created as '${exchange.repoName}' but the pending credential could ` +
|
|
3537
|
+
`not be re-pointed to it (${repointed.kind}). ${repointed.error}`);
|
|
3538
|
+
}
|
|
3539
|
+
repoName = exchange.repoName;
|
|
2556
3540
|
}
|
|
2557
|
-
|
|
2558
|
-
|
|
3541
|
+
log(` bootstrap invite redeemed — project '${repoName}' is ready`);
|
|
3542
|
+
// The minted key. From here the flow rejoins the existing path unchanged.
|
|
3543
|
+
apiKey = keySecret;
|
|
3544
|
+
}
|
|
3545
|
+
// ---- Step 2 (cont.) — verify connectivity BEFORE writing the key anywhere durable ----
|
|
3546
|
+
// R5: ping before persisting anything durably. Pinging before writeHostConfigs
|
|
3547
|
+
// (and the credential store) means a bad key on a first-time install halts
|
|
3548
|
+
// WITHOUT leaving an invalid key in the config — which would otherwise trip the
|
|
3549
|
+
// overwrite-consent gate on every retry (a trapped state). In invite mode this
|
|
3550
|
+
// runs AFTER the exchange, with the just-minted key; the pending record is the one
|
|
3551
|
+
// deliberate write that precedes it, for the protocol reason above.
|
|
3552
|
+
if (!bootstrapInviteMode) {
|
|
3553
|
+
currentStep = INSTALL_BRIDGE_STEP_LABELS.verifyConnectivity;
|
|
3554
|
+
log(currentStep);
|
|
3555
|
+
}
|
|
3556
|
+
// BAPI-668: a bootstrap-minted key is verified WITHOUT source attribution.
|
|
3557
|
+
const ping = await verifyConnectivity(deps, baseUrl, repoName, apiKey, bootstrapInviteMode ? undefined : apiKeySource);
|
|
3558
|
+
if (!ping.ok) {
|
|
3559
|
+
// BAPI-667: in either need-key mode the exchange already succeeded and the
|
|
3560
|
+
// pending record survives, so this failure IS resumable — but only with the
|
|
3561
|
+
// instruction that matches how the token was obtained.
|
|
3562
|
+
return fatal(bootstrapInviteMode
|
|
3563
|
+
? `Error: ${ping.message} ${buildBootstrapRetryAdvice(selfServeSignupMode)}`
|
|
3564
|
+
: `Error: ${ping.message}`);
|
|
3565
|
+
}
|
|
3566
|
+
log(" connectivity OK");
|
|
3567
|
+
const entry = buildInstallBridgeServerEntry(deps.cwd, repoName, apiKey, baseUrl, docsDir);
|
|
3568
|
+
// BAPI-666: the secret-free variant (no BAPI_API_KEY) is written instead of the
|
|
3569
|
+
// real entry into any git-tracked project config the user did not explicitly
|
|
3570
|
+
// consent to, and into any target left invalid — so the real key is never
|
|
3571
|
+
// written to a tracked or un-ignored project config without explicit consent.
|
|
3572
|
+
const secretFreeEntry = buildInstallBridgeSecretFreeServerEntry(deps.cwd, repoName, baseUrl, docsDir);
|
|
3573
|
+
// ---- Step 3 — write per-host MCP config ----
|
|
3574
|
+
// Valid, untracked (freshly-gitignored) project configs receive the REAL key.
|
|
3575
|
+
// Git-tracked configs require explicit TTY consent (default-No) or fall back to
|
|
3576
|
+
// the secret-free entry; invalid (unparseable) configs are left untouched.
|
|
3577
|
+
currentStep = INSTALL_BRIDGE_STEP_LABELS.writeHostConfigs;
|
|
3578
|
+
log(currentStep);
|
|
3579
|
+
// BAPI-635 (Step 8): every project-local, secret-bearing config MUST be
|
|
3580
|
+
// gitignored BEFORE the real API key is written. A project-target gitignore
|
|
3581
|
+
// failure is FATAL before the secret write (fixed, secret-free message).
|
|
3582
|
+
const gitignoreDeps = {
|
|
3583
|
+
readFile: deps.readFile,
|
|
3584
|
+
writeFile: (p, data) => deps.writeFile(p, data, { encoding: "utf-8" }),
|
|
3585
|
+
mkdir: (p, o) => deps.mkdir(p, o),
|
|
3586
|
+
};
|
|
3587
|
+
for (const target of targets) {
|
|
3588
|
+
try {
|
|
3589
|
+
await ensureGitignoredShared(deps.cwd, target.relPath, gitignoreDeps);
|
|
2559
3590
|
}
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
return 1;
|
|
2564
|
-
}
|
|
2565
|
-
// The server-returned name is authoritative from here on: config, ping,
|
|
2566
|
-
// credential target, and the spawned session all key off it.
|
|
2567
|
-
if (exchange.repoName !== repoName) {
|
|
2568
|
-
const repointed = await deps.repointBootstrapPending({
|
|
2569
|
-
fromRepoName: repoName,
|
|
2570
|
-
toRepoName: exchange.repoName,
|
|
2571
|
-
inviteFingerprint,
|
|
2572
|
-
allowOverwriteExistingCredential: overwriteConsent,
|
|
2573
|
-
}, credentialWriteDeps);
|
|
2574
|
-
if (!repointed.ok) {
|
|
2575
|
-
errorLog(`Error: the project was created as '${exchange.repoName}' but the pending credential could ` +
|
|
2576
|
-
`not be re-pointed to it (${repointed.kind}). ${repointed.error}`);
|
|
3591
|
+
catch {
|
|
3592
|
+
errorLog("Error: could not add a project MCP config to .gitignore before writing your key. " +
|
|
3593
|
+
"Aborting so the API key is never written to an un-ignored file.");
|
|
2577
3594
|
return 1;
|
|
2578
3595
|
}
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
//
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
3596
|
+
}
|
|
3597
|
+
// BAPI-666: after the (fatal) gitignore prerequisite has succeeded and before any
|
|
3598
|
+
// target can receive the real key, probe each project target's git-tracked state
|
|
3599
|
+
// (read-only, fail-open). A tracked config would carry the key into version control,
|
|
3600
|
+
// so it only receives the real key with explicit TTY consent — otherwise the
|
|
3601
|
+
// secret-free entry. Global targets are provisioned separately and are outside this
|
|
3602
|
+
// project-local Git guard.
|
|
3603
|
+
const trackedState = new Map();
|
|
3604
|
+
for (const target of targets) {
|
|
3605
|
+
trackedState.set(target.relPath, await isTrackedProjectConfig(deps, target.relPath));
|
|
3606
|
+
}
|
|
3607
|
+
const writeResult = await writeHostConfigs(deps, targets, { real: entry, secretFree: secretFreeEntry }, trackedState, { repoName, credentialStorePath });
|
|
3608
|
+
for (const { relPath, mode } of writeResult.written) {
|
|
3609
|
+
log(mode === "secret-free" ? ` wrote ${relPath} (secret-free — key resolved at runtime)` : ` wrote ${relPath}`);
|
|
3610
|
+
}
|
|
3611
|
+
for (const { relPath } of writeResult.skipped) {
|
|
3612
|
+
log(` skipped ${relPath} — existing config could not be parsed safely; left untouched`);
|
|
3613
|
+
}
|
|
3614
|
+
// BAPI-635: provision selected GLOBAL targets (Codex, Copilot CLI) and MANUAL
|
|
3615
|
+
// targets (Windsurf) via the registry-driven emitter. Global paths are never
|
|
3616
|
+
// added to the repository .gitignore.
|
|
3617
|
+
// BAPI-669 (R8): only a need-key run has a key the user has never seen, so only it
|
|
3618
|
+
// needs to be told where that key was stored. `bootstrapInviteMode` is true for the
|
|
3619
|
+
// explicit invite, the self-serve, AND the reclassified-invite flows alike, and
|
|
3620
|
+
// carries no credential — just the repo name and the store path.
|
|
3621
|
+
const needKeyManual = bootstrapInviteMode
|
|
3622
|
+
? { repoName, credentialStorePath }
|
|
3623
|
+
: undefined;
|
|
3624
|
+
const globalLogLines = await provisionSelectedGlobalTargets(deps, selectedPlatforms, entry, needKeyManual);
|
|
3625
|
+
for (const line of globalLogLines)
|
|
3626
|
+
log(line);
|
|
3627
|
+
// Legacy manual-editor instructions cover editors that are DETECTED but were
|
|
3628
|
+
// NOT part of the selection (so the emitter above did not handle them). The two
|
|
3629
|
+
// editors are suppressed INDEPENDENTLY: Codex is dropped when it was selected
|
|
3630
|
+
// (auto-provisioned or emitted above), Windsurf is dropped only when it was
|
|
3631
|
+
// selected — auto-provisioning Codex must never hide the Windsurf snippet.
|
|
3632
|
+
const legacyManualEditors = {
|
|
3633
|
+
windsurf: manualEditors.windsurf && !selectedPlatforms.includes("windsurf"),
|
|
3634
|
+
codex: manualEditors.codex && !selectedPlatforms.includes("codex"),
|
|
3635
|
+
};
|
|
3636
|
+
const manualInstructions = buildManualHostInstructions(entry, legacyManualEditors, needKeyManual);
|
|
3637
|
+
if (manualInstructions)
|
|
3638
|
+
log(manualInstructions);
|
|
3639
|
+
// BAPI-635 (Step 7): when both Claude Code and Copilot CLI are selected, warn
|
|
3640
|
+
// that they use different, non-shared config surfaces.
|
|
3641
|
+
if (selectedPlatforms.includes("claude-code") && selectedPlatforms.includes("copilot-cli")) {
|
|
3642
|
+
log(" Note: Claude Code uses the project .mcp.json while GitHub Copilot CLI uses only its " +
|
|
3643
|
+
"global ~/.copilot/mcp-config.json — the two are configured separately.");
|
|
3644
|
+
}
|
|
3645
|
+
// BAPI-635 (Step 7): Claude trust reminder — a written project MCP config is
|
|
3646
|
+
// not a live connection until approved in Claude Code's trust dialog.
|
|
3647
|
+
if (selectedPlatforms.includes("claude-code")) {
|
|
3648
|
+
log(" Claude Code: the project MCP server is pending approval in Claude Code's trust dialog; " +
|
|
3649
|
+
"restart or reload an already-running session for it to take effect.");
|
|
3650
|
+
}
|
|
3651
|
+
// BAPI-635 (Step 8 + Step 3): persist the secret-free install state, ignoring
|
|
3652
|
+
// it before the write. Project-local paths only; global paths are never in it.
|
|
2612
3653
|
try {
|
|
2613
|
-
await ensureGitignoredShared(deps.cwd,
|
|
3654
|
+
await ensureGitignoredShared(deps.cwd, ".bridge/install-state.json", gitignoreDeps);
|
|
3655
|
+
// writeMcpInstallState catches its own I/O errors and returns { ok: false }
|
|
3656
|
+
// (it does NOT throw), so the failure warning must inspect the return value —
|
|
3657
|
+
// a try/catch alone would silently swallow a real persistence failure.
|
|
3658
|
+
const stateResult = await writeMcpInstallState(deps.cwd, { selectedPlatforms, projectConfigPaths: targets.map((t) => t.relPath) }, {
|
|
3659
|
+
readFile: deps.readFile,
|
|
3660
|
+
writeFile: (p, data) => deps.writeFile(p, data, { encoding: "utf-8" }),
|
|
3661
|
+
rename: deps.rename,
|
|
3662
|
+
mkdir: async (p, o) => {
|
|
3663
|
+
await deps.mkdir(p, o);
|
|
3664
|
+
},
|
|
3665
|
+
unlink: deps.unlink,
|
|
3666
|
+
});
|
|
3667
|
+
if (!stateResult.ok) {
|
|
3668
|
+
// Install-state persistence is advisory — never fail the install over it.
|
|
3669
|
+
errorLog("Warning: could not persist the install-state file (non-fatal).");
|
|
3670
|
+
}
|
|
2614
3671
|
}
|
|
2615
3672
|
catch {
|
|
2616
|
-
|
|
2617
|
-
"Aborting so the API key is never written to an un-ignored file.");
|
|
2618
|
-
return 1;
|
|
2619
|
-
}
|
|
2620
|
-
}
|
|
2621
|
-
const written = await writeHostConfigs(deps, targets, entry);
|
|
2622
|
-
for (const relPath of written)
|
|
2623
|
-
log(` wrote ${relPath}`);
|
|
2624
|
-
// BAPI-635: provision selected GLOBAL targets (Codex, Copilot CLI) and MANUAL
|
|
2625
|
-
// targets (Windsurf) via the registry-driven emitter. Global paths are never
|
|
2626
|
-
// added to the repository .gitignore.
|
|
2627
|
-
const globalLogLines = await provisionSelectedGlobalTargets(deps, selectedPlatforms, entry);
|
|
2628
|
-
for (const line of globalLogLines)
|
|
2629
|
-
log(line);
|
|
2630
|
-
// Legacy manual-editor instructions cover editors that are DETECTED but were
|
|
2631
|
-
// NOT part of the selection (so the emitter above did not handle them). The two
|
|
2632
|
-
// editors are suppressed INDEPENDENTLY: Codex is dropped when it was selected
|
|
2633
|
-
// (auto-provisioned or emitted above), Windsurf is dropped only when it was
|
|
2634
|
-
// selected — auto-provisioning Codex must never hide the Windsurf snippet.
|
|
2635
|
-
const legacyManualEditors = {
|
|
2636
|
-
windsurf: manualEditors.windsurf && !selectedPlatforms.includes("windsurf"),
|
|
2637
|
-
codex: manualEditors.codex && !selectedPlatforms.includes("codex"),
|
|
2638
|
-
};
|
|
2639
|
-
const manualInstructions = buildManualHostInstructions(entry, legacyManualEditors);
|
|
2640
|
-
if (manualInstructions)
|
|
2641
|
-
log(manualInstructions);
|
|
2642
|
-
// BAPI-635 (Step 7): when both Claude Code and Copilot CLI are selected, warn
|
|
2643
|
-
// that they use different, non-shared config surfaces.
|
|
2644
|
-
if (selectedPlatforms.includes("claude-code") && selectedPlatforms.includes("copilot-cli")) {
|
|
2645
|
-
log(" Note: Claude Code uses the project .mcp.json while GitHub Copilot CLI uses only its " +
|
|
2646
|
-
"global ~/.copilot/mcp-config.json — the two are configured separately.");
|
|
2647
|
-
}
|
|
2648
|
-
// BAPI-635 (Step 7): Claude trust reminder — a written project MCP config is
|
|
2649
|
-
// not a live connection until approved in Claude Code's trust dialog.
|
|
2650
|
-
if (selectedPlatforms.includes("claude-code")) {
|
|
2651
|
-
log(" Claude Code: the project MCP server is pending approval in Claude Code's trust dialog; " +
|
|
2652
|
-
"restart or reload an already-running session for it to take effect.");
|
|
2653
|
-
}
|
|
2654
|
-
// BAPI-635 (Step 8 + Step 3): persist the secret-free install state, ignoring
|
|
2655
|
-
// it before the write. Project-local paths only; global paths are never in it.
|
|
2656
|
-
try {
|
|
2657
|
-
await ensureGitignoredShared(deps.cwd, ".bridge/install-state.json", gitignoreDeps);
|
|
2658
|
-
// writeMcpInstallState catches its own I/O errors and returns { ok: false }
|
|
2659
|
-
// (it does NOT throw), so the failure warning must inspect the return value —
|
|
2660
|
-
// a try/catch alone would silently swallow a real persistence failure.
|
|
2661
|
-
const stateResult = await writeMcpInstallState(deps.cwd, { selectedPlatforms, projectConfigPaths: targets.map((t) => t.relPath) }, {
|
|
2662
|
-
readFile: deps.readFile,
|
|
2663
|
-
writeFile: (p, data) => deps.writeFile(p, data, { encoding: "utf-8" }),
|
|
2664
|
-
rename: deps.rename,
|
|
2665
|
-
mkdir: async (p, o) => {
|
|
2666
|
-
await deps.mkdir(p, o);
|
|
2667
|
-
},
|
|
2668
|
-
unlink: deps.unlink,
|
|
2669
|
-
});
|
|
2670
|
-
if (!stateResult.ok) {
|
|
2671
|
-
// Install-state persistence is advisory — never fail the install over it.
|
|
3673
|
+
// ensureGitignored for the state file can still throw — also advisory.
|
|
2672
3674
|
errorLog("Warning: could not persist the install-state file (non-fatal).");
|
|
2673
3675
|
}
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
//
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
3676
|
+
// ---- Step 4 — persist the credential ----
|
|
3677
|
+
// Gated on the post-exchange transition rather than the mode flag (BAPI-668):
|
|
3678
|
+
// in bootstrap mode the exchange loop above is the ONLY way to reach here, so the
|
|
3679
|
+
// two are equivalent — but keying promotion to the same state that justifies the
|
|
3680
|
+
// catch block's resume guidance makes that invariant explicit and un-driftable.
|
|
3681
|
+
if (bootstrapExchangeSucceeded) {
|
|
3682
|
+
// FAIL-CLOSED (unlike normal mode below). The pending secret IS the minted admin
|
|
3683
|
+
// key — if it never lands under bapi:<repo>, the user has a live key they cannot
|
|
3684
|
+
// resolve. Promotion removes the pending record and writes the credential in one
|
|
3685
|
+
// durable replacement; on failure the pending record survives so a re-run replays
|
|
3686
|
+
// the same redemption. The agent session is NOT spawned until this succeeds.
|
|
3687
|
+
currentStep = INSTALL_BRIDGE_STEP_LABELS.promoteCredential;
|
|
3688
|
+
log(currentStep);
|
|
3689
|
+
const promoted = await deps.promoteBootstrapPending({ repoName, inviteFingerprint, allowOverwriteExistingCredential: overwriteConsent }, credentialWriteDeps);
|
|
3690
|
+
if (!promoted.ok) {
|
|
3691
|
+
// Mode-aware (BAPI-667): a self-serve user has no invite to "re-present" —
|
|
3692
|
+
// telling them to is an instruction they cannot follow.
|
|
3693
|
+
return fatal(`Error: the project and API key were created, but the credential could not be stored ` +
|
|
3694
|
+
`(${promoted.kind}). ${promoted.error} Your key is still saved locally as a pending ` +
|
|
3695
|
+
`record. ${buildBootstrapRetryAdvice(selfServeSignupMode)}`);
|
|
3696
|
+
}
|
|
3697
|
+
log(` stored routing credential for ${promoted.target} at ${promoted.path}`);
|
|
2694
3698
|
}
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
3699
|
+
else {
|
|
3700
|
+
// ---- persist routing credential (non-blocking / fail-open) ----
|
|
3701
|
+
currentStep = INSTALL_BRIDGE_STEP_LABELS.persistCredential;
|
|
3702
|
+
log(currentStep);
|
|
3703
|
+
try {
|
|
3704
|
+
const result = await deps.upsertCredential(repoName, apiKey, credentialWriteDeps);
|
|
3705
|
+
if (result.ok) {
|
|
3706
|
+
log(` stored routing credential for ${result.target} at ${result.path}`);
|
|
3707
|
+
}
|
|
3708
|
+
else {
|
|
3709
|
+
log(` warning: could not persist the routing credential (${result.kind}). ` +
|
|
3710
|
+
`start-tickets model routing may not resolve the key for bapi:${repoName} ` +
|
|
3711
|
+
"and will fail open to the premium/Opus tier (the most expensive) — " +
|
|
3712
|
+
"set BAPI_API_KEY in the shell or re-run install-bridge, then verify with " +
|
|
3713
|
+
"'npx -y @bridge_gpt/mcp-server doctor'.");
|
|
3714
|
+
}
|
|
2704
3715
|
}
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
"
|
|
3716
|
+
catch {
|
|
3717
|
+
// Fail-open: persistence is best-effort (mirrors Stage 6). The secret is never
|
|
3718
|
+
// included in the warning.
|
|
3719
|
+
log(" warning: could not persist the routing credential (unexpected error). " +
|
|
3720
|
+
"start-tickets model routing may need BAPI_API_KEY in the shell and will fail open " +
|
|
3721
|
+
"to the premium/Opus tier (the most expensive) until fixed — verify with " +
|
|
2710
3722
|
"'npx -y @bridge_gpt/mcp-server doctor'.");
|
|
2711
3723
|
}
|
|
2712
3724
|
}
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
log(" launcher bucket warmed (the first MCP launch will not pay a cold install).");
|
|
2738
|
-
log(` ${MCP_TIMEOUT_GUIDANCE}`);
|
|
3725
|
+
// ---- optional GitHub connect offer (BAPI-631) ----
|
|
3726
|
+
// Placed AFTER the credential is durable (the flow needs a resolvable key) and BEFORE
|
|
3727
|
+
// the agent spawn, for two reasons: the spawned session's capability report should
|
|
3728
|
+
// observe GitHub as configured if the user connects it here, and running it after the
|
|
3729
|
+
// spawn would put two prompts on the same terminal at once.
|
|
3730
|
+
await offerGithubConnection(repoName, baseUrl, deps, log);
|
|
3731
|
+
// ---- Step 3b (await) — settle the background pre-warm before the session opens ----
|
|
3732
|
+
// The warm was kicked off (only on a spawn-capable path) right after the
|
|
3733
|
+
// launch-command guards; await it HERE so the bucket is settled before the spawned
|
|
3734
|
+
// session's first MCP launch, but AFTER all the work it overlapped with — and BEFORE
|
|
3735
|
+
// the consent prompt, so no spawn ever races an in-flight warm. Strictly fail-open —
|
|
3736
|
+
// the exit code is never affected. Skipped entirely when no session will be opened.
|
|
3737
|
+
if (prewarmPromise) {
|
|
3738
|
+
const prewarm = await prewarmPromise;
|
|
3739
|
+
if (prewarm.ok) {
|
|
3740
|
+
log(" launcher bucket warmed (the first MCP launch will not pay a cold install).");
|
|
3741
|
+
log(` ${MCP_TIMEOUT_GUIDANCE}`);
|
|
3742
|
+
}
|
|
3743
|
+
else {
|
|
3744
|
+
// Escalate to a stronger, still secret-free warning when pre-warm didn't
|
|
3745
|
+
// complete cleanly — but the install itself still succeeds (exit unaffected).
|
|
3746
|
+
errorLog(`Warning: could not pre-warm the version-pinned launcher bucket${prewarm.warning ? ` (${prewarm.warning})` : ""}. ` +
|
|
3747
|
+
`The first MCP launch may pay a one-time cold install and could be slow. ${MCP_TIMEOUT_GUIDANCE}`);
|
|
3748
|
+
}
|
|
2739
3749
|
}
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
//
|
|
2770
|
-
//
|
|
2771
|
-
|
|
2772
|
-
log(
|
|
3750
|
+
// ---- Step 5 — consent-gate, then spawn the selected tool (or print continuation) ----
|
|
3751
|
+
// AC-7: for a spawn-capable path, ask Y/N first (`requestInstallBridgeLaunchConsent`
|
|
3752
|
+
// resolves to no-spawn without prompting on a non-TTY run). A decline, a no-launchable
|
|
3753
|
+
// selection, a non-TTY run, or a declined/aborted chooser all skip the spawn and fall
|
|
3754
|
+
// through to the shared /install-bridge continuation + limited-tools notice below — so
|
|
3755
|
+
// every no-session outcome gets identical, accurate remediation.
|
|
3756
|
+
if (finalAgentName && launchCommand) {
|
|
3757
|
+
const consent = await requestInstallBridgeLaunchConsent(toolLabelForLaunchAgent(finalAgentName), deps);
|
|
3758
|
+
if (consent === "spawn") {
|
|
3759
|
+
currentStep = buildInstallBridgeLaunchStepLabel(finalAgentName);
|
|
3760
|
+
log(currentStep);
|
|
3761
|
+
const terminal = detectTerminal(undefined, deps.env);
|
|
3762
|
+
// Only the validated short runner reaches the terminal — never the inline prompt.
|
|
3763
|
+
// The branded "Bridge Install" title labels the tab/badge instead of "install
|
|
3764
|
+
// Implementation" (AC-6).
|
|
3765
|
+
const spawnResult = await deps.spawnTerminalTab(deps.startTicketsDeps, terminal, launchCommand, {
|
|
3766
|
+
key: "install",
|
|
3767
|
+
worktreePath: deps.cwd,
|
|
3768
|
+
title: "Bridge Install",
|
|
3769
|
+
});
|
|
3770
|
+
if (!spawnResult.ok) {
|
|
3771
|
+
// Steps 1–4 (scaffold, config, connectivity-verified, credential persist) all
|
|
3772
|
+
// succeeded and are durable; only the best-effort Step 5 tab spawn failed. The
|
|
3773
|
+
// deterministic setup is complete, so this is a non-fatal warning (exit 0), with
|
|
3774
|
+
// the same shared continuation so the remediation is consistent.
|
|
3775
|
+
errorLog(`Warning: setup steps completed, but the agent session could not be opened (${spawnResult.error}).`);
|
|
3776
|
+
log(buildManualInstallBridgeContinuation("configured", labelsForPlatforms(selectedPlatforms)));
|
|
3777
|
+
return 0;
|
|
3778
|
+
}
|
|
3779
|
+
// BAPI-669 (U3): the handoff speaks the HOST TOOL'S display label, never the
|
|
3780
|
+
// raw agent binary name — computed once so all three sentences agree.
|
|
3781
|
+
const handoffToolLabel = toolLabelForLaunchAgent(finalAgentName);
|
|
3782
|
+
log("");
|
|
3783
|
+
log(`install-bridge setup steps complete. A fresh ${handoffToolLabel} session is now applying ` +
|
|
3784
|
+
"configuration, presenting the concise capability report, and recommending /learn-repository.");
|
|
3785
|
+
// The trust dialog is the single most common place a spawned session stalls:
|
|
3786
|
+
// the tab opens, nothing is approved, and the install looks hung.
|
|
3787
|
+
log(`In the new tab: approve the workspace and the 'bridge-api' MCP server if ${handoffToolLabel} ` +
|
|
3788
|
+
"asks — configuration can't proceed until you do.");
|
|
3789
|
+
// Recovery was previously printed ONLY on no-spawn paths, so a tab that opened
|
|
3790
|
+
// and then died left the user with no instruction at all.
|
|
3791
|
+
log(`If that tab is closed or fails, open a session in ${handoffToolLabel} and run /install-bridge.`);
|
|
3792
|
+
log("NOTE: the install is not finished until that session's apply reports applied fields — " +
|
|
3793
|
+
"it will pause to ask you to approve the project description. Indexing starts automatically " +
|
|
3794
|
+
"once the repository reaches full parse readiness — there is no indexing question to answer. " +
|
|
3795
|
+
`Verify afterwards at ${setupUrl} (Get Started page — install status panel) or via the ` +
|
|
3796
|
+
"session's 'Applied N of M' summary.");
|
|
2773
3797
|
return 0;
|
|
2774
3798
|
}
|
|
2775
|
-
|
|
2776
|
-
log(
|
|
2777
|
-
"configuration, presenting the concise capability report, and recommending /learn-repository.");
|
|
2778
|
-
log("NOTE: the install is not finished until that session's apply reports applied fields — " +
|
|
2779
|
-
"it will pause to ask you to approve the project description. Indexing starts automatically " +
|
|
2780
|
-
"once the repository reaches full parse readiness — there is no indexing question to answer. " +
|
|
2781
|
-
"Verify afterwards on the project's Get Started page (install status panel) or via the " +
|
|
2782
|
-
"session's 'Applied N of M' summary.");
|
|
3799
|
+
// Declined (or non-TTY suppression): the deterministic setup is complete and durable.
|
|
3800
|
+
log(buildManualInstallBridgeContinuation("configured", labelsForPlatforms(selectedPlatforms)));
|
|
2783
3801
|
return 0;
|
|
2784
3802
|
}
|
|
2785
|
-
//
|
|
3803
|
+
// No launchable session (a no-launchable selection, or a non-TTY / declined chooser):
|
|
3804
|
+
// finalAgentName is null, so nothing was materialized. The configured hosts are
|
|
3805
|
+
// durable — print the shared continuation + limited-tools note (never the
|
|
3806
|
+
// empty-selection variant, which already returned above).
|
|
2786
3807
|
log(buildManualInstallBridgeContinuation("configured", labelsForPlatforms(selectedPlatforms)));
|
|
2787
3808
|
return 0;
|
|
2788
3809
|
}
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
3810
|
+
catch (error) {
|
|
3811
|
+
// Resume guidance ONLY after the exchange actually succeeded (R5-2): before
|
|
3812
|
+
// that the invite is unspent and there is nothing to resume. The wording comes
|
|
3813
|
+
// from the mode-aware helper, never a fresh literal, so a self-serve user is
|
|
3814
|
+
// never told to re-present a token they were never shown.
|
|
3815
|
+
const resumeAdvice = bootstrapInviteMode && bootstrapExchangeSucceeded
|
|
3816
|
+
? buildBootstrapRetryAdvice(selfServeSignupMode)
|
|
3817
|
+
: undefined;
|
|
3818
|
+
// The formatter receives ONLY safe context: a printed step label, the debug
|
|
3819
|
+
// gate, and the helper-produced advice. No apiKey, inviteToken, fingerprint,
|
|
3820
|
+
// keySecret, signup email, or response body is in scope for it by construction.
|
|
3821
|
+
// Raw diagnostics are emitted through `errorLog`, NOT `deps.debugLog`, whose
|
|
3822
|
+
// contract admits only secret-free text.
|
|
3823
|
+
const lines = buildInstallBridgeFailureLines(error, {
|
|
3824
|
+
step: currentStep,
|
|
3825
|
+
debugEnabled: Boolean(deps.env.BAPI_INSTALL_DEBUG),
|
|
3826
|
+
...(resumeAdvice ? { resumeAdvice } : {}),
|
|
3827
|
+
});
|
|
3828
|
+
for (const line of lines)
|
|
3829
|
+
errorLog(line);
|
|
3830
|
+
return 1;
|
|
3831
|
+
}
|
|
2795
3832
|
}
|