@bridge_gpt/mcp-server 0.2.32 → 0.2.34
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 +51 -21
- package/build/conductor/bridge-api-client.js +37 -3
- package/build/conductor-bin.js +1 -1
- package/build/connect-github-api.js +103 -0
- package/build/connect-github.js +325 -39
- package/build/index.js +22 -10
- package/build/install-bridge.js +323 -68
- package/build/readme.generated.js +1 -1
- package/build/version.generated.js +1 -1
- package/docs/install/github-app.md +96 -8
- package/package.json +3 -3
package/build/install-bridge.js
CHANGED
|
@@ -252,36 +252,52 @@ export function getInstallBridgeUsage(baseUrl = DEFAULT_BAPI_BASE_URL) {
|
|
|
252
252
|
...INSTALL_BRIDGE_ONBOARDING_CHOICES.map((choice) => ` ${choice}`),
|
|
253
253
|
"There is NO default — pressing Enter selects nothing; you must type 1, 2, or 3",
|
|
254
254
|
"(one blank or invalid answer re-prompts once, then exits with guidance). Option 1",
|
|
255
|
-
"is the
|
|
256
|
-
"
|
|
257
|
-
"
|
|
255
|
+
"is the bootstrap-invite flow, option 2 prompts for an email and creates a",
|
|
256
|
+
"brand-new Bridge workspace for you (the self-serve flow), and option 3 is the",
|
|
257
|
+
"existing-project API-key flow below. The project-CREATING routes lead",
|
|
258
|
+
"deliberately. That question is asked ONLY for a bare interactive run: passing",
|
|
258
259
|
"ANY flag, setting BAPI_API_KEY, or running without an interactive terminal keeps",
|
|
259
260
|
"the existing deterministic behavior and no prompt.",
|
|
260
261
|
"",
|
|
262
|
+
"Entered the wrong kind of credential? Both directions are detected by SHAPE",
|
|
263
|
+
"alone — no probe request is sent — and neither switches routes in silence:",
|
|
264
|
+
" - a bootstrap invite (bapi_inv_…) entered as an API key is announced, then",
|
|
265
|
+
" confirmed on a terminal before it is redeemed; a non-interactive run keeps",
|
|
266
|
+
" today's automatic switch but prints the notice.",
|
|
267
|
+
" - an API-key-shaped value entered as an invite is announced and may switch in",
|
|
268
|
+
" place only after you confirm on a terminal — non-interactive runs exit with",
|
|
269
|
+
" guidance instead, so a script is never silently redirected. The check runs",
|
|
270
|
+
" BEFORE any project-name prompt, credential write, or network call.",
|
|
271
|
+
"A value that is neither shape is left on the route you chose.",
|
|
272
|
+
"",
|
|
261
273
|
"Inputs (the only two irreducible ones):",
|
|
262
274
|
" --api-key <key> Bridge API key OR bootstrap invite. Falls back to the",
|
|
263
275
|
" BAPI_API_KEY env var, then an interactive (no-echo) prompt.",
|
|
264
276
|
` Generate a key at ${setupUrl} (Security page) — this`,
|
|
265
277
|
" command consumes a key, it does not create one. A value",
|
|
266
278
|
" detected as a bootstrap invite (bapi_inv_…), from any of the",
|
|
267
|
-
" three sources above, is instead redeemed to
|
|
268
|
-
" project and its first admin key — the same as
|
|
269
|
-
" so it skips repository lookup entirely (--email
|
|
270
|
-
" remain the explicit, preferred entry points for
|
|
271
|
-
" NEVER printed or logged.",
|
|
279
|
+
" three sources above, is announced and instead redeemed to",
|
|
280
|
+
" CREATE a new project and its first admin key — the same as",
|
|
281
|
+
" --invite — so it skips repository lookup entirely (--email",
|
|
282
|
+
" and --invite remain the explicit, preferred entry points for",
|
|
283
|
+
" a new project). NEVER printed or logged.",
|
|
272
284
|
" --repo <name> Repository name. --repo and BAPI_REPO_NAME still take",
|
|
273
285
|
" priority and short-circuit before any network call. When",
|
|
274
286
|
" neither is set, a compatible server resolves the unique",
|
|
275
|
-
" repository from your existing API key automatically
|
|
276
|
-
"
|
|
277
|
-
"
|
|
278
|
-
|
|
279
|
-
"
|
|
280
|
-
"
|
|
281
|
-
"
|
|
282
|
-
" --
|
|
283
|
-
"
|
|
284
|
-
"
|
|
287
|
+
" repository from your existing API key automatically. If the",
|
|
288
|
+
" key does not identify exactly one project, the run STOPS —",
|
|
289
|
+
" it never guesses — and asks you to re-run with --repo <name>,",
|
|
290
|
+
` or to rotate the key at ${setupUrl} (Security page).`,
|
|
291
|
+
" If instead the server is older or resolution fails",
|
|
292
|
+
" transiently, you are asked to type the registered name, with",
|
|
293
|
+
" NO pre-filled default (and",
|
|
294
|
+
" --repo is required when stdin is non-interactive). In the",
|
|
295
|
+
" existing-project flow it MUST match the server-side repo",
|
|
296
|
+
" registration (it keys the credential store as bapi:<repo>).",
|
|
297
|
+
" In either new-project flow (--email, --invite, or chooser",
|
|
298
|
+
" option 1 or 2 above) it instead NAMES the project this run",
|
|
299
|
+
" creates, so you are asked to name a new project — with a",
|
|
300
|
+
" suggested default — rather than match an existing one; the",
|
|
285
301
|
" name must be globally unique.",
|
|
286
302
|
"",
|
|
287
303
|
"Self-serve onboarding (no account, no API key, no pre-issued invite):",
|
|
@@ -290,7 +306,7 @@ export function getInstallBridgeUsage(baseUrl = DEFAULT_BAPI_BASE_URL) {
|
|
|
290
306
|
" It requests a fresh workspace for that email, then creates",
|
|
291
307
|
" the project and mints your own admin API key in one command.",
|
|
292
308
|
" Falls back to the BAPI_SIGNUP_EMAIL env var, then a visible",
|
|
293
|
-
" interactive prompt — which is also what chooser option
|
|
309
|
+
" interactive prompt — which is also what chooser option 2 on a",
|
|
294
310
|
" bare run reaches. The email is NOT a",
|
|
295
311
|
" secret (it is shown as you type), but it is never printed to",
|
|
296
312
|
" a log. Mutually",
|
|
@@ -997,11 +1013,24 @@ export function resolveInstallBridgeOnboardingBranch(options, env) {
|
|
|
997
1013
|
* numbered options once, accept ONE strict line.
|
|
998
1014
|
*/
|
|
999
1015
|
export const INSTALL_BRIDGE_KEY_SELECTOR_PROMPT = "How would you like to connect to Bridge API?";
|
|
1000
|
-
/**
|
|
1016
|
+
/**
|
|
1017
|
+
* The chooser's numbered options, in render order. Index + 1 is the accepted token.
|
|
1018
|
+
*
|
|
1019
|
+
* BAPI-687 (A-1) put the two PROJECT-CREATING routes first. The previous order led
|
|
1020
|
+
* with the existing-project key path, labelled only "I have a Bridge API key" — which
|
|
1021
|
+
* reads as the default for anyone holding any credential, so a user with a valid
|
|
1022
|
+
* invite picked it and dead-ended at a repo-not-registered 403 with no route to the
|
|
1023
|
+
* path that would have created their project. Position was doing the opposite of the
|
|
1024
|
+
* intended routing.
|
|
1025
|
+
*
|
|
1026
|
+
* Option 3's label carries its own scope ("for an existing project") rather than
|
|
1027
|
+
* relying on position (A-3): a stale screenshot or a habituated user must still be
|
|
1028
|
+
* able to read the right choice off the label itself.
|
|
1029
|
+
*/
|
|
1001
1030
|
export const INSTALL_BRIDGE_ONBOARDING_CHOICES = [
|
|
1002
|
-
"1. I have
|
|
1003
|
-
"2. I
|
|
1004
|
-
"3. I
|
|
1031
|
+
"1. I have an invite token",
|
|
1032
|
+
"2. I'm new — set me up with just my email",
|
|
1033
|
+
"3. I have an API key for an existing project",
|
|
1005
1034
|
];
|
|
1006
1035
|
/** The single-line answer prompt drawn after the options (no bracketed default). */
|
|
1007
1036
|
export const INSTALL_BRIDGE_ONBOARDING_CHOICE_PROMPT = "Enter 1, 2, or 3: ";
|
|
@@ -1017,7 +1046,7 @@ export const INSTALL_BRIDGE_ONBOARDING_CHOICE_HINT = "Enter 1, 2, or 3.";
|
|
|
1017
1046
|
*/
|
|
1018
1047
|
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
1048
|
"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
|
|
1049
|
+
"to sign up with just an email — on a bare interactive run, re-run and choose option 2 to " +
|
|
1021
1050
|
"sign up with just an email.";
|
|
1022
1051
|
/**
|
|
1023
1052
|
* Interactive wrapper around {@link resolveInstallBridgeOnboardingBranch}.
|
|
@@ -1042,10 +1071,16 @@ export const INSTALL_BRIDGE_ENTRY_ROUTES_ADVICE = "Re-run install-bridge and cho
|
|
|
1042
1071
|
*
|
|
1043
1072
|
* BAPI-667 replaced the Y/n question with a three-option chooser. Two properties
|
|
1044
1073
|
* 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
|
|
1046
|
-
*
|
|
1074
|
+
* brand-new user into the hidden key prompt), and the self-serve route is named
|
|
1075
|
+
* explicitly so it is discoverable without reading `--help`. Validation is
|
|
1047
1076
|
* bounded to ONE re-prompt — an unanswerable prompt that loops is the same
|
|
1048
1077
|
* dead-end in a different costume.
|
|
1078
|
+
*
|
|
1079
|
+
* BAPI-687 reordered the options so the project-CREATING routes lead (1 = invite,
|
|
1080
|
+
* 2 = self-serve email, 3 = existing-project API key). Only the labels and the
|
|
1081
|
+
* dispatch moved: the heading, the `Enter 1, 2, or 3: ` prompt, the accepted-token
|
|
1082
|
+
* set, the no-default rule, EOF handling, and every flag/env/non-TTY bypass are
|
|
1083
|
+
* byte-for-byte unchanged.
|
|
1049
1084
|
*/
|
|
1050
1085
|
export async function resolveInstallBridgeOnboardingBranchForRun(options, deps, argv) {
|
|
1051
1086
|
const branch = resolveInstallBridgeOnboardingBranch(options, deps.env);
|
|
@@ -1070,15 +1105,18 @@ export async function resolveInstallBridgeOnboardingBranchForRun(options, deps,
|
|
|
1070
1105
|
// it can never hang or silently select a branch.
|
|
1071
1106
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1072
1107
|
const answer = (await promptLine(INSTALL_BRIDGE_ONBOARDING_CHOICE_PROMPT)).trim();
|
|
1073
|
-
//
|
|
1074
|
-
//
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1108
|
+
// BAPI-687 (A-2): the dispatch moved WITH the labels — the accepted tokens are
|
|
1109
|
+
// still exactly 1/2/3 (no letters, no aliases), so the one-strict-line input
|
|
1110
|
+
// contract BAPI-667 established is unchanged. Either branch stays
|
|
1111
|
+
// credential-agnostic: a value pasted onto the wrong path is detected and
|
|
1112
|
+
// re-routed downstream rather than silently accepted (B-2 / B-3).
|
|
1113
|
+
if (answer === "1") {
|
|
1078
1114
|
return { ok: true, branch: { kind: "need-key", method: "bootstrap-invite" } };
|
|
1079
1115
|
}
|
|
1080
|
-
if (answer === "
|
|
1116
|
+
if (answer === "2")
|
|
1081
1117
|
return { ok: true, branch: { kind: "need-key", method: "self-serve" } };
|
|
1118
|
+
if (answer === "3")
|
|
1119
|
+
return { ok: true, branch: { kind: "have-key" } };
|
|
1082
1120
|
if (attempt === 0)
|
|
1083
1121
|
deps.log(INSTALL_BRIDGE_ONBOARDING_CHOICE_HINT);
|
|
1084
1122
|
}
|
|
@@ -1112,23 +1150,30 @@ export function resolveConfiguredRepoName(options, env) {
|
|
|
1112
1150
|
return undefined;
|
|
1113
1151
|
}
|
|
1114
1152
|
/**
|
|
1115
|
-
* Resolve the repo name: `--repo` → `BAPI_REPO_NAME` env →
|
|
1116
|
-
*
|
|
1117
|
-
*
|
|
1118
|
-
* the repo identity keys the credential store, so it is never silently inferred
|
|
1153
|
+
* Resolve the repo name: `--repo` → `BAPI_REPO_NAME` env → an interactive prompt.
|
|
1154
|
+
* Fails fast when neither is supplied and stdin is non-interactive — the repo
|
|
1155
|
+
* identity keys the credential store, so it is never silently inferred
|
|
1119
1156
|
* non-interactively.
|
|
1120
1157
|
*
|
|
1121
|
-
* `mode` selects
|
|
1122
|
-
*
|
|
1123
|
-
*
|
|
1158
|
+
* `mode` selects the WORDING **and, since BAPI-687 (C-3), whether a default is
|
|
1159
|
+
* inferred at all**:
|
|
1160
|
+
*
|
|
1161
|
+
* - `new-project` — unchanged. The project does not exist yet, so a suggested
|
|
1162
|
+
* name (from `.bridge/config`, else the cwd basename) is genuinely helpful and
|
|
1163
|
+
* a bare Enter accepts it.
|
|
1164
|
+
* - `existing-registration` — NO inference and NO bracketed default. The name
|
|
1165
|
+
* must match a registration the user already has server-side, where the cwd
|
|
1166
|
+
* basename is essentially never right; BAPI-661 already tried warning harder
|
|
1167
|
+
* and the 2026-07-21 run shows the user accepted the guess anyway, then took a
|
|
1168
|
+
* 403 two steps later. A pre-filled wrong answer is the defect, so the user now
|
|
1169
|
+
* types the registered name deliberately.
|
|
1124
1170
|
*/
|
|
1125
1171
|
export async function resolveRepoName(options, deps, mode = "existing-registration") {
|
|
1126
1172
|
const configured = resolveConfiguredRepoName(options, deps.env);
|
|
1127
1173
|
if (configured !== undefined) {
|
|
1128
1174
|
return { ok: true, value: configured };
|
|
1129
1175
|
}
|
|
1130
|
-
// Non-interactive: fail fast and require --repo.
|
|
1131
|
-
// convenience only (the user must confirm it).
|
|
1176
|
+
// Non-interactive: fail fast and require --repo.
|
|
1132
1177
|
if (!deps.isTTY || !deps.promptLine) {
|
|
1133
1178
|
return {
|
|
1134
1179
|
ok: false,
|
|
@@ -1137,10 +1182,19 @@ export async function resolveRepoName(options, deps, mode = "existing-registrati
|
|
|
1137
1182
|
"variable (no interactive terminal is available to confirm an inferred name). It " +
|
|
1138
1183
|
"names the new Bridge project this run creates and must be globally unique."
|
|
1139
1184
|
: "A repo name is required. Pass --repo or set the BAPI_REPO_NAME environment variable " +
|
|
1140
|
-
"(no interactive terminal is available to
|
|
1141
|
-
"server-side repository registration.",
|
|
1185
|
+
"(no interactive terminal is available to prompt for it). It must match the " +
|
|
1186
|
+
"server-side repository registration exactly.",
|
|
1142
1187
|
};
|
|
1143
1188
|
}
|
|
1189
|
+
// BAPI-687 (C-3): only the new-project mode infers. The existing-registration
|
|
1190
|
+
// prompt is rendered with NO bracketed default, so a bare Enter cannot commit a
|
|
1191
|
+
// guess the user never chose.
|
|
1192
|
+
if (mode !== "new-project") {
|
|
1193
|
+
const answer = (await deps.promptLine("Repo name (must match server-side registration): ")).trim();
|
|
1194
|
+
if (answer.length > 0)
|
|
1195
|
+
return { ok: true, value: answer };
|
|
1196
|
+
return { ok: false, error: "No repo name provided." };
|
|
1197
|
+
}
|
|
1144
1198
|
// Infer a sensible default: existing .bridge/config, else the cwd basename.
|
|
1145
1199
|
let inferred = await resolveStartTicketsRepoName({
|
|
1146
1200
|
env: deps.env,
|
|
@@ -1153,19 +1207,13 @@ export async function resolveRepoName(options, deps, mode = "existing-registrati
|
|
|
1153
1207
|
inferred = validated.value;
|
|
1154
1208
|
}
|
|
1155
1209
|
if (inferred) {
|
|
1156
|
-
const
|
|
1157
|
-
? `Name your new Bridge project [${inferred}]: `
|
|
1158
|
-
: `Repo name [${inferred}] (must match server-side registration): `;
|
|
1159
|
-
const answer = (await deps.promptLine(promptText)).trim();
|
|
1210
|
+
const answer = (await deps.promptLine(`Name your new Bridge project [${inferred}]: `)).trim();
|
|
1160
1211
|
const chosen = answer.length > 0 ? answer : inferred;
|
|
1161
1212
|
if (chosen.length > 0)
|
|
1162
1213
|
return { ok: true, value: chosen };
|
|
1163
1214
|
}
|
|
1164
1215
|
else {
|
|
1165
|
-
const
|
|
1166
|
-
? "Name your new Bridge project: "
|
|
1167
|
-
: "Repo name (must match server-side registration): ";
|
|
1168
|
-
const answer = (await deps.promptLine(promptText)).trim();
|
|
1216
|
+
const answer = (await deps.promptLine("Name your new Bridge project: ")).trim();
|
|
1169
1217
|
if (answer.length > 0)
|
|
1170
1218
|
return { ok: true, value: answer };
|
|
1171
1219
|
}
|
|
@@ -1986,6 +2034,23 @@ export async function verifyConnectivity(deps, baseUrl, repoName, apiKey, apiKey
|
|
|
1986
2034
|
message: `Connectivity check failed (HTTP ${resp.status}). Verify your repo, API key, and BAPI_BASE_URL.`,
|
|
1987
2035
|
};
|
|
1988
2036
|
}
|
|
2037
|
+
/**
|
|
2038
|
+
* The `unresolved` (409) hard stop (BAPI-687, C-2). Names both recoveries; the
|
|
2039
|
+
* caller appends the address-bearing rotation line so this string stays a constant.
|
|
2040
|
+
* Secret-free: no key, no HTTP status, no server detail.
|
|
2041
|
+
*/
|
|
2042
|
+
export const INSTALL_BRIDGE_UNRESOLVED_KEY_ERROR = "Error: that API key does not identify exactly one Bridge project, so the repository could " +
|
|
2043
|
+
"not be resolved from it. Re-run with --repo <name> (or set BAPI_REPO_NAME) to name the " +
|
|
2044
|
+
"registered project explicitly.";
|
|
2045
|
+
/**
|
|
2046
|
+
* The surviving `not-deployed` (404) / `error` fallback notice (BAPI-687, C-5).
|
|
2047
|
+
*
|
|
2048
|
+
* Re-worded for a path that no longer OFFERS a name: the old copy said the CLI was
|
|
2049
|
+
* "falling back to a guessed name" and told the user to confirm it against the setup
|
|
2050
|
+
* UI, which is now false in both halves — there is nothing pre-filled to confirm.
|
|
2051
|
+
*/
|
|
2052
|
+
export const INSTALL_BRIDGE_RESOLUTION_UNAVAILABLE_NOTICE = "Automatic repository resolution isn't available for this run. Enter the name your project " +
|
|
2053
|
+
"is registered under (or re-run with --repo <name>).";
|
|
1989
2054
|
/** The resolve endpoint: `<base>/setup/resolve-repo`. No query string — ever. */
|
|
1990
2055
|
export function buildResolveRepoUrl(baseUrl) {
|
|
1991
2056
|
return `${baseUrl.replace(/\/+$/, "")}/setup/resolve-repo`;
|
|
@@ -2166,6 +2231,118 @@ export const BOOTSTRAP_INVITE_TOKEN_PREFIX = "bapi_inv_";
|
|
|
2166
2231
|
export function classifyEnteredCredential(value) {
|
|
2167
2232
|
return value.trim().startsWith(BOOTSTRAP_INVITE_TOKEN_PREFIX) ? "invite" : "api-key";
|
|
2168
2233
|
}
|
|
2234
|
+
// ---------------------------------------------------------------------------
|
|
2235
|
+
// Cross-credential detection (BAPI-687, Part B)
|
|
2236
|
+
// ---------------------------------------------------------------------------
|
|
2237
|
+
/** The exact encoded length of `secrets.token_urlsafe(32)`: 32 bytes, unpadded base64url. */
|
|
2238
|
+
export const USER_ACCESS_KEY_ENCODED_LENGTH = 43;
|
|
2239
|
+
/** The exact decoded length the encoded form must yield. */
|
|
2240
|
+
export const USER_ACCESS_KEY_DECODED_BYTES = 32;
|
|
2241
|
+
/** Strict base64url alphabet, anchored to the exact issuance length. */
|
|
2242
|
+
const USER_ACCESS_KEY_SHAPE_PATTERN = new RegExp(`^[A-Za-z0-9_-]{${USER_ACCESS_KEY_ENCODED_LENGTH}}$`);
|
|
2243
|
+
/**
|
|
2244
|
+
* POSITIVE test for the project's API-key shape — the TypeScript port of the
|
|
2245
|
+
* server's `is_high_entropy_user_access_key_shape`
|
|
2246
|
+
* (`src/python/library/auth/user_access_lookup.py`). Detection had to become
|
|
2247
|
+
* positive: "is an API key" was previously only the NEGATIVE of the `bapi_inv_`
|
|
2248
|
+
* prefix check, which would confidently mis-announce a typo'd invite as an API key.
|
|
2249
|
+
*
|
|
2250
|
+
* Admits ONLY `secrets.token_urlsafe(32)`: exactly 43 characters from the strict
|
|
2251
|
+
* base64url alphabet, decoding to exactly 32 bytes, AND canonical — the input must
|
|
2252
|
+
* equal the unpadded base64url re-encoding of those bytes. The canonical check is
|
|
2253
|
+
* not decoration: the final 6-bit group of a 32-byte value carries 2 unused low
|
|
2254
|
+
* bits, so several distinct 43-character strings decode to the same 32 bytes and
|
|
2255
|
+
* length-only validation would admit the aliases. It deliberately does NOT admit
|
|
2256
|
+
* shorter legacy keys — a legacy credential misses detection rather than being
|
|
2257
|
+
* re-routed on a guess.
|
|
2258
|
+
*
|
|
2259
|
+
* Pure and total: no logging, no trimming (the caller owns normalization), no
|
|
2260
|
+
* throw, and the input is never placed in a returned or thrown value. Every
|
|
2261
|
+
* failure path returns `false`.
|
|
2262
|
+
*/
|
|
2263
|
+
export function isHighEntropyApiKeyShape(value) {
|
|
2264
|
+
if (typeof value !== "string" || !USER_ACCESS_KEY_SHAPE_PATTERN.test(value))
|
|
2265
|
+
return false;
|
|
2266
|
+
let decoded;
|
|
2267
|
+
try {
|
|
2268
|
+
// The alphabet was already validated above, so this decode cannot silently
|
|
2269
|
+
// discard characters the way a lenient base64url decode otherwise would.
|
|
2270
|
+
decoded = Buffer.from(value, "base64url");
|
|
2271
|
+
}
|
|
2272
|
+
catch {
|
|
2273
|
+
return false;
|
|
2274
|
+
}
|
|
2275
|
+
if (decoded.length !== USER_ACCESS_KEY_DECODED_BYTES)
|
|
2276
|
+
return false;
|
|
2277
|
+
return decoded.toString("base64url") === value;
|
|
2278
|
+
}
|
|
2279
|
+
/**
|
|
2280
|
+
* Notice printed when a bootstrap invite is detected on the API-key path (B-2).
|
|
2281
|
+
*
|
|
2282
|
+
* The reclassification itself is not new (BAPI-661) — the SILENCE was. A user who
|
|
2283
|
+
* called their value an "API key" was branch-switched with no indication, which is
|
|
2284
|
+
* half of why the reported run was opaque to everyone reading the transcript.
|
|
2285
|
+
*
|
|
2286
|
+
* Secret discipline: names the credential TYPE and the destination ROUTE only.
|
|
2287
|
+
* No part of the value — no prefix excerpt, no length, no fingerprint.
|
|
2288
|
+
*/
|
|
2289
|
+
export const INSTALL_BRIDGE_INVITE_ON_KEY_PATH_NOTICE = "That looks like a bootstrap invite token, not a Bridge API key. An invite CREATES a new " +
|
|
2290
|
+
"Bridge project and mints your first admin key, instead of connecting to a project that " +
|
|
2291
|
+
"already exists.";
|
|
2292
|
+
/** TTY confirmation for the invite-detected-on-the-key-path switch. Default No. */
|
|
2293
|
+
export const INSTALL_BRIDGE_INVITE_ON_KEY_PATH_PROMPT = "Redeem it as an invite and create a new project? [y/N]: ";
|
|
2294
|
+
/** Non-TTY form: the switch still happens automatically, but it is announced (B-2). */
|
|
2295
|
+
export const INSTALL_BRIDGE_INVITE_ON_KEY_PATH_AUTO_NOTICE = "Redeeming it as a bootstrap invite (non-interactive — no confirmation is possible here).";
|
|
2296
|
+
/** Printed on decline. Names the other route rather than leaving the user stranded. */
|
|
2297
|
+
export const INSTALL_BRIDGE_INVITE_ON_KEY_PATH_DECLINED = "Error: cancelled — nothing was created. Re-run install-bridge with an API key for an " +
|
|
2298
|
+
"existing project (--api-key <key>, or BAPI_API_KEY), or re-run and confirm to redeem the " +
|
|
2299
|
+
"invite and create a new project.";
|
|
2300
|
+
/**
|
|
2301
|
+
* Notice printed when an API-key-shaped value is detected on the invite path (B-3).
|
|
2302
|
+
*
|
|
2303
|
+
* This direction had NO detection at all, and the cost of that was severe: the run
|
|
2304
|
+
* prompted for a new project name, fsynced a durable `bootstrap-pending:<repo>`
|
|
2305
|
+
* record, POSTed the exchange, took a 401, and printed the "you cannot recover it
|
|
2306
|
+
* yourself — ask your Bridge API operator" message. For a wrong-credential-type
|
|
2307
|
+
* paste. The orphaned pending record then blocked the next attempt.
|
|
2308
|
+
*
|
|
2309
|
+
* Secret discipline: credential TYPE and destination ROUTE only.
|
|
2310
|
+
*/
|
|
2311
|
+
export const INSTALL_BRIDGE_KEY_ON_INVITE_PATH_NOTICE = "That looks like a Bridge API key, not a bootstrap invite token. An API key connects to a " +
|
|
2312
|
+
"project that already exists, instead of creating a new one.";
|
|
2313
|
+
/** TTY confirmation for the key-detected-on-the-invite-path switch. Default No. */
|
|
2314
|
+
export const INSTALL_BRIDGE_KEY_ON_INVITE_PATH_PROMPT = "Use it to connect to your existing project instead? [y/N]: ";
|
|
2315
|
+
/**
|
|
2316
|
+
* Printed when the switch is declined, unconfirmable, or non-interactive.
|
|
2317
|
+
*
|
|
2318
|
+
* Non-TTY does NOT auto-switch here, and the asymmetry is deliberate: the invite
|
|
2319
|
+
* direction merely continues a path BAPI-661 already established, while this
|
|
2320
|
+
* direction would silently redirect a script away from the project-creating run it
|
|
2321
|
+
* asked for. A script gets a non-zero exit and the exact flag it should have passed.
|
|
2322
|
+
*/
|
|
2323
|
+
export const INSTALL_BRIDGE_KEY_ON_INVITE_PATH_DECLINED = "Error: cancelled — nothing was created and no invite was spent. Re-run install-bridge with " +
|
|
2324
|
+
"--api-key <key> (or BAPI_API_KEY) to connect to your existing project, or supply a bootstrap " +
|
|
2325
|
+
"invite token (bapi_inv_…) to create a new one.";
|
|
2326
|
+
/**
|
|
2327
|
+
* Shared default-No confirmation for a cross-credential route switch (B-2 / B-3).
|
|
2328
|
+
*
|
|
2329
|
+
* Accepts only an explicit `y`/`yes` (case-insensitive, trimmed); EVERY other
|
|
2330
|
+
* answer — including a prompt that throws or an EOF-resolved empty line — is a
|
|
2331
|
+
* decline, because an unreadable prompt is not consent. The credential value is
|
|
2332
|
+
* never passed in, so neither the prompt text nor a caught exception can carry it.
|
|
2333
|
+
*/
|
|
2334
|
+
export async function requestCredentialRouteSwitchConfirmation(deps, promptText) {
|
|
2335
|
+
if (!deps.isTTY || !deps.promptLine)
|
|
2336
|
+
return false;
|
|
2337
|
+
try {
|
|
2338
|
+
const answer = (await deps.promptLine(promptText)).trim().toLowerCase();
|
|
2339
|
+
return answer === "y" || answer === "yes";
|
|
2340
|
+
}
|
|
2341
|
+
catch {
|
|
2342
|
+
// Secret-free by construction: the caught value is never surfaced or inspected.
|
|
2343
|
+
return false;
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2169
2346
|
/** The self-serve mint endpoint: `<base>/setup/bootstrap/self-serve`. No query string — ever. */
|
|
2170
2347
|
export function buildSelfServeMintUrl(baseUrl) {
|
|
2171
2348
|
return `${baseUrl.replace(/\/+$/, "")}/setup/bootstrap/self-serve`;
|
|
@@ -2344,10 +2521,15 @@ export function buildDryRunPreview(plan) {
|
|
|
2344
2521
|
if (plan.bootstrapInvite)
|
|
2345
2522
|
return buildBootstrapDryRunPreview(plan);
|
|
2346
2523
|
return [
|
|
2347
|
-
|
|
2524
|
+
// Any defined status means the read-only GET really happened, so the header is
|
|
2525
|
+
// truthful for every outcome — but ONLY `resolved` may claim the name came back
|
|
2526
|
+
// from the server (BAPI-687, C-4).
|
|
2527
|
+
plan.serverRepoResolutionStatus !== undefined
|
|
2348
2528
|
? "install-bridge --dry-run (one read-only repository-resolution GET may already have occurred; no writes, no state-changing requests, no spawns)"
|
|
2349
2529
|
: "install-bridge --dry-run (no writes, no network, no spawns)",
|
|
2350
|
-
`Repo name: ${plan.repoName}${plan.
|
|
2530
|
+
`Repo name: ${plan.repoName}${plan.serverRepoResolutionStatus === "resolved"
|
|
2531
|
+
? " (resolved server-side from your API key)"
|
|
2532
|
+
: ""}`,
|
|
2351
2533
|
`Base URL (ping): ${plan.baseUrl}`,
|
|
2352
2534
|
`Docs dir: ${plan.docsDir}`,
|
|
2353
2535
|
`Agent: ${describePlannedLaunchAgent(plan.launch)}`,
|
|
@@ -2933,6 +3115,14 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
2933
3115
|
// downstream that branches on these two flags then follows the existing
|
|
2934
3116
|
// bootstrap-invite protocol automatically. Explicit `--invite`/`BAPI_INVITE` and
|
|
2935
3117
|
// `--email`/`BAPI_SIGNUP_EMAIL` never pass through this reclassification.
|
|
3118
|
+
//
|
|
3119
|
+
// BAPI-687 (Part B) makes the reclassification BIDIRECTIONAL and, in both
|
|
3120
|
+
// directions, ANNOUNCED. The two directions are deliberately asymmetric on a
|
|
3121
|
+
// non-TTY: an invite found on the key path still switches automatically (a script
|
|
3122
|
+
// that worked yesterday must not gain a prompt) but now prints a notice, whereas
|
|
3123
|
+
// an API key found on the invite path exits non-zero, because auto-switching there
|
|
3124
|
+
// would silently redirect a script away from the project-creating run it asked
|
|
3125
|
+
// for. Detection is by SHAPE ONLY — no probe request, no credential-store read.
|
|
2936
3126
|
let bootstrapInviteMode = branch.kind === "need-key";
|
|
2937
3127
|
let selfServeSignupMode = branch.kind === "need-key" && branch.method === "self-serve";
|
|
2938
3128
|
// ---- Resolve inputs (may prompt when interactive) ----
|
|
@@ -2965,6 +3155,37 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
2965
3155
|
return 1;
|
|
2966
3156
|
}
|
|
2967
3157
|
inviteToken = inviteResult.value;
|
|
3158
|
+
// BAPI-687 (B-3/B-4): an API-key-shaped value pasted onto the invite path is
|
|
3159
|
+
// caught HERE — before the project-name prompt, before `prepareBootstrapPending`
|
|
3160
|
+
// writes a durable `bootstrap-pending:<repo>` record, and before any fetch. That
|
|
3161
|
+
// ordering IS the fix: today's failure leaves exactly that debris behind, and the
|
|
3162
|
+
// orphaned pending record is what turns a wrong-credential paste into an
|
|
3163
|
+
// unrecoverable "ask your Bridge API operator" message plus a `pending-conflict`
|
|
3164
|
+
// on the next attempt.
|
|
3165
|
+
//
|
|
3166
|
+
// Guarded by BOTH conditions (B-5): not `bapi_inv_`-prefixed AND a positive
|
|
3167
|
+
// API-key shape. A value that is neither — a typo'd invite, say — is not
|
|
3168
|
+
// reclassified in either direction; it stays on the branch the user chose and
|
|
3169
|
+
// fails there exactly as it does today.
|
|
3170
|
+
if (classifyEnteredCredential(inviteToken) !== "invite" &&
|
|
3171
|
+
isHighEntropyApiKeyShape(inviteToken)) {
|
|
3172
|
+
log(INSTALL_BRIDGE_KEY_ON_INVITE_PATH_NOTICE);
|
|
3173
|
+
const switchToKeyPath = await requestCredentialRouteSwitchConfirmation(deps, INSTALL_BRIDGE_KEY_ON_INVITE_PATH_PROMPT);
|
|
3174
|
+
if (!switchToKeyPath) {
|
|
3175
|
+
// Decline, an unreadable prompt, and a non-TTY run all land here — nothing
|
|
3176
|
+
// has been prompted for, written, or sent, so the exit costs the user only
|
|
3177
|
+
// the re-run named in the guidance.
|
|
3178
|
+
errorLog(INSTALL_BRIDGE_KEY_ON_INVITE_PATH_DECLINED);
|
|
3179
|
+
return 1;
|
|
3180
|
+
}
|
|
3181
|
+
apiKey = inviteToken;
|
|
3182
|
+
inviteToken = "";
|
|
3183
|
+
bootstrapInviteMode = false;
|
|
3184
|
+
selfServeSignupMode = false;
|
|
3185
|
+
// Deliberately unattributed: the value came through the INVITE input seam, so
|
|
3186
|
+
// naming `--api-key` or `BAPI_API_KEY` on a later 401 would simply be false.
|
|
3187
|
+
apiKeySource = undefined;
|
|
3188
|
+
}
|
|
2968
3189
|
}
|
|
2969
3190
|
else {
|
|
2970
3191
|
const keyResult = await resolveApiKey(options, deps);
|
|
@@ -2978,6 +3199,22 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
2978
3199
|
// resolution is what lets the rest of the function reuse the existing
|
|
2979
3200
|
// bootstrap-invite protocol verbatim.
|
|
2980
3201
|
if (classifyEnteredCredential(keyResult.value) === "invite") {
|
|
3202
|
+
// BAPI-687 (B-2): the switch survives, the SILENCE does not. The user is told
|
|
3203
|
+
// which credential type was detected and what it will do, and on a TTY is
|
|
3204
|
+
// asked to confirm before the branch changes. A non-TTY run keeps today's
|
|
3205
|
+
// automatic switch — a script must not gain a prompt — but still prints the
|
|
3206
|
+
// notice, so the transcript explains itself.
|
|
3207
|
+
log(INSTALL_BRIDGE_INVITE_ON_KEY_PATH_NOTICE);
|
|
3208
|
+
if (deps.isTTY && deps.promptLine) {
|
|
3209
|
+
const switchToInvite = await requestCredentialRouteSwitchConfirmation(deps, INSTALL_BRIDGE_INVITE_ON_KEY_PATH_PROMPT);
|
|
3210
|
+
if (!switchToInvite) {
|
|
3211
|
+
errorLog(INSTALL_BRIDGE_INVITE_ON_KEY_PATH_DECLINED);
|
|
3212
|
+
return 1;
|
|
3213
|
+
}
|
|
3214
|
+
}
|
|
3215
|
+
else {
|
|
3216
|
+
log(INSTALL_BRIDGE_INVITE_ON_KEY_PATH_AUTO_NOTICE);
|
|
3217
|
+
}
|
|
2981
3218
|
inviteToken = keyResult.value.trim();
|
|
2982
3219
|
apiKey = "";
|
|
2983
3220
|
bootstrapInviteMode = true;
|
|
@@ -2997,13 +3234,25 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
2997
3234
|
// ---- Resolve the repository name ----
|
|
2998
3235
|
// Need-key (invite or self-serve): choose-a-name for the project this run is
|
|
2999
3236
|
// about to create, so the prompt says exactly that. Have-key:
|
|
3000
|
-
// `--repo`/`BAPI_REPO_NAME` short-circuit deterministically; otherwise resolve
|
|
3001
|
-
//
|
|
3002
|
-
//
|
|
3003
|
-
//
|
|
3004
|
-
//
|
|
3237
|
+
// `--repo`/`BAPI_REPO_NAME` short-circuit deterministically; otherwise resolve it
|
|
3238
|
+
// server-side from the API key (BAPI-616).
|
|
3239
|
+
//
|
|
3240
|
+
// BAPI-687 (C-2) TIERS the non-`resolved` outcomes instead of collapsing them:
|
|
3241
|
+
//
|
|
3242
|
+
// - `unresolved` (409) → HARD STOP. The key does not identify exactly one
|
|
3243
|
+
// project, and that answer is real rather than a deployment artifact:
|
|
3244
|
+
// `user_access.api_key_lookup` cannot be backfilled offline, but
|
|
3245
|
+
// `verify_repo_access` lazily populates it on any successful authentication,
|
|
3246
|
+
// so the permanently-unresolvable population is only pre-migration keys that
|
|
3247
|
+
// have never authenticated since — small and shrinking. Guessing a name here
|
|
3248
|
+
// buys nothing but a 403 two steps later.
|
|
3249
|
+
// - `not-deployed` (404) / `error` → still recoverable, and must stay that way:
|
|
3250
|
+
// 404 is the BAPI-616 feature-detect for an older or self-hosted server, and
|
|
3251
|
+
// `error` covers a transient network failure or a 401. Collapsing these into
|
|
3252
|
+
// the hard stop would make a blip unrecoverable.
|
|
3005
3253
|
let repoName;
|
|
3006
|
-
|
|
3254
|
+
/** The resolver's OUTCOME (C-4), or undefined when it was never called. */
|
|
3255
|
+
let serverRepoResolutionStatus;
|
|
3007
3256
|
if (bootstrapInviteMode) {
|
|
3008
3257
|
const repoResult = await resolveRepoName(options, deps, "new-project");
|
|
3009
3258
|
if (!repoResult.ok) {
|
|
@@ -3026,20 +3275,24 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
3026
3275
|
repoName = configured;
|
|
3027
3276
|
}
|
|
3028
3277
|
else {
|
|
3029
|
-
attemptedServerResolution = true;
|
|
3030
3278
|
log("Resolving repository…");
|
|
3031
3279
|
const resolution = await deps.resolveRepoViaServer(baseUrl, apiKey);
|
|
3280
|
+
serverRepoResolutionStatus = resolution.status;
|
|
3032
3281
|
if (resolution.status === "resolved") {
|
|
3033
3282
|
repoName = resolution.repoName;
|
|
3034
3283
|
}
|
|
3284
|
+
else if (resolution.status === "unresolved") {
|
|
3285
|
+
// C-2: a 409 means the key does not identify exactly one project. Stop here,
|
|
3286
|
+
// before any prompt, ping, scaffold, persist, or spawn — and name BOTH
|
|
3287
|
+
// recoveries rather than inferring anything.
|
|
3288
|
+
return fatal(INSTALL_BRIDGE_UNRESOLVED_KEY_ERROR, ` Or rotate the key at ${setupUrl} (Security page) if it should identify exactly one project.`);
|
|
3289
|
+
}
|
|
3035
3290
|
else {
|
|
3036
|
-
// Feature-detect + degrade: 404 (old server)
|
|
3037
|
-
//
|
|
3038
|
-
//
|
|
3039
|
-
//
|
|
3040
|
-
|
|
3041
|
-
log("Couldn't auto-resolve your repo from the key; falling back to a guessed name — confirm " +
|
|
3042
|
-
`it matches ${setupUrl} (setup UI).`);
|
|
3291
|
+
// Feature-detect + degrade: 404 (old server) and network/other errors keep
|
|
3292
|
+
// the local fallback prompt — WITHOUT a cause-specific message or leaked
|
|
3293
|
+
// detail. C-3/C-5: it no longer offers a name, so it no longer says
|
|
3294
|
+
// "guessed" or asks the user to confirm a value against the setup UI.
|
|
3295
|
+
log(INSTALL_BRIDGE_RESOLUTION_UNAVAILABLE_NOTICE);
|
|
3043
3296
|
const repoResult = await resolveRepoName(options, deps, "existing-registration");
|
|
3044
3297
|
if (!repoResult.ok) {
|
|
3045
3298
|
errorLog(`Error: ${repoResult.error}`);
|
|
@@ -3105,7 +3358,9 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
3105
3358
|
? { bootstrapInvite: true, exchangeUrl: buildBootstrapExchangeUrl(baseUrl) }
|
|
3106
3359
|
: {}),
|
|
3107
3360
|
...(selfServeSignupMode ? { selfServeSignup: true } : {}),
|
|
3108
|
-
...(
|
|
3361
|
+
...(serverRepoResolutionStatus !== undefined
|
|
3362
|
+
? { serverRepoResolutionStatus }
|
|
3363
|
+
: {}),
|
|
3109
3364
|
};
|
|
3110
3365
|
// ---- --dry-run: preview every step, strictly no side effects ----
|
|
3111
3366
|
// Positioned BEFORE the CSPRNG, the pending write, the exchange, the scaffold,
|