@bridge_gpt/mcp-server 0.2.59 → 0.2.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +181 -51
  2. package/build/agent-guidance.generated.js +1 -1
  3. package/build/agent-notes.js +7 -1
  4. package/build/commands.generated.js +6 -6
  5. package/build/conductor/bridge-api-client.js +406 -4
  6. package/build/conductor/doctor.js +255 -53
  7. package/build/conductor/epic-runtime.js +11 -2
  8. package/build/conductor/install-doctor.js +24 -2
  9. package/build/conductor/local-merge.js +319 -26
  10. package/build/conductor/merge-execution-contract.js +86 -0
  11. package/build/conductor/merge-execution-eligibility.js +130 -0
  12. package/build/conductor/readiness-sections.js +256 -16
  13. package/build/conductor/readiness.js +203 -29
  14. package/build/conductor/recovery-operations.js +8 -2
  15. package/build/connect-bitbucket-api.js +6 -1
  16. package/build/connect-bitbucket.js +398 -88
  17. package/build/credential-store.js +172 -0
  18. package/build/docs.generated.js +2 -2
  19. package/build/doctor.js +277 -15
  20. package/build/drive-epic.js +147 -12
  21. package/build/epic-implementer-bin.js +15 -15
  22. package/build/epic-implementer-claude-hook-bin.js +1 -1
  23. package/build/epic-implementer-env.js +43 -14
  24. package/build/epic-integration-pr.js +61 -15
  25. package/build/epic-launch-profile.js +455 -0
  26. package/build/executor/ci-rerun-job.js +269 -0
  27. package/build/executor/cli.js +40 -0
  28. package/build/executor/credentials.js +28 -1
  29. package/build/executor/env.js +60 -0
  30. package/build/executor/http-client.js +45 -1
  31. package/build/executor/index.js +21 -4
  32. package/build/executor/integration-job.js +759 -0
  33. package/build/executor/job-runner.js +218 -9
  34. package/build/executor/job-types.js +12 -2
  35. package/build/executor/merge-job.js +11 -2
  36. package/build/executor/observation.js +114 -7
  37. package/build/executor/runner.js +101 -1
  38. package/build/executor/service-lifecycle.js +136 -0
  39. package/build/executor/service-unit.js +427 -9
  40. package/build/executor/types.js +48 -0
  41. package/build/index.js +479 -240
  42. package/build/install-bridge-conductor.js +599 -14
  43. package/build/install-bridge.js +448 -46
  44. package/build/mcp-install-state.js +42 -4
  45. package/build/merge-pull-request.js +103 -10
  46. package/build/merge-workflow-codeowners.js +224 -0
  47. package/build/merge-workflow-config.js +187 -0
  48. package/build/merge-workflow-drift.js +97 -0
  49. package/build/merge-workflow-install.js +638 -0
  50. package/build/merge-workflow.js +956 -0
  51. package/build/pipelines.generated.js +3 -3
  52. package/build/plane/cli.js +24 -2
  53. package/build/plane/preflight.js +68 -0
  54. package/build/plane/supervisor.js +6 -0
  55. package/build/readme.generated.js +1 -1
  56. package/build/run-unit-tests-launcher.js +5 -0
  57. package/build/setup-epic.js +263 -38
  58. package/build/version.generated.js +3 -3
  59. package/package.json +3 -3
  60. package/build/conduct-epic/bridge-client.js +0 -773
  61. package/build/conduct-epic/checkpoint-store.js +0 -542
  62. package/build/conduct-epic/cli.js +0 -3145
  63. package/build/conduct-epic/cut-protocol.js +0 -392
  64. package/build/conduct-epic/lock.js +0 -302
  65. package/build/conduct-epic/pr-state.js +0 -286
  66. package/build/conduct-epic/spawn.js +0 -113
  67. package/build/conductor/cli.js +0 -1278
  68. package/build/conductor-bundle-cli.js +0 -256
  69. package/build/direct-ticket-tools.js +0 -70
  70. package/build/executor/reconcile-overlap-governance.js +0 -129
  71. package/build/implement-epic/bridge-client.js +0 -773
  72. package/build/implement-epic/checkpoint-store.js +0 -542
  73. package/build/implement-epic/cli.js +0 -3158
  74. package/build/implement-epic/cut-protocol.js +0 -392
  75. package/build/implement-epic/lock.js +0 -302
  76. package/build/implement-epic/pr-state.js +0 -286
  77. package/build/implement-epic/spawn.js +0 -113
  78. package/build/plan-epic-conductor-eligibility.js +0 -213
