@bridge_gpt/mcp-server 0.2.23 → 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.
@@ -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,8 +28,10 @@
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.
@@ -85,15 +93,44 @@ export function buildPrewarmArgs() {
85
93
  export function buildPrewarmCommandPreview() {
86
94
  return `npx ${buildPrewarmArgs().join(" ")}`;
87
95
  }
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.";
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.";
97
134
  /** Default base URL when `BAPI_BASE_URL` is unset (mirrors index.ts). */
98
135
  export const DEFAULT_BAPI_BASE_URL = "https://bridgegpt-api.com";
99
136
  /** Default docs dir when `BAPI_DOCS_DIR` is unset (mirrors index.ts). */
@@ -107,7 +144,7 @@ export function getInstallBridgeUsage() {
107
144
  "One-command Bridge API project bootstrap. Scaffolds the project, writes the",
108
145
  "per-host MCP config with your credentials, verifies connectivity, persists the",
109
146
  "routing credential, then opens a fresh agent session to derive the remaining",
110
- "config and run /learn-repository.",
147
+ "config, present a capability report, and offer optional repository indexing.",
111
148
  "",
112
149
  "Inputs (the only two irreducible ones):",
113
150
  " --api-key <key> Bridge API key. Falls back to the BAPI_API_KEY env var,",
@@ -116,18 +153,35 @@ export function getInstallBridgeUsage() {
116
153
  " key, it does not create one (--invite is the one exception:",
117
154
  " it CREATES the project and its first admin key). NEVER",
118
155
  " 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).",
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.",
125
179
  "",
126
180
  "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).",
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).",
131
185
  "",
132
186
  " Run it WITHOUT a value — `install-bridge --invite` — and the",
133
187
  " token is read from an interactive prompt with echo",
@@ -157,8 +211,10 @@ export function getInstallBridgeUsage() {
157
211
  "",
158
212
  "Environment: BAPI_BASE_URL (default https://bridgegpt-api.com) and BAPI_DOCS_DIR",
159
213
  "(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).",
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).",
162
218
  ].join("\n");
163
219
  }
164
220
  /**
@@ -178,10 +234,12 @@ export function parseInstallBridgeArgs(argv) {
178
234
  let dryRun = false;
179
235
  let agentName = DEFAULT_AGENT_NAME;
180
236
  let invite;
237
+ let email;
181
238
  // Track SUPPLIED-ness separately from the values: `--invite` is legitimately
182
239
  // valueless (prompt path) and `--api-key ""` is still a contradiction with it.
183
240
  let inviteSupplied = false;
184
241
  let apiKeySupplied = false;
242
+ let emailSupplied = false;
185
243
  /** Read a `--flag value` or `--flag=value` value, advancing the index. */
186
244
  const readValue = (arg, flag, i) => {
187
245
  if (arg.startsWith(`${flag}=`)) {
@@ -228,6 +286,21 @@ export function parseInstallBridgeArgs(argv) {
228
286
  }
229
287
  continue;
230
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;
301
+ i = r.nextIndex;
302
+ continue;
303
+ }
231
304
  if (arg === "--repo" || arg.startsWith("--repo=")) {
232
305
  const r = readValue(arg, "--repo", i);
233
306
  if ("error" in r)
@@ -267,9 +340,26 @@ export function parseInstallBridgeArgs(argv) {
267
340
  "it does not consume an existing one.",
268
341
  };
269
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
+ }
270
360
  return {
271
361
  status: "ok",
272
- options: { apiKey, repo, force, dryRun, agentName, invite, inviteMode: inviteSupplied },
362
+ options: { apiKey, repo, force, dryRun, agentName, invite, inviteMode: inviteSupplied, email },
273
363
  };
274
364
  }
275
365
  /**
@@ -358,13 +448,25 @@ function promptLineViaReadline(promptText) {
358
448
  * Always resolves (never rejects): a spawn error / non-zero exit / timeout becomes
359
449
  * `{ ok: false, warning }` with secret-free warning text.
360
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
+ }
361
467
  function spawnPrewarmDefault(command, args, env) {
362
468
  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;
469
+ const sanitizedEnv = sanitizePrewarmEnv(env);
368
470
  try {
369
471
  const child = spawn(command, args, {
370
472
  shell: false,
@@ -393,6 +495,11 @@ function spawnPrewarmDefault(command, args, env) {
393
495
  /** Build default deps from the live process. */
