@bridge_gpt/mcp-server 0.2.31 → 0.2.33
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 +53 -21
- package/build/connect-github-api.js +103 -0
- package/build/connect-github.js +325 -39
- package/build/credential-store.js +302 -18
- package/build/doctor.js +80 -35
- package/build/index.js +31 -18
- package/build/install-bridge.js +1477 -440
- package/build/install-doctor.js +37 -4
- package/build/readme.generated.js +1 -1
- package/build/start-tickets.js +12 -1
- package/build/version.generated.js +1 -1
- package/docs/install/github-app.md +96 -8
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -31,18 +31,29 @@ npx -y @bridge_gpt/mcp-server@latest install-bridge
|
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
Run bare like that in a terminal and it starts by asking
|
|
34
|
-
**`
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
- **
|
|
43
|
-
Bridge
|
|
44
|
-
|
|
45
|
-
|
|
34
|
+
**`How would you like to connect to Bridge API?`** with three numbered choices:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
1. I have a Bridge API key
|
|
38
|
+
2. I have an invite token
|
|
39
|
+
3. I'm new — set me up with just my email
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- **1** — the existing-key flow. It asks for your **API key** (generate one on the
|
|
43
|
+
Bridge API web UI **Security** page) and a **repo name** matching your server-side
|
|
44
|
+
registration; everything else is derived. A `bapi_inv_…` credential entered here
|
|
45
|
+
instead of a full API key is automatically detected and redeemed as a **bootstrap
|
|
46
|
+
invite** — it creates a brand-new project and mints your admin API key rather than
|
|
47
|
+
looking up an existing repository.
|
|
48
|
+
- **2** — the **bootstrap-invite** flow. It asks for the invite token you were given,
|
|
49
|
+
with echo suppressed. Same as passing `--invite` (see below).
|
|
50
|
+
- **3** — the **self-serve** flow, and the right answer if you have nothing yet. It
|
|
51
|
+
asks for an **email**, then a name for your new Bridge project, and creates the
|
|
52
|
+
workspace and your own admin API key for you. No account, no key, and no invite
|
|
53
|
+
needed beforehand. Same as passing `--email you@example.com` (see below).
|
|
54
|
+
|
|
55
|
+
There is **no default**: pressing Enter selects nothing. A blank or invalid answer
|
|
56
|
+
re-prompts once with a hint, then exits with guidance naming all three routes.
|
|
46
57
|
|
|
47
58
|
That question is asked only for a *bare interactive* run. Passing any flag, setting
|
|
48
59
|
`BAPI_API_KEY`, or running without an interactive terminal skips it and keeps the
|
|
@@ -51,11 +62,12 @@ existing deterministic behavior.
|
|
|
51
62
|
From there `install-bridge` scaffolds the project, writes your editor's MCP config
|
|
52
63
|
with real values, verifies connectivity, persists your API key to the user-scoped
|
|
53
64
|
credential store, and opens a fresh agent session that runs `/install-bridge` to
|
|
54
|
-
derive and apply the remaining config, presents a **capability report**
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
65
|
+
derive and apply the remaining config, presents a concise **capability report**
|
|
66
|
+
("What Bridge can help with"), and recommends `/learn-repository` as your next step.
|
|
67
|
+
It does **not** run `/learn-repository` itself — that stays your next explicit
|
|
68
|
+
invocation. There is **no indexing question**: indexing starts automatically
|
|
69
|
+
server-side once the repository reaches full parse readiness. Add `--dry-run` to
|
|
70
|
+
preview every step without writing, pinging, or spawning anything.
|
|
59
71
|
|
|
60
72
|
**Were you sent a bootstrap invite?** Then you don't need an API key or the web UI
|
|
61
73
|
at all — run the command your operator gave you:
|
|
@@ -148,12 +160,30 @@ shown**.
|
|
|
148
160
|
The email may instead come from the `BAPI_SIGNUP_EMAIL` environment variable or a
|
|
149
161
|
**visible** interactive prompt (email is not a secret, so it is echoed as you type —
|
|
150
162
|
unlike the API key and the invite token, which use a hidden prompt). That prompt is
|
|
151
|
-
what
|
|
152
|
-
|
|
153
|
-
|
|
163
|
+
what **option 3** of the bare-run chooser reaches, so
|
|
164
|
+
`install-bridge --email you@example.com` and a bare `install-bridge` + `3` land in
|
|
165
|
+
the same place. The email is still **never written to a log line**. No email
|
|
154
166
|
verification is performed and no message is sent to the address — it only labels your
|
|
155
167
|
new workspace. `--email` is mutually exclusive with `--api-key` and `--invite`.
|
|
156
168
|
|
|
169
|
+
##### Self-serve retries resume automatically
|
|
170
|
+
|
|
171
|
+
A self-serve run that fails part-way through — a network blip on the exchange, a
|
|
172
|
+
failed connectivity check, an interrupted credential write — saves its signup state
|
|
173
|
+
under `bootstrap-pending:<repo>` in the credential store (mode `0600`, fsync'd, the
|
|
174
|
+
same record that already holds your `key_secret`).
|
|
175
|
+
|
|
176
|
+
**Just re-run the self-serve flow.** It detects that saved attempt, prints
|
|
177
|
+
`resuming your previous signup attempt for <repo>`, and re-drives the *same*
|
|
178
|
+
exchange — it does **not** sign up again, so a retry never creates a second
|
|
179
|
+
workspace. This is why the self-serve record stores the minted invite token: you
|
|
180
|
+
were never shown that token, so nothing else could re-present it.
|
|
181
|
+
|
|
182
|
+
Do **not** copy, display, or hand-remove that record. If the saved invite has
|
|
183
|
+
genuinely expired, the CLI says so and **asks for confirmation** before discarding
|
|
184
|
+
it and starting fresh — it never discards it silently, because a record whose
|
|
185
|
+
exchange already succeeded is the only trace of a live admin key.
|
|
186
|
+
|
|
157
187
|
Because this flow *creates* the project, it asks you to **name a new project**
|
|
158
188
|
(`Name your new Bridge project [<inferred>]: `) rather than to match an existing
|
|
159
189
|
server-side registration. The name must be globally unique; if it's taken, you're
|
|
@@ -689,12 +719,14 @@ Per-OS prerequisites: macOS `wt`, `git`, `osascript`; Windows `git-wt`, Git for
|
|
|
689
719
|
|
|
690
720
|
### `doctor`
|
|
691
721
|
|
|
692
|
-
The package also ships a strictly **read-only** `doctor` subcommand
|
|
722
|
+
The package also ships a strictly **read-only** `doctor` subcommand — titled **`bridge doctor — read-only diagnostics`** — that diagnoses your whole Bridge install without changing anything:
|
|
693
723
|
|
|
694
724
|
```
|
|
695
725
|
npx -y @bridge_gpt/mcp-server doctor [--agent <name>]
|
|
696
726
|
```
|
|
697
727
|
|
|
728
|
+
The report always leads with the advisory **`Install status`** section (repo identity, credential resolution, server connectivity, bootstrap-field completeness, integration credentials, indexing state) **before** the `start-tickets` prerequisite diagnostics; the launcher-cache and MCP tool-surface sections follow. `Install status` is read-only GETs only and never affects the exit code.
|
|
729
|
+
|
|
698
730
|
It is **read-only**: it never installs anything, modifies your system, adds an npm `postinstall`, spawns a terminal, or starts the MCP server, and there is no `--fix`. For each prerequisite it prints found/missing and, when missing, the exact per-OS install command **as a manual instruction you run yourself**. The checked set is the `start-tickets` preflight prerequisites **plus `uv`** **plus the selected agent's command** (`claude` by default, or `cursor-agent` with `--agent cursor-agent`). The Worktrunk binary is probed via the resolved name (honoring `BAPI_WORKTRUNK_BIN`), not a hard-coded one. **Exit code:** `0` when all required prerequisites are present, non-zero when any is missing or the platform is unsupported. A failing `start-tickets` preflight now hints you to run `doctor` for an actionable diagnostics report.
|
|
699
731
|
|
|
700
732
|
### `setup-epic`
|
|
@@ -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 {
|