@bridge_gpt/mcp-server 0.2.24 → 0.2.26
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 +98 -28
- package/build/agents.generated.js +1 -1
- package/build/bridge-api-urls.js +31 -0
- package/build/commands.generated.js +5 -5
- package/build/conductor/epic-reconcile.js +7 -1
- package/build/conductor/epic-runtime.js +5 -0
- package/build/conductor-bundle-artifacts.js +802 -0
- package/build/conductor-bundle-cli.js +256 -0
- package/build/connect-github-api.js +365 -0
- package/build/connect-github.js +415 -0
- package/build/decision-page-schema.js +34 -5
- package/build/decision-page-template.js +117 -35
- package/build/docs.generated.js +2 -1
- package/build/doctor.js +148 -1
- package/build/env-flags.js +31 -0
- package/build/index.js +3467 -498
- package/build/init.js +7 -3
- package/build/install-bridge.js +624 -38
- package/build/install-doctor.js +64 -0
- package/build/mcp-host-config.js +521 -0
- package/build/mcp-host-targets.js +194 -0
- package/build/mcp-install-state.js +175 -0
- package/build/pipelines.generated.js +127 -132
- package/build/readme.generated.js +1 -1
- package/build/start-tickets.js +166 -18
- package/build/tool-surface-gating.js +396 -0
- package/build/version.generated.js +1 -1
- package/docs/install/github-app.md +80 -17
- package/docs/install/mcp-tool-integrations.md +2 -2
- package/package.json +5 -5
- package/pipelines/learn-repository.json +111 -119
- package/public/css/main.min.css +258 -65
- package/public/css/main.min.css.map +1 -1
- package/public/js/main.min.js +188 -92
- package/public/js/main.min.js.map +1 -1
- package/smoke-test/SMOKE-TEST.md +4 -4
package/build/install-bridge.js
CHANGED
|
@@ -36,8 +36,26 @@
|
|
|
36
36
|
* reload the just-written `.mcp.json`, and field derivation needs an agent
|
|
37
37
|
* runtime the shell does not have.
|
|
38
38
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
39
|
+
* That spawn command embeds the entire agent prompt and runs to multiple KB, which
|
|
40
|
+
* no macOS terminal will accept as one typed line. So it is never typed: the full
|
|
41
|
+
* command is written to a restricted (0600) launch script and only a short
|
|
42
|
+
* `. '<path>'` runner is spawned (BAPI-626, via `materializeWorkerLaunchCommand`).
|
|
43
|
+
* Materialization happens BEFORE any install side effect, because a command that
|
|
44
|
+
* cannot be launched is a whole-run failure, not a Step 5 warning.
|
|
45
|
+
*
|
|
46
|
+
* ONBOARDING BRANCHES — `have-key` (consume an existing key) vs. `need-key` (this
|
|
47
|
+
* command creates the project AND its first admin key). There are two need-key
|
|
48
|
+
* methods, differing only in how the first token is obtained: `bootstrap-invite`
|
|
49
|
+
* (BAPI-606) redeems a pre-issued invite; `self-serve` (BAPI-618) mints one from an
|
|
50
|
+
* email. Both then feed the SAME redemption protocol. Selection is pure and
|
|
51
|
+
* deterministic from flags/env, except that a BARE interactive run is asked which
|
|
52
|
+
* branch it wants (BAPI-626) — without that question a first-time user cannot
|
|
53
|
+
* discover self-serve at all. Either need-key method NAMES a new project, so the
|
|
54
|
+
* repository prompt asks for a new project name rather than an existing
|
|
55
|
+
* registration (see `RepoNamePromptMode`).
|
|
56
|
+
*
|
|
57
|
+
* BOOTSTRAP-INVITE MODE (BAPI-606) — one of the two exceptions to "this command
|
|
58
|
+
* consumes a key, it does not create one". With `--invite` (or `BAPI_INVITE`) there is no
|
|
41
59
|
* API key yet, so the pre-flight ping of Step 2 CANNOT be made: the exchange is
|
|
42
60
|
* what mints the key, and it REPLACES that ping. The order becomes:
|
|
43
61
|
*
|
|
@@ -70,10 +88,18 @@ import readline from "readline";
|
|
|
70
88
|
import { runInit, buildBridgeApiEntry } from "./init.js";
|
|
71
89
|
import { VERSION } from "./version.generated.js";
|
|
72
90
|
import { validateRepoName } from "./bridge-config.js";
|
|
91
|
+
import { MCP_HOST_TARGETS, HOST_PLATFORM_ORDER, allHostTargets, isHostPlatformId, detectDefaultPlatforms, } from "./mcp-host-targets.js";
|
|
92
|
+
import { provisionHostTarget, createDefaultVendorProcessDeps, } from "./mcp-host-config.js";
|
|
93
|
+
import { writeMcpInstallState } from "./mcp-install-state.js";
|
|
94
|
+
import { ensureGitignored as ensureGitignoredShared, } from "./git-ignore-utils.js";
|
|
73
95
|
import { resolveStartTicketsRepoName } from "./start-tickets-repo.js";
|
|
74
|
-
import { upsertBapiCredential, getPrimaryCredentialStorePath, prepareBootstrapPendingCredential, repointBootstrapPendingCredential, promoteBootstrapPendingCredential, } from "./credential-store.js";
|
|
96
|
+
import { upsertBapiCredential, getPrimaryCredentialStorePath, prepareBootstrapPendingCredential, repointBootstrapPendingCredential, promoteBootstrapPendingCredential, resolveBapiCredentials, } from "./credential-store.js";
|
|
97
|
+
// BAPI-631: the optional GitHub connect offer reuses the standalone command's flow and
|
|
98
|
+
// API primitives verbatim — no duplicated polling, browser, or picker logic here.
|
|
99
|
+
import { fetchGithubConfigurationState } from "./connect-github-api.js";
|
|
100
|
+
import { createDefaultConnectGithubDeps, runGithubConnectionFlow } from "./connect-github.js";
|
|
75
101
|
import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, formatValidAgentNames, } from "./agent-registry.js";
|
|
76
|
-
import { buildGenericAgentShellCommand, getDefaultSpawnTerminalTabForPlatform, detectTerminal, createDefaultStartTicketsDeps, } from "./start-tickets.js";
|
|
102
|
+
import { buildGenericAgentShellCommand, getDefaultSpawnTerminalTabForPlatform, detectTerminal, createDefaultStartTicketsDeps, materializeWorkerLaunchCommand, MAX_TERMINAL_COMMAND_BYTES, } from "./start-tickets.js";
|
|
77
103
|
/** Redaction sentinel — the API-key value is NEVER printed; this stands in. */
|
|
78
104
|
export const REDACTED_API_KEY = "<REDACTED>";
|
|
79
105
|
/**
|
|
@@ -126,8 +152,10 @@ export const INSTALL_BRIDGE_AGENT_PROMPT = "Execute the /install-bridge command
|
|
|
126
152
|
"claim the job was queued — report the sanitized result and leave indexing pending. " +
|
|
127
153
|
"On NO (or any unavailable/non-interactive resolution): do not index; print the exact copy-paste " +
|
|
128
154
|
"continuation command '/parse-repository' on its own line and state that indexing remains pending. " +
|
|
129
|
-
"Never request, echo, or transport any credential — only ever direct the human to
|
|
130
|
-
"
|
|
155
|
+
"Never request, echo, or transport any credential — only ever direct the human to that " +
|
|
156
|
+
"integration's own configure_in pointer, verbatim. The pointer is per-integration and is NOT " +
|
|
157
|
+
"always the setup UI: GitHub's is a terminal command (connect-github), while Jira, SFCC, and " +
|
|
158
|
+
"Bitbucket point at the setup UI. Follow whatever the report says rather than assuming. " +
|
|
131
159
|
"End with an explicit summary line stating how many config fields the apply_install_manifest call " +
|
|
132
160
|
"applied (e.g. 'Applied 8 of 9 derived fields') and whether indexing was queued or left pending — " +
|
|
133
161
|
"if 0 fields were applied, say so loudly and explain what is still pending.";
|
|
@@ -146,13 +174,20 @@ export function getInstallBridgeUsage() {
|
|
|
146
174
|
"routing credential, then opens a fresh agent session to derive the remaining",
|
|
147
175
|
"config, present a capability report, and offer optional repository indexing.",
|
|
148
176
|
"",
|
|
177
|
+
"Run it bare — `install-bridge` with no flags — in a terminal and it asks",
|
|
178
|
+
`\`${INSTALL_BRIDGE_KEY_SELECTOR_PROMPT.trim()}\` first. Answer yes (or press Enter) for the`,
|
|
179
|
+
"existing-key flow below; answer no and it asks for an email and creates a new",
|
|
180
|
+
"Bridge workspace for you (the self-serve flow). That question is asked ONLY for a",
|
|
181
|
+
"bare interactive run: passing ANY flag, setting BAPI_API_KEY, or running without",
|
|
182
|
+
"an interactive terminal keeps the existing deterministic behavior and no prompt.",
|
|
183
|
+
"",
|
|
149
184
|
"Inputs (the only two irreducible ones):",
|
|
150
185
|
" --api-key <key> Bridge API key. Falls back to the BAPI_API_KEY env var,",
|
|
151
186
|
" then an interactive (no-echo) prompt. Generate one in the",
|
|
152
187
|
" Bridge API web UI Security page — this command consumes a",
|
|
153
|
-
" key, it does not create one (--invite
|
|
154
|
-
"
|
|
155
|
-
" printed or logged.",
|
|
188
|
+
" key, it does not create one (--email and --invite are the",
|
|
189
|
+
" exceptions: they CREATE the project and its first admin key).",
|
|
190
|
+
" NEVER printed or logged.",
|
|
156
191
|
" --repo <name> Repository name. --repo and BAPI_REPO_NAME still take",
|
|
157
192
|
" priority and short-circuit before any network call. When",
|
|
158
193
|
" neither is set, a compatible server resolves the unique",
|
|
@@ -160,10 +195,13 @@ export function getInstallBridgeUsage() {
|
|
|
160
195
|
" server is older, the key is unresolvable, or resolution",
|
|
161
196
|
" fails, it falls back to an inferred default you confirm",
|
|
162
197
|
" interactively (and to a required --repo when stdin is",
|
|
163
|
-
" non-interactive). MUST match the
|
|
164
|
-
" registration (it keys the credential store
|
|
165
|
-
"
|
|
166
|
-
"
|
|
198
|
+
" non-interactive). In the existing-key flow it MUST match the",
|
|
199
|
+
" server-side repo registration (it keys the credential store",
|
|
200
|
+
" as bapi:<repo>). In either new-project flow (--email,",
|
|
201
|
+
" --invite, or a negative answer to the key question above) it",
|
|
202
|
+
" instead NAMES the project this run creates, so you are asked",
|
|
203
|
+
" to name a new project rather than match an existing one; the",
|
|
204
|
+
" name must be globally unique.",
|
|
167
205
|
"",
|
|
168
206
|
"Self-serve onboarding (no account, no API key, no pre-issued invite):",
|
|
169
207
|
" --email <addr> Create a brand-new Bridge workspace from just an email —",
|
|
@@ -171,8 +209,10 @@ export function getInstallBridgeUsage() {
|
|
|
171
209
|
" It requests a fresh workspace for that email, then creates",
|
|
172
210
|
" the project and mints your own admin API key in one command.",
|
|
173
211
|
" Falls back to the BAPI_SIGNUP_EMAIL env var, then a visible",
|
|
174
|
-
" interactive prompt
|
|
175
|
-
"
|
|
212
|
+
" interactive prompt — which is also what a negative answer to",
|
|
213
|
+
" the bare-run key question above reaches. The email is NOT a",
|
|
214
|
+
" secret (it is shown as you type), but it is never printed to",
|
|
215
|
+
" a log. Mutually",
|
|
176
216
|
" exclusive with --api-key and --invite. No email verification",
|
|
177
217
|
" is performed and no message is sent to the address — it only",
|
|
178
218
|
" labels the new workspace.",
|
|
@@ -196,6 +236,18 @@ export function getInstallBridgeUsage() {
|
|
|
196
236
|
" THE TOKEN to your shell history and to the process list.",
|
|
197
237
|
"",
|
|
198
238
|
"Flags:",
|
|
239
|
+
" --tools <ids> Comma-separated AI-coding tools to configure,",
|
|
240
|
+
" bypassing the interactive picker. Accepted ids:",
|
|
241
|
+
` ${HOST_PLATFORM_ORDER.join(", ")}.`,
|
|
242
|
+
" Both --tools=claude-code,codex and",
|
|
243
|
+
" --tools claude-code,codex are accepted. On an",
|
|
244
|
+
" interactive terminal WITHOUT this flag you are",
|
|
245
|
+
" asked which tools you use (Claude Code plus any",
|
|
246
|
+
" detected editors are pre-checked). A non-",
|
|
247
|
+
" interactive run without --tools writes the legacy",
|
|
248
|
+
" automatic set (Claude Code plus any detected",
|
|
249
|
+
" Cursor / Copilot VS Code). --tools= (empty) is an",
|
|
250
|
+
" explicit empty selection and writes nothing.",
|
|
199
251
|
" --force Overwrite an existing real BAPI_API_KEY in a",
|
|
200
252
|
" host config (or in the credential store) without",
|
|
201
253
|
" prompting.",
|
|
@@ -235,6 +287,7 @@ export function parseInstallBridgeArgs(argv) {
|
|
|
235
287
|
let agentName = DEFAULT_AGENT_NAME;
|
|
236
288
|
let invite;
|
|
237
289
|
let email;
|
|
290
|
+
let tools;
|
|
238
291
|
// Track SUPPLIED-ness separately from the values: `--invite` is legitimately
|
|
239
292
|
// valueless (prompt path) and `--api-key ""` is still a contradiction with it.
|
|
240
293
|
let inviteSupplied = false;
|
|
@@ -309,6 +362,17 @@ export function parseInstallBridgeArgs(argv) {
|
|
|
309
362
|
i = r.nextIndex;
|
|
310
363
|
continue;
|
|
311
364
|
}
|
|
365
|
+
if (arg === "--tools" || arg.startsWith("--tools=")) {
|
|
366
|
+
const r = readValue(arg, "--tools", i);
|
|
367
|
+
if ("error" in r)
|
|
368
|
+
return { status: "error", message: r.error };
|
|
369
|
+
const parsed = parseToolsSelection(r.value);
|
|
370
|
+
if ("error" in parsed)
|
|
371
|
+
return { status: "error", message: parsed.error };
|
|
372
|
+
tools = parsed.tools;
|
|
373
|
+
i = r.nextIndex;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
312
376
|
if (arg === "--agent" || arg.startsWith("--agent=")) {
|
|
313
377
|
const r = readValue(arg, "--agent", i);
|
|
314
378
|
if ("error" in r)
|
|
@@ -359,9 +423,36 @@ export function parseInstallBridgeArgs(argv) {
|
|
|
359
423
|
}
|
|
360
424
|
return {
|
|
361
425
|
status: "ok",
|
|
362
|
-
options: { apiKey, repo, force, dryRun, agentName, invite, inviteMode: inviteSupplied, email },
|
|
426
|
+
options: { apiKey, repo, force, dryRun, agentName, invite, inviteMode: inviteSupplied, email, tools },
|
|
363
427
|
};
|
|
364
428
|
}
|
|
429
|
+
/**
|
|
430
|
+
* Parse a `--tools` value into a validated platform-ID selection. A blank value
|
|
431
|
+
* (`--tools=`) is an EXPLICIT empty selection (`[]`), not an error. Comma-split
|
|
432
|
+
* IDs are trimmed, deduped in registry order, and each is validated against the
|
|
433
|
+
* host registry allowlist so an unvalidated string can never choose a path or
|
|
434
|
+
* command. Unknown IDs are rejected by name (the value is safe to echo — it is
|
|
435
|
+
* a platform ID, never a secret).
|
|
436
|
+
*/
|
|
437
|
+
export function parseToolsSelection(value) {
|
|
438
|
+
const raw = value
|
|
439
|
+
.split(",")
|
|
440
|
+
.map((s) => s.trim())
|
|
441
|
+
.filter((s) => s.length > 0);
|
|
442
|
+
const seen = new Set();
|
|
443
|
+
for (const id of raw) {
|
|
444
|
+
if (!isHostPlatformId(id)) {
|
|
445
|
+
const allowed = HOST_PLATFORM_ORDER.join(", ");
|
|
446
|
+
return {
|
|
447
|
+
error: `Invalid --tools value: '${id}' (allowed tools: ${allowed}).`,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
seen.add(id);
|
|
451
|
+
}
|
|
452
|
+
// Dedupe + deterministic registry order.
|
|
453
|
+
const tools = HOST_PLATFORM_ORDER.filter((id) => seen.has(id));
|
|
454
|
+
return { tools };
|
|
455
|
+
}
|
|
365
456
|
/**
|
|
366
457
|
* No-echo secret prompt on stderr (so it never lands in piped stdout).
|
|
367
458
|
*
|
|
@@ -420,6 +511,66 @@ export function promptSecretViaReadline(promptText, input = process.stdin, outpu
|
|
|
420
511
|
muted = true;
|
|
421
512
|
});
|
|
422
513
|
}
|
|
514
|
+
/**
|
|
515
|
+
* Offer to connect GitHub, if it is not already connected (BAPI-631).
|
|
516
|
+
*
|
|
517
|
+
* Entirely best-effort and non-destructive: by the time this runs the install itself is
|
|
518
|
+
* already complete and durable, so nothing here may fail the run. Every branch that is
|
|
519
|
+
* not "the user said yes and it worked" simply proceeds to the agent session.
|
|
520
|
+
*
|
|
521
|
+
* Reuses the shared connect-github flow rather than duplicating the API, polling,
|
|
522
|
+
* browser, or picker logic — there is exactly one implementation of that handshake.
|
|
523
|
+
*/
|
|
524
|
+
async function offerGithubConnection(repoName, deps, log) {
|
|
525
|
+
// No prompt surface → no offer. Never assume consent on a non-interactive run.
|
|
526
|
+
if (!deps.isTTY || !deps.promptLine)
|
|
527
|
+
return;
|
|
528
|
+
try {
|
|
529
|
+
const credDeps = {
|
|
530
|
+
env: deps.env,
|
|
531
|
+
homedir: deps.homedir,
|
|
532
|
+
platform: deps.platform,
|
|
533
|
+
readFile: deps.readFile,
|
|
534
|
+
stat: deps.stat,
|
|
535
|
+
stderr: () => { },
|
|
536
|
+
};
|
|
537
|
+
// Resolve through the shared resolver rather than reusing an in-memory key from
|
|
538
|
+
// this run: the offered flow is the same shell-spawned surface the standalone
|
|
539
|
+
// command uses, and it must resolve credentials the same way (project MCP config
|
|
540
|
+
// env is NOT visible to a spawned shell).
|
|
541
|
+
const cred = await resolveBapiCredentials(repoName, credDeps);
|
|
542
|
+
if (!cred.ok)
|
|
543
|
+
return;
|
|
544
|
+
const api = {
|
|
545
|
+
fetch: deps.fetch,
|
|
546
|
+
baseUrl: deps.env.BAPI_BASE_URL?.trim() || DEFAULT_BAPI_BASE_URL,
|
|
547
|
+
apiKey: cred.credentials.apiKey,
|
|
548
|
+
};
|
|
549
|
+
const state = await fetchGithubConfigurationState(api, repoName);
|
|
550
|
+
if (state === "configured")
|
|
551
|
+
return;
|
|
552
|
+
if (state === "unavailable") {
|
|
553
|
+
// Do NOT fabricate "unconfigured" from a probe that simply failed — offering to
|
|
554
|
+
// connect an already-connected repo is worse than staying quiet.
|
|
555
|
+
log(" note: could not read GitHub configuration status; skipping the GitHub offer.");
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const answer = (await deps.promptLine("Connect GitHub? (Y/n): ")).trim().toLowerCase();
|
|
559
|
+
if (answer === "n" || answer === "no")
|
|
560
|
+
return; // declining is a normal outcome
|
|
561
|
+
const connectDeps = createDefaultConnectGithubDeps();
|
|
562
|
+
const code = await runGithubConnectionFlow(connectDeps, api, repoName);
|
|
563
|
+
if (code !== 0) {
|
|
564
|
+
log(" note: GitHub was not connected. Your install is complete — connect GitHub later with " +
|
|
565
|
+
`'npx -y @bridge_gpt/mcp-server@latest connect-github --repo ${repoName}'.`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
catch {
|
|
569
|
+
// The install is already durable; a failure here is never fatal to it.
|
|
570
|
+
log(" note: the GitHub connection offer could not run. Your install is complete — connect " +
|
|
571
|
+
`GitHub later with 'npx -y @bridge_gpt/mcp-server@latest connect-github --repo ${repoName}'.`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
423
574
|
/** Echoed single-line prompt on stderr (used for repo confirmation / value). */
|
|
424
575
|
function promptLineViaReadline(promptText) {
|
|
425
576
|
return new Promise((resolve) => {
|
|
@@ -524,6 +675,8 @@ export function createDefaultInstallBridgeDeps() {
|
|
|
524
675
|
randomBytes: (size) => cryptoRandomBytes(size),
|
|
525
676
|
promptSecret: isTTY ? promptSecretViaReadline : undefined,
|
|
526
677
|
promptLine: isTTY ? promptLineViaReadline : undefined,
|
|
678
|
+
promptMultiSelect: isTTY ? promptMultiSelectViaReadline : undefined,
|
|
679
|
+
vendor: createDefaultVendorProcessDeps(spawn),
|
|
527
680
|
fetch: productionFetch,
|
|
528
681
|
resolveRepoViaServer: (baseUrl, apiKey) => resolveRepoViaServer(productionFetch, baseUrl, apiKey),
|
|
529
682
|
spawnPrewarm: spawnPrewarmDefault,
|
|
@@ -666,6 +819,68 @@ export function resolveInstallBridgeOnboardingBranch(options, env) {
|
|
|
666
819
|
return { kind: "need-key", method: "self-serve" };
|
|
667
820
|
return { kind: "have-key" };
|
|
668
821
|
}
|
|
822
|
+
/** The exact visible text of the bare-TTY onboarding selector (BAPI-626). */
|
|
823
|
+
export const INSTALL_BRIDGE_KEY_SELECTOR_PROMPT = "Do you have a Bridge API key? [Y/n] ";
|
|
824
|
+
/**
|
|
825
|
+
* Interactive wrapper around {@link resolveInstallBridgeOnboardingBranch}.
|
|
826
|
+
*
|
|
827
|
+
* The pure resolver defaults every un-signalled invocation to `have-key`, which is
|
|
828
|
+
* correct for a script but wrong for a human: a first-time user with nothing yet
|
|
829
|
+
* runs a bare `install-bridge`, gets the hidden API-key prompt, and has no way to
|
|
830
|
+
* discover that the self-serve email path exists. So a BARE INTERACTIVE run — and
|
|
831
|
+
* only that — is asked which branch it wants.
|
|
832
|
+
*
|
|
833
|
+
* "Bare" is deliberately strict. Any of these keeps the existing deterministic
|
|
834
|
+
* behaviour with NO prompt:
|
|
835
|
+
*
|
|
836
|
+
* - stdin is not a TTY, or no `promptLine` seam is available (scripts, CI);
|
|
837
|
+
* - any CLI argument was supplied (the user already stated an intent);
|
|
838
|
+
* - a non-blank `BAPI_API_KEY` is present (that IS the existing-key intent);
|
|
839
|
+
* - the pure resolver already chose a need-key branch explicitly.
|
|
840
|
+
*
|
|
841
|
+
* A prompt failure becomes a typed, secret-free failure rather than an exception:
|
|
842
|
+
* the caller turns it into an exit code, and terminal/internal error text never
|
|
843
|
+
* reaches the user.
|
|
844
|
+
*/
|
|
845
|
+
export async function resolveInstallBridgeOnboardingBranchForRun(options, deps, argv) {
|
|
846
|
+
const branch = resolveInstallBridgeOnboardingBranch(options, deps.env);
|
|
847
|
+
// An explicit need-key intent is already unambiguous — never re-ask it.
|
|
848
|
+
if (branch.kind === "need-key")
|
|
849
|
+
return { ok: true, branch };
|
|
850
|
+
const hasEnvApiKey = (deps.env.BAPI_API_KEY ?? "").trim().length > 0;
|
|
851
|
+
const isBareInvocation = argv.length === 0;
|
|
852
|
+
if (!deps.isTTY || !deps.promptLine || !isBareInvocation || hasEnvApiKey) {
|
|
853
|
+
return { ok: true, branch };
|
|
854
|
+
}
|
|
855
|
+
const promptLine = deps.promptLine;
|
|
856
|
+
try {
|
|
857
|
+
// Bounded so a prompt seam that returns the same invalid value forever (a
|
|
858
|
+
// misbehaving pipe that passes the TTY check) cannot spin indefinitely.
|
|
859
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
860
|
+
const answer = (await promptLine(INSTALL_BRIDGE_KEY_SELECTOR_PROMPT)).trim().toLowerCase();
|
|
861
|
+
// Blank = accept the bracketed default (Y), matching the prompt's own contract.
|
|
862
|
+
if (answer.length === 0 || answer === "y" || answer === "yes") {
|
|
863
|
+
return { ok: true, branch: { kind: "have-key" } };
|
|
864
|
+
}
|
|
865
|
+
if (answer === "n" || answer === "no") {
|
|
866
|
+
return { ok: true, branch: { kind: "need-key", method: "self-serve" } };
|
|
867
|
+
}
|
|
868
|
+
deps.log("Please answer y or n (press Enter for yes).");
|
|
869
|
+
}
|
|
870
|
+
return {
|
|
871
|
+
ok: false,
|
|
872
|
+
error: "No valid answer to the Bridge API key question. Re-run and answer y or n.",
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
catch {
|
|
876
|
+
// Secret-free by construction: the caught value is never surfaced.
|
|
877
|
+
return {
|
|
878
|
+
ok: false,
|
|
879
|
+
error: "Could not read your answer from the terminal. Re-run with --api-key <key> if you have a " +
|
|
880
|
+
"Bridge API key, or --email <addr> to create a new Bridge workspace.",
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
}
|
|
669
884
|
/**
|
|
670
885
|
* Return the explicitly configured repository name (`--repo`, then
|
|
671
886
|
* `BAPI_REPO_NAME`), trimmed, or `undefined` when neither is supplied. Pure: no
|
|
@@ -682,7 +897,18 @@ export function resolveConfiguredRepoName(options, env) {
|
|
|
682
897
|
}
|
|
683
898
|
return undefined;
|
|
684
899
|
}
|
|
685
|
-
|
|
900
|
+
/**
|
|
901
|
+
* Resolve the repo name: `--repo` → `BAPI_REPO_NAME` env → inferred default
|
|
902
|
+
* (from .bridge/config, else the cwd basename) confirmed interactively. Fails
|
|
903
|
+
* fast (no inference) when neither is supplied and stdin is non-interactive —
|
|
904
|
+
* the repo identity keys the credential store, so it is never silently inferred
|
|
905
|
+
* non-interactively.
|
|
906
|
+
*
|
|
907
|
+
* `mode` selects only the WORDING (prompt and non-interactive error); resolution
|
|
908
|
+
* order, inference, and validation are identical in both. It defaults to
|
|
909
|
+
* `existing-registration` to preserve the behaviour of pre-BAPI-626 callers.
|
|
910
|
+
*/
|
|
911
|
+
export async function resolveRepoName(options, deps, mode = "existing-registration") {
|
|
686
912
|
const configured = resolveConfiguredRepoName(options, deps.env);
|
|
687
913
|
if (configured !== undefined) {
|
|
688
914
|
return { ok: true, value: configured };
|
|
@@ -692,9 +918,13 @@ export async function resolveRepoName(options, deps) {
|
|
|
692
918
|
if (!deps.isTTY || !deps.promptLine) {
|
|
693
919
|
return {
|
|
694
920
|
ok: false,
|
|
695
|
-
error:
|
|
696
|
-
"
|
|
697
|
-
|
|
921
|
+
error: mode === "new-project"
|
|
922
|
+
? "A project name is required. Pass --repo or set the BAPI_REPO_NAME environment " +
|
|
923
|
+
"variable (no interactive terminal is available to confirm an inferred name). It " +
|
|
924
|
+
"names the new Bridge project this run creates and must be globally unique."
|
|
925
|
+
: "A repo name is required. Pass --repo or set the BAPI_REPO_NAME environment variable " +
|
|
926
|
+
"(no interactive terminal is available to confirm an inferred name). It must match the " +
|
|
927
|
+
"server-side repository registration.",
|
|
698
928
|
};
|
|
699
929
|
}
|
|
700
930
|
// Infer a sensible default: existing .bridge/config, else the cwd basename.
|
|
@@ -709,18 +939,182 @@ export async function resolveRepoName(options, deps) {
|
|
|
709
939
|
inferred = validated.value;
|
|
710
940
|
}
|
|
711
941
|
if (inferred) {
|
|
712
|
-
const
|
|
942
|
+
const promptText = mode === "new-project"
|
|
943
|
+
? `Name your new Bridge project [${inferred}]: `
|
|
944
|
+
: `Repo name [${inferred}] (must match server-side registration): `;
|
|
945
|
+
const answer = (await deps.promptLine(promptText)).trim();
|
|
713
946
|
const chosen = answer.length > 0 ? answer : inferred;
|
|
714
947
|
if (chosen.length > 0)
|
|
715
948
|
return { ok: true, value: chosen };
|
|
716
949
|
}
|
|
717
950
|
else {
|
|
718
|
-
const
|
|
951
|
+
const promptText = mode === "new-project"
|
|
952
|
+
? "Name your new Bridge project: "
|
|
953
|
+
: "Repo name (must match server-side registration): ";
|
|
954
|
+
const answer = (await deps.promptLine(promptText)).trim();
|
|
719
955
|
if (answer.length > 0)
|
|
720
956
|
return { ok: true, value: answer };
|
|
721
957
|
}
|
|
722
958
|
return { ok: false, error: "No repo name provided." };
|
|
723
959
|
}
|
|
960
|
+
// ---------------------------------------------------------------------------
|
|
961
|
+
// Per-host config write (Step 2)
|
|
962
|
+
// ---------------------------------------------------------------------------
|
|
963
|
+
/** A per-host MCP config target (mirrors runInit's configTargets shape). */
|
|
964
|
+
/** Stable wording of the interactive tool-selection prompt (BAPI-635). */
|
|
965
|
+
export const INSTALL_BRIDGE_TOOL_SELECTOR_PROMPT = "Which AI coding tools do you use on this project?";
|
|
966
|
+
/**
|
|
967
|
+
* Interactive numbered multi-select prompt on stderr (TTY only). Displays each
|
|
968
|
+
* option with a checked/unchecked marker seeded from `defaults`, accepts a
|
|
969
|
+
* comma-separated list of numbers to TOGGLE, and accepts the current selection on
|
|
970
|
+
* a bare Enter. Reprints on an invalid token rather than corrupting state.
|
|
971
|
+
* Resolves safely (to the seeded defaults) on EOF / synchronous close so a
|
|
972
|
+
* readline regression can never hang or discard the answer.
|
|
973
|
+
*/
|
|
974
|
+
export function promptMultiSelectViaReadline(promptText, options, defaults, input = process.stdin, output = process.stderr) {
|
|
975
|
+
return new Promise((resolve) => {
|
|
976
|
+
const selected = new Set(defaults);
|
|
977
|
+
const render = () => {
|
|
978
|
+
output.write(`\n${promptText}\n`);
|
|
979
|
+
options.forEach((opt, idx) => {
|
|
980
|
+
const mark = selected.has(opt.id) ? "[x]" : "[ ]";
|
|
981
|
+
output.write(` ${idx + 1}. ${mark} ${opt.label}\n`);
|
|
982
|
+
});
|
|
983
|
+
output.write("Enter numbers to toggle (comma-separated), or press Enter to accept: ");
|
|
984
|
+
};
|
|
985
|
+
const rl = readline.createInterface({ input, output });
|
|
986
|
+
let answered = false;
|
|
987
|
+
const finish = () => {
|
|
988
|
+
answered = true;
|
|
989
|
+
rl.close();
|
|
990
|
+
resolve(options.filter((o) => selected.has(o.id)).map((o) => o.id));
|
|
991
|
+
};
|
|
992
|
+
// EOF / synchronous close must resolve rather than deadlock the top-level
|
|
993
|
+
// await; `answered` guards the synchronous close from discarding a real answer.
|
|
994
|
+
rl.on("close", () => {
|
|
995
|
+
if (!answered)
|
|
996
|
+
resolve(options.filter((o) => selected.has(o.id)).map((o) => o.id));
|
|
997
|
+
});
|
|
998
|
+
const ask = () => {
|
|
999
|
+
render();
|
|
1000
|
+
rl.question("", (answer) => {
|
|
1001
|
+
const trimmed = answer.trim();
|
|
1002
|
+
if (trimmed.length === 0) {
|
|
1003
|
+
finish();
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
const tokens = trimmed.split(",").map((t) => t.trim());
|
|
1007
|
+
const nums = [];
|
|
1008
|
+
let bad = false;
|
|
1009
|
+
for (const tok of tokens) {
|
|
1010
|
+
const n = Number(tok);
|
|
1011
|
+
if (!Number.isInteger(n) || n < 1 || n > options.length) {
|
|
1012
|
+
bad = true;
|
|
1013
|
+
break;
|
|
1014
|
+
}
|
|
1015
|
+
nums.push(n);
|
|
1016
|
+
}
|
|
1017
|
+
if (bad) {
|
|
1018
|
+
output.write(`Invalid selection. Enter numbers between 1 and ${options.length}.\n`);
|
|
1019
|
+
ask();
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
for (const n of nums) {
|
|
1023
|
+
const opt = options[n - 1];
|
|
1024
|
+
if (selected.has(opt.id))
|
|
1025
|
+
selected.delete(opt.id);
|
|
1026
|
+
else
|
|
1027
|
+
selected.add(opt.id);
|
|
1028
|
+
}
|
|
1029
|
+
finish();
|
|
1030
|
+
});
|
|
1031
|
+
};
|
|
1032
|
+
ask();
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Resolve the selected host platforms with strict precedence (BAPI-635):
|
|
1037
|
+
* 1. explicit `--tools` (including an explicit EMPTY selection — never falls
|
|
1038
|
+
* back to detection),
|
|
1039
|
+
* 2. interactive multi-select on a TTY (seeded from registry detection, with
|
|
1040
|
+
* Claude Code always checked as a default),
|
|
1041
|
+
* 3. the legacy non-TTY automatic set: Claude Code plus only the currently
|
|
1042
|
+
* detected Copilot VS Code and Cursor automatic targets (never Codex or
|
|
1043
|
+
* Copilot CLI just because a global directory exists).
|
|
1044
|
+
*/
|
|
1045
|
+
export async function resolveSelectedHostPlatforms(deps, options) {
|
|
1046
|
+
// 1. Explicit --tools (empty array is an explicit empty selection).
|
|
1047
|
+
if (options.tools !== undefined) {
|
|
1048
|
+
return options.tools;
|
|
1049
|
+
}
|
|
1050
|
+
const ctx = await buildDetectionContext(deps);
|
|
1051
|
+
const detected = new Set(detectDefaultPlatforms(ctx));
|
|
1052
|
+
// 2. Interactive multi-select on a TTY.
|
|
1053
|
+
if (deps.isTTY && deps.promptMultiSelect) {
|
|
1054
|
+
const optionList = allHostTargets().map((t) => ({ id: t.id, label: t.label }));
|
|
1055
|
+
// Claude Code is always a checked default; add every detected platform.
|
|
1056
|
+
const defaults = HOST_PLATFORM_ORDER.filter((id) => id === "claude-code" || detected.has(id));
|
|
1057
|
+
const chosen = await deps.promptMultiSelect(INSTALL_BRIDGE_TOOL_SELECTOR_PROMPT, optionList, defaults);
|
|
1058
|
+
return HOST_PLATFORM_ORDER.filter((id) => chosen.includes(id));
|
|
1059
|
+
}
|
|
1060
|
+
// 3. Legacy non-TTY automatic set: Claude + detected Cursor / Copilot VS Code.
|
|
1061
|
+
const legacy = ["claude-code"];
|
|
1062
|
+
if (detected.has("cursor"))
|
|
1063
|
+
legacy.push("cursor");
|
|
1064
|
+
if (detected.has("copilot-vscode"))
|
|
1065
|
+
legacy.push("copilot-vscode");
|
|
1066
|
+
return HOST_PLATFORM_ORDER.filter((id) => legacy.includes(id));
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* Build a registry detection context from install deps. install-bridge has only
|
|
1070
|
+
* async `stat`, but the registry's `detect` callbacks are synchronous, so we
|
|
1071
|
+
* pre-probe the candidate marker paths (the same ones the registry consults) and
|
|
1072
|
+
* expose them through a synchronous `exists` set. Paths are built in the registry's
|
|
1073
|
+
* POSIX-join form so the lookup matches exactly what `detect` passes to `exists`.
|
|
1074
|
+
*/
|
|
1075
|
+
async function buildDetectionContext(deps) {
|
|
1076
|
+
const cwd = deps.cwd;
|
|
1077
|
+
const homedir = deps.homedir();
|
|
1078
|
+
const posixJoin = (base, rel) => `${base.endsWith("/") ? base.slice(0, -1) : base}/${rel}`;
|
|
1079
|
+
const candidates = [
|
|
1080
|
+
posixJoin(cwd, ".cursor"),
|
|
1081
|
+
posixJoin(cwd, ".vscode"),
|
|
1082
|
+
posixJoin(cwd, ".windsurf"),
|
|
1083
|
+
posixJoin(cwd, ".windsurfrules"),
|
|
1084
|
+
posixJoin(homedir, ".codex"),
|
|
1085
|
+
];
|
|
1086
|
+
const present = new Set();
|
|
1087
|
+
await Promise.all(candidates.map(async (p) => {
|
|
1088
|
+
try {
|
|
1089
|
+
await deps.stat(p);
|
|
1090
|
+
present.add(p);
|
|
1091
|
+
}
|
|
1092
|
+
catch {
|
|
1093
|
+
// absent — leave out of the set.
|
|
1094
|
+
}
|
|
1095
|
+
}));
|
|
1096
|
+
return {
|
|
1097
|
+
cwd,
|
|
1098
|
+
homedir,
|
|
1099
|
+
env: deps.env,
|
|
1100
|
+
exists: (p) => present.has(p),
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* Resolve which project-local JSON host configs to write for the selected
|
|
1105
|
+
* platforms. Only the project-scoped JSON targets (Claude Code, Cursor, Copilot
|
|
1106
|
+
* VS Code) are returned here; global targets (Codex, Copilot CLI) and manual
|
|
1107
|
+
* targets (Windsurf) are handled by the registry-driven emitter in
|
|
1108
|
+
* `runInstallBridgeCli`. The returned shape is unchanged so the existing
|
|
1109
|
+
* read-merge-write path and overwrite-consent detection are preserved.
|
|
1110
|
+
*/
|
|
1111
|
+
function hostConfigTargetsForPlatforms(platforms) {
|
|
1112
|
+
const set = new Set(platforms);
|
|
1113
|
+
return HOST_PLATFORM_ORDER.filter((id) => set.has(id))
|
|
1114
|
+
.map((id) => MCP_HOST_TARGETS[id])
|
|
1115
|
+
.filter((t) => t.scope === "project" && t.format === "json")
|
|
1116
|
+
.map((t) => ({ relPath: t.relPath, topLevelKey: t.topLevelKey }));
|
|
1117
|
+
}
|
|
724
1118
|
/** Resolve which project-local host configs to write, mirroring runInit detection. */
|
|
725
1119
|
async function resolveHostConfigTargets(deps) {
|
|
726
1120
|
const targets = [
|
|
@@ -822,6 +1216,58 @@ async function writeHostConfigs(deps, targets, entry) {
|
|
|
822
1216
|
}
|
|
823
1217
|
return written;
|
|
824
1218
|
}
|
|
1219
|
+
/**
|
|
1220
|
+
* Provision the selected GLOBAL (Codex, Copilot CLI) and MANUAL (Windsurf)
|
|
1221
|
+
* targets through the registry-driven emitter (BAPI-635). Project JSON targets
|
|
1222
|
+
* are handled by {@link writeHostConfigs}; this covers everything else. Global
|
|
1223
|
+
* config paths are never added to the repository .gitignore. Returns secret-free
|
|
1224
|
+
* log lines and whether Codex was auto-provisioned (so the legacy Codex manual
|
|
1225
|
+
* hint can be suppressed).
|
|
1226
|
+
*/
|
|
1227
|
+
async function provisionSelectedGlobalTargets(deps, platforms, entry) {
|
|
1228
|
+
const logLines = [];
|
|
1229
|
+
const provisionDeps = {
|
|
1230
|
+
fs: {
|
|
1231
|
+
readFile: deps.readFile,
|
|
1232
|
+
writeFile: (p, data) => deps.writeFile(p, data, { encoding: "utf-8" }),
|
|
1233
|
+
mkdir: async (p, o) => {
|
|
1234
|
+
await deps.mkdir(p, o);
|
|
1235
|
+
},
|
|
1236
|
+
},
|
|
1237
|
+
vendor: deps.vendor,
|
|
1238
|
+
cwd: deps.cwd,
|
|
1239
|
+
homedir: deps.homedir(),
|
|
1240
|
+
env: deps.env,
|
|
1241
|
+
};
|
|
1242
|
+
const set = new Set(platforms);
|
|
1243
|
+
for (const id of HOST_PLATFORM_ORDER) {
|
|
1244
|
+
if (!set.has(id))
|
|
1245
|
+
continue;
|
|
1246
|
+
const target = MCP_HOST_TARGETS[id];
|
|
1247
|
+
// Skip project JSON targets — those are handled by writeHostConfigs.
|
|
1248
|
+
if (target.scope === "project" && target.format === "json")
|
|
1249
|
+
continue;
|
|
1250
|
+
const outcome = await provisionHostTarget(target, entry, provisionDeps);
|
|
1251
|
+
switch (outcome.status) {
|
|
1252
|
+
case "vendor-written":
|
|
1253
|
+
case "direct-written":
|
|
1254
|
+
case "created":
|
|
1255
|
+
logLines.push(` configured ${target.label} (${outcome.displayPath})`);
|
|
1256
|
+
break;
|
|
1257
|
+
case "manual-required":
|
|
1258
|
+
logLines.push(` ${target.label}: add the bridge-api MCP server manually to ${outcome.displayPath} ` +
|
|
1259
|
+
"(the API key is redacted in printed instructions).");
|
|
1260
|
+
break;
|
|
1261
|
+
case "skipped-invalid":
|
|
1262
|
+
logLines.push(` ${target.label}: skipped ${outcome.displayPath} — existing config is not valid; left untouched.`);
|
|
1263
|
+
break;
|
|
1264
|
+
case "failed":
|
|
1265
|
+
logLines.push(` ${target.label}: could not be configured automatically; configure it manually.`);
|
|
1266
|
+
break;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
return logLines;
|
|
1270
|
+
}
|
|
825
1271
|
/** Build the `/jira/ping` URL exactly like the MCP `ping` tool / buildGetUrl. */
|
|
826
1272
|
export function buildPingUrl(baseUrl, repoName) {
|
|
827
1273
|
const url = new URL(`${baseUrl.replace(/\/+$/, "")}/jira/ping`);
|
|
@@ -1159,7 +1605,29 @@ export function buildDryRunPreview(plan) {
|
|
|
1159
1605
|
`Step 3b — pre-warm the version-pinned launcher bucket (fail-open, env sanitized — BAPI_API_KEY removed): ${plan.prewarmCommand}`,
|
|
1160
1606
|
MCP_TIMEOUT_GUIDANCE,
|
|
1161
1607
|
`Step 4 — persist routing credential: target ${plan.credentialTarget} at ${plan.credentialStorePath}`,
|
|
1162
|
-
|
|
1608
|
+
...buildLaunchStepPreview(plan),
|
|
1609
|
+
];
|
|
1610
|
+
}
|
|
1611
|
+
/**
|
|
1612
|
+
* The Step 5 preview lines, shared by both previews so the launch description
|
|
1613
|
+
* cannot drift between the have-key and need-key flows.
|
|
1614
|
+
*
|
|
1615
|
+
* A --dry-run never writes the launch script, so the preview describes the
|
|
1616
|
+
* materialization rather than performing it — but it still shows the full command,
|
|
1617
|
+
* because the command is what the user is previewing and it is secret-free.
|
|
1618
|
+
*/
|
|
1619
|
+
function buildLaunchStepPreview(plan) {
|
|
1620
|
+
return [
|
|
1621
|
+
// BAPI-631: described, never performed in --dry-run — a preview must not open a
|
|
1622
|
+
// browser or reach the network. It is also strictly optional, so it carries no step
|
|
1623
|
+
// number of its own and never changes the 5-step count.
|
|
1624
|
+
"Step 4b — optional GitHub connect (SKIPPED in --dry-run): read GitHub's configured state",
|
|
1625
|
+
" via the install manifest and, only when it is unconfigured and the terminal is",
|
|
1626
|
+
" interactive, offer 'Connect GitHub? (Y/n)' before the agent session starts.",
|
|
1627
|
+
"Step 5 — spawn agent session: the full command below is stored in a restricted launch script",
|
|
1628
|
+
" (mode 0600, under the system temp dir) and only a short sourced runner is spawned",
|
|
1629
|
+
" (the script itself is NOT written in --dry-run):",
|
|
1630
|
+
` ${plan.spawnCommand}`,
|
|
1163
1631
|
];
|
|
1164
1632
|
}
|
|
1165
1633
|
/**
|
|
@@ -1214,7 +1682,7 @@ function buildBootstrapDryRunPreview(plan) {
|
|
|
1214
1682
|
`Step 3b — pre-warm the version-pinned launcher bucket (fail-open, env sanitized — BAPI_API_KEY / BAPI_INVITE removed): ${plan.prewarmCommand}`,
|
|
1215
1683
|
MCP_TIMEOUT_GUIDANCE,
|
|
1216
1684
|
`Step 4 — promote ${pendingTarget} → ${plan.credentialTarget} at ${plan.credentialStorePath} (only after the exchange succeeds)`,
|
|
1217
|
-
|
|
1685
|
+
...buildLaunchStepPreview(plan),
|
|
1218
1686
|
];
|
|
1219
1687
|
}
|
|
1220
1688
|
/**
|
|
@@ -1301,12 +1769,21 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
1301
1769
|
return 1;
|
|
1302
1770
|
}
|
|
1303
1771
|
const options = parsed.options;
|
|
1304
|
-
// Onboarding branch
|
|
1305
|
-
//
|
|
1306
|
-
//
|
|
1307
|
-
//
|
|
1308
|
-
//
|
|
1309
|
-
|
|
1772
|
+
// Onboarding branch: have-key vs. a need-key method (`bootstrap-invite` = redeem
|
|
1773
|
+
// a pre-issued invite, `self-serve` = mint one from an email, BAPI-618). BOTH
|
|
1774
|
+
// need-key methods share the downstream redemption protocol, so
|
|
1775
|
+
// `bootstrapInviteMode` is true for both; `selfServeSignupMode` discriminates the
|
|
1776
|
+
// one extra step (mint-from-email) the self-serve path adds.
|
|
1777
|
+
//
|
|
1778
|
+
// The selection is pure and deterministic for every explicit, env-driven, and
|
|
1779
|
+
// non-TTY invocation; only a BARE interactive run is asked which branch it wants
|
|
1780
|
+
// (BAPI-626 — otherwise a first-time user can never reach self-serve).
|
|
1781
|
+
const branchResult = await resolveInstallBridgeOnboardingBranchForRun(options, deps, argv);
|
|
1782
|
+
if (!branchResult.ok) {
|
|
1783
|
+
errorLog(`Error: ${branchResult.error}`);
|
|
1784
|
+
return 1;
|
|
1785
|
+
}
|
|
1786
|
+
const branch = branchResult.branch;
|
|
1310
1787
|
const bootstrapInviteMode = branch.kind === "need-key";
|
|
1311
1788
|
const selfServeSignupMode = branch.kind === "need-key" && branch.method === "self-serve";
|
|
1312
1789
|
// ---- Resolve inputs (may prompt when interactive) ----
|
|
@@ -1348,15 +1825,17 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
1348
1825
|
const baseUrl = deps.env.BAPI_BASE_URL ?? DEFAULT_BAPI_BASE_URL;
|
|
1349
1826
|
const docsDir = deps.env.BAPI_DOCS_DIR ?? DEFAULT_BAPI_DOCS_DIR;
|
|
1350
1827
|
// ---- Resolve the repository name ----
|
|
1351
|
-
//
|
|
1828
|
+
// Need-key (invite or self-serve): choose-a-name for the project this run is
|
|
1829
|
+
// about to create, so the prompt says exactly that. Have-key:
|
|
1352
1830
|
// `--repo`/`BAPI_REPO_NAME` short-circuit deterministically; otherwise resolve
|
|
1353
1831
|
// it server-side from the API key (BAPI-616), and on ANY non-resolution outcome
|
|
1354
1832
|
// (unresolved / not-deployed / error) fall back to the existing local
|
|
1355
|
-
// prompt/inference — never a hard failure.
|
|
1833
|
+
// prompt/inference — never a hard failure. That fallback keeps the
|
|
1834
|
+
// existing-registration wording: there the name must match a real project.
|
|
1356
1835
|
let repoName;
|
|
1357
1836
|
let attemptedServerResolution = false;
|
|
1358
1837
|
if (bootstrapInviteMode) {
|
|
1359
|
-
const repoResult = await resolveRepoName(options, deps);
|
|
1838
|
+
const repoResult = await resolveRepoName(options, deps, "new-project");
|
|
1360
1839
|
if (!repoResult.ok) {
|
|
1361
1840
|
errorLog(`Error: ${repoResult.error}`);
|
|
1362
1841
|
return 1;
|
|
@@ -1387,7 +1866,7 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
1387
1866
|
// Feature-detect + degrade: 404 (old server), 409 (unresolved/ambiguous/
|
|
1388
1867
|
// client-scoped), and network/other errors all fall back to the existing
|
|
1389
1868
|
// local resolution WITHOUT a cause-specific message or leaked detail.
|
|
1390
|
-
const repoResult = await resolveRepoName(options, deps);
|
|
1869
|
+
const repoResult = await resolveRepoName(options, deps, "existing-registration");
|
|
1391
1870
|
if (!repoResult.ok) {
|
|
1392
1871
|
errorLog(`Error: ${repoResult.error}`);
|
|
1393
1872
|
return 1;
|
|
@@ -1402,7 +1881,13 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
1402
1881
|
env: deps.env,
|
|
1403
1882
|
homedir: deps.homedir,
|
|
1404
1883
|
});
|
|
1405
|
-
|
|
1884
|
+
// BAPI-635: resolve the AI-coding-tool selection (explicit --tools, then TTY
|
|
1885
|
+
// multi-select, then the legacy non-TTY automatic set). The selected project
|
|
1886
|
+
// JSON targets drive the existing read-merge-write path; selected global
|
|
1887
|
+
// (Codex / Copilot CLI) and manual (Windsurf) targets are provisioned by the
|
|
1888
|
+
// registry-driven emitter after the connectivity check.
|
|
1889
|
+
const selectedPlatforms = await resolveSelectedHostPlatforms(deps, options);
|
|
1890
|
+
const targets = hostConfigTargetsForPlatforms(selectedPlatforms);
|
|
1406
1891
|
// Read-only detection (safe in dry-run) of global-config editors we can't write.
|
|
1407
1892
|
const manualEditors = await detectManualEditors(deps);
|
|
1408
1893
|
const plan = {
|
|
@@ -1432,6 +1917,31 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
1432
1917
|
log(line);
|
|
1433
1918
|
return 0;
|
|
1434
1919
|
}
|
|
1920
|
+
// ---- Materialize the Step 5 launch command BEFORE any install side effect ----
|
|
1921
|
+
// `spawnCommand` embeds the whole INSTALL_BRIDGE_AGENT_PROMPT and is multiple KB
|
|
1922
|
+
// — far past what osascript can type into a Terminal/iTerm tab, which is why the
|
|
1923
|
+
// spawn silently delivered a truncated line. It must travel via a launch script.
|
|
1924
|
+
//
|
|
1925
|
+
// This runs HERE, before the scaffold/mint/credential/config writes, because a
|
|
1926
|
+
// command that cannot be launched safely is a whole-run failure, not a Step 5
|
|
1927
|
+
// warning: the deterministic setup would otherwise complete and leave the user
|
|
1928
|
+
// in the "configured but never configured by the agent" state the warning below
|
|
1929
|
+
// explicitly calls out. Failing first means nothing is half-done.
|
|
1930
|
+
const materialized = await materializeWorkerLaunchCommand(deps.startTicketsDeps, "install", spawnCommand);
|
|
1931
|
+
if (!materialized.ok) {
|
|
1932
|
+
errorLog(`Error: ${materialized.error}`);
|
|
1933
|
+
return 1;
|
|
1934
|
+
}
|
|
1935
|
+
const launchCommand = materialized.command;
|
|
1936
|
+
// Independent final guard on the line ACTUALLY handed to the terminal — not on
|
|
1937
|
+
// the original command. It catches the two ways a "successful" materialization
|
|
1938
|
+
// can still be unlaunchable: no writer seam (inline command preserved verbatim),
|
|
1939
|
+
// and a runner whose script path is unexpectedly long.
|
|
1940
|
+
if (Buffer.byteLength(launchCommand, "utf8") >= MAX_TERMINAL_COMMAND_BYTES) {
|
|
1941
|
+
errorLog("Error: the agent session command is too long to send to the terminal safely. " +
|
|
1942
|
+
"Check that the system temporary directory is writable so the launch script can be used.");
|
|
1943
|
+
return 1;
|
|
1944
|
+
}
|
|
1435
1945
|
const credentialWriteDeps = {
|
|
1436
1946
|
env: deps.env,
|
|
1437
1947
|
homedir: deps.homedir,
|
|
@@ -1652,13 +2162,82 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
1652
2162
|
const entry = buildInstallBridgeServerEntry(deps.cwd, repoName, apiKey, baseUrl, docsDir);
|
|
1653
2163
|
// ---- Step 3 — write per-host MCP config with real values ----
|
|
1654
2164
|
log("Step 3/5 — writing per-host MCP config…");
|
|
2165
|
+
// BAPI-635 (Step 8): every project-local, secret-bearing config MUST be
|
|
2166
|
+
// gitignored BEFORE the real API key is written. A project-target gitignore
|
|
2167
|
+
// failure is FATAL before the secret write (fixed, secret-free message).
|
|
2168
|
+
const gitignoreDeps = {
|
|
2169
|
+
readFile: deps.readFile,
|
|
2170
|
+
writeFile: (p, data) => deps.writeFile(p, data, { encoding: "utf-8" }),
|
|
2171
|
+
mkdir: (p, o) => deps.mkdir(p, o),
|
|
2172
|
+
};
|
|
2173
|
+
for (const target of targets) {
|
|
2174
|
+
try {
|
|
2175
|
+
await ensureGitignoredShared(deps.cwd, target.relPath, gitignoreDeps);
|
|
2176
|
+
}
|
|
2177
|
+
catch {
|
|
2178
|
+
errorLog("Error: could not add a project MCP config to .gitignore before writing your key. " +
|
|
2179
|
+
"Aborting so the API key is never written to an un-ignored file.");
|
|
2180
|
+
return 1;
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
1655
2183
|
const written = await writeHostConfigs(deps, targets, entry);
|
|
1656
2184
|
for (const relPath of written)
|
|
1657
2185
|
log(` wrote ${relPath}`);
|
|
1658
|
-
//
|
|
1659
|
-
|
|
2186
|
+
// BAPI-635: provision selected GLOBAL targets (Codex, Copilot CLI) and MANUAL
|
|
2187
|
+
// targets (Windsurf) via the registry-driven emitter. Global paths are never
|
|
2188
|
+
// added to the repository .gitignore.
|
|
2189
|
+
const globalLogLines = await provisionSelectedGlobalTargets(deps, selectedPlatforms, entry);
|
|
2190
|
+
for (const line of globalLogLines)
|
|
2191
|
+
log(line);
|
|
2192
|
+
// Legacy manual-editor instructions cover editors that are DETECTED but were
|
|
2193
|
+
// NOT part of the selection (so the emitter above did not handle them). The two
|
|
2194
|
+
// editors are suppressed INDEPENDENTLY: Codex is dropped when it was selected
|
|
2195
|
+
// (auto-provisioned or emitted above), Windsurf is dropped only when it was
|
|
2196
|
+
// selected — auto-provisioning Codex must never hide the Windsurf snippet.
|
|
2197
|
+
const legacyManualEditors = {
|
|
2198
|
+
windsurf: manualEditors.windsurf && !selectedPlatforms.includes("windsurf"),
|
|
2199
|
+
codex: manualEditors.codex && !selectedPlatforms.includes("codex"),
|
|
2200
|
+
};
|
|
2201
|
+
const manualInstructions = buildManualHostInstructions(entry, legacyManualEditors);
|
|
1660
2202
|
if (manualInstructions)
|
|
1661
2203
|
log(manualInstructions);
|
|
2204
|
+
// BAPI-635 (Step 7): when both Claude Code and Copilot CLI are selected, warn
|
|
2205
|
+
// that they use different, non-shared config surfaces.
|
|
2206
|
+
if (selectedPlatforms.includes("claude-code") && selectedPlatforms.includes("copilot-cli")) {
|
|
2207
|
+
log(" Note: Claude Code uses the project .mcp.json while GitHub Copilot CLI uses only its " +
|
|
2208
|
+
"global ~/.copilot/mcp-config.json — the two are configured separately.");
|
|
2209
|
+
}
|
|
2210
|
+
// BAPI-635 (Step 7): Claude trust reminder — a written project MCP config is
|
|
2211
|
+
// not a live connection until approved in Claude Code's trust dialog.
|
|
2212
|
+
if (selectedPlatforms.includes("claude-code")) {
|
|
2213
|
+
log(" Claude Code: the project MCP server is pending approval in Claude Code's trust dialog; " +
|
|
2214
|
+
"restart or reload an already-running session for it to take effect.");
|
|
2215
|
+
}
|
|
2216
|
+
// BAPI-635 (Step 8 + Step 3): persist the secret-free install state, ignoring
|
|
2217
|
+
// it before the write. Project-local paths only; global paths are never in it.
|
|
2218
|
+
try {
|
|
2219
|
+
await ensureGitignoredShared(deps.cwd, ".bridge/install-state.json", gitignoreDeps);
|
|
2220
|
+
// writeMcpInstallState catches its own I/O errors and returns { ok: false }
|
|
2221
|
+
// (it does NOT throw), so the failure warning must inspect the return value —
|
|
2222
|
+
// a try/catch alone would silently swallow a real persistence failure.
|
|
2223
|
+
const stateResult = await writeMcpInstallState(deps.cwd, { selectedPlatforms, projectConfigPaths: targets.map((t) => t.relPath) }, {
|
|
2224
|
+
readFile: deps.readFile,
|
|
2225
|
+
writeFile: (p, data) => deps.writeFile(p, data, { encoding: "utf-8" }),
|
|
2226
|
+
rename: deps.rename,
|
|
2227
|
+
mkdir: async (p, o) => {
|
|
2228
|
+
await deps.mkdir(p, o);
|
|
2229
|
+
},
|
|
2230
|
+
unlink: deps.unlink,
|
|
2231
|
+
});
|
|
2232
|
+
if (!stateResult.ok) {
|
|
2233
|
+
// Install-state persistence is advisory — never fail the install over it.
|
|
2234
|
+
errorLog("Warning: could not persist the install-state file (non-fatal).");
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
catch {
|
|
2238
|
+
// ensureGitignored for the state file can still throw — also advisory.
|
|
2239
|
+
errorLog("Warning: could not persist the install-state file (non-fatal).");
|
|
2240
|
+
}
|
|
1662
2241
|
// ---- Step 3b — pre-warm the @${VERSION}-pinned _npx bucket (BAPI-451 W3) ----
|
|
1663
2242
|
// The launcher just written is pinned to @${VERSION}, a DIFFERENT _npx bucket
|
|
1664
2243
|
// than the @latest bucket this `npx … install-bridge` invocation warmed. Spawn
|
|
@@ -1721,10 +2300,17 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
1721
2300
|
"'npx -y @bridge_gpt/mcp-server doctor'.");
|
|
1722
2301
|
}
|
|
1723
2302
|
}
|
|
2303
|
+
// ---- optional GitHub connect offer (BAPI-631) ----
|
|
2304
|
+
// Placed AFTER the credential is durable (the flow needs a resolvable key) and BEFORE
|
|
2305
|
+
// the agent spawn, for two reasons: the spawned session's capability report should
|
|
2306
|
+
// observe GitHub as configured if the user connects it here, and running it after the
|
|
2307
|
+
// spawn would put two prompts on the same terminal at once.
|
|
2308
|
+
await offerGithubConnection(repoName, deps, log);
|
|
1724
2309
|
// ---- Step 5 — spawn a fresh agent session for the agentic remainder ----
|
|
1725
2310
|
log(`Step 5/5 — opening a ${agent.name} session for /install-bridge configuration + capability report…`);
|
|
1726
2311
|
const terminal = detectTerminal(undefined, deps.env);
|
|
1727
|
-
|
|
2312
|
+
// Only the validated short runner reaches the terminal — never the inline prompt.
|
|
2313
|
+
const spawnResult = await deps.spawnTerminalTab(deps.startTicketsDeps, terminal, launchCommand, {
|
|
1728
2314
|
key: "install",
|
|
1729
2315
|
worktreePath: deps.cwd,
|
|
1730
2316
|
});
|