@bridge_gpt/mcp-server 0.2.23 → 0.2.25

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.
@@ -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,14 +28,34 @@
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
- * BOOTSTRAP-INVITE MODE (BAPI-606) the one exception to "this command consumes
32
- * a key, it does not create one". With `--invite` (or `BAPI_INVITE`) there is no
39
+ * That spawn command embeds the entire agent prompt and runs to multiple KB, which
40
+ * no macOS terminal will accept as one typed line. So it is never typed: the full
41
+ * command is written to a restricted (0600) launch script and only a short
42
+ * `. '<path>'` runner is spawned (BAPI-626, via `materializeWorkerLaunchCommand`).
43
+ * Materialization happens BEFORE any install side effect, because a command that
44
+ * cannot be launched is a whole-run failure, not a Step 5 warning.
45
+ *
46
+ * ONBOARDING BRANCHES — `have-key` (consume an existing key) vs. `need-key` (this
47
+ * command creates the project AND its first admin key). There are two need-key
48
+ * methods, differing only in how the first token is obtained: `bootstrap-invite`
49
+ * (BAPI-606) redeems a pre-issued invite; `self-serve` (BAPI-618) mints one from an
50
+ * email. Both then feed the SAME redemption protocol. Selection is pure and
51
+ * deterministic from flags/env, except that a BARE interactive run is asked which
52
+ * branch it wants (BAPI-626) — without that question a first-time user cannot
53
+ * discover self-serve at all. Either need-key method NAMES a new project, so the
54
+ * repository prompt asks for a new project name rather than an existing
55
+ * registration (see `RepoNamePromptMode`).
56
+ *
57
+ * BOOTSTRAP-INVITE MODE (BAPI-606) — one of the two exceptions to "this command
58
+ * consumes a key, it does not create one". With `--invite` (or `BAPI_INVITE`) there is no
33
59
  * API key yet, so the pre-flight ping of Step 2 CANNOT be made: the exchange is
34
60
  * what mints the key, and it REPLACES that ping. The order becomes:
35
61
  *
@@ -63,9 +89,13 @@ import { runInit, buildBridgeApiEntry } from "./init.js";
63
89
  import { VERSION } from "./version.generated.js";
64
90
  import { validateRepoName } from "./bridge-config.js";
65
91
  import { resolveStartTicketsRepoName } from "./start-tickets-repo.js";
66
- import { upsertBapiCredential, getPrimaryCredentialStorePath, prepareBootstrapPendingCredential, repointBootstrapPendingCredential, promoteBootstrapPendingCredential, } from "./credential-store.js";
92
+ import { upsertBapiCredential, getPrimaryCredentialStorePath, prepareBootstrapPendingCredential, repointBootstrapPendingCredential, promoteBootstrapPendingCredential, resolveBapiCredentials, } from "./credential-store.js";
93
+ // BAPI-631: the optional GitHub connect offer reuses the standalone command's flow and
94
+ // API primitives verbatim — no duplicated polling, browser, or picker logic here.
95
+ import { fetchGithubConfigurationState } from "./connect-github-api.js";
96
+ import { createDefaultConnectGithubDeps, runGithubConnectionFlow } from "./connect-github.js";
67
97
  import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, formatValidAgentNames, } from "./agent-registry.js";
68
- import { buildGenericAgentShellCommand, getDefaultSpawnTerminalTabForPlatform, detectTerminal, createDefaultStartTicketsDeps, } from "./start-tickets.js";
98
+ import { buildGenericAgentShellCommand, getDefaultSpawnTerminalTabForPlatform, detectTerminal, createDefaultStartTicketsDeps, materializeWorkerLaunchCommand, MAX_TERMINAL_COMMAND_BYTES, } from "./start-tickets.js";
69
99
  /** Redaction sentinel — the API-key value is NEVER printed; this stands in. */
70
100
  export const REDACTED_API_KEY = "<REDACTED>";
71
101
  /**
@@ -85,15 +115,46 @@ export function buildPrewarmArgs() {
85
115
  export function buildPrewarmCommandPreview() {
86
116
  return `npx ${buildPrewarmArgs().join(" ")}`;
87
117
  }
88
- /** The natural-language sequential prompt handed to the spawned agent session. */
89
- export const INSTALL_BRIDGE_AGENT_PROMPT = "Please execute the /install-bridge command. Once it completes successfully, execute the " +
90
- "/learn-repository command. After /learn-repository completes, ask the user for consent to start " +
91
- "repository indexing; with consent, call the parse_repository MCP tool once and report that the " +
92
- "job was queued (progress is checked with get_parse_status — do not poll it to completion). " +
93
- "When you are done, end with an explicit summary line stating how " +
94
- "many config fields the apply_install_manifest call applied (e.g. 'Applied 8 of 9 derived " +
95
- "fields') and whether indexing was queued — if 0 fields were applied, say so loudly and explain " +
96
- "what is still pending.";
118
+ /**
119
+ * The natural-language prompt handed to the spawned agent session. It is
120
+ * CONFIGURE-ONLY: it derives and applies configuration, presents the capability
121
+ * report, and ends with exactly one indexing-consent question. It never chains
122
+ * /learn-repository and never indexes before explicit consent.
123
+ *
124
+ * The index-consent beat is AGENT-OWNED (asked inside this spawned session), not
125
+ * parent-CLI-owned, because `spawnTerminalTab()` opens an asynchronous terminal
126
+ * session: the parent CLI returns immediately and cannot reliably ask a question
127
+ * that must appear AFTER the spawned session's capability report. Asking it via
128
+ * the parent's `deps.promptLine` would display the question before the report
129
+ * even exists. `deps.promptLine` therefore stays the owner of the parent-CLI
130
+ * prompts (repository / overwrite / invite confirmation) and is intentionally
131
+ * NOT used for this post-report question.
132
+ */
133
+ export const INSTALL_BRIDGE_AGENT_PROMPT = "Execute the /install-bridge command in the install-spawn context (tell the command it is running " +
134
+ "in the install-spawn context so it SKIPS its Stage 8 and Stage 9 offers — this session's only " +
135
+ "closing interaction is the single indexing question below). Do NOT run /learn-repository. Do NOT " +
136
+ "call parse_repository (or otherwise start indexing) before the capability report and explicit " +
137
+ "consent below. " +
138
+ "Complete the command's read-after-write five-section capability report first: 'Connected ✓', " +
139
+ "'Not yet connected ✗', 'Tools you can use now', 'Tools you'll unlock', and 'Recommended next " +
140
+ "step + why'. " +
141
+ "Only AFTER that report is fully presented, ask exactly one question using this visible prompt: " +
142
+ "'[Y/n] Index repository now?'. Only an explicit affirmative answer (e.g. 'y'/'yes') starts " +
143
+ "indexing; a blank answer, a negative answer, EOF, an unavailable interaction, and any " +
144
+ "non-interactive/headless run all resolve to NO. " +
145
+ "On an affirmative answer: call the parse_repository MCP tool exactly once, describe the accepted " +
146
+ "job as QUEUED, and direct later progress checks to get_parse_status or /check-parse-status " +
147
+ "(do NOT poll it to completion). If parse_repository returns a blocking refusal or error, do NOT " +
148
+ "claim the job was queued — report the sanitized result and leave indexing pending. " +
149
+ "On NO (or any unavailable/non-interactive resolution): do not index; print the exact copy-paste " +
150
+ "continuation command '/parse-repository' on its own line and state that indexing remains pending. " +
151
+ "Never request, echo, or transport any credential — only ever direct the human to that " +
152
+ "integration's own configure_in pointer, verbatim. The pointer is per-integration and is NOT " +
153
+ "always the setup UI: GitHub's is a terminal command (connect-github), while Jira, SFCC, and " +
154
+ "Bitbucket point at the setup UI. Follow whatever the report says rather than assuming. " +
155
+ "End with an explicit summary line stating how many config fields the apply_install_manifest call " +
156
+ "applied (e.g. 'Applied 8 of 9 derived fields') and whether indexing was queued or left pending — " +
157
+ "if 0 fields were applied, say so loudly and explain what is still pending.";
97
158
  /** Default base URL when `BAPI_BASE_URL` is unset (mirrors index.ts). */
