@bridge_gpt/mcp-server 0.2.28 → 0.2.30
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 +13 -5
- package/build/commands.generated.js +2 -2
- package/build/index.js +39 -17
- package/build/install-bridge.js +250 -113
- package/build/readme.generated.js +1 -1
- package/build/start-tickets.js +13 -8
- package/build/version.generated.js +1 -1
- package/package.json +2 -2
package/build/install-bridge.js
CHANGED
|
@@ -57,10 +57,23 @@
|
|
|
57
57
|
* repository prompt asks for a new project name rather than an existing
|
|
58
58
|
* registration (see `RepoNamePromptMode`).
|
|
59
59
|
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
60
|
+
* ORDINARY-ENTRY INVITE RECLASSIFICATION (BAPI-661) — the have-key branch's own
|
|
61
|
+
* credential input (`--api-key`, `BAPI_API_KEY`, or its hidden prompt) is
|
|
62
|
+
* CREDENTIAL-AGNOSTIC: it accepts a full API key OR a bootstrap-invite token,
|
|
63
|
+
* because a user handed only an invite has no way to know it belongs in a
|
|
64
|
+
* different flag. `classifyEnteredCredential` inspects the resolved value
|
|
65
|
+
* immediately after input resolution (before repository-name resolution); a
|
|
66
|
+
* `bapi_inv_…` value flips the branch's `bootstrapInviteMode`/`inviteToken` and
|
|
67
|
+
* the run falls into the SAME bootstrap-invite protocol described below, verbatim.
|
|
68
|
+
* Explicit `--invite`/`BAPI_INVITE` and `--email`/`BAPI_SIGNUP_EMAIL` are never
|
|
69
|
+
* reclassified — they are already unambiguous and remain authoritative.
|
|
70
|
+
*
|
|
71
|
+
* BOOTSTRAP-INVITE MODE (BAPI-606) — one of the exceptions to "this command
|
|
72
|
+
* consumes a key, it does not create one" (the ordinary-entry reclassification
|
|
73
|
+
* above is the other). With `--invite` (or `BAPI_INVITE`, or a reclassified
|
|
74
|
+
* ordinary-entry value) there is no API key yet, so the pre-flight ping of Step 2
|
|
75
|
+
* CANNOT be made: the exchange is what mints the key, and it REPLACES that ping.
|
|
76
|
+
* The order becomes:
|
|
64
77
|
*
|
|
65
78
|
* resolve bootstrap-invite token (no-echo prompt by DEFAULT — the delivered
|
|
66
79
|
* one-liner is secret-free) → resolve repo name → scaffold → generate
|
|
@@ -137,18 +150,27 @@ export function buildPrewarmCommandPreview() {
|
|
|
137
150
|
* (`maybe_auto_parse_if_ready`), with no prompt on any path.
|
|
138
151
|
*/
|
|
139
152
|
export const INSTALL_BRIDGE_AGENT_PROMPT = "Execute the /install-bridge command in the install-spawn context (tell the command it is running " +
|
|
140
|
-
"in the install-spawn context so it SKIPS its Stage 8, Stage 9, and Stage 10 offers
|
|
141
|
-
"
|
|
142
|
-
"
|
|
143
|
-
"
|
|
153
|
+
"in the install-spawn context so it SKIPS its Stage 8, Stage 9, and Stage 10 offers). " +
|
|
154
|
+
"The command chooses its own mode from the install manifest's `configured` flag — do NOT force a " +
|
|
155
|
+
"mode: when `configured` is false it runs its fresh-configuration flow, and when `configured` is " +
|
|
156
|
+
"true it runs JOIN MODE. In JOIN MODE the command must NOT derive, approve, apply, or offer any " +
|
|
157
|
+
"configuration and applies zero fields for any caller; a joining member's only closing interaction " +
|
|
158
|
+
"is the welcome plus the concise capability report (no /learn-repository prompt for a member). " +
|
|
159
|
+
"Complete the command's concise capability report: the single 'What Bridge " +
|
|
144
160
|
"can help with' section, rendered exactly as the server's concise_tool_capabilities field gives it " +
|
|
145
161
|
"(Regularly useful, then Occasionally useful, each tool's display_name only, plus each tier's " +
|
|
146
162
|
"server-computed '+N more' where non-zero). Do not render the obsolete five-section report, and do " +
|
|
147
163
|
"not locally filter, count, or fall back to the complete tool_capabilities catalog. " +
|
|
164
|
+
"The install-spawn skip set is exactly Stage 8, Stage 9, and Stage 10. The command-owned Stage 11 " +
|
|
165
|
+
"(invite teammates) is NOT in that skip set: it is independently gated and may run only when the " +
|
|
166
|
+
"caller's role is admin AND customer_type is b2b AND an interactive response is available; " +
|
|
167
|
+
"otherwise it is skipped silently. If Stage 11 mints a teammate key, show that plaintext key " +
|
|
168
|
+
"exactly once and never repeat it in any summary, retry, or diagnostic. " +
|
|
148
169
|
"Do NOT ask any indexing question, call parse_repository, mention /parse-repository, or claim " +
|
|
149
170
|
"indexing has started or is pending — indexing is decided entirely by the server-side readiness " +
|
|
150
171
|
"funnel with no visibility from this session, so say nothing about it at all. " +
|
|
151
|
-
"After the capability report, recommend /learn-repository as the
|
|
172
|
+
"After the capability report on the fresh-configuration path, recommend /learn-repository as the " +
|
|
173
|
+
"next step: explain briefly that it " +
|
|
152
174
|
"learns repository-specific architecture, review, testing, correctness, and validation-manual " +
|
|
153
175
|
"configuration by researching the actual codebase. Do NOT run /learn-repository yourself — only " +
|
|
154
176
|
"recommend it; running it is the human's next explicit invocation. " +
|
|
@@ -156,9 +178,11 @@ export const INSTALL_BRIDGE_AGENT_PROMPT = "Execute the /install-bridge command
|
|
|
156
178
|
"integration's own configure_in pointer, verbatim. The pointer is per-integration and is NOT " +
|
|
157
179
|
"always the setup UI: GitHub's is a terminal command (connect-github), while Jira, SFCC, and " +
|
|
158
180
|
"Bitbucket point at the setup UI. Follow whatever the report says rather than assuming. " +
|
|
159
|
-
"
|
|
181
|
+
"On the fresh-configuration path, end with an explicit summary line stating how many config fields " +
|
|
182
|
+
"the apply_install_manifest call " +
|
|
160
183
|
"applied (e.g. 'Applied 8 of 9 derived fields') — if 0 fields were applied, say so loudly and " +
|
|
161
|
-
"explain what is still pending."
|
|
184
|
+
"explain what is still pending. In JOIN MODE there is no apply — state instead that the project " +
|
|
185
|
+
"was already configured and zero changes were made, without a fabricated applied count.";
|
|
162
186
|
/** Default base URL when `BAPI_BASE_URL` is unset (mirrors index.ts). */
|
|
163
187
|
export const DEFAULT_BAPI_BASE_URL = "https://bridgegpt-api.com";
|
|
164
188
|
/** Default docs dir when `BAPI_DOCS_DIR` is unset (mirrors index.ts). */
|
|
@@ -172,10 +196,13 @@ export function getInstallBridgeUsage() {
|
|
|
172
196
|
"One-command Bridge API project bootstrap. Scaffolds the project, writes the",
|
|
173
197
|
"per-host MCP config with your credentials, verifies connectivity, persists the",
|
|
174
198
|
"routing credential, then — on a TTY, only after a Y/N consent prompt — opens a",
|
|
175
|
-
"fresh session in your selected tool
|
|
176
|
-
"
|
|
177
|
-
"
|
|
178
|
-
"
|
|
199
|
+
"fresh session in your selected tool. If the project is not yet configured it",
|
|
200
|
+
"derives the remaining config, presents a concise capability report, and",
|
|
201
|
+
"recommends /learn-repository. If the project is already configured it instead",
|
|
202
|
+
"joins you to it without proposing or applying any changes and just shows the",
|
|
203
|
+
"concise capability report (a b2b admin is additionally offered a teammate-invite",
|
|
204
|
+
"step). So not every run applies config fields. Indexing is never asked about —",
|
|
205
|
+
"it starts automatically once the repository reaches full parse readiness.",
|
|
179
206
|
"",
|
|
180
207
|
"Run it bare — `install-bridge` with no flags — in a terminal and it asks",
|
|
181
208
|
`\`${INSTALL_BRIDGE_KEY_SELECTOR_PROMPT.trim()}\` first. Answer yes (or press Enter) for the`,
|
|
@@ -185,11 +212,15 @@ export function getInstallBridgeUsage() {
|
|
|
185
212
|
"an interactive terminal keeps the existing deterministic behavior and no prompt.",
|
|
186
213
|
"",
|
|
187
214
|
"Inputs (the only two irreducible ones):",
|
|
188
|
-
" --api-key <key> Bridge API key. Falls back to the
|
|
189
|
-
" then an interactive (no-echo) prompt.
|
|
190
|
-
" Bridge API web UI Security page — this
|
|
191
|
-
" key, it does not create one
|
|
192
|
-
"
|
|
215
|
+
" --api-key <key> Bridge API key OR bootstrap invite. Falls back to the",
|
|
216
|
+
" BAPI_API_KEY env var, then an interactive (no-echo) prompt.",
|
|
217
|
+
" Generate a key in the Bridge API web UI Security page — this",
|
|
218
|
+
" command consumes a key, it does not create one. A value",
|
|
219
|
+
" detected as a bootstrap invite (bapi_inv_…), from any of the",
|
|
220
|
+
" three sources above, is instead redeemed to CREATE a new",
|
|
221
|
+
" project and its first admin key — the same as --invite —",
|
|
222
|
+
" so it skips repository lookup entirely (--email and --invite",
|
|
223
|
+
" remain the explicit, preferred entry points for a new project).",
|
|
193
224
|
" NEVER printed or logged.",
|
|
194
225
|
" --repo <name> Repository name. --repo and BAPI_REPO_NAME still take",
|
|
195
226
|
" priority and short-circuit before any network call. When",
|
|
@@ -244,10 +275,12 @@ export function getInstallBridgeUsage() {
|
|
|
244
275
|
` ${HOST_PLATFORM_ORDER.join(", ")}.`,
|
|
245
276
|
" Both --tools=claude-code,codex and",
|
|
246
277
|
" --tools claude-code,codex are accepted. On an",
|
|
247
|
-
" interactive terminal WITHOUT this flag
|
|
248
|
-
"
|
|
249
|
-
" (
|
|
250
|
-
"
|
|
278
|
+
" interactive terminal WITHOUT this flag you get a",
|
|
279
|
+
" numbered picker: enter one or more tool numbers,",
|
|
280
|
+
" comma-separated (e.g. 1,3), and press Enter once —",
|
|
281
|
+
" no tool is pre-selected (not even Claude Code) and",
|
|
282
|
+
" you must select at least one. A non-interactive",
|
|
283
|
+
" (non-TTY) run without",
|
|
251
284
|
" --tools writes the legacy automatic set (Claude",
|
|
252
285
|
" Code plus any detected Cursor / Copilot VS Code)",
|
|
253
286
|
" and never opens an agent terminal (it prints",
|
|
@@ -710,8 +743,16 @@ export function createDefaultInstallBridgeDeps() {
|
|
|
710
743
|
};
|
|
711
744
|
}
|
|
712
745
|
/**
|
|
713
|
-
* Resolve the
|
|
714
|
-
* prompt. Fails (secret-free) when none is available and
|
|
746
|
+
* Resolve the ordinary credential entry: `--api-key` → `BAPI_API_KEY` env →
|
|
747
|
+
* interactive no-echo prompt. Fails (secret-free) when none is available and
|
|
748
|
+
* stdin is non-interactive.
|
|
749
|
+
*
|
|
750
|
+
* Despite the name, this is CREDENTIAL-AGNOSTIC (BAPI-661): the returned value may
|
|
751
|
+
* be a full Bridge API key or a bootstrap-invite token (`bapi_inv_…`) — the caller
|
|
752
|
+
* classifies it with {@link classifyEnteredCredential} immediately after this
|
|
753
|
+
* resolves and routes an invite into the existing redemption workflow. This
|
|
754
|
+
* function itself performs no classification; it only resolves which raw string
|
|
755
|
+
* was entered.
|
|
715
756
|
*/
|
|
716
757
|
export async function resolveApiKey(options, deps) {
|
|
717
758
|
if (typeof options.apiKey === "string" && options.apiKey.trim().length > 0) {
|
|
@@ -722,16 +763,20 @@ export async function resolveApiKey(options, deps) {
|
|
|
722
763
|
return { ok: true, value: fromEnv.trim() };
|
|
723
764
|
}
|
|
724
765
|
if (deps.isTTY && deps.promptSecret) {
|
|
725
|
-
const entered = (await deps.promptSecret("Bridge API key (input hidden): ")).trim();
|
|
766
|
+
const entered = (await deps.promptSecret("Bridge API key or invite (input hidden): ")).trim();
|
|
726
767
|
if (entered.length > 0) {
|
|
727
768
|
return { ok: true, value: entered };
|
|
728
769
|
}
|
|
729
|
-
return {
|
|
770
|
+
return {
|
|
771
|
+
ok: false,
|
|
772
|
+
error: "No Bridge API key or invite entered. Pass --api-key, set the BAPI_API_KEY environment " +
|
|
773
|
+
"variable, or try the hidden prompt again.",
|
|
774
|
+
};
|
|
730
775
|
}
|
|
731
776
|
return {
|
|
732
777
|
ok: false,
|
|
733
|
-
error: "
|
|
734
|
-
"(no interactive terminal is available to prompt for it).",
|
|
778
|
+
error: "A Bridge API key or invite is required. Pass --api-key or set the BAPI_API_KEY " +
|
|
779
|
+
"environment variable (no interactive terminal is available to prompt for it).",
|
|
735
780
|
};
|
|
736
781
|
}
|
|
737
782
|
/**
|
|
@@ -836,8 +881,8 @@ export function resolveInstallBridgeOnboardingBranch(options, env) {
|
|
|
836
881
|
return { kind: "need-key", method: "self-serve" };
|
|
837
882
|
return { kind: "have-key" };
|
|
838
883
|
}
|
|
839
|
-
/** The exact visible text of the bare-TTY onboarding selector (BAPI-626). */
|
|
840
|
-
export const INSTALL_BRIDGE_KEY_SELECTOR_PROMPT = "Do you have a Bridge API key? [Y/n] ";
|
|
884
|
+
/** The exact visible text of the bare-TTY onboarding selector (BAPI-626, BAPI-661). */
|
|
885
|
+
export const INSTALL_BRIDGE_KEY_SELECTOR_PROMPT = "Do you have a Bridge API key or invite? [Y/n] ";
|
|
841
886
|
/**
|
|
842
887
|
* Interactive wrapper around {@link resolveInstallBridgeOnboardingBranch}.
|
|
843
888
|
*
|
|
@@ -980,87 +1025,78 @@ export async function resolveRepoName(options, deps, mode = "existing-registrati
|
|
|
980
1025
|
/** A per-host MCP config target (mirrors runInit's configTargets shape). */
|
|
981
1026
|
/** Stable wording of the interactive tool-selection prompt (BAPI-635). */
|
|
982
1027
|
export const INSTALL_BRIDGE_TOOL_SELECTOR_PROMPT = "Which AI coding tools do you use on this project?";
|
|
1028
|
+
/** A one-based selection token is exactly one or more digits, no sign/decimal/leading-zero. */
|
|
1029
|
+
const SELECTION_TOKEN_PATTERN = /^[1-9][0-9]*$/;
|
|
983
1030
|
/**
|
|
984
|
-
* Interactive numbered
|
|
985
|
-
*
|
|
986
|
-
*
|
|
987
|
-
*
|
|
988
|
-
*
|
|
989
|
-
*
|
|
990
|
-
*
|
|
991
|
-
*
|
|
1031
|
+
* Interactive single-shot numbered picker on stderr (TTY only). Renders the
|
|
1032
|
+
* heading and each option exactly once, then accepts ONE strict
|
|
1033
|
+
* comma-separated line of one-based numbers and resolves immediately — there
|
|
1034
|
+
* is no preselected/toggle state and no separate confirmation step (BAPI-663).
|
|
1035
|
+
*
|
|
1036
|
+
* A blank line or a line containing any invalid token (non-numeric, zero,
|
|
1037
|
+
* signed, leading-zero, decimal, out-of-range) is rejected atomically — the
|
|
1038
|
+
* whole line is discarded, one error line is printed, and the picker
|
|
1039
|
+
* re-prompts without re-rendering the option list. A valid line is
|
|
1040
|
+
* deduplicated and returned in `options` order regardless of entry order.
|
|
1041
|
+
*
|
|
1042
|
+
* Resolves to `[]` on EOF / synchronous close before any line has been
|
|
1043
|
+
* committed, so a readline regression can never hang. Returns `[]`
|
|
1044
|
+
* immediately, without creating a readline interface, when `options` is
|
|
1045
|
+
* empty.
|
|
992
1046
|
*/
|
|
993
|
-
export function promptMultiSelectViaReadline(promptText, options,
|
|
1047
|
+
export function promptMultiSelectViaReadline(promptText, options, input = process.stdin, output = process.stderr) {
|
|
1048
|
+
if (options.length === 0)
|
|
1049
|
+
return Promise.resolve([]);
|
|
994
1050
|
return new Promise((resolve) => {
|
|
995
|
-
const selected = new Set(defaults);
|
|
996
|
-
const render = () => {
|
|
997
|
-
output.write(`\n${promptText}\n`);
|
|
998
|
-
output.write("[x] = selected · [ ] = not selected\n");
|
|
999
|
-
options.forEach((opt, idx) => {
|
|
1000
|
-
const mark = selected.has(opt.id) ? "[x]" : "[ ]";
|
|
1001
|
-
output.write(` ${idx + 1}. ${mark} ${opt.label}\n`);
|
|
1002
|
-
});
|
|
1003
|
-
output.write("Type numbers to toggle, e.g. 1,3 — then Enter. Enter with ≥1 selected accepts.\n");
|
|
1004
|
-
output.write("Enter numbers to toggle (comma-separated), or press Enter to accept: ");
|
|
1005
|
-
};
|
|
1006
1051
|
const rl = readline.createInterface({ input, output });
|
|
1007
|
-
let
|
|
1008
|
-
const finish = () => {
|
|
1009
|
-
|
|
1052
|
+
let settled = false;
|
|
1053
|
+
const finish = (result) => {
|
|
1054
|
+
if (settled)
|
|
1055
|
+
return;
|
|
1056
|
+
settled = true;
|
|
1010
1057
|
rl.close();
|
|
1011
|
-
resolve(
|
|
1058
|
+
resolve(result);
|
|
1012
1059
|
};
|
|
1013
1060
|
// EOF / synchronous close must resolve rather than deadlock the top-level
|
|
1014
|
-
// await; `
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1061
|
+
// await; the `settled` guard stops a synchronous `close` (fired by `rl.close()`
|
|
1062
|
+
// above) from replacing an already-committed answer with the EOF fallback.
|
|
1063
|
+
rl.on("close", () => finish([]));
|
|
1064
|
+
output.write(`\n${promptText}\n`);
|
|
1065
|
+
options.forEach((opt, idx) => {
|
|
1066
|
+
output.write(` ${idx + 1}. ${opt.label}\n`);
|
|
1018
1067
|
});
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
rl.question("", (answer) => {
|
|
1068
|
+
output.write("Enter the number(s) of the tools you use (e.g. 1,3), then Enter.\n");
|
|
1069
|
+
const prompt = () => {
|
|
1070
|
+
rl.question("> ", (answer) => {
|
|
1022
1071
|
const trimmed = answer.trim();
|
|
1023
1072
|
if (trimmed.length === 0) {
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
// still resolves the current — possibly empty — set so input can never hang.
|
|
1027
|
-
if (selected.size === 0) {
|
|
1028
|
-
output.write("Select at least one tool.\n");
|
|
1029
|
-
ask();
|
|
1030
|
-
return;
|
|
1031
|
-
}
|
|
1032
|
-
finish();
|
|
1073
|
+
output.write("Select at least one tool.\n");
|
|
1074
|
+
prompt();
|
|
1033
1075
|
return;
|
|
1034
1076
|
}
|
|
1035
1077
|
const tokens = trimmed.split(",").map((t) => t.trim());
|
|
1036
|
-
const
|
|
1037
|
-
let bad = false;
|
|
1078
|
+
const indices = [];
|
|
1038
1079
|
for (const tok of tokens) {
|
|
1080
|
+
if (!SELECTION_TOKEN_PATTERN.test(tok)) {
|
|
1081
|
+
output.write(`Invalid selection. Enter numbers between 1 and ${options.length}.\n`);
|
|
1082
|
+
prompt();
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1039
1085
|
const n = Number(tok);
|
|
1040
|
-
if (
|
|
1041
|
-
|
|
1042
|
-
|
|
1086
|
+
if (n < 1 || n > options.length) {
|
|
1087
|
+
output.write(`Invalid selection. Enter numbers between 1 and ${options.length}.\n`);
|
|
1088
|
+
prompt();
|
|
1089
|
+
return;
|
|
1043
1090
|
}
|
|
1044
|
-
|
|
1045
|
-
}
|
|
1046
|
-
if (bad) {
|
|
1047
|
-
output.write(`Invalid selection. Enter numbers between 1 and ${options.length}.\n`);
|
|
1048
|
-
ask();
|
|
1049
|
-
return;
|
|
1050
|
-
}
|
|
1051
|
-
for (const n of nums) {
|
|
1052
|
-
const opt = options[n - 1];
|
|
1053
|
-
if (selected.has(opt.id))
|
|
1054
|
-
selected.delete(opt.id);
|
|
1055
|
-
else
|
|
1056
|
-
selected.add(opt.id);
|
|
1091
|
+
indices.push(n);
|
|
1057
1092
|
}
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1093
|
+
const chosen = new Set(indices);
|
|
1094
|
+
finish(options
|
|
1095
|
+
.filter((_, idx) => chosen.has(idx + 1))
|
|
1096
|
+
.map((o) => o.id));
|
|
1061
1097
|
});
|
|
1062
1098
|
};
|
|
1063
|
-
|
|
1099
|
+
prompt();
|
|
1064
1100
|
});
|
|
1065
1101
|
}
|
|
1066
1102
|
/**
|
|
@@ -1080,13 +1116,11 @@ export async function resolveSelectedHostPlatforms(deps, options) {
|
|
|
1080
1116
|
}
|
|
1081
1117
|
const ctx = await buildDetectionContext(deps);
|
|
1082
1118
|
const detected = new Set(detectDefaultPlatforms(ctx));
|
|
1083
|
-
// 2. Interactive
|
|
1084
|
-
// picker
|
|
1085
|
-
// inclusion and detected-platform seeding) and requires an explicit selection.
|
|
1119
|
+
// 2. Interactive single-shot numbered picker on a TTY. No row is pre-selected
|
|
1120
|
+
// — the picker has no default state and requires an explicit selection.
|
|
1086
1121
|
if (deps.isTTY && deps.promptMultiSelect) {
|
|
1087
1122
|
const optionList = allHostTargets().map((t) => ({ id: t.id, label: t.label }));
|
|
1088
|
-
const
|
|
1089
|
-
const chosen = await deps.promptMultiSelect(INSTALL_BRIDGE_TOOL_SELECTOR_PROMPT, optionList, defaults);
|
|
1123
|
+
const chosen = await deps.promptMultiSelect(INSTALL_BRIDGE_TOOL_SELECTOR_PROMPT, optionList);
|
|
1090
1124
|
return HOST_PLATFORM_ORDER.filter((id) => chosen.includes(id));
|
|
1091
1125
|
}
|
|
1092
1126
|
// 3. Legacy non-TTY automatic set: Claude + detected Cursor / Copilot VS Code.
|
|
@@ -1195,20 +1229,41 @@ export async function chooseInstallBridgeLaunchAgent(agents, deps) {
|
|
|
1195
1229
|
* completes.
|
|
1196
1230
|
*
|
|
1197
1231
|
* Condition-specific warning prefixes are added by the caller, never inside here.
|
|
1232
|
+
*
|
|
1233
|
+
* `toolLabels` (BAPI-661) names the actually-selected platform(s) in the
|
|
1234
|
+
* `"configured"` message so "open it in an AI coding tool" becomes "open it in
|
|
1235
|
+
* GitHub Copilot (VS Code)" when that is what was selected — an empty array (the
|
|
1236
|
+
* explicit empty-selection call site, or any caller with nothing to name) falls
|
|
1237
|
+
* back to the prior generic "an AI coding tool" wording unchanged.
|
|
1198
1238
|
*/
|
|
1199
|
-
export function buildManualInstallBridgeContinuation(kind) {
|
|
1239
|
+
export function buildManualInstallBridgeContinuation(kind, toolLabels) {
|
|
1200
1240
|
if (kind === "empty-selection") {
|
|
1201
1241
|
return [
|
|
1202
1242
|
"No AI coding tools were configured, so nothing was set up for this project.",
|
|
1203
1243
|
"Re-run install-bridge and select at least one tool to configure it.",
|
|
1204
1244
|
].join("\n");
|
|
1205
1245
|
}
|
|
1246
|
+
const toolPhrase = formatToolLabelPhrase(toolLabels);
|
|
1206
1247
|
return [
|
|
1207
|
-
|
|
1248
|
+
`To finish configuring this project, open it in ${toolPhrase} that has the`,
|
|
1208
1249
|
"Bridge MCP server configured and run /install-bridge.",
|
|
1209
1250
|
"Until the project is configured, your Bridge MCP tools stay limited.",
|
|
1210
1251
|
].join("\n");
|
|
1211
1252
|
}
|
|
1253
|
+
/**
|
|
1254
|
+
* Format 0, 1, or many tool labels into a readable phrase for the continuation
|
|
1255
|
+
* message. Empty falls back to the generic "an AI coding tool"; one label is used
|
|
1256
|
+
* bare; two or more join with a serial (Oxford) comma and "and".
|
|
1257
|
+
*/
|
|
1258
|
+
function formatToolLabelPhrase(labels) {
|
|
1259
|
+
if (labels.length === 0)
|
|
1260
|
+
return "an AI coding tool";
|
|
1261
|
+
if (labels.length === 1)
|
|
1262
|
+
return labels[0];
|
|
1263
|
+
if (labels.length === 2)
|
|
1264
|
+
return `${labels[0]} and ${labels[1]}`;
|
|
1265
|
+
return `${labels.slice(0, -1).join(", ")}, and ${labels[labels.length - 1]}`;
|
|
1266
|
+
}
|
|
1212
1267
|
/** The exact consent-prompt prefix, shared with tests so the copy cannot drift. */
|
|
1213
1268
|
export const INSTALL_BRIDGE_LAUNCH_CONSENT_PROMPT_PREFIX = "Bridge can configure and set up this project for you automatically. Open a ";
|
|
1214
1269
|
/**
|
|
@@ -1287,6 +1342,17 @@ function hostConfigTargetsForPlatforms(platforms) {
|
|
|
1287
1342
|
.filter((t) => t.scope === "project" && t.format === "json")
|
|
1288
1343
|
.map((t) => ({ relPath: t.relPath, topLevelKey: t.topLevelKey }));
|
|
1289
1344
|
}
|
|
1345
|
+
/**
|
|
1346
|
+
* Derive the human-facing labels for a set of selected platforms, in registry
|
|
1347
|
+
* (`HOST_PLATFORM_ORDER`) order rather than the caller's input order, so a
|
|
1348
|
+
* continuation message names selected tools deterministically (BAPI-661).
|
|
1349
|
+
* Deduplicates by platform ID and reads every label from `MCP_HOST_TARGETS` —
|
|
1350
|
+
* never a second, hand-maintained label map.
|
|
1351
|
+
*/
|
|
1352
|
+
export function labelsForPlatforms(platforms) {
|
|
1353
|
+
const set = new Set(platforms);
|
|
1354
|
+
return HOST_PLATFORM_ORDER.filter((id) => set.has(id)).map((id) => MCP_HOST_TARGETS[id].label);
|
|
1355
|
+
}
|
|
1290
1356
|
/** Resolve which project-local host configs to write, mirroring runInit detection. */
|
|
1291
1357
|
async function resolveHostConfigTargets(deps) {
|
|
1292
1358
|
const targets = [
|
|
@@ -1447,9 +1513,19 @@ export function buildPingUrl(baseUrl, repoName) {
|
|
|
1447
1513
|
return url.toString();
|
|
1448
1514
|
}
|
|
1449
1515
|
/**
|
|
1450
|
-
*
|
|
1451
|
-
*
|
|
1452
|
-
*
|
|
1516
|
+
* Fixed, secret-free fallback for an HTTP 403 whose body carries no usable
|
|
1517
|
+
* `detail` (absent, malformed, unreadable, or a non-string `detail`). Deliberately
|
|
1518
|
+
* does NOT claim the key is invalid or expired — a 403 means the credential was
|
|
1519
|
+
* understood but denied access, which is a different (and often more actionable)
|
|
1520
|
+
* failure than an invalid/expired key.
|
|
1521
|
+
*/
|
|
1522
|
+
const CONNECTIVITY_ACCESS_DENIED_FALLBACK = "The Bridge API denied access to this repository (HTTP 403). Verify the repo_name and that this " +
|
|
1523
|
+
"credential is authorized for it.";
|
|
1524
|
+
/**
|
|
1525
|
+
* Verify connectivity. Distinguishes a rejected/expired credential (401) from a
|
|
1526
|
+
* repository-specific access denial (403, BAPI-661) from an unknown repo /
|
|
1527
|
+
* not-found (404) where the response allows. The key is sent in the `X-API-Key`
|
|
1528
|
+
* header and NEVER appears in any returned message.
|
|
1453
1529
|
*/
|
|
1454
1530
|
export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
|
|
1455
1531
|
const url = buildPingUrl(baseUrl, repoName);
|
|
@@ -1474,7 +1550,7 @@ export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
|
|
|
1474
1550
|
}
|
|
1475
1551
|
if (resp.ok)
|
|
1476
1552
|
return { ok: true };
|
|
1477
|
-
if (resp.status === 401
|
|
1553
|
+
if (resp.status === 401) {
|
|
1478
1554
|
return {
|
|
1479
1555
|
ok: false,
|
|
1480
1556
|
message: `The Bridge API rejected the credential (HTTP ${resp.status}). The API key may be invalid or expired ` +
|
|
@@ -1482,6 +1558,27 @@ export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
|
|
|
1482
1558
|
"permission error.)",
|
|
1483
1559
|
};
|
|
1484
1560
|
}
|
|
1561
|
+
if (resp.status === 403) {
|
|
1562
|
+
// BAPI-661: a 403 means the credential was understood but access to THIS repo
|
|
1563
|
+
// was denied — the server's `detail` (e.g. "repository '<name>' is not
|
|
1564
|
+
// registered; verify the repo_name") is a safe, specific, non-secret message
|
|
1565
|
+
// that is far more actionable than the blanket invalid-key diagnosis 401 uses.
|
|
1566
|
+
// Only a nonblank string `detail` is ever surfaced; any other shape (missing
|
|
1567
|
+
// body, invalid JSON, a non-string/blank detail, or a body-read exception)
|
|
1568
|
+
// collapses to the fixed fallback so nothing unvalidated ever reaches the user.
|
|
1569
|
+
let detail;
|
|
1570
|
+
try {
|
|
1571
|
+
const body = (await resp.json());
|
|
1572
|
+
detail = body?.detail;
|
|
1573
|
+
}
|
|
1574
|
+
catch {
|
|
1575
|
+
return { ok: false, message: CONNECTIVITY_ACCESS_DENIED_FALLBACK };
|
|
1576
|
+
}
|
|
1577
|
+
if (typeof detail === "string" && detail.trim().length > 0) {
|
|
1578
|
+
return { ok: false, message: detail.trim() };
|
|
1579
|
+
}
|
|
1580
|
+
return { ok: false, message: CONNECTIVITY_ACCESS_DENIED_FALLBACK };
|
|
1581
|
+
}
|
|
1485
1582
|
if (resp.status === 404) {
|
|
1486
1583
|
return {
|
|
1487
1584
|
ok: false,
|
|
@@ -1651,6 +1748,21 @@ export async function exchangeBootstrapInvite(deps, baseUrl, token, repoName, ke
|
|
|
1651
1748
|
* treated as a `failed` mint rather than fed into the redemption path.
|
|
1652
1749
|
*/
|
|
1653
1750
|
export const BOOTSTRAP_INVITE_TOKEN_PREFIX = "bapi_inv_";
|
|
1751
|
+
/**
|
|
1752
|
+
* Classify a value entered through the ORDINARY credential input (`--api-key`,
|
|
1753
|
+
* `BAPI_API_KEY`, or the have-key hidden prompt) as a bootstrap-invite token or a
|
|
1754
|
+
* full API key (BAPI-661). This lets a user who was handed an invite but reaches
|
|
1755
|
+
* for the ordinary key input still get redeemed correctly, instead of the invite
|
|
1756
|
+
* being sent as an (invalid) API key to repository resolution / connectivity.
|
|
1757
|
+
*
|
|
1758
|
+
* Pure and total: trims first, then does a case-sensitive prefix check against
|
|
1759
|
+
* {@link BOOTSTRAP_INVITE_TOKEN_PREFIX}. Blank and every non-matching value is
|
|
1760
|
+
* `"api-key"` — the existing resolvers already reject a blank value, so this
|
|
1761
|
+
* function does not need to special-case it. Never logs or returns the value.
|
|
1762
|
+
*/
|
|
1763
|
+
export function classifyEnteredCredential(value) {
|
|
1764
|
+
return value.trim().startsWith(BOOTSTRAP_INVITE_TOKEN_PREFIX) ? "invite" : "api-key";
|
|
1765
|
+
}
|
|
1654
1766
|
/** The self-serve mint endpoint: `<base>/setup/bootstrap/self-serve`. No query string — ever. */
|
|
1655
1767
|
export function buildSelfServeMintUrl(baseUrl) {
|
|
1656
1768
|
return `${baseUrl.replace(/\/+$/, "")}/setup/bootstrap/self-serve`;
|
|
@@ -1996,8 +2108,14 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
1996
2108
|
return 1;
|
|
1997
2109
|
}
|
|
1998
2110
|
const branch = branchResult.branch;
|
|
1999
|
-
|
|
2000
|
-
|
|
2111
|
+
// Mutable (BAPI-661): an ordinary have-key credential that classifies as a
|
|
2112
|
+
// bootstrap invite (see the have-key arm below) flips `bootstrapInviteMode` to
|
|
2113
|
+
// true AFTER branch selection, before repository-name resolution — everything
|
|
2114
|
+
// downstream that branches on these two flags then follows the existing
|
|
2115
|
+
// bootstrap-invite protocol automatically. Explicit `--invite`/`BAPI_INVITE` and
|
|
2116
|
+
// `--email`/`BAPI_SIGNUP_EMAIL` never pass through this reclassification.
|
|
2117
|
+
let bootstrapInviteMode = branch.kind === "need-key";
|
|
2118
|
+
let selfServeSignupMode = branch.kind === "need-key" && branch.method === "self-serve";
|
|
2001
2119
|
// ---- Resolve inputs (may prompt when interactive) ----
|
|
2002
2120
|
// Resolve the credential/entry input first so a fully-empty non-interactive
|
|
2003
2121
|
// invocation fails with the (more relevant) missing-input message before the repo
|
|
@@ -2030,7 +2148,20 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
2030
2148
|
errorLog(`Error: ${keyResult.error}`);
|
|
2031
2149
|
return 1;
|
|
2032
2150
|
}
|
|
2033
|
-
|
|
2151
|
+
// BAPI-661: an invite pasted into the ordinary credential input (flag, env, or
|
|
2152
|
+
// hidden prompt) is redeemed like an explicit --invite, not sent as an API key
|
|
2153
|
+
// to repository resolution / connectivity. Reclassifying BEFORE that
|
|
2154
|
+
// resolution is what lets the rest of the function reuse the existing
|
|
2155
|
+
// bootstrap-invite protocol verbatim.
|
|
2156
|
+
if (classifyEnteredCredential(keyResult.value) === "invite") {
|
|
2157
|
+
inviteToken = keyResult.value.trim();
|
|
2158
|
+
apiKey = "";
|
|
2159
|
+
bootstrapInviteMode = true;
|
|
2160
|
+
selfServeSignupMode = false;
|
|
2161
|
+
}
|
|
2162
|
+
else {
|
|
2163
|
+
apiKey = keyResult.value;
|
|
2164
|
+
}
|
|
2034
2165
|
}
|
|
2035
2166
|
// baseUrl is resolved BEFORE repository input because the have-key branch needs
|
|
2036
2167
|
// it for the server-resolution request.
|
|
@@ -2077,7 +2208,11 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
2077
2208
|
else {
|
|
2078
2209
|
// Feature-detect + degrade: 404 (old server), 409 (unresolved/ambiguous/
|
|
2079
2210
|
// client-scoped), and network/other errors all fall back to the existing
|
|
2080
|
-
// local resolution WITHOUT a cause-specific message or leaked detail
|
|
2211
|
+
// local resolution — WITHOUT a cause-specific message or leaked detail, but
|
|
2212
|
+
// (BAPI-661) WITH a visible signal that the fallback is happening, so a
|
|
2213
|
+
// guessed name is never confirmed silently as if it had been verified.
|
|
2214
|
+
log("Couldn't auto-resolve your repo from the key; falling back to a guessed name — confirm " +
|
|
2215
|
+
"it matches the setup UI.");
|
|
2081
2216
|
const repoResult = await resolveRepoName(options, deps, "existing-registration");
|
|
2082
2217
|
if (!repoResult.ok) {
|
|
2083
2218
|
errorLog(`Error: ${repoResult.error}`);
|
|
@@ -2159,7 +2294,9 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
2159
2294
|
// Return BEFORE any config/credential write, materialization, prewarm, or spawn, so a
|
|
2160
2295
|
// bare `--tools=` (or an EOF-resolved picker) cleanly does nothing but guide a re-run.
|
|
2161
2296
|
if (planLaunch.kind === "manual" && planLaunch.reason === "empty-selection") {
|
|
2162
|
-
|
|
2297
|
+
// Explicit empty selection: no labels to name, so the generic fallback is
|
|
2298
|
+
// intentional here — pass [] rather than `labelsForPlatforms(selectedPlatforms)`.
|
|
2299
|
+
log(buildManualInstallBridgeContinuation("empty-selection", []));
|
|
2163
2300
|
return 0;
|
|
2164
2301
|
}
|
|
2165
2302
|
// ---- Finalize the launch agent for spawn-capable decisions ----
|
|
@@ -2609,7 +2746,7 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
2609
2746
|
// deterministic setup is complete, so this is a non-fatal warning (exit 0), with
|
|
2610
2747
|
// the same shared continuation so the remediation is consistent.
|
|
2611
2748
|
errorLog(`Warning: setup steps completed, but the agent session could not be opened (${spawnResult.error}).`);
|
|
2612
|
-
log(buildManualInstallBridgeContinuation("configured"));
|
|
2749
|
+
log(buildManualInstallBridgeContinuation("configured", labelsForPlatforms(selectedPlatforms)));
|
|
2613
2750
|
return 0;
|
|
2614
2751
|
}
|
|
2615
2752
|
log("");
|
|
@@ -2623,13 +2760,13 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
2623
2760
|
return 0;
|
|
2624
2761
|
}
|
|
2625
2762
|
// Declined (or non-TTY suppression): the deterministic setup is complete and durable.
|
|
2626
|
-
log(buildManualInstallBridgeContinuation("configured"));
|
|
2763
|
+
log(buildManualInstallBridgeContinuation("configured", labelsForPlatforms(selectedPlatforms)));
|
|
2627
2764
|
return 0;
|
|
2628
2765
|
}
|
|
2629
2766
|
// No launchable session (a no-launchable selection, or a non-TTY / declined chooser):
|
|
2630
2767
|
// finalAgentName is null, so nothing was materialized. The configured hosts are
|
|
2631
2768
|
// durable — print the shared continuation + limited-tools note (never the
|
|
2632
2769
|
// empty-selection variant, which already returned above).
|
|
2633
|
-
log(buildManualInstallBridgeContinuation("configured"));
|
|
2770
|
+
log(buildManualInstallBridgeContinuation("configured", labelsForPlatforms(selectedPlatforms)));
|
|
2634
2771
|
return 0;
|
|
2635
2772
|
}
|