@bridge_gpt/mcp-server 0.2.21 → 0.2.24

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.
Files changed (41) hide show
  1. package/README.md +144 -18
  2. package/build/base-ref.js +151 -0
  3. package/build/commands.generated.js +6 -4
  4. package/build/conductor/bridge-api-client.js +44 -3
  5. package/build/conductor/doctor.js +33 -22
  6. package/build/conductor/epic-runtime.js +101 -5
  7. package/build/conductor/pr-ci-producer.js +21 -2
  8. package/build/conductor/pr-discovery.js +12 -2
  9. package/build/conductor-bin.js +50 -20
  10. package/build/credential-store.js +564 -64
  11. package/build/decision-page-template.js +9 -4
  12. package/build/docs.generated.js +5 -0
  13. package/build/executor/base-branch.js +50 -0
  14. package/build/executor/env.js +12 -1
  15. package/build/executor/job-errors.js +1 -0
  16. package/build/executor/job-runner.js +38 -7
  17. package/build/executor/test-clock.js +6 -1
  18. package/build/executor/worker-finalization.js +88 -1
  19. package/build/executor/worktree.js +21 -1
  20. package/build/index.js +2741 -702
  21. package/build/init.js +29 -0
  22. package/build/install-bridge.js +1076 -114
  23. package/build/pipelines.generated.js +2 -2
  24. package/build/pr-base-contract.js +36 -0
  25. package/build/readme.generated.js +1 -1
  26. package/build/setup-epic.js +483 -0
  27. package/build/sfcc/log-gate.js +85 -0
  28. package/build/sfcc/log-query.js +170 -0
  29. package/build/sfcc/register.js +10 -0
  30. package/build/sfcc/setup-status.js +33 -3
  31. package/build/start-tickets.js +164 -75
  32. package/build/version.generated.js +1 -1
  33. package/build/worktree-core.js +62 -10
  34. package/{CONDUCTOR.md → docs/CONDUCTOR.md} +88 -29
  35. package/docs/install/github-app.md +189 -0
  36. package/docs/install/mcp-tool-integrations.md +305 -0
  37. package/docs/install/sfcc-integration.md +140 -0
  38. package/package.json +5 -5
  39. package/public/js/main.min.js +55 -10
  40. package/public/js/main.min.js.map +1 -1
  41. package/smoke-test/SMOKE-TEST.md +3 -2
@@ -6,6 +6,12 @@
6
6
  * Collapses the previously five-step manual setup into a single CLI subcommand.
7
7
  * It does the DETERMINISTIC work in the shell:
8
8
  *
9
+ * Step 0 Resolve the repository name. `--repo` / `BAPI_REPO_NAME` short-circuit
10
+ * deterministically; otherwise, on the consume-an-existing-key flow, a
11
+ * compatible server resolves the unique repo from the API key via a
12
+ * read-only `GET <base>/setup/resolve-repo` (BAPI-616) BEFORE the
13
+ * connectivity check. An old server / unresolvable key / failure falls
14
+ * back to the existing local prompt/inference — never a hard failure.
9
15
  * Step 1 Scaffold via `runInit` (commands, agents, pipelines, secret-free
10
16
  * per-host MCP config placeholders, .bridge/config).
11
17
  * Step 2 Verify connectivity over HTTP (`GET <base>/jira/ping?repo_name=…`
@@ -22,19 +28,42 @@
22
28
  * (`~/.config/bridge/credentials.json`, target `bapi:<repo>`) via
23
29
  * `upsertBapiCredential`. Non-blocking (fail-open) — mirrors Stage 6.
24
30
  *
25
- * then SPAWNS a fresh agent session (Step 5) for the agentic remainder
26
- * (`/install-bridge` config-field derivation, then `/learn-repository`). The
31
+ * then SPAWNS a fresh agent session (Step 5) for the CONFIGURE-ONLY agentic
32
+ * remainder: `/install-bridge` config-field derivation the read-after-write
33
+ * capability report → a single optional indexing-consent question. It does NOT
34
+ * chain `/learn-repository` and does NOT index without explicit consent. The
27
35
  * fresh session is required because a CLI cannot force the running editor to
28
36
  * reload the just-written `.mcp.json`, and field derivation needs an agent
29
37
  * runtime the shell does not have.
30
38
  *
31
- * SECRET DISCIPLINE: the API key is NEVER printed or logged — not in stdout,
32
- * stderr, error messages, or --dry-run output (it is redacted to `<REDACTED>`).
33
- * The ONLY place the key is durably written is the per-host MCP config (Step 3,
34
- * gitignored) and the user-scoped credential store (Step 4).
39
+ * BOOTSTRAP-INVITE MODE (BAPI-606) the one exception to "this command consumes
40
+ * a key, it does not create one". With `--invite` (or `BAPI_INVITE`) there is no
41
+ * API key yet, so the pre-flight ping of Step 2 CANNOT be made: the exchange is
42
+ * what mints the key, and it REPLACES that ping. The order becomes:
43
+ *
44
+ * resolve bootstrap-invite token (no-echo prompt by DEFAULT — the delivered
45
+ * one-liner is secret-free) → resolve repo name → scaffold → generate
46
+ * `key_secret` (CSPRNG) and **fsync it to a pending credential record** →
47
+ * `POST <base>/setup/bootstrap` {token, repo_name, key_secret} → ping with the
48
+ * newly-minted key → write host configs → promote pending → `bapi:<repo>` →
49
+ * spawn. Everything from the ping onward is the existing flow, unchanged.
50
+ *
51
+ * The persist-before-exchange ordering is LOAD-BEARING PROTOCOL (see the comment
52
+ * at the pending-preparation call site): the local `key_secret` is the ONLY proof
53
+ * that can replay a redemption, so a successful exchange with a failed local write
54
+ * would leave an unrecoverable admin key and a permanently spent invite.
55
+ *
56
+ * SECRET DISCIPLINE: THREE secrets now — the API key, the bootstrap-invite token,
57
+ * and the client-generated `key_secret`. NONE is ever printed or logged — not in
58
+ * stdout, stderr, error messages, or --dry-run output (they are redacted to
59
+ * `<REDACTED>`). The ONLY places a secret is durably written are the per-host MCP
60
+ * config (Step 3, gitignored) and the user-scoped credential store (Step 4 / the
61
+ * pending record). The bootstrap-invite token itself is NEVER written to disk —
62
+ * only a SHA-256 fingerprint of it, to key the pending record.
35
63
  */
36
- import { readFile, writeFile, mkdir, stat, rename, chmod, unlink } from "fs/promises";
64
+ import { readFile, writeFile, mkdir, stat, rename, chmod, unlink, open } from "fs/promises";
37
65
  import { spawn } from "child_process";
66
+ import { randomBytes as cryptoRandomBytes, createHash } from "crypto";
38
67
  import os from "os";
39
68
  import path from "path";
40
69
  import readline from "readline";
@@ -42,7 +71,7 @@ import { runInit, buildBridgeApiEntry } from "./init.js";
42
71
  import { VERSION } from "./version.generated.js";
43
72
  import { validateRepoName } from "./bridge-config.js";
44
73
  import { resolveStartTicketsRepoName } from "./start-tickets-repo.js";
45
- import { upsertBapiCredential, getPrimaryCredentialStorePath, } from "./credential-store.js";
74
+ import { upsertBapiCredential, getPrimaryCredentialStorePath, prepareBootstrapPendingCredential, repointBootstrapPendingCredential, promoteBootstrapPendingCredential, } from "./credential-store.js";
46
75
  import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, formatValidAgentNames, } from "./agent-registry.js";
47
76
  import { buildGenericAgentShellCommand, getDefaultSpawnTerminalTabForPlatform, detectTerminal, createDefaultStartTicketsDeps, } from "./start-tickets.js";
48
77
  /** Redaction sentinel — the API-key value is NEVER printed; this stands in. */
