@bridge_gpt/mcp-server 0.2.30 → 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/command-assets-doctor.js +72 -0
- package/build/command-provisioning.js +157 -0
- package/build/credential-store.js +302 -18
- package/build/doctor.js +80 -35
- package/build/executor/test-clock.js +3 -1
- package/build/executor/worktree.js +31 -2
- package/build/git-ignore-utils.js +67 -14
- package/build/index.js +2874 -2873
- package/build/install-bridge.js +1513 -453
- package/build/install-doctor.js +37 -4
- package/build/readme.generated.js +1 -1
- package/build/start-tickets-prereqs.js +45 -0
- package/build/start-tickets.js +44 -3
- package/build/version.generated.js +1 -1
- package/package.json +4 -3
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",
|
|
@@ -300,12 +358,15 @@ export function getInstallBridgeUsage() {
|
|
|
300
358
|
" --agent claude|cursor-agent Explicit launch override for the post-install",
|
|
301
359
|
" session — it always wins, and there is NO Claude",
|
|
302
360
|
" default. Without it the launch tool is derived",
|
|
303
|
-
" from your selection: Claude Code opens `claude
|
|
304
|
-
" Cursor
|
|
305
|
-
"
|
|
306
|
-
"
|
|
307
|
-
"
|
|
308
|
-
"
|
|
361
|
+
" from your selection: Claude Code opens `claude`.",
|
|
362
|
+
" Cursor is NOT auto-opened (its cursor-agent CLI",
|
|
363
|
+
" first-run workspace-trust prompt collapses a spawned",
|
|
364
|
+
" session); selecting Cursor writes .cursor/mcp.json",
|
|
365
|
+
" and prints how to finish by running /install-bridge",
|
|
366
|
+
" in a new Cursor session. A selection whose tools have",
|
|
367
|
+
" no agentic CLI (e.g. Copilot) likewise opens nothing",
|
|
368
|
+
" and prints how to finish configuring later. Passing",
|
|
369
|
+
" --agent cursor-agent still force-spawns it.",
|
|
309
370
|
" -h, --help Show this help.",
|
|
310
371
|
"",
|
|
311
372
|
"Environment: BAPI_BASE_URL (default https://bridgegpt-api.com) and BAPI_DOCS_DIR",
|
|
@@ -561,6 +622,25 @@ export function promptSecretViaReadline(promptText, input = process.stdin, outpu
|
|
|
561
622
|
muted = true;
|
|
562
623
|
});
|
|
563
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]: ";
|
|
564
644
|
/**
|
|
565
645
|
* Offer to connect GitHub, if it is not already connected (BAPI-631).
|
|
566
646
|
*
|
|
@@ -571,7 +651,7 @@ export function promptSecretViaReadline(promptText, input = process.stdin, outpu
|
|
|
571
651
|
* Reuses the shared connect-github flow rather than duplicating the API, polling,
|
|
572
652
|
* browser, or picker logic — there is exactly one implementation of that handshake.
|
|
573
653
|
*/
|
|
574
|
-
async function offerGithubConnection(repoName, deps, log) {
|
|
654
|
+
export async function offerGithubConnection(repoName, baseUrl, deps, log) {
|
|
575
655
|
// No prompt surface → no offer. Never assume consent on a non-interactive run.
|
|
576
656
|
if (!deps.isTTY || !deps.promptLine)
|
|
577
657
|
return;
|
|
@@ -592,8 +672,11 @@ async function offerGithubConnection(repoName, deps, log) {
|
|
|
592
672
|
if (!cred.ok)
|
|
593
673
|
return;
|
|
594
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.
|
|
595
678
|
fetch: deps.fetch,
|
|
596
|
-
baseUrl
|
|
679
|
+
baseUrl,
|
|
597
680
|
apiKey: cred.credentials.apiKey,
|
|
598
681
|
};
|
|
599
682
|
const state = await fetchGithubConfigurationState(api, repoName);
|
|
@@ -605,8 +688,14 @@ async function offerGithubConnection(repoName, deps, log) {
|
|
|
605
688
|
log(" note: could not read GitHub configuration status; skipping the GitHub offer.");
|
|
606
689
|
return;
|
|
607
690
|
}
|
|
608
|
-
|
|
609
|
-
|
|
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")
|
|
610
699
|
return; // declining is a normal outcome
|
|
611
700
|
const connectDeps = createDefaultConnectGithubDeps();
|
|
612
701
|
const code = await runGithubConnectionFlow(connectDeps, api, repoName);
|
|
@@ -735,11 +824,20 @@ export function createDefaultInstallBridgeDeps() {
|
|
|
735
824
|
prepareBootstrapPending: prepareBootstrapPendingCredential,
|
|
736
825
|
repointBootstrapPending: repointBootstrapPendingCredential,
|
|
737
826
|
promoteBootstrapPending: promoteBootstrapPendingCredential,
|
|
827
|
+
lookupSelfServeBootstrapPending: lookupSelfServeBootstrapPendingCredential,
|
|
828
|
+
discardBootstrapPending: discardBootstrapPendingCredential,
|
|
738
829
|
buildShellCommand: buildGenericAgentShellCommand,
|
|
739
830
|
spawnTerminalTab: getDefaultSpawnTerminalTabForPlatform(process.platform),
|
|
740
831
|
startTicketsDeps: createDefaultStartTicketsDeps(),
|
|
741
832
|
log: (m) => console.log(m),
|
|
742
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
|
+
},
|
|
743
841
|
};
|
|
744
842
|
}
|
|
745
843
|
/**
|
|
@@ -747,6 +845,10 @@ export function createDefaultInstallBridgeDeps() {
|
|
|
747
845
|
* interactive no-echo prompt. Fails (secret-free) when none is available and
|
|
748
846
|
* stdin is non-interactive.
|
|
749
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
|
+
*
|
|
750
852
|
* Despite the name, this is CREDENTIAL-AGNOSTIC (BAPI-661): the returned value may
|
|
751
853
|
* be a full Bridge API key or a bootstrap-invite token (`bapi_inv_…`) — the caller
|
|
752
854
|
* classifies it with {@link classifyEnteredCredential} immediately after this
|
|
@@ -756,27 +858,29 @@ export function createDefaultInstallBridgeDeps() {
|
|
|
756
858
|
*/
|
|
757
859
|
export async function resolveApiKey(options, deps) {
|
|
758
860
|
if (typeof options.apiKey === "string" && options.apiKey.trim().length > 0) {
|
|
759
|
-
return { ok: true, value: options.apiKey.trim() };
|
|
861
|
+
return { ok: true, value: options.apiKey.trim(), source: "flag" };
|
|
760
862
|
}
|
|
761
863
|
const fromEnv = deps.env.BAPI_API_KEY;
|
|
762
864
|
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
|
|
763
|
-
return { ok: true, value: fromEnv.trim() };
|
|
865
|
+
return { ok: true, value: fromEnv.trim(), source: "env" };
|
|
764
866
|
}
|
|
765
867
|
if (deps.isTTY && deps.promptSecret) {
|
|
766
868
|
const entered = (await deps.promptSecret("Bridge API key or invite (input hidden): ")).trim();
|
|
767
869
|
if (entered.length > 0) {
|
|
768
|
-
return { ok: true, value: entered };
|
|
870
|
+
return { ok: true, value: entered, source: "prompt" };
|
|
769
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.
|
|
770
875
|
return {
|
|
771
876
|
ok: false,
|
|
772
|
-
error:
|
|
773
|
-
"variable, or try the hidden prompt again.",
|
|
877
|
+
error: `No Bridge API key or invite entered. ${INSTALL_BRIDGE_ENTRY_ROUTES_ADVICE}`,
|
|
774
878
|
};
|
|
775
879
|
}
|
|
776
880
|
return {
|
|
777
881
|
ok: false,
|
|
778
|
-
error: "A Bridge API key or invite is required
|
|
779
|
-
|
|
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}`,
|
|
780
884
|
};
|
|
781
885
|
}
|
|
782
886
|
/**
|
|
@@ -881,8 +985,40 @@ export function resolveInstallBridgeOnboardingBranch(options, env) {
|
|
|
881
985
|
return { kind: "need-key", method: "self-serve" };
|
|
882
986
|
return { kind: "have-key" };
|
|
883
987
|
}
|
|
884
|
-
/**
|
|
885
|
-
|
|
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.";
|
|
886
1022
|
/**
|
|
887
1023
|
* Interactive wrapper around {@link resolveInstallBridgeOnboardingBranch}.
|
|
888
1024
|
*
|
|
@@ -903,6 +1039,13 @@ export const INSTALL_BRIDGE_KEY_SELECTOR_PROMPT = "Do you have a Bridge API key
|
|
|
903
1039
|
* A prompt failure becomes a typed, secret-free failure rather than an exception:
|
|
904
1040
|
* the caller turns it into an exit code, and terminal/internal error text never
|
|
905
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.
|
|
906
1049
|
*/
|
|
907
1050
|
export async function resolveInstallBridgeOnboardingBranchForRun(options, deps, argv) {
|
|
908
1051
|
const branch = resolveInstallBridgeOnboardingBranch(options, deps.env);
|
|
@@ -916,30 +1059,39 @@ export async function resolveInstallBridgeOnboardingBranchForRun(options, deps,
|
|
|
916
1059
|
}
|
|
917
1060
|
const promptLine = deps.promptLine;
|
|
918
1061
|
try {
|
|
919
|
-
//
|
|
920
|
-
//
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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")
|
|
925
1076
|
return { ok: true, branch: { kind: "have-key" } };
|
|
1077
|
+
if (answer === "2") {
|
|
1078
|
+
return { ok: true, branch: { kind: "need-key", method: "bootstrap-invite" } };
|
|
926
1079
|
}
|
|
927
|
-
if (answer === "
|
|
1080
|
+
if (answer === "3")
|
|
928
1081
|
return { ok: true, branch: { kind: "need-key", method: "self-serve" } };
|
|
929
|
-
|
|
930
|
-
|
|
1082
|
+
if (attempt === 0)
|
|
1083
|
+
deps.log(INSTALL_BRIDGE_ONBOARDING_CHOICE_HINT);
|
|
931
1084
|
}
|
|
932
1085
|
return {
|
|
933
1086
|
ok: false,
|
|
934
|
-
error:
|
|
1087
|
+
error: `No option was selected. ${INSTALL_BRIDGE_ENTRY_ROUTES_ADVICE}`,
|
|
935
1088
|
};
|
|
936
1089
|
}
|
|
937
1090
|
catch {
|
|
938
1091
|
// Secret-free by construction: the caught value is never surfaced.
|
|
939
1092
|
return {
|
|
940
1093
|
ok: false,
|
|
941
|
-
error:
|
|
942
|
-
"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}`,
|
|
943
1095
|
};
|
|
944
1096
|
}
|
|
945
1097
|
}
|
|
@@ -1131,18 +1283,37 @@ export async function resolveSelectedHostPlatforms(deps, options) {
|
|
|
1131
1283
|
legacy.push("copilot-vscode");
|
|
1132
1284
|
return HOST_PLATFORM_ORDER.filter((id) => legacy.includes(id));
|
|
1133
1285
|
}
|
|
1286
|
+
/**
|
|
1287
|
+
* Launch agents install-bridge will NEVER auto-spawn from a host SELECTION, even
|
|
1288
|
+
* though their CLI exists and the host's MCP config is still written normally.
|
|
1289
|
+
*
|
|
1290
|
+
* `cursor-agent` is excluded: its FIRST-RUN workspace-trust handshake collapses
|
|
1291
|
+
* under our osascript-spawned launch — the "Workspace Trust Required" dialog
|
|
1292
|
+
* renders, then the process exits before a selection registers, so configuration
|
|
1293
|
+
* never runs (reproduced on a pristine, never-trusted workspace; the identical
|
|
1294
|
+
* command works only where Cursor already trusts the directory). The trust gate is
|
|
1295
|
+
* cursor-agent's own behavior on an untrusted workspace and is not something our
|
|
1296
|
+
* launch can drive, so we treat Cursor as a no-auto-launch host: selecting it
|
|
1297
|
+
* still writes `.cursor/mcp.json`, then prints the manual `/install-bridge`
|
|
1298
|
+
* continuation (same path as Copilot/Codex/Windsurf, which have no agentic CLI).
|
|
1299
|
+
* An explicit `--agent cursor-agent` override still spawns it — a deliberate,
|
|
1300
|
+
* documented power-user opt-in that bypasses this selection guard.
|
|
1301
|
+
*/
|
|
1302
|
+
const SELECTION_NON_LAUNCHABLE_AGENTS = new Set(["cursor-agent"]);
|
|
1134
1303
|
/**
|
|
1135
1304
|
* Resolve the post-install launch decision from the selected host platforms and an
|
|
1136
1305
|
* OPTIONAL explicit `--agent` override (BAPI-657). Pure and total:
|
|
1137
1306
|
*
|
|
1138
1307
|
* - An explicit `--agent` is an ABSOLUTE override (ratified AC-4): a valid explicit
|
|
1139
1308
|
* agent always returns `spawn(agent)`, regardless of which tools were selected —
|
|
1140
|
-
* the flag is honored even for a host outside the selection
|
|
1309
|
+
* the flag is honored even for a host outside the selection, and it bypasses the
|
|
1310
|
+
* {@link SELECTION_NON_LAUNCHABLE_AGENTS} guard below.
|
|
1141
1311
|
* - Otherwise the selection drives it: map each selected host through
|
|
1142
|
-
* {@link agentForPlatform}, drop non-launchable
|
|
1143
|
-
*
|
|
1144
|
-
* `manual("
|
|
1145
|
-
* `
|
|
1312
|
+
* {@link agentForPlatform}, drop hosts that are non-launchable OR excluded from
|
|
1313
|
+
* selection auto-spawn ({@link SELECTION_NON_LAUNCHABLE_AGENTS}, e.g. Cursor),
|
|
1314
|
+
* dedup in registry order, then return `manual("empty-selection")` for an empty
|
|
1315
|
+
* selection, `manual("no-launchable-agent")` for zero launchable agents, `spawn`
|
|
1316
|
+
* for one, or `choose-one` for more than one.
|
|
1146
1317
|
*
|
|
1147
1318
|
* Never prompts, logs, resolves an {@link AgentSpec}, or touches the filesystem.
|
|
1148
1319
|
*/
|
|
@@ -1158,8 +1329,9 @@ export function resolveInstallBridgeLaunchDecision(selectedPlatforms, explicitAg
|
|
|
1158
1329
|
if (!selectedPlatforms.includes(id))
|
|
1159
1330
|
continue;
|
|
1160
1331
|
const agent = agentForPlatform(id);
|
|
1161
|
-
if (agent && !agents.includes(agent))
|
|
1332
|
+
if (agent && !SELECTION_NON_LAUNCHABLE_AGENTS.has(agent) && !agents.includes(agent)) {
|
|
1162
1333
|
agents.push(agent);
|
|
1334
|
+
}
|
|
1163
1335
|
}
|
|
1164
1336
|
if (agents.length === 0)
|
|
1165
1337
|
return { kind: "manual", reason: "no-launchable-agent" };
|
|
@@ -1171,12 +1343,17 @@ export function resolveInstallBridgeLaunchDecision(selectedPlatforms, explicitAg
|
|
|
1171
1343
|
* The human-facing tool label for a launch agent, derived by scanning the host
|
|
1172
1344
|
* registry's {@link HostTargetDefinition.launchAgent} metadata rather than keeping a
|
|
1173
1345
|
* duplicate agent→label map (BAPI-657). Returns e.g. `"Claude Code"` for `"claude"`
|
|
1174
|
-
* and `"Cursor"` for `"cursor-agent"
|
|
1175
|
-
*
|
|
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.
|
|
1176
1352
|
*/
|
|
1353
|
+
export const UNKNOWN_LAUNCH_TOOL_LABEL = "your AI coding tool";
|
|
1177
1354
|
export function toolLabelForLaunchAgent(agent) {
|
|
1178
1355
|
const target = allHostTargets().find((t) => t.launchAgent === agent);
|
|
1179
|
-
return target?.label ??
|
|
1356
|
+
return target?.label ?? UNKNOWN_LAUNCH_TOOL_LABEL;
|
|
1180
1357
|
}
|
|
1181
1358
|
/**
|
|
1182
1359
|
* Interactive numbered chooser for when MORE THAN ONE launchable tool was selected
|
|
@@ -1246,7 +1423,7 @@ export function buildManualInstallBridgeContinuation(kind, toolLabels) {
|
|
|
1246
1423
|
const toolPhrase = formatToolLabelPhrase(toolLabels);
|
|
1247
1424
|
return [
|
|
1248
1425
|
`To finish configuring this project, open it in ${toolPhrase} that has the`,
|
|
1249
|
-
"Bridge MCP server configured and run /install-bridge.",
|
|
1426
|
+
"Bridge MCP server configured, start a new session, and run /install-bridge.",
|
|
1250
1427
|
"Until the project is configured, your Bridge MCP tools stay limited.",
|
|
1251
1428
|
].join("\n");
|
|
1252
1429
|
}
|
|
@@ -1401,58 +1578,244 @@ export function buildInstallBridgeServerEntry(cwd, repoName, apiKey, baseUrl, do
|
|
|
1401
1578
|
};
|
|
1402
1579
|
return { command: entry.command, args: entry.args, env };
|
|
1403
1580
|
}
|
|
1404
|
-
/**
|
|
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
|
+
*/
|
|
1405
1647
|
async function readHostConfig(deps, fullPath) {
|
|
1406
1648
|
let raw;
|
|
1407
1649
|
try {
|
|
1408
1650
|
raw = await deps.readFile(fullPath);
|
|
1409
1651
|
}
|
|
1410
|
-
catch {
|
|
1411
|
-
|
|
1412
|
-
|
|
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;
|
|
1413
1661
|
try {
|
|
1414
|
-
|
|
1415
|
-
return parsed && typeof parsed === "object" ? parsed : null;
|
|
1662
|
+
parsed = JSON.parse(raw);
|
|
1416
1663
|
}
|
|
1417
1664
|
catch {
|
|
1418
|
-
return
|
|
1665
|
+
return { state: "invalid" };
|
|
1666
|
+
}
|
|
1667
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1668
|
+
return { state: "invalid" };
|
|
1419
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;
|
|
1420
1677
|
}
|
|
1421
1678
|
/**
|
|
1422
1679
|
* Detect whether any resolved host config already carries a real (non-placeholder)
|
|
1423
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).
|
|
1424
1684
|
*/
|
|
1425
1685
|
async function detectExistingRealKey(deps, targets) {
|
|
1426
1686
|
for (const target of targets) {
|
|
1427
|
-
const
|
|
1428
|
-
|
|
1429
|
-
|
|
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"])) {
|
|
1430
1696
|
return true;
|
|
1431
1697
|
}
|
|
1432
1698
|
}
|
|
1433
1699
|
return false;
|
|
1434
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
|
+
}
|
|
1435
1750
|
/**
|
|
1436
1751
|
* Write the `bridge-api` entry into each host config via read-merge-write,
|
|
1437
|
-
* preserving unrelated servers and top-level keys.
|
|
1438
|
-
*
|
|
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.
|
|
1439
1763
|
*/
|
|
1440
|
-
async function writeHostConfigs(deps, targets,
|
|
1764
|
+
async function writeHostConfigs(deps, targets, entries, trackedState, ctx) {
|
|
1441
1765
|
const written = [];
|
|
1766
|
+
const skipped = [];
|
|
1442
1767
|
for (const target of targets) {
|
|
1443
1768
|
const fullPath = path.join(deps.cwd, target.relPath);
|
|
1444
|
-
const
|
|
1445
|
-
|
|
1446
|
-
|
|
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;
|
|
1447
1779
|
}
|
|
1448
|
-
|
|
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;
|
|
1449
1812
|
await deps.mkdir(path.dirname(fullPath), { recursive: true });
|
|
1450
|
-
await deps.writeFile(fullPath, JSON.stringify(
|
|
1813
|
+
await deps.writeFile(fullPath, JSON.stringify(config, null, 2) + "\n", {
|
|
1451
1814
|
encoding: "utf-8",
|
|
1452
1815
|
});
|
|
1453
|
-
written.push(target.relPath);
|
|
1816
|
+
written.push({ relPath: target.relPath, mode });
|
|
1454
1817
|
}
|
|
1455
|
-
return written;
|
|
1818
|
+
return { written, skipped };
|
|
1456
1819
|
}
|
|
1457
1820
|
/**
|
|
1458
1821
|
* Provision the selected GLOBAL (Codex, Copilot CLI) and MANUAL (Windsurf)
|
|
@@ -1461,9 +1824,15 @@ async function writeHostConfigs(deps, targets, entry) {
|
|
|
1461
1824
|
* config paths are never added to the repository .gitignore. Returns secret-free
|
|
1462
1825
|
* log lines and whether Codex was auto-provisioned (so the legacy Codex manual
|
|
1463
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.
|
|
1464
1832
|
*/
|
|
1465
|
-
async function provisionSelectedGlobalTargets(deps, platforms, entry) {
|
|
1833
|
+
async function provisionSelectedGlobalTargets(deps, platforms, entry, needKey) {
|
|
1466
1834
|
const logLines = [];
|
|
1835
|
+
let anyManualRequired = false;
|
|
1467
1836
|
const provisionDeps = {
|
|
1468
1837
|
fs: {
|
|
1469
1838
|
readFile: deps.readFile,
|
|
@@ -1493,6 +1862,7 @@ async function provisionSelectedGlobalTargets(deps, platforms, entry) {
|
|
|
1493
1862
|
logLines.push(` configured ${target.label} (${outcome.displayPath})`);
|
|
1494
1863
|
break;
|
|
1495
1864
|
case "manual-required":
|
|
1865
|
+
anyManualRequired = true;
|
|
1496
1866
|
logLines.push(` ${target.label}: add the bridge-api MCP server manually to ${outcome.displayPath} ` +
|
|
1497
1867
|
"(the API key is redacted in printed instructions).");
|
|
1498
1868
|
break;
|
|
@@ -1504,6 +1874,9 @@ async function provisionSelectedGlobalTargets(deps, platforms, entry) {
|
|
|
1504
1874
|
break;
|
|
1505
1875
|
}
|
|
1506
1876
|
}
|
|
1877
|
+
if (needKey && anyManualRequired) {
|
|
1878
|
+
logLines.push(` ${formatNeedKeyCredentialStoreLine(needKey.repoName, needKey.credentialStorePath)}`);
|
|
1879
|
+
}
|
|
1507
1880
|
return logLines;
|
|
1508
1881
|
}
|
|
1509
1882
|
/** Build the `/jira/ping` URL exactly like the MCP `ping` tool / buildGetUrl. */
|
|
@@ -1521,13 +1894,30 @@ export function buildPingUrl(baseUrl, repoName) {
|
|
|
1521
1894
|
*/
|
|
1522
1895
|
const CONNECTIVITY_ACCESS_DENIED_FALLBACK = "The Bridge API denied access to this repository (HTTP 403). Verify the repo_name and that this " +
|
|
1523
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
|
+
};
|
|
1524
1910
|
/**
|
|
1525
1911
|
* Verify connectivity. Distinguishes a rejected/expired credential (401) from a
|
|
1526
1912
|
* repository-specific access denial (403, BAPI-661) from an unknown repo /
|
|
1527
1913
|
* not-found (404) where the response allows. The key is sent in the `X-API-Key`
|
|
1528
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.
|
|
1529
1919
|
*/
|
|
1530
|
-
export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
|
|
1920
|
+
export async function verifyConnectivity(deps, baseUrl, repoName, apiKey, apiKeySource) {
|
|
1531
1921
|
const url = buildPingUrl(baseUrl, repoName);
|
|
1532
1922
|
let resp;
|
|
1533
1923
|
try {
|
|
@@ -1551,11 +1941,16 @@ export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
|
|
|
1551
1941
|
if (resp.ok)
|
|
1552
1942
|
return { ok: true };
|
|
1553
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);
|
|
1554
1948
|
return {
|
|
1555
1949
|
ok: false,
|
|
1556
1950
|
message: `The Bridge API rejected the credential (HTTP ${resp.status}). The API key may be invalid or expired ` +
|
|
1557
|
-
|
|
1558
|
-
"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] : ""),
|
|
1559
1954
|
};
|
|
1560
1955
|
}
|
|
1561
1956
|
if (resp.status === 403) {
|
|
@@ -1657,10 +2052,15 @@ export function generateBootstrapKeySecret(randomBytes) {
|
|
|
1657
2052
|
return randomBytes(BOOTSTRAP_KEY_SECRET_BYTES).toString("base64url");
|
|
1658
2053
|
}
|
|
1659
2054
|
/**
|
|
1660
|
-
* SHA-256 hex digest of the bootstrap-invite token — the
|
|
1661
|
-
* pending record
|
|
1662
|
-
*
|
|
1663
|
-
*
|
|
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.
|
|
1664
2064
|
*/
|
|
1665
2065
|
export function fingerprintBootstrapInvite(token) {
|
|
1666
2066
|
return createHash("sha256").update(token, "utf-8").digest("hex");
|
|
@@ -1689,12 +2089,15 @@ export async function exchangeBootstrapInvite(deps, baseUrl, token, repoName, ke
|
|
|
1689
2089
|
// The message may contain the request (some fetch impls echo it), so it is
|
|
1690
2090
|
// deliberately NOT interpolated here.
|
|
1691
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}.
|
|
1692
2096
|
return {
|
|
1693
2097
|
ok: false,
|
|
1694
2098
|
kind: "failed",
|
|
1695
2099
|
message: `Could not reach the Bridge API at ${baseUrl} to redeem the bootstrap invite. Check ` +
|
|
1696
|
-
"BAPI_BASE_URL and your network
|
|
1697
|
-
"re-run will reuse the same locally-stored secret.",
|
|
2100
|
+
"BAPI_BASE_URL and your network. The invite has not been used.",
|
|
1698
2101
|
};
|
|
1699
2102
|
}
|
|
1700
2103
|
if (resp.ok) {
|
|
@@ -1859,6 +2262,76 @@ export const BOOTSTRAP_INVITE_LOST_SECRET_MESSAGE = [
|
|
|
1859
2262
|
export const BOOTSTRAP_INVITE_REJECTED_MESSAGE = "The Bridge API rejected the bootstrap invite (HTTP 401). The invite is invalid, expired, or " +
|
|
1860
2263
|
"revoked — ask your Bridge API operator for a new one. (Your locally-stored secret was sent " +
|
|
1861
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
|
+
}
|
|
1862
2335
|
/**
|
|
1863
2336
|
* Render the --dry-run preview lines. Every secret is ALWAYS redacted — the
|
|
1864
2337
|
* spawnCommand, config preview, and (in bootstrap-invite mode) the exchange body
|
|
@@ -1881,7 +2354,9 @@ export function buildDryRunPreview(plan) {
|
|
|
1881
2354
|
"",
|
|
1882
2355
|
"Step 1 — scaffold (runInit): commands, agents, pipelines, .bridge/config, secret-free MCP placeholders.",
|
|
1883
2356
|
`Step 2 — connectivity ping (before any durable key write): GET ${plan.pingUrl} (X-API-Key: ${REDACTED_API_KEY})`,
|
|
1884
|
-
"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):",
|
|
1885
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}`),
|
|
1886
2361
|
...(plan.manualEditors.length > 0
|
|
1887
2362
|
? [` ${plan.manualEditors.join(" + ")}: detected (global config) — manual setup instructions would be printed.`]
|
|
@@ -1925,9 +2400,16 @@ function buildLaunchStepPreview(plan) {
|
|
|
1925
2400
|
// BAPI-631: described, never performed in --dry-run — a preview must not open a
|
|
1926
2401
|
// browser or reach the network. It is also strictly optional, so it carries no step
|
|
1927
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.
|
|
1928
2407
|
"Step 4b — optional GitHub connect (SKIPPED in --dry-run): read GitHub's configured state",
|
|
1929
2408
|
" via the install manifest and, only when it is unconfigured and the terminal is",
|
|
1930
|
-
|
|
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}`,
|
|
1931
2413
|
];
|
|
1932
2414
|
if (plan.launch.kind === "spawn") {
|
|
1933
2415
|
return [
|
|
@@ -1978,9 +2460,12 @@ function buildBootstrapDryRunPreview(plan) {
|
|
|
1978
2460
|
const selfServeStep = plan.selfServeSignup
|
|
1979
2461
|
? [
|
|
1980
2462
|
"Step 2·pre — self-serve signup (PREVIEWED, SKIPPED in --dry-run): no Bridge workspace",
|
|
1981
|
-
" signup is requested, no mint call is made,
|
|
1982
|
-
|
|
1983
|
-
"
|
|
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.",
|
|
1984
2469
|
]
|
|
1985
2470
|
: [];
|
|
1986
2471
|
return [
|
|
@@ -1998,7 +2483,9 @@ function buildBootstrapDryRunPreview(plan) {
|
|
|
1998
2483
|
` POST ${plan.exchangeUrl}`,
|
|
1999
2484
|
` body: {"token": "${REDACTED_API_KEY}", "repo_name": "${plan.repoName}", "key_secret": "${REDACTED_API_KEY}"}`,
|
|
2000
2485
|
`Step 2c — connectivity ping with the newly-minted key (before any durable key write): GET ${plan.pingUrl} (X-API-Key: ${REDACTED_API_KEY})`,
|
|
2001
|
-
"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):",
|
|
2002
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}`),
|
|
2003
2490
|
...(plan.manualEditors.length > 0
|
|
2004
2491
|
? [` ${plan.manualEditors.join(" + ")}: detected (global config) — manual setup instructions would be printed.`]
|
|
@@ -2041,12 +2528,29 @@ export function manualEditorNames(editors) {
|
|
|
2041
2528
|
names.push("Codex");
|
|
2042
2529
|
return names;
|
|
2043
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
|
+
}
|
|
2044
2545
|
/**
|
|
2045
2546
|
* Build the manual-instruction block for the DETECTED global-config editors only
|
|
2046
2547
|
* (key always redacted). Returns `null` when neither Windsurf nor Codex is
|
|
2047
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.
|
|
2048
2552
|
*/
|
|
2049
|
-
function buildManualHostInstructions(entry, editors) {
|
|
2553
|
+
function buildManualHostInstructions(entry, editors, needKey) {
|
|
2050
2554
|
if (!editors.windsurf && !editors.codex)
|
|
2051
2555
|
return null;
|
|
2052
2556
|
const redactedEnv = { ...entry.env, BAPI_API_KEY: REDACTED_API_KEY };
|
|
@@ -2062,9 +2566,252 @@ function buildManualHostInstructions(entry, editors) {
|
|
|
2062
2566
|
if (editors.codex) {
|
|
2063
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).");
|
|
2064
2568
|
}
|
|
2569
|
+
if (needKey) {
|
|
2570
|
+
lines.push("", ` ${formatNeedKeyCredentialStoreLine(needKey.repoName, needKey.credentialStorePath)}`);
|
|
2571
|
+
}
|
|
2065
2572
|
return lines.join("\n");
|
|
2066
2573
|
}
|
|
2067
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
|
+
// ---------------------------------------------------------------------------
|
|
2068
2815
|
// CLI entry
|
|
2069
2816
|
// ---------------------------------------------------------------------------
|
|
2070
2817
|
/**
|
|
@@ -2081,18 +2828,90 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
2081
2828
|
deps.resolveRepoViaServer = (baseUrl, apiKey) => resolveRepoViaServer(deps.fetch, baseUrl, apiKey);
|
|
2082
2829
|
}
|
|
2083
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;
|
|
2084
2861
|
const parsed = parseInstallBridgeArgs(argv);
|
|
2085
2862
|
if (parsed.status === "help") {
|
|
2086
|
-
log(
|
|
2863
|
+
log(getInstallBridgeUsage(usageBaseUrl));
|
|
2087
2864
|
return 0;
|
|
2088
2865
|
}
|
|
2089
2866
|
if (parsed.status === "error") {
|
|
2090
2867
|
errorLog(`Error: ${parsed.message}`);
|
|
2091
2868
|
errorLog("");
|
|
2092
|
-
errorLog(getInstallBridgeUsage());
|
|
2869
|
+
errorLog(getInstallBridgeUsage(usageBaseUrl));
|
|
2093
2870
|
return 1;
|
|
2094
2871
|
}
|
|
2095
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
|
+
}
|
|
2096
2915
|
// Onboarding branch: have-key vs. a need-key method (`bootstrap-invite` = redeem
|
|
2097
2916
|
// a pre-issued invite, `self-serve` = mint one from an email, BAPI-618). BOTH
|
|
2098
2917
|
// need-key methods share the downstream redemption protocol, so
|
|
@@ -2126,6 +2945,11 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
2126
2945
|
let apiKey = "";
|
|
2127
2946
|
let inviteToken = "";
|
|
2128
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;
|
|
2129
2953
|
if (selfServeSignupMode) {
|
|
2130
2954
|
const emailResult = await resolveSignupEmail(options, deps);
|
|
2131
2955
|
if (!emailResult.ok) {
|
|
@@ -2158,14 +2982,17 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
2158
2982
|
apiKey = "";
|
|
2159
2983
|
bootstrapInviteMode = true;
|
|
2160
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;
|
|
2161
2988
|
}
|
|
2162
2989
|
else {
|
|
2163
2990
|
apiKey = keyResult.value;
|
|
2991
|
+
apiKeySource = keyResult.source;
|
|
2164
2992
|
}
|
|
2165
2993
|
}
|
|
2166
|
-
// baseUrl
|
|
2167
|
-
//
|
|
2168
|
-
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.
|
|
2169
2996
|
const docsDir = deps.env.BAPI_DOCS_DIR ?? DEFAULT_BAPI_DOCS_DIR;
|
|
2170
2997
|
// ---- Resolve the repository name ----
|
|
2171
2998
|
// Need-key (invite or self-serve): choose-a-name for the project this run is
|
|
@@ -2212,7 +3039,7 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
2212
3039
|
// (BAPI-661) WITH a visible signal that the fallback is happening, so a
|
|
2213
3040
|
// guessed name is never confirmed silently as if it had been verified.
|
|
2214
3041
|
log("Couldn't auto-resolve your repo from the key; falling back to a guessed name — confirm " +
|
|
2215
|
-
|
|
3042
|
+
`it matches ${setupUrl} (setup UI).`);
|
|
2216
3043
|
const repoResult = await resolveRepoName(options, deps, "existing-registration");
|
|
2217
3044
|
if (!repoResult.ok) {
|
|
2218
3045
|
errorLog(`Error: ${repoResult.error}`);
|
|
@@ -2387,386 +3214,619 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
2387
3214
|
return 1;
|
|
2388
3215
|
}
|
|
2389
3216
|
}
|
|
2390
|
-
// ----
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
//
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
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}`);
|
|
2413
3260
|
}
|
|
2414
|
-
|
|
2415
|
-
|
|
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}`);
|
|
2416
3271
|
}
|
|
2417
|
-
return 1;
|
|
2418
3272
|
}
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
let prepared = await deps.prepareBootstrapPending({
|
|
2447
|
-
repoName,
|
|
2448
|
-
inviteFingerprint,
|
|
2449
|
-
generateKeySecret: () => generateBootstrapKeySecret(deps.randomBytes),
|
|
2450
|
-
allowOverwriteExistingCredential: overwriteConsent,
|
|
2451
|
-
}, credentialWriteDeps);
|
|
2452
|
-
// An existing bapi:<repo> credential is never clobbered without consent. The
|
|
2453
|
-
// gate mirrors the host-config gate above: --force, else an interactive prompt,
|
|
2454
|
-
// else a hard non-interactive failure — all BEFORE the exchange.
|
|
2455
|
-
if (!prepared.ok && prepared.kind === "credential-conflict") {
|
|
2456
|
-
if (deps.isTTY && deps.promptLine) {
|
|
2457
|
-
const answer = (await deps.promptLine(`The credential store already holds a key for ${prepared.target}. Overwrite it? [y/N]: `)).trim().toLowerCase();
|
|
2458
|
-
if (answer !== "y" && answer !== "yes") {
|
|
2459
|
-
errorLog("Aborted: existing credential left unchanged (re-run with --force to overwrite).");
|
|
2460
|
-
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;
|
|
2461
3300
|
}
|
|
2462
|
-
|
|
2463
|
-
|
|
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({
|
|
2464
3305
|
repoName,
|
|
2465
3306
|
inviteFingerprint,
|
|
2466
3307
|
generateKeySecret: () => generateBootstrapKeySecret(deps.randomBytes),
|
|
2467
|
-
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,
|
|
2468
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;
|
|
2469
3347
|
}
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
//
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
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)`);
|
|
2511
3419
|
}
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
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)}`);
|
|
2523
3520
|
}
|
|
2524
|
-
|
|
2525
|
-
//
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
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;
|
|
2533
3540
|
}
|
|
2534
|
-
|
|
2535
|
-
|
|
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);
|
|
2536
3590
|
}
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
return 1;
|
|
2541
|
-
}
|
|
2542
|
-
// The server-returned name is authoritative from here on: config, ping,
|
|
2543
|
-
// credential target, and the spawned session all key off it.
|
|
2544
|
-
if (exchange.repoName !== repoName) {
|
|
2545
|
-
const repointed = await deps.repointBootstrapPending({
|
|
2546
|
-
fromRepoName: repoName,
|
|
2547
|
-
toRepoName: exchange.repoName,
|
|
2548
|
-
inviteFingerprint,
|
|
2549
|
-
allowOverwriteExistingCredential: overwriteConsent,
|
|
2550
|
-
}, credentialWriteDeps);
|
|
2551
|
-
if (!repointed.ok) {
|
|
2552
|
-
errorLog(`Error: the project was created as '${exchange.repoName}' but the pending credential could ` +
|
|
2553
|
-
`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.");
|
|
2554
3594
|
return 1;
|
|
2555
3595
|
}
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
//
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
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.
|
|
2589
3653
|
try {
|
|
2590
|
-
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
|
+
}
|
|
2591
3671
|
}
|
|
2592
3672
|
catch {
|
|
2593
|
-
|
|
2594
|
-
"Aborting so the API key is never written to an un-ignored file.");
|
|
2595
|
-
return 1;
|
|
2596
|
-
}
|
|
2597
|
-
}
|
|
2598
|
-
const written = await writeHostConfigs(deps, targets, entry);
|
|
2599
|
-
for (const relPath of written)
|
|
2600
|
-
log(` wrote ${relPath}`);
|
|
2601
|
-
// BAPI-635: provision selected GLOBAL targets (Codex, Copilot CLI) and MANUAL
|
|
2602
|
-
// targets (Windsurf) via the registry-driven emitter. Global paths are never
|
|
2603
|
-
// added to the repository .gitignore.
|
|
2604
|
-
const globalLogLines = await provisionSelectedGlobalTargets(deps, selectedPlatforms, entry);
|
|
2605
|
-
for (const line of globalLogLines)
|
|
2606
|
-
log(line);
|
|
2607
|
-
// Legacy manual-editor instructions cover editors that are DETECTED but were
|
|
2608
|
-
// NOT part of the selection (so the emitter above did not handle them). The two
|
|
2609
|
-
// editors are suppressed INDEPENDENTLY: Codex is dropped when it was selected
|
|
2610
|
-
// (auto-provisioned or emitted above), Windsurf is dropped only when it was
|
|
2611
|
-
// selected — auto-provisioning Codex must never hide the Windsurf snippet.
|
|
2612
|
-
const legacyManualEditors = {
|
|
2613
|
-
windsurf: manualEditors.windsurf && !selectedPlatforms.includes("windsurf"),
|
|
2614
|
-
codex: manualEditors.codex && !selectedPlatforms.includes("codex"),
|
|
2615
|
-
};
|
|
2616
|
-
const manualInstructions = buildManualHostInstructions(entry, legacyManualEditors);
|
|
2617
|
-
if (manualInstructions)
|
|
2618
|
-
log(manualInstructions);
|
|
2619
|
-
// BAPI-635 (Step 7): when both Claude Code and Copilot CLI are selected, warn
|
|
2620
|
-
// that they use different, non-shared config surfaces.
|
|
2621
|
-
if (selectedPlatforms.includes("claude-code") && selectedPlatforms.includes("copilot-cli")) {
|
|
2622
|
-
log(" Note: Claude Code uses the project .mcp.json while GitHub Copilot CLI uses only its " +
|
|
2623
|
-
"global ~/.copilot/mcp-config.json — the two are configured separately.");
|
|
2624
|
-
}
|
|
2625
|
-
// BAPI-635 (Step 7): Claude trust reminder — a written project MCP config is
|
|
2626
|
-
// not a live connection until approved in Claude Code's trust dialog.
|
|
2627
|
-
if (selectedPlatforms.includes("claude-code")) {
|
|
2628
|
-
log(" Claude Code: the project MCP server is pending approval in Claude Code's trust dialog; " +
|
|
2629
|
-
"restart or reload an already-running session for it to take effect.");
|
|
2630
|
-
}
|
|
2631
|
-
// BAPI-635 (Step 8 + Step 3): persist the secret-free install state, ignoring
|
|
2632
|
-
// it before the write. Project-local paths only; global paths are never in it.
|
|
2633
|
-
try {
|
|
2634
|
-
await ensureGitignoredShared(deps.cwd, ".bridge/install-state.json", gitignoreDeps);
|
|
2635
|
-
// writeMcpInstallState catches its own I/O errors and returns { ok: false }
|
|
2636
|
-
// (it does NOT throw), so the failure warning must inspect the return value —
|
|
2637
|
-
// a try/catch alone would silently swallow a real persistence failure.
|
|
2638
|
-
const stateResult = await writeMcpInstallState(deps.cwd, { selectedPlatforms, projectConfigPaths: targets.map((t) => t.relPath) }, {
|
|
2639
|
-
readFile: deps.readFile,
|
|
2640
|
-
writeFile: (p, data) => deps.writeFile(p, data, { encoding: "utf-8" }),
|
|
2641
|
-
rename: deps.rename,
|
|
2642
|
-
mkdir: async (p, o) => {
|
|
2643
|
-
await deps.mkdir(p, o);
|
|
2644
|
-
},
|
|
2645
|
-
unlink: deps.unlink,
|
|
2646
|
-
});
|
|
2647
|
-
if (!stateResult.ok) {
|
|
2648
|
-
// Install-state persistence is advisory — never fail the install over it.
|
|
3673
|
+
// ensureGitignored for the state file can still throw — also advisory.
|
|
2649
3674
|
errorLog("Warning: could not persist the install-state file (non-fatal).");
|
|
2650
3675
|
}
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
//
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
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}`);
|
|
2671
3698
|
}
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
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
|
+
}
|
|
2681
3715
|
}
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
"
|
|
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 " +
|
|
2687
3722
|
"'npx -y @bridge_gpt/mcp-server doctor'.");
|
|
2688
3723
|
}
|
|
2689
3724
|
}
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
log(" launcher bucket warmed (the first MCP launch will not pay a cold install).");
|
|
2715
|
-
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
|
+
}
|
|
2716
3749
|
}
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
//
|
|
2747
|
-
//
|
|
2748
|
-
|
|
2749
|
-
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.");
|
|
2750
3797
|
return 0;
|
|
2751
3798
|
}
|
|
2752
|
-
|
|
2753
|
-
log(
|
|
2754
|
-
"configuration, presenting the concise capability report, and recommending /learn-repository.");
|
|
2755
|
-
log("NOTE: the install is not finished until that session's apply reports applied fields — " +
|
|
2756
|
-
"it will pause to ask you to approve the project description. Indexing starts automatically " +
|
|
2757
|
-
"once the repository reaches full parse readiness — there is no indexing question to answer. " +
|
|
2758
|
-
"Verify afterwards on the project's Get Started page (install status panel) or via the " +
|
|
2759
|
-
"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)));
|
|
2760
3801
|
return 0;
|
|
2761
3802
|
}
|
|
2762
|
-
//
|
|
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).
|
|
2763
3807
|
log(buildManualInstallBridgeContinuation("configured", labelsForPlatforms(selectedPlatforms)));
|
|
2764
3808
|
return 0;
|
|
2765
3809
|
}
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
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
|
+
}
|
|
2772
3832
|
}
|