394
496
  export function createDefaultInstallBridgeDeps() {
395
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);
396
503
  return {
397
504
  env: process.env,
398
505
  cwd: process.cwd(),
@@ -417,7 +524,8 @@ export function createDefaultInstallBridgeDeps() {
417
524
  randomBytes: (size) => cryptoRandomBytes(size),
418
525
  promptSecret: isTTY ? promptSecretViaReadline : undefined,
419
526
  promptLine: isTTY ? promptLineViaReadline : undefined,
420
- fetch: (...args) => fetch(...args),
527
+ fetch: productionFetch,
528
+ resolveRepoViaServer: (baseUrl, apiKey) => resolveRepoViaServer(productionFetch, baseUrl, apiKey),
421
529
  spawnPrewarm: spawnPrewarmDefault,
422
530
  runInit,
423
531
  upsertCredential: upsertBapiCredential,
@@ -496,20 +604,89 @@ export async function resolveInviteToken(options, deps) {
496
604
  };
497
605
  }
498
606
  /**
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.
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.
504
621
  */
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() };
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() };
508
625
  }
509
- const fromEnv = deps.env.BAPI_REPO_NAME;
626
+ const fromEnv = deps.env.BAPI_SIGNUP_EMAIL;
510
627
  if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
511
628
  return { ok: true, value: fromEnv.trim() };
512
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
+ }
513
690
  // Non-interactive: fail fast and require --repo. Inference is an interactive
514
691
  // convenience only (the user must confirm it).
515
692
  if (!deps.isTTY || !deps.promptLine) {
@@ -668,11 +845,13 @@ export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
668
845
  signal: AbortSignal.timeout(10_000),
669
846
  });
670
847
  }
671
- catch (err) {
672
- 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).
673
852
  return {
674
853
  ok: false,
675
- 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.`,
676
855
  };
677
856
  }
678
857
  if (resp.ok)
@@ -697,6 +876,54 @@ export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
697
876
  message: `Connectivity check failed (HTTP ${resp.status}). Verify your repo, API key, and BAPI_BASE_URL.`,
698
877
  };
699
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
+ }
883
+ /**
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
+ }
700
927
  // ---------------------------------------------------------------------------
701
928
  // Bootstrap-invite exchange (BAPI-606) — replaces the pre-flight ping in invite mode
702
929
  // ---------------------------------------------------------------------------
@@ -796,6 +1023,76 @@ export async function exchangeBootstrapInvite(deps, baseUrl, token, repoName, ke
796
1023
  message: `The bootstrap exchange failed (HTTP ${resp.status}). Verify BAPI_BASE_URL and try again.`,
797
1024
  };
798
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
+ }
799
1096
  /**
800
1097
  * The 401 message for a run that had to GENERATE a fresh secret — i.e. no local
801
1098
  * pending record existed. Besides a simply-wrong token, this is exactly what a lost
@@ -844,8 +1141,10 @@ export function buildDryRunPreview(plan) {
844
1141
  if (plan.bootstrapInvite)
845
1142
  return buildBootstrapDryRunPreview(plan);
846
1143
  return [
847
- "install-bridge --dry-run (no writes, no network, no spawns)",
848
- `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)" : ""}`,
849
1148
  `Base URL (ping): ${plan.baseUrl}`,
850
1149
  `Docs dir: ${plan.docsDir}`,
851
1150
  `Agent: ${plan.agentName}`,
