@bridge_gpt/mcp-server 0.2.38 → 0.2.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +189 -14
  2. package/build/agent-capabilities/probe-context.js +2 -1
  3. package/build/agent-launchers/claude-executor-adapter.js +392 -0
  4. package/build/agent-launchers/executor-adapter-inspection.js +163 -0
  5. package/build/agent-launchers/executor-adapter-registry.js +90 -0
  6. package/build/agent-launchers/executor-adapter.js +136 -0
  7. package/build/agent-registry.js +28 -0
  8. package/build/agents.generated.js +1 -1
  9. package/build/claude-login.js +85 -0
  10. package/build/claude-user-config-doctor.js +59 -33
  11. package/build/commands.generated.js +12 -11
  12. package/build/conduct-epic/bridge-client.js +345 -0
  13. package/build/conduct-epic/checkpoint-store.js +423 -0
  14. package/build/conduct-epic/cli.js +1732 -0
  15. package/build/conduct-epic/lock.js +302 -0
  16. package/build/conduct-epic/pr-state.js +197 -0
  17. package/build/conduct-epic/spawn.js +101 -0
  18. package/build/conductor/bridge-api-client.js +37 -2
  19. package/build/conductor/doctor.js +11 -1
  20. package/build/conductor/install-doctor.js +184 -10
  21. package/build/conductor-bin.js +7 -7
  22. package/build/credential-store.js +10 -4
  23. package/build/credentials-cli.js +34 -19
  24. package/build/docs.generated.js +1 -1
  25. package/build/doctor.js +579 -88
  26. package/build/executor/agent-identity.js +32 -0
  27. package/build/executor/cli.js +50 -39
  28. package/build/executor/deps.js +15 -1
  29. package/build/executor/env.js +56 -45
  30. package/build/executor/index.js +9 -1
  31. package/build/executor/install-preflight.js +138 -0
  32. package/build/executor/job-errors.js +200 -0
  33. package/build/executor/job-runner.js +619 -268
  34. package/build/executor/observation.js +165 -0
  35. package/build/executor/permissions.js +163 -36
  36. package/build/executor/platform.js +54 -0
  37. package/build/executor/preflight.js +175 -67
  38. package/build/executor/process.js +39 -7
  39. package/build/executor/runner.js +19 -0
  40. package/build/executor/service-lifecycle.js +269 -0
  41. package/build/executor/service-unit.js +121 -12
  42. package/build/executor/stale-artifacts.js +70 -0
  43. package/build/executor/test-clock.js +188 -24
  44. package/build/executor/worker-command.js +22 -58
  45. package/build/executor/worker-log.js +82 -0
  46. package/build/executor/worktree-lock.js +264 -0
  47. package/build/index.js +527 -357
  48. package/build/install-bridge-conductor.js +376 -38
  49. package/build/install-bridge.js +414 -114
  50. package/build/install-doctor.js +13 -0
  51. package/build/install-reexec.js +5 -3
  52. package/build/mcp-install-state.js +130 -0
  53. package/build/mcp-profile.js +11 -2
  54. package/build/mcp-provisioning.js +15 -0
  55. package/build/merge-pull-request.js +562 -0
  56. package/build/phase-result-artifacts.js +450 -0
  57. package/build/pipeline-orchestrator.js +4 -0
  58. package/build/pipeline-utils.js +16 -0
  59. package/build/pipelines.generated.js +7 -7
  60. package/build/plane/preflight.js +18 -14
  61. package/build/plane/supervisor.js +8 -1
  62. package/build/project-root.js +34 -0
  63. package/build/readme.generated.js +1 -1
  64. package/build/run-unit-tests-launcher.js +36 -9
  65. package/build/setup-epic.js +57 -4
  66. package/build/sfcc/permissions.js +25 -6
  67. package/build/sfcc/reads-site-preference.js +6 -0
  68. package/build/sfcc/register.js +61 -23
  69. package/build/sfcc/registration-inventory.js +89 -0
  70. package/build/sfcc/setup-status.js +18 -34
  71. package/build/sfcc/tool-wrapper.js +294 -17
  72. package/build/sfcc/write-grants.js +33 -1
  73. package/build/sfcc/write-guard.js +41 -12
  74. package/build/sfcc/writes-custom-object-def.js +6 -2
  75. package/build/sfcc/writes-site-preference.js +6 -1
  76. package/build/sfcc/writes-system-object.js +11 -2
  77. package/build/sfcc/writes.js +13 -8
  78. package/build/start-tickets-prereqs.js +25 -15
  79. package/build/start-tickets.js +123 -21
  80. package/build/version.generated.js +1 -1
  81. package/build/worktree-core.js +9 -3
  82. package/docs/install/mcp-tool-integrations.md +54 -9
  83. package/docs/install/sfcc-integration.md +71 -24
  84. package/package.json +3 -3
  85. package/build/executor/worker-config-isolation.js +0 -287
@@ -144,11 +144,19 @@ import os from "os";
144
144
  import path from "path";
145
145
  import readline from "readline";
146
146
  import { runInit, buildBridgeApiEntry, resolveInitScaffoldAssets, refreshBridgeApiPackageSpec, currentBridgePackageSpec, } from "./init.js";
147
+ import { hasProjectRootMarker as sharedHasProjectRootMarker } from "./project-root.js";
147
148
  import { VERSION } from "./version.generated.js";
148
149
  import { validateRepoName } from "./bridge-config.js";
149
150
  import { MCP_HOST_TARGETS, HOST_PLATFORM_ORDER, allHostTargets, agentForPlatform, isHostPlatformId, detectDefaultPlatforms, } from "./mcp-host-targets.js";
150
151
  import { provisionHostTarget, createDefaultVendorProcessDeps, } from "./mcp-host-config.js";
151
- import { recordInstalledProjectArtifact, writeMcpInstallState } from "./mcp-install-state.js";
152
+ import { recordInstalledExecutorServiceUnit, recordInstalledProjectArtifact, writeMcpInstallState, } from "./mcp-install-state.js";
153
+ // Executor provisioning (BAPI-779). The generator and the lifecycle module are
154
+ // imported DIRECTLY and driven programmatically — never by spawning
155
+ // `executor install-service` as a subprocess, which would lose the typed plan,
156
+ // the write outcome, and the unit path this flow has to record.
157
+ import { collectExecutorInstallPreflight, } from "./executor/install-preflight.js";
158
+ import { inspectExecutorServiceState, startExecutorService, } from "./executor/service-lifecycle.js";
159
+ import { executorLaunchdLabelForId, executorSystemdUnitNameForId, executorLaunchdPlistPathForId, executorSystemdUnitPathForId, resolvePackagedExecutorInvocation, writeExecutorServicePlan, } from "./executor/service-unit.js";
152
160
  import { runInstallBridgeConductorCli, } from "./install-bridge-conductor.js";