@@ -64,15 +93,44 @@ export function buildPrewarmArgs() {
64
93
  export function buildPrewarmCommandPreview() {
65
94
  return `npx ${buildPrewarmArgs().join(" ")}`;
66
95
  }
67
- /** The natural-language sequential prompt handed to the spawned agent session. */
68
- export const INSTALL_BRIDGE_AGENT_PROMPT = "Please execute the /install-bridge command. Once it completes successfully, execute the " +
69
- "/learn-repository command. After /learn-repository completes, ask the user for consent to start " +
70
- "repository indexing; with consent, call the parse_repository MCP tool once and report that the " +
71
- "job was queued (progress is checked with get_parse_status — do not poll it to completion). " +
72
- "When you are done, end with an explicit summary line stating how " +
73
- "many config fields the apply_install_manifest call applied (e.g. 'Applied 8 of 9 derived " +
74
- "fields') and whether indexing was queued — if 0 fields were applied, say so loudly and explain " +
75
- "what is still pending.";
96
+ /**
97
+ * The natural-language prompt handed to the spawned agent session. It is
98
+ * CONFIGURE-ONLY: it derives and applies configuration, presents the capability
99
+ * report, and ends with exactly one indexing-consent question. It never chains
100
+ * /learn-repository and never indexes before explicit consent.
101
+ *
102
+ * The index-consent beat is AGENT-OWNED (asked inside this spawned session), not
103
+ * parent-CLI-owned, because `spawnTerminalTab()` opens an asynchronous terminal
104
+ * session: the parent CLI returns immediately and cannot reliably ask a question
105
+ * that must appear AFTER the spawned session's capability report. Asking it via
106
+ * the parent's `deps.promptLine` would display the question before the report
107
+ * even exists. `deps.promptLine` therefore stays the owner of the parent-CLI
108
+ * prompts (repository / overwrite / invite confirmation) and is intentionally
109
+ * NOT used for this post-report question.
110
+ */
111
+ export const INSTALL_BRIDGE_AGENT_PROMPT = "Execute the /install-bridge command in the install-spawn context (tell the command it is running " +
112
+ "in the install-spawn context so it SKIPS its Stage 8 and Stage 9 offers — this session's only " +
113
+ "closing interaction is the single indexing question below). Do NOT run /learn-repository. Do NOT " +
114
+ "call parse_repository (or otherwise start indexing) before the capability report and explicit " +
115
+ "consent below. " +
116
+ "Complete the command's read-after-write five-section capability report first: 'Connected ✓', " +
117
+ "'Not yet connected ✗', 'Tools you can use now', 'Tools you'll unlock', and 'Recommended next " +
118
+ "step + why'. " +
119
+ "Only AFTER that report is fully presented, ask exactly one question using this visible prompt: " +
120
+ "'[Y/n] Index repository now?'. Only an explicit affirmative answer (e.g. 'y'/'yes') starts " +
121
+ "indexing; a blank answer, a negative answer, EOF, an unavailable interaction, and any " +
122
+ "non-interactive/headless run all resolve to NO. " +
123
+ "On an affirmative answer: call the parse_repository MCP tool exactly once, describe the accepted " +
124
+ "job as QUEUED, and direct later progress checks to get_parse_status or /check-parse-status " +
125
+ "(do NOT poll it to completion). If parse_repository returns a blocking refusal or error, do NOT " +
126
+ "claim the job was queued — report the sanitized result and leave indexing pending. " +
127
+ "On NO (or any unavailable/non-interactive resolution): do not index; print the exact copy-paste " +
128
+ "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 the setup UI via " +
130
+ "the command's configure_in pointer. " +
131
+ "End with an explicit summary line stating how many config fields the apply_install_manifest call " +
132
+ "applied (e.g. 'Applied 8 of 9 derived fields') and whether indexing was queued or left pending — " +
133
+ "if 0 fields were applied, say so loudly and explain what is still pending.";
76
134
  /** Default base URL when `BAPI_BASE_URL` is unset (mirrors index.ts). */
77
135
  export const DEFAULT_BAPI_BASE_URL = "https://bridgegpt-api.com";
78
136
  /** Default docs dir when `BAPI_DOCS_DIR` is unset (mirrors index.ts). */
@@ -86,39 +144,85 @@ export function getInstallBridgeUsage() {
86
144
  "One-command Bridge API project bootstrap. Scaffolds the project, writes the",
87
145
  "per-host MCP config with your credentials, verifies connectivity, persists the",
88
146
  "routing credential, then opens a fresh agent session to derive the remaining",
89
- "config and run /learn-repository.",
147
+ "config, present a capability report, and offer optional repository indexing.",
90
148
  "",
91
149
  "Inputs (the only two irreducible ones):",
92
150
  " --api-key <key> Bridge API key. Falls back to the BAPI_API_KEY env var,",
93
151
  " then an interactive (no-echo) prompt. Generate one in the",
94
152
  " Bridge API web UI Security page — this command consumes a",
95
- " key, it does not create one. NEVER printed or logged.",
96
- " --repo <name> Repository name. Falls back to BAPI_REPO_NAME, then an",
97
- " inferred default you confirm interactively. MUST match the",
98
- " server-side repo registration (it keys the credential",
99
- " store as bapi:<repo>). Required (no inference) when stdin",
100
- " is non-interactive.",
153
+ " key, it does not create one (--invite is the one exception:",
154
+ " it CREATES the project and its first admin key). NEVER",
155
+ " printed or logged.",
156
+ " --repo <name> Repository name. --repo and BAPI_REPO_NAME still take",
157
+ " priority and short-circuit before any network call. When",
158
+ " neither is set, a compatible server resolves the unique",
159
+ " repository from your existing API key automatically; if the",
160
+ " server is older, the key is unresolvable, or resolution",
161
+ " fails, it falls back to an inferred default you confirm",
162
+ " interactively (and to a required --repo when stdin is",
163
+ " non-interactive). MUST match the server-side repo",
164
+ " registration (it keys the credential store as bapi:<repo>).",
165
+ " With --invite it is the name your NEW project is created",
166
+ " under (globally unique).",
167
+ "",
168
+ "Self-serve onboarding (no account, no API key, no pre-issued invite):",
169
+ " --email <addr> Create a brand-new Bridge workspace from just an email —",
170
+ " the primary path for a first-time user with nothing yet.",
171
+ " It requests a fresh workspace for that email, then creates",
172
+ " the project and mints your own admin API key in one command.",
173
+ " Falls back to the BAPI_SIGNUP_EMAIL env var, then a visible",
174
+ " interactive prompt. The email is NOT a secret (it is shown",
175
+ " as you type), but it is never printed to a log. Mutually",
176
+ " exclusive with --api-key and --invite. No email verification",
177
+ " is performed and no message is sent to the address — it only",
178
+ " labels the new workspace.",
179
+ "",
180
+ "Bootstrap-invite onboarding (no web UI, no pre-existing key):",
181
+ " --invite [token] Redeem a bootstrap invite you were already given: creates",
182
+ " the project and mints your own admin API key in one command.",
183
+ " Mutually exclusive with --api-key and --email (in this mode",
184
+ " the key is created, not consumed).",
185
+ "",
186
+ " Run it WITHOUT a value — `install-bridge --invite` — and the",
187
+ " token is read from an interactive prompt with echo",
188
+ " suppressed, then sent only in the request body. This is the",
189
+ " default and the recommended path: 'a copy/paste one-liner'",
190
+ " and 'the token never touches shell history' are",
191
+ " contradictory, so the one-liner your operator sends you is",
192
+ " SECRET-FREE and the CLI asks for the token.",
193
+ "",
194
+ " Passing the token inline (--invite <token>, --invite=<token>)",
195
+ " or via BAPI_INVITE is for SCRIPTING ONLY: both forms EXPOSE",
196
+ " THE TOKEN to your shell history and to the process list.",
101
197
  "",
102
198
  "Flags:",
103
199
  " --force Overwrite an existing real BAPI_API_KEY in a",
104
- " host config without prompting.",
200
+ " host config (or in the credential store) without",
201
+ " prompting.",
105
202
  " --dry-run Preview every step (scaffold targets, config",
106
203
  " files + keys with the key REDACTED, ping",
107
204
  " target, credential target, spawn command)",
108
205
  " without writing, pinging, or spawning anything.",
206
+ " With --invite it also never calls the exchange",
207
+ " endpoint and never generates or stores a secret.",
109
208
  " --agent claude|cursor-agent Agent to launch for the agentic remainder",
110
209
  " (default: claude).",
111
210
  " -h, --help Show this help.",
112
211
  "",
113
212
  "Environment: BAPI_BASE_URL (default https://bridgegpt-api.com) and BAPI_DOCS_DIR",
114
213
  "(default docs/tmp) are read from the environment with the shown fallbacks.",
214
+ "BAPI_SIGNUP_EMAIL supplies the self-serve signup email non-interactively (it is",
215
+ "visible input, not a secret). BAPI_INVITE supplies the bootstrap-invite token",
216
+ "non-interactively (scripting only — it is exposed to shell history; prefer the",
217
+ "prompt).",
115
218
  ].join("\n");
116
219
  }
117
220
  /**
118
221
  * Parse argv strictly. Supports `--api-key`, `--repo`, `--agent` (each with a
119
- * `--flag value` or `--flag=value` form), the boolean `--force` / `--dry-run`,
120
- * and `-h`/`--help`. Unknown flags and positional args are rejected. The API key
121
- * value is captured but never echoed (no error message includes it).
222
+ * `--flag value` or `--flag=value` form), the OPTIONAL-value `--invite`, the
223
+ * boolean `--force` / `--dry-run`, and `-h`/`--help`. Unknown flags and positional
224
+ * args are rejected. The API-key and bootstrap-invite values are captured but
225
+ * never echoed (no error message includes either).
122
226
  */