98
159
  export const DEFAULT_BAPI_BASE_URL = "https://bridgegpt-api.com";
99
160
  /** Default docs dir when `BAPI_DOCS_DIR` is unset (mirrors index.ts). */
@@ -107,27 +168,56 @@ export function getInstallBridgeUsage() {
107
168
  "One-command Bridge API project bootstrap. Scaffolds the project, writes the",
108
169
  "per-host MCP config with your credentials, verifies connectivity, persists the",
109
170
  "routing credential, then opens a fresh agent session to derive the remaining",
110
- "config and run /learn-repository.",
171
+ "config, present a capability report, and offer optional repository indexing.",
172
+ "",
173
+ "Run it bare — `install-bridge` with no flags — in a terminal and it asks",
174
+ `\`${INSTALL_BRIDGE_KEY_SELECTOR_PROMPT.trim()}\` first. Answer yes (or press Enter) for the`,
175
+ "existing-key flow below; answer no and it asks for an email and creates a new",
176
+ "Bridge workspace for you (the self-serve flow). That question is asked ONLY for a",
177
+ "bare interactive run: passing ANY flag, setting BAPI_API_KEY, or running without",
178
+ "an interactive terminal keeps the existing deterministic behavior and no prompt.",
111
179
  "",
112
180
  "Inputs (the only two irreducible ones):",
113
181
  " --api-key <key> Bridge API key. Falls back to the BAPI_API_KEY env var,",
114
182
  " then an interactive (no-echo) prompt. Generate one in the",
115
183
  " Bridge API web UI Security page — this command consumes a",
116
- " key, it does not create one (--invite is the one exception:",
117
- " it CREATES the project and its first admin key). NEVER",
118
- " printed or logged.",
119
- " --repo <name> Repository name. Falls back to BAPI_REPO_NAME, then an",
120
- " inferred default you confirm interactively. MUST match the",
121
- " server-side repo registration (it keys the credential",
122
- " store as bapi:<repo>). Required (no inference) when stdin",
123
- " is non-interactive. With --invite it is the name your NEW",
124
- " project is created under (globally unique).",
184
+ " key, it does not create one (--email and --invite are the",
185
+ " exceptions: they CREATE the project and its first admin key).",
186
+ " NEVER printed or logged.",
187
+ " --repo <name> Repository name. --repo and BAPI_REPO_NAME still take",
188
+ " priority and short-circuit before any network call. When",
189
+ " neither is set, a compatible server resolves the unique",
190
+ " repository from your existing API key automatically; if the",
191
+ " server is older, the key is unresolvable, or resolution",
192
+ " fails, it falls back to an inferred default you confirm",
193
+ " interactively (and to a required --repo when stdin is",
194
+ " non-interactive). In the existing-key flow it MUST match the",
195
+ " server-side repo registration (it keys the credential store",
196
+ " as bapi:<repo>). In either new-project flow (--email,",
197
+ " --invite, or a negative answer to the key question above) it",
198
+ " instead NAMES the project this run creates, so you are asked",
199
+ " to name a new project rather than match an existing one; the",
200
+ " name must be globally unique.",
201
+ "",
202
+ "Self-serve onboarding (no account, no API key, no pre-issued invite):",
203
+ " --email <addr> Create a brand-new Bridge workspace from just an email —",
204
+ " the primary path for a first-time user with nothing yet.",
205
+ " It requests a fresh workspace for that email, then creates",
206
+ " the project and mints your own admin API key in one command.",
207
+ " Falls back to the BAPI_SIGNUP_EMAIL env var, then a visible",
208
+ " interactive prompt — which is also what a negative answer to",
209
+ " the bare-run key question above reaches. The email is NOT a",
210
+ " secret (it is shown as you type), but it is never printed to",
211
+ " a log. Mutually",
212
+ " exclusive with --api-key and --invite. No email verification",
213
+ " is performed and no message is sent to the address — it only",
214
+ " labels the new workspace.",
125
215
  "",
126
216
  "Bootstrap-invite onboarding (no web UI, no pre-existing key):",
127
- " --invite [token] Redeem a bootstrap invite: creates the project and mints",
128
- " your own admin API key in one command. Mutually exclusive",
129
- " with --api-key (in this mode the key is created, not",
130
- " consumed).",
217
+ " --invite [token] Redeem a bootstrap invite you were already given: creates",
218
+ " the project and mints your own admin API key in one command.",
219
+ " Mutually exclusive with --api-key and --email (in this mode",
220
+ " the key is created, not consumed).",
131
221
  "",
132
222
  " Run it WITHOUT a value — `install-bridge --invite` — and the",
133
223
  " token is read from an interactive prompt with echo",
@@ -157,8 +247,10 @@ export function getInstallBridgeUsage() {
157
247
  "",
158
248
  "Environment: BAPI_BASE_URL (default https://bridgegpt-api.com) and BAPI_DOCS_DIR",
159
249
  "(default docs/tmp) are read from the environment with the shown fallbacks.",
160
- "BAPI_INVITE supplies the bootstrap-invite token non-interactively (scripting",
161
- "only it is exposed to shell history; prefer the prompt).",
250
+ "BAPI_SIGNUP_EMAIL supplies the self-serve signup email non-interactively (it is",
251
+ "visible input, not a secret). BAPI_INVITE supplies the bootstrap-invite token",
252
+ "non-interactively (scripting only — it is exposed to shell history; prefer the",
253
+ "prompt).",
162
254
  ].join("\n");
163
255
  }
164
256
  /**
@@ -178,10 +270,12 @@ export function parseInstallBridgeArgs(argv) {
178
270
  let dryRun = false;
179
271
  let agentName = DEFAULT_AGENT_NAME;
180
272
  let invite;
273
+ let email;
181
274
  // Track SUPPLIED-ness separately from the values: `--invite` is legitimately
182
275
  // valueless (prompt path) and `--api-key ""` is still a contradiction with it.
183
276
  let inviteSupplied = false;
184
277
  let apiKeySupplied = false;
278
+ let emailSupplied = false;
185
279
  /** Read a `--flag value` or `--flag=value` value, advancing the index. */
186
280
  const readValue = (arg, flag, i) => {
187
281
  if (arg.startsWith(`${flag}=`)) {
@@ -228,6 +322,21 @@ export function parseInstallBridgeArgs(argv) {
228
322
  }
229
323
  continue;
230
324
  }
325
+ if (arg === "--email" || arg.startsWith("--email=")) {
326
+ // REQUIRED-value flag (unlike --invite): the email is what selects and drives
327
+ // the self-serve mint, so a bare or blank `--email` is a hard error rather
328
+ // than a prompt trigger. The value is NOT echoed in any error message.
329
+ const r = readValue(arg, "--email", i);
330
+ if ("error" in r)
331
+ return { status: "error", message: r.error };
332
+ if (r.value.trim().length === 0) {
333
+ return { status: "error", message: "--email requires a non-empty value." };
334
+ }
335
+ email = r.value.trim();
336
+ emailSupplied = true;
337
+ i = r.nextIndex;
338
+ continue;
339
+ }
231
340
  if (arg === "--repo" || arg.startsWith("--repo=")) {
232
341
  const r = readValue(arg, "--repo", i);
233
342
  if ("error" in r)
@@ -267,9 +376,26 @@ export function parseInstallBridgeArgs(argv) {
267
376
  "it does not consume an existing one.",
268
377
  };
269
378
  }
379
+ // --email selects self-serve mode, which CREATES the key — so it conflicts with
380
+ // both consuming an existing one (--api-key) and redeeming a pre-issued invite
381
+ // (--invite). Names the flags only — never the email or token values (BAPI-618).
382
+ if (emailSupplied && apiKeySupplied) {
383
+ return {
384
+ status: "error",
385
+ message: "--email and --api-key are mutually exclusive: self-serve signup creates your API key, " +
386
+ "it does not consume an existing one.",
387
+ };
388
+ }
389
+ if (emailSupplied && inviteSupplied) {
390
+ return {
391
+ status: "error",
392
+ message: "--email and --invite are mutually exclusive: use --email for self-serve signup (no " +
393
+ "pre-issued invite), or --invite to redeem an invite you already have.",
394
+ };
395
+ }
270
396
  return {
271
397
  status: "ok",
272
- options: { apiKey, repo, force, dryRun, agentName, invite, inviteMode: inviteSupplied },
398
+ options: { apiKey, repo, force, dryRun, agentName, invite, inviteMode: inviteSupplied, email },
273
399
  };
274
400
  }
