@bridge_gpt/mcp-server 0.2.21 → 0.2.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -18
- package/build/base-ref.js +151 -0
- package/build/commands.generated.js +6 -4
- package/build/conductor/bridge-api-client.js +44 -3
- package/build/conductor/doctor.js +33 -22
- package/build/conductor/epic-runtime.js +101 -5
- package/build/conductor/pr-ci-producer.js +21 -2
- package/build/conductor/pr-discovery.js +12 -2
- package/build/conductor-bin.js +50 -20
- package/build/credential-store.js +564 -64
- package/build/decision-page-template.js +9 -4
- package/build/docs.generated.js +5 -0
- package/build/executor/base-branch.js +50 -0
- package/build/executor/env.js +12 -1
- package/build/executor/job-errors.js +1 -0
- package/build/executor/job-runner.js +38 -7
- package/build/executor/test-clock.js +6 -1
- package/build/executor/worker-finalization.js +88 -1
- package/build/executor/worktree.js +21 -1
- package/build/index.js +2741 -702
- package/build/init.js +29 -0
- package/build/install-bridge.js +1076 -114
- package/build/pipelines.generated.js +2 -2
- package/build/pr-base-contract.js +36 -0
- package/build/readme.generated.js +1 -1
- package/build/setup-epic.js +483 -0
- package/build/sfcc/log-gate.js +85 -0
- package/build/sfcc/log-query.js +170 -0
- package/build/sfcc/register.js +10 -0
- package/build/sfcc/setup-status.js +33 -3
- package/build/start-tickets.js +164 -75
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +62 -10
- package/{CONDUCTOR.md → docs/CONDUCTOR.md} +88 -29
- package/docs/install/github-app.md +189 -0
- package/docs/install/mcp-tool-integrations.md +305 -0
- package/docs/install/sfcc-integration.md +140 -0
- package/package.json +5 -5
- package/public/js/main.min.js +55 -10
- package/public/js/main.min.js.map +1 -1
- package/smoke-test/SMOKE-TEST.md +3 -2
package/build/install-bridge.js
CHANGED
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
* Collapses the previously five-step manual setup into a single CLI subcommand.
|
|
7
7
|
* It does the DETERMINISTIC work in the shell:
|
|
8
8
|
*
|
|
9
|
+
* Step 0 Resolve the repository name. `--repo` / `BAPI_REPO_NAME` short-circuit
|
|
10
|
+
* deterministically; otherwise, on the consume-an-existing-key flow, a
|
|
11
|
+
* compatible server resolves the unique repo from the API key via a
|
|
12
|
+
* read-only `GET <base>/setup/resolve-repo` (BAPI-616) BEFORE the
|
|
13
|
+
* connectivity check. An old server / unresolvable key / failure falls
|
|
14
|
+
* back to the existing local prompt/inference — never a hard failure.
|
|
9
15
|
* Step 1 Scaffold via `runInit` (commands, agents, pipelines, secret-free
|
|
10
16
|
* per-host MCP config placeholders, .bridge/config).
|
|
11
17
|
* Step 2 Verify connectivity over HTTP (`GET <base>/jira/ping?repo_name=…`
|
|
@@ -22,19 +28,42 @@
|
|
|
22
28
|
* (`~/.config/bridge/credentials.json`, target `bapi:<repo>`) via
|
|
23
29
|
* `upsertBapiCredential`. Non-blocking (fail-open) — mirrors Stage 6.
|
|
24
30
|
*
|
|
25
|
-
* then SPAWNS a fresh agent session (Step 5) for the agentic
|
|
26
|
-
*
|
|
31
|
+
* then SPAWNS a fresh agent session (Step 5) for the CONFIGURE-ONLY agentic
|
|
32
|
+
* remainder: `/install-bridge` config-field derivation → the read-after-write
|
|
33
|
+
* capability report → a single optional indexing-consent question. It does NOT
|
|
34
|
+
* chain `/learn-repository` and does NOT index without explicit consent. The
|
|
27
35
|
* fresh session is required because a CLI cannot force the running editor to
|
|
28
36
|
* reload the just-written `.mcp.json`, and field derivation needs an agent
|
|
29
37
|
* runtime the shell does not have.
|
|
30
38
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
39
|
+
* BOOTSTRAP-INVITE MODE (BAPI-606) — the one exception to "this command consumes
|
|
40
|
+
* a key, it does not create one". With `--invite` (or `BAPI_INVITE`) there is no
|
|
41
|
+
* API key yet, so the pre-flight ping of Step 2 CANNOT be made: the exchange is
|
|
42
|
+
* what mints the key, and it REPLACES that ping. The order becomes:
|
|
43
|
+
*
|
|
44
|
+
* resolve bootstrap-invite token (no-echo prompt by DEFAULT — the delivered
|
|
45
|
+
* one-liner is secret-free) → resolve repo name → scaffold → generate
|
|
46
|
+
* `key_secret` (CSPRNG) and **fsync it to a pending credential record** →
|
|
47
|
+
* `POST <base>/setup/bootstrap` {token, repo_name, key_secret} → ping with the
|
|
48
|
+
* newly-minted key → write host configs → promote pending → `bapi:<repo>` →
|
|
49
|
+
* spawn. Everything from the ping onward is the existing flow, unchanged.
|
|
50
|
+
*
|
|
51
|
+
* The persist-before-exchange ordering is LOAD-BEARING PROTOCOL (see the comment
|
|
52
|
+
* at the pending-preparation call site): the local `key_secret` is the ONLY proof
|
|
53
|
+
* that can replay a redemption, so a successful exchange with a failed local write
|
|
54
|
+
* would leave an unrecoverable admin key and a permanently spent invite.
|
|
55
|
+
*
|
|
56
|
+
* SECRET DISCIPLINE: THREE secrets now — the API key, the bootstrap-invite token,
|
|
57
|
+
* and the client-generated `key_secret`. NONE is ever printed or logged — not in
|
|
58
|
+
* stdout, stderr, error messages, or --dry-run output (they are redacted to
|
|
59
|
+
* `<REDACTED>`). The ONLY places a secret is durably written are the per-host MCP
|
|
60
|
+
* config (Step 3, gitignored) and the user-scoped credential store (Step 4 / the
|
|
61
|
+
* pending record). The bootstrap-invite token itself is NEVER written to disk —
|
|
62
|
+
* only a SHA-256 fingerprint of it, to key the pending record.
|
|
35
63
|
*/
|
|
36
|
-
import { readFile, writeFile, mkdir, stat, rename, chmod, unlink } from "fs/promises";
|
|
64
|
+
import { readFile, writeFile, mkdir, stat, rename, chmod, unlink, open } from "fs/promises";
|
|
37
65
|
import { spawn } from "child_process";
|
|
66
|
+
import { randomBytes as cryptoRandomBytes, createHash } from "crypto";
|
|
38
67
|
import os from "os";
|
|
39
68
|
import path from "path";
|
|
40
69
|
import readline from "readline";
|
|
@@ -42,7 +71,7 @@ import { runInit, buildBridgeApiEntry } from "./init.js";
|
|
|
42
71
|
import { VERSION } from "./version.generated.js";
|
|
43
72
|
import { validateRepoName } from "./bridge-config.js";
|
|
44
73
|
import { resolveStartTicketsRepoName } from "./start-tickets-repo.js";
|
|
45
|
-
import { upsertBapiCredential, getPrimaryCredentialStorePath, } from "./credential-store.js";
|
|
74
|
+
import { upsertBapiCredential, getPrimaryCredentialStorePath, prepareBootstrapPendingCredential, repointBootstrapPendingCredential, promoteBootstrapPendingCredential, } from "./credential-store.js";
|
|
46
75
|
import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, formatValidAgentNames, } from "./agent-registry.js";
|
|
47
76
|
import { buildGenericAgentShellCommand, getDefaultSpawnTerminalTabForPlatform, detectTerminal, createDefaultStartTicketsDeps, } from "./start-tickets.js";
|
|
48
77
|
/** Redaction sentinel — the API-key value is NEVER printed; this stands in. */
|
|
@@ -64,15 +93,44 @@ export function buildPrewarmArgs() {
|
|
|
64
93
|
export function buildPrewarmCommandPreview() {
|
|
65
94
|
return `npx ${buildPrewarmArgs().join(" ")}`;
|
|
66
95
|
}
|
|
67
|
-
/**
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
96
|
+
/**
|
|
97
|
+
* The natural-language prompt handed to the spawned agent session. It is
|
|
98
|
+
* CONFIGURE-ONLY: it derives and applies configuration, presents the capability
|
|
99
|
+
* report, and ends with exactly one indexing-consent question. It never chains
|
|
100
|
+
* /learn-repository and never indexes before explicit consent.
|
|
101
|
+
*
|
|
102
|
+
* The index-consent beat is AGENT-OWNED (asked inside this spawned session), not
|
|
103
|
+
* parent-CLI-owned, because `spawnTerminalTab()` opens an asynchronous terminal
|
|
104
|
+
* session: the parent CLI returns immediately and cannot reliably ask a question
|
|
105
|
+
* that must appear AFTER the spawned session's capability report. Asking it via
|
|
106
|
+
* the parent's `deps.promptLine` would display the question before the report
|
|
107
|
+
* even exists. `deps.promptLine` therefore stays the owner of the parent-CLI
|
|
108
|
+
* prompts (repository / overwrite / invite confirmation) and is intentionally
|
|
109
|
+
* NOT used for this post-report question.
|
|
110
|
+
*/
|
|
111
|
+
export const INSTALL_BRIDGE_AGENT_PROMPT = "Execute the /install-bridge command in the install-spawn context (tell the command it is running " +
|
|
112
|
+
"in the install-spawn context so it SKIPS its Stage 8 and Stage 9 offers — this session's only " +
|
|
113
|
+
"closing interaction is the single indexing question below). Do NOT run /learn-repository. Do NOT " +
|
|
114
|
+
"call parse_repository (or otherwise start indexing) before the capability report and explicit " +
|
|
115
|
+
"consent below. " +
|
|
116
|
+
"Complete the command's read-after-write five-section capability report first: 'Connected ✓', " +
|
|
117
|
+
"'Not yet connected ✗', 'Tools you can use now', 'Tools you'll unlock', and 'Recommended next " +
|
|
118
|
+
"step + why'. " +
|
|
119
|
+
"Only AFTER that report is fully presented, ask exactly one question using this visible prompt: " +
|
|
120
|
+
"'[Y/n] Index repository now?'. Only an explicit affirmative answer (e.g. 'y'/'yes') starts " +
|
|
121
|
+
"indexing; a blank answer, a negative answer, EOF, an unavailable interaction, and any " +
|
|
122
|
+
"non-interactive/headless run all resolve to NO. " +
|
|
123
|
+
"On an affirmative answer: call the parse_repository MCP tool exactly once, describe the accepted " +
|
|
124
|
+
"job as QUEUED, and direct later progress checks to get_parse_status or /check-parse-status " +
|
|
125
|
+
"(do NOT poll it to completion). If parse_repository returns a blocking refusal or error, do NOT " +
|
|
126
|
+
"claim the job was queued — report the sanitized result and leave indexing pending. " +
|
|
127
|
+
"On NO (or any unavailable/non-interactive resolution): do not index; print the exact copy-paste " +
|
|
128
|
+
"continuation command '/parse-repository' on its own line and state that indexing remains pending. " +
|
|
129
|
+
"Never request, echo, or transport any credential — only ever direct the human to the setup UI via " +
|
|
130
|
+
"the command's configure_in pointer. " +
|
|
131
|
+
"End with an explicit summary line stating how many config fields the apply_install_manifest call " +
|
|
132
|
+
"applied (e.g. 'Applied 8 of 9 derived fields') and whether indexing was queued or left pending — " +
|
|
133
|
+
"if 0 fields were applied, say so loudly and explain what is still pending.";
|
|
76
134
|
/** Default base URL when `BAPI_BASE_URL` is unset (mirrors index.ts). */
|
|
77
135
|
export const DEFAULT_BAPI_BASE_URL = "https://bridgegpt-api.com";
|
|
78
136
|
/** Default docs dir when `BAPI_DOCS_DIR` is unset (mirrors index.ts). */
|
|
@@ -86,39 +144,85 @@ export function getInstallBridgeUsage() {
|
|
|
86
144
|
"One-command Bridge API project bootstrap. Scaffolds the project, writes the",
|
|
87
145
|
"per-host MCP config with your credentials, verifies connectivity, persists the",
|
|
88
146
|
"routing credential, then opens a fresh agent session to derive the remaining",
|
|
89
|
-
"config and
|
|
147
|
+
"config, present a capability report, and offer optional repository indexing.",
|
|
90
148
|
"",
|
|
91
149
|
"Inputs (the only two irreducible ones):",
|
|
92
150
|
" --api-key <key> Bridge API key. Falls back to the BAPI_API_KEY env var,",
|
|
93
151
|
" then an interactive (no-echo) prompt. Generate one in the",
|
|
94
152
|
" Bridge API web UI Security page — this command consumes a",
|
|
95
|
-
" key, it does not create one
|
|
96
|
-
"
|
|
97
|
-
"
|
|
98
|
-
"
|
|
99
|
-
"
|
|
100
|
-
" is
|
|
153
|
+
" key, it does not create one (--invite is the one exception:",
|
|
154
|
+
" it CREATES the project and its first admin key). NEVER",
|
|
155
|
+
" printed or logged.",
|
|
156
|
+
" --repo <name> Repository name. --repo and BAPI_REPO_NAME still take",
|
|
157
|
+
" priority and short-circuit before any network call. When",
|
|
158
|
+
" neither is set, a compatible server resolves the unique",
|
|
159
|
+
" repository from your existing API key automatically; if the",
|
|
160
|
+
" server is older, the key is unresolvable, or resolution",
|
|
161
|
+
" fails, it falls back to an inferred default you confirm",
|
|
162
|
+
" interactively (and to a required --repo when stdin is",
|
|
163
|
+
" non-interactive). MUST match the server-side repo",
|
|
164
|
+
" registration (it keys the credential store as bapi:<repo>).",
|
|
165
|
+
" With --invite it is the name your NEW project is created",
|
|
166
|
+
" under (globally unique).",
|
|
167
|
+
"",
|
|
168
|
+
"Self-serve onboarding (no account, no API key, no pre-issued invite):",
|
|
169
|
+
" --email <addr> Create a brand-new Bridge workspace from just an email —",
|
|
170
|
+
" the primary path for a first-time user with nothing yet.",
|
|
171
|
+
" It requests a fresh workspace for that email, then creates",
|
|
172
|
+
" the project and mints your own admin API key in one command.",
|
|
173
|
+
" Falls back to the BAPI_SIGNUP_EMAIL env var, then a visible",
|
|
174
|
+
" interactive prompt. The email is NOT a secret (it is shown",
|
|
175
|
+
" as you type), but it is never printed to a log. Mutually",
|
|
176
|
+
" exclusive with --api-key and --invite. No email verification",
|
|
177
|
+
" is performed and no message is sent to the address — it only",
|
|
178
|
+
" labels the new workspace.",
|
|
179
|
+
"",
|
|
180
|
+
"Bootstrap-invite onboarding (no web UI, no pre-existing key):",
|
|
181
|
+
" --invite [token] Redeem a bootstrap invite you were already given: creates",
|
|
182
|
+
" the project and mints your own admin API key in one command.",
|
|
183
|
+
" Mutually exclusive with --api-key and --email (in this mode",
|
|
184
|
+
" the key is created, not consumed).",
|
|
185
|
+
"",
|
|
186
|
+
" Run it WITHOUT a value — `install-bridge --invite` — and the",
|
|
187
|
+
" token is read from an interactive prompt with echo",
|
|
188
|
+
" suppressed, then sent only in the request body. This is the",
|
|
189
|
+
" default and the recommended path: 'a copy/paste one-liner'",
|
|
190
|
+
" and 'the token never touches shell history' are",
|
|
191
|
+
" contradictory, so the one-liner your operator sends you is",
|
|
192
|
+
" SECRET-FREE and the CLI asks for the token.",
|
|
193
|
+
"",
|
|
194
|
+
" Passing the token inline (--invite <token>, --invite=<token>)",
|
|
195
|
+
" or via BAPI_INVITE is for SCRIPTING ONLY: both forms EXPOSE",
|
|
196
|
+
" THE TOKEN to your shell history and to the process list.",
|
|
101
197
|
"",
|
|
102
198
|
"Flags:",
|
|
103
199
|
" --force Overwrite an existing real BAPI_API_KEY in a",
|
|
104
|
-
" host config without
|
|
200
|
+
" host config (or in the credential store) without",
|
|
201
|
+
" prompting.",
|
|
105
202
|
" --dry-run Preview every step (scaffold targets, config",
|
|
106
203
|
" files + keys with the key REDACTED, ping",
|
|
107
204
|
" target, credential target, spawn command)",
|
|
108
205
|
" without writing, pinging, or spawning anything.",
|
|
206
|
+
" With --invite it also never calls the exchange",
|
|
207
|
+
" endpoint and never generates or stores a secret.",
|
|
109
208
|
" --agent claude|cursor-agent Agent to launch for the agentic remainder",
|
|
110
209
|
" (default: claude).",
|
|
111
210
|
" -h, --help Show this help.",
|
|
112
211
|
"",
|
|
113
212
|
"Environment: BAPI_BASE_URL (default https://bridgegpt-api.com) and BAPI_DOCS_DIR",
|
|
114
213
|
"(default docs/tmp) are read from the environment with the shown fallbacks.",
|
|
214
|
+
"BAPI_SIGNUP_EMAIL supplies the self-serve signup email non-interactively (it is",
|
|
215
|
+
"visible input, not a secret). BAPI_INVITE supplies the bootstrap-invite token",
|
|
216
|
+
"non-interactively (scripting only — it is exposed to shell history; prefer the",
|
|
217
|
+
"prompt).",
|
|
115
218
|
].join("\n");
|
|
116
219
|
}
|
|
117
220
|
/**
|
|
118
221
|
* Parse argv strictly. Supports `--api-key`, `--repo`, `--agent` (each with a
|
|
119
|
-
* `--flag value` or `--flag=value` form), the
|
|
120
|
-
* and `-h`/`--help`. Unknown flags and positional
|
|
121
|
-
*
|
|
222
|
+
* `--flag value` or `--flag=value` form), the OPTIONAL-value `--invite`, the
|
|
223
|
+
* boolean `--force` / `--dry-run`, and `-h`/`--help`. Unknown flags and positional
|
|
224
|
+
* args are rejected. The API-key and bootstrap-invite values are captured but
|
|
225
|
+
* never echoed (no error message includes either).
|
|
122
226
|
*/
|
|
123
227
|
export function parseInstallBridgeArgs(argv) {
|
|
124
228
|
if (argv.includes("-h") || argv.includes("--help")) {
|
|
@@ -129,6 +233,13 @@ export function parseInstallBridgeArgs(argv) {
|
|
|
129
233
|
let force = false;
|
|
130
234
|
let dryRun = false;
|
|
131
235
|
let agentName = DEFAULT_AGENT_NAME;
|
|
236
|
+
let invite;
|
|
237
|
+
let email;
|
|
238
|
+
// Track SUPPLIED-ness separately from the values: `--invite` is legitimately
|
|
239
|
+
// valueless (prompt path) and `--api-key ""` is still a contradiction with it.
|
|
240
|
+
let inviteSupplied = false;
|
|
241
|
+
let apiKeySupplied = false;
|
|
242
|
+
let emailSupplied = false;
|
|
132
243
|
/** Read a `--flag value` or `--flag=value` value, advancing the index. */
|
|
133
244
|
const readValue = (arg, flag, i) => {
|
|
134
245
|
if (arg.startsWith(`${flag}=`)) {
|
|
@@ -154,6 +265,39 @@ export function parseInstallBridgeArgs(argv) {
|
|
|
154
265
|
if ("error" in r)
|
|
155
266
|
return { status: "error", message: r.error };
|
|
156
267
|
apiKey = r.value;
|
|
268
|
+
apiKeySupplied = true;
|
|
269
|
+
i = r.nextIndex;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (arg === "--invite" || arg.startsWith("--invite=")) {
|
|
273
|
+
inviteSupplied = true;
|
|
274
|
+
if (arg.startsWith("--invite=")) {
|
|
275
|
+
invite = arg.slice("--invite=".length);
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
// OPTIONAL value: a bare `--invite` selects bootstrap-invite mode and the
|
|
279
|
+
// token is prompted for (no echo). Only a following non-flag token is
|
|
280
|
+
// consumed as the value — a bootstrap-invite token never starts with '-'.
|
|
281
|
+
const next = argv[i + 1];
|
|
282
|
+
if (typeof next === "string" && !next.startsWith("-")) {
|
|
283
|
+
invite = next;
|
|
284
|
+
i += 1;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (arg === "--email" || arg.startsWith("--email=")) {
|
|
290
|
+
// REQUIRED-value flag (unlike --invite): the email is what selects and drives
|
|
291
|
+
// the self-serve mint, so a bare or blank `--email` is a hard error rather
|
|
292
|
+
// than a prompt trigger. The value is NOT echoed in any error message.
|
|
293
|
+
const r = readValue(arg, "--email", i);
|
|
294
|
+
if ("error" in r)
|
|
295
|
+
return { status: "error", message: r.error };
|
|
296
|
+
if (r.value.trim().length === 0) {
|
|
297
|
+
return { status: "error", message: "--email requires a non-empty value." };
|
|
298
|
+
}
|
|
299
|
+
email = r.value.trim();
|
|
300
|
+
emailSupplied = true;
|
|
157
301
|
i = r.nextIndex;
|
|
158
302
|
continue;
|
|
159
303
|
}
|
|
@@ -187,38 +331,109 @@ export function parseInstallBridgeArgs(argv) {
|
|
|
187
331
|
message: `Unexpected positional argument: '${arg}'. install-bridge does not accept positional arguments.`,
|
|
188
332
|
};
|
|
189
333
|
}
|
|
190
|
-
|
|
334
|
+
// Mutually exclusive by construction: bootstrap-invite mode CREATES the key, so
|
|
335
|
+
// consuming one is a contradiction. Names the flags only — never their values.
|
|
336
|
+
if (inviteSupplied && apiKeySupplied) {
|
|
337
|
+
return {
|
|
338
|
+
status: "error",
|
|
339
|
+
message: "--invite and --api-key are mutually exclusive: a bootstrap invite creates your API key, " +
|
|
340
|
+
"it does not consume an existing one.",
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
// --email selects self-serve mode, which CREATES the key — so it conflicts with
|
|
344
|
+
// both consuming an existing one (--api-key) and redeeming a pre-issued invite
|
|
345
|
+
// (--invite). Names the flags only — never the email or token values (BAPI-618).
|
|
346
|
+
if (emailSupplied && apiKeySupplied) {
|
|
347
|
+
return {
|
|
348
|
+
status: "error",
|
|
349
|
+
message: "--email and --api-key are mutually exclusive: self-serve signup creates your API key, " +
|
|
350
|
+
"it does not consume an existing one.",
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
if (emailSupplied && inviteSupplied) {
|
|
354
|
+
return {
|
|
355
|
+
status: "error",
|
|
356
|
+
message: "--email and --invite are mutually exclusive: use --email for self-serve signup (no " +
|
|
357
|
+
"pre-issued invite), or --invite to redeem an invite you already have.",
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
status: "ok",
|
|
362
|
+
options: { apiKey, repo, force, dryRun, agentName, invite, inviteMode: inviteSupplied, email },
|
|
363
|
+
};
|
|
191
364
|
}
|
|
192
|
-
/**
|
|
193
|
-
|
|
365
|
+
/**
|
|
366
|
+
* No-echo secret prompt on stderr (so it never lands in piped stdout).
|
|
367
|
+
*
|
|
368
|
+
* `input`/`output` are injectable for tests ONLY — production always uses the real
|
|
369
|
+
* stdin/stderr. They exist because this function is the one piece of the install
|
|
370
|
+
* flow that talks to a live terminal, and stubbing it out at the deps seam left the
|
|
371
|
+
* terminal behavior itself (prompt visibility, echo suppression) fully unverified.
|
|
372
|
+
*/
|
|
373
|
+
export function promptSecretViaReadline(promptText, input = process.stdin, output = process.stderr) {
|
|
194
374
|
return new Promise((resolve) => {
|
|
195
375
|
const rl = readline.createInterface({
|
|
196
|
-
input
|
|
197
|
-
output
|
|
376
|
+
input,
|
|
377
|
+
output,
|
|
198
378
|
terminal: true,
|
|
199
379
|
});
|
|
200
|
-
//
|
|
201
|
-
//
|
|
380
|
+
// The prompt MUST be owned by readline (passed to `question` below), never
|
|
381
|
+
// written to stderr by hand. On every redraw readline emits cursorTo(0) +
|
|
382
|
+
// clearScreenDown straight to `output`, bypassing the `_writeToOutput` hook
|
|
383
|
+
// — so a hand-written prompt is erased on the first redraw, leaving a blank
|
|
384
|
+
// line with muted echo that is indistinguishable from a hang.
|
|
385
|
+
//
|
|
386
|
+
// Echo suppression therefore works by reprinting the prompt and swallowing
|
|
387
|
+
// everything else: readline redraws the line as `prompt + typed input`, so a
|
|
388
|
+
// write containing the prompt is a redraw (reprint the prompt alone, drop the
|
|
389
|
+
// secret) and any other write is a keystroke echo (drop it).
|
|
202
390
|
const mutable = rl;
|
|
203
391
|
let muted = false;
|
|
204
392
|
mutable._writeToOutput = (s) => {
|
|
205
|
-
if (!muted)
|
|
206
|
-
|
|
393
|
+
if (!muted) {
|
|
394
|
+
output.write(s);
|
|
395
|
+
}
|
|
396
|
+
else if (s.includes(promptText)) {
|
|
397
|
+
output.write(promptText);
|
|
398
|
+
}
|
|
207
399
|
};
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
400
|
+
// `close` fires without an answer when stdin hits EOF (piped/closed input).
|
|
401
|
+
// Resolving empty lets the caller emit its real "no key entered" error; left
|
|
402
|
+
// unresolved this promise deadlocks the top-level await and Node exits 13
|
|
403
|
+
// with only an "unsettled top-level await" warning.
|
|
404
|
+
//
|
|
405
|
+
// `answered` is load-bearing: `rl.close()` emits `close` SYNCHRONOUSLY, so on
|
|
406
|
+
// the happy path this handler runs before `resolve(answer)` below and would
|
|
407
|
+
// otherwise settle the promise empty — silently discarding the typed key.
|
|
408
|
+
let answered = false;
|
|
409
|
+
rl.on("close", () => {
|
|
410
|
+
if (!answered)
|
|
411
|
+
resolve("");
|
|
412
|
+
});
|
|
413
|
+
rl.question(promptText, (answer) => {
|
|
414
|
+
answered = true;
|
|
211
415
|
rl.close();
|
|
212
|
-
|
|
416
|
+
output.write("\n");
|
|
213
417
|
resolve(answer.trim());
|
|
214
418
|
});
|
|
419
|
+
// Only mute AFTER `question` has drawn the prompt at full visibility.
|
|
420
|
+
muted = true;
|
|
215
421
|
});
|
|
216
422
|
}
|
|
217
423
|
/** Echoed single-line prompt on stderr (used for repo confirmation / value). */
|
|
218
424
|
function promptLineViaReadline(promptText) {
|
|
219
425
|
return new Promise((resolve) => {
|
|
220
426
|
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
427
|
+
// See promptSecretViaReadline: EOF must resolve rather than deadlock the
|
|
428
|
+
// top-level await, and `answered` guards the synchronous `close` from
|
|
429
|
+
// discarding a real answer.
|
|
430
|
+
let answered = false;
|
|
431
|
+
rl.on("close", () => {
|
|
432
|
+
if (!answered)
|
|
433
|
+
resolve("");
|
|
434
|
+
});
|
|
221
435
|
rl.question(promptText, (answer) => {
|
|
436
|
+
answered = true;
|
|
222
437
|
rl.close();
|
|
223
438
|
resolve(answer.trim());
|
|
224
439
|
});
|
|
@@ -233,10 +448,25 @@ function promptLineViaReadline(promptText) {
|
|
|
233
448
|
* Always resolves (never rejects): a spawn error / non-zero exit / timeout becomes
|
|
234
449
|
* `{ ok: false, warning }` with secret-free warning text.
|
|
235
450
|
*/
|
|
451
|
+
/**
|
|
452
|
+
* The env clone handed to the pre-warm child, with every credential/PII variable
|
|
453
|
+
* removed: `BAPI_API_KEY` and the bootstrap-invite token (`BAPI_INVITE`) are
|
|
454
|
+
* secrets that must never reach an npm lifecycle script, and the self-serve signup
|
|
455
|
+
* email (`BAPI_SIGNUP_EMAIL`, BAPI-618) is invitee PII with no business in a
|
|
456
|
+
* `--version` probe. Extracted as a pure, exported helper so the strip is directly
|
|
457
|
+
* unit-testable (the default spawner below uses the real `spawn`, which is not
|
|
458
|
+
* injectable).
|
|
459
|
+
*/
|
|
460
|
+
export function sanitizePrewarmEnv(env) {
|
|
461
|
+
const sanitized = { ...env };
|
|
462
|
+
delete sanitized.BAPI_API_KEY;
|
|
463
|
+
delete sanitized.BAPI_INVITE;
|
|
464
|
+
delete sanitized.BAPI_SIGNUP_EMAIL;
|
|
465
|
+
return sanitized;
|
|
466
|
+
}
|
|
236
467
|
function spawnPrewarmDefault(command, args, env) {
|
|
237
468
|
return new Promise((resolve) => {
|
|
238
|
-
const sanitizedEnv =
|
|
239
|
-
delete sanitizedEnv.BAPI_API_KEY;
|
|
469
|
+
const sanitizedEnv = sanitizePrewarmEnv(env);
|
|
240
470
|
try {
|
|
241
471
|
const child = spawn(command, args, {
|
|
242
472
|
shell: false,
|
|
@@ -265,6 +495,11 @@ function spawnPrewarmDefault(command, args, env) {
|
|
|
265
495
|
/** Build default deps from the live process. */
|
|
266
496
|
export function createDefaultInstallBridgeDeps() {
|
|
267
497
|
const isTTY = Boolean(process.stdin.isTTY);
|
|
498
|
+
// One production fetch, reused for both the `fetch` seam and the default
|
|
499
|
+
// resolver, so a direct caller of this factory gets a resolver bound to the
|
|
500
|
+
// same client. (runInstallBridgeCli re-binds the resolver to the FINAL merged
|
|
501
|
+
// fetch when the caller did not override the resolver — see below.)
|
|
502
|
+
const productionFetch = (...args) => fetch(...args);
|
|
268
503
|
return {
|
|
269
504
|
env: process.env,
|
|
270
505
|
cwd: process.cwd(),
|
|
@@ -278,12 +513,25 @@ export function createDefaultInstallBridgeDeps() {
|
|
|
278
513
|
rename: (a, b) => rename(a, b),
|
|
279
514
|
chmod: (p, m) => chmod(p, m),
|
|
280
515
|
unlink: (p) => unlink(p),
|
|
516
|
+
open: async (p, flags, mode) => {
|
|
517
|
+
const handle = await open(p, flags, mode);
|
|
518
|
+
return {
|
|
519
|
+
writeFile: (data) => handle.writeFile(data, { encoding: "utf-8" }),
|
|
520
|
+
sync: () => handle.sync(),
|
|
521
|
+
close: () => handle.close(),
|
|
522
|
+
};
|
|
523
|
+
},
|
|
524
|
+
randomBytes: (size) => cryptoRandomBytes(size),
|
|
281
525
|
promptSecret: isTTY ? promptSecretViaReadline : undefined,
|
|
282
526
|
promptLine: isTTY ? promptLineViaReadline : undefined,
|
|
283
|
-
fetch:
|
|
527
|
+
fetch: productionFetch,
|
|
528
|
+
resolveRepoViaServer: (baseUrl, apiKey) => resolveRepoViaServer(productionFetch, baseUrl, apiKey),
|
|
284
529
|
spawnPrewarm: spawnPrewarmDefault,
|
|
285
530
|
runInit,
|
|
286
531
|
upsertCredential: upsertBapiCredential,
|
|
532
|
+
prepareBootstrapPending: prepareBootstrapPendingCredential,
|
|
533
|
+
repointBootstrapPending: repointBootstrapPendingCredential,
|
|
534
|
+
promoteBootstrapPending: promoteBootstrapPendingCredential,
|
|
287
535
|
buildShellCommand: buildGenericAgentShellCommand,
|
|
288
536
|
spawnTerminalTab: getDefaultSpawnTerminalTabForPlatform(process.platform),
|
|
289
537
|
startTicketsDeps: createDefaultStartTicketsDeps(),
|
|
@@ -317,20 +565,128 @@ export async function resolveApiKey(options, deps) {
|
|
|
317
565
|
};
|
|
318
566
|
}
|
|
319
567
|
/**
|
|
320
|
-
* Resolve the
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
568
|
+
* Resolve the BOOTSTRAP-INVITE token: `--invite <token>` → `BAPI_INVITE` env →
|
|
569
|
+
* interactive no-echo prompt. Fails (secret-free) when none is available and
|
|
570
|
+
* stdin is non-interactive.
|
|
571
|
+
*
|
|
572
|
+
* The order mirrors {@link resolveApiKey}, but the EMPHASIS is inverted, and that
|
|
573
|
+
* inversion is the point: "a copy/paste one-liner" and "the token never touches
|
|
574
|
+
* shell history" are contradictory — both `--invite <token>` and
|
|
575
|
+
* `BAPI_INVITE=… npx …` land in shell history and in `ps`. So the one-liner an
|
|
576
|
+
* operator hands an invitee is SECRET-FREE (`install-bridge --invite`), and the
|
|
577
|
+
* no-echo prompt below is the DEFAULT path rather than a last-resort fallback. The
|
|
578
|
+
* token then travels only in the exchange request body. The inline/env forms remain
|
|
579
|
+
* for scripting and are documented as history-exposing.
|
|
580
|
+
*
|
|
581
|
+
* The token value is never echoed: not in the prompt, not in any error here.
|
|
325
582
|
*/
|
|
326
|
-
export async function
|
|
327
|
-
if (typeof options.
|
|
328
|
-
return { ok: true, value: options.
|
|
583
|
+
export async function resolveInviteToken(options, deps) {
|
|
584
|
+
if (typeof options.invite === "string" && options.invite.trim().length > 0) {
|
|
585
|
+
return { ok: true, value: options.invite.trim() };
|
|
329
586
|
}
|
|
330
|
-
const fromEnv = deps.env.
|
|
587
|
+
const fromEnv = deps.env.BAPI_INVITE;
|
|
331
588
|
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
|
|
332
589
|
return { ok: true, value: fromEnv.trim() };
|
|
333
590
|
}
|
|
591
|
+
if (deps.isTTY && deps.promptSecret) {
|
|
592
|
+
const entered = (await deps.promptSecret("Bootstrap invite token (input hidden): ")).trim();
|
|
593
|
+
if (entered.length > 0) {
|
|
594
|
+
return { ok: true, value: entered };
|
|
595
|
+
}
|
|
596
|
+
return { ok: false, error: "No bootstrap invite token entered." };
|
|
597
|
+
}
|
|
598
|
+
return {
|
|
599
|
+
ok: false,
|
|
600
|
+
error: "A bootstrap invite token is required. Pass --invite <token> or set the BAPI_INVITE " +
|
|
601
|
+
"environment variable (no interactive terminal is available to prompt for it). Note that " +
|
|
602
|
+
"both forms expose the token to your shell history and process list — prefer running " +
|
|
603
|
+
"'install-bridge --invite' interactively.",
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Resolve the SELF-SERVE signup email: `--email <addr>` → `BAPI_SIGNUP_EMAIL` env →
|
|
608
|
+
* interactive VISIBLE prompt (BAPI-618). Fails (secret-free) when none is available
|
|
609
|
+
* and stdin is non-interactive.
|
|
610
|
+
*
|
|
611
|
+
* The precedence mirrors {@link resolveInviteToken}, but the input is NOT a secret:
|
|
612
|
+
* an email is not a credential, so it uses the ECHOED {@link InstallBridgeDeps.promptLine}
|
|
613
|
+
* — never `promptSecret`. (It IS invitee PII, so like every value in this module it
|
|
614
|
+
* is never written to a log line; it is only ever echoed as the user's own keystrokes
|
|
615
|
+
* and placed in the mint request body.)
|
|
616
|
+
*
|
|
617
|
+
* No email-format validation is performed here — only surrounding whitespace is
|
|
618
|
+
* trimmed, matching the other CLI input seams and the server's deliberately
|
|
619
|
+
* permissive "stored unvalidated" contract (D-2). The entered value is never echoed
|
|
620
|
+
* back in an error message.
|
|
621
|
+
*/
|
|
622
|
+
export async function resolveSignupEmail(options, deps) {
|
|
623
|
+
if (typeof options.email === "string" && options.email.trim().length > 0) {
|
|
624
|
+
return { ok: true, value: options.email.trim() };
|
|
625
|
+
}
|
|
626
|
+
const fromEnv = deps.env.BAPI_SIGNUP_EMAIL;
|
|
627
|
+
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
|
|
628
|
+
return { ok: true, value: fromEnv.trim() };
|
|
629
|
+
}
|
|
630
|
+
if (deps.isTTY && deps.promptLine) {
|
|
631
|
+
const entered = (await deps.promptLine("Email for Bridge workspace setup: ")).trim();
|
|
632
|
+
if (entered.length > 0) {
|
|
633
|
+
return { ok: true, value: entered };
|
|
634
|
+
}
|
|
635
|
+
return { ok: false, error: "No email entered." };
|
|
636
|
+
}
|
|
637
|
+
return {
|
|
638
|
+
ok: false,
|
|
639
|
+
error: "An email is required to create a Bridge workspace. Pass --email <addr> or set the " +
|
|
640
|
+
"BAPI_SIGNUP_EMAIL environment variable (no interactive terminal is available to prompt " +
|
|
641
|
+
"for it).",
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Select the onboarding branch as pure logic (deterministic, no I/O, no prompt):
|
|
646
|
+
*
|
|
647
|
+
* - `--invite` (with or without a value) or a non-empty `BAPI_INVITE` selects
|
|
648
|
+
* the `bootstrap-invite` need-key branch.
|
|
649
|
+
* - otherwise, `--email` or a non-empty `BAPI_SIGNUP_EMAIL` selects the
|
|
650
|
+
* `self-serve` need-key branch (BAPI-618).
|
|
651
|
+
* - every other invocation selects `have-key`.
|
|
652
|
+
*
|
|
653
|
+
* Precedence is explicit: an invite input wins over an email input (both are
|
|
654
|
+
* need-key, but a pre-issued invite is the more specific intent), and any
|
|
655
|
+
* need-key input wins over the have-key default. The parser already rejects
|
|
656
|
+
* `--email` alongside `--api-key`/`--invite`, so a flag-level conflict never
|
|
657
|
+
* reaches here; the precedence only disambiguates env-var combinations.
|
|
658
|
+
*/
|
|
659
|
+
export function resolveInstallBridgeOnboardingBranch(options, env) {
|
|
660
|
+
const inviteMode = options.inviteMode === true || (env.BAPI_INVITE ?? "").trim().length > 0;
|
|
661
|
+
if (inviteMode)
|
|
662
|
+
return { kind: "need-key", method: "bootstrap-invite" };
|
|
663
|
+
const emailMode = (options.email ?? "").trim().length > 0 ||
|
|
664
|
+
(env.BAPI_SIGNUP_EMAIL ?? "").trim().length > 0;
|
|
665
|
+
if (emailMode)
|
|
666
|
+
return { kind: "need-key", method: "self-serve" };
|
|
667
|
+
return { kind: "have-key" };
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Return the explicitly configured repository name (`--repo`, then
|
|
671
|
+
* `BAPI_REPO_NAME`), trimmed, or `undefined` when neither is supplied. Pure: no
|
|
672
|
+
* prompting, no inference, no I/O. `--repo` takes priority over the environment.
|
|
673
|
+
* This is the deterministic short-circuit that runs BEFORE any server resolution.
|
|
674
|
+
*/
|
|
675
|
+
export function resolveConfiguredRepoName(options, env) {
|
|
676
|
+
if (typeof options.repo === "string" && options.repo.trim().length > 0) {
|
|
677
|
+
return options.repo.trim();
|
|
678
|
+
}
|
|
679
|
+
const fromEnv = env.BAPI_REPO_NAME;
|
|
680
|
+
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
|
|
681
|
+
return fromEnv.trim();
|
|
682
|
+
}
|
|
683
|
+
return undefined;
|
|
684
|
+
}
|
|
685
|
+
export async function resolveRepoName(options, deps) {
|
|
686
|
+
const configured = resolveConfiguredRepoName(options, deps.env);
|
|
687
|
+
if (configured !== undefined) {
|
|
688
|
+
return { ok: true, value: configured };
|
|
689
|
+
}
|
|
334
690
|
// Non-interactive: fail fast and require --repo. Inference is an interactive
|
|
335
691
|
// convenience only (the user must confirm it).
|
|
336
692
|
if (!deps.isTTY || !deps.promptLine) {
|
|
@@ -489,11 +845,13 @@ export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
|
|
|
489
845
|
signal: AbortSignal.timeout(10_000),
|
|
490
846
|
});
|
|
491
847
|
}
|
|
492
|
-
catch
|
|
493
|
-
|
|
848
|
+
catch {
|
|
849
|
+
// The caught exception message is NOT interpolated: some fetch/undici errors
|
|
850
|
+
// echo request detail (which carries the X-API-Key header) into the message,
|
|
851
|
+
// so a fixed, secret-free string is used instead (BAPI-616).
|
|
494
852
|
return {
|
|
495
853
|
ok: false,
|
|
496
|
-
message: `Could not reach the Bridge API at ${baseUrl}
|
|
854
|
+
message: `Could not reach the Bridge API at ${baseUrl}. Check BAPI_BASE_URL and your network.`,
|
|
497
855
|
};
|
|
498
856
|
}
|
|
499
857
|
if (resp.ok)
|
|
@@ -518,14 +876,275 @@ export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
|
|
|
518
876
|
message: `Connectivity check failed (HTTP ${resp.status}). Verify your repo, API key, and BAPI_BASE_URL.`,
|
|
519
877
|
};
|
|
520
878
|
}
|
|
879
|
+
/** The resolve endpoint: `<base>/setup/resolve-repo`. No query string — ever. */
|
|
880
|
+
export function buildResolveRepoUrl(baseUrl) {
|
|
881
|
+
return `${baseUrl.replace(/\/+$/, "")}/setup/resolve-repo`;
|
|
882
|
+
}
|
|
521
883
|
/**
|
|
522
|
-
*
|
|
523
|
-
*
|
|
884
|
+
* Resolve the repository name server-side from an existing API key. The key
|
|
885
|
+
* travels ONLY in the `X-API-Key` header (never the URL, never the body), bounded
|
|
886
|
+
* by the same 10s timeout as {@link verifyConnectivity}.
|
|
887
|
+
*
|
|
888
|
+
* Status mapping is the whole compatibility story: `404` → `not-deployed` (old
|
|
889
|
+
* server), `409` → `unresolved` (ambiguous/shared/client-scoped/legacy), every
|
|
890
|
+
* other non-OK (`401`, `403`, `5xx`, unexpected) → `error`, and any
|
|
891
|
+
* network/timeout/malformed-body failure → `error`. A caught exception or a
|
|
892
|
+
* response body is NEVER read into the result.
|
|
893
|
+
*/
|
|
894
|
+
export async function resolveRepoViaServer(fetchImpl, baseUrl, apiKey) {
|
|
895
|
+
const url = buildResolveRepoUrl(baseUrl);
|
|
896
|
+
let resp;
|
|
897
|
+
try {
|
|
898
|
+
resp = await fetchImpl(url, {
|
|
899
|
+
headers: { "X-API-Key": apiKey },
|
|
900
|
+
signal: AbortSignal.timeout(10_000),
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
catch {
|
|
904
|
+
// Network failure / timeout. The exception may echo the request (and thus the
|
|
905
|
+
// X-API-Key header), so it is never inspected or propagated.
|
|
906
|
+
return { status: "error" };
|
|
907
|
+
}
|
|
908
|
+
if (resp.status === 404)
|
|
909
|
+
return { status: "not-deployed" };
|
|
910
|
+
if (resp.status === 409)
|
|
911
|
+
return { status: "unresolved" };
|
|
912
|
+
if (!resp.ok)
|
|
913
|
+
return { status: "error" };
|
|
914
|
+
let body;
|
|
915
|
+
try {
|
|
916
|
+
body = await resp.json();
|
|
917
|
+
}
|
|
918
|
+
catch {
|
|
919
|
+
return { status: "error" };
|
|
920
|
+
}
|
|
921
|
+
const repoName = body?.repo_name;
|
|
922
|
+
const validated = validateRepoName(repoName);
|
|
923
|
+
if (!validated.ok)
|
|
924
|
+
return { status: "error" };
|
|
925
|
+
return { status: "resolved", repoName: validated.value };
|
|
926
|
+
}
|
|
927
|
+
// ---------------------------------------------------------------------------
|
|
928
|
+
// Bootstrap-invite exchange (BAPI-606) — replaces the pre-flight ping in invite mode
|
|
929
|
+
// ---------------------------------------------------------------------------
|
|
930
|
+
/** Bytes of CSPRNG entropy behind `key_secret` (server requires exactly 32). */
|
|
931
|
+
export const BOOTSTRAP_KEY_SECRET_BYTES = 32;
|
|
932
|
+
/**
|
|
933
|
+
* Generate the client-side `key_secret`: exactly 32 CSPRNG bytes, base64url,
|
|
934
|
+
* which yields exactly the 43 unpadded characters the server validates. The
|
|
935
|
+
* length is EXACT, not a floor — the server rejects 42 and 44, because ">= 43"
|
|
936
|
+
* had no upper bound and an over-72-byte input is a bcrypt-truncation hazard.
|
|
937
|
+
*
|
|
938
|
+
* The returned value is a SECRET. It is never logged, never previewed, and never
|
|
939
|
+
* placed in an error.
|
|
940
|
+
*/
|
|
941
|
+
export function generateBootstrapKeySecret(randomBytes) {
|
|
942
|
+
return randomBytes(BOOTSTRAP_KEY_SECRET_BYTES).toString("base64url");
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* SHA-256 hex digest of the bootstrap-invite token — the key under which the
|
|
946
|
+
* pending record is stored. The TOKEN ITSELF IS NEVER WRITTEN TO DISK; only this
|
|
947
|
+
* one-way fingerprint, so a retry can recognize "same invite, same repo" and reuse
|
|
948
|
+
* the exact pending `key_secret` (the replay proof).
|
|
949
|
+
*/
|
|
950
|
+
export function fingerprintBootstrapInvite(token) {
|
|
951
|
+
return createHash("sha256").update(token, "utf-8").digest("hex");
|
|
952
|
+
}
|
|
953
|
+
/** The exchange endpoint: `<base>/setup/bootstrap`. No query string — ever. */
|
|
954
|
+
export function buildBootstrapExchangeUrl(baseUrl) {
|
|
955
|
+
return `${baseUrl.replace(/\/+$/, "")}/setup/bootstrap`;
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* POST the bootstrap-invite exchange. The token and `key_secret` travel ONLY in
|
|
959
|
+
* the JSON request body (never a query string, never a header, never a log line).
|
|
960
|
+
* Bounded by the same 10s timeout as the connectivity ping.
|
|
961
|
+
*/
|
|
962
|
+
export async function exchangeBootstrapInvite(deps, baseUrl, token, repoName, keySecret) {
|
|
963
|
+
const url = buildBootstrapExchangeUrl(baseUrl);
|
|
964
|
+
let resp;
|
|
965
|
+
try {
|
|
966
|
+
resp = await deps.fetch(url, {
|
|
967
|
+
method: "POST",
|
|
968
|
+
headers: { "Content-Type": "application/json" },
|
|
969
|
+
body: JSON.stringify({ token, repo_name: repoName, key_secret: keySecret }),
|
|
970
|
+
signal: AbortSignal.timeout(10_000),
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
catch (err) {
|
|
974
|
+
// The message may contain the request (some fetch impls echo it), so it is
|
|
975
|
+
// deliberately NOT interpolated here.
|
|
976
|
+
void err;
|
|
977
|
+
return {
|
|
978
|
+
ok: false,
|
|
979
|
+
kind: "failed",
|
|
980
|
+
message: `Could not reach the Bridge API at ${baseUrl} to redeem the bootstrap invite. Check ` +
|
|
981
|
+
"BAPI_BASE_URL and your network, then re-run — the invite has not been used, and the " +
|
|
982
|
+
"re-run will reuse the same locally-stored secret.",
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
if (resp.ok) {
|
|
986
|
+
let repo;
|
|
987
|
+
try {
|
|
988
|
+
const body = (await resp.json());
|
|
989
|
+
repo = body?.repo_name;
|
|
990
|
+
}
|
|
991
|
+
catch {
|
|
992
|
+
return {
|
|
993
|
+
ok: false,
|
|
994
|
+
kind: "failed",
|
|
995
|
+
message: "The Bridge API returned an unreadable response to the bootstrap exchange.",
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
// Validate the authoritative name before it reaches a path, a config target, or
|
|
999
|
+
// the credential store.
|
|
1000
|
+
const validated = validateRepoName(repo);
|
|
1001
|
+
if (!validated.ok) {
|
|
1002
|
+
return {
|
|
1003
|
+
ok: false,
|
|
1004
|
+
kind: "failed",
|
|
1005
|
+
message: "The Bridge API returned an unexpected repo name for the bootstrap exchange.",
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
return { ok: true, repoName: validated.value };
|
|
1009
|
+
}
|
|
1010
|
+
if (resp.status === 409) {
|
|
1011
|
+
return {
|
|
1012
|
+
ok: false,
|
|
1013
|
+
kind: "repo-name-taken",
|
|
1014
|
+
message: `The repo name '${repoName}' is already taken (HTTP 409). Repo names are globally unique.`,
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
if (resp.status === 401) {
|
|
1018
|
+
return { ok: false, kind: "invalid-invite", message: `The Bridge API rejected the bootstrap invite (HTTP ${resp.status}).` };
|
|
1019
|
+
}
|
|
1020
|
+
return {
|
|
1021
|
+
ok: false,
|
|
1022
|
+
kind: "failed",
|
|
1023
|
+
message: `The bootstrap exchange failed (HTTP ${resp.status}). Verify BAPI_BASE_URL and try again.`,
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
// ---------------------------------------------------------------------------
|
|
1027
|
+
// Self-serve mint (BAPI-618): email -> invite token
|
|
1028
|
+
// ---------------------------------------------------------------------------
|
|
1029
|
+
/**
|
|
1030
|
+
* The self-serve invite-token prefix, mirroring the server's
|
|
1031
|
+
* ``BOOTSTRAP_INVITE_TOKEN_PREFIX`` (``bapi_inv_``). A well-formed token
|
|
1032
|
+
* self-identifies with this prefix; a success response whose token does not is
|
|
1033
|
+
* treated as a `failed` mint rather than fed into the redemption path.
|
|
1034
|
+
*/
|
|
1035
|
+
export const BOOTSTRAP_INVITE_TOKEN_PREFIX = "bapi_inv_";
|
|
1036
|
+
/** The self-serve mint endpoint: `<base>/setup/bootstrap/self-serve`. No query string — ever. */
|
|
1037
|
+
export function buildSelfServeMintUrl(baseUrl) {
|
|
1038
|
+
return `${baseUrl.replace(/\/+$/, "")}/setup/bootstrap/self-serve`;
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* POST the self-serve mint: an email in, an invite token out. Mirrors
|
|
1042
|
+
* {@link exchangeBootstrapInvite}'s fetch/timeout/secret-free-result discipline.
|
|
1043
|
+
*
|
|
1044
|
+
* The email travels ONLY in the JSON request body (never a query string, never a
|
|
1045
|
+
* header, never a log line). Bounded by the same 10s timeout as the exchange and
|
|
1046
|
+
* the connectivity ping. On any non-success status the failure body is NOT read —
|
|
1047
|
+
* the status alone decides the category — because an untrusted upstream body could
|
|
1048
|
+
* echo the email or carry internals. A caught fetch exception is likewise never
|
|
1049
|
+
* inspected or interpolated; some fetch impls embed the request (and thus the
|
|
1050
|
+
* email) in the message.
|
|
1051
|
+
*/
|
|
1052
|
+
export async function mintSelfServeInvite(deps, baseUrl, email) {
|
|
1053
|
+
const url = buildSelfServeMintUrl(baseUrl);
|
|
1054
|
+
let resp;
|
|
1055
|
+
try {
|
|
1056
|
+
resp = await deps.fetch(url, {
|
|
1057
|
+
method: "POST",
|
|
1058
|
+
headers: { "Content-Type": "application/json" },
|
|
1059
|
+
body: JSON.stringify({ invitee_email: email }),
|
|
1060
|
+
signal: AbortSignal.timeout(10_000),
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
catch (err) {
|
|
1064
|
+
// The message may contain the request (some fetch impls echo it), so it is
|
|
1065
|
+
// deliberately NOT inspected or interpolated here.
|
|
1066
|
+
void err;
|
|
1067
|
+
return { ok: false, category: "failed" };
|
|
1068
|
+
}
|
|
1069
|
+
if (resp.ok) {
|
|
1070
|
+
let token;
|
|
1071
|
+
try {
|
|
1072
|
+
const body = (await resp.json());
|
|
1073
|
+
token = body?.token;
|
|
1074
|
+
}
|
|
1075
|
+
catch {
|
|
1076
|
+
// An unreadable success body is not actionable and must not be exposed.
|
|
1077
|
+
return { ok: false, category: "failed" };
|
|
1078
|
+
}
|
|
1079
|
+
if (typeof token !== "string" ||
|
|
1080
|
+
token.trim().length === 0 ||
|
|
1081
|
+
!token.startsWith(BOOTSTRAP_INVITE_TOKEN_PREFIX)) {
|
|
1082
|
+
return { ok: false, category: "failed" };
|
|
1083
|
+
}
|
|
1084
|
+
return { ok: true, token };
|
|
1085
|
+
}
|
|
1086
|
+
// Non-success: the status alone decides the category. The failure body is NEVER
|
|
1087
|
+
// read — it is untrusted and could echo the email or leak internals.
|
|
1088
|
+
if (resp.status === 429) {
|
|
1089
|
+
return { ok: false, category: "rate-limited" };
|
|
1090
|
+
}
|
|
1091
|
+
if (resp.status === 400 || resp.status === 422) {
|
|
1092
|
+
return { ok: false, category: "invalid" };
|
|
1093
|
+
}
|
|
1094
|
+
return { ok: false, category: "failed" };
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* The 401 message for a run that had to GENERATE a fresh secret — i.e. no local
|
|
1098
|
+
* pending record existed. Besides a simply-wrong token, this is exactly what a lost
|
|
1099
|
+
* `~/.config/bridge` looks like after a successful redemption: the retry sends a
|
|
1100
|
+
* NEW secret, so the server's replay branch cannot match and correctly answers 401
|
|
1101
|
+
* (letting a different secret replace the key would turn a spent token back into a
|
|
1102
|
+
* bearer credential).
|
|
1103
|
+
*
|
|
1104
|
+
* The recovery is therefore NOT "re-run" and NOT "re-mint" — re-running sends
|
|
1105
|
+
* another new secret and gets 401 again, and a fresh invite cannot reuse the repo
|
|
1106
|
+
* name, which is globally unique and now taken by the project already created. Do
|
|
1107
|
+
* not suggest either.
|
|
1108
|
+
*/
|
|
1109
|
+
export const BOOTSTRAP_INVITE_LOST_SECRET_MESSAGE = [
|
|
1110
|
+
"The Bridge API rejected the bootstrap invite (HTTP 401).",
|
|
1111
|
+
"",
|
|
1112
|
+
"Either the invite is invalid, expired, or revoked — or it was ALREADY redeemed from this",
|
|
1113
|
+
"machine and the local secret has since been lost (e.g. ~/.config/bridge was deleted).",
|
|
1114
|
+
"",
|
|
1115
|
+
"If it was already redeemed, you cannot recover it yourself:",
|
|
1116
|
+
" • Re-running will NOT work: each run without the original local secret sends a new one,",
|
|
1117
|
+
" which cannot match what the server stored, so it will keep returning 401.",
|
|
1118
|
+
" • A new bootstrap invite will NOT work either: your repo name is globally unique and is",
|
|
1119
|
+
" now taken by the project you already created, so it cannot be redeemed again.",
|
|
1120
|
+
"",
|
|
1121
|
+
"Ask your Bridge API operator to recover it for you: they revoke the orphaned key",
|
|
1122
|
+
"(DELETE /setup/keys/{id}) and issue a replacement key for the EXISTING project",
|
|
1123
|
+
"(POST /setup/keys), then send you that key. Run install-bridge with --api-key <that key>.",
|
|
1124
|
+
].join("\n");
|
|
1125
|
+
/**
|
|
1126
|
+
* The 401 message for a run that REUSED an existing pending secret — the replay
|
|
1127
|
+
* proof was sent and still rejected, so the invite itself is not usable.
|
|
1128
|
+
*/
|
|
1129
|
+
export const BOOTSTRAP_INVITE_REJECTED_MESSAGE = "The Bridge API rejected the bootstrap invite (HTTP 401). The invite is invalid, expired, or " +
|
|
1130
|
+
"revoked — ask your Bridge API operator for a new one. (Your locally-stored secret was sent " +
|
|
1131
|
+
"unchanged, so this is not a lost-secret problem.)";
|
|
1132
|
+
/**
|
|
1133
|
+
* Render the --dry-run preview lines. Every secret is ALWAYS redacted — the
|
|
1134
|
+
* spawnCommand, config preview, and (in bootstrap-invite mode) the exchange body
|
|
1135
|
+
* never embed the API key, the invite token, or the generated `key_secret`.
|
|
1136
|
+
*
|
|
1137
|
+
* The plan itself holds no secret and no invite fingerprint, so this function
|
|
1138
|
+
* CANNOT leak one: in bootstrap-invite mode it is called before any secret exists.
|
|
524
1139
|
*/
|
|
525
1140
|
export function buildDryRunPreview(plan) {
|
|
1141
|
+
if (plan.bootstrapInvite)
|
|
1142
|
+
return buildBootstrapDryRunPreview(plan);
|
|
526
1143
|
return [
|
|
527
|
-
|
|
528
|
-
|
|
1144
|
+
plan.attemptedServerResolution
|
|
1145
|
+
? "install-bridge --dry-run (one read-only repository-resolution GET may already have occurred; no writes, no state-changing requests, no spawns)"
|
|
1146
|
+
: "install-bridge --dry-run (no writes, no network, no spawns)",
|
|
1147
|
+
`Repo name: ${plan.repoName}${plan.attemptedServerResolution ? " (resolved server-side from your API key)" : ""}`,
|
|
529
1148
|
`Base URL (ping): ${plan.baseUrl}`,
|
|
530
1149
|
`Docs dir: ${plan.docsDir}`,
|
|
531
1150
|
`Agent: ${plan.agentName}`,
|
|
@@ -543,6 +1162,61 @@ export function buildDryRunPreview(plan) {
|
|
|
543
1162
|
`Step 5 — spawn agent session: ${plan.spawnCommand}`,
|
|
544
1163
|
];
|
|
545
1164
|
}
|
|
1165
|
+
/**
|
|
1166
|
+
* Bootstrap-invite dry-run preview. A --dry-run must not consume the invite OR
|
|
1167
|
+
* LEAVE STATE BEHIND: skipping the HTTP call is not enough — a preview that wrote
|
|
1168
|
+
* a pending `key_secret` would have left credential material on disk for a
|
|
1169
|
+
* redemption that never happened. So the caller returns here BEFORE the CSPRNG
|
|
1170
|
+
* runs and before anything is written.
|
|
1171
|
+
*/
|
|
1172
|
+
function buildBootstrapDryRunPreview(plan) {
|
|
1173
|
+
const pendingTarget = `bootstrap-pending:${plan.repoName}`;
|
|
1174
|
+
// Self-serve signup (BAPI-618): the ONLY structural difference from a pre-issued
|
|
1175
|
+
// invite is a mint-from-email step (Step 2·pre) that turns an email into the
|
|
1176
|
+
// invite token before the unchanged redemption below. The preview states plainly
|
|
1177
|
+
// that in --dry-run that signup/mint is previewed-and-skipped: no account is
|
|
1178
|
+
// created, no mint request is sent, no email leaves the machine. The email and
|
|
1179
|
+
// any synthetic token are deliberately absent from this output.
|
|
1180
|
+
const header = plan.selfServeSignup
|
|
1181
|
+
? "install-bridge --email --dry-run (no writes, no network, no spawns, no account created, no secret generated)"
|
|
1182
|
+
: "install-bridge --invite --dry-run (no writes, no network, no spawns, no secret generated)";
|
|
1183
|
+
const repoLine = plan.selfServeSignup
|
|
1184
|
+
? `Repo name: ${plan.repoName} (created by the self-serve exchange; globally unique)`
|
|
1185
|
+
: `Repo name: ${plan.repoName} (created by the exchange; globally unique)`;
|
|
1186
|
+
const selfServeStep = plan.selfServeSignup
|
|
1187
|
+
? [
|
|
1188
|
+
"Step 2·pre — self-serve signup (PREVIEWED, SKIPPED in --dry-run): no Bridge workspace",
|
|
1189
|
+
" signup is requested, no mint call is made, and no email is sent or transmitted;",
|
|
1190
|
+
" a real run would request a fresh workspace for your email and receive an invite",
|
|
1191
|
+
" token, which then feeds the SAME redemption protocol below.",
|
|
1192
|
+
]
|
|
1193
|
+
: [];
|
|
1194
|
+
return [
|
|
1195
|
+
header,
|
|
1196
|
+
repoLine,
|
|
1197
|
+
`Base URL: ${plan.baseUrl}`,
|
|
1198
|
+
`Docs dir: ${plan.docsDir}`,
|
|
1199
|
+
`Agent: ${plan.agentName}`,
|
|
1200
|
+
"",
|
|
1201
|
+
"Step 1 — scaffold (runInit): commands, agents, pipelines, .bridge/config, secret-free MCP placeholders.",
|
|
1202
|
+
...selfServeStep,
|
|
1203
|
+
`Step 2a — generate key_secret (32 CSPRNG bytes) and fsync it to ${pendingTarget} at ${plan.credentialStorePath}`,
|
|
1204
|
+
" BEFORE the exchange. If that write fails the run ABORTS and no invite is spent.",
|
|
1205
|
+
`Step 2b — redeem the bootstrap invite (replaces the pre-flight ping — there is no key yet):`,
|
|
1206
|
+
` POST ${plan.exchangeUrl}`,
|
|
1207
|
+
` body: {"token": "${REDACTED_API_KEY}", "repo_name": "${plan.repoName}", "key_secret": "${REDACTED_API_KEY}"}`,
|
|
1208
|
+
`Step 2c — connectivity ping with the newly-minted key (before any durable key write): GET ${plan.pingUrl} (X-API-Key: ${REDACTED_API_KEY})`,
|
|
1209
|
+
"Step 3 — write per-host MCP config (read-merge-write, launcher version-pinned):",
|
|
1210
|
+
...plan.configTargets.map((t) => ` ${t}: BAPI_REPO_NAME=${plan.repoName}, BAPI_API_KEY=${REDACTED_API_KEY}, BAPI_BASE_URL=${plan.baseUrl}, BAPI_DOCS_DIR=${plan.docsDir}`),
|
|
1211
|
+
...(plan.manualEditors.length > 0
|
|
1212
|
+
? [` ${plan.manualEditors.join(" + ")}: detected (global config) — manual setup instructions would be printed.`]
|
|
1213
|
+
: []),
|
|
1214
|
+
`Step 3b — pre-warm the version-pinned launcher bucket (fail-open, env sanitized — BAPI_API_KEY / BAPI_INVITE removed): ${plan.prewarmCommand}`,
|
|
1215
|
+
MCP_TIMEOUT_GUIDANCE,
|
|
1216
|
+
`Step 4 — promote ${pendingTarget} → ${plan.credentialTarget} at ${plan.credentialStorePath} (only after the exchange succeeds)`,
|
|
1217
|
+
`Step 5 — spawn agent session: ${plan.spawnCommand}`,
|
|
1218
|
+
];
|
|
1219
|
+
}
|
|
546
1220
|
/**
|
|
547
1221
|
* Detect global-config editors that install-bridge cannot safely write into
|
|
548
1222
|
* (their configs live outside the project). Windsurf is detected from the
|
|
@@ -608,6 +1282,12 @@ function buildManualHostInstructions(entry, editors) {
|
|
|
608
1282
|
*/
|
|
609
1283
|
export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
610
1284
|
const deps = { ...createDefaultInstallBridgeDeps(), ...overrides };
|
|
1285
|
+
// When the caller overrode only `fetch` (the common test seam), bind the
|
|
1286
|
+
// default resolver to the FINAL merged fetch so server resolution still routes
|
|
1287
|
+
// through the override. An explicit `resolveRepoViaServer` override is honored.
|
|
1288
|
+
if (!overrides.resolveRepoViaServer) {
|
|
1289
|
+
deps.resolveRepoViaServer = (baseUrl, apiKey) => resolveRepoViaServer(deps.fetch, baseUrl, apiKey);
|
|
1290
|
+
}
|
|
611
1291
|
const { log, errorLog } = deps;
|
|
612
1292
|
const parsed = parseInstallBridgeArgs(argv);
|
|
613
1293
|
if (parsed.status === "help") {
|
|
@@ -621,23 +1301,101 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
621
1301
|
return 1;
|
|
622
1302
|
}
|
|
623
1303
|
const options = parsed.options;
|
|
1304
|
+
// Onboarding branch (pure, deterministic): have-key vs. a need-key method
|
|
1305
|
+
// (`bootstrap-invite` = redeem a pre-issued invite, `self-serve` = mint one from
|
|
1306
|
+
// an email, BAPI-618). BOTH need-key methods share the downstream redemption
|
|
1307
|
+
// protocol, so `bootstrapInviteMode` is true for both; `selfServeSignupMode`
|
|
1308
|
+
// discriminates the one extra step (mint-from-email) the self-serve path adds.
|
|
1309
|
+
const branch = resolveInstallBridgeOnboardingBranch(options, deps.env);
|
|
1310
|
+
const bootstrapInviteMode = branch.kind === "need-key";
|
|
1311
|
+
const selfServeSignupMode = branch.kind === "need-key" && branch.method === "self-serve";
|
|
624
1312
|
// ---- Resolve inputs (may prompt when interactive) ----
|
|
625
|
-
// Resolve the
|
|
626
|
-
// with the (more
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
1313
|
+
// Resolve the credential/entry input first so a fully-empty non-interactive
|
|
1314
|
+
// invocation fails with the (more relevant) missing-input message before the repo
|
|
1315
|
+
// one. In either need-key mode there is no API key to resolve — the exchange
|
|
1316
|
+
// MINTS it. Self-serve resolves the email here (up-front, before the dry-run
|
|
1317
|
+
// guard) so a missing email fails fast; the actual MINT happens later, strictly
|
|
1318
|
+
// after the dry-run return, so a preview neither mints nor sends anything.
|
|
1319
|
+
let apiKey = "";
|
|
1320
|
+
let inviteToken = "";
|
|
1321
|
+
let signupEmail = "";
|
|
1322
|
+
if (selfServeSignupMode) {
|
|
1323
|
+
const emailResult = await resolveSignupEmail(options, deps);
|
|
1324
|
+
if (!emailResult.ok) {
|
|
1325
|
+
errorLog(`Error: ${emailResult.error}`);
|
|
1326
|
+
return 1;
|
|
1327
|
+
}
|
|
1328
|
+
signupEmail = emailResult.value;
|
|
631
1329
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
1330
|
+
else if (bootstrapInviteMode) {
|
|
1331
|
+
const inviteResult = await resolveInviteToken(options, deps);
|
|
1332
|
+
if (!inviteResult.ok) {
|
|
1333
|
+
errorLog(`Error: ${inviteResult.error}`);
|
|
1334
|
+
return 1;
|
|
1335
|
+
}
|
|
1336
|
+
inviteToken = inviteResult.value;
|
|
637
1337
|
}
|
|
638
|
-
|
|
1338
|
+
else {
|
|
1339
|
+
const keyResult = await resolveApiKey(options, deps);
|
|
1340
|
+
if (!keyResult.ok) {
|
|
1341
|
+
errorLog(`Error: ${keyResult.error}`);
|
|
1342
|
+
return 1;
|
|
1343
|
+
}
|
|
1344
|
+
apiKey = keyResult.value;
|
|
1345
|
+
}
|
|
1346
|
+
// baseUrl is resolved BEFORE repository input because the have-key branch needs
|
|
1347
|
+
// it for the server-resolution request.
|
|
639
1348
|
const baseUrl = deps.env.BAPI_BASE_URL ?? DEFAULT_BAPI_BASE_URL;
|
|
640
1349
|
const docsDir = deps.env.BAPI_DOCS_DIR ?? DEFAULT_BAPI_DOCS_DIR;
|
|
1350
|
+
// ---- Resolve the repository name ----
|
|
1351
|
+
// Bootstrap-invite: unchanged (choose-a-name for the new project). Have-key:
|
|
1352
|
+
// `--repo`/`BAPI_REPO_NAME` short-circuit deterministically; otherwise resolve
|
|
1353
|
+
// it server-side from the API key (BAPI-616), and on ANY non-resolution outcome
|
|
1354
|
+
// (unresolved / not-deployed / error) fall back to the existing local
|
|
1355
|
+
// prompt/inference — never a hard failure.
|
|
1356
|
+
let repoName;
|
|
1357
|
+
let attemptedServerResolution = false;
|
|
1358
|
+
if (bootstrapInviteMode) {
|
|
1359
|
+
const repoResult = await resolveRepoName(options, deps);
|
|
1360
|
+
if (!repoResult.ok) {
|
|
1361
|
+
errorLog(`Error: ${repoResult.error}`);
|
|
1362
|
+
return 1;
|
|
1363
|
+
}
|
|
1364
|
+
// Invite mode creates the project, so the name must be valid BEFORE any
|
|
1365
|
+
// pending state or exchange.
|
|
1366
|
+
const validated = validateRepoName(repoResult.value);
|
|
1367
|
+
if (!validated.ok) {
|
|
1368
|
+
errorLog(`Error: invalid repo name — ${validated.error}.`);
|
|
1369
|
+
return 1;
|
|
1370
|
+
}
|
|
1371
|
+
repoName = validated.value;
|
|
1372
|
+
}
|
|
1373
|
+
else {
|
|
1374
|
+
const configured = resolveConfiguredRepoName(options, deps.env);
|
|
1375
|
+
if (configured !== undefined) {
|
|
1376
|
+
// Deterministic short-circuit: no server round-trip when the name is known.
|
|
1377
|
+
repoName = configured;
|
|
1378
|
+
}
|
|
1379
|
+
else {
|
|
1380
|
+
attemptedServerResolution = true;
|
|
1381
|
+
log("Resolving repository…");
|
|
1382
|
+
const resolution = await deps.resolveRepoViaServer(baseUrl, apiKey);
|
|
1383
|
+
if (resolution.status === "resolved") {
|
|
1384
|
+
repoName = resolution.repoName;
|
|
1385
|
+
}
|
|
1386
|
+
else {
|
|
1387
|
+
// Feature-detect + degrade: 404 (old server), 409 (unresolved/ambiguous/
|
|
1388
|
+
// client-scoped), and network/other errors all fall back to the existing
|
|
1389
|
+
// local resolution WITHOUT a cause-specific message or leaked detail.
|
|
1390
|
+
const repoResult = await resolveRepoName(options, deps);
|
|
1391
|
+
if (!repoResult.ok) {
|
|
1392
|
+
errorLog(`Error: ${repoResult.error}`);
|
|
1393
|
+
return 1;
|
|
1394
|
+
}
|
|
1395
|
+
repoName = repoResult.value;
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
641
1399
|
const agent = resolveAgentSpec(options.agentName) ?? resolveAgentSpec(DEFAULT_AGENT_NAME);
|
|
642
1400
|
const spawnCommand = deps.buildShellCommand(agent, INSTALL_BRIDGE_AGENT_PROMPT, deps.cwd, deps.platform);
|
|
643
1401
|
const credentialStorePath = getPrimaryCredentialStorePath({
|
|
@@ -659,16 +1417,36 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
659
1417
|
pingUrl: buildPingUrl(baseUrl, repoName),
|
|
660
1418
|
prewarmCommand: buildPrewarmCommandPreview(),
|
|
661
1419
|
spawnCommand,
|
|
1420
|
+
...(bootstrapInviteMode
|
|
1421
|
+
? { bootstrapInvite: true, exchangeUrl: buildBootstrapExchangeUrl(baseUrl) }
|
|
1422
|
+
: {}),
|
|
1423
|
+
...(selfServeSignupMode ? { selfServeSignup: true } : {}),
|
|
1424
|
+
...(attemptedServerResolution ? { attemptedServerResolution: true } : {}),
|
|
662
1425
|
};
|
|
663
1426
|
// ---- --dry-run: preview every step, strictly no side effects ----
|
|
1427
|
+
// Positioned BEFORE the CSPRNG, the pending write, the exchange, the scaffold,
|
|
1428
|
+
// the config writes, the pre-warm, and the spawn: in bootstrap-invite mode a
|
|
1429
|
+
// preview must neither consume the invite NOR leave a pending secret on disk.
|
|
664
1430
|
if (options.dryRun) {
|
|
665
1431
|
for (const line of buildDryRunPreview(plan))
|
|
666
1432
|
log(line);
|
|
667
1433
|
return 0;
|
|
668
1434
|
}
|
|
669
|
-
const
|
|
1435
|
+
const credentialWriteDeps = {
|
|
1436
|
+
env: deps.env,
|
|
1437
|
+
homedir: deps.homedir,
|
|
1438
|
+
platform: deps.platform,
|
|
1439
|
+
readFile: deps.readFile,
|
|
1440
|
+
mkdir: deps.mkdir,
|
|
1441
|
+
writeFile: (p, d, o) => deps.writeFile(p, d, o),
|
|
1442
|
+
rename: deps.rename,
|
|
1443
|
+
chmod: deps.chmod,
|
|
1444
|
+
unlink: deps.unlink,
|
|
1445
|
+
open: deps.open,
|
|
1446
|
+
};
|
|
670
1447
|
// ---- Overwrite consent: a real existing key requires --force or a prompt ----
|
|
671
1448
|
const hasRealKey = await detectExistingRealKey(deps, targets);
|
|
1449
|
+
let overwriteConsent = options.force;
|
|
672
1450
|
if (hasRealKey && !options.force) {
|
|
673
1451
|
if (deps.isTTY && deps.promptLine) {
|
|
674
1452
|
const answer = (await deps.promptLine("A host config already contains a BAPI_API_KEY. Overwrite it? [y/N]: ")).trim().toLowerCase();
|
|
@@ -676,6 +1454,7 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
676
1454
|
errorLog("Aborted: existing API key left unchanged (re-run with --force to overwrite).");
|
|
677
1455
|
return 1;
|
|
678
1456
|
}
|
|
1457
|
+
overwriteConsent = true;
|
|
679
1458
|
}
|
|
680
1459
|
else {
|
|
681
1460
|
errorLog("Error: a host config already contains a BAPI_API_KEY. Re-run with --force to overwrite it " +
|
|
@@ -686,18 +1465,191 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
686
1465
|
// ---- Step 1 — scaffold (secret-free placeholders only) ----
|
|
687
1466
|
log("Step 1/5 — scaffolding project (commands, agents, pipelines, config placeholders)…");
|
|
688
1467
|
await deps.runInit(deps.cwd);
|
|
689
|
-
// ---- Step 2 —
|
|
1468
|
+
// ---- Step 2 — mint (invite mode) or verify (normal mode) the credential ----
|
|
1469
|
+
let inviteFingerprint = "";
|
|
1470
|
+
if (bootstrapInviteMode) {
|
|
1471
|
+
// ---- Self-serve signup (BAPI-618): mint the invite token from the email ----
|
|
1472
|
+
// This is the ONE step that precedes the unchanged BAPI-606 redemption below.
|
|
1473
|
+
// It sits AFTER the --dry-run return above, so a preview never mints, never
|
|
1474
|
+
// sends the email, and never writes a pending secret. On success the minted
|
|
1475
|
+
// token is assigned to `inviteToken` and the flow falls into the existing
|
|
1476
|
+
// persist-before-exchange block VERBATIM — identical to a --invite token from
|
|
1477
|
+
// here on. On failure we return BEFORE any CSPRNG/pending write, so nothing is
|
|
1478
|
+
// persisted. The email and the token are never logged.
|
|
1479
|
+
if (selfServeSignupMode) {
|
|
1480
|
+
log("Step 2/5 — requesting Bridge self-serve setup…");
|
|
1481
|
+
const mint = await mintSelfServeInvite(deps, baseUrl, signupEmail);
|
|
1482
|
+
if (!mint.ok) {
|
|
1483
|
+
if (mint.category === "rate-limited") {
|
|
1484
|
+
errorLog("Error: Self-serve setup is temporarily rate limited. Try again later.");
|
|
1485
|
+
}
|
|
1486
|
+
else if (mint.category === "invalid") {
|
|
1487
|
+
errorLog("Error: Self-serve setup could not be requested. Check the email value and try again.");
|
|
1488
|
+
}
|
|
1489
|
+
else {
|
|
1490
|
+
errorLog("Error: Unable to complete self-serve setup. Check connectivity and retry.");
|
|
1491
|
+
}
|
|
1492
|
+
return 1;
|
|
1493
|
+
}
|
|
1494
|
+
// Treated identically to a manually supplied invite token from here on.
|
|
1495
|
+
inviteToken = mint.token;
|
|
1496
|
+
}
|
|
1497
|
+
// ===================================================================
|
|
1498
|
+
// LOAD-BEARING ORDERING — NOT DEFENSIVE POLISH. DO NOT "TIDY" THIS.
|
|
1499
|
+
//
|
|
1500
|
+
// The key_secret is generated HERE, fsynced to a pending record HERE, and
|
|
1501
|
+
// only THEN sent to the exchange. That order is the protocol:
|
|
1502
|
+
//
|
|
1503
|
+
// * The locally-stored key_secret is the ONLY proof that can replay a
|
|
1504
|
+
// redemption. The server's replay branch matches on (repo_name, bcrypt of
|
|
1505
|
+
// key_secret); it never returns a recoverable credential.
|
|
1506
|
+
// * So a successful exchange followed by a failed local write = an
|
|
1507
|
+
// unrecoverable admin key AND a permanently spent invite. The user cannot
|
|
1508
|
+
// re-run (a new secret gets a correct 401) and cannot re-mint (the repo
|
|
1509
|
+
// name is now globally taken by the project they just created); only an
|
|
1510
|
+
// operator can dig them out.
|
|
1511
|
+
//
|
|
1512
|
+
// Therefore the write is FAIL-CLOSED: if it does not land (including its
|
|
1513
|
+
// fsync), we abort with a non-zero exit and NEVER call fetch. Nothing is
|
|
1514
|
+
// consumed. Moving this persist after the exchange — the "natural" order —
|
|
1515
|
+
// reintroduces exactly the bug above. (Contrast Step 4 in normal mode, which
|
|
1516
|
+
// is deliberately fail-open: there, the key already exists server-side and a
|
|
1517
|
+
// persist failure only degrades start-tickets routing.)
|
|
1518
|
+
// ===================================================================
|
|
1519
|
+
inviteFingerprint = fingerprintBootstrapInvite(inviteToken);
|
|
1520
|
+
log("Step 2/5 — redeeming the bootstrap invite…");
|
|
1521
|
+
let prepared = await deps.prepareBootstrapPending({
|
|
1522
|
+
repoName,
|
|
1523
|
+
inviteFingerprint,
|
|
1524
|
+
generateKeySecret: () => generateBootstrapKeySecret(deps.randomBytes),
|
|
1525
|
+
allowOverwriteExistingCredential: overwriteConsent,
|
|
1526
|
+
}, credentialWriteDeps);
|
|
1527
|
+
// An existing bapi:<repo> credential is never clobbered without consent. The
|
|
1528
|
+
// gate mirrors the host-config gate above: --force, else an interactive prompt,
|
|
1529
|
+
// else a hard non-interactive failure — all BEFORE the exchange.
|
|
1530
|
+
if (!prepared.ok && prepared.kind === "credential-conflict") {
|
|
1531
|
+
if (deps.isTTY && deps.promptLine) {
|
|
1532
|
+
const answer = (await deps.promptLine(`The credential store already holds a key for ${prepared.target}. Overwrite it? [y/N]: `)).trim().toLowerCase();
|
|
1533
|
+
if (answer !== "y" && answer !== "yes") {
|
|
1534
|
+
errorLog("Aborted: existing credential left unchanged (re-run with --force to overwrite).");
|
|
1535
|
+
return 1;
|
|
1536
|
+
}
|
|
1537
|
+
overwriteConsent = true;
|
|
1538
|
+
prepared = await deps.prepareBootstrapPending({
|
|
1539
|
+
repoName,
|
|
1540
|
+
inviteFingerprint,
|
|
1541
|
+
generateKeySecret: () => generateBootstrapKeySecret(deps.randomBytes),
|
|
1542
|
+
allowOverwriteExistingCredential: true,
|
|
1543
|
+
}, credentialWriteDeps);
|
|
1544
|
+
}
|
|
1545
|
+
else {
|
|
1546
|
+
errorLog(`Error: ${prepared.error} Re-run with --force to overwrite it (refusing to overwrite a ` +
|
|
1547
|
+
"credential non-interactively without consent).");
|
|
1548
|
+
return 1;
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
// A pending record left by a DIFFERENT invite is that redemption's only replay
|
|
1552
|
+
// proof. Unlike the credential conflict above there is no consent path — not
|
|
1553
|
+
// even --force — so re-running is not the fix and must not be advised.
|
|
1554
|
+
if (!prepared.ok && prepared.kind === "pending-conflict") {
|
|
1555
|
+
errorLog(`Error: ${prepared.error} This invite has NOT been used, and re-running will not clear ` +
|
|
1556
|
+
"the conflict.");
|
|
1557
|
+
return 1;
|
|
1558
|
+
}
|
|
1559
|
+
if (!prepared.ok) {
|
|
1560
|
+
// Fail closed: the exchange has NOT been called, so the invite is unspent.
|
|
1561
|
+
errorLog(`Error: could not durably store the bootstrap credential (${prepared.kind}). ${prepared.error} ` +
|
|
1562
|
+
"The bootstrap invite has NOT been used — fix the problem and re-run.");
|
|
1563
|
+
return 1;
|
|
1564
|
+
}
|
|
1565
|
+
const keySecret = prepared.keySecret;
|
|
1566
|
+
const reusedPendingSecret = prepared.reused;
|
|
1567
|
+
log(` saved the pending credential for ${prepared.target} (fsynced before the exchange)`);
|
|
1568
|
+
// The exchange REPLACES the pre-flight ping: in invite mode there is no key to
|
|
1569
|
+
// ping with — this call is what mints it.
|
|
1570
|
+
let exchange = await exchangeBootstrapInvite(deps, baseUrl, inviteToken, repoName, keySecret);
|
|
1571
|
+
// 409 repo_name_taken: the server ROLLED BACK, so the invite is NOT consumed and
|
|
1572
|
+
// the SAME token can be retried under a new name — with the SAME pending secret,
|
|
1573
|
+
// re-pointed to that name.
|
|
1574
|
+
while (!exchange.ok && exchange.kind === "repo-name-taken") {
|
|
1575
|
+
if (!deps.isTTY || !deps.promptLine) {
|
|
1576
|
+
errorLog(`Error: ${exchange.message} Re-run with a different --repo (the bootstrap invite has NOT ` +
|
|
1577
|
+
"been used).");
|
|
1578
|
+
return 1;
|
|
1579
|
+
}
|
|
1580
|
+
errorLog(exchange.message);
|
|
1581
|
+
const answer = (await deps.promptLine("Choose a different repo name: ")).trim();
|
|
1582
|
+
const validated = validateRepoName(answer);
|
|
1583
|
+
if (!validated.ok) {
|
|
1584
|
+
errorLog(`Error: invalid repo name — ${validated.error}.`);
|
|
1585
|
+
return 1;
|
|
1586
|
+
}
|
|
1587
|
+
const nextRepo = validated.value;
|
|
1588
|
+
const repointed = await deps.repointBootstrapPending({
|
|
1589
|
+
fromRepoName: repoName,
|
|
1590
|
+
toRepoName: nextRepo,
|
|
1591
|
+
inviteFingerprint,
|
|
1592
|
+
allowOverwriteExistingCredential: overwriteConsent,
|
|
1593
|
+
}, credentialWriteDeps);
|
|
1594
|
+
if (!repointed.ok) {
|
|
1595
|
+
errorLog(`Error: could not re-point the pending bootstrap credential to '${nextRepo}' ` +
|
|
1596
|
+
`(${repointed.kind}). ${repointed.error} The bootstrap invite has NOT been used.`);
|
|
1597
|
+
return 1;
|
|
1598
|
+
}
|
|
1599
|
+
repoName = nextRepo;
|
|
1600
|
+
// Same token, same key_secret — never regenerated inside the rename loop.
|
|
1601
|
+
exchange = await exchangeBootstrapInvite(deps, baseUrl, inviteToken, repoName, keySecret);
|
|
1602
|
+
}
|
|
1603
|
+
if (!exchange.ok) {
|
|
1604
|
+
if (exchange.kind === "invalid-invite") {
|
|
1605
|
+
errorLog(reusedPendingSecret
|
|
1606
|
+
? BOOTSTRAP_INVITE_REJECTED_MESSAGE
|
|
1607
|
+
: BOOTSTRAP_INVITE_LOST_SECRET_MESSAGE);
|
|
1608
|
+
}
|
|
1609
|
+
else {
|
|
1610
|
+
errorLog(`Error: ${exchange.message}`);
|
|
1611
|
+
}
|
|
1612
|
+
// The pending record is deliberately LEFT INTACT: it is the replay proof for a
|
|
1613
|
+
// retry (network failure / transient error), and destroying it would be the
|
|
1614
|
+
// unrecoverable case above.
|
|
1615
|
+
return 1;
|
|
1616
|
+
}
|
|
1617
|
+
// The server-returned name is authoritative from here on: config, ping,
|
|
1618
|
+
// credential target, and the spawned session all key off it.
|
|
1619
|
+
if (exchange.repoName !== repoName) {
|
|
1620
|
+
const repointed = await deps.repointBootstrapPending({
|
|
1621
|
+
fromRepoName: repoName,
|
|
1622
|
+
toRepoName: exchange.repoName,
|
|
1623
|
+
inviteFingerprint,
|
|
1624
|
+
allowOverwriteExistingCredential: overwriteConsent,
|
|
1625
|
+
}, credentialWriteDeps);
|
|
1626
|
+
if (!repointed.ok) {
|
|
1627
|
+
errorLog(`Error: the project was created as '${exchange.repoName}' but the pending credential could ` +
|
|
1628
|
+
`not be re-pointed to it (${repointed.kind}). ${repointed.error}`);
|
|
1629
|
+
return 1;
|
|
1630
|
+
}
|
|
1631
|
+
repoName = exchange.repoName;
|
|
1632
|
+
}
|
|
1633
|
+
log(` bootstrap invite redeemed — project '${repoName}' is ready`);
|
|
1634
|
+
// The minted key. From here the flow rejoins the existing path unchanged.
|
|
1635
|
+
apiKey = keySecret;
|
|
1636
|
+
}
|
|
1637
|
+
// ---- Step 2 (cont.) — verify connectivity BEFORE writing the key anywhere durable ----
|
|
690
1638
|
// R5: ping before persisting anything durably. Pinging before writeHostConfigs
|
|
691
1639
|
// (and the credential store) means a bad key on a first-time install halts
|
|
692
1640
|
// WITHOUT leaving an invalid key in the config — which would otherwise trip the
|
|
693
|
-
// overwrite-consent gate on every retry (a trapped state).
|
|
694
|
-
|
|
1641
|
+
// overwrite-consent gate on every retry (a trapped state). In invite mode this
|
|
1642
|
+
// runs AFTER the exchange, with the just-minted key; the pending record is the one
|
|
1643
|
+
// deliberate write that precedes it, for the protocol reason above.
|
|
1644
|
+
if (!bootstrapInviteMode)
|
|
1645
|
+
log("Step 2/5 — verifying connectivity…");
|
|
695
1646
|
const ping = await verifyConnectivity(deps, baseUrl, repoName, apiKey);
|
|
696
1647
|
if (!ping.ok) {
|
|
697
1648
|
errorLog(`Error: ${ping.message}`);
|
|
698
1649
|
return 1;
|
|
699
1650
|
}
|
|
700
1651
|
log(" connectivity OK");
|
|
1652
|
+
const entry = buildInstallBridgeServerEntry(deps.cwd, repoName, apiKey, baseUrl, docsDir);
|
|
701
1653
|
// ---- Step 3 — write per-host MCP config with real values ----
|
|
702
1654
|
log("Step 3/5 — writing per-host MCP config…");
|
|
703
1655
|
const written = await writeHostConfigs(deps, targets, entry);
|
|
@@ -726,42 +1678,51 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
726
1678
|
errorLog(`Warning: could not pre-warm the version-pinned launcher bucket${prewarm.warning ? ` (${prewarm.warning})` : ""}. ` +
|
|
727
1679
|
`The first MCP launch may pay a one-time cold install and could be slow. ${MCP_TIMEOUT_GUIDANCE}`);
|
|
728
1680
|
}
|
|
729
|
-
// ---- Step 4 — persist
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
if (result.ok) {
|
|
745
|
-
log(` stored routing credential for ${result.target} at ${result.path}`);
|
|
1681
|
+
// ---- Step 4 — persist the credential ----
|
|
1682
|
+
if (bootstrapInviteMode) {
|
|
1683
|
+
// FAIL-CLOSED (unlike normal mode below). The pending secret IS the minted admin
|
|
1684
|
+
// key — if it never lands under bapi:<repo>, the user has a live key they cannot
|
|
1685
|
+
// resolve. Promotion removes the pending record and writes the credential in one
|
|
1686
|
+
// durable replacement; on failure the pending record survives so a re-run replays
|
|
1687
|
+
// the same redemption. The agent session is NOT spawned until this succeeds.
|
|
1688
|
+
log("Step 4/5 — promoting the bootstrap credential…");
|
|
1689
|
+
const promoted = await deps.promoteBootstrapPending({ repoName, inviteFingerprint, allowOverwriteExistingCredential: overwriteConsent }, credentialWriteDeps);
|
|
1690
|
+
if (!promoted.ok) {
|
|
1691
|
+
errorLog(`Error: the project and API key were created, but the credential could not be stored ` +
|
|
1692
|
+
`(${promoted.kind}). ${promoted.error} Your key is still saved locally as a pending ` +
|
|
1693
|
+
"record — re-run install-bridge with the same bootstrap invite to finish (the redemption " +
|
|
1694
|
+
"will replay and return the same key).");
|
|
1695
|
+
return 1;
|
|
746
1696
|
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
1697
|
+
log(` stored routing credential for ${promoted.target} at ${promoted.path}`);
|
|
1698
|
+
}
|
|
1699
|
+
else {
|
|
1700
|
+
// ---- persist routing credential (non-blocking / fail-open) ----
|
|
1701
|
+
log("Step 4/5 — persisting routing credential…");
|
|
1702
|
+
try {
|
|
1703
|
+
const result = await deps.upsertCredential(repoName, apiKey, credentialWriteDeps);
|
|
1704
|
+
if (result.ok) {
|
|
1705
|
+
log(` stored routing credential for ${result.target} at ${result.path}`);
|
|
1706
|
+
}
|
|
1707
|
+
else {
|
|
1708
|
+
log(` warning: could not persist the routing credential (${result.kind}). ` +
|
|
1709
|
+
`start-tickets model routing may not resolve the key for bapi:${repoName} ` +
|
|
1710
|
+
"and will fail open to the premium/Opus tier (the most expensive) — " +
|
|
1711
|
+
"set BAPI_API_KEY in the shell or re-run install-bridge, then verify with " +
|
|
1712
|
+
"'npx -y @bridge_gpt/mcp-server doctor'.");
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
catch {
|
|
1716
|
+
// Fail-open: persistence is best-effort (mirrors Stage 6). The secret is never
|
|
1717
|
+
// included in the warning.
|
|
1718
|
+
log(" warning: could not persist the routing credential (unexpected error). " +
|
|
1719
|
+
"start-tickets model routing may need BAPI_API_KEY in the shell and will fail open " +
|
|
1720
|
+
"to the premium/Opus tier (the most expensive) until fixed — verify with " +
|
|
752
1721
|
"'npx -y @bridge_gpt/mcp-server doctor'.");
|
|
753
1722
|
}
|
|
754
1723
|
}
|
|
755
|
-
catch {
|
|
756
|
-
// Fail-open: persistence is best-effort (mirrors Stage 6). The secret is never
|
|
757
|
-
// included in the warning.
|
|
758
|
-
log(" warning: could not persist the routing credential (unexpected error). " +
|
|
759
|
-
"start-tickets model routing may need BAPI_API_KEY in the shell and will fail open " +
|
|
760
|
-
"to the premium/Opus tier (the most expensive) until fixed — verify with " +
|
|
761
|
-
"'npx -y @bridge_gpt/mcp-server doctor'.");
|
|
762
|
-
}
|
|
763
1724
|
// ---- Step 5 — spawn a fresh agent session for the agentic remainder ----
|
|
764
|
-
log(`Step 5/5 — opening a ${agent.name} session for /install-bridge +
|
|
1725
|
+
log(`Step 5/5 — opening a ${agent.name} session for /install-bridge configuration + capability report…`);
|
|
765
1726
|
const terminal = detectTerminal(undefined, deps.env);
|
|
766
1727
|
const spawnResult = await deps.spawnTerminalTab(deps.startTicketsDeps, terminal, spawnCommand, {
|
|
767
1728
|
key: "install",
|
|
@@ -774,16 +1735,17 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
|
|
|
774
1735
|
// user can run the agentic remainder by hand. Mirrors start-tickets treating a
|
|
775
1736
|
// spawn failure as non-fatal to the run.
|
|
776
1737
|
errorLog(`Warning: setup steps completed, but the agent session could not be opened (${spawnResult.error}). ` +
|
|
777
|
-
"The project is NOT configured yet — run /install-bridge
|
|
778
|
-
"
|
|
1738
|
+
"The project is NOT configured yet — run /install-bridge manually in this project to derive " +
|
|
1739
|
+
"and apply the config fields and see the capability report, then choose whether to run " +
|
|
1740
|
+
"/parse-repository to index the repository.");
|
|
779
1741
|
return 0;
|
|
780
1742
|
}
|
|
781
1743
|
log("");
|
|
782
|
-
log(`install-bridge setup steps complete. A fresh ${agent.name} session is now
|
|
783
|
-
"
|
|
1744
|
+
log(`install-bridge setup steps complete. A fresh ${agent.name} session is now applying ` +
|
|
1745
|
+
"configuration, presenting the capability report, and ending with one indexing-consent question.");
|
|
784
1746
|
log("NOTE: the install is not finished until that session's apply reports applied fields — " +
|
|
785
|
-
"it will pause to ask you to approve the project description
|
|
786
|
-
"
|
|
787
|
-
"'Applied N of M' summary.");
|
|
1747
|
+
"it will pause to ask you to approve the project description, and it will close by asking " +
|
|
1748
|
+
"'[Y/n] Index repository now?'. Verify afterwards on the project's Get Started page " +
|
|
1749
|
+
"(install status panel) or via the session's 'Applied N of M' summary.");
|
|
788
1750
|
return 0;
|
|
789
1751
|
}
|