123
227
  export function parseInstallBridgeArgs(argv) {
124
228
  if (argv.includes("-h") || argv.includes("--help")) {
@@ -129,6 +233,13 @@ export function parseInstallBridgeArgs(argv) {
129
233
  let force = false;
130
234
  let dryRun = false;
131
235
  let agentName = DEFAULT_AGENT_NAME;
236
+ let invite;
237
+ let email;
238
+ // Track SUPPLIED-ness separately from the values: `--invite` is legitimately
239
+ // valueless (prompt path) and `--api-key ""` is still a contradiction with it.
240
+ let inviteSupplied = false;
241
+ let apiKeySupplied = false;
242
+ let emailSupplied = false;
132
243
  /** Read a `--flag value` or `--flag=value` value, advancing the index. */
133
244
  const readValue = (arg, flag, i) => {
134
245
  if (arg.startsWith(`${flag}=`)) {
@@ -154,6 +265,39 @@ export function parseInstallBridgeArgs(argv) {
154
265
  if ("error" in r)
155
266
  return { status: "error", message: r.error };
156
267
  apiKey = r.value;
268
+ apiKeySupplied = true;
269
+ i = r.nextIndex;
270
+ continue;
271
+ }
272
+ if (arg === "--invite" || arg.startsWith("--invite=")) {
273
+ inviteSupplied = true;
274
+ if (arg.startsWith("--invite=")) {
275
+ invite = arg.slice("--invite=".length);
276
+ }
277
+ else {
278
+ // OPTIONAL value: a bare `--invite` selects bootstrap-invite mode and the
279
+ // token is prompted for (no echo). Only a following non-flag token is
280
+ // consumed as the value — a bootstrap-invite token never starts with '-'.
281
+ const next = argv[i + 1];
282
+ if (typeof next === "string" && !next.startsWith("-")) {
283
+ invite = next;
284
+ i += 1;
285
+ }
286
+ }
287
+ continue;
288
+ }
289
+ if (arg === "--email" || arg.startsWith("--email=")) {
290
+ // REQUIRED-value flag (unlike --invite): the email is what selects and drives
291
+ // the self-serve mint, so a bare or blank `--email` is a hard error rather
292
+ // than a prompt trigger. The value is NOT echoed in any error message.
293
+ const r = readValue(arg, "--email", i);
294
+ if ("error" in r)
295
+ return { status: "error", message: r.error };
296
+ if (r.value.trim().length === 0) {
297
+ return { status: "error", message: "--email requires a non-empty value." };
298
+ }
299
+ email = r.value.trim();
300
+ emailSupplied = true;
157
301
  i = r.nextIndex;
158
302
  continue;
159
303
  }
@@ -187,38 +331,109 @@ export function parseInstallBridgeArgs(argv) {
187
331
  message: `Unexpected positional argument: '${arg}'. install-bridge does not accept positional arguments.`,
188
332
  };
189
333
  }
190
- return { status: "ok", options: { apiKey, repo, force, dryRun, agentName } };
334
+ // Mutually exclusive by construction: bootstrap-invite mode CREATES the key, so
335
+ // consuming one is a contradiction. Names the flags only — never their values.
336
+ if (inviteSupplied && apiKeySupplied) {
337
+ return {
338
+ status: "error",
339
+ message: "--invite and --api-key are mutually exclusive: a bootstrap invite creates your API key, " +
340
+ "it does not consume an existing one.",
341
+ };
342
+ }
343
+ // --email selects self-serve mode, which CREATES the key — so it conflicts with
344
+ // both consuming an existing one (--api-key) and redeeming a pre-issued invite
345
+ // (--invite). Names the flags only — never the email or token values (BAPI-618).
346
+ if (emailSupplied && apiKeySupplied) {
347
+ return {
348
+ status: "error",
349
+ message: "--email and --api-key are mutually exclusive: self-serve signup creates your API key, " +
350
+ "it does not consume an existing one.",
351
+ };
352
+ }
353
+ if (emailSupplied && inviteSupplied) {
354
+ return {
355
+ status: "error",
356
+ message: "--email and --invite are mutually exclusive: use --email for self-serve signup (no " +
357
+ "pre-issued invite), or --invite to redeem an invite you already have.",
358
+ };
359
+ }
360
+ return {
361
+ status: "ok",
362
+ options: { apiKey, repo, force, dryRun, agentName, invite, inviteMode: inviteSupplied, email },
363
+ };
191
364
  }
192
- /** No-echo secret prompt on stderr (so it never lands in piped stdout). */
193
- function promptSecretViaReadline(promptText) {
365
+ /**
366
+ * No-echo secret prompt on stderr (so it never lands in piped stdout).
367
+ *
368
+ * `input`/`output` are injectable for tests ONLY — production always uses the real
369
+ * stdin/stderr. They exist because this function is the one piece of the install
370
+ * flow that talks to a live terminal, and stubbing it out at the deps seam left the
371
+ * terminal behavior itself (prompt visibility, echo suppression) fully unverified.
372
+ */
373
+ export function promptSecretViaReadline(promptText, input = process.stdin, output = process.stderr) {
194
374
  return new Promise((resolve) => {
195
375
  const rl = readline.createInterface({
196
- input: process.stdin,
197
- output: process.stderr,
376
+ input,
377
+ output,
198
378
  terminal: true,
199
379
  });
200
- // Suppress echo of typed characters: override the internal writer so only the
201
- // prompt (written explicitly below) is shown, never the keystrokes.
380
+ // The prompt MUST be owned by readline (passed to `question` below), never
381
+ // written to stderr by hand. On every redraw readline emits cursorTo(0) +
382
+ // clearScreenDown straight to `output`, bypassing the `_writeToOutput` hook
383
+ // — so a hand-written prompt is erased on the first redraw, leaving a blank
384
+ // line with muted echo that is indistinguishable from a hang.
385
+ //
386
+ // Echo suppression therefore works by reprinting the prompt and swallowing
387
+ // everything else: readline redraws the line as `prompt + typed input`, so a
388
+ // write containing the prompt is a redraw (reprint the prompt alone, drop the
389
+ // secret) and any other write is a keystroke echo (drop it).
202
390
  const mutable = rl;
203
391
  let muted = false;
204
392
  mutable._writeToOutput = (s) => {
205
- if (!muted)
206
- process.stderr.write(s);
393
+ if (!muted) {
394
+ output.write(s);
395
+ }
396
+ else if (s.includes(promptText)) {
397
+ output.write(promptText);
398
+ }
207
399
  };
208
- process.stderr.write(promptText);
209
- muted = true;
210
- rl.question("", (answer) => {
400
+ // `close` fires without an answer when stdin hits EOF (piped/closed input).
401
+ // Resolving empty lets the caller emit its real "no key entered" error; left
402
+ // unresolved this promise deadlocks the top-level await and Node exits 13
403
+ // with only an "unsettled top-level await" warning.
404
+ //
405
+ // `answered` is load-bearing: `rl.close()` emits `close` SYNCHRONOUSLY, so on
406
+ // the happy path this handler runs before `resolve(answer)` below and would
407
+ // otherwise settle the promise empty — silently discarding the typed key.
408
+ let answered = false;
409
+ rl.on("close", () => {
410
+ if (!answered)
411
+ resolve("");
412
+ });
413
+ rl.question(promptText, (answer) => {
414
+ answered = true;
211
415
  rl.close();
212
- process.stderr.write("\n");
416
+ output.write("\n");
213
417
  resolve(answer.trim());
214
418
  });
419
+ // Only mute AFTER `question` has drawn the prompt at full visibility.
420
+ muted = true;
215
421
  });
216
422
  }
217
423
  /** Echoed single-line prompt on stderr (used for repo confirmation / value). */
218
424
  function promptLineViaReadline(promptText) {
219
425
  return new Promise((resolve) => {
220
426
  const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
427
+ // See promptSecretViaReadline: EOF must resolve rather than deadlock the
428
+ // top-level await, and `answered` guards the synchronous `close` from
429
+ // discarding a real answer.
430
+ let answered = false;
431
+ rl.on("close", () => {
432
+ if (!answered)
433
+ resolve("");
434
+ });
221
435
  rl.question(promptText, (answer) => {
436
+ answered = true;
222
437
  rl.close();
223
438
  resolve(answer.trim());
224
439
  });
@@ -233,10 +448,25 @@ function promptLineViaReadline(promptText) {
233
448
  * Always resolves (never rejects): a spawn error / non-zero exit / timeout becomes
234
449
  * `{ ok: false, warning }` with secret-free warning text.
235
450
  */
451
+ /**
452
+ * The env clone handed to the pre-warm child, with every credential/PII variable
453
+ * removed: `BAPI_API_KEY` and the bootstrap-invite token (`BAPI_INVITE`) are
454
+ * secrets that must never reach an npm lifecycle script, and the self-serve signup
455
+ * email (`BAPI_SIGNUP_EMAIL`, BAPI-618) is invitee PII with no business in a
456
+ * `--version` probe. Extracted as a pure, exported helper so the strip is directly
457
+ * unit-testable (the default spawner below uses the real `spawn`, which is not
458
+ * injectable).
459
+ */
460
+ export function sanitizePrewarmEnv(env) {
461
+ const sanitized = { ...env };
462
+ delete sanitized.BAPI_API_KEY;
463
+ delete sanitized.BAPI_INVITE;
464
+ delete sanitized.BAPI_SIGNUP_EMAIL;
465
+ return sanitized;
466
+ }
236
467
  function spawnPrewarmDefault(command, args, env) {
237
468
  return new Promise((resolve) => {
238
- const sanitizedEnv = { ...env };
239
- delete sanitizedEnv.BAPI_API_KEY;
469
+ const sanitizedEnv = sanitizePrewarmEnv(env);
240
470
  try {
241
471
  const child = spawn(command, args, {
242
472
  shell: false,
@@ -265,6 +495,11 @@ function spawnPrewarmDefault(command, args, env) {
265
495
  /** Build default deps from the live process. */
266
496
  export function createDefaultInstallBridgeDeps() {
267
497
  const isTTY = Boolean(process.stdin.isTTY);
498
+ // One production fetch, reused for both the `fetch` seam and the default
499
+ // resolver, so a direct caller of this factory gets a resolver bound to the
500
+ // same client. (runInstallBridgeCli re-binds the resolver to the FINAL merged
501
+ // fetch when the caller did not override the resolver — see below.)
502
+ const productionFetch = (...args) => fetch(...args);
268
503
  return {
269
504
  env: process.env,
270
505
  cwd: process.cwd(),
@@ -278,12 +513,25 @@ export function createDefaultInstallBridgeDeps() {
278
513
  rename: (a, b) => rename(a, b),
279
514
  chmod: (p, m) => chmod(p, m),
280
515
  unlink: (p) => unlink(p),
516
+ open: async (p, flags, mode) => {
517
+ const handle = await open(p, flags, mode);
518
+ return {
519
+ writeFile: (data) => handle.writeFile(data, { encoding: "utf-8" }),
520
+ sync: () => handle.sync(),
521
+ close: () => handle.close(),
522
+ };
523
+ },
524
+ randomBytes: (size) => cryptoRandomBytes(size),
281
525
  promptSecret: isTTY ? promptSecretViaReadline : undefined,
282
526
  promptLine: isTTY ? promptLineViaReadline : undefined,
283
- fetch: (...args) => fetch(...args),
527
+ fetch: productionFetch,
528
+ resolveRepoViaServer: (baseUrl, apiKey) => resolveRepoViaServer(productionFetch, baseUrl, apiKey),
284
529
  spawnPrewarm: spawnPrewarmDefault,
285
530
  runInit,
286
531
  upsertCredential: upsertBapiCredential,
532
+ prepareBootstrapPending: prepareBootstrapPendingCredential,
533
+ repointBootstrapPending: repointBootstrapPendingCredential,
534
+ promoteBootstrapPending: promoteBootstrapPendingCredential,
287
535
  buildShellCommand: buildGenericAgentShellCommand,
288
536
  spawnTerminalTab: getDefaultSpawnTerminalTabForPlatform(process.platform),
289
537
  startTicketsDeps: createDefaultStartTicketsDeps(),
@@ -317,20 +565,128 @@ export async function resolveApiKey(options, deps) {
317
565
  };
318
566
  }
319
567
  /**
320
- * Resolve the repo name: `--repo` → `BAPI_REPO_NAME` env → inferred default
321
- * (from .bridge/config, else the cwd basename) confirmed interactively. Fails
322
- * fast (no inference) when neither is supplied and stdin is non-interactive
323
- * the repo identity keys the credential store and must match server-side
324
- * registration, so it is never silently inferred non-interactively.
568
+ * Resolve the BOOTSTRAP-INVITE token: `--invite <token>` → `BAPI_INVITE` env →
569
+ * interactive no-echo prompt. Fails (secret-free) when none is available and
570
+ * stdin is non-interactive.
571
+ *
572
+ * The order mirrors {@link resolveApiKey}, but the EMPHASIS is inverted, and that
573
+ * inversion is the point: "a copy/paste one-liner" and "the token never touches
574
+ * shell history" are contradictory — both `--invite <token>` and
575
+ * `BAPI_INVITE=… npx …` land in shell history and in `ps`. So the one-liner an
576
+ * operator hands an invitee is SECRET-FREE (`install-bridge --invite`), and the
577
+ * no-echo prompt below is the DEFAULT path rather than a last-resort fallback. The
578
+ * token then travels only in the exchange request body. The inline/env forms remain
579
+ * for scripting and are documented as history-exposing.
580
+ *
581
+ * The token value is never echoed: not in the prompt, not in any error here.
325
582
  */
326
- export async function resolveRepoName(options, deps) {
327
- if (typeof options.repo === "string" && options.repo.trim().length > 0) {
328
- return { ok: true, value: options.repo.trim() };
583
+ export async function resolveInviteToken(options, deps) {
584
+ if (typeof options.invite === "string" && options.invite.trim().length > 0) {
585
+ return { ok: true, value: options.invite.trim() };
329
586
  }
330
- const fromEnv = deps.env.BAPI_REPO_NAME;
587
+ const fromEnv = deps.env.BAPI_INVITE;
331
588
  if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
332
589
  return { ok: true, value: fromEnv.trim() };
333
590
  }
591
+ if (deps.isTTY && deps.promptSecret) {
592
+ const entered = (await deps.promptSecret("Bootstrap invite token (input hidden): ")).trim();
593
+ if (entered.length > 0) {
594
+ return { ok: true, value: entered };
595
+ }
596
+ return { ok: false, error: "No bootstrap invite token entered." };
597
+ }
598
+ return {
599
+ ok: false,
600
+ error: "A bootstrap invite token is required. Pass --invite <token> or set the BAPI_INVITE " +
601
+ "environment variable (no interactive terminal is available to prompt for it). Note that " +
602
+ "both forms expose the token to your shell history and process list — prefer running " +
603
+ "'install-bridge --invite' interactively.",
604
+ };
605
+ }
606
+ /**
607
+ * Resolve the SELF-SERVE signup email: `--email <addr>` → `BAPI_SIGNUP_EMAIL` env →
608
+ * interactive VISIBLE prompt (BAPI-618). Fails (secret-free) when none is available
609
+ * and stdin is non-interactive.
610
+ *
611
+ * The precedence mirrors {@link resolveInviteToken}, but the input is NOT a secret:
612
+ * an email is not a credential, so it uses the ECHOED {@link InstallBridgeDeps.promptLine}
613
+ * — never `promptSecret`. (It IS invitee PII, so like every value in this module it
614
+ * is never written to a log line; it is only ever echoed as the user's own keystrokes
615
+ * and placed in the mint request body.)
616
+ *
617
+ * No email-format validation is performed here — only surrounding whitespace is
618
+ * trimmed, matching the other CLI input seams and the server's deliberately
619
+ * permissive "stored unvalidated" contract (D-2). The entered value is never echoed
620
+ * back in an error message.
621
+ */
622
+ export async function resolveSignupEmail(options, deps) {
623
+ if (typeof options.email === "string" && options.email.trim().length > 0) {
624
+ return { ok: true, value: options.email.trim() };
625
+ }
626
+ const fromEnv = deps.env.BAPI_SIGNUP_EMAIL;
627
+ if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
628
+ return { ok: true, value: fromEnv.trim() };
629
+ }
630
+ if (deps.isTTY && deps.promptLine) {
631
+ const entered = (await deps.promptLine("Email for Bridge workspace setup: ")).trim();
632
+ if (entered.length > 0) {
633
+ return { ok: true, value: entered };
634
+ }
635
+ return { ok: false, error: "No email entered." };
636
+ }
637
+ return {
638
+ ok: false,
639
+ error: "An email is required to create a Bridge workspace. Pass --email <addr> or set the " +
640
+ "BAPI_SIGNUP_EMAIL environment variable (no interactive terminal is available to prompt " +
641
+ "for it).",
642
+ };
643
+ }
644
+ /**
645
+ * Select the onboarding branch as pure logic (deterministic, no I/O, no prompt):
646
+ *
647
+ * - `--invite` (with or without a value) or a non-empty `BAPI_INVITE` selects
648
+ * the `bootstrap-invite` need-key branch.
649
+ * - otherwise, `--email` or a non-empty `BAPI_SIGNUP_EMAIL` selects the
650
+ * `self-serve` need-key branch (BAPI-618).
651
+ * - every other invocation selects `have-key`.
652
+ *
653
+ * Precedence is explicit: an invite input wins over an email input (both are
654
+ * need-key, but a pre-issued invite is the more specific intent), and any
655
+ * need-key input wins over the have-key default. The parser already rejects
656
+ * `--email` alongside `--api-key`/`--invite`, so a flag-level conflict never
657
+ * reaches here; the precedence only disambiguates env-var combinations.
658
+ */
659
+ export function resolveInstallBridgeOnboardingBranch(options, env) {
660
+ const inviteMode = options.inviteMode === true || (env.BAPI_INVITE ?? "").trim().length > 0;
661
+ if (inviteMode)
662
+ return { kind: "need-key", method: "bootstrap-invite" };
663
+ const emailMode = (options.email ?? "").trim().length > 0 ||
664
+ (env.BAPI_SIGNUP_EMAIL ?? "").trim().length > 0;
665
+ if (emailMode)
666
+ return { kind: "need-key", method: "self-serve" };
667
+ return { kind: "have-key" };
668
+ }
669
+ /**
670
+ * Return the explicitly configured repository name (`--repo`, then
671
+ * `BAPI_REPO_NAME`), trimmed, or `undefined` when neither is supplied. Pure: no
672
+ * prompting, no inference, no I/O. `--repo` takes priority over the environment.
673
+ * This is the deterministic short-circuit that runs BEFORE any server resolution.
674
+ */
675
+ export function resolveConfiguredRepoName(options, env) {
676
+ if (typeof options.repo === "string" && options.repo.trim().length > 0) {
677
+ return options.repo.trim();
678
+ }
679
+ const fromEnv = env.BAPI_REPO_NAME;
680
+ if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
681
+ return fromEnv.trim();
682
+ }
683
+ return undefined;
684
+ }
685
+ export async function resolveRepoName(options, deps) {
686
+ const configured = resolveConfiguredRepoName(options, deps.env);
687
+ if (configured !== undefined) {
688
+ return { ok: true, value: configured };
689
+ }
334
690
  // Non-interactive: fail fast and require --repo. Inference is an interactive
335
691
  // convenience only (the user must confirm it).
336
692
  if (!deps.isTTY || !deps.promptLine) {
@@ -489,11 +845,13 @@ export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
489
845
  signal: AbortSignal.timeout(10_000),
490
846
  });
491
847
  }
492
- catch (err) {
493
- const msg = err instanceof Error ? err.message : String(err);
848
+ catch {
849
+ // The caught exception message is NOT interpolated: some fetch/undici errors
850
+ // echo request detail (which carries the X-API-Key header) into the message,
851
+ // so a fixed, secret-free string is used instead (BAPI-616).
494
852
  return {
495
853
  ok: false,
496
- message: `Could not reach the Bridge API at ${baseUrl} (${msg}). Check BAPI_BASE_URL and your network.`,
854
+ message: `Could not reach the Bridge API at ${baseUrl}. Check BAPI_BASE_URL and your network.`,
497
855
  };
498
856
  }
499
857
  if (resp.ok)
@@ -518,14 +876,275 @@ export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
518
876
  message: `Connectivity check failed (HTTP ${resp.status}). Verify your repo, API key, and BAPI_BASE_URL.`,
519
877
  };
520
878
  }
879
+ /** The resolve endpoint: `<base>/setup/resolve-repo`. No query string — ever. */
880
+ export function buildResolveRepoUrl(baseUrl) {
881
+ return `${baseUrl.replace(/\/+$/, "")}/setup/resolve-repo`;
882
+ }
521
883
  /**
522
- * Render the --dry-run preview lines. The API key is ALWAYS redacted — the
523
- * spawnCommand and config preview never embed the secret.
884
+ * Resolve the repository name server-side from an existing API key. The key
885
+ * travels ONLY in the `X-API-Key` header (never the URL, never the body), bounded
886
+ * by the same 10s timeout as {@link verifyConnectivity}.
887
+ *
888
+ * Status mapping is the whole compatibility story: `404` → `not-deployed` (old
889
+ * server), `409` → `unresolved` (ambiguous/shared/client-scoped/legacy), every
890
+ * other non-OK (`401`, `403`, `5xx`, unexpected) → `error`, and any
891
+ * network/timeout/malformed-body failure → `error`. A caught exception or a
892
+ * response body is NEVER read into the result.
893
+ */
894
+ export async function resolveRepoViaServer(fetchImpl, baseUrl, apiKey) {
895
+ const url = buildResolveRepoUrl(baseUrl);
896
+ let resp;
897
+ try {
898
+ resp = await fetchImpl(url, {
899
+ headers: { "X-API-Key": apiKey },
900
+ signal: AbortSignal.timeout(10_000),
901
+ });
902
+ }
903
+ catch {
904
+ // Network failure / timeout. The exception may echo the request (and thus the
905
+ // X-API-Key header), so it is never inspected or propagated.
906
+ return { status: "error" };
907
+ }
908
+ if (resp.status === 404)
909
+ return { status: "not-deployed" };
910
+ if (resp.status === 409)
911
+ return { status: "unresolved" };
912
+ if (!resp.ok)
913
+ return { status: "error" };
914
+ let body;
915
+ try {
916
+ body = await resp.json();
917
+ }
918
+ catch {
919
+ return { status: "error" };
920
+ }
921
+ const repoName = body?.repo_name;
922
+ const validated = validateRepoName(repoName);
923
+ if (!validated.ok)
924
+ return { status: "error" };
925
+ return { status: "resolved", repoName: validated.value };
926
+ }
927
+ // ---------------------------------------------------------------------------
928
+ // Bootstrap-invite exchange (BAPI-606) — replaces the pre-flight ping in invite mode
929
+ // ---------------------------------------------------------------------------
930
+ /** Bytes of CSPRNG entropy behind `key_secret` (server requires exactly 32). */
931
+ export const BOOTSTRAP_KEY_SECRET_BYTES = 32;
932
+ /**
933
+ * Generate the client-side `key_secret`: exactly 32 CSPRNG bytes, base64url,
934
+ * which yields exactly the 43 unpadded characters the server validates. The
935
+ * length is EXACT, not a floor — the server rejects 42 and 44, because ">= 43"
936
+ * had no upper bound and an over-72-byte input is a bcrypt-truncation hazard.
937
+ *
938
+ * The returned value is a SECRET. It is never logged, never previewed, and never
939
+ * placed in an error.
940
+ */
941
+ export function generateBootstrapKeySecret(randomBytes) {
942
+ return randomBytes(BOOTSTRAP_KEY_SECRET_BYTES).toString("base64url");
943
+ }
944
+ /**
945
+ * SHA-256 hex digest of the bootstrap-invite token — the key under which the
946
+ * pending record is stored. The TOKEN ITSELF IS NEVER WRITTEN TO DISK; only this
947
+ * one-way fingerprint, so a retry can recognize "same invite, same repo" and reuse
948
+ * the exact pending `key_secret` (the replay proof).
949
+ */
950
+ export function fingerprintBootstrapInvite(token) {
951
+ return createHash("sha256").update(token, "utf-8").digest("hex");
952
+ }
953
+ /** The exchange endpoint: `<base>/setup/bootstrap`. No query string — ever. */
954
+ export function buildBootstrapExchangeUrl(baseUrl) {
955
+ return `${baseUrl.replace(/\/+$/, "")}/setup/bootstrap`;
956
+ }
957
+ /**
958
+ * POST the bootstrap-invite exchange. The token and `key_secret` travel ONLY in
959
+ * the JSON request body (never a query string, never a header, never a log line).
960
+ * Bounded by the same 10s timeout as the connectivity ping.
961
+ */
962
+ export async function exchangeBootstrapInvite(deps, baseUrl, token, repoName, keySecret) {
963
+ const url = buildBootstrapExchangeUrl(baseUrl);
964
+ let resp;
965
+ try {
966
+ resp = await deps.fetch(url, {
967
+ method: "POST",
968
+ headers: { "Content-Type": "application/json" },
969
+ body: JSON.stringify({ token, repo_name: repoName, key_secret: keySecret }),
970
+ signal: AbortSignal.timeout(10_000),
971
+ });
972
+ }
973
+ catch (err) {
974
+ // The message may contain the request (some fetch impls echo it), so it is
975
+ // deliberately NOT interpolated here.
976
+ void err;
977
+ return {
978
+ ok: false,
979
+ kind: "failed",
980
+ message: `Could not reach the Bridge API at ${baseUrl} to redeem the bootstrap invite. Check ` +
981
+ "BAPI_BASE_URL and your network, then re-run — the invite has not been used, and the " +
982
+ "re-run will reuse the same locally-stored secret.",
983
+ };
984
+ }
985
+ if (resp.ok) {
986
+ let repo;
987
+ try {
988
+ const body = (await resp.json());
989
+ repo = body?.repo_name;
990
+ }
991
+ catch {
992
+ return {
993
+ ok: false,
994
+ kind: "failed",
995
+ message: "The Bridge API returned an unreadable response to the bootstrap exchange.",
996
+ };
997
+ }
998
+ // Validate the authoritative name before it reaches a path, a config target, or
999
+ // the credential store.
1000
+ const validated = validateRepoName(repo);
1001
+ if (!validated.ok) {
1002
+ return {
1003
+ ok: false,
1004
+ kind: "failed",
1005
+ message: "The Bridge API returned an unexpected repo name for the bootstrap exchange.",
1006
+ };
1007
+ }
1008
+ return { ok: true, repoName: validated.value };
1009
+ }
1010
+ if (resp.status === 409) {
1011
+ return {
1012
+ ok: false,
1013
+ kind: "repo-name-taken",
1014
+ message: `The repo name '${repoName}' is already taken (HTTP 409). Repo names are globally unique.`,
1015
+ };
1016
+ }
1017
+ if (resp.status === 401) {
1018
+ return { ok: false, kind: "invalid-invite", message: `The Bridge API rejected the bootstrap invite (HTTP ${resp.status}).` };
1019
+ }
1020
+ return {
1021
+ ok: false,
1022
+ kind: "failed",
1023
+ message: `The bootstrap exchange failed (HTTP ${resp.status}). Verify BAPI_BASE_URL and try again.`,
1024
+ };
1025
+ }
1026
+ // ---------------------------------------------------------------------------
1027
+ // Self-serve mint (BAPI-618): email -> invite token
1028
+ // ---------------------------------------------------------------------------
1029
+ /**
1030
+ * The self-serve invite-token prefix, mirroring the server's
1031
+ * ``BOOTSTRAP_INVITE_TOKEN_PREFIX`` (``bapi_inv_``). A well-formed token
1032
+ * self-identifies with this prefix; a success response whose token does not is
1033
+ * treated as a `failed` mint rather than fed into the redemption path.
1034
+ */
1035
+ export const BOOTSTRAP_INVITE_TOKEN_PREFIX = "bapi_inv_";
1036
+ /** The self-serve mint endpoint: `<base>/setup/bootstrap/self-serve`. No query string — ever. */
1037
+ export function buildSelfServeMintUrl(baseUrl) {
1038
+ return `${baseUrl.replace(/\/+$/, "")}/setup/bootstrap/self-serve`;
1039
+ }
1040
+ /**
1041
+ * POST the self-serve mint: an email in, an invite token out. Mirrors
1042
+ * {@link exchangeBootstrapInvite}'s fetch/timeout/secret-free-result discipline.
1043
+ *
1044
+ * The email travels ONLY in the JSON request body (never a query string, never a
1045
+ * header, never a log line). Bounded by the same 10s timeout as the exchange and
1046
+ * the connectivity ping. On any non-success status the failure body is NOT read —
1047
+ * the status alone decides the category — because an untrusted upstream body could
1048
+ * echo the email or carry internals. A caught fetch exception is likewise never
1049
+ * inspected or interpolated; some fetch impls embed the request (and thus the
1050
+ * email) in the message.
1051
+ */
1052
+ export async function mintSelfServeInvite(deps, baseUrl, email) {
1053
+ const url = buildSelfServeMintUrl(baseUrl);
1054
+ let resp;
1055
+ try {
1056
+ resp = await deps.fetch(url, {
1057
+ method: "POST",
1058
+ headers: { "Content-Type": "application/json" },
1059
+ body: JSON.stringify({ invitee_email: email }),
1060
+ signal: AbortSignal.timeout(10_000),
1061
+ });
1062
+ }
1063
+ catch (err) {
1064
+ // The message may contain the request (some fetch impls echo it), so it is
1065
+ // deliberately NOT inspected or interpolated here.
1066
+ void err;
1067
+ return { ok: false, category: "failed" };
1068
+ }
1069
+ if (resp.ok) {
1070
+ let token;
1071
+ try {
1072
+ const body = (await resp.json());
1073
+ token = body?.token;
1074
+ }
1075
+ catch {
1076
+ // An unreadable success body is not actionable and must not be exposed.
1077
+ return { ok: false, category: "failed" };
1078
+ }
1079
+ if (typeof token !== "string" ||
1080
+ token.trim().length === 0 ||
1081
+ !token.startsWith(BOOTSTRAP_INVITE_TOKEN_PREFIX)) {
1082
+ return { ok: false, category: "failed" };
1083
+ }
1084
+ return { ok: true, token };
1085
+ }
1086
+ // Non-success: the status alone decides the category. The failure body is NEVER
1087
+ // read — it is untrusted and could echo the email or leak internals.
1088
+ if (resp.status === 429) {
1089
+ return { ok: false, category: "rate-limited" };
1090
+ }
1091
+ if (resp.status === 400 || resp.status === 422) {
1092
+ return { ok: false, category: "invalid" };
1093
+ }
1094
+ return { ok: false, category: "failed" };
1095
+ }
1096
+ /**
1097
+ * The 401 message for a run that had to GENERATE a fresh secret — i.e. no local
1098
+ * pending record existed. Besides a simply-wrong token, this is exactly what a lost
1099
+ * `~/.config/bridge` looks like after a successful redemption: the retry sends a
1100
+ * NEW secret, so the server's replay branch cannot match and correctly answers 401
1101
+ * (letting a different secret replace the key would turn a spent token back into a
1102
+ * bearer credential).
1103
+ *
1104
+ * The recovery is therefore NOT "re-run" and NOT "re-mint" — re-running sends
1105
+ * another new secret and gets 401 again, and a fresh invite cannot reuse the repo
1106
+ * name, which is globally unique and now taken by the project already created. Do
1107
+ * not suggest either.
1108
+ */
1109
+ export const BOOTSTRAP_INVITE_LOST_SECRET_MESSAGE = [
1110
+ "The Bridge API rejected the bootstrap invite (HTTP 401).",
1111
+ "",
1112
+ "Either the invite is invalid, expired, or revoked — or it was ALREADY redeemed from this",
1113
+ "machine and the local secret has since been lost (e.g. ~/.config/bridge was deleted).",
1114
+ "",
1115
+ "If it was already redeemed, you cannot recover it yourself:",
1116
+ " • Re-running will NOT work: each run without the original local secret sends a new one,",
1117
+ " which cannot match what the server stored, so it will keep returning 401.",
1118
+ " • A new bootstrap invite will NOT work either: your repo name is globally unique and is",
1119
+ " now taken by the project you already created, so it cannot be redeemed again.",
1120
+ "",
1121
+ "Ask your Bridge API operator to recover it for you: they revoke the orphaned key",
1122
+ "(DELETE /setup/keys/{id}) and issue a replacement key for the EXISTING project",
1123
+ "(POST /setup/keys), then send you that key. Run install-bridge with --api-key <that key>.",
1124
+ ].join("\n");
1125
+ /**
1126
+ * The 401 message for a run that REUSED an existing pending secret — the replay
1127
+ * proof was sent and still rejected, so the invite itself is not usable.
1128
+ */
1129
+ export const BOOTSTRAP_INVITE_REJECTED_MESSAGE = "The Bridge API rejected the bootstrap invite (HTTP 401). The invite is invalid, expired, or " +
1130
+ "revoked — ask your Bridge API operator for a new one. (Your locally-stored secret was sent " +
1131
+ "unchanged, so this is not a lost-secret problem.)";
1132
+ /**
1133
+ * Render the --dry-run preview lines. Every secret is ALWAYS redacted — the
1134
+ * spawnCommand, config preview, and (in bootstrap-invite mode) the exchange body
1135
+ * never embed the API key, the invite token, or the generated `key_secret`.
1136
+ *
1137
+ * The plan itself holds no secret and no invite fingerprint, so this function
1138
+ * CANNOT leak one: in bootstrap-invite mode it is called before any secret exists.
524
1139
  */
525
1140
  export function buildDryRunPreview(plan) {
1141
+ if (plan.bootstrapInvite)
1142
+ return buildBootstrapDryRunPreview(plan);
526
1143
  return [
527
- "install-bridge --dry-run (no writes, no network, no spawns)",
528
- `Repo name: ${plan.repoName}`,
1144
+ plan.attemptedServerResolution
1145
+ ? "install-bridge --dry-run (one read-only repository-resolution GET may already have occurred; no writes, no state-changing requests, no spawns)"
1146
+ : "install-bridge --dry-run (no writes, no network, no spawns)",
1147
+ `Repo name: ${plan.repoName}${plan.attemptedServerResolution ? " (resolved server-side from your API key)" : ""}`,
529
1148
  `Base URL (ping): ${plan.baseUrl}`,
530
1149
  `Docs dir: ${plan.docsDir}`,
531
1150
  `Agent: ${plan.agentName}`,
@@ -543,6 +1162,61 @@ export function buildDryRunPreview(plan) {
543
1162
  `Step 5 — spawn agent session: ${plan.spawnCommand}`,
544
1163
  ];
545
1164
  }
1165
+ /**
1166
+ * Bootstrap-invite dry-run preview. A --dry-run must not consume the invite OR
1167
+ * LEAVE STATE BEHIND: skipping the HTTP call is not enough — a preview that wrote
1168
+ * a pending `key_secret` would have left credential material on disk for a
1169
+ * redemption that never happened. So the caller returns here BEFORE the CSPRNG
1170
+ * runs and before anything is written.
1171
+ */
1172
+ function buildBootstrapDryRunPreview(plan) {
1173
+ const pendingTarget = `bootstrap-pending:${plan.repoName}`;
1174
+ // Self-serve signup (BAPI-618): the ONLY structural difference from a pre-issued
1175
+ // invite is a mint-from-email step (Step 2·pre) that turns an email into the
1176
+ // invite token before the unchanged redemption below. The preview states plainly
1177
+ // that in --dry-run that signup/mint is previewed-and-skipped: no account is
1178
+ // created, no mint request is sent, no email leaves the machine. The email and
1179
+ // any synthetic token are deliberately absent from this output.
1180
+ const header = plan.selfServeSignup
1181
+ ? "install-bridge --email --dry-run (no writes, no network, no spawns, no account created, no secret generated)"
1182
+ : "install-bridge --invite --dry-run (no writes, no network, no spawns, no secret generated)";
1183
+ const repoLine = plan.selfServeSignup
1184
+ ? `Repo name: ${plan.repoName} (created by the self-serve exchange; globally unique)`
1185
+ : `Repo name: ${plan.repoName} (created by the exchange; globally unique)`;
1186
+ const selfServeStep = plan.selfServeSignup
1187
+ ? [
1188
+ "Step 2·pre — self-serve signup (PREVIEWED, SKIPPED in --dry-run): no Bridge workspace",
1189
+ " signup is requested, no mint call is made, and no email is sent or transmitted;",
1190
+ " a real run would request a fresh workspace for your email and receive an invite",
1191
+ " token, which then feeds the SAME redemption protocol below.",
1192
+ ]
1193
+ : [];
1194
+ return [
1195
+ header,
1196
+ repoLine,
1197
+ `Base URL: ${plan.baseUrl}`,
1198
+ `Docs dir: ${plan.docsDir}`,
1199
+ `Agent: ${plan.agentName}`,
1200
+ "",
1201
+ "Step 1 — scaffold (runInit): commands, agents, pipelines, .bridge/config, secret-free MCP placeholders.",
1202
+ ...selfServeStep,
1203
+ `Step 2a — generate key_secret (32 CSPRNG bytes) and fsync it to ${pendingTarget} at ${plan.credentialStorePath}`,
1204
+ " BEFORE the exchange. If that write fails the run ABORTS and no invite is spent.",
1205
+ `Step 2b — redeem the bootstrap invite (replaces the pre-flight ping — there is no key yet):`,
1206
+ ` POST ${plan.exchangeUrl}`,
1207
+ ` body: {"token": "${REDACTED_API_KEY}", "repo_name": "${plan.repoName}", "key_secret": "${REDACTED_API_KEY}"}`,
1208
+ `Step 2c — connectivity ping with the newly-minted key (before any durable key write): GET ${plan.pingUrl} (X-API-Key: ${REDACTED_API_KEY})`,
1209
+ "Step 3 — write per-host MCP config (read-merge-write, launcher version-pinned):",
1210
+ ...plan.configTargets.map((t) => ` ${t}: BAPI_REPO_NAME=${plan.repoName}, BAPI_API_KEY=${REDACTED_API_KEY}, BAPI_BASE_URL=${plan.baseUrl}, BAPI_DOCS_DIR=${plan.docsDir}`),
1211
+ ...(plan.manualEditors.length > 0
1212
+ ? [` ${plan.manualEditors.join(" + ")}: detected (global config) — manual setup instructions would be printed.`]
1213
+ : []),
1214
+ `Step 3b — pre-warm the version-pinned launcher bucket (fail-open, env sanitized — BAPI_API_KEY / BAPI_INVITE removed): ${plan.prewarmCommand}`,
1215
+ MCP_TIMEOUT_GUIDANCE,
1216
+ `Step 4 — promote ${pendingTarget} → ${plan.credentialTarget} at ${plan.credentialStorePath} (only after the exchange succeeds)`,
1217
+ `Step 5 — spawn agent session: ${plan.spawnCommand}`,
1218
+ ];
1219
+ }
546
1220
  /**
547
1221
  * Detect global-config editors that install-bridge cannot safely write into
548
1222
  * (their configs live outside the project). Windsurf is detected from the
@@ -608,6 +1282,12 @@ function buildManualHostInstructions(entry, editors) {
608
1282
  */
609
1283
  export async function runInstallBridgeCli(argv, overrides = {}) {
610
1284
  const deps = { ...createDefaultInstallBridgeDeps(), ...overrides };
1285
+ // When the caller overrode only `fetch` (the common test seam), bind the
1286
+ // default resolver to the FINAL merged fetch so server resolution still routes
1287
+ // through the override. An explicit `resolveRepoViaServer` override is honored.
1288
+ if (!overrides.resolveRepoViaServer) {
1289
+ deps.resolveRepoViaServer = (baseUrl, apiKey) => resolveRepoViaServer(deps.fetch, baseUrl, apiKey);
1290
+ }
611
1291
  const { log, errorLog } = deps;
612
1292
  const parsed = parseInstallBridgeArgs(argv);
613
1293
  if (parsed.status === "help") {
@@ -621,23 +1301,101 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
621
1301
  return 1;
622
1302
  }
623
1303
  const options = parsed.options;
1304
+ // Onboarding branch (pure, deterministic): have-key vs. a need-key method
1305
+ // (`bootstrap-invite` = redeem a pre-issued invite, `self-serve` = mint one from
1306
+ // an email, BAPI-618). BOTH need-key methods share the downstream redemption
1307
+ // protocol, so `bootstrapInviteMode` is true for both; `selfServeSignupMode`
1308
+ // discriminates the one extra step (mint-from-email) the self-serve path adds.
1309
+ const branch = resolveInstallBridgeOnboardingBranch(options, deps.env);
1310
+ const bootstrapInviteMode = branch.kind === "need-key";
1311
+ const selfServeSignupMode = branch.kind === "need-key" && branch.method === "self-serve";
624
1312
  // ---- Resolve inputs (may prompt when interactive) ----
625
- // Resolve the API key first so a fully-empty non-interactive invocation fails
626
- // with the (more security-relevant) missing-key message before the repo one.
627
- const keyResult = await resolveApiKey(options, deps);
628
- if (!keyResult.ok) {
629
- errorLog(`Error: ${keyResult.error}`);
630
- return 1;
1313
+ // Resolve the credential/entry input first so a fully-empty non-interactive
1314
+ // invocation fails with the (more relevant) missing-input message before the repo
1315
+ // one. In either need-key mode there is no API key to resolve — the exchange
1316
+ // MINTS it. Self-serve resolves the email here (up-front, before the dry-run
1317
+ // guard) so a missing email fails fast; the actual MINT happens later, strictly
1318
+ // after the dry-run return, so a preview neither mints nor sends anything.
1319
+ let apiKey = "";
1320
+ let inviteToken = "";
1321
+ let signupEmail = "";
1322
+ if (selfServeSignupMode) {
1323
+ const emailResult = await resolveSignupEmail(options, deps);
1324
+ if (!emailResult.ok) {
1325
+ errorLog(`Error: ${emailResult.error}`);
1326
+ return 1;
1327
+ }
1328
+ signupEmail = emailResult.value;
631
1329
  }
632
- const apiKey = keyResult.value;
633
- const repoResult = await resolveRepoName(options, deps);
634
- if (!repoResult.ok) {
635
- errorLog(`Error: ${repoResult.error}`);
636
- return 1;
1330
+ else if (bootstrapInviteMode) {
1331
+ const inviteResult = await resolveInviteToken(options, deps);
1332
+ if (!inviteResult.ok) {
1333
+ errorLog(`Error: ${inviteResult.error}`);
1334
+ return 1;
1335
+ }
1336
+ inviteToken = inviteResult.value;
637
1337
  }
638
- const repoName = repoResult.value;
1338
+ else {
1339
+ const keyResult = await resolveApiKey(options, deps);
1340
+ if (!keyResult.ok) {
1341
+ errorLog(`Error: ${keyResult.error}`);
1342
+ return 1;
1343
+ }
1344
+ apiKey = keyResult.value;
1345
+ }
1346
+ // baseUrl is resolved BEFORE repository input because the have-key branch needs
1347
+ // it for the server-resolution request.
639
1348
  const baseUrl = deps.env.BAPI_BASE_URL ?? DEFAULT_BAPI_BASE_URL;
640
1349
  const docsDir = deps.env.BAPI_DOCS_DIR ?? DEFAULT_BAPI_DOCS_DIR;
1350
+ // ---- Resolve the repository name ----
1351
+ // Bootstrap-invite: unchanged (choose-a-name for the new project). Have-key:
1352
+ // `--repo`/`BAPI_REPO_NAME` short-circuit deterministically; otherwise resolve
1353
+ // it server-side from the API key (BAPI-616), and on ANY non-resolution outcome
1354
+ // (unresolved / not-deployed / error) fall back to the existing local
1355
+ // prompt/inference — never a hard failure.
1356
+ let repoName;
1357
+ let attemptedServerResolution = false;
1358
+ if (bootstrapInviteMode) {
1359
+ const repoResult = await resolveRepoName(options, deps);
1360
+ if (!repoResult.ok) {
1361
+ errorLog(`Error: ${repoResult.error}`);
1362
+ return 1;
1363
+ }
1364
+ // Invite mode creates the project, so the name must be valid BEFORE any
1365
+ // pending state or exchange.
1366
+ const validated = validateRepoName(repoResult.value);
1367
+ if (!validated.ok) {
1368
+ errorLog(`Error: invalid repo name — ${validated.error}.`);
1369
+ return 1;
1370
+ }
1371
+ repoName = validated.value;
1372
+ }
1373
+ else {
1374
+ const configured = resolveConfiguredRepoName(options, deps.env);
1375
+ if (configured !== undefined) {
1376
+ // Deterministic short-circuit: no server round-trip when the name is known.
1377
+ repoName = configured;
1378
+ }
1379
+ else {
1380
+ attemptedServerResolution = true;
1381
+ log("Resolving repository…");
1382
+ const resolution = await deps.resolveRepoViaServer(baseUrl, apiKey);
1383
+ if (resolution.status === "resolved") {
1384
+ repoName = resolution.repoName;
1385
+ }
1386
+ else {
1387
+ // Feature-detect + degrade: 404 (old server), 409 (unresolved/ambiguous/
1388
+ // client-scoped), and network/other errors all fall back to the existing
1389
+ // local resolution WITHOUT a cause-specific message or leaked detail.
1390
+ const repoResult = await resolveRepoName(options, deps);
1391
+ if (!repoResult.ok) {
1392
+ errorLog(`Error: ${repoResult.error}`);
1393
+ return 1;
1394
+ }
1395
+ repoName = repoResult.value;
1396
+ }
1397
+ }
1398
+ }
641
1399
  const agent = resolveAgentSpec(options.agentName) ?? resolveAgentSpec(DEFAULT_AGENT_NAME);
642
1400
  const spawnCommand = deps.buildShellCommand(agent, INSTALL_BRIDGE_AGENT_PROMPT, deps.cwd, deps.platform);
643
1401
  const credentialStorePath = getPrimaryCredentialStorePath({
@@ -659,16 +1417,36 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
659
1417
  pingUrl: buildPingUrl(baseUrl, repoName),
660
1418
  prewarmCommand: buildPrewarmCommandPreview(),
661
1419
  spawnCommand,
1420
+ ...(bootstrapInviteMode
1421
+ ? { bootstrapInvite: true, exchangeUrl: buildBootstrapExchangeUrl(baseUrl) }
1422
+ : {}),
1423
+ ...(selfServeSignupMode ? { selfServeSignup: true } : {}),
1424
+ ...(attemptedServerResolution ? { attemptedServerResolution: true } : {}),
662
1425
  };
663
1426
  // ---- --dry-run: preview every step, strictly no side effects ----
1427
+ // Positioned BEFORE the CSPRNG, the pending write, the exchange, the scaffold,
1428
+ // the config writes, the pre-warm, and the spawn: in bootstrap-invite mode a
1429
+ // preview must neither consume the invite NOR leave a pending secret on disk.
664
1430
  if (options.dryRun) {
665
1431
  for (const line of buildDryRunPreview(plan))
666
1432
  log(line);
667
1433
  return 0;
668
1434
  }
669
- const entry = buildInstallBridgeServerEntry(deps.cwd, repoName, apiKey, baseUrl, docsDir);
1435
+ const credentialWriteDeps = {
1436
+ env: deps.env,
1437
+ homedir: deps.homedir,
1438
+ platform: deps.platform,
1439
+ readFile: deps.readFile,
1440
+ mkdir: deps.mkdir,
1441
+ writeFile: (p, d, o) => deps.writeFile(p, d, o),
1442
+ rename: deps.rename,
1443
+ chmod: deps.chmod,
1444
+ unlink: deps.unlink,
1445
+ open: deps.open,
1446
+ };
670
1447
  // ---- Overwrite consent: a real existing key requires --force or a prompt ----
671
1448
  const hasRealKey = await detectExistingRealKey(deps, targets);
1449
+ let overwriteConsent = options.force;
672
1450
  if (hasRealKey && !options.force) {
673
1451
  if (deps.isTTY && deps.promptLine) {
674
1452
  const answer = (await deps.promptLine("A host config already contains a BAPI_API_KEY. Overwrite it? [y/N]: ")).trim().toLowerCase();
@@ -676,6 +1454,7 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
676
1454
  errorLog("Aborted: existing API key left unchanged (re-run with --force to overwrite).");
677
1455
  return 1;
678
1456
  }
1457
+ overwriteConsent = true;
679
1458
  }
680
1459
  else {
681
1460
  errorLog("Error: a host config already contains a BAPI_API_KEY. Re-run with --force to overwrite it " +
@@ -686,18 +1465,191 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
686
1465
  // ---- Step 1 — scaffold (secret-free placeholders only) ----
687
1466
  log("Step 1/5 — scaffolding project (commands, agents, pipelines, config placeholders)…");
688
1467
  await deps.runInit(deps.cwd);
689
- // ---- Step 2 — verify connectivity BEFORE writing the key anywhere durable ----
1468
+ // ---- Step 2 — mint (invite mode) or verify (normal mode) the credential ----
1469
+ let inviteFingerprint = "";
1470
+ if (bootstrapInviteMode) {
1471
+ // ---- Self-serve signup (BAPI-618): mint the invite token from the email ----
1472
+ // This is the ONE step that precedes the unchanged BAPI-606 redemption below.
1473
+ // It sits AFTER the --dry-run return above, so a preview never mints, never
1474
+ // sends the email, and never writes a pending secret. On success the minted
1475
+ // token is assigned to `inviteToken` and the flow falls into the existing
1476
+ // persist-before-exchange block VERBATIM — identical to a --invite token from
1477
+ // here on. On failure we return BEFORE any CSPRNG/pending write, so nothing is
1478
+ // persisted. The email and the token are never logged.
1479
+ if (selfServeSignupMode) {
1480
+ log("Step 2/5 — requesting Bridge self-serve setup…");
1481
+ const mint = await mintSelfServeInvite(deps, baseUrl, signupEmail);
1482
+ if (!mint.ok) {
1483
+ if (mint.category === "rate-limited") {
1484
+ errorLog("Error: Self-serve setup is temporarily rate limited. Try again later.");
1485
+ }
1486
+ else if (mint.category === "invalid") {
1487
+ errorLog("Error: Self-serve setup could not be requested. Check the email value and try again.");
1488
+ }
1489
+ else {
1490
+ errorLog("Error: Unable to complete self-serve setup. Check connectivity and retry.");
1491
+ }
1492
+ return 1;
1493
+ }
1494
+ // Treated identically to a manually supplied invite token from here on.
1495
+ inviteToken = mint.token;
1496
+ }
1497
+ // ===================================================================
1498
+ // LOAD-BEARING ORDERING — NOT DEFENSIVE POLISH. DO NOT "TIDY" THIS.
1499
+ //
1500
+ // The key_secret is generated HERE, fsynced to a pending record HERE, and
1501
+ // only THEN sent to the exchange. That order is the protocol:
1502
+ //
1503
+ // * The locally-stored key_secret is the ONLY proof that can replay a
1504
+ // redemption. The server's replay branch matches on (repo_name, bcrypt of
1505
+ // key_secret); it never returns a recoverable credential.
1506
+ // * So a successful exchange followed by a failed local write = an
1507
+ // unrecoverable admin key AND a permanently spent invite. The user cannot
1508
+ // re-run (a new secret gets a correct 401) and cannot re-mint (the repo
1509
+ // name is now globally taken by the project they just created); only an
1510
+ // operator can dig them out.
1511
+ //
1512
+ // Therefore the write is FAIL-CLOSED: if it does not land (including its
1513
+ // fsync), we abort with a non-zero exit and NEVER call fetch. Nothing is
1514
+ // consumed. Moving this persist after the exchange — the "natural" order —
1515
+ // reintroduces exactly the bug above. (Contrast Step 4 in normal mode, which
1516
+ // is deliberately fail-open: there, the key already exists server-side and a
1517
+ // persist failure only degrades start-tickets routing.)
1518
+ // ===================================================================
1519
+ inviteFingerprint = fingerprintBootstrapInvite(inviteToken);
1520
+ log("Step 2/5 — redeeming the bootstrap invite…");
1521
+ let prepared = await deps.prepareBootstrapPending({
1522
+ repoName,
1523
+ inviteFingerprint,
1524
+ generateKeySecret: () => generateBootstrapKeySecret(deps.randomBytes),
1525
+ allowOverwriteExistingCredential: overwriteConsent,
1526
+ }, credentialWriteDeps);
1527
+ // An existing bapi:<repo> credential is never clobbered without consent. The
1528
+ // gate mirrors the host-config gate above: --force, else an interactive prompt,
1529
+ // else a hard non-interactive failure — all BEFORE the exchange.
1530
+ if (!prepared.ok && prepared.kind === "credential-conflict") {
1531
+ if (deps.isTTY && deps.promptLine) {
1532
+ const answer = (await deps.promptLine(`The credential store already holds a key for ${prepared.target}. Overwrite it? [y/N]: `)).trim().toLowerCase();
1533
+ if (answer !== "y" && answer !== "yes") {
1534
+ errorLog("Aborted: existing credential left unchanged (re-run with --force to overwrite).");
1535
+ return 1;
1536
+ }
1537
+ overwriteConsent = true;
1538
+ prepared = await deps.prepareBootstrapPending({
1539
+ repoName,
1540
+ inviteFingerprint,
1541
+ generateKeySecret: () => generateBootstrapKeySecret(deps.randomBytes),
1542
+ allowOverwriteExistingCredential: true,
1543
+ }, credentialWriteDeps);
1544
+ }
1545
+ else {
1546
+ errorLog(`Error: ${prepared.error} Re-run with --force to overwrite it (refusing to overwrite a ` +
1547
+ "credential non-interactively without consent).");
1548
+ return 1;
1549
+ }
1550
+ }
1551
+ // A pending record left by a DIFFERENT invite is that redemption's only replay
1552
+ // proof. Unlike the credential conflict above there is no consent path — not
1553
+ // even --force — so re-running is not the fix and must not be advised.
1554
+ if (!prepared.ok && prepared.kind === "pending-conflict") {
1555
+ errorLog(`Error: ${prepared.error} This invite has NOT been used, and re-running will not clear ` +
1556
+ "the conflict.");
1557
+ return 1;
1558
+ }
1559
+ if (!prepared.ok) {
1560
+ // Fail closed: the exchange has NOT been called, so the invite is unspent.
1561
+ errorLog(`Error: could not durably store the bootstrap credential (${prepared.kind}). ${prepared.error} ` +
1562
+ "The bootstrap invite has NOT been used — fix the problem and re-run.");
1563
+ return 1;
1564
+ }
1565
+ const keySecret = prepared.keySecret;
1566
+ const reusedPendingSecret = prepared.reused;
1567
+ log(` saved the pending credential for ${prepared.target} (fsynced before the exchange)`);
1568
+ // The exchange REPLACES the pre-flight ping: in invite mode there is no key to
1569
+ // ping with — this call is what mints it.
1570
+ let exchange = await exchangeBootstrapInvite(deps, baseUrl, inviteToken, repoName, keySecret);
1571
+ // 409 repo_name_taken: the server ROLLED BACK, so the invite is NOT consumed and
1572
+ // the SAME token can be retried under a new name — with the SAME pending secret,
1573
+ // re-pointed to that name.
1574
+ while (!exchange.ok && exchange.kind === "repo-name-taken") {
1575
+ if (!deps.isTTY || !deps.promptLine) {
1576
+ errorLog(`Error: ${exchange.message} Re-run with a different --repo (the bootstrap invite has NOT ` +
1577
+ "been used).");
1578
+ return 1;
1579
+ }
1580
+ errorLog(exchange.message);
1581
+ const answer = (await deps.promptLine("Choose a different repo name: ")).trim();
1582
+ const validated = validateRepoName(answer);
1583
+ if (!validated.ok) {
1584
+ errorLog(`Error: invalid repo name — ${validated.error}.`);
1585
+ return 1;
1586
+ }
1587
+ const nextRepo = validated.value;
1588
+ const repointed = await deps.repointBootstrapPending({
1589
+ fromRepoName: repoName,
1590
+ toRepoName: nextRepo,
1591
+ inviteFingerprint,
1592
+ allowOverwriteExistingCredential: overwriteConsent,
1593
+ }, credentialWriteDeps);
1594
+ if (!repointed.ok) {
1595
+ errorLog(`Error: could not re-point the pending bootstrap credential to '${nextRepo}' ` +
1596
+ `(${repointed.kind}). ${repointed.error} The bootstrap invite has NOT been used.`);
1597
+ return 1;
1598
+ }
1599
+ repoName = nextRepo;
1600
+ // Same token, same key_secret — never regenerated inside the rename loop.
1601
+ exchange = await exchangeBootstrapInvite(deps, baseUrl, inviteToken, repoName, keySecret);
1602
+ }
1603
+ if (!exchange.ok) {
1604
+ if (exchange.kind === "invalid-invite") {
1605
+ errorLog(reusedPendingSecret
1606
+ ? BOOTSTRAP_INVITE_REJECTED_MESSAGE
1607
+ : BOOTSTRAP_INVITE_LOST_SECRET_MESSAGE);
1608
+ }
1609
+ else {
1610
+ errorLog(`Error: ${exchange.message}`);
1611
+ }
1612
+ // The pending record is deliberately LEFT INTACT: it is the replay proof for a
1613
+ // retry (network failure / transient error), and destroying it would be the
1614
+ // unrecoverable case above.
1615
+ return 1;
1616
+ }
1617
+ // The server-returned name is authoritative from here on: config, ping,
1618
+ // credential target, and the spawned session all key off it.
1619
+ if (exchange.repoName !== repoName) {
1620
+ const repointed = await deps.repointBootstrapPending({
1621
+ fromRepoName: repoName,
1622
+ toRepoName: exchange.repoName,
1623
+ inviteFingerprint,
1624
+ allowOverwriteExistingCredential: overwriteConsent,
1625
+ }, credentialWriteDeps);
1626
+ if (!repointed.ok) {
1627
+ errorLog(`Error: the project was created as '${exchange.repoName}' but the pending credential could ` +
1628
+ `not be re-pointed to it (${repointed.kind}). ${repointed.error}`);
1629
+ return 1;
1630
+ }
1631
+ repoName = exchange.repoName;
1632
+ }
1633
+ log(` bootstrap invite redeemed — project '${repoName}' is ready`);
1634
+ // The minted key. From here the flow rejoins the existing path unchanged.
1635
+ apiKey = keySecret;
1636
+ }
1637
+ // ---- Step 2 (cont.) — verify connectivity BEFORE writing the key anywhere durable ----
690
1638
  // R5: ping before persisting anything durably. Pinging before writeHostConfigs
691
1639
  // (and the credential store) means a bad key on a first-time install halts
692
1640
  // WITHOUT leaving an invalid key in the config — which would otherwise trip the
693
- // overwrite-consent gate on every retry (a trapped state).
694
- log("Step 2/5 verifying connectivity…");
1641
+ // overwrite-consent gate on every retry (a trapped state). In invite mode this
1642
+ // runs AFTER the exchange, with the just-minted key; the pending record is the one
1643
+ // deliberate write that precedes it, for the protocol reason above.
1644
+ if (!bootstrapInviteMode)
1645
+ log("Step 2/5 — verifying connectivity…");
695
1646
  const ping = await verifyConnectivity(deps, baseUrl, repoName, apiKey);
696
1647
  if (!ping.ok) {
697
1648
  errorLog(`Error: ${ping.message}`);
698
1649
  return 1;
699
1650
  }
700
1651
  log(" connectivity OK");
1652
+ const entry = buildInstallBridgeServerEntry(deps.cwd, repoName, apiKey, baseUrl, docsDir);
701
1653
  // ---- Step 3 — write per-host MCP config with real values ----
702
1654
  log("Step 3/5 — writing per-host MCP config…");
703
1655
  const written = await writeHostConfigs(deps, targets, entry);
@@ -726,42 +1678,51 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
726
1678
  errorLog(`Warning: could not pre-warm the version-pinned launcher bucket${prewarm.warning ? ` (${prewarm.warning})` : ""}. ` +
727
1679
  `The first MCP launch may pay a one-time cold install and could be slow. ${MCP_TIMEOUT_GUIDANCE}`);
728
1680
  }
729
- // ---- Step 4 — persist routing credential (non-blocking / fail-open) ----
730
- log("Step 4/5 — persisting routing credential…");
731
- try {
732
- const writeDeps = {
733
- env: deps.env,
734
- homedir: deps.homedir,
735
- platform: deps.platform,
736
- readFile: deps.readFile,
737
- mkdir: deps.mkdir,
738
- writeFile: (p, d, o) => deps.writeFile(p, d, o),
739
- rename: deps.rename,
740
- chmod: deps.chmod,
741
- unlink: deps.unlink,
742
- };
743
- const result = await deps.upsertCredential(repoName, apiKey, writeDeps);
744
- if (result.ok) {
745
- log(` stored routing credential for ${result.target} at ${result.path}`);
1681
+ // ---- Step 4 — persist the credential ----
1682
+ if (bootstrapInviteMode) {
1683
+ // FAIL-CLOSED (unlike normal mode below). The pending secret IS the minted admin
1684
+ // key if it never lands under bapi:<repo>, the user has a live key they cannot
1685
+ // resolve. Promotion removes the pending record and writes the credential in one
1686
+ // durable replacement; on failure the pending record survives so a re-run replays
1687
+ // the same redemption. The agent session is NOT spawned until this succeeds.
1688
+ log("Step 4/5 — promoting the bootstrap credential…");
1689
+ const promoted = await deps.promoteBootstrapPending({ repoName, inviteFingerprint, allowOverwriteExistingCredential: overwriteConsent }, credentialWriteDeps);
1690
+ if (!promoted.ok) {
1691
+ errorLog(`Error: the project and API key were created, but the credential could not be stored ` +
1692
+ `(${promoted.kind}). ${promoted.error} Your key is still saved locally as a pending ` +
1693
+ "record — re-run install-bridge with the same bootstrap invite to finish (the redemption " +
1694
+ "will replay and return the same key).");
1695
+ return 1;
746
1696
  }
747
- else {
748
- log(` warning: could not persist the routing credential (${result.kind}). ` +
749
- `start-tickets model routing may not resolve the key for ${plan.credentialTarget} ` +
750
- "and will fail open to the premium/Opus tier (the most expensive) — " +
751
- "set BAPI_API_KEY in the shell or re-run install-bridge, then verify with " +
1697
+ log(` stored routing credential for ${promoted.target} at ${promoted.path}`);
1698
+ }
1699
+ else {
1700
+ // ---- persist routing credential (non-blocking / fail-open) ----
1701
+ log("Step 4/5 persisting routing credential…");
1702
+ try {
1703
+ const result = await deps.upsertCredential(repoName, apiKey, credentialWriteDeps);
1704
+ if (result.ok) {
1705
+ log(` stored routing credential for ${result.target} at ${result.path}`);
1706
+ }
1707
+ else {
1708
+ log(` warning: could not persist the routing credential (${result.kind}). ` +
1709
+ `start-tickets model routing may not resolve the key for bapi:${repoName} ` +
1710
+ "and will fail open to the premium/Opus tier (the most expensive) — " +
1711
+ "set BAPI_API_KEY in the shell or re-run install-bridge, then verify with " +
1712
+ "'npx -y @bridge_gpt/mcp-server doctor'.");
1713
+ }
1714
+ }
1715
+ catch {
1716
+ // Fail-open: persistence is best-effort (mirrors Stage 6). The secret is never
1717
+ // included in the warning.
1718
+ log(" warning: could not persist the routing credential (unexpected error). " +
1719
+ "start-tickets model routing may need BAPI_API_KEY in the shell and will fail open " +
1720
+ "to the premium/Opus tier (the most expensive) until fixed — verify with " +
752
1721
  "'npx -y @bridge_gpt/mcp-server doctor'.");
753
1722
  }
754
1723
  }
755
- catch {
756
- // Fail-open: persistence is best-effort (mirrors Stage 6). The secret is never
757
- // included in the warning.
758
- log(" warning: could not persist the routing credential (unexpected error). " +
759
- "start-tickets model routing may need BAPI_API_KEY in the shell and will fail open " +
760
- "to the premium/Opus tier (the most expensive) until fixed — verify with " +
761
- "'npx -y @bridge_gpt/mcp-server doctor'.");
762
- }
763
1724
  // ---- Step 5 — spawn a fresh agent session for the agentic remainder ----
764
- log(`Step 5/5 — opening a ${agent.name} session for /install-bridge + /learn-repository…`);
1725
+ log(`Step 5/5 — opening a ${agent.name} session for /install-bridge configuration + capability report…`);
765
1726
  const terminal = detectTerminal(undefined, deps.env);
766
1727
  const spawnResult = await deps.spawnTerminalTab(deps.startTicketsDeps, terminal, spawnCommand, {
767
1728
  key: "install",
@@ -774,16 +1735,17 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
774
1735
  // user can run the agentic remainder by hand. Mirrors start-tickets treating a
775
1736
  // spawn failure as non-fatal to the run.
776
1737
  errorLog(`Warning: setup steps completed, but the agent session could not be opened (${spawnResult.error}). ` +
777
- "The project is NOT configured yet — run /install-bridge then /learn-repository manually " +
778
- "in this project to derive and apply the config fields.");
1738
+ "The project is NOT configured yet — run /install-bridge manually in this project to derive " +
1739
+ "and apply the config fields and see the capability report, then choose whether to run " +
1740
+ "/parse-repository to index the repository.");
779
1741
  return 0;
780
1742
  }
781
1743
  log("");
782
- log(`install-bridge setup steps complete. A fresh ${agent.name} session is now running ` +
783
- "/install-bridge then /learn-repository.");
1744
+ log(`install-bridge setup steps complete. A fresh ${agent.name} session is now applying ` +
1745
+ "configuration, presenting the capability report, and ending with one indexing-consent question.");
784
1746
  log("NOTE: the install is not finished until that session's apply reports applied fields — " +
785
- "it will pause to ask you to approve the project description. Verify afterwards on the " +
786
- "project's Get Started page (install status panel) or via the session's " +
787
- "'Applied N of M' summary.");
1747
+ "it will pause to ask you to approve the project description, and it will close by asking " +
1748
+ "'[Y/n] Index repository now?'. Verify afterwards on the project's Get Started page " +
1749
+ "(install status panel) or via the session's 'Applied N of M' summary.");
788
1750
  return 0;
789
1751
  }