275
401
  /**
@@ -330,6 +456,66 @@ export function promptSecretViaReadline(promptText, input = process.stdin, outpu
330
456
  muted = true;
331
457
  });
332
458
  }
459
+ /**
460
+ * Offer to connect GitHub, if it is not already connected (BAPI-631).
461
+ *
462
+ * Entirely best-effort and non-destructive: by the time this runs the install itself is
463
+ * already complete and durable, so nothing here may fail the run. Every branch that is
464
+ * not "the user said yes and it worked" simply proceeds to the agent session.
465
+ *
466
+ * Reuses the shared connect-github flow rather than duplicating the API, polling,
467
+ * browser, or picker logic — there is exactly one implementation of that handshake.
468
+ */
469
+ async function offerGithubConnection(repoName, deps, log) {
470
+ // No prompt surface → no offer. Never assume consent on a non-interactive run.
471
+ if (!deps.isTTY || !deps.promptLine)
472
+ return;
473
+ try {
474
+ const credDeps = {
475
+ env: deps.env,
476
+ homedir: deps.homedir,
477
+ platform: deps.platform,
478
+ readFile: deps.readFile,
479
+ stat: deps.stat,
480
+ stderr: () => { },
481
+ };
482
+ // Resolve through the shared resolver rather than reusing an in-memory key from
483
+ // this run: the offered flow is the same shell-spawned surface the standalone
484
+ // command uses, and it must resolve credentials the same way (project MCP config
485
+ // env is NOT visible to a spawned shell).
486
+ const cred = await resolveBapiCredentials(repoName, credDeps);
487
+ if (!cred.ok)
488
+ return;
489
+ const api = {
490
+ fetch: deps.fetch,
491
+ baseUrl: deps.env.BAPI_BASE_URL?.trim() || DEFAULT_BAPI_BASE_URL,
492
+ apiKey: cred.credentials.apiKey,
493
+ };
494
+ const state = await fetchGithubConfigurationState(api, repoName);
495
+ if (state === "configured")
496
+ return;
497
+ if (state === "unavailable") {
498
+ // Do NOT fabricate "unconfigured" from a probe that simply failed — offering to
499
+ // connect an already-connected repo is worse than staying quiet.
500
+ log(" note: could not read GitHub configuration status; skipping the GitHub offer.");
501
+ return;
502
+ }
503
+ const answer = (await deps.promptLine("Connect GitHub? (Y/n): ")).trim().toLowerCase();
504
+ if (answer === "n" || answer === "no")
505
+ return; // declining is a normal outcome
506
+ const connectDeps = createDefaultConnectGithubDeps();
507
+ const code = await runGithubConnectionFlow(connectDeps, api, repoName);
508
+ if (code !== 0) {
509
+ log(" note: GitHub was not connected. Your install is complete — connect GitHub later with " +
510
+ `'npx -y @bridge_gpt/mcp-server@latest connect-github --repo ${repoName}'.`);
511
+ }
512
+ }
513
+ catch {
514
+ // The install is already durable; a failure here is never fatal to it.
515
+ log(" note: the GitHub connection offer could not run. Your install is complete — connect " +
516
+ `GitHub later with 'npx -y @bridge_gpt/mcp-server@latest connect-github --repo ${repoName}'.`);
517
+ }
518
+ }
333
519
  /** Echoed single-line prompt on stderr (used for repo confirmation / value). */