@@ -872,14 +1171,35 @@ export function buildDryRunPreview(plan) {
872
1171
  */
873
1172
  function buildBootstrapDryRunPreview(plan) {
874
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
+ : [];
875
1194
  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)`,
1195
+ header,
1196
+ repoLine,
878
1197
  `Base URL: ${plan.baseUrl}`,
879
1198
  `Docs dir: ${plan.docsDir}`,
880
1199
  `Agent: ${plan.agentName}`,
881
1200
  "",
882
1201
  "Step 1 — scaffold (runInit): commands, agents, pipelines, .bridge/config, secret-free MCP placeholders.",
1202
+ ...selfServeStep,
883
1203
  `Step 2a — generate key_secret (32 CSPRNG bytes) and fsync it to ${pendingTarget} at ${plan.credentialStorePath}`,
884
1204
  " BEFORE the exchange. If that write fails the run ABORTS and no invite is spent.",
885
1205
  `Step 2b — redeem the bootstrap invite (replaces the pre-flight ping — there is no key yet):`,
@@ -962,6 +1282,12 @@ function buildManualHostInstructions(entry, editors) {
962
1282
  */
963
1283
  export async function runInstallBridgeCli(argv, overrides = {}) {
964
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
+ }
965
1291
  const { log, errorLog } = deps;
966
1292
  const parsed = parseInstallBridgeArgs(argv);
967
1293
  if (parsed.status === "help") {
@@ -975,18 +1301,33 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
975
1301
  return 1;
976
1302
  }
977
1303
  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;
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";
982
1312
  // ---- 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.
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.
987
1319
  let apiKey = "";
988
1320
  let inviteToken = "";
989
- if (bootstrapInviteMode) {
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;
1329
+ }
1330
+ else if (bootstrapInviteMode) {
990
1331
  const inviteResult = await resolveInviteToken(options, deps);
991
1332
  if (!inviteResult.ok) {
992
1333
  errorLog(`Error: ${inviteResult.error}`);
@@ -1002,25 +1343,59 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
1002
1343
  }
1003
1344
  apiKey = keyResult.value;
1004
1345
  }
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.)
1346
+ // baseUrl is resolved BEFORE repository input because the have-key branch needs
1347
+ // it for the server-resolution request.
1348
+ const baseUrl = deps.env.BAPI_BASE_URL ?? DEFAULT_BAPI_BASE_URL;
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;
1014
1358
  if (bootstrapInviteMode) {
1015
- const validated = validateRepoName(repoName);
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);
1016
1367
  if (!validated.ok) {
1017
1368
  errorLog(`Error: invalid repo name — ${validated.error}.`);
1018
1369
  return 1;
1019
1370
  }
1020
1371
  repoName = validated.value;
1021
1372
  }
1022
- const baseUrl = deps.env.BAPI_BASE_URL ?? DEFAULT_BAPI_BASE_URL;
1023
- const docsDir = deps.env.BAPI_DOCS_DIR ?? DEFAULT_BAPI_DOCS_DIR;
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
+ }
1024
1399
  const agent = resolveAgentSpec(options.agentName) ?? resolveAgentSpec(DEFAULT_AGENT_NAME);
1025
1400
  const spawnCommand = deps.buildShellCommand(agent, INSTALL_BRIDGE_AGENT_PROMPT, deps.cwd, deps.platform);
1026
1401
  const credentialStorePath = getPrimaryCredentialStorePath({
@@ -1045,6 +1420,8 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
1045
1420
  ...(bootstrapInviteMode
1046
1421
  ? { bootstrapInvite: true, exchangeUrl: buildBootstrapExchangeUrl(baseUrl) }
1047
1422
  : {}),
1423
+ ...(selfServeSignupMode ? { selfServeSignup: true } : {}),
1424
+ ...(attemptedServerResolution ? { attemptedServerResolution: true } : {}),
1048
1425
  };
1049
1426
  // ---- --dry-run: preview every step, strictly no side effects ----
1050
1427
  // Positioned BEFORE the CSPRNG, the pending write, the exchange, the scaffold,
@@ -1091,6 +1468,32 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
1091
1468
  // ---- Step 2 — mint (invite mode) or verify (normal mode) the credential ----
1092
1469
  let inviteFingerprint = "";
1093
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
+ }
1094
1497
  // ===================================================================
1095
1498
  // LOAD-BEARING ORDERING — NOT DEFENSIVE POLISH. DO NOT "TIDY" THIS.
1096
1499
  //
@@ -1319,7 +1722,7 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
1319
1722
  }
1320
1723
  }
1321
1724
  // ---- 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…`);
1725
+ log(`Step 5/5 — opening a ${agent.name} session for /install-bridge configuration + capability report…`);
1323
1726
  const terminal = detectTerminal(undefined, deps.env);
1324
1727
  const spawnResult = await deps.spawnTerminalTab(deps.startTicketsDeps, terminal, spawnCommand, {
1325
1728
  key: "install",
@@ -1332,16 +1735,17 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
1332
1735
  // user can run the agentic remainder by hand. Mirrors start-tickets treating a
1333
1736
  // spawn failure as non-fatal to the run.
1334
1737
  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.");
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.");
1337
1741
  return 0;
1338
1742
  }
1339
1743
  log("");
1340
- log(`install-bridge setup steps complete. A fresh ${agent.name} session is now running ` +
1341
- "/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.");
1342
1746
  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.");
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.");
1346
1750
  return 0;
1347
1751
  }