@bridge_gpt/mcp-server 0.2.32 → 0.2.34
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 +51 -21
- package/build/conductor/bridge-api-client.js +37 -3
- package/build/conductor-bin.js +1 -1
- package/build/connect-github-api.js +103 -0
- package/build/connect-github.js +325 -39
- package/build/index.js +22 -10
- package/build/install-bridge.js +323 -68
- package/build/readme.generated.js +1 -1
- package/build/version.generated.js +1 -1
- package/docs/install/github-app.md +96 -8
- package/package.json +3 -3
|
@@ -22,6 +22,7 @@ const TERMINAL_STATUSES = new Set([
|
|
|
22
22
|
"connected",
|
|
23
23
|
"expired",
|
|
24
24
|
"invalid",
|
|
25
|
+
"revoked",
|
|
25
26
|
"verification-failed",
|
|
26
27
|
"no-repositories",
|
|
27
28
|
"conflict",
|
|
@@ -228,6 +229,108 @@ export async function confirmGithubConnection(deps, repoName, state, githubRepos
|
|
|
228
229
|
},
|
|
229
230
|
};
|
|
230
231
|
}
|
|
232
|
+
const ALL_HANDOFF_STATUSES = new Set([
|
|
233
|
+
"pending",
|
|
234
|
+
"staged",
|
|
235
|
+
"awaiting-organization-approval",
|
|
236
|
+
"connected",
|
|
237
|
+
"expired",
|
|
238
|
+
"revoked",
|
|
239
|
+
"verification-failed",
|
|
240
|
+
"no-repositories",
|
|
241
|
+
"conflict",
|
|
242
|
+
"failed",
|
|
243
|
+
]);
|
|
244
|
+
/**
|
|
245
|
+
* Mint a delegated handoff link.
|
|
246
|
+
*
|
|
247
|
+
* Deliberately parses only the URL and expiry: the backend does not return the state as
|
|
248
|
+
* a separate field, and nothing here should reconstruct one.
|
|
249
|
+
*/
|
|
250
|
+
export async function mintGithubHandoff(deps, repoName) {
|
|
251
|
+
const res = await postJson(deps, "/setup/github/cli/handoff", {
|
|
252
|
+
repo_name: repoName,
|
|
253
|
+
});
|
|
254
|
+
if (!res.ok)
|
|
255
|
+
return res;
|
|
256
|
+
if (res.value.status !== 200) {
|
|
257
|
+
return { ok: false, kind: classifyStatus(res.value.status) };
|
|
258
|
+
}
|
|
259
|
+
const body = asRecord(res.value.body);
|
|
260
|
+
const installUrl = asString(body?.install_url);
|
|
261
|
+
const expiresAt = asString(body?.expires_at);
|
|
262
|
+
const ttlSeconds = body?.ttl_seconds;
|
|
263
|
+
if (!body || !installUrl || !expiresAt || typeof ttlSeconds !== "number") {
|
|
264
|
+
return { ok: false, kind: "malformed" };
|
|
265
|
+
}
|
|
266
|
+
return { ok: true, value: { installUrl, expiresAt, ttlSeconds } };
|
|
267
|
+
}
|
|
268
|
+
/** Strictly parse one handoff list item. Returns null for any malformed shape. */
|
|
269
|
+
function parseHandoffSnapshot(raw) {
|
|
270
|
+
const rec = asRecord(raw);
|
|
271
|
+
if (!rec)
|
|
272
|
+
return null;
|
|
273
|
+
const state = asString(rec.state);
|
|
274
|
+
const status = asString(rec.status);
|
|
275
|
+
if (!state || !status || !ALL_HANDOFF_STATUSES.has(status))
|
|
276
|
+
return null;
|
|
277
|
+
const candidates = parseCandidates(rec.candidates ?? []);
|
|
278
|
+
if (candidates === null)
|
|
279
|
+
return null;
|
|
280
|
+
return {
|
|
281
|
+
state,
|
|
282
|
+
status: status,
|
|
283
|
+
createdAt: asNullableString(rec.created_at),
|
|
284
|
+
expiresAt: asNullableString(rec.expires_at),
|
|
285
|
+
revokedAt: asNullableString(rec.revoked_at),
|
|
286
|
+
candidates,
|
|
287
|
+
githubRepoName: asNullableString(rec.github_repo_name),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
/** Discover outstanding delegated handoffs for a project (the resume/revoke lookup). */
|
|
291
|
+
export async function listGithubHandoffs(deps, repoName) {
|
|
292
|
+
const res = await postJson(deps, "/setup/github/cli/handoffs", {
|
|
293
|
+
repo_name: repoName,
|
|
294
|
+
});
|
|
295
|
+
if (!res.ok)
|
|
296
|
+
return res;
|
|
297
|
+
if (res.value.status !== 200) {
|
|
298
|
+
return { ok: false, kind: classifyStatus(res.value.status) };
|
|
299
|
+
}
|
|
300
|
+
const body = asRecord(res.value.body);
|
|
301
|
+
const rawHandoffs = body?.handoffs;
|
|
302
|
+
if (!body || !Array.isArray(rawHandoffs)) {
|
|
303
|
+
return { ok: false, kind: "malformed" };
|
|
304
|
+
}
|
|
305
|
+
const out = [];
|
|
306
|
+
for (const raw of rawHandoffs) {
|
|
307
|
+
const parsed = parseHandoffSnapshot(raw);
|
|
308
|
+
// A malformed entry is a malformed RESPONSE, not an item to quietly drop: dropping
|
|
309
|
+
// it would silently shrink the admin's picker and could hide a live handoff.
|
|
310
|
+
if (!parsed)
|
|
311
|
+
return { ok: false, kind: "malformed" };
|
|
312
|
+
out.push(parsed);
|
|
313
|
+
}
|
|
314
|
+
return { ok: true, value: out };
|
|
315
|
+
}
|
|
316
|
+
/** Revoke one outstanding delegated handoff. The state travels in the body. */
|
|
317
|
+
export async function revokeGithubHandoff(deps, repoName, state) {
|
|
318
|
+
const res = await postJson(deps, "/setup/github/cli/revoke", {
|
|
319
|
+
repo_name: repoName,
|
|
320
|
+
state,
|
|
321
|
+
});
|
|
322
|
+
if (!res.ok)
|
|
323
|
+
return res;
|
|
324
|
+
if (res.value.status !== 200) {
|
|
325
|
+
return { ok: false, kind: classifyStatus(res.value.status) };
|
|
326
|
+
}
|
|
327
|
+
const body = asRecord(res.value.body);
|
|
328
|
+
const status = asString(body?.status);
|
|
329
|
+
if (!body || (status !== "revoked" && status !== "already-revoked")) {
|
|
330
|
+
return { ok: false, kind: "malformed" };
|
|
331
|
+
}
|
|
332
|
+
return { ok: true, value: status };
|
|
333
|
+
}
|
|
231
334
|
/**
|
|
232
335
|
* Read whether GitHub is already configured for a project.
|
|
233
336
|
*
|
package/build/connect-github.js
CHANGED
|
@@ -28,8 +28,8 @@ import { DEFAULT_BAPI_BASE_URL } from "./install-bridge.js";
|
|
|
28
28
|
import { validateRepoName } from "./bridge-config.js";
|
|
29
29
|
import { resolveStartTicketsRepoName } from "./start-tickets-repo.js";
|
|
30
30
|
import { resolveBapiCredentials, } from "./credential-store.js";
|
|
31
|
-
import { POLL_DEADLINE_MS, confirmGithubConnection, mintGithubConnection, pollGithubConnection, } from "./connect-github-api.js";
|
|
32
|
-
const USAGE = `Usage: connect-github [--repo <repo_name>]
|
|
31
|
+
import { POLL_DEADLINE_MS, confirmGithubConnection, listGithubHandoffs, mintGithubConnection, mintGithubHandoff, pollGithubConnection, revokeGithubHandoff, } from "./connect-github-api.js";
|
|
32
|
+
const USAGE = `Usage: connect-github [--repo <repo_name>] [--handoff | --resume | --revoke]
|
|
33
33
|
|
|
34
34
|
Connect a GitHub repository to a Bridge project from your terminal.
|
|
35
35
|
|
|
@@ -37,10 +37,24 @@ Opens the GitHub App install page in your browser, waits for you to install it,
|
|
|
37
37
|
then asks which repository to connect. You are never asked for a GitHub token or
|
|
38
38
|
password — you authenticate to GitHub in the browser.
|
|
39
39
|
|
|
40
|
+
If you do not administer the GitHub organization yourself, use the delegated
|
|
41
|
+
handoff: mint a link with --handoff, send it to whoever does, and finish with
|
|
42
|
+
--resume once they have completed their part.
|
|
43
|
+
|
|
40
44
|
Options:
|
|
41
45
|
--repo <repo_name> Bridge project to connect (inferred from this directory
|
|
42
46
|
when omitted; you will be asked to confirm).
|
|
43
|
-
--
|
|
47
|
+
--handoff Print a shareable install link (valid 72 hours) instead of
|
|
48
|
+
connecting here. Does not open a browser or wait.
|
|
49
|
+
--resume Look up an outstanding handoff and finish connecting it.
|
|
50
|
+
Works from any machine holding this project's API key.
|
|
51
|
+
--revoke Invalidate an outstanding handoff link.
|
|
52
|
+
--help Show this message.
|
|
53
|
+
|
|
54
|
+
--handoff, --resume, and --revoke are mutually exclusive. Minting a new link does
|
|
55
|
+
not invalidate an existing one — use --revoke for that.`;
|
|
56
|
+
/** The three delegated modes, for the mutual-exclusion check and its message. */
|
|
57
|
+
const DELEGATED_MODE_FLAGS = ["--handoff", "--resume", "--revoke"];
|
|
44
58
|
/**
|
|
45
59
|
* Parse argv. Deliberately strict — an unknown flag is an error, not something to
|
|
46
60
|
* ignore. In particular there is NO `--yes` (every bind is confirmed by a human) and no
|
|
@@ -55,6 +69,18 @@ export function parseConnectGithubArgs(argv) {
|
|
|
55
69
|
out.help = true;
|
|
56
70
|
continue;
|
|
57
71
|
}
|
|
72
|
+
if (arg === "--handoff") {
|
|
73
|
+
out.handoff = true;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (arg === "--resume") {
|
|
77
|
+
out.resume = true;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (arg === "--revoke") {
|
|
81
|
+
out.revoke = true;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
58
84
|
if (arg === "--repo") {
|
|
59
85
|
const value = argv[i + 1];
|
|
60
86
|
if (!value || value.startsWith("-")) {
|
|
@@ -91,6 +117,18 @@ export function parseConnectGithubArgs(argv) {
|
|
|
91
117
|
}
|
|
92
118
|
return { ok: false, error: `Unexpected argument: ${arg}` };
|
|
93
119
|
}
|
|
120
|
+
// Mutually exclusive: each mode is a different operation on a different object, and
|
|
121
|
+
// guessing at a precedence order would silently do something the caller did not ask
|
|
122
|
+
// for. Refuse rather than pick.
|
|
123
|
+
const selected = DELEGATED_MODE_FLAGS.filter((flag) => (flag === "--handoff" && out.handoff) ||
|
|
124
|
+
(flag === "--resume" && out.resume) ||
|
|
125
|
+
(flag === "--revoke" && out.revoke));
|
|
126
|
+
if (selected.length > 1) {
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
error: `Choose only one of ${DELEGATED_MODE_FLAGS.join(", ")} (got ${selected.join(", ")}).`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
94
132
|
return { ok: true, value: out };
|
|
95
133
|
}
|
|
96
134
|
/** Echoed single-line prompt on stderr. */
|
|
@@ -195,6 +233,28 @@ export async function resolveConnectGithubRepoName(args, deps) {
|
|
|
195
233
|
const validated = validateRepoName(chosen);
|
|
196
234
|
return validated.ok ? { ok: true, value: validated.value } : { ok: false, error: validated.error };
|
|
197
235
|
}
|
|
236
|
+
/**
|
|
237
|
+
* Resolve the project for non-interactive `--handoff` (BAPI-686).
|
|
238
|
+
*
|
|
239
|
+
* Synchronous and prompt-free, because mint mode may legitimately run without a TTY.
|
|
240
|
+
* That removes the interactive confirmation that makes inference safe in the default
|
|
241
|
+
* flow, so this deliberately does NOT fall back to guessing from the directory name:
|
|
242
|
+
* either `--repo` was given, or the caller is told to give it. Silently minting a live
|
|
243
|
+
* bearer link against the wrong project is a worse outcome than an error message.
|
|
244
|
+
*/
|
|
245
|
+
export function resolveHandoffRepoName(args, _deps) {
|
|
246
|
+
if (!args.repo) {
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
error: "connect-github --handoff needs an explicit project: pass --repo <repo_name>. " +
|
|
250
|
+
"It cannot ask you to confirm an inferred name when run non-interactively.",
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
const validated = validateRepoName(args.repo);
|
|
254
|
+
return validated.ok
|
|
255
|
+
? { ok: true, value: validated.value }
|
|
256
|
+
: { ok: false, error: validated.error };
|
|
257
|
+
}
|
|
198
258
|
// ---------------------------------------------------------------------------
|
|
199
259
|
// Browser handoff
|
|
200
260
|
// ---------------------------------------------------------------------------
|
|
@@ -262,6 +322,47 @@ function reportNoConnection(deps, detail) {
|
|
|
262
322
|
deps.stderr(detail);
|
|
263
323
|
return 1;
|
|
264
324
|
}
|
|
325
|
+
/**
|
|
326
|
+
* Ask which repository to bind. Returns null when the user declined or chose nothing.
|
|
327
|
+
*
|
|
328
|
+
* Extracted from `runGithubConnectionFlow` (BAPI-686) so the delegated `--resume` path
|
|
329
|
+
* reuses this exact interaction rather than growing a parallel copy that could drift
|
|
330
|
+
* from it. Behavior — the single-candidate `[y/N]`, the numbered picker, the absence of
|
|
331
|
+
* a default, and every message — is unchanged from BAPI-631.
|
|
332
|
+
*/
|
|
333
|
+
async function chooseCandidate(deps, candidates) {
|
|
334
|
+
if (candidates.length === 1) {
|
|
335
|
+
const only = candidates[0];
|
|
336
|
+
// Even with one option: show the full identity and require a yes. The user is
|
|
337
|
+
// authorizing a binding, not acknowledging a notice.
|
|
338
|
+
const answer = (await deps.promptLine(`Connect ${candidateLabel(only)}? [y/N]: `))
|
|
339
|
+
.trim()
|
|
340
|
+
.toLowerCase();
|
|
341
|
+
if (answer !== "y" && answer !== "yes") {
|
|
342
|
+
deps.stderr("");
|
|
343
|
+
deps.stderr("No GitHub connection was made. Nothing was changed.");
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
return only;
|
|
347
|
+
}
|
|
348
|
+
deps.stderr("");
|
|
349
|
+
deps.stderr("Your GitHub installation includes multiple repositories:");
|
|
350
|
+
candidates.forEach((c, i) => {
|
|
351
|
+
deps.stderr(` ${String(i + 1).padStart(2, " ")}. ${candidateLabel(c)}`);
|
|
352
|
+
});
|
|
353
|
+
deps.stderr("");
|
|
354
|
+
// No default: a stray Enter must not bind anything.
|
|
355
|
+
const answer = (await deps.promptLine(`Choose a repository [1-${candidates.length}]: `)).trim();
|
|
356
|
+
const index = Number(answer);
|
|
357
|
+
if (!/^\d+$/.test(answer) || !Number.isInteger(index) || index < 1 || index > candidates.length) {
|
|
358
|
+
deps.stderr("");
|
|
359
|
+
deps.stderr("No GitHub connection was made. No repository was selected.");
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
const selected = candidates[index - 1];
|
|
363
|
+
deps.stderr(`Selected ${candidateLabel(selected)}.`);
|
|
364
|
+
return selected;
|
|
365
|
+
}
|
|
265
366
|
export async function runGithubConnectionFlow(deps, api, repoName) {
|
|
266
367
|
renderStep(deps, 0);
|
|
267
368
|
const minted = await mintGithubConnection(api, repoName);
|
|
@@ -313,39 +414,9 @@ export async function runGithubConnectionFlow(deps, api, repoName) {
|
|
|
313
414
|
return reportNoConnection(deps, OUTCOME_MESSAGES["no-repositories"]);
|
|
314
415
|
}
|
|
315
416
|
renderStep(deps, 3);
|
|
316
|
-
|
|
317
|
-
if (
|
|
318
|
-
|
|
319
|
-
// Even with one option: show the full identity and require a yes. The user is
|
|
320
|
-
// authorizing a binding, not acknowledging a notice.
|
|
321
|
-
const answer = (await deps.promptLine(`Connect ${candidateLabel(only)}? [y/N]: `))
|
|
322
|
-
.trim()
|
|
323
|
-
.toLowerCase();
|
|
324
|
-
if (answer !== "y" && answer !== "yes") {
|
|
325
|
-
deps.stderr("");
|
|
326
|
-
deps.stderr("No GitHub connection was made. Nothing was changed.");
|
|
327
|
-
return 1;
|
|
328
|
-
}
|
|
329
|
-
selected = only;
|
|
330
|
-
}
|
|
331
|
-
else {
|
|
332
|
-
deps.stderr("");
|
|
333
|
-
deps.stderr("Your GitHub installation includes multiple repositories:");
|
|
334
|
-
candidates.forEach((c, i) => {
|
|
335
|
-
deps.stderr(` ${String(i + 1).padStart(2, " ")}. ${candidateLabel(c)}`);
|
|
336
|
-
});
|
|
337
|
-
deps.stderr("");
|
|
338
|
-
// No default: a stray Enter must not bind anything.
|
|
339
|
-
const answer = (await deps.promptLine(`Choose a repository [1-${candidates.length}]: `)).trim();
|
|
340
|
-
const index = Number(answer);
|
|
341
|
-
if (!/^\d+$/.test(answer) || !Number.isInteger(index) || index < 1 || index > candidates.length) {
|
|
342
|
-
deps.stderr("");
|
|
343
|
-
deps.stderr("No GitHub connection was made. No repository was selected.");
|
|
344
|
-
return 1;
|
|
345
|
-
}
|
|
346
|
-
selected = candidates[index - 1];
|
|
347
|
-
deps.stderr(`Selected ${candidateLabel(selected)}.`);
|
|
348
|
-
}
|
|
417
|
+
const selected = await chooseCandidate(deps, candidates);
|
|
418
|
+
if (!selected)
|
|
419
|
+
return 1;
|
|
349
420
|
renderStep(deps, 4);
|
|
350
421
|
const confirmed = await confirmGithubConnection(api, repoName, minted.value.state, selected.github_repository_id);
|
|
351
422
|
if (!confirmed.ok) {
|
|
@@ -365,6 +436,207 @@ export async function runGithubConnectionFlow(deps, api, repoName) {
|
|
|
365
436
|
return 0;
|
|
366
437
|
}
|
|
367
438
|
// ---------------------------------------------------------------------------
|
|
439
|
+
// BAPI-686: delegated handoff modes
|
|
440
|
+
//
|
|
441
|
+
// Three self-contained flows that share nothing with `runGithubConnectionFlow` except
|
|
442
|
+
// the API transport and the candidate picker. The default flow above is deliberately
|
|
443
|
+
// untouched (AC-7): it still opens the browser itself and still never emits a URL.
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
/**
|
|
446
|
+
* The one place in this file permitted to write a bearer URL to stdout.
|
|
447
|
+
*
|
|
448
|
+
* `openGithubInstallPage`'s no-print contract is what keeps the *default* flow's nonce
|
|
449
|
+
* out of the user's scrollback, and it stays intact — mint mode never calls it. Here the
|
|
450
|
+
* URL is the entire deliverable: the admin has to be able to copy it and send it on. It
|
|
451
|
+
* goes to stdout only (never stderr, never a log, never an error path), so it can be
|
|
452
|
+
* piped without dragging the surrounding narration along with it.
|
|
453
|
+
*/
|
|
454
|
+
export async function runGithubHandoffMintFlow(deps, api, repoName) {
|
|
455
|
+
const minted = await mintGithubHandoff(api, repoName);
|
|
456
|
+
if (!minted.ok) {
|
|
457
|
+
return reportNoHandoff(deps, FAILURE_MESSAGES[minted.kind]);
|
|
458
|
+
}
|
|
459
|
+
deps.stdout(minted.value.installUrl);
|
|
460
|
+
deps.stderr("");
|
|
461
|
+
deps.stderr(`Send this link to whoever administers your GitHub organization.`);
|
|
462
|
+
deps.stderr(`It works once and expires ${formatExpiry(minted.value.expiresAt)}.`);
|
|
463
|
+
deps.stderr("They'll finish in their browser — ask them to ping you, then run " +
|
|
464
|
+
"`connect-github --resume`.");
|
|
465
|
+
return 0;
|
|
466
|
+
}
|
|
467
|
+
/** Render an expiry for humans without pretending to know the reader's locale. */
|
|
468
|
+
function formatExpiry(iso) {
|
|
469
|
+
const parsed = Date.parse(iso);
|
|
470
|
+
if (Number.isNaN(parsed))
|
|
471
|
+
return "in 72 hours";
|
|
472
|
+
return `on ${new Date(parsed).toISOString().replace("T", " ").slice(0, 16)} UTC`;
|
|
473
|
+
}
|
|
474
|
+
/** The no-handoff framing, mirroring `reportNoConnection` for the delegated modes. */
|
|
475
|
+
function reportNoHandoff(deps, detail) {
|
|
476
|
+
deps.stderr("");
|
|
477
|
+
deps.stderr("No handoff link was created.");
|
|
478
|
+
deps.stderr(detail);
|
|
479
|
+
return 1;
|
|
480
|
+
}
|
|
481
|
+
/** Statuses a handoff can still be revoked from. Anything else has nothing to withdraw. */
|
|
482
|
+
const REVOCABLE_STATUSES = new Set([
|
|
483
|
+
"pending",
|
|
484
|
+
"awaiting-organization-approval",
|
|
485
|
+
"staged",
|
|
486
|
+
]);
|
|
487
|
+
/** One fixed, actionable line per non-staged handoff status. Never any upstream detail. */
|
|
488
|
+
const HANDOFF_STATUS_MESSAGES = {
|
|
489
|
+
pending: "Nobody has completed the link yet. Ask them to open it, then run connect-github --resume again.",
|
|
490
|
+
expired: "The link expired before it was used. Run connect-github --handoff to create a new one.",
|
|
491
|
+
revoked: "This link was revoked. Run connect-github --handoff to create a new one.",
|
|
492
|
+
"verification-failed": "Bridge could not verify the GitHub installation. Run connect-github --handoff to create a new link.",
|
|
493
|
+
"no-repositories": "The GitHub App installation did not include any repositories Bridge can access. " +
|
|
494
|
+
"Create a new link with connect-github --handoff and ask them to grant access to at least one repository.",
|
|
495
|
+
conflict: "This GitHub installation or project is already connected to a different Bridge " +
|
|
496
|
+
"account or repository. Contact support if that is unexpected.",
|
|
497
|
+
failed: "The GitHub install did not complete. Run connect-github --handoff to create a new link.",
|
|
498
|
+
};
|
|
499
|
+
/**
|
|
500
|
+
* Label one handoff for the selection prompt.
|
|
501
|
+
*
|
|
502
|
+
* Uses ONLY status and timestamps. The state never appears — it is a bearer credential,
|
|
503
|
+
* and printing it into a picker would paste it into the user's scrollback for no reason.
|
|
504
|
+
*/
|
|
505
|
+
function handoffLabel(h) {
|
|
506
|
+
const created = h.createdAt ? ` · created ${h.createdAt.slice(0, 16).replace("T", " ")}` : "";
|
|
507
|
+
return `${h.status}${created}`;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Choose among discovered handoffs.
|
|
511
|
+
*
|
|
512
|
+
* A single candidate is returned without a prompt: unlike a repository *binding*, which
|
|
513
|
+
* always requires an explicit yes, selecting which handoff to inspect changes nothing on
|
|
514
|
+
* its own — the binding confirmation still happens downstream.
|
|
515
|
+
*/
|
|
516
|
+
export async function selectDelegatedHandoff(deps, handoffs) {
|
|
517
|
+
if (handoffs.length === 0)
|
|
518
|
+
return null;
|
|
519
|
+
if (handoffs.length === 1)
|
|
520
|
+
return handoffs[0];
|
|
521
|
+
deps.stderr("");
|
|
522
|
+
deps.stderr("Outstanding handoff links:");
|
|
523
|
+
handoffs.forEach((h, i) => {
|
|
524
|
+
deps.stderr(` ${String(i + 1).padStart(2, " ")}. ${handoffLabel(h)}`);
|
|
525
|
+
});
|
|
526
|
+
deps.stderr("");
|
|
527
|
+
// No default: a stray Enter must not pick one.
|
|
528
|
+
const answer = (await deps.promptLine(`Choose a handoff [1-${handoffs.length}]: `)).trim();
|
|
529
|
+
const index = Number(answer);
|
|
530
|
+
if (!/^\d+$/.test(answer) || !Number.isInteger(index) || index < 1 || index > handoffs.length) {
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
return handoffs[index - 1];
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Look up an outstanding handoff and continue it (AC-4, AC-6).
|
|
537
|
+
*
|
|
538
|
+
* Status-only, never polling: a delegated handoff waits on a person who may take days,
|
|
539
|
+
* so blocking a terminal on it would be useless. The admin re-runs this when they are
|
|
540
|
+
* told the other half is done.
|
|
541
|
+
*/
|
|
542
|
+
export async function runGithubHandoffResumeFlow(deps, api, repoName) {
|
|
543
|
+
const listed = await listGithubHandoffs(api, repoName);
|
|
544
|
+
if (!listed.ok) {
|
|
545
|
+
return reportNoConnection(deps, FAILURE_MESSAGES[listed.kind]);
|
|
546
|
+
}
|
|
547
|
+
if (listed.value.length === 0) {
|
|
548
|
+
return reportNoConnection(deps, "No handoff links exist for this project. Run connect-github --handoff to create one.");
|
|
549
|
+
}
|
|
550
|
+
const selected = await selectDelegatedHandoff(deps, listed.value);
|
|
551
|
+
if (!selected) {
|
|
552
|
+
return reportNoConnection(deps, "No handoff was selected.");
|
|
553
|
+
}
|
|
554
|
+
if (selected.status === "connected") {
|
|
555
|
+
deps.stdout(`Connected ${selected.githubRepoName ?? repoName}.`);
|
|
556
|
+
return 0;
|
|
557
|
+
}
|
|
558
|
+
if (selected.status === "awaiting-organization-approval") {
|
|
559
|
+
// R-6: this is genuinely in-flight, not a failure — the link stays live until its
|
|
560
|
+
// deadline, and approval may still land. Reported as a live state (exit 0) so a
|
|
561
|
+
// scripted caller does not treat "waiting on a human" as an error.
|
|
562
|
+
deps.stderr("");
|
|
563
|
+
deps.stderr("The install was sent to a GitHub organization owner for approval, and that " +
|
|
564
|
+
"approval has not happened yet.");
|
|
565
|
+
deps.stderr("GitHub does not notify Bridge when it lands, so re-run connect-github --resume " +
|
|
566
|
+
"once the owner has approved it.");
|
|
567
|
+
if (selected.expiresAt) {
|
|
568
|
+
deps.stderr(`The link stays usable until ${formatExpiry(selected.expiresAt)}.`);
|
|
569
|
+
}
|
|
570
|
+
return 0;
|
|
571
|
+
}
|
|
572
|
+
if (selected.status !== "staged") {
|
|
573
|
+
const message = HANDOFF_STATUS_MESSAGES[selected.status] ?? HANDOFF_STATUS_MESSAGES.failed;
|
|
574
|
+
if (selected.status === "pending" || selected.status === "expired") {
|
|
575
|
+
// AC-8 fallback. Bridge cannot safely discover an existing installation on an org
|
|
576
|
+
// it was never called back about, so this deliberately does NOT claim to have
|
|
577
|
+
// detected one — it points at the documented manual path instead.
|
|
578
|
+
deps.stderr("");
|
|
579
|
+
deps.stderr(message);
|
|
580
|
+
deps.stderr("If the GitHub App is already installed on that organization, GitHub may not " +
|
|
581
|
+
"send Bridge a callback at all. In that case follow the manual installation-ID " +
|
|
582
|
+
"steps in the GitHub App setup guide (docs/install/github-app.md).");
|
|
583
|
+
// `pending` is a live state, not a failure; `expired` is terminal.
|
|
584
|
+
return selected.status === "pending" ? 0 : 1;
|
|
585
|
+
}
|
|
586
|
+
return reportNoConnection(deps, message);
|
|
587
|
+
}
|
|
588
|
+
// Staged: reuse the existing candidate flow verbatim — same labels, same picker, same
|
|
589
|
+
// explicit [y/N] on a single candidate. A delegated handoff gets no auto-bind shortcut.
|
|
590
|
+
const candidates = selected.candidates;
|
|
591
|
+
if (candidates.length === 0) {
|
|
592
|
+
return reportNoConnection(deps, OUTCOME_MESSAGES["no-repositories"]);
|
|
593
|
+
}
|
|
594
|
+
const chosen = await chooseCandidate(deps, candidates);
|
|
595
|
+
if (!chosen)
|
|
596
|
+
return 1;
|
|
597
|
+
const confirmed = await confirmGithubConnection(api, repoName, selected.state, chosen.github_repository_id);
|
|
598
|
+
if (!confirmed.ok) {
|
|
599
|
+
return reportNoConnection(deps, FAILURE_MESSAGES[confirmed.kind]);
|
|
600
|
+
}
|
|
601
|
+
deps.stdout(`Connected ${confirmed.value.githubRepoFullName ?? confirmed.value.githubRepoName}.`);
|
|
602
|
+
if (confirmed.value.indexingStatus === "started") {
|
|
603
|
+
deps.stdout("Indexing started automatically.");
|
|
604
|
+
}
|
|
605
|
+
else if (confirmed.value.indexingStatus === "waiting-for-setup") {
|
|
606
|
+
deps.stdout("Indexing will start automatically once setup completes.");
|
|
607
|
+
}
|
|
608
|
+
return 0;
|
|
609
|
+
}
|
|
610
|
+
/** Invalidate an outstanding handoff (R-5). */
|
|
611
|
+
export async function runGithubHandoffRevokeFlow(deps, api, repoName) {
|
|
612
|
+
const listed = await listGithubHandoffs(api, repoName);
|
|
613
|
+
if (!listed.ok) {
|
|
614
|
+
return reportNoRevocation(deps, FAILURE_MESSAGES[listed.kind]);
|
|
615
|
+
}
|
|
616
|
+
const revocable = listed.value.filter((h) => REVOCABLE_STATUSES.has(h.status));
|
|
617
|
+
if (revocable.length === 0) {
|
|
618
|
+
return reportNoRevocation(deps, "There are no handoff links left to revoke.");
|
|
619
|
+
}
|
|
620
|
+
const selected = await selectDelegatedHandoff(deps, revocable);
|
|
621
|
+
if (!selected) {
|
|
622
|
+
return reportNoRevocation(deps, "No handoff was selected.");
|
|
623
|
+
}
|
|
624
|
+
const revoked = await revokeGithubHandoff(api, repoName, selected.state);
|
|
625
|
+
if (!revoked.ok) {
|
|
626
|
+
return reportNoRevocation(deps, FAILURE_MESSAGES[revoked.kind]);
|
|
627
|
+
}
|
|
628
|
+
deps.stdout(revoked.value === "already-revoked"
|
|
629
|
+
? "That handoff link was already revoked."
|
|
630
|
+
: "Handoff link revoked. It can no longer be used.");
|
|
631
|
+
return 0;
|
|
632
|
+
}
|
|
633
|
+
function reportNoRevocation(deps, detail) {
|
|
634
|
+
deps.stderr("");
|
|
635
|
+
deps.stderr("Nothing was revoked.");
|
|
636
|
+
deps.stderr(detail);
|
|
637
|
+
return 1;
|
|
638
|
+
}
|
|
639
|
+
// ---------------------------------------------------------------------------
|
|
368
640
|
// Process boundary
|
|
369
641
|
// ---------------------------------------------------------------------------
|
|
370
642
|
/**
|
|
@@ -386,14 +658,22 @@ export async function runConnectGithubCli(argv, injected) {
|
|
|
386
658
|
deps.stdout(USAGE);
|
|
387
659
|
return 0;
|
|
388
660
|
}
|
|
389
|
-
|
|
661
|
+
const mintOnly = parsed.value.handoff;
|
|
662
|
+
// Checked BEFORE minting: these flows always end in a human choice, so a
|
|
390
663
|
// non-interactive run can only ever strand a code it can never confirm.
|
|
391
|
-
|
|
664
|
+
//
|
|
665
|
+
// `--handoff` is the one exception (BAPI-686), and only because the premise no
|
|
666
|
+
// longer holds for it: it ends at a printed URL, not at a prompt, and it strands
|
|
667
|
+
// nothing because handoffs are discoverable server-side afterwards via --resume.
|
|
668
|
+
// --resume and --revoke both still prompt, so they keep the gate.
|
|
669
|
+
if (!deps.isTTY && !mintOnly) {
|
|
392
670
|
deps.stderr("connect-github needs an interactive terminal: it asks you to confirm which " +
|
|
393
671
|
"repository to connect. Run it directly in your terminal.");
|
|
394
672
|
return 1;
|
|
395
673
|
}
|
|
396
|
-
const repo =
|
|
674
|
+
const repo = mintOnly
|
|
675
|
+
? resolveHandoffRepoName(parsed.value, deps)
|
|
676
|
+
: await resolveConnectGithubRepoName(parsed.value, deps);
|
|
397
677
|
if (!repo.ok) {
|
|
398
678
|
deps.stderr(repo.error);
|
|
399
679
|
return 1;
|
|
@@ -415,6 +695,12 @@ export async function runConnectGithubCli(argv, injected) {
|
|
|
415
695
|
baseUrl: deps.env.BAPI_BASE_URL?.trim() || DEFAULT_BAPI_BASE_URL,
|
|
416
696
|
apiKey: cred.credentials.apiKey,
|
|
417
697
|
};
|
|
698
|
+
if (parsed.value.handoff)
|
|
699
|
+
return await runGithubHandoffMintFlow(deps, api, repo.value);
|
|
700
|
+
if (parsed.value.resume)
|
|
701
|
+
return await runGithubHandoffResumeFlow(deps, api, repo.value);
|
|
702
|
+
if (parsed.value.revoke)
|
|
703
|
+
return await runGithubHandoffRevokeFlow(deps, api, repo.value);
|
|
418
704
|
return await runGithubConnectionFlow(deps, api, repo.value);
|
|
419
705
|
}
|
|
420
706
|
catch {
|