153
161
  import { runConductorInstallDoctor, } from "./conductor/install-doctor.js";
154
162
  import { resolveConductorBridgeApiAccess, } from "./conductor/bridge-api-client.js";
@@ -846,8 +854,12 @@ export async function offerGithubConnection(repoName, baseUrl, deps, log) {
846
854
  `GitHub later with 'npx -y @bridge_gpt/mcp-server@latest connect-github --repo ${repoName}'.`);
847
855
  }
848
856
  }
849
- /** Echoed single-line prompt on stderr (used for repo confirmation / value). */
850
- function promptLineViaReadline(promptText) {
857
+ /**
858
+ * Echoed single-line prompt on stderr (used for repo confirmation / value).
859
+ * Exported so `index.ts`'s `--init` can share the exact same prompt mechanics
860
+ * as `install`'s missing-project-root confirmation (BAPI-818, R1).
861
+ */
862
+ export function promptLineViaReadline(promptText) {
851
863
  return new Promise((resolve) => {
852
864
  const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
853
865
  // See promptSecretViaReadline: EOF must resolve rather than deadlock the
@@ -983,6 +995,9 @@ export function createDefaultInstallBridgeDeps() {
983
995
  fetch: params.fetch,
984
996
  reviewPolicySource: params.reviewPolicySource,
985
997
  readWorkflowFile: () => params.readFile(claudeReviewWorkflowPath(params.cwd)),
998
+ // Read-only service-state probe (BAPI-779). Typed to return a state,
999
+ // never a runner, so the doctor cannot start/enable/reload anything.
1000
+ inspectExecutorServiceState: params.inspectExecutorServiceState,
986
1001
  installDoctorDeps: {
987
1002
  env: params.env,
988
1003
  cwd: params.cwd,
@@ -1025,6 +1040,133 @@ export function createDefaultInstallBridgeDeps() {
1025
1040
  conductorRunSetupEpicDryRun: async (argv) => ({
1026
1041
  exitCode: await runSetupEpicCli(argv),
1027
1042
  }),
1043
+ // ---- Executor provisioning seams (BAPI-779) ----
1044
+ lookupExecutable: (name) => defaultExecutablePresenceProbe(name),
1045
+ runLifecycleCommand: (executable, args) => defaultShellFreeRunner(executable, args),
1046
+ // `process.getuid` is absent on win32; a null uid makes the launchd start
1047
+ // plan refuse to build rather than interpolating `undefined` into a domain
1048
+ // target.
1049
+ getuid: () => (typeof process.getuid === "function" ? process.getuid() : null),
1050
+ // MONOTONIC, not wall clock: a budget measured with `Date.now()` would be
1051
+ // shortened or extended by an NTP step mid-observation.
1052
+ monotonicNow: () => Number(process.hrtime.bigint() / 1000000n),
1053
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
1054
+ };
1055
+ }
1056
+ /**
1057
+ * Shell-free command runner used for every service-manager invocation.
1058
+ *
1059
+ * `shell: false` and an argument ARRAY are the whole point: a unit path or unit
1060
+ * name containing shell metacharacters is data here, never syntax. Output is
1061
+ * captured for internal classification and is deliberately NOT surfaced —
1062
+ * `service-lifecycle.ts` reports a stage and an exit code instead.
1063
+ */
1064
+ function defaultShellFreeRunner(executable, args) {
1065
+ return new Promise((resolve, reject) => {
1066
+ const child = spawn(executable, args, { shell: false, stdio: ["ignore", "pipe", "pipe"] });
1067
+ let stdout = "";
1068
+ let stderr = "";
1069
+ child.stdout?.on("data", (chunk) => {
1070
+ stdout += chunk.toString("utf-8");
1071
+ });
1072
+ child.stderr?.on("data", (chunk) => {
1073
+ stderr += chunk.toString("utf-8");
1074
+ });
1075
+ // A spawn error (ENOENT on `systemctl`, a sandbox denial) rejects, which the
1076
+ // lifecycle module treats as ONE attempted failure — never a retry.
1077
+ child.on("error", reject);
1078
+ child.on("close", (code) => resolve({ exitCode: code ?? 1, stdout, stderr }));
1079
+ });
1080
+ }
1081
+ /** Presence probe: `<tool> --version` exiting 0 means the tool resolves on PATH. */
1082
+ async function defaultExecutablePresenceProbe(name) {
1083
+ try {
1084
+ const result = await defaultShellFreeRunner(name, ["--version"]);
1085
+ return result.exitCode === 0;
1086
+ }
1087
+ catch {
1088
+ return false;
1089
+ }
1090
+ }
1091
+ /**
1092
+ * Compose the executor-provisioning bundle the nested conductor phase runs on
1093
+ * (BAPI-779), from the FINAL merged deps.
1094
+ *
1095
+ * Every effect is a thin adapter over an existing seam — nothing here decides
1096
+ * anything. The orchestration (consent, ordering, degradation, observation)
1097
+ * stays in `install-bridge-conductor.ts`; moving it into this dispatch layer
1098
+ * would put policy in the place least able to test it.
1099
+ */
1100
+ function buildConductorExecutorDeps(deps, baseUrl) {
1101
+ const homeDir = deps.homedir();
1102
+ const lookupExecutable = deps.lookupExecutable ?? defaultExecutablePresenceProbe;
1103
+ const runLifecycleCommand = deps.runLifecycleCommand ?? defaultShellFreeRunner;
1104
+ return {
1105
+ platform: deps.platform,
1106
+ homeDir,
1107
+ uid: deps.getuid ? deps.getuid() : null,
1108
+ // Captured ONCE here, never re-read inside a renderer (BAPI-327): the unit
1109
+ // bakes the generating shell's PATH so a service-launched executor can
1110
+ // resolve the bare `claude` worker command.
1111
+ envPath: deps.env.PATH ?? deps.env.Path ?? "",
1112
+ resolveInvocation: () => resolvePackagedExecutorInvocation(),
1113
+ runPreflight: (input) => collectExecutorInstallPreflight(input, {
1114
+ lookupExecutable: (name) => lookupExecutable(name),
1115
+ homedir: deps.homedir,
1116
+ readFile: deps.readFile,
1117
+ }),
1118
+ // The programmatic generator, NOT a spawned `executor install-service`: the
1119
+ // typed plan, the write outcome, and the unit path all have to come back.
1120
+ writeUnit: (plan) => writeExecutorServicePlan(plan, {
1121
+ stat: (filePath) => deps.stat(filePath),
1122
+ mkdir: (dirPath, options) => deps.mkdir(dirPath, options),
1123
+ writeFile: (filePath, content) => deps.writeFile(filePath, content),
1124
+ // Supplying `readFile` is what enables the `unchanged` outcome, so a
1125
+ // re-run against an identical unit reports the truth instead of an
1126
+ // unnecessary rewrite.
1127
+ readFile: deps.readFile,
1128
+ }),
1129
+ recordUnit: async (unitPath) => {
1130
+ const result = await recordInstalledExecutorServiceUnit(deps.cwd, unitPath, homeDir, {
1131
+ readFile: deps.readFile,
1132
+ writeFile: (p, data) => deps.writeFile(p, data),
1133
+ rename: deps.rename,
1134
+ mkdir: (p, o) => deps.mkdir(p, o),
1135
+ unlink: deps.unlink,
1136
+ });
1137
+ return result.ok ? { ok: true } : { ok: false, error: result.error };
1138
+ },
1139
+ startService: (plan) => startExecutorService(plan, runLifecycleCommand),
1140
+ now: deps.monotonicNow ?? (() => Number(process.hrtime.bigint() / 1000000n)),
1141
+ sleep: deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
1142
+ };
1143
+ }
1144
+ /**
1145
+ * Read-only local service-state inspector for the unified doctor (BAPI-779).
1146
+ *
1147
+ * Resolves the unit's service-manager handle from the executor id through the
1148
+ * generator's own naming helpers, so the doctor addresses exactly the unit the
1149
+ * generator would have written. Returns `unknown` on an unsupported platform
1150
+ * rather than probing anything.
1151
+ */
1152
+ function buildDoctorServiceStateInspector(deps) {
1153
+ const runLifecycleCommand = deps.runLifecycleCommand ?? defaultShellFreeRunner;
1154
+ const homeDir = deps.homedir();
1155
+ const uid = deps.getuid ? deps.getuid() : null;
1156
+ return async ({ executorId }) => {
1157
+ if (deps.platform !== "darwin" && deps.platform !== "linux")
1158
+ return "unknown";
1159
+ const inspection = await inspectExecutorServiceState({
1160
+ platform: deps.platform,
1161
+ unitPath: deps.platform === "darwin"
1162
+ ? executorLaunchdPlistPathForId(executorId, homeDir)
1163
+ : executorSystemdUnitPathForId(executorId, homeDir),
1164
+ serviceIdentifier: deps.platform === "darwin"
1165
+ ? executorLaunchdLabelForId(executorId)
1166
+ : executorSystemdUnitNameForId(executorId),
1167
+ uid,
1168
+ }, runLifecycleCommand);
1169
+ return inspection.state;
1028
1170
  };
1029
1171
  }
1030
1172
  /**
@@ -1502,8 +1644,6 @@ export async function resolveSelectedHostPlatforms(deps, options) {
1502
1644
  if (options.tools !== undefined) {
1503
1645
  return options.tools;
1504
1646
  }
1505
- const ctx = await buildDetectionContext(deps);
1506
- const detected = new Set(detectDefaultPlatforms(ctx));
1507
1647
  // 2. Interactive single-shot numbered picker on a TTY. No row is pre-selected
1508
1648
  // — the picker has no default state and requires an explicit selection.
1509
1649
  if (deps.isTTY && deps.promptMultiSelect) {
@@ -1512,6 +1652,19 @@ export async function resolveSelectedHostPlatforms(deps, options) {
1512
1652
  return HOST_PLATFORM_ORDER.filter((id) => chosen.includes(id));
1513
1653
  }
1514
1654
  // 3. Legacy non-TTY automatic set: Claude + detected Cursor / Copilot VS Code.
1655
+ return resolveLegacyAutoDetectedHostPlatforms(deps);
1656
+ }
1657
+ /**
1658
+ * The legacy non-TTY automatic host set (Claude + detected Cursor / Copilot
1659
+ * VS Code) — branch 3 of {@link resolveSelectedHostPlatforms}, extracted so a
1660
+ * `--dry-run` preview (BAPI-818) can compute the SAME deterministic answer
1661
+ * without touching the TTY branch above it (which would prompt). Pure/local:
1662
+ * only reads the injected `stat` boundary, never prompts, never touches the
1663
+ * network.
1664
+ */
1665
+ async function resolveLegacyAutoDetectedHostPlatforms(deps) {
1666
+ const ctx = await buildDetectionContext(deps);
1667
+ const detected = new Set(detectDefaultPlatforms(ctx));
1515
1668
  const legacy = ["claude-code"];
1516
1669
  if (detected.has("cursor"))
1517
1670
  legacy.push("cursor");
@@ -1519,6 +1672,21 @@ export async function resolveSelectedHostPlatforms(deps, options) {
1519
1672
  legacy.push("copilot-vscode");
1520
1673
  return HOST_PLATFORM_ORDER.filter((id) => legacy.includes(id));
1521
1674
  }
1675
+ /**
1676
+ * Host-platform selection as known LOCALLY at dry-run time (BAPI-818, R2):
1677
+ * an explicit `--tools`, or — when the run would otherwise fall back to the
1678
+ * legacy non-TTY automatic set (no TTY, or no `promptMultiSelect` seam) — that
1679
+ * SAME deterministic local detection. Returns `undefined` ONLY when a real run
1680
+ * would need the interactive multi-select prompt, which a dry run may never
1681
+ * invoke.
1682
+ */
1683
+ async function resolveDryRunHostPlatforms(deps, options) {
1684
+ if (options.tools !== undefined)
1685
+ return options.tools;
1686
+ if (deps.isTTY && deps.promptMultiSelect)
1687
+ return undefined;
1688
+ return resolveLegacyAutoDetectedHostPlatforms(deps);
1689
+ }
1522
1690
  /**
1523
1691
  * Launch agents install-bridge will NEVER auto-spawn from a host SELECTION, even
1524
1692
  * though their CLI exists and the host's MCP config is still written normally.
@@ -3118,45 +3286,72 @@ export function buildCommitGeneratedAssetsNotice(safePaths, gitignoredPaths) {
3118
3286
  return "";
3119
3287
  return [INSTALL_BRIDGE_COMMIT_ASSETS_HEADING, ...lines].join("\n");
3120
3288
  }
3289
+ /** The repo-name preview line, honest about whether the value is known yet. */
3290
+ function describeDryRunRepoLine(repo, deferredHint) {
3291
+ if (repo.kind === "deferred")
3292
+ return `Repo name: (not yet known — ${deferredHint})`;
3293
+ const source = repo.kind === "explicit" ? "from --repo" : "from BAPI_REPO_NAME";
3294
+ return `Repo name: ${repo.value} (${source})`;
3295
+ }
3296
+ /** A human-readable stand-in for an unresolved repo name inside a sentence. */
3297
+ function repoNamePlaceholder(repo) {
3298
+ return repo.kind === "deferred" ? "<repo name — not yet known>" : repo.value;
3299
+ }
3300
+ /** The project-root preview line. Reports the LOCAL read-only result only — never prompts. */
3301
+ function describeDryRunRootLine(projectRootPresent) {
3302
+ return projectRootPresent
3303
+ ? "Project root: .git found"
3304
+ : "Project root: no .git found here — a real run would ask to continue (default No)";
3305
+ }
3121
3306
  /**
3122
- * Render the --dry-run preview lines. Every secret is ALWAYS redacted the
3123
- * spawnCommand, config preview, and (in bootstrap-invite mode) the exchange body
3124
- * never embed the API key, the invite token, or the generated `key_secret`.
3125
- *
3126
- * The plan itself holds no secret and no invite fingerprint, so this function
3127
- * CANNOT leak one: in bootstrap-invite mode it is called before any secret exists.
3307
+ * Render the --dry-run preview lines. Every value is either a pure LOCAL
3308
+ * computation or an explicit CLI/env input nothing here required a prompt, a
3309
+ * credential read/resolution, or a network call to produce (BAPI-818, R2).
3310
+ * Genuinely unresolved information (the repo name absent both `--repo` and
3311
+ * `BAPI_REPO_NAME`; the tool selection absent `--tools`) is reported as
3312
+ * deferred rather than guessed, inferred, or silently omitted.
3128
3313
  */
3129
- export function buildDryRunPreview(plan) {
3130
- if (plan.bootstrapInvite)
3131
- return buildBootstrapDryRunPreview(plan);
3314
+ export function buildDryRunPreview(ctx) {
3315
+ if (ctx.bootstrapInvite || ctx.selfServeSignup)
3316
+ return buildBootstrapDryRunPreview(ctx);
3317
+ const pingStep = ctx.repo.kind === "deferred"
3318
+ ? ["Step 2 — connectivity ping: deferred (the repo name is not yet known)."]
3319
+ : [
3320
+ `Step 2 — connectivity ping (before any durable key write): GET ${buildPingUrl(ctx.baseUrl, ctx.repo.value)} (X-API-Key: ${REDACTED_API_KEY})`,
3321
+ ];
3322
+ const hostConfigStep = ctx.host.kind === "deferred"
3323
+ ? [
3324
+ "Step 3 — write per-host MCP config: deferred (tool selection is not yet known —",
3325
+ " a real run asks interactively on a TTY, or auto-detects non-interactively).",
3326
+ ]
3327
+ : [
3328
+ "Step 3 — write per-host MCP config (read-merge-write, launcher version-pinned;",
3329
+ " a git-tracked config needs default-No consent for the real key, else a",
3330
+ " secret-free entry; an unparseable config is skipped and left untouched):",
3331
+ ...ctx.host.configTargets.map((t) => ` ${t}: BAPI_REPO_NAME=${repoNamePlaceholder(ctx.repo)}, BAPI_API_KEY=${REDACTED_API_KEY}, BAPI_BASE_URL=${ctx.baseUrl}, BAPI_DOCS_DIR=${ctx.docsDir}`),
3332
+ ...(ctx.host.manualEditors.length > 0
3333
+ ? [` ${ctx.host.manualEditors.join(" + ")}: detected (global config) — manual setup instructions would be printed.`]
3334
+ : []),
3335
+ ];
3336
+ const credentialStep = ctx.repo.kind === "deferred"
3337
+ ? [`Step 4 — persist routing credential: deferred (the repo name is not yet known) at ${ctx.credentialStorePath}`]
3338
+ : [`Step 4 — persist routing credential: target bapi:${ctx.repo.value} at ${ctx.credentialStorePath}`];
3132
3339
  return [
3133
- // Any defined status means the read-only GET really happened, so the header is
3134
- // truthful for every outcome but ONLY `resolved` may claim the name came back
3135
- // from the server (BAPI-687, C-4).
3136
- plan.serverRepoResolutionStatus !== undefined
3137
- ? "install --dry-run (one read-only repository-resolution GET may already have occurred; no writes, no state-changing requests, no spawns)"
3138
- : "install --dry-run (no writes, no network, no spawns)",
3139
- `Repo name: ${plan.repoName}${plan.serverRepoResolutionStatus === "resolved"
3140
- ? " (resolved server-side from your API key)"
3141
- : ""}`,
3142
- `Base URL (ping): ${plan.baseUrl}`,
3143
- `Docs dir: ${plan.docsDir}`,
3144
- `Agent: ${describePlannedLaunchAgent(plan.launch)}`,
3340
+ "install --dry-run (no writes, no network, no spawns, no credential resolved or prompted for)",
3341
+ describeDryRunRepoLine(ctx.repo, "a real run would resolve it from your API key, or prompt if it cannot be"),
3342
+ `Base URL: ${ctx.baseUrl}`,
3343
+ `Docs dir: ${ctx.docsDir}`,
3344
+ describeDryRunRootLine(ctx.projectRootPresent),
3345
+ `Agent: ${ctx.host.kind === "explicit" ? describePlannedLaunchAgent(ctx.host.launch) : "(deferred tool selection is not yet known)"}`,
3145
3346
  "",
3146
3347
  "Step 1 — scaffold (runInit): commands, agents, pipelines, .bridge/config, secret-free MCP placeholders.",
3147
- `Step 2 — connectivity ping (before any durable key write): GET ${plan.pingUrl} (X-API-Key: ${REDACTED_API_KEY})`,
3148
- "Step 3 — write per-host MCP config (read-merge-write, launcher version-pinned;",
3149
- " a git-tracked config needs default-No consent for the real key, else a",
3150
- " secret-free entry; an unparseable config is skipped and left untouched):",
3151
- ...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}`),
3152
- ...(plan.manualEditors.length > 0
3153
- ? [` ${plan.manualEditors.join(" + ")}: detected (global config) — manual setup instructions would be printed.`]
3154
- : []),
3155
- `Step 3b — pre-warm the version-pinned launcher bucket (fail-open, env sanitized — BAPI_API_KEY removed): ${plan.prewarmCommand}`,
3348
+ ...pingStep,
3349
+ ...hostConfigStep,
3350
+ `Step 3b — pre-warm the version-pinned launcher bucket (fail-open, env sanitized BAPI_API_KEY removed): ${ctx.prewarmCommand}`,
3156
3351
  MCP_TIMEOUT_GUIDANCE,
3157
- `Step 4 — persist routing credential: target ${plan.credentialTarget} at ${plan.credentialStorePath}`,
3158
- ...buildCommitAssetsPreviewLines(plan),
3159
- ...buildLaunchStepPreview(plan),
3352
+ ...credentialStep,
3353
+ ...buildCommitAssetsPreviewLines(ctx.commitSafeAssets, ctx.host.kind === "explicit" ? ctx.host.configTargets : []),
3354
+ ...(ctx.host.kind === "explicit" ? buildLaunchStepPreview(ctx.host.launch) : buildDeferredLaunchStepPreview()),
3160
3355
  ];
3161
3356
  }
3162
3357
  /**
@@ -3164,12 +3359,12 @@ export function buildDryRunPreview(plan) {
3164
3359
  * returns before the live emission point, and this module's convention is that a
3165
3360
  * preview never advertises different behavior — so the same notice, built from the
3166
3361
  * same resolved manifest, appears here too. The gitignored exclusion set is the
3167
- * plan's own `configTargets` plus the install-state file, exactly as the live path
3168
- * computes it.
3362
+ * caller's own config targets plus the install-state file, exactly as the live
3363
+ * path computes it.
3169
3364
  */
3170
- function buildCommitAssetsPreviewLines(plan) {
3171
- const notice = buildCommitGeneratedAssetsNotice(plan.commitSafeAssets ?? [], [
3172
- ...plan.configTargets,
3365
+ function buildCommitAssetsPreviewLines(commitSafeAssets, configTargets) {
3366
+ const notice = buildCommitGeneratedAssetsNotice(commitSafeAssets ?? [], [
3367
+ ...configTargets,
3173
3368
  INSTALL_BRIDGE_INSTALL_STATE_PATH,
3174
3369
  ]);
3175
3370
  return notice.length > 0 ? ["", notice] : [];
@@ -3202,7 +3397,7 @@ function describePlannedLaunchAgent(launch) {
3202
3397
  * A --dry-run never writes the launch script, prompts for consent, or spawns, so
3203
3398
  * these lines describe the outcome rather than performing it.
3204
3399
  */
3205
- function buildLaunchStepPreview(plan) {
3400
+ function buildLaunchStepPreview(launch) {
3206
3401
  const githubLines = [
3207
3402
  // BAPI-631: described, never performed in --dry-run — a preview must not open a
3208
3403
  // browser or reach the network. It is also strictly optional, so it carries no step
@@ -3220,18 +3415,18 @@ function buildLaunchStepPreview(plan) {
3220
3415
  " It opens github.com in your browser, and shares no GitHub credential with Bridge; it",
3221
3416
  ` defaults to No and can be run later instead: ${INSTALL_BRIDGE_CONNECT_GITHUB_COMMAND}`,
3222
3417
  ];
3223
- if (plan.launch.kind === "spawn") {
3418
+ if (launch.kind === "spawn") {
3224
3419
  return [
3225
3420
  ...githubLines,
3226
- `Step 5 — agent session (${toolLabelForLaunchAgent(plan.launch.agent)}): on a TTY the wizard first asks`,
3421
+ `Step 5 — agent session (${toolLabelForLaunchAgent(launch.agent)}): on a TTY the wizard first asks`,
3227
3422
  " 'Open a … session to do that now? (Y/n)'; only on consent is the full command below",
3228
3423
  " stored in a restricted launch script (mode 0600, under the system temp dir) and a short",
3229
3424
  " sourced runner spawned (the script itself is NOT written in --dry-run):",
3230
- ` ${plan.launch.spawnCommand}`,
3425
+ ` ${launch.spawnCommand}`,
3231
3426
  ];
3232
3427
  }
3233
- if (plan.launch.kind === "choose-one") {
3234
- const labels = plan.launch.agents.map((a) => toolLabelForLaunchAgent(a)).join(", ");
3428
+ if (launch.kind === "choose-one") {
3429
+ const labels = launch.agents.map((a) => toolLabelForLaunchAgent(a)).join(", ");
3235
3430
  return [
3236
3431
  ...githubLines,
3237
3432
  `Step 5 — agent session: more than one selected tool can host it (${labels}); on a TTY the wizard`,
@@ -3240,33 +3435,47 @@ function buildLaunchStepPreview(plan) {
3240
3435
  }
3241
3436
  return [
3242
3437
  ...githubLines,
3243
- plan.launch.reason === "empty-selection"
3438
+ launch.reason === "empty-selection"
3244
3439
  ? "Step 5 — no agent session: no tools were selected, so nothing is configured or opened; re-run and select at least one tool."
3245
3440
  : "Step 5 — no automatic launch: no selected tool has an agentic CLI. The deterministic setup completes and copy-pasteable /install-bridge continuation is printed (your Bridge MCP tools stay limited until configured).",
3246
3441
  ];
3247
3442
  }
3443
+ /**
3444
+ * The deferred form of {@link buildLaunchStepPreview}, used when the tool
3445
+ * selection itself was not resolved (no `--tools`) — the launch outcome cannot
3446
+ * be computed without it, so this states that plainly instead of guessing.
3447
+ */
3448
+ function buildDeferredLaunchStepPreview() {
3449
+ return [
3450
+ "Step 4b — GitHub connect offer and agent session: deferred (tool selection is",
3451
+ " not yet known — a real run resolves it first, then follows from there).",
3452
+ ];
3453
+ }
3248
3454
  /**
3249
3455
  * Bootstrap-invite dry-run preview. A --dry-run must not consume the invite OR
3250
3456
  * LEAVE STATE BEHIND: skipping the HTTP call is not enough — a preview that wrote
3251
3457
  * a pending `key_secret` would have left credential material on disk for a
3252
- * redemption that never happened. So the caller returns here BEFORE the CSPRNG
3253
- * runs and before anything is written.
3458
+ * redemption that never happened. So this is reached BEFORE the CSPRNG runs and
3459
+ * before anything is written — indeed before the invite/email is even resolved,
3460
+ * since the early dry-run short-circuit in `runInstallBridgeCli` returns before
3461
+ * `resolveInviteToken`/`resolveSignupEmail` run at all.
3254
3462
  */
3255
- function buildBootstrapDryRunPreview(plan) {
3256
- const pendingTarget = `bootstrap-pending:${plan.repoName}`;
3463
+ function buildBootstrapDryRunPreview(ctx) {
3464
+ const pendingTarget = `bootstrap-pending:${repoNamePlaceholder(ctx.repo)}`;
3257
3465
  // Self-serve signup (BAPI-618): the ONLY structural difference from a pre-issued
3258
3466
  // invite is a mint-from-email step (Step 2·pre) that turns an email into the
3259
3467
  // invite token before the unchanged redemption below. The preview states plainly
3260
3468
  // that in --dry-run that signup/mint is previewed-and-skipped: no account is
3261
3469
  // created, no mint request is sent, no email leaves the machine. The email and
3262
3470
  // any synthetic token are deliberately absent from this output.
3263
- const header = plan.selfServeSignup
3471
+ const header = ctx.selfServeSignup
3264
3472
  ? "install --email --dry-run (no writes, no network, no spawns, no account created, no secret generated)"
3265
3473
  : "install --invite --dry-run (no writes, no network, no spawns, no secret generated)";
3266
- const repoLine = plan.selfServeSignup
3267
- ? `Repo name: ${plan.repoName} (created by the self-serve exchange; globally unique)`
3268
- : `Repo name: ${plan.repoName} (created by the exchange; globally unique)`;
3269
- const selfServeStep = plan.selfServeSignup
3474
+ const createdBy = ctx.selfServeSignup ? "the self-serve exchange" : "the exchange";
3475
+ const repoLine = ctx.repo.kind === "deferred"
3476
+ ? `Repo name: (not yet known a real run would prompt for a new, globally-unique project name)`
3477
+ : `Repo name: ${ctx.repo.value} (would be created by ${createdBy}; globally unique)`;
3478
+ const selfServeStep = ctx.selfServeSignup
3270
3479
  ? [
3271
3480
  "Step 2·pre — self-serve signup (PREVIEWED, SKIPPED in --dry-run): no Bridge workspace",
3272
3481
  " signup is requested, no mint call is made, no email is sent or transmitted, and",
@@ -3277,33 +3486,51 @@ function buildBootstrapDryRunPreview(plan) {
3277
3486
  " redemption protocol below.",
3278
3487
  ]
3279
3488
  : [];
3489
+ const exchangeUrl = buildBootstrapExchangeUrl(ctx.baseUrl);
3490
+ const pingStep = ctx.repo.kind === "deferred"
3491
+ ? ["Step 2c — connectivity ping with the newly-minted key: deferred (the repo name is not yet known)."]
3492
+ : [
3493
+ `Step 2c — connectivity ping with the newly-minted key (before any durable key write): GET ${buildPingUrl(ctx.baseUrl, ctx.repo.value)} (X-API-Key: ${REDACTED_API_KEY})`,
3494
+ ];
3495
+ const hostConfigStep = ctx.host.kind === "deferred"
3496
+ ? [
3497
+ "Step 3 — write per-host MCP config: deferred (tool selection is not yet known —",
3498
+ " a real run asks interactively on a TTY, or auto-detects non-interactively).",
3499
+ ]
3500
+ : [
3501
+ "Step 3 — write per-host MCP config (read-merge-write, launcher version-pinned;",
3502
+ " a git-tracked config needs default-No consent for the real key, else a",
3503
+ " secret-free entry; an unparseable config is skipped and left untouched):",
3504
+ ...ctx.host.configTargets.map((t) => ` ${t}: BAPI_REPO_NAME=${repoNamePlaceholder(ctx.repo)}, BAPI_API_KEY=${REDACTED_API_KEY}, BAPI_BASE_URL=${ctx.baseUrl}, BAPI_DOCS_DIR=${ctx.docsDir}`),
3505
+ ...(ctx.host.manualEditors.length > 0
3506
+ ? [` ${ctx.host.manualEditors.join(" + ")}: detected (global config) — manual setup instructions would be printed.`]
3507
+ : []),
3508
+ ];
3509
+ const promoteStep = ctx.repo.kind === "deferred"
3510
+ ? [`Step 4 — promote ${pendingTarget} to its final credential (only after the exchange succeeds) at ${ctx.credentialStorePath}`]
3511
+ : [`Step 4 — promote ${pendingTarget} → bapi:${ctx.repo.value} at ${ctx.credentialStorePath} (only after the exchange succeeds)`];
3280
3512
  return [
3281
3513
  header,
3282
3514
  repoLine,
3283
- `Base URL: ${plan.baseUrl}`,
3284
- `Docs dir: ${plan.docsDir}`,
3285
- `Agent: ${describePlannedLaunchAgent(plan.launch)}`,
3515
+ `Base URL: ${ctx.baseUrl}`,
3516
+ `Docs dir: ${ctx.docsDir}`,
3517
+ describeDryRunRootLine(ctx.projectRootPresent),
3518
+ `Agent: ${ctx.host.kind === "explicit" ? describePlannedLaunchAgent(ctx.host.launch) : "(deferred — tool selection is not yet known)"}`,
3286
3519
  "",
3287
3520
  "Step 1 — scaffold (runInit): commands, agents, pipelines, .bridge/config, secret-free MCP placeholders.",
3288
3521
  ...selfServeStep,
3289
- `Step 2a — generate key_secret (32 CSPRNG bytes) and fsync it to ${pendingTarget} at ${plan.credentialStorePath}`,
3522
+ `Step 2a — generate key_secret (32 CSPRNG bytes) and fsync it to ${pendingTarget} at ${ctx.credentialStorePath}`,
3290
3523
  " BEFORE the exchange. If that write fails the run ABORTS and no invite is spent.",
3291
3524
  `Step 2b — redeem the bootstrap invite (replaces the pre-flight ping — there is no key yet):`,
3292
- ` POST ${plan.exchangeUrl}`,
3293
- ` body: {"token": "${REDACTED_API_KEY}", "repo_name": "${plan.repoName}", "key_secret": "${REDACTED_API_KEY}"}`,
3294
- `Step 2c — connectivity ping with the newly-minted key (before any durable key write): GET ${plan.pingUrl} (X-API-Key: ${REDACTED_API_KEY})`,
3295
- "Step 3 — write per-host MCP config (read-merge-write, launcher version-pinned;",
3296
- " a git-tracked config needs default-No consent for the real key, else a",
3297
- " secret-free entry; an unparseable config is skipped and left untouched):",
3298
- ...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}`),
3299
- ...(plan.manualEditors.length > 0
3300
- ? [` ${plan.manualEditors.join(" + ")}: detected (global config) — manual setup instructions would be printed.`]
3301
- : []),
3302
- `Step 3b — pre-warm the version-pinned launcher bucket (fail-open, env sanitized — BAPI_API_KEY / BAPI_INVITE removed): ${plan.prewarmCommand}`,
3525
+ ` POST ${exchangeUrl}`,
3526
+ ` body: {"token": "${REDACTED_API_KEY}", "repo_name": "${repoNamePlaceholder(ctx.repo)}", "key_secret": "${REDACTED_API_KEY}"}`,
3527
+ ...pingStep,
3528
+ ...hostConfigStep,
3529
+ `Step 3b — pre-warm the version-pinned launcher bucket (fail-open, env sanitized BAPI_API_KEY / BAPI_INVITE removed): ${ctx.prewarmCommand}`,
3303
3530
  MCP_TIMEOUT_GUIDANCE,
3304
- `Step 4 — promote ${pendingTarget} → ${plan.credentialTarget} at ${plan.credentialStorePath} (only after the exchange succeeds)`,
3305
- ...buildCommitAssetsPreviewLines(plan),
3306
- ...buildLaunchStepPreview(plan),
3531
+ ...promoteStep,
3532
+ ...buildCommitAssetsPreviewLines(ctx.commitSafeAssets, ctx.host.kind === "explicit" ? ctx.host.configTargets : []),
3533
+ ...(ctx.host.kind === "explicit" ? buildLaunchStepPreview(ctx.host.launch) : buildDeferredLaunchStepPreview()),
3307
3534
  ];
3308
3535
  }
3309
3536
  /**
@@ -3635,20 +3862,15 @@ export const INSTALL_BRIDGE_NO_GIT_ABORT = "Aborted — run install from your pr
3635
3862
  /** The non-TTY form: same finding, stated as a warning, then the run continues. */
3636
3863
  export const INSTALL_BRIDGE_NO_GIT_NONINTERACTIVE_WARNING = `Warning: ${INSTALL_BRIDGE_NO_GIT_WARNING} — continuing (non-interactive).`;
3637
3864
  /**
3638
- * Existence probe for `<cwd>/.git`, through the injected `stat` boundary. A git
3865
+ * Existence probe for `<cwd>/.git`, delegated to the shared project-root probe
3866
+ * (BAPI-818) so `--init` and `install` use exactly one marker definition. A git
3639
3867
  * worktree's `.git` is a FILE rather than a directory, so only existence is
3640
3868
  * checked — never the entry type. Any probe failure is treated as "absent"; this
3641
3869
  * is a hint for a warning, never an authorization decision. Repository-name
3642
3870
  * inference is untouched by it.
3643
3871
  */
3644
3872
  async function hasProjectRootMarker(deps) {
3645
- try {
3646
- await deps.stat(path.join(deps.cwd, ".git"));
3647
- return true;
3648
- }
3649
- catch {
3650
- return false;
3651
- }
3873
+ return sharedHasProjectRootMarker(deps.cwd, deps.stat);
3652
3874
  }
3653
3875
  // ---------------------------------------------------------------------------
3654
3876
  // CLI entry
@@ -3744,6 +3966,9 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
3744
3966
  cwd: deps.cwd,
3745
3967
  env: deps.env,
3746
3968
  readFile: deps.readFile,
3969
+ // Read-only service-state probe (BAPI-779), built from the FINAL
3970
+ // merged deps so a test's platform/runner overrides reach the doctor.
3971
+ inspectExecutorServiceState: buildDoctorServiceStateInspector(deps),
3747
3972
  }),
3748
3973
  writeWorkflow: (cwd, content, options) => (deps.conductorWriteWorkflow ??
3749
3974
  (async () => ({ ok: false, error: "workflow seam unavailable" })))(cwd, content, options, deps),
@@ -3751,6 +3976,10 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
3751
3976
  (async () => ({ ok: false, error: "install-state seam unavailable" })))(cwd, relPath, deps),
3752
3977
  runSetupEpicDryRun: (setupArgv) => (deps.conductorRunSetupEpicDryRun ??
3753
3978
  (async () => ({ exitCode: 1 })))(setupArgv),
3979
+ // Built from the FINAL merged deps (BAPI-779), for the same reason the
3980
+ // seams above are: a test that overrides only `fetch` or only `homedir`
3981
+ // must see its override here, not a stale default captured earlier.
3982
+ executorProvisioning: buildConductorExecutorDeps(deps, conductorBaseUrl),
3754
3983
  };
3755
3984
  return runInstallBridgeConductorCli(argv.slice(1), conductorDeps);
3756
3985
  }
