@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.
- package/README.md +181 -51
- package/build/agent-guidance.generated.js +1 -1
- package/build/agent-notes.js +7 -1
- package/build/commands.generated.js +6 -6
- package/build/conductor/bridge-api-client.js +406 -4
- package/build/conductor/doctor.js +255 -53
- package/build/conductor/epic-runtime.js +11 -2
- package/build/conductor/install-doctor.js +24 -2
- package/build/conductor/local-merge.js +319 -26
- package/build/conductor/merge-execution-contract.js +86 -0
- package/build/conductor/merge-execution-eligibility.js +130 -0
- package/build/conductor/readiness-sections.js +256 -16
- package/build/conductor/readiness.js +203 -29
- package/build/conductor/recovery-operations.js +8 -2
- package/build/connect-bitbucket-api.js +6 -1
- package/build/connect-bitbucket.js +398 -88
- package/build/credential-store.js +172 -0
- package/build/docs.generated.js +2 -2
- package/build/doctor.js +277 -15
- package/build/drive-epic.js +147 -12
- package/build/epic-implementer-bin.js +15 -15
- package/build/epic-implementer-claude-hook-bin.js +1 -1
- package/build/epic-implementer-env.js +43 -14
- package/build/epic-integration-pr.js +61 -15
- package/build/epic-launch-profile.js +455 -0
- package/build/executor/ci-rerun-job.js +269 -0
- package/build/executor/cli.js +40 -0
- package/build/executor/credentials.js +28 -1
- package/build/executor/env.js +60 -0
- package/build/executor/http-client.js +45 -1
- package/build/executor/index.js +21 -4
- package/build/executor/integration-job.js +759 -0
- package/build/executor/job-runner.js +218 -9
- package/build/executor/job-types.js +12 -2
- package/build/executor/merge-job.js +11 -2
- package/build/executor/observation.js +114 -7
- package/build/executor/runner.js +101 -1
- package/build/executor/service-lifecycle.js +136 -0
- package/build/executor/service-unit.js +427 -9
- package/build/executor/types.js +48 -0
- package/build/index.js +479 -240
- package/build/install-bridge-conductor.js +599 -14
- package/build/install-bridge.js +448 -46
- package/build/mcp-install-state.js +42 -4
- package/build/merge-pull-request.js +103 -10
- package/build/merge-workflow-codeowners.js +224 -0
- package/build/merge-workflow-config.js +187 -0
- package/build/merge-workflow-drift.js +97 -0
- package/build/merge-workflow-install.js +638 -0
- package/build/merge-workflow.js +956 -0
- package/build/pipelines.generated.js +3 -3
- package/build/plane/cli.js +24 -2
- package/build/plane/preflight.js +68 -0
- package/build/plane/supervisor.js +6 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +5 -0
- package/build/setup-epic.js +263 -38
- package/build/version.generated.js +3 -3
- package/package.json +3 -3
- package/build/conduct-epic/bridge-client.js +0 -773
- package/build/conduct-epic/checkpoint-store.js +0 -542
- package/build/conduct-epic/cli.js +0 -3145
- package/build/conduct-epic/cut-protocol.js +0 -392
- package/build/conduct-epic/lock.js +0 -302
- package/build/conduct-epic/pr-state.js +0 -286
- package/build/conduct-epic/spawn.js +0 -113
- package/build/conductor/cli.js +0 -1278
- package/build/conductor-bundle-cli.js +0 -256
- package/build/direct-ticket-tools.js +0 -70
- package/build/executor/reconcile-overlap-governance.js +0 -129
- package/build/implement-epic/bridge-client.js +0 -773
- package/build/implement-epic/checkpoint-store.js +0 -542
- package/build/implement-epic/cli.js +0 -3158
- package/build/implement-epic/cut-protocol.js +0 -392
- package/build/implement-epic/lock.js +0 -302
- package/build/implement-epic/pr-state.js +0 -286
- package/build/implement-epic/spawn.js +0 -113
- 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
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
command asks which single
|
|
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
|
-
|
|
122
|
-
if (
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
274
|
-
function
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
282
|
-
*
|
|
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
|
-
|
|
290
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|