334
520
  function promptLineViaReadline(promptText) {
335
521
  return new Promise((resolve) => {
@@ -358,13 +544,25 @@ function promptLineViaReadline(promptText) {
358
544
  * Always resolves (never rejects): a spawn error / non-zero exit / timeout becomes
359
545
  * `{ ok: false, warning }` with secret-free warning text.
360
546
  */
547
+ /**
548
+ * The env clone handed to the pre-warm child, with every credential/PII variable
549
+ * removed: `BAPI_API_KEY` and the bootstrap-invite token (`BAPI_INVITE`) are
550
+ * secrets that must never reach an npm lifecycle script, and the self-serve signup
551
+ * email (`BAPI_SIGNUP_EMAIL`, BAPI-618) is invitee PII with no business in a
552
+ * `--version` probe. Extracted as a pure, exported helper so the strip is directly
553
+ * unit-testable (the default spawner below uses the real `spawn`, which is not
554
+ * injectable).
555
+ */
556
+ export function sanitizePrewarmEnv(env) {
557
+ const sanitized = { ...env };
558
+ delete sanitized.BAPI_API_KEY;
559
+ delete sanitized.BAPI_INVITE;
560
+ delete sanitized.BAPI_SIGNUP_EMAIL;
561
+ return sanitized;
562
+ }
361
563
  function spawnPrewarmDefault(command, args, env) {
362
564
  return new Promise((resolve) => {
363
- const sanitizedEnv = { ...env };
364
- delete sanitizedEnv.BAPI_API_KEY;
365
- // The bootstrap-invite token is likewise never needed by a `--version` probe
366
- // and must not reach an npm lifecycle script.
367
- delete sanitizedEnv.BAPI_INVITE;
565
+ const sanitizedEnv = sanitizePrewarmEnv(env);
368
566
  try {
369
567
  const child = spawn(command, args, {
370
568
  shell: false,
@@ -393,6 +591,11 @@ function spawnPrewarmDefault(command, args, env) {
393
591
  /** Build default deps from the live process. */
394
592
  export function createDefaultInstallBridgeDeps() {
395
593
  const isTTY = Boolean(process.stdin.isTTY);
594
+ // One production fetch, reused for both the `fetch` seam and the default
595
+ // resolver, so a direct caller of this factory gets a resolver bound to the
596
+ // same client. (runInstallBridgeCli re-binds the resolver to the FINAL merged
597
+ // fetch when the caller did not override the resolver — see below.)
598
+ const productionFetch = (...args) => fetch(...args);
396
599
  return {
397
600
  env: process.env,
398
601
  cwd: process.cwd(),
@@ -417,7 +620,8 @@ export function createDefaultInstallBridgeDeps() {
417
620
  randomBytes: (size) => cryptoRandomBytes(size),
418
621
  promptSecret: isTTY ? promptSecretViaReadline : undefined,
419
622
  promptLine: isTTY ? promptLineViaReadline : undefined,
420
- fetch: (...args) => fetch(...args),
623
+ fetch: productionFetch,
624
+ resolveRepoViaServer: (baseUrl, apiKey) => resolveRepoViaServer(productionFetch, baseUrl, apiKey),
421
625
  spawnPrewarm: spawnPrewarmDefault,
422
626
  runInit,
423
627
  upsertCredential: upsertBapiCredential,
@@ -496,28 +700,174 @@ export async function resolveInviteToken(options, deps) {
496
700
  };
497
701
  }
498
702
  /**
499
- * Resolve the repo name: `--repo` → `BAPI_REPO_NAME` env → inferred default
500
- * (from .bridge/config, else the cwd basename) confirmed interactively. Fails
501
- * fast (no inference) when neither is supplied and stdin is non-interactive
502
- * the repo identity keys the credential store and must match server-side
503
- * registration, so it is never silently inferred non-interactively.
703
+ * Resolve the SELF-SERVE signup email: `--email <addr>` → `BAPI_SIGNUP_EMAIL` env →
704
+ * interactive VISIBLE prompt (BAPI-618). Fails (secret-free) when none is available
705
+ * and stdin is non-interactive.
706
+ *
707
+ * The precedence mirrors {@link resolveInviteToken}, but the input is NOT a secret:
708
+ * an email is not a credential, so it uses the ECHOED {@link InstallBridgeDeps.promptLine}
709
+ * — never `promptSecret`. (It IS invitee PII, so like every value in this module it
710
+ * is never written to a log line; it is only ever echoed as the user's own keystrokes
711
+ * and placed in the mint request body.)
712
+ *
713
+ * No email-format validation is performed here — only surrounding whitespace is
714
+ * trimmed, matching the other CLI input seams and the server's deliberately
715
+ * permissive "stored unvalidated" contract (D-2). The entered value is never echoed
716
+ * back in an error message.
504
717
  */
505
- export async function resolveRepoName(options, deps) {
506
- if (typeof options.repo === "string" && options.repo.trim().length > 0) {
507
- return { ok: true, value: options.repo.trim() };
718
+ export async function resolveSignupEmail(options, deps) {
719
+ if (typeof options.email === "string" && options.email.trim().length > 0) {
720
+ return { ok: true, value: options.email.trim() };
508
721
  }
509
- const fromEnv = deps.env.BAPI_REPO_NAME;
722
+ const fromEnv = deps.env.BAPI_SIGNUP_EMAIL;
510
723
  if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
511
724
  return { ok: true, value: fromEnv.trim() };
512
725
  }
726
+ if (deps.isTTY && deps.promptLine) {
727
+ const entered = (await deps.promptLine("Email for Bridge workspace setup: ")).trim();
728
+ if (entered.length > 0) {
729
+ return { ok: true, value: entered };
730
+ }
731
+ return { ok: false, error: "No email entered." };
732
+ }
733
+ return {
734
+ ok: false,
735
+ error: "An email is required to create a Bridge workspace. Pass --email <addr> or set the " +
736
+ "BAPI_SIGNUP_EMAIL environment variable (no interactive terminal is available to prompt " +
737
+ "for it).",
738
+ };
739
+ }
740
+ /**
741
+ * Select the onboarding branch as pure logic (deterministic, no I/O, no prompt):
742
+ *
743
+ * - `--invite` (with or without a value) or a non-empty `BAPI_INVITE` selects
744
+ * the `bootstrap-invite` need-key branch.
745
+ * - otherwise, `--email` or a non-empty `BAPI_SIGNUP_EMAIL` selects the
746
+ * `self-serve` need-key branch (BAPI-618).
747
+ * - every other invocation selects `have-key`.
748
+ *
749
+ * Precedence is explicit: an invite input wins over an email input (both are
750
+ * need-key, but a pre-issued invite is the more specific intent), and any
751
+ * need-key input wins over the have-key default. The parser already rejects
752
+ * `--email` alongside `--api-key`/`--invite`, so a flag-level conflict never
753
+ * reaches here; the precedence only disambiguates env-var combinations.
754
+ */
755
+ export function resolveInstallBridgeOnboardingBranch(options, env) {
756
+ const inviteMode = options.inviteMode === true || (env.BAPI_INVITE ?? "").trim().length > 0;
757
+ if (inviteMode)
758
+ return { kind: "need-key", method: "bootstrap-invite" };
759
+ const emailMode = (options.email ?? "").trim().length > 0 ||
760
+ (env.BAPI_SIGNUP_EMAIL ?? "").trim().length > 0;
761
+ if (emailMode)
762
+ return { kind: "need-key", method: "self-serve" };
763
+ return { kind: "have-key" };
764
+ }
765
+ /** The exact visible text of the bare-TTY onboarding selector (BAPI-626). */
766
+ export const INSTALL_BRIDGE_KEY_SELECTOR_PROMPT = "Do you have a Bridge API key? [Y/n] ";
767
+ /**
768
+ * Interactive wrapper around {@link resolveInstallBridgeOnboardingBranch}.
769
+ *
770
+ * The pure resolver defaults every un-signalled invocation to `have-key`, which is
771
+ * correct for a script but wrong for a human: a first-time user with nothing yet
772
+ * runs a bare `install-bridge`, gets the hidden API-key prompt, and has no way to
773
+ * discover that the self-serve email path exists. So a BARE INTERACTIVE run — and
774
+ * only that — is asked which branch it wants.
775
+ *
776
+ * "Bare" is deliberately strict. Any of these keeps the existing deterministic
777
+ * behaviour with NO prompt:
778
+ *
779
+ * - stdin is not a TTY, or no `promptLine` seam is available (scripts, CI);
780
+ * - any CLI argument was supplied (the user already stated an intent);
781
+ * - a non-blank `BAPI_API_KEY` is present (that IS the existing-key intent);
782
+ * - the pure resolver already chose a need-key branch explicitly.
783
+ *
784
+ * A prompt failure becomes a typed, secret-free failure rather than an exception:
785
+ * the caller turns it into an exit code, and terminal/internal error text never
786
+ * reaches the user.
787
+ */
788
+ export async function resolveInstallBridgeOnboardingBranchForRun(options, deps, argv) {
789
+ const branch = resolveInstallBridgeOnboardingBranch(options, deps.env);
790
+ // An explicit need-key intent is already unambiguous — never re-ask it.
791
+ if (branch.kind === "need-key")
792
+ return { ok: true, branch };
793
+ const hasEnvApiKey = (deps.env.BAPI_API_KEY ?? "").trim().length > 0;
794
+ const isBareInvocation = argv.length === 0;
795
+ if (!deps.isTTY || !deps.promptLine || !isBareInvocation || hasEnvApiKey) {
796
+ return { ok: true, branch };
797
+ }
798
+ const promptLine = deps.promptLine;
799
+ try {
800
+ // Bounded so a prompt seam that returns the same invalid value forever (a
801
+ // misbehaving pipe that passes the TTY check) cannot spin indefinitely.
802
+ for (let attempt = 0; attempt < 5; attempt += 1) {
803
+ const answer = (await promptLine(INSTALL_BRIDGE_KEY_SELECTOR_PROMPT)).trim().toLowerCase();
804
+ // Blank = accept the bracketed default (Y), matching the prompt's own contract.
805
+ if (answer.length === 0 || answer === "y" || answer === "yes") {
806
+ return { ok: true, branch: { kind: "have-key" } };
807
+ }
808
+ if (answer === "n" || answer === "no") {
809
+ return { ok: true, branch: { kind: "need-key", method: "self-serve" } };
810
+ }
811
+ deps.log("Please answer y or n (press Enter for yes).");
812
+ }
813
+ return {
814
+ ok: false,
815
+ error: "No valid answer to the Bridge API key question. Re-run and answer y or n.",
816
+ };
817
+ }
818
+ catch {
819
+ // Secret-free by construction: the caught value is never surfaced.
820
+ return {
821
+ ok: false,
822
+ error: "Could not read your answer from the terminal. Re-run with --api-key <key> if you have a " +
823
+ "Bridge API key, or --email <addr> to create a new Bridge workspace.",
824
+ };
825
+ }
826
+ }
827
+ /**
828
+ * Return the explicitly configured repository name (`--repo`, then
829
+ * `BAPI_REPO_NAME`), trimmed, or `undefined` when neither is supplied. Pure: no
830
+ * prompting, no inference, no I/O. `--repo` takes priority over the environment.
831
+ * This is the deterministic short-circuit that runs BEFORE any server resolution.
832
+ */
833
+ export function resolveConfiguredRepoName(options, env) {
834
+ if (typeof options.repo === "string" && options.repo.trim().length > 0) {
835
+ return options.repo.trim();
836
+ }
837
+ const fromEnv = env.BAPI_REPO_NAME;
838
+ if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
839
+ return fromEnv.trim();
840
+ }
841
+ return undefined;
842
+ }
843
+ /**
844
+ * Resolve the repo name: `--repo` → `BAPI_REPO_NAME` env → inferred default
845
+ * (from .bridge/config, else the cwd basename) confirmed interactively. Fails
846
+ * fast (no inference) when neither is supplied and stdin is non-interactive —
847
+ * the repo identity keys the credential store, so it is never silently inferred
848
+ * non-interactively.
849
+ *
850
+ * `mode` selects only the WORDING (prompt and non-interactive error); resolution
851
+ * order, inference, and validation are identical in both. It defaults to
852
+ * `existing-registration` to preserve the behaviour of pre-BAPI-626 callers.
853
+ */
854
+ export async function resolveRepoName(options, deps, mode = "existing-registration") {
855
+ const configured = resolveConfiguredRepoName(options, deps.env);
856
+ if (configured !== undefined) {
857
+ return { ok: true, value: configured };
858
+ }
513
859
  // Non-interactive: fail fast and require --repo. Inference is an interactive
514
860
  // convenience only (the user must confirm it).
515
861
  if (!deps.isTTY || !deps.promptLine) {
516
862
  return {
517
863
  ok: false,
518
- error: "A repo name is required. Pass --repo or set the BAPI_REPO_NAME environment variable " +
519
- "(no interactive terminal is available to confirm an inferred name). It must match the " +
520
- "server-side repository registration.",
864
+ error: mode === "new-project"
865
+ ? "A project name is required. Pass --repo or set the BAPI_REPO_NAME environment " +
866
+ "variable (no interactive terminal is available to confirm an inferred name). It " +
867
+ "names the new Bridge project this run creates and must be globally unique."
868
+ : "A repo name is required. Pass --repo or set the BAPI_REPO_NAME environment variable " +
869
+ "(no interactive terminal is available to confirm an inferred name). It must match the " +
870
+ "server-side repository registration.",
521
871
  };
522
872
  }
523
873
  // Infer a sensible default: existing .bridge/config, else the cwd basename.
@@ -532,13 +882,19 @@ export async function resolveRepoName(options, deps) {
532
882
  inferred = validated.value;
533
883
  }
534
884
  if (inferred) {
535
- const answer = (await deps.promptLine(`Repo name [${inferred}] (must match server-side registration): `)).trim();
885
+ const promptText = mode === "new-project"
886
+ ? `Name your new Bridge project [${inferred}]: `
887
+ : `Repo name [${inferred}] (must match server-side registration): `;
888
+ const answer = (await deps.promptLine(promptText)).trim();
536
889
  const chosen = answer.length > 0 ? answer : inferred;
537
890
  if (chosen.length > 0)
538
891
  return { ok: true, value: chosen };
539
892
  }
540
893
  else {
541
- const answer = (await deps.promptLine("Repo name (must match server-side registration): ")).trim();
894
+ const promptText = mode === "new-project"
895
+ ? "Name your new Bridge project: "
896
+ : "Repo name (must match server-side registration): ";
897
+ const answer = (await deps.promptLine(promptText)).trim();
542
898
  if (answer.length > 0)
543
899
  return { ok: true, value: answer };
544
900
  }
@@ -668,11 +1024,13 @@ export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
668
1024
  signal: AbortSignal.timeout(10_000),
669
1025
  });
670
1026
  }
671
- catch (err) {
672
- const msg = err instanceof Error ? err.message : String(err);
1027
+ catch {
1028
+ // The caught exception message is NOT interpolated: some fetch/undici errors
1029
+ // echo request detail (which carries the X-API-Key header) into the message,
1030
+ // so a fixed, secret-free string is used instead (BAPI-616).
673
1031
  return {
674
1032
  ok: false,
675
- message: `Could not reach the Bridge API at ${baseUrl} (${msg}). Check BAPI_BASE_URL and your network.`,
1033
+ message: `Could not reach the Bridge API at ${baseUrl}. Check BAPI_BASE_URL and your network.`,
676
1034
  };
677
1035
  }
678
1036
  if (resp.ok)
@@ -697,6 +1055,54 @@ export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
697
1055
  message: `Connectivity check failed (HTTP ${resp.status}). Verify your repo, API key, and BAPI_BASE_URL.`,
698
1056
  };
699
1057
  }
1058
+ /** The resolve endpoint: `<base>/setup/resolve-repo`. No query string — ever. */
1059
+ export function buildResolveRepoUrl(baseUrl) {
1060
+ return `${baseUrl.replace(/\/+$/, "")}/setup/resolve-repo`;
1061
+ }
1062
+ /**
1063
+ * Resolve the repository name server-side from an existing API key. The key
1064
+ * travels ONLY in the `X-API-Key` header (never the URL, never the body), bounded
1065
+ * by the same 10s timeout as {@link verifyConnectivity}.
1066
+ *
1067
+ * Status mapping is the whole compatibility story: `404` → `not-deployed` (old
1068
+ * server), `409` → `unresolved` (ambiguous/shared/client-scoped/legacy), every
1069
+ * other non-OK (`401`, `403`, `5xx`, unexpected) → `error`, and any
1070
+ * network/timeout/malformed-body failure → `error`. A caught exception or a
1071
+ * response body is NEVER read into the result.
1072
+ */
1073
+ export async function resolveRepoViaServer(fetchImpl, baseUrl, apiKey) {
1074
+ const url = buildResolveRepoUrl(baseUrl);
1075
+ let resp;
1076
+ try {
1077
+ resp = await fetchImpl(url, {
1078
+ headers: { "X-API-Key": apiKey },
1079
+ signal: AbortSignal.timeout(10_000),
1080
+ });
1081
+ }
1082
+ catch {
1083
+ // Network failure / timeout. The exception may echo the request (and thus the
1084
+ // X-API-Key header), so it is never inspected or propagated.
1085
+ return { status: "error" };
1086
+ }
1087
+ if (resp.status === 404)
1088
+ return { status: "not-deployed" };
1089
+ if (resp.status === 409)
1090
+ return { status: "unresolved" };
1091
+ if (!resp.ok)
1092
+ return { status: "error" };
1093
+ let body;
1094
+ try {
1095
+ body = await resp.json();
1096
+ }
1097
+ catch {
1098
+ return { status: "error" };
1099
+ }
1100
+ const repoName = body?.repo_name;
1101
+ const validated = validateRepoName(repoName);
1102
+ if (!validated.ok)
1103
+ return { status: "error" };
1104
+ return { status: "resolved", repoName: validated.value };
1105
+ }
700
1106
  // ---------------------------------------------------------------------------
701
1107
  // Bootstrap-invite exchange (BAPI-606) — replaces the pre-flight ping in invite mode
702
1108
  // ---------------------------------------------------------------------------
@@ -796,6 +1202,76 @@ export async function exchangeBootstrapInvite(deps, baseUrl, token, repoName, ke
796
1202
  message: `The bootstrap exchange failed (HTTP ${resp.status}). Verify BAPI_BASE_URL and try again.`,
797
1203
  };
798
1204
  }
1205
+ // ---------------------------------------------------------------------------
1206
+ // Self-serve mint (BAPI-618): email -> invite token
1207
+ // ---------------------------------------------------------------------------
1208
+ /**
1209
+ * The self-serve invite-token prefix, mirroring the server's
1210
+ * ``BOOTSTRAP_INVITE_TOKEN_PREFIX`` (``bapi_inv_``). A well-formed token
1211
+ * self-identifies with this prefix; a success response whose token does not is
1212
+ * treated as a `failed` mint rather than fed into the redemption path.
1213
+ */
1214
+ export const BOOTSTRAP_INVITE_TOKEN_PREFIX = "bapi_inv_";
1215
+ /** The self-serve mint endpoint: `<base>/setup/bootstrap/self-serve`. No query string — ever. */
1216
+ export function buildSelfServeMintUrl(baseUrl) {
1217
+ return `${baseUrl.replace(/\/+$/, "")}/setup/bootstrap/self-serve`;
1218
+ }
1219
+ /**
1220
+ * POST the self-serve mint: an email in, an invite token out. Mirrors
1221
+ * {@link exchangeBootstrapInvite}'s fetch/timeout/secret-free-result discipline.
1222
+ *
1223
+ * The email travels ONLY in the JSON request body (never a query string, never a
1224
+ * header, never a log line). Bounded by the same 10s timeout as the exchange and
1225
+ * the connectivity ping. On any non-success status the failure body is NOT read —
1226
+ * the status alone decides the category — because an untrusted upstream body could
1227
+ * echo the email or carry internals. A caught fetch exception is likewise never
1228
+ * inspected or interpolated; some fetch impls embed the request (and thus the
1229
+ * email) in the message.
1230
+ */
1231
+ export async function mintSelfServeInvite(deps, baseUrl, email) {
1232
+ const url = buildSelfServeMintUrl(baseUrl);
1233
+ let resp;
1234
+ try {
1235
+ resp = await deps.fetch(url, {
1236
+ method: "POST",
1237
+ headers: { "Content-Type": "application/json" },
1238
+ body: JSON.stringify({ invitee_email: email }),
1239
+ signal: AbortSignal.timeout(10_000),
1240
+ });
1241
+ }
1242
+ catch (err) {
1243
+ // The message may contain the request (some fetch impls echo it), so it is
1244
+ // deliberately NOT inspected or interpolated here.
1245
+ void err;
1246
+ return { ok: false, category: "failed" };
1247
+ }
1248
+ if (resp.ok) {
1249
+ let token;
1250
+ try {
1251
+ const body = (await resp.json());
1252
+ token = body?.token;
1253
+ }
1254
+ catch {
1255
+ // An unreadable success body is not actionable and must not be exposed.
1256
+ return { ok: false, category: "failed" };
1257
+ }
1258
+ if (typeof token !== "string" ||
1259
+ token.trim().length === 0 ||
1260
+ !token.startsWith(BOOTSTRAP_INVITE_TOKEN_PREFIX)) {
1261
+ return { ok: false, category: "failed" };
1262
+ }
1263
+ return { ok: true, token };
1264
+ }
1265
+ // Non-success: the status alone decides the category. The failure body is NEVER
1266
+ // read — it is untrusted and could echo the email or leak internals.
1267
+ if (resp.status === 429) {
1268
+ return { ok: false, category: "rate-limited" };
1269
+ }
1270
+ if (resp.status === 400 || resp.status === 422) {
1271
+ return { ok: false, category: "invalid" };
1272
+ }
1273
+ return { ok: false, category: "failed" };
1274
+ }
799
1275
  /**
800
1276
  * The 401 message for a run that had to GENERATE a fresh secret — i.e. no local
801
1277
  * pending record existed. Besides a simply-wrong token, this is exactly what a lost
@@ -844,8 +1320,10 @@ export function buildDryRunPreview(plan) {
844
1320
  if (plan.bootstrapInvite)
845
1321
  return buildBootstrapDryRunPreview(plan);
846
1322
  return [
847
- "install-bridge --dry-run (no writes, no network, no spawns)",
848
- `Repo name: ${plan.repoName}`,
1323
+ plan.attemptedServerResolution
1324
+ ? "install-bridge --dry-run (one read-only repository-resolution GET may already have occurred; no writes, no state-changing requests, no spawns)"
1325
+ : "install-bridge --dry-run (no writes, no network, no spawns)",
1326
+ `Repo name: ${plan.repoName}${plan.attemptedServerResolution ? " (resolved server-side from your API key)" : ""}`,
849
1327
  `Base URL (ping): ${plan.baseUrl}`,
850
1328
  `Docs dir: ${plan.docsDir}`,
851
1329
  `Agent: ${plan.agentName}`,
@@ -860,7 +1338,29 @@ export function buildDryRunPreview(plan) {
860
1338
  `Step 3b — pre-warm the version-pinned launcher bucket (fail-open, env sanitized — BAPI_API_KEY removed): ${plan.prewarmCommand}`,
861
1339
  MCP_TIMEOUT_GUIDANCE,
862
1340
  `Step 4 — persist routing credential: target ${plan.credentialTarget} at ${plan.credentialStorePath}`,
863
- `Step 5 — spawn agent session: ${plan.spawnCommand}`,
1341
+ ...buildLaunchStepPreview(plan),
1342
+ ];
1343
+ }
1344
+ /**
1345
+ * The Step 5 preview lines, shared by both previews so the launch description
1346
+ * cannot drift between the have-key and need-key flows.
1347
+ *
1348
+ * A --dry-run never writes the launch script, so the preview describes the
1349
+ * materialization rather than performing it — but it still shows the full command,
1350
+ * because the command is what the user is previewing and it is secret-free.
1351
+ */
1352
+ function buildLaunchStepPreview(plan) {
1353
+ return [
1354
+ // BAPI-631: described, never performed in --dry-run — a preview must not open a
1355
+ // browser or reach the network. It is also strictly optional, so it carries no step
1356
+ // number of its own and never changes the 5-step count.
1357
+ "Step 4b — optional GitHub connect (SKIPPED in --dry-run): read GitHub's configured state",
1358
+ " via the install manifest and, only when it is unconfigured and the terminal is",
1359
+ " interactive, offer 'Connect GitHub? (Y/n)' before the agent session starts.",
1360
+ "Step 5 — spawn agent session: the full command below is stored in a restricted launch script",
1361
+ " (mode 0600, under the system temp dir) and only a short sourced runner is spawned",
1362
+ " (the script itself is NOT written in --dry-run):",
1363
+ ` ${plan.spawnCommand}`,
864
1364
  ];
865
1365
  }
866
1366
  /**
@@ -872,14 +1372,35 @@ export function buildDryRunPreview(plan) {
872
1372
  */
873
1373
  function buildBootstrapDryRunPreview(plan) {
874
1374
  const pendingTarget = `bootstrap-pending:${plan.repoName}`;
1375
+ // Self-serve signup (BAPI-618): the ONLY structural difference from a pre-issued
1376
+ // invite is a mint-from-email step (Step 2·pre) that turns an email into the
1377
+ // invite token before the unchanged redemption below. The preview states plainly
1378
+ // that in --dry-run that signup/mint is previewed-and-skipped: no account is
1379
+ // created, no mint request is sent, no email leaves the machine. The email and
1380
+ // any synthetic token are deliberately absent from this output.
1381
+ const header = plan.selfServeSignup
1382
+ ? "install-bridge --email --dry-run (no writes, no network, no spawns, no account created, no secret generated)"
1383
+ : "install-bridge --invite --dry-run (no writes, no network, no spawns, no secret generated)";
1384
+ const repoLine = plan.selfServeSignup
1385
+ ? `Repo name: ${plan.repoName} (created by the self-serve exchange; globally unique)`
1386
+ : `Repo name: ${plan.repoName} (created by the exchange; globally unique)`;
1387
+ const selfServeStep = plan.selfServeSignup
1388
+ ? [
1389
+ "Step 2·pre — self-serve signup (PREVIEWED, SKIPPED in --dry-run): no Bridge workspace",
1390
+ " signup is requested, no mint call is made, and no email is sent or transmitted;",
1391
+ " a real run would request a fresh workspace for your email and receive an invite",
1392
+ " token, which then feeds the SAME redemption protocol below.",
1393
+ ]
1394
+ : [];
875
1395
  return [
876
- "install-bridge --invite --dry-run (no writes, no network, no spawns, no secret generated)",
877
- `Repo name: ${plan.repoName} (created by the exchange; globally unique)`,
1396
+ header,
1397
+ repoLine,
878
1398
  `Base URL: ${plan.baseUrl}`,
879
1399
  `Docs dir: ${plan.docsDir}`,
880
1400
  `Agent: ${plan.agentName}`,
881
1401
  "",
882
1402
  "Step 1 — scaffold (runInit): commands, agents, pipelines, .bridge/config, secret-free MCP placeholders.",
1403
+ ...selfServeStep,
883
1404
  `Step 2a — generate key_secret (32 CSPRNG bytes) and fsync it to ${pendingTarget} at ${plan.credentialStorePath}`,
884
1405
  " BEFORE the exchange. If that write fails the run ABORTS and no invite is spent.",
885
1406
  `Step 2b — redeem the bootstrap invite (replaces the pre-flight ping — there is no key yet):`,
@@ -894,7 +1415,7 @@ function buildBootstrapDryRunPreview(plan) {
894
1415
  `Step 3b — pre-warm the version-pinned launcher bucket (fail-open, env sanitized — BAPI_API_KEY / BAPI_INVITE removed): ${plan.prewarmCommand}`,
895
1416
  MCP_TIMEOUT_GUIDANCE,
896
1417
  `Step 4 — promote ${pendingTarget} → ${plan.credentialTarget} at ${plan.credentialStorePath} (only after the exchange succeeds)`,
897
- `Step 5 — spawn agent session: ${plan.spawnCommand}`,
1418
+ ...buildLaunchStepPreview(plan),
898
1419
  ];
899
1420
  }
900
1421
  /**
@@ -962,6 +1483,12 @@ function buildManualHostInstructions(entry, editors) {
962
1483
  */
963
1484
  export async function runInstallBridgeCli(argv, overrides = {}) {
964
1485
  const deps = { ...createDefaultInstallBridgeDeps(), ...overrides };
1486
+ // When the caller overrode only `fetch` (the common test seam), bind the
1487
+ // default resolver to the FINAL merged fetch so server resolution still routes
1488
+ // through the override. An explicit `resolveRepoViaServer` override is honored.
1489
+ if (!overrides.resolveRepoViaServer) {
1490
+ deps.resolveRepoViaServer = (baseUrl, apiKey) => resolveRepoViaServer(deps.fetch, baseUrl, apiKey);
1491
+ }
965
1492
  const { log, errorLog } = deps;
966
1493
  const parsed = parseInstallBridgeArgs(argv);
967
1494
  if (parsed.status === "help") {
@@ -975,18 +1502,42 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
975
1502
  return 1;
976
1503
  }
977
1504
  const options = parsed.options;
978
- // Bootstrap-invite mode: `--invite` (with or without a value), or a non-empty
979
- // BAPI_INVITE. Everything below branches on this ONE flag; an invocation without
980
- // it is the unchanged consume-a-key flow.
981
- const bootstrapInviteMode = options.inviteMode === true || (deps.env.BAPI_INVITE ?? "").trim().length > 0;
1505
+ // Onboarding branch: have-key vs. a need-key method (`bootstrap-invite` = redeem
1506
+ // a pre-issued invite, `self-serve` = mint one from an email, BAPI-618). BOTH
1507
+ // need-key methods share the downstream redemption protocol, so
1508
+ // `bootstrapInviteMode` is true for both; `selfServeSignupMode` discriminates the
1509
+ // one extra step (mint-from-email) the self-serve path adds.
1510
+ //
1511
+ // The selection is pure and deterministic for every explicit, env-driven, and
1512
+ // non-TTY invocation; only a BARE interactive run is asked which branch it wants
1513
+ // (BAPI-626 — otherwise a first-time user can never reach self-serve).
1514
+ const branchResult = await resolveInstallBridgeOnboardingBranchForRun(options, deps, argv);
1515
+ if (!branchResult.ok) {
1516
+ errorLog(`Error: ${branchResult.error}`);
1517
+ return 1;
1518
+ }
1519
+ const branch = branchResult.branch;
1520
+ const bootstrapInviteMode = branch.kind === "need-key";
1521
+ const selfServeSignupMode = branch.kind === "need-key" && branch.method === "self-serve";
982
1522
  // ---- Resolve inputs (may prompt when interactive) ----
983
- // Resolve the credential input first so a fully-empty non-interactive invocation
984
- // fails with the (more security-relevant) missing-secret message before the repo
985
- // one. In bootstrap-invite mode there is no API key to resolve — the exchange
986
- // MINTS it so resolveApiKey is skipped entirely.
1523
+ // Resolve the credential/entry input first so a fully-empty non-interactive
1524
+ // invocation fails with the (more relevant) missing-input message before the repo
1525
+ // one. In either need-key mode there is no API key to resolve — the exchange
1526
+ // MINTS it. Self-serve resolves the email here (up-front, before the dry-run
1527
+ // guard) so a missing email fails fast; the actual MINT happens later, strictly
1528
+ // after the dry-run return, so a preview neither mints nor sends anything.
987
1529
  let apiKey = "";
988
1530
  let inviteToken = "";
989
- if (bootstrapInviteMode) {
1531
+ let signupEmail = "";
1532
+ if (selfServeSignupMode) {
1533
+ const emailResult = await resolveSignupEmail(options, deps);
1534
+ if (!emailResult.ok) {
1535
+ errorLog(`Error: ${emailResult.error}`);
1536
+ return 1;
1537
+ }
1538
+ signupEmail = emailResult.value;
1539
+ }
1540
+ else if (bootstrapInviteMode) {
990
1541
  const inviteResult = await resolveInviteToken(options, deps);
991
1542
  if (!inviteResult.ok) {
992
1543
  errorLog(`Error: ${inviteResult.error}`);
@@ -1002,25 +1553,61 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
1002
1553
  }
1003
1554
  apiKey = keyResult.value;
1004
1555
  }
1005
- const repoResult = await resolveRepoName(options, deps);
1006
- if (!repoResult.ok) {
1007
- errorLog(`Error: ${repoResult.error}`);
1008
- return 1;
1009
- }
1010
- let repoName = repoResult.value;
1011
- // Invite mode creates the project, so the name must be valid BEFORE any pending
1012
- // state or exchange. (Normal mode's name is already registered server-side, so
1013
- // its behavior is deliberately left untouched.)
1556
+ // baseUrl is resolved BEFORE repository input because the have-key branch needs
1557
+ // it for the server-resolution request.
1558
+ const baseUrl = deps.env.BAPI_BASE_URL ?? DEFAULT_BAPI_BASE_URL;
1559
+ const docsDir = deps.env.BAPI_DOCS_DIR ?? DEFAULT_BAPI_DOCS_DIR;
1560
+ // ---- Resolve the repository name ----
1561
+ // Need-key (invite or self-serve): choose-a-name for the project this run is
1562
+ // about to create, so the prompt says exactly that. Have-key:
1563
+ // `--repo`/`BAPI_REPO_NAME` short-circuit deterministically; otherwise resolve
1564
+ // it server-side from the API key (BAPI-616), and on ANY non-resolution outcome
1565
+ // (unresolved / not-deployed / error) fall back to the existing local
1566
+ // prompt/inference — never a hard failure. That fallback keeps the
1567
+ // existing-registration wording: there the name must match a real project.
1568
+ let repoName;
1569
+ let attemptedServerResolution = false;
1014
1570
  if (bootstrapInviteMode) {
1015
- const validated = validateRepoName(repoName);
1571
+ const repoResult = await resolveRepoName(options, deps, "new-project");
1572
+ if (!repoResult.ok) {
1573
+ errorLog(`Error: ${repoResult.error}`);
1574
+ return 1;
1575
+ }
1576
+ // Invite mode creates the project, so the name must be valid BEFORE any
1577
+ // pending state or exchange.
1578
+ const validated = validateRepoName(repoResult.value);
1016
1579
  if (!validated.ok) {
1017
1580
  errorLog(`Error: invalid repo name — ${validated.error}.`);
1018
1581
  return 1;
1019
1582
  }
1020
1583
  repoName = validated.value;
1021
1584
  }
1022
- const baseUrl = deps.env.BAPI_BASE_URL ?? DEFAULT_BAPI_BASE_URL;
1023
- const docsDir = deps.env.BAPI_DOCS_DIR ?? DEFAULT_BAPI_DOCS_DIR;
1585
+ else {
1586
+ const configured = resolveConfiguredRepoName(options, deps.env);
1587
+ if (configured !== undefined) {
1588
+ // Deterministic short-circuit: no server round-trip when the name is known.
1589
+ repoName = configured;
1590
+ }
1591
+ else {
1592
+ attemptedServerResolution = true;
1593
+ log("Resolving repository…");
1594
+ const resolution = await deps.resolveRepoViaServer(baseUrl, apiKey);
1595
+ if (resolution.status === "resolved") {
1596
+ repoName = resolution.repoName;
1597
+ }
1598
+ else {
1599
+ // Feature-detect + degrade: 404 (old server), 409 (unresolved/ambiguous/
1600
+ // client-scoped), and network/other errors all fall back to the existing
1601
+ // local resolution WITHOUT a cause-specific message or leaked detail.
1602
+ const repoResult = await resolveRepoName(options, deps, "existing-registration");
1603
+ if (!repoResult.ok) {
1604
+ errorLog(`Error: ${repoResult.error}`);
1605
+ return 1;
1606
+ }
1607
+ repoName = repoResult.value;
1608
+ }
1609
+ }
1610
+ }
1024
1611
  const agent = resolveAgentSpec(options.agentName) ?? resolveAgentSpec(DEFAULT_AGENT_NAME);
1025
1612
  const spawnCommand = deps.buildShellCommand(agent, INSTALL_BRIDGE_AGENT_PROMPT, deps.cwd, deps.platform);
1026
1613
  const credentialStorePath = getPrimaryCredentialStorePath({
@@ -1045,6 +1632,8 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
1045
1632
  ...(bootstrapInviteMode
1046
1633
  ? { bootstrapInvite: true, exchangeUrl: buildBootstrapExchangeUrl(baseUrl) }
1047
1634
  : {}),
1635
+ ...(selfServeSignupMode ? { selfServeSignup: true } : {}),
1636
+ ...(attemptedServerResolution ? { attemptedServerResolution: true } : {}),
1048
1637
  };
1049
1638
  // ---- --dry-run: preview every step, strictly no side effects ----
1050
1639
  // Positioned BEFORE the CSPRNG, the pending write, the exchange, the scaffold,
@@ -1055,6 +1644,31 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
1055
1644
  log(line);
1056
1645
  return 0;
1057
1646
  }
1647
+ // ---- Materialize the Step 5 launch command BEFORE any install side effect ----
1648
+ // `spawnCommand` embeds the whole INSTALL_BRIDGE_AGENT_PROMPT and is multiple KB
1649
+ // — far past what osascript can type into a Terminal/iTerm tab, which is why the
1650
+ // spawn silently delivered a truncated line. It must travel via a launch script.
1651
+ //
1652
+ // This runs HERE, before the scaffold/mint/credential/config writes, because a
1653
+ // command that cannot be launched safely is a whole-run failure, not a Step 5
1654
+ // warning: the deterministic setup would otherwise complete and leave the user
1655
+ // in the "configured but never configured by the agent" state the warning below
1656
+ // explicitly calls out. Failing first means nothing is half-done.
1657
+ const materialized = await materializeWorkerLaunchCommand(deps.startTicketsDeps, "install", spawnCommand);
1658
+ if (!materialized.ok) {
1659
+ errorLog(`Error: ${materialized.error}`);
1660
+ return 1;
1661
+ }
1662
+ const launchCommand = materialized.command;
1663
+ // Independent final guard on the line ACTUALLY handed to the terminal — not on
1664
+ // the original command. It catches the two ways a "successful" materialization
1665
+ // can still be unlaunchable: no writer seam (inline command preserved verbatim),
1666
+ // and a runner whose script path is unexpectedly long.
1667
+ if (Buffer.byteLength(launchCommand, "utf8") >= MAX_TERMINAL_COMMAND_BYTES) {
1668
+ errorLog("Error: the agent session command is too long to send to the terminal safely. " +
1669
+ "Check that the system temporary directory is writable so the launch script can be used.");
1670
+ return 1;
1671
+ }
1058
1672
  const credentialWriteDeps = {
1059
1673
  env: deps.env,
1060
1674
  homedir: deps.homedir,
@@ -1091,6 +1705,32 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
1091
1705
  // ---- Step 2 — mint (invite mode) or verify (normal mode) the credential ----
1092
1706
  let inviteFingerprint = "";
1093
1707
  if (bootstrapInviteMode) {
1708
+ // ---- Self-serve signup (BAPI-618): mint the invite token from the email ----
1709
+ // This is the ONE step that precedes the unchanged BAPI-606 redemption below.
1710
+ // It sits AFTER the --dry-run return above, so a preview never mints, never
1711
+ // sends the email, and never writes a pending secret. On success the minted
1712
+ // token is assigned to `inviteToken` and the flow falls into the existing
1713
+ // persist-before-exchange block VERBATIM — identical to a --invite token from
1714
+ // here on. On failure we return BEFORE any CSPRNG/pending write, so nothing is
1715
+ // persisted. The email and the token are never logged.
1716
+ if (selfServeSignupMode) {
1717
+ log("Step 2/5 — requesting Bridge self-serve setup…");
1718
+ const mint = await mintSelfServeInvite(deps, baseUrl, signupEmail);
1719
+ if (!mint.ok) {
1720
+ if (mint.category === "rate-limited") {
1721
+ errorLog("Error: Self-serve setup is temporarily rate limited. Try again later.");
1722
+ }
1723
+ else if (mint.category === "invalid") {
1724
+ errorLog("Error: Self-serve setup could not be requested. Check the email value and try again.");
1725
+ }
1726
+ else {
1727
+ errorLog("Error: Unable to complete self-serve setup. Check connectivity and retry.");
1728
+ }
1729
+ return 1;
1730
+ }
1731
+ // Treated identically to a manually supplied invite token from here on.
1732
+ inviteToken = mint.token;
1733
+ }
1094
1734
  // ===================================================================
1095
1735
  // LOAD-BEARING ORDERING — NOT DEFENSIVE POLISH. DO NOT "TIDY" THIS.
1096
1736
  //
@@ -1318,10 +1958,17 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
1318
1958
  "'npx -y @bridge_gpt/mcp-server doctor'.");
1319
1959
  }
1320
1960
  }
1961
+ // ---- optional GitHub connect offer (BAPI-631) ----
1962
+ // Placed AFTER the credential is durable (the flow needs a resolvable key) and BEFORE
1963
+ // the agent spawn, for two reasons: the spawned session's capability report should
1964
+ // observe GitHub as configured if the user connects it here, and running it after the
1965
+ // spawn would put two prompts on the same terminal at once.
1966
+ await offerGithubConnection(repoName, deps, log);
1321
1967
  // ---- Step 5 — spawn a fresh agent session for the agentic remainder ----
1322
- log(`Step 5/5 — opening a ${agent.name} session for /install-bridge + /learn-repository…`);
1968
+ log(`Step 5/5 — opening a ${agent.name} session for /install-bridge configuration + capability report…`);
1323
1969
  const terminal = detectTerminal(undefined, deps.env);
1324
- const spawnResult = await deps.spawnTerminalTab(deps.startTicketsDeps, terminal, spawnCommand, {
1970
+ // Only the validated short runner reaches the terminal never the inline prompt.
1971
+ const spawnResult = await deps.spawnTerminalTab(deps.startTicketsDeps, terminal, launchCommand, {
1325
1972
  key: "install",
1326
1973
  worktreePath: deps.cwd,
1327
1974
  });
@@ -1332,16 +1979,17 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
1332
1979
  // user can run the agentic remainder by hand. Mirrors start-tickets treating a
1333
1980
  // spawn failure as non-fatal to the run.
1334
1981
  errorLog(`Warning: setup steps completed, but the agent session could not be opened (${spawnResult.error}). ` +
1335
- "The project is NOT configured yet — run /install-bridge then /learn-repository manually " +
1336
- "in this project to derive and apply the config fields.");
1982
+ "The project is NOT configured yet — run /install-bridge manually in this project to derive " +
1983
+ "and apply the config fields and see the capability report, then choose whether to run " +
1984
+ "/parse-repository to index the repository.");
1337
1985
  return 0;
1338
1986
  }
1339
1987
  log("");
1340
- log(`install-bridge setup steps complete. A fresh ${agent.name} session is now running ` +
1341
- "/install-bridge then /learn-repository.");
1988
+ log(`install-bridge setup steps complete. A fresh ${agent.name} session is now applying ` +
1989
+ "configuration, presenting the capability report, and ending with one indexing-consent question.");
1342
1990
  log("NOTE: the install is not finished until that session's apply reports applied fields — " +
1343
- "it will pause to ask you to approve the project description. Verify afterwards on the " +
1344
- "project's Get Started page (install status panel) or via the session's " +
1345
- "'Applied N of M' summary.");
1991
+ "it will pause to ask you to approve the project description, and it will close by asking " +
1992
+ "'[Y/n] Index repository now?'. Verify afterwards on the project's Get Started page " +
1993
+ "(install status panel) or via the session's 'Applied N of M' summary.");
1346
1994
  return 0;
1347
1995
  }