@@ -138,7 +138,12 @@ export async function mintBitbucketConnection(deps, repoName) {
138
138
  if (!body || !state || !installUrl || typeof ttlSeconds !== "number") {
139
139
  return { ok: false, kind: "malformed" };
140
140
  }
141
- return { ok: true, value: { state, installUrl, ttlSeconds } };
141
+ // Optional and permissive ON PURPOSE, and only here: absent, null, empty, or any
142
+ // non-string collapses to null through the same nullable helper the candidate parser
143
+ // uses. Required-field strictness above is untouched — an optional field must not
144
+ // become a new way for a server change to fail a mint.
145
+ const redeemUrlTemplate = asNullableString(body.redeem_url_template);
146
+ return { ok: true, value: { state, installUrl, ttlSeconds, redeemUrlTemplate } };
142
147
  }
143
148
  /** Strictly parse candidates. A candidate missing a usable identity fails the whole list. */
144
149
  function parseCandidates(value) {
@@ -36,20 +36,56 @@ import { validateRepoName } from "./bridge-config.js";
36
36
  import { resolveStartTicketsRepoName } from "./start-tickets-repo.js";
37
37
  import { resolveBapiCredentials, } from "./credential-store.js";
38
38
  import { POLL_DEADLINE_MS, confirmBitbucketConnection, mintBitbucketConnection, pollBitbucketConnection, } from "./connect-bitbucket-api.js";
39
- const USAGE = `Usage: connect-bitbucket [--repo <repo_name>]
39
+ const USAGE = `Usage: connect-bitbucket [--repo <repo_name>] [--workspace <slug>]
40
40
 
41
41
  Connect a Bitbucket repository to a Bridge project from your terminal.
42
42
 
43
43
  Opens the Bitbucket Forge app's install page in your browser and prints a
44
- connection code. Install the app, then paste the printed code into its admin
45
- panel — that is how you authenticate to Bitbucket; Bridge never asks for a
46
- Bitbucket token or app password. Once the app reports the code back, this
47
- command asks which single repository to connect.
44
+ connection code. Install the app, then redeem the code in Bitbucket under
45
+ Workspace settings → Apps → "Connect Bitbucket to Bridge API" — that is how you
46
+ authenticate to Bitbucket; Bridge never asks for a Bitbucket token or app
47
+ password. Once the app reports the code back, this command asks which single
48
+ repository to connect.
48
49
 
49
50
  Options:
50
51
  --repo <repo_name> Bridge project to connect (inferred from this directory
51
52
  when omitted; you will be asked to confirm).
53
+ --workspace <slug> Bitbucket workspace slug. Prints a direct link to that
54
+ workspace's settings page for the app. The connection
55
+ code still has to be pasted there by hand — the link only
56
+ saves you finding the page.
52
57
  --help Show this message.`;
58
+ /**
59
+ * Bitbucket workspace slugs are NOT Bridge project names, so they do not go through
60
+ * `validateRepoName`. This mirrors the server's own workspace-slug allowlist: an
61
+ * alphanumeric first and last character, with dots, underscores, and hyphens allowed in
62
+ * between, capped at 255 characters.
63
+ *
64
+ * The allowlist is what makes substitution safe. A slug that cannot contain `/`, `?`,
65
+ * `#`, `%`, a space, or a control character cannot reinterpret the template's path
66
+ * structure once it is substituted — and `encodeURIComponent` at the substitution site
67
+ * is the second, independent guarantee of the same property.
68
+ */
69
+ const BITBUCKET_WORKSPACE_SLUG_RE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
70
+ const BITBUCKET_WORKSPACE_SLUG_MAX_LENGTH = 255;
71
+ const WORKSPACE_VALUE_ERROR = "--workspace requires a Bitbucket workspace slug (letters, digits, '.', '_', '-'; " +
72
+ "e.g. --workspace acme-dev).";
73
+ /** Validate a Bitbucket workspace slug. Never mutates the value beyond trimming. */
74
+ export function validateBitbucketWorkspace(raw) {
75
+ const value = raw.trim();
76
+ if (!value)
77
+ return { ok: false, error: WORKSPACE_VALUE_ERROR };
78
+ if (value.length > BITBUCKET_WORKSPACE_SLUG_MAX_LENGTH) {
79
+ return {
80
+ ok: false,
81
+ error: `--workspace must be at most ${BITBUCKET_WORKSPACE_SLUG_MAX_LENGTH} characters.`,
82
+ };
83
+ }
84
+ if (!BITBUCKET_WORKSPACE_SLUG_RE.test(value)) {
85
+ return { ok: false, error: WORKSPACE_VALUE_ERROR };
86
+ }
87
+ return { ok: true, value };
88
+ }
53
89
  /**
54
90
  * Parse argv. Deliberately strict — an unknown flag is an error, not something to
55
91
  * ignore. In particular there is NO `--yes` (every bind is confirmed by a human), no
@@ -82,6 +118,25 @@ export function parseConnectBitbucketArgs(argv) {
82
118
  out.repo = value;
83
119
  continue;
84
120
  }
121
+ if (arg === "--workspace") {
122
+ const value = argv[i + 1];
123
+ if (!value || value.startsWith("-")) {
124
+ return { ok: false, error: WORKSPACE_VALUE_ERROR };
125
+ }
126
+ const validated = validateBitbucketWorkspace(value);
127
+ if (!validated.ok)
128
+ return { ok: false, error: validated.error };
129
+ out.workspace = validated.value;
130
+ i += 1;
131
+ continue;
132
+ }
133
+ if (arg.startsWith("--workspace=")) {
134
+ const validated = validateBitbucketWorkspace(arg.slice("--workspace=".length));
135
+ if (!validated.ok)
136
+ return { ok: false, error: validated.error };
137
+ out.workspace = validated.value;
138
+ continue;
139
+ }
85
140
  if (arg === "--yes" || arg === "-y") {
86
141
  return {
87
142
  ok: false,
@@ -111,22 +166,43 @@ export function parseConnectBitbucketArgs(argv) {
111
166
  return { ok: true, value: out };
112
167
  }
113
168
  /** Echoed single-line prompt on stderr. */
114
- function defaultPromptLine(promptText) {
169
+ export function defaultPromptLine(promptText) {
115
170
  return new Promise((resolve) => {
116
171
  const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
117
172
  // `rl.close()` emits `close` SYNCHRONOUSLY, so without this guard the close handler
118
173
  // would settle the promise empty and discard a real answer. EOF must also resolve
119
174
  // rather than deadlock a top-level await.
120
175
  let answered = false;
121
- rl.on("close", () => {
122
- if (!answered)
123
- resolve("");
124
- });
125
- rl.question(promptText, (answer) => {
176
+ const settleEmpty = () => {
177
+ if (answered)
178
+ return;
126
179
  answered = true;
180
+ resolve("");
181
+ };
182
+ rl.on("close", settleEmpty);
183
+ // BAPI-1247: an interrupted prompt (Ctrl-C, a closed input) is a DECLINE, not a
184
+ // crash. Resolving empty routes it through the ordinary "user said no" path, so the
185
+ // flow prints its resume guidance instead of surfacing a stack trace that could
186
+ // carry the install URL.
187
+ rl.on("SIGINT", () => {
127
188
  rl.close();
128
- resolve(answer.trim());
189
+ settleEmpty();
129
190
  });
191
+ rl.on("error", () => {
192
+ rl.close();
193
+ settleEmpty();
194
+ });
195
+ try {
196
+ rl.question(promptText, (answer) => {
197
+ answered = true;
198
+ rl.close();
199
+ resolve(answer.trim());
200
+ });
201
+ }
202
+ catch {
203
+ rl.close();
204
+ settleEmpty();
205
+ }
130
206
  });
131
207
  }
132
208
  /**
@@ -245,6 +321,116 @@ const STEPS = [
245
321
  function renderStep(deps, index) {
246
322
  deps.stderr(`[${index + 1}/${STEPS.length}] ${STEPS[index]}`);
247
323
  }
324
+ // ---------------------------------------------------------------------------
325
+ // Redemption guidance (BAPI-1247)
326
+ // ---------------------------------------------------------------------------
327
+ /**
328
+ * The permanent, deployment-independent way to reach the redemption page. Printed for
329
+ * EVERY minted code, with or without a link.
330
+ *
331
+ * This is the fallback that makes the whole feature safe to configure wrong: a link is
332
+ * a shortcut, and a shortcut that is missing, stale, or pointed at the wrong deployment
333
+ * must never leave the user with nothing. The words are the app's own Bitbucket
334
+ * navigation, so they stay correct without any Bridge-side configuration at all.
335
+ */
336
+ export const BITBUCKET_REDEEM_NAVIGATION_PATH = 'Workspace settings → Apps → "Connect Bitbucket to Bridge API"';
337
+ /** The one `{workspace}` token a redemption template may carry. */
338
+ const WORKSPACE_TOKEN = "{workspace}";
339
+ /**
340
+ * Resolve a server template into a printable link for *workspace*.
341
+ *
342
+ * Returns null — silently — for any template this client will not vouch for. The server
343
+ * validates too, but this is guidance the CLI is about to print as a destination, and
344
+ * re-checking here means a server change, a proxy rewrite, or an older/newer server can
345
+ * never talk this command into printing a link it did not verify itself.
346
+ *
347
+ * `encodeURIComponent` is applied to a slug that already passed
348
+ * {@link validateBitbucketWorkspace}, so the substituted value cannot introduce a path
349
+ * segment, query, or fragment of its own.
350
+ */
351
+ export function renderBitbucketRedeemLink(template, workspace) {
352
+ if (typeof template !== "string")
353
+ return null;
354
+ const trimmed = template.trim();
355
+ if (!trimmed)
356
+ return null;
357
+ // Control characters, rejected BEFORE parsing — the fourth of the server's four rules,
358
+ // and the only one `new URL()` cannot stand in for: it strips tab/LF/CR outright and
359
+ // percent-encodes the rest of the C0 set in the path, so a template carrying terminal
360
+ // escape bytes parses cleanly and yields a link. That matters because the no-workspace
361
+ // branch of `renderRedemptionGuidance` prints the template VERBATIM rather than the
362
+ // normalized URL, so without this the raw escapes would reach stderr.
363
+ if (/[\u0000-\u001F\u007F]/.test(trimmed))
364
+ return null;
365
+ // Exactly one placeholder, and it is the workspace token — the same rule the server
366
+ // applies, so `{state}`/`{code}`/anything else is refused rather than left literal.
367
+ const placeholders = trimmed.match(/\{[^{}]*\}/g);
368
+ if (!placeholders || placeholders.length !== 1 || placeholders[0] !== WORKSPACE_TOKEN) {
369
+ return null;
370
+ }
371
+ // A brace surviving removal of the single legal token means a nested or unbalanced
372
+ // construct — `{{workspace}}` matches the inner token above but would substitute to a
373
+ // literal `{acme-dev}` path segment. The server applies this same residue check, and
374
+ // the two must agree about what a safe template is.
375
+ const residue = trimmed.replace(WORKSPACE_TOKEN, "");
376
+ if (residue.includes("{") || residue.includes("}"))
377
+ return null;
378
+ const slug = validateBitbucketWorkspace(workspace);
379
+ if (!slug.ok)
380
+ return null;
381
+ const resolved = trimmed.replace(WORKSPACE_TOKEN, encodeURIComponent(slug.value));
382
+ let parsed;
383
+ try {
384
+ parsed = new URL(resolved);
385
+ }
386
+ catch {
387
+ return null;
388
+ }
389
+ if (parsed.protocol !== "https:")
390
+ return null;
391
+ if (!parsed.hostname)
392
+ return null;
393
+ if (parsed.username || parsed.password)
394
+ return null;
395
+ return parsed.toString();
396
+ }
397
+ /**
398
+ * Is *template* structurally usable as printed guidance, even without a workspace?
399
+ *
400
+ * Uses the same rules as {@link renderBitbucketRedeemLink} by substituting a throwaway
401
+ * slug, so the two can never disagree about what counts as a safe template.
402
+ */
403
+ function isPrintableRedeemTemplate(template) {
404
+ return renderBitbucketRedeemLink(template, "workspace") !== null;
405
+ }
406
+ /**
407
+ * Print the redemption destination for the code that is about to be shown.
408
+ *
409
+ * Invariant: the textual path is ALWAYS printed. A link, when one can be resolved, sits
410
+ * beneath it as a shortcut — never in place of it. Three shapes, in descending
411
+ * helpfulness, none of which is framed as an error:
412
+ *
413
+ * - workspace + safe template → the resolved HTTPS link;
414
+ * - safe template, no workspace → the literal template, with what to replace;
415
+ * - no usable template → the textual path alone.
416
+ *
417
+ * Nothing printed here carries the connection code, the state nonce, or the install URL.
418
+ */
419
+ function renderRedemptionGuidance(deps, template, workspace) {
420
+ deps.stderr(`In Bitbucket, go to: ${BITBUCKET_REDEEM_NAVIGATION_PATH}`);
421
+ if (workspace) {
422
+ const link = renderBitbucketRedeemLink(template, workspace);
423
+ // A malformed or unsafe template is dropped in silence: guidance configuration must
424
+ // never be able to stop, delay, or complicate a connection.
425
+ if (link)
426
+ deps.stderr(` Direct link: ${link}`);
427
+ return;
428
+ }
429
+ if (isPrintableRedeemTemplate(template)) {
430
+ deps.stderr(` Direct link: ${template.trim()}`);
431
+ deps.stderr(" Replace {workspace} with your Bitbucket workspace slug.");
432
+ }
433
+ }
248
434
  /** Display identity for a candidate: prefer the unambiguous workspace/repo slug form. */
249
435
  function candidateLabel(c) {
250
436
  const slugForm = `${c.workspace_slug}/${c.repository_slug}`;
@@ -270,31 +456,52 @@ const FAILURE_MESSAGES = {
270
456
  deadline: "Timed out waiting for the Bitbucket Forge app. If you completed the install and pasted " +
271
457
  "the code, run connect-bitbucket again to pick up the connection.",
272
458
  };
273
- /** The no-connection-made framing, used for every unsuccessful terminal path. */
274
- function reportNoConnection(deps, detail) {
275
- deps.stderr("");
276
- deps.stderr("No Bitbucket connection was made.");
277
- deps.stderr(detail);
278
- return 1;
459
+ /** Only an explicit, case-insensitive `y`/`yes` approves. One definition, used everywhere. */
460
+ function isAffirmative(answer) {
461
+ const normalized = answer.trim().toLowerCase();
462
+ return normalized === "y" || normalized === "yes";
279
463
  }
280
464
  /**
281
- * Ask which repository to bind. Returns null when the user declined or chose nothing.
282
- * Never renders a workspace/repository UUID or installation identifier.
465
+ * Ask a yes/no question. Every non-affirmative outcome — empty input, `n`, unrecognized
466
+ * text, EOF, an interrupted prompt — is a clean decline. A prompt that throws is a
467
+ * decline too: the user is trying to get out, not to see an exception.
283
468
  */
469
+ async function confirm(deps, question) {
470
+ try {
471
+ return isAffirmative(await deps.promptLine(question));
472
+ }
473
+ catch {
474
+ return false;
475
+ }
476
+ }
477
+ /** The exact command that resumes this attempt. Quoted so a hostile repo name is inert. */
478
+ function resumeCommand(repoName) {
479
+ return `connect-bitbucket --repo '${repoName}'`;
480
+ }
481
+ /**
482
+ * The no-connection-made framing, used for every unsuccessful terminal path.
483
+ *
484
+ * Always says what to run next: the code that was printed is gone (single-use and
485
+ * short-lived), so the only useful next step is a fresh run, and saying so is what stops
486
+ * a user hunting for a code that can no longer work. The expired code itself is never
487
+ * reprinted.
488
+ */
489
+ function reportNoConnection(deps, detail, repoName, kind = "failed") {
490
+ deps.stderr("");
491
+ deps.stderr("Bitbucket connection was not completed.");
492
+ deps.stderr(detail);
493
+ deps.stderr(`To try again: ${resumeCommand(repoName)} (this issues a new code).`);
494
+ return { kind };
495
+ }
284
496
  async function chooseCandidate(deps, candidates) {
285
497
  if (candidates.length === 1) {
286
498
  const only = candidates[0];
287
499
  // Even with one option: show the full identity and require a yes. The user is
288
500
  // authorizing a binding, not acknowledging a notice.
289
- const answer = (await deps.promptLine(`Connect ${candidateLabel(only)}? [y/N]: `))
290
- .trim()
291
- .toLowerCase();
292
- if (answer !== "y" && answer !== "yes") {
293
- deps.stderr("");
294
- deps.stderr("No Bitbucket connection was made. Nothing was changed.");
295
- return null;
501
+ if (!(await confirm(deps, `Connect ${candidateLabel(only)}? [y/N]: `))) {
502
+ return { ok: false, detail: "Nothing was changed." };
296
503
  }
297
- return only;
504
+ return { ok: true, value: only };
298
505
  }
299
506
  deps.stderr("");
300
507
  deps.stderr("Your Bitbucket Forge installation includes multiple repositories:");
@@ -303,81 +510,175 @@ async function chooseCandidate(deps, candidates) {
303
510
  });
304
511
  deps.stderr("");
305
512
  // No default: a stray Enter must not bind anything.
306
- const answer = (await deps.promptLine(`Choose a repository [1-${candidates.length}]: `)).trim();
513
+ let answer = "";
514
+ try {
515
+ answer = (await deps.promptLine(`Choose a repository [1-${candidates.length}]: `)).trim();
516
+ }
517
+ catch {
518
+ answer = "";
519
+ }
307
520
  const index = Number(answer);
308
521
  if (answer.toLowerCase() === "q" ||
309
522
  !/^\d+$/.test(answer) ||
310
523
  !Number.isInteger(index) ||
311
524
  index < 1 ||
312
525
  index > candidates.length) {
313
- deps.stderr("");
314
- deps.stderr("No Bitbucket connection was made. No repository was selected.");
315
- return null;
526
+ return { ok: false, detail: "No repository was selected." };
316
527
  }
317
528
  const selected = candidates[index - 1];
318
529
  deps.stderr(`Selected ${candidateLabel(selected)}.`);
319
- return selected;
530
+ return { ok: true, value: selected };
320
531
  }
321
- export async function runBitbucketConnectionFlow(deps, api, repoName) {
532
+ /**
533
+ * Run the full connect handshake, with unlimited user-approved code renewals.
534
+ *
535
+ * The loop exists because the failure this command actually hits is mundane: a code
536
+ * lives ~15 minutes, and a user who is mid-way through installing a Forge app can easily
537
+ * spend longer than that. Before BAPI-1247 that meant "run the whole command again";
538
+ * now it means one `[y/N]`. Renewal is *always* a fresh mint under the server's own TTL
539
+ * cap — a code is never extended, resumed, or reprinted.
540
+ *
541
+ * Renewal is offered for exactly two outcomes, both read from the typed discriminant
542
+ * rather than from message text: a server-reported `expired` status, and a client-side
543
+ * polling `deadline`. Everything else — `invalid`, `no-repositories`, a confirmation
544
+ * failure, any transport or API failure — stays terminal, because repeating a request
545
+ * the server already refused cannot help.
546
+ *
547
+ * Each iteration's minted value is the ONLY state used for polling and confirmation, so
548
+ * a renewal can never confirm against a previous code.
549
+ */
550
+ export async function runBitbucketConnectionFlow(deps, api, repoName, options = {}) {
551
+ const { workspace, initialMintApproved = false } = options;
322
552
  renderStep(deps, 0);
323
- const minted = await mintBitbucketConnection(api, repoName);
324
- if (!minted.ok) {
325
- return reportNoConnection(deps, FAILURE_MESSAGES[minted.kind]);
326
- }
327
- renderStep(deps, 1);
328
- deps.stderr("Opening Bitbucket…");
329
- // The URL goes straight from memory to the opener — never through a log line.
330
- const opened = await openBitbucketInstallPage(deps, minted.value.installUrl);
331
- if (!opened.ok) {
332
- return reportNoConnection(deps, opened.error);
333
- }
334
- // The code itself is not a bearer credential the way the URL's state parameter is
335
- // treated elsewhere in this flow — it must be visible so the user can paste it into
336
- // the installed Forge app's admin panel, which is how redemption actually happens for
337
- // a Forge app (there is no GitHub-style browser redirect back to Bridge).
338
- deps.stderr(`Paste this code into the installed app's admin panel: ${minted.value.state}`);
339
- renderStep(deps, 2);
340
- const minutes = Math.floor(POLL_DEADLINE_MS / 60_000);
341
- deps.stderr(`Waiting for the Bitbucket Forge app… (up to ~${minutes} minutes)`);
342
- const pollDeps = { sleep: deps.sleep, now: deps.now, jitter: deps.jitter };
343
- const started = deps.now();
344
- const polled = await pollBitbucketConnection(api, pollDeps, repoName, minted.value.state);
345
- if (!polled.ok) {
346
- return reportNoConnection(deps, FAILURE_MESSAGES[polled.kind]);
347
- }
348
- const elapsedSec = Math.max(0, Math.round((deps.now() - started) / 1_000));
349
- deps.stderr(`Waited ${elapsedSec}s.`);
350
- const result = polled.value;
351
- if (result.status === "connected") {
352
- // Already bound (e.g. a re-run against a finished handshake).
353
- deps.stdout(`Connected ${result.repoDisplayName ?? repoName}.`);
354
- return 0;
355
- }
356
- if (result.status !== "staged") {
357
- return reportNoConnection(deps, OUTCOME_MESSAGES[result.status] ?? OUTCOME_MESSAGES.invalid);
358
- }
359
- const candidates = result.candidates;
360
- if (candidates.length === 0) {
361
- return reportNoConnection(deps, OUTCOME_MESSAGES["no-repositories"]);
553
+ if (!initialMintApproved) {
554
+ // A standalone run asks before it mints: minting is a side effect (a live code, a
555
+ // browser window), so it is opted in to, never assumed.
556
+ if (!(await confirm(deps, "Create a Bitbucket connection code now? [y/N]: "))) {
557
+ return reportNoConnection(deps, "No connection code was created.", repoName, "incomplete");
558
+ }
362
559
  }
363
- renderStep(deps, 3);
364
- const selected = await chooseCandidate(deps, candidates);
365
- if (!selected)
366
- return 1;
367
- renderStep(deps, 4);
368
- const confirmed = await confirmBitbucketConnection(api, repoName, minted.value.state, selected.workspace_uuid, selected.repository_uuid);
369
- if (!confirmed.ok) {
370
- return reportNoConnection(deps, FAILURE_MESSAGES[confirmed.kind]);
560
+ let browserOpened = false;
561
+ let expiryCount = 0;
562
+ for (;;) {
563
+ const minted = await mintBitbucketConnection(api, repoName);
564
+ if (!minted.ok) {
565
+ // Includes a renewal mint: a failed fresh mint is terminal and resumable, never
566
+ // silently retried. An automatic retry here would spend codes the user cannot see.
567
+ return reportNoConnection(deps, FAILURE_MESSAGES[minted.kind], repoName);
568
+ }
569
+ // Iteration-local. Nothing outside this block may read a previous code.
570
+ const connection = minted.value;
571
+ if (!browserOpened) {
572
+ renderStep(deps, 1);
573
+ deps.stderr("Opening Bitbucket…");
574
+ // The URL goes straight from memory to the opener — never through a log line.
575
+ const opened = await openBitbucketInstallPage(deps, connection.installUrl);
576
+ if (!opened.ok) {
577
+ return reportNoConnection(deps, opened.error, repoName);
578
+ }
579
+ // Only the FIRST code opens a browser. On a renewal the install page is already
580
+ // open (and its URL carries the old, now-dead state), so reopening it would be
581
+ // both noise and a stale destination.
582
+ browserOpened = true;
583
+ }
584
+ renderRedemptionGuidance(deps, connection.redeemUrlTemplate, workspace);
585
+ // The code itself is not a bearer credential the way the URL's state parameter is
586
+ // treated elsewhere in this flow — it must be visible so the user can paste it into
587
+ // the installed Forge app's admin panel, which is how redemption actually happens for
588
+ // a Forge app (there is no GitHub-style browser redirect back to Bridge). This is
589
+ // the single statement in the command that prints a code, and it runs once per mint.
590
+ deps.stderr(`Paste this code there: ${connection.state}`);
591
+ renderStep(deps, 2);
592
+ const minutes = Math.floor(POLL_DEADLINE_MS / 60_000);
593
+ deps.stderr(`Waiting for the Bitbucket Forge app… (up to ~${minutes} minutes)`);
594
+ const pollDeps = { sleep: deps.sleep, now: deps.now, jitter: deps.jitter };
595
+ const started = deps.now();
596
+ const polled = await pollBitbucketConnection(api, pollDeps, repoName, connection.state);
597
+ // The two renewable outcomes, taken from the typed discriminant.
598
+ const renewable = (polled.ok && polled.value.status === "expired") || (!polled.ok && polled.kind === "deadline");
599
+ if (renewable) {
600
+ expiryCount += 1;
601
+ deps.stderr("");
602
+ deps.stderr("That code expired and can no longer be used.");
603
+ if (expiryCount > 1) {
604
+ // Said only after it has happened more than once — by then the likely cause is
605
+ // not slowness but a wrong page or an app that was never installed.
606
+ deps.stderr("If this keeps happening, check that the Forge app is installed and that you " +
607
+ "have the right workspace's settings page open.");
608
+ }
609
+ if (await confirm(deps, "Issue a fresh code and keep waiting? [y/N]: ")) {
610
+ continue;
611
+ }
612
+ return reportNoConnection(deps, polled.ok ? OUTCOME_MESSAGES.expired : FAILURE_MESSAGES.deadline, repoName, "incomplete");
613
+ }
614
+ if (!polled.ok) {
615
+ return reportNoConnection(deps, FAILURE_MESSAGES[polled.kind], repoName);
616
+ }
617
+ const elapsedSec = Math.max(0, Math.round((deps.now() - started) / 1_000));
618
+ deps.stderr(`Waited ${elapsedSec}s.`);
619
+ const result = polled.value;
620
+ if (result.status === "connected") {
621
+ // Already bound (e.g. a re-run against a finished handshake).
622
+ const displayName = result.repoDisplayName ?? repoName;
623
+ deps.stdout(`Connected ${displayName}.`);
624
+ return { kind: "connected", displayName };
625
+ }
626
+ if (result.status !== "staged") {
627
+ return reportNoConnection(deps, OUTCOME_MESSAGES[result.status] ?? OUTCOME_MESSAGES.invalid, repoName);
628
+ }
629
+ const candidates = result.candidates;
630
+ if (candidates.length === 0) {
631
+ return reportNoConnection(deps, OUTCOME_MESSAGES["no-repositories"], repoName);
632
+ }
633
+ renderStep(deps, 3);
634
+ const selected = await chooseCandidate(deps, candidates);
635
+ if (!selected.ok) {
636
+ return reportNoConnection(deps, selected.detail, repoName, "incomplete");
637
+ }
638
+ renderStep(deps, 4);
639
+ const confirmed = await confirmBitbucketConnection(api, repoName,
640
+ // This iteration's code only. The server still derives the installation from it;
641
+ // there is no installation-id or codeless fallback.
642
+ connection.state, selected.value.workspace_uuid, selected.value.repository_uuid);
643
+ if (!confirmed.ok) {
644
+ return reportNoConnection(deps, FAILURE_MESSAGES[confirmed.kind], repoName);
645
+ }
646
+ const displayName = confirmed.value.workspaceSlug && confirmed.value.repositorySlug
647
+ ? `${confirmed.value.workspaceSlug}/${confirmed.value.repositorySlug}`
648
+ : confirmed.value.repoName;
649
+ deps.stdout(`Connected ${displayName}.`);
650
+ return { kind: "connected", displayName };
371
651
  }
372
- const displayName = confirmed.value.workspaceSlug && confirmed.value.repositorySlug
373
- ? `${confirmed.value.workspaceSlug}/${confirmed.value.repositorySlug}`
374
- : confirmed.value.repoName;
375
- deps.stdout(`Connected ${displayName}.`);
376
- return 0;
377
652
  }
378
653
  // ---------------------------------------------------------------------------
379
654
  // Process boundary
380
655
  // ---------------------------------------------------------------------------
656
+ /**
657
+ * Fixed, secret-free wording for each credential-resolution failure (BAPI-1247).
658
+ *
659
+ * `cred.error` is deliberately never interpolated. It can carry an absolute filesystem
660
+ * path, a JSON parser message quoting file contents, or text derived from stored
661
+ * credential material — none of which belongs in a terminal a user may screenshot or
662
+ * paste into a bug report. The user does not need the internal detail; they need to know
663
+ * which of two things to do.
664
+ */
665
+ const CREDENTIAL_PROBLEM_MESSAGES = {
666
+ "not-found": "No stored Bridge credentials were found for this project.",
667
+ "missing-key": "The stored Bridge credentials for this project have no API key.",
668
+ "read-error": "The stored Bridge credentials could not be read.",
669
+ "parse-error": "The stored Bridge credentials are not in a readable format.",
670
+ };
671
+ /** Print the credential problem and exactly the two ways out of it. */
672
+ function reportCredentialProblem(deps, kind) {
673
+ deps.stderr(CREDENTIAL_PROBLEM_MESSAGES[kind] ?? "Bridge credentials for this project are unavailable.");
674
+ deps.stderr("");
675
+ deps.stderr("Two ways to fix this:");
676
+ // Route 1 is the one that also repairs a damaged store, so it goes first.
677
+ deps.stderr(" 1. Run the full Bridge install first — it provisions and stores a key for this project.");
678
+ // Named, not spelled as an assignment: `VAR=value cmd` is not portable shell, and
679
+ // telling a PowerShell user to type it would be wrong.
680
+ deps.stderr(" 2. Provide BAPI_API_KEY in the environment this command runs in.");
681
+ }
381
682
  /**
382
683
  * Entry point. Catches everything and always resolves to a numeric exit code — an
383
684
  * unhandled rejection here would surface a stack trace that could contain the install
@@ -419,7 +720,8 @@ export async function runConnectBitbucketCli(argv, injected) {
419
720
  stderr: () => { },
420
721
  });
421
722
  if (!cred.ok) {
422
- deps.stderr(cred.error);
723
+ // Checked BEFORE minting or opening a browser: there is nothing to connect with.
724
+ reportCredentialProblem(deps, cred.kind);
423
725
  return 1;
424
726
  }
425
727
  const api = {
@@ -427,7 +729,15 @@ export async function runConnectBitbucketCli(argv, injected) {
427
729
  baseUrl: deps.env.BAPI_BASE_URL?.trim() || DEFAULT_BAPI_BASE_URL,
428
730
  apiKey: cred.credentials.apiKey,
429
731
  };
430
- return await runBitbucketConnectionFlow(deps, api, repo.value);
732
+ const result = await runBitbucketConnectionFlow(deps, api, repo.value, {
733
+ workspace: parsed.value.workspace,
734
+ // Standalone: nobody has asked yet, so the flow owns the initial confirmation.
735
+ initialMintApproved: false,
736
+ });
737
+ // Only a confirmed connection is a success. `incomplete` (the user stopped) and
738
+ // `failed` (something broke) are both non-zero — the shell cannot act on the
739
+ // difference, and the narrative distinction was already printed.
740
+ return result.kind === "connected" ? 0 : 1;
431
741
  }
432
742
  catch {
433
743
  // Never forward the caught value: it may carry the install URL or the nonce.