@@ -3782,6 +4011,101 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
3782
4011
  // every downstream `deps.env` consumer observes it. The caller-owned object (and
3783
4012
  // `process.env`) is never mutated.
3784
4013
  deps.env = { ...deps.env, BAPI_BASE_URL: baseUrl };
4014
+ // ---- BAPI-818 (R2): --dry-run short-circuits HERE — genuinely inert ----
4015
+ // Fires immediately after argument parsing and the pure base-URL validation
4016
+ // above, and strictly BEFORE the project-root confirmation prompt, onboarding
4017
+ // branch selection, credential resolution, the server-side repo-resolution
4018
+ // round-trip, the host multi-select, and every bootstrap/invite, write,
4019
+ // prewarm, or spawn action below. That ordering IS the fix: a dry run used to
4020
+ // reach every one of those first (README:258/119's "no writes, no contacting
4021
+ // Bridge, no opening anything" was false). Everything the preview below reports
4022
+ // is either an explicit CLI/env input or a pure local computation — never a
4023
+ // credential read, an invite exchange, or a network call. The bootstrap-invite
4024
+ // property this ordering must preserve (a preview must neither consume the
4025
+ // invite NOR leave a pending secret on disk) holds trivially here: this branch
4026
+ // returns before `resolveInviteToken`/`resolveSignupEmail` even run.
4027
+ if (options.dryRun) {
4028
+ log(`${INSTALL_BRIDGE_CWD_BANNER_PREFIX}${deps.cwd}`);
4029
+ const repo = typeof options.repo === "string" && options.repo.trim().length > 0
4030
+ ? { kind: "explicit", value: options.repo.trim() }
4031
+ : typeof deps.env.BAPI_REPO_NAME === "string" && deps.env.BAPI_REPO_NAME.trim().length > 0
4032
+ ? { kind: "configured", value: deps.env.BAPI_REPO_NAME.trim() }
4033
+ : { kind: "deferred" };
4034
+ // Read-only local .git probe — the SAME check the live path uses, but
4035
+ // reported here rather than turned into a confirmation prompt.
4036
+ const projectRootPresent = await hasProjectRootMarker(deps);
4037
+ // Pure, deterministic, no I/O (see its own doc comment) — safe to call here
4038
+ // even though the interactive wrapper `resolveInstallBridgeOnboardingBranchForRun`
4039
+ // (which CAN prompt on a bare TTY run) is not.
4040
+ const onboardingBranch = resolveInstallBridgeOnboardingBranch(options, deps.env);
4041
+ const bootstrapInvite = onboardingBranch.kind === "need-key";
4042
+ const selfServeSignup = onboardingBranch.kind === "need-key" && onboardingBranch.method === "self-serve";
4043
+ // Host/tool selection: known locally whenever it would be deterministic in a
4044
+ // real run too — an explicit `--tools`, or the legacy non-TTY auto-detected
4045
+ // set (`resolveDryRunHostPlatforms` mirrors `resolveSelectedHostPlatforms`
4046
+ // exactly, minus the branch that prompts). Only genuinely deferred when a
4047
+ // real run would need the interactive multi-select, which a dry run may not
4048
+ // invoke.
4049
+ const dryRunPlatforms = await resolveDryRunHostPlatforms(deps, options);
4050
+ let host;
4051
+ if (dryRunPlatforms !== undefined) {
4052
+ const dryRunTargets = hostConfigTargetsForPlatforms(dryRunPlatforms);
4053
+ const launchDecision = resolveInstallBridgeLaunchDecision(dryRunPlatforms, options.agentName);
4054
+ let launch;
4055
+ if (launchDecision.kind === "spawn") {
4056
+ const spec = resolveAgentSpec(launchDecision.agent);
4057
+ if (!spec) {
4058
+ errorLog(`Error: no launch agent is registered for '${launchDecision.agent}'.`);
4059
+ return 1;
4060
+ }
4061
+ launch = {
4062
+ kind: "spawn",
4063
+ agent: launchDecision.agent,
4064
+ spawnCommand: deps.buildShellCommand(spec, INSTALL_BRIDGE_AGENT_PROMPT, deps.cwd, deps.platform),
4065
+ };
4066
+ }
4067
+ else if (launchDecision.kind === "choose-one") {
4068
+ launch = { kind: "choose-one", agents: launchDecision.agents };
4069
+ }
4070
+ else {
4071
+ launch = { kind: "manual", reason: launchDecision.reason };
4072
+ }
4073
+ host = {
4074
+ kind: "explicit",
4075
+ configTargets: dryRunTargets.map((t) => t.relPath),
4076
+ manualEditors: manualEditorNames(await detectManualEditors(deps)),
4077
+ launch,
4078
+ };
4079
+ }
4080
+ else {
4081
+ host = { kind: "deferred" };
4082
+ }
4083
+ // Local, read-only filesystem probes — never a credential, never the network.
4084
+ const dryRunScaffoldAssets = await resolveInitScaffoldAssets({
4085
+ cwd: deps.cwd,
4086
+ env: deps.env,
4087
+ stat: deps.stat,
4088
+ });
4089
+ const dryRunCredentialStorePath = getPrimaryCredentialStorePath({
4090
+ env: deps.env,
4091
+ homedir: deps.homedir,
4092
+ });
4093
+ for (const line of buildDryRunPreview({
4094
+ baseUrl,
4095
+ docsDir: deps.env.BAPI_DOCS_DIR ?? DEFAULT_BAPI_DOCS_DIR,
4096
+ credentialStorePath: dryRunCredentialStorePath,
4097
+ prewarmCommand: buildPrewarmCommandPreview(),
4098
+ commitSafeAssets: dryRunScaffoldAssets.commitSafePaths,
4099
+ projectRootPresent,
4100
+ repo,
4101
+ host,
4102
+ bootstrapInvite,
4103
+ selfServeSignup,
4104
+ })) {
4105
+ log(line);
4106
+ }
4107
+ return 0;
4108
+ }
3785
4109
  // ---- BAPI-669 (U2): working-directory banner + project-root gate ----
3786
4110
  // See the section comment on `hasProjectRootMarker`: read-only, and positioned
3787
4111
  // ahead of ALL credential and configuration work so a decline costs nothing.
@@ -4098,35 +4422,11 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
4098
4422
  env: deps.env,
4099
4423
  stat: deps.stat,
4100
4424
  });
4101
- const plan = {
4102
- repoName,
4103
- baseUrl,
4104
- docsDir,
4105
- launch: planLaunch,
4106
- configTargets: targets.map((t) => t.relPath),
4107
- commitSafeAssets: scaffoldAssets.commitSafePaths,
4108
- manualEditors: manualEditorNames(manualEditors),
4109
- credentialTarget: `bapi:${repoName}`,
4110
- credentialStorePath,
4111
- pingUrl: buildPingUrl(baseUrl, repoName),
4112
- prewarmCommand: buildPrewarmCommandPreview(),
4113
- ...(bootstrapInviteMode
4114
- ? { bootstrapInvite: true, exchangeUrl: buildBootstrapExchangeUrl(baseUrl) }
4115
- : {}),
4116
- ...(selfServeSignupMode ? { selfServeSignup: true } : {}),
4117
- ...(serverRepoResolutionStatus !== undefined
4118
- ? { serverRepoResolutionStatus }
4119
- : {}),
4120
- };
4121
- // ---- --dry-run: preview every step, strictly no side effects ----
4122
- // Positioned BEFORE the CSPRNG, the pending write, the exchange, the scaffold,
4123
- // the config writes, the pre-warm, and the spawn: in bootstrap-invite mode a
4124
- // preview must neither consume the invite NOR leave a pending secret on disk.
4125
- if (options.dryRun) {
4126
- for (const line of buildDryRunPreview(plan))
4127
- log(line);
4128
- return 0;
4129
- }
4425
+ // NOTE (BAPI-818, R2): `--dry-run` no longer branches here. It short-circuits
4426
+ // far earlier — immediately after argument parsing and base-URL validation,
4427
+ // before any of the credential/repo/host resolution above — so by this point
4428
+ // the run is unconditionally the LIVE path. See the early return above for
4429
+ // the inert preview.
4130
4430
  // ---- Resolved-empty selection: a handled no-op (exit 0), never a Claude fallback ----
4131
4431
  // AC-3: with zero configured tools there is nothing to write and no session to open.
4132
4432
  // Return BEFORE any config/credential write, materialization, prewarm, or spawn, so a