@bridge_gpt/mcp-server 0.2.68 → 0.2.70

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 (166) hide show
  1. package/README.md +226 -320
  2. package/build/agent-launchers/claude-executor-adapter.js +17 -4
  3. package/build/agents.generated.js +4 -4
  4. package/build/artifact-diff.js +110 -0
  5. package/build/bin-entrypoint.js +36 -0
  6. package/build/bridge-permissions.js +650 -98
  7. package/build/check-update-cli.js +74 -0
  8. package/build/claude-review-workflow-drift.js +3 -3
  9. package/build/claude-settings.js +84 -2
  10. package/build/cli-help.js +7 -1
  11. package/build/codex-bridge-profile.js +1 -1
  12. package/build/codex-home.js +9 -1
  13. package/build/codex-launcher-pin.js +598 -0
  14. package/build/commands.generated.js +8 -8
  15. package/build/conductor/bridge-api-client.js +598 -19
  16. package/build/conductor/bring-up-facts.js +9 -9
  17. package/build/conductor/doctor.js +466 -18
  18. package/build/conductor/epic-child-continuation.js +54 -3
  19. package/build/conductor/epic-implementer-cli.js +132 -34
  20. package/build/conductor/epic-runtime.js +2 -2
  21. package/build/conductor/epic-state.js +1 -1
  22. package/build/conductor/hook-runtime-qualification.js +1 -1
  23. package/build/conductor/install-doctor.js +4 -2
  24. package/build/conductor/launch-need-probes.js +337 -0
  25. package/build/conductor/local-merge.js +241 -56
  26. package/build/conductor/merge-credential.js +172 -0
  27. package/build/conductor/merge-execution-eligibility.js +1 -1
  28. package/build/conductor/merged-child-check.js +95 -0
  29. package/build/conductor/plan-trim.js +30 -0
  30. package/build/conductor/pr-ci-producer.js +1 -1
  31. package/build/conductor/readiness-cli.js +11 -6
  32. package/build/conductor/readiness-prerequisites.js +41 -0
  33. package/build/conductor/readiness-sections.js +345 -55
  34. package/build/conductor/readiness.js +99 -87
  35. package/build/conductor/recovery-cli.js +606 -21
  36. package/build/conductor/recovery-operations.js +306 -7
  37. package/build/conductor/repair-contract.js +47 -2
  38. package/build/conductor/repository-workflow-remedy.js +8 -6
  39. package/build/conductor/stored-merge-credential.js +76 -0
  40. package/build/conductor/stored-merge-token-probe.js +28 -0
  41. package/build/conductor/temporary-plan.js +50 -0
  42. package/build/conductor/wait-run-cli.js +759 -0
  43. package/build/conductor/worker-database-doctor-inspector.js +49 -0
  44. package/build/conductor-bin.js +2 -2
  45. package/build/conductor-claude-hook-bin.js +2 -2
  46. package/build/conductor-claude-hook-removed-stub-bin.js +2 -5
  47. package/build/conductor-removed-stub-bin.js +2 -5
  48. package/build/connect-github-api.js +33 -14
  49. package/build/credential-store.js +356 -13
  50. package/build/docs.generated.js +27 -14
  51. package/build/doctor.js +203 -38
  52. package/build/drive-epic-help.js +212 -0
  53. package/build/drive-epic.js +1840 -373
  54. package/build/epic-ci-workflow-drift.js +77 -0
  55. package/build/epic-ci-workflow-install.js +183 -0
  56. package/build/epic-ci-workflow.js +157 -0
  57. package/build/epic-contract-context.js +326 -0
  58. package/build/epic-implementer-bin.js +131 -120
  59. package/build/epic-implementer-claude-hook-bin.js +2 -2
  60. package/build/escalation-reachability.js +134 -0
  61. package/build/executor/cleanup-report.js +99 -0
  62. package/build/executor/cli.js +12 -1
  63. package/build/executor/completion-contract.js +68 -11
  64. package/build/executor/completion-supervision.js +99 -0
  65. package/build/executor/delivery-outcome.js +179 -0
  66. package/build/executor/deps.js +3 -0
  67. package/build/executor/env.js +71 -0
  68. package/build/executor/failure-reason-codes.js +126 -0
  69. package/build/executor/heartbeat.js +38 -0
  70. package/build/executor/http-client.js +115 -4
  71. package/build/executor/integration-job.js +298 -5
  72. package/build/executor/job-errors.js +36 -6
  73. package/build/executor/job-runner.js +720 -36
  74. package/build/executor/likely-tests.js +36 -2
  75. package/build/executor/merge-job.js +145 -2
  76. package/build/executor/observation.js +73 -4
  77. package/build/executor/permissions.js +193 -97
  78. package/build/executor/preflight.js +14 -3
  79. package/build/executor/process-containment.js +28 -2
  80. package/build/executor/prompt-spec.js +50 -5
  81. package/build/executor/rebase-finalization.js +38 -3
  82. package/build/executor/reconcile-overlap-job.js +35 -7
  83. package/build/executor/required-work-ledger.js +236 -81
  84. package/build/executor/resume-pre-spawn.js +38 -1
  85. package/build/executor/runner.js +91 -3
  86. package/build/executor/test-clock.js +8 -0
  87. package/build/executor/types.js +37 -2
  88. package/build/executor/worker-cli-launcher.js +259 -0
  89. package/build/executor/worker-command.js +41 -4
  90. package/build/executor/worker-database-target.js +91 -0
  91. package/build/executor/worker-database.js +908 -0
  92. package/build/executor/worker-finalization.js +106 -0
  93. package/build/executor/worker-guard-hook.js +203 -0
  94. package/build/executor/worker-resources.js +47 -0
  95. package/build/executor/worker-verification-guidance.js +15 -2
  96. package/build/executor/worktree.js +303 -15
  97. package/build/executor-launch.js +11 -1
  98. package/build/generated-assets-doctor.js +9 -1
  99. package/build/generated-assets-health.js +13 -4
  100. package/build/generated-assets-legacy.js +39 -0
  101. package/build/generated-assets-paths.js +12 -1
  102. package/build/generated-assets-repair.js +4 -1
  103. package/build/generated-assets-test-support.js +12 -0
  104. package/build/github-remote.js +33 -0
  105. package/build/github-sign-in.js +62 -0
  106. package/build/index.js +1214 -465
  107. package/build/init.js +39 -1
  108. package/build/install-bridge-conductor-gate.js +78 -0
  109. package/build/install-bridge-conductor-setup.js +413 -0
  110. package/build/install-bridge-conductor.js +1226 -267
  111. package/build/install-bridge.js +629 -350
  112. package/build/launcher-config-inspection.js +184 -19
  113. package/build/mcp-host-targets.js +4 -0
  114. package/build/mcp-identity.js +7 -0
  115. package/build/mcp-install-state.js +8 -2
  116. package/build/merge-pull-request.js +6 -0
  117. package/build/merge-workflow-install.js +763 -345
  118. package/build/phase-result-artifacts.js +75 -20
  119. package/build/pipeline-orchestrator.js +247 -4
  120. package/build/pipelines.generated.js +35 -35
  121. package/build/plan-discovery.js +15 -2
  122. package/build/plan-execution-ledger.js +306 -17
  123. package/build/plan-phase-routing.js +48 -13
  124. package/build/plane/cli.js +105 -51
  125. package/build/plane/defaults.js +3 -0
  126. package/build/plane/plane-freshness.js +294 -0
  127. package/build/plane/schema-compatibility.js +137 -0
  128. package/build/plane/shutdown-deps.js +38 -0
  129. package/build/plane/shutdown.js +83 -8
  130. package/build/plane/terminal-run-cleanup.js +303 -0
  131. package/build/printed-remedy-commands.js +26 -4
  132. package/build/readiness-check.js +6 -2
  133. package/build/readiness-prerequisites.generated.js +532 -0
  134. package/build/readme.generated.js +1 -1
  135. package/build/session-upgrade-notice.js +86 -0
  136. package/build/setup-epic.js +400 -30
  137. package/build/shared-resources.generated.js +5 -0
  138. package/build/start-tickets.js +43 -15
  139. package/build/test-command-detection.js +247 -0
  140. package/build/test-command-validation.js +73 -0
  141. package/build/testing-config-client.js +150 -0
  142. package/build/ticket-delivery-artifacts.js +258 -0
  143. package/build/ticket-delivery-enrollment.js +306 -0
  144. package/build/ticket-delivery.js +205 -0
  145. package/build/ticket-review-summary.js +14 -0
  146. package/build/tool-call-advisory.js +162 -0
  147. package/build/tool-surface-gating.js +28 -89
  148. package/build/update-check.js +97 -16
  149. package/build/update-status.js +61 -71
  150. package/build/upgrade-advice.js +15 -11
  151. package/build/upgrade-cli.js +360 -20
  152. package/build/v2-readiness-requirements.js +552 -0
  153. package/build/version.generated.js +3 -3
  154. package/build/worker-guard-hook-bin.js +2 -2
  155. package/build/worktree-core.js +22 -7
  156. package/docs/BRIDGE-DIRECTORY-README.md +48 -0
  157. package/docs/EPIC-IMPLEMENTER-INSTALL.md +12 -867
  158. package/docs/EPIC-IMPLEMENTER.md +104 -63
  159. package/docs/epic-implementer/getting-started.md +131 -0
  160. package/docs/epic-implementer/self-hosting.md +133 -0
  161. package/package.json +3 -3
  162. package/pipelines/idea-to-ticket.json +1 -1
  163. package/pipelines/implement-ticket.json +8 -8
  164. package/pipelines/review-ticket.json +2 -2
  165. package/design-assets/favicon/favicon-32x32.png +0 -0
  166. package/design-assets/just-logo-rough-draft.png +0 -0
@@ -0,0 +1,49 @@
1
+ /**
2
+ * BAPI-1479 / BAPI-1496 — the production read-only worker-database inspector.
3
+ *
4
+ * Moved out of `epic-implementer-cli.ts` so `drive-epic` can reuse it for a
5
+ * local launch's advisory readiness rows without importing the conductor CLI.
6
+ * Nothing about it changed in the move: `conductor doctor` still builds it the
7
+ * same way, through this module.
8
+ */
9
+ /** Inner budget for the doctor's worker-database inspection (doctor adds its own hard ceiling). */
10
+ const WORKER_DATABASE_DOCTOR_BUDGET_MS = 15_000;
11
+ /**
12
+ * BAPI-1479 — the production read-only worker-database inspector for doctor.
13
+ *
14
+ * The operator checkout is the git top level of `cwd` (falling back to `cwd`
15
+ * outside a repository), matching the executor, which resolves the repository
16
+ * `.venv` Alembic from its own working directory. Every module is imported
17
+ * lazily so a doctor run that never reaches this probe loads none of the
18
+ * worker-database lifecycle graph. Inspection only: nothing is created,
19
+ * dropped, swept, or written.
20
+ */
21
+ export function createWorkerDatabaseDoctorInspector(options) {
22
+ return async () => {
23
+ const [{ spawnSync }, os, workerDatabase, containment, registry] = await Promise.all([
24
+ import("node:child_process"),
25
+ import("node:os"),
26
+ import("../executor/worker-database.js"),
27
+ import("../executor/process-containment.js"),
28
+ import("../executor/job-log-registry.js"),
29
+ ]);
30
+ const topLevel = spawnSync("git", ["rev-parse", "--show-toplevel"], {
31
+ cwd: options.cwd,
32
+ encoding: "utf-8",
33
+ timeout: 5_000,
34
+ stdio: ["ignore", "pipe", "ignore"],
35
+ });
36
+ const resolvedTop = topLevel.status === 0 && typeof topLevel.stdout === "string" ? topLevel.stdout.trim() : "";
37
+ const operatorRepositoryRoot = resolvedTop.length > 0 ? resolvedTop : options.cwd;
38
+ const stateDir = registry.resolveExecutorStateDir({
39
+ env: options.env,
40
+ tmpdir: os.tmpdir,
41
+ platform: process.platform,
42
+ });
43
+ return workerDatabase.inspectWorkerDatabaseReadiness({
44
+ operatorRepositoryRoot,
45
+ hostDigest: containment.hostIdentityDigest(os.hostname()),
46
+ budgetMs: WORKER_DATABASE_DOCTOR_BUDGET_MS,
47
+ }, workerDatabase.createDefaultWorkerDatabaseDeps({ stateDir, env: options.env }));
48
+ };
49
+ }
@@ -1,3 +1,3 @@
1
1
  #!/usr/bin/env node
2
- import path from"node:path";import{fileURLToPath}from"node:url";var CONDUCTOR_REMOVED_STUB_MESSAGE=`'conductor' was renamed to 'epic-implementer'. Run 'epic-implementer ...' instead.
3
- `;function runConductorRemovedStubBin(deps={}){let writeStderr=deps.writeStderr??(chunk=>process.stderr.write(chunk)),exit=deps.exit??process.exit.bind(process);writeStderr(CONDUCTOR_REMOVED_STUB_MESSAGE),exit(1)}var invokedDirectly=process.argv[1]!==void 0&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url);invokedDirectly&&runConductorRemovedStubBin();export{CONDUCTOR_REMOVED_STUB_MESSAGE,runConductorRemovedStubBin};
2
+ import{realpathSync}from"node:fs";import path from"node:path";import{fileURLToPath}from"node:url";function isBinEntrypoint(callerModuleUrl,argv1=process.argv[1]){if(argv1===void 0)return!1;let callerPath=fileURLToPath(callerModuleUrl);try{return realpathSync(argv1)===realpathSync(callerPath)}catch{return path.resolve(argv1)===callerPath}}var CONDUCTOR_REMOVED_STUB_MESSAGE=`'conductor' was renamed to 'epic-implementer'. Run 'epic-implementer ...' instead.
3
+ `;function runConductorRemovedStubBin(deps={}){let writeStderr=deps.writeStderr??(chunk=>process.stderr.write(chunk)),exit=deps.exit??process.exit.bind(process);writeStderr(CONDUCTOR_REMOVED_STUB_MESSAGE),exit(1)}isBinEntrypoint(import.meta.url)&&runConductorRemovedStubBin();export{CONDUCTOR_REMOVED_STUB_MESSAGE,runConductorRemovedStubBin};
@@ -1,3 +1,3 @@
1
1
  #!/usr/bin/env node
2
- import path from"node:path";import{fileURLToPath}from"node:url";var CONDUCTOR_CLAUDE_HOOK_REMOVED_STUB_MESSAGE=`'conductor-claude-hook' was renamed to 'epic-implementer-claude-hook'. Run 'epic-implementer-claude-hook ...' instead.
3
- `;function runConductorClaudeHookRemovedStubBin(deps={}){let writeStderr=deps.writeStderr??(chunk=>process.stderr.write(chunk)),exit=deps.exit??process.exit.bind(process);writeStderr(CONDUCTOR_CLAUDE_HOOK_REMOVED_STUB_MESSAGE),exit(1)}var invokedDirectly=process.argv[1]!==void 0&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url);invokedDirectly&&runConductorClaudeHookRemovedStubBin();export{CONDUCTOR_CLAUDE_HOOK_REMOVED_STUB_MESSAGE,runConductorClaudeHookRemovedStubBin};
2
+ import{realpathSync}from"node:fs";import path from"node:path";import{fileURLToPath}from"node:url";function isBinEntrypoint(callerModuleUrl,argv1=process.argv[1]){if(argv1===void 0)return!1;let callerPath=fileURLToPath(callerModuleUrl);try{return realpathSync(argv1)===realpathSync(callerPath)}catch{return path.resolve(argv1)===callerPath}}var CONDUCTOR_CLAUDE_HOOK_REMOVED_STUB_MESSAGE=`'conductor-claude-hook' was renamed to 'epic-implementer-claude-hook'. Run 'epic-implementer-claude-hook ...' instead.
3
+ `;function runConductorClaudeHookRemovedStubBin(deps={}){let writeStderr=deps.writeStderr??(chunk=>process.stderr.write(chunk)),exit=deps.exit??process.exit.bind(process);writeStderr(CONDUCTOR_CLAUDE_HOOK_REMOVED_STUB_MESSAGE),exit(1)}isBinEntrypoint(import.meta.url)&&runConductorClaudeHookRemovedStubBin();export{CONDUCTOR_CLAUDE_HOOK_REMOVED_STUB_MESSAGE,runConductorClaudeHookRemovedStubBin};
@@ -10,8 +10,7 @@
10
10
  * delegated hook work: it writes exactly one fixed line to stderr, nothing to
11
11
  * stdout, and always exits `1`.
12
12
  */
13
- import path from "node:path";
14
- import { fileURLToPath } from "node:url";
13
+ import { isBinEntrypoint } from "./bin-entrypoint.js";
15
14
  /** The fixed migration-pointer line this stub writes to stderr, and nowhere else. */
16
15
  export const CONDUCTOR_CLAUDE_HOOK_REMOVED_STUB_MESSAGE = "'conductor-claude-hook' was renamed to 'epic-implementer-claude-hook'. Run 'epic-implementer-claude-hook ...' instead.\n";
17
16
  /** Run the stub: write the fixed line to stderr and exit `1`. Delegates to nothing. */
@@ -24,8 +23,6 @@ export function runConductorClaudeHookRemovedStubBin(deps = {}) {
24
23
  // Run for real ONLY when this file is the process's own entry — importing it
25
24
  // for its exported runner/deps (e.g. from a unit test) must never spend a real
26
25
  // process.exit as a side effect of import.
27
- const invokedDirectly = process.argv[1] !== undefined &&
28
- path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
29
- if (invokedDirectly) {
26
+ if (isBinEntrypoint(import.meta.url)) {
30
27
  runConductorClaudeHookRemovedStubBin();
31
28
  }
@@ -9,8 +9,7 @@
9
9
  * writes exactly one fixed line to stderr, nothing to stdout, and always
10
10
  * exits `1`.
11
11
  */
12
- import path from "node:path";
13
- import { fileURLToPath } from "node:url";
12
+ import { isBinEntrypoint } from "./bin-entrypoint.js";
14
13
  /** The fixed migration-pointer line this stub writes to stderr, and nowhere else. */
15
14
  export const CONDUCTOR_REMOVED_STUB_MESSAGE = "'conductor' was renamed to 'epic-implementer'. Run 'epic-implementer ...' instead.\n";
16
15
  /** Run the stub: write the fixed line to stderr and exit `1`. Delegates to nothing. */
@@ -23,8 +22,6 @@ export function runConductorRemovedStubBin(deps = {}) {
23
22
  // Run for real ONLY when this file is the process's own entry — importing it
24
23
  // for its exported runner/deps (e.g. from a unit test) must never spend a real
25
24
  // process.exit as a side effect of import.
26
- const invokedDirectly = process.argv[1] !== undefined &&
27
- path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
28
- if (invokedDirectly) {
25
+ if (isBinEntrypoint(import.meta.url)) {
29
26
  runConductorRemovedStubBin();
30
27
  }
@@ -331,15 +331,21 @@ export async function revokeGithubHandoff(deps, repoName, state) {
331
331
  }
332
332
  return { ok: true, value: status };
333
333
  }
334
+ /** A server-supplied ticket prefix is a short alphanumeric key, never free text. */
335
+ const TICKET_PREFIX_SHAPE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/;
336
+ function parseTicketPrefix(body) {
337
+ const raw = asRecord(body)?.ticket_prefix;
338
+ if (typeof raw !== "string")
339
+ return undefined;
340
+ const trimmed = raw.trim();
341
+ return TICKET_PREFIX_SHAPE.test(trimmed) ? trimmed : undefined;
342
+ }
334
343
  /**
335
- * Read whether GitHub is already configured for a project.
336
- *
337
- * Uses the existing authenticated install-manifest/capability surface — read-only, and
338
- * unrelated to any in-flight connection attempt. `unavailable` is a real answer and is
339
- * deliberately distinct from `unconfigured`: callers must be able to skip rather than
340
- * fabricate "not configured" from a probe that simply failed.
344
+ * Read the install manifest once and report GitHub's configured state and the
345
+ * optional ticket prefix. Same request, same deadline, and same GitHub parsing as
346
+ * {@link fetchGithubConfigurationState}, which delegates here.
341
347
  */
342
- export async function fetchGithubConfigurationState(deps, repoName) {
348
+ export async function fetchGithubConfigurationDetails(deps, repoName) {
343
349
  let resp;
344
350
  try {
345
351
  resp = await deps.fetch(`${deps.baseUrl}/jira/config/install-manifest?repo_name=${encodeURIComponent(repoName)}`, {
@@ -348,31 +354,44 @@ export async function fetchGithubConfigurationState(deps, repoName) {
348
354
  });
349
355
  }
350
356
  catch {
351
- return "unavailable";
357
+ return { state: "unavailable" };
352
358
  }
353
359
  if (resp.status !== 200)
354
- return "unavailable";
360
+ return { state: "unavailable" };
355
361
  let body;
356
362
  try {
357
363
  body = await resp.json();
358
364
  }
359
365
  catch {
360
- return "unavailable";
366
+ return { state: "unavailable" };
361
367
  }
368
+ const ticketPrefix = parseTicketPrefix(body);
369
+ const withPrefix = (state) => ticketPrefix === undefined ? { state } : { state, ticketPrefix };
362
370
  const integrations = asRecord(body)?.integrations;
363
371
  if (!Array.isArray(integrations))
364
- return "unavailable";
372
+ return withPrefix("unavailable");
365
373
  for (const raw of integrations) {
366
374
  const rec = asRecord(raw);
367
375
  if (rec?.id !== "github_app")
368
376
  continue;
369
377
  if (typeof rec.is_configured !== "boolean")
370
- return "unavailable";
371
- return rec.is_configured ? "configured" : "unconfigured";
378
+ return withPrefix("unavailable");
379
+ return withPrefix(rec.is_configured ? "configured" : "unconfigured");
372
380
  }
373
381
  // The checklist is provider-scoped: GitHub is legitimately absent for a Bitbucket
374
382
  // project. Absent is not "unconfigured" — there is nothing here to connect.
375
- return "unavailable";
383
+ return withPrefix("unavailable");
384
+ }
385
+ /**
386
+ * Read whether GitHub is already configured for a project.
387
+ *
388
+ * Uses the existing authenticated install-manifest/capability surface — read-only, and
389
+ * unrelated to any in-flight connection attempt. `unavailable` is a real answer and is
390
+ * deliberately distinct from `unconfigured`: callers must be able to skip rather than
391
+ * fabricate "not configured" from a probe that simply failed.
392
+ */
393
+ export async function fetchGithubConfigurationState(deps, repoName) {
394
+ return (await fetchGithubConfigurationDetails(deps, repoName)).state;
376
395
  }
377
396
  /**
378
397
  * Slightly longer than the server's 15-minute code TTL, so an expiry is reported by the
@@ -174,11 +174,19 @@ function collectEnvValues(env, requiredKeys) {
174
174
  * names, and the expected store path — never a secret value. Never creates
175
175
  * files.
176
176
  *
177
- * DURABLE-HOST BOUNDARY (BAPI-1137, R-4). The bundle key is supplied by the
178
- * caller, and the executor's only caller is {@link resolveBapiCredentials} with
179
- * `bapi:<repo>`. Nothing on the executor path asks this function for a GitHub
180
- * bundle, and BAPI-1137 introduces no GitHub credential resolver, registration
181
- * field, CLI argument, or diagnostic value that could begin to.
177
+ * DURABLE-HOST BOUNDARY (BAPI-1137, R-4; amended by BAPI-1461). The bundle key
178
+ * is supplied by the caller, and the executor's only caller is
179
+ * {@link resolveBapiCredentials} with `bapi:<repo>`. Nothing on the executor path
180
+ * asks this function for a GitHub bundle.
181
+ *
182
+ * BAPI-1461 adds ONE GitHub read, and not through this function: the local merge
183
+ * kind may read a stored `github-merge:` entry through
184
+ * {@link readGithubMergeCredential}. Only a run-scoped executor that
185
+ * `drive-epic` starts in the person's own session does that, and only for the
186
+ * local kind. A durable-host service executor runs with its inherited GitHub
187
+ * credentials scrubbed and never reads a `github-merge:` entry, so it still
188
+ * holds no standing GitHub write credential. Bridge credentials keep exactly
189
+ * the resolution path described above.
182
190
  */
183
191
  export async function resolveCredentialBundle(bundleKey, requiredKeys, deps) {
184
192
  const primaryPath = getPrimaryCredentialStorePath(deps);
@@ -264,6 +272,9 @@ export async function resolveCredentialBundle(bundleKey, requiredKeys, deps) {
264
272
  * structural rather than on a scrub of something resolved. (The executor process
265
273
  * additionally deletes any GitHub write variable it INHERITED at startup — see
266
274
  * `executor/env.ts` — which covers the environment, not this seam.)
275
+ *
276
+ * BAPI-1461: the bundle key is the fixed `bapi:<repo>`, so a stored
277
+ * `github-merge:` entry is never selected here, whatever the repository name.
267
278
  */
268
279
  export async function resolveBapiCredentials(repoName, deps) {
269
280
  const result = await resolveCredentialBundle(`bapi:${repoName}`, ["BAPI_API_KEY"], deps);
@@ -343,6 +354,12 @@ const LOCK_TIMEOUT_MS = 5_000;
343
354
  export function getCredentialStoreLockPath(deps) {
344
355
  return `${getPrimaryCredentialStorePath(deps)}.lock`;
345
356
  }
357
+ /**
358
+ * BAPI-1449: the one support address every user-facing recovery message names.
359
+ * Messages read "contact Bridge support at ${BRIDGE_SUPPORT_EMAIL}"; none repeats
360
+ * the literal.
361
+ */
362
+ export const BRIDGE_SUPPORT_EMAIL = "info@bridgegpt.ai";
346
363
  /**
347
364
  * Exclusive creation with bounded fail-closed contention. No writer steals or
348
365
  * replaces a held lock; only the successful owner can release it. Abandonment
@@ -351,7 +368,7 @@ export function getCredentialStoreLockPath(deps) {
351
368
  async function acquireCredentialStoreLock(deps) {
352
369
  const open = deps.open;
353
370
  if (!open || !deps.unlink)
354
- return { ok: false, error: "Exclusive credential storage is unavailable. Resume from the original folder with installation support." };
371
+ return { ok: false, error: `Exclusive credential storage is unavailable. Resume from the original folder and contact Bridge support at ${BRIDGE_SUPPORT_EMAIL}; preserve recovery proof.` };
355
372
  const lockPath = getCredentialStoreLockPath(deps);
356
373
  const isPosix = deps.platform !== "win32";
357
374
  const now = deps.now ?? (() => Date.now());
@@ -399,7 +416,7 @@ async function acquireCredentialStoreLock(deps) {
399
416
  }
400
417
  return {
401
418
  ok: false,
402
- error: `Timed out waiting for exclusive credential storage at ${lockPath}. Another installer may still be running. Resume from the original folder with installation support; preserve recovery proof.`,
419
+ error: `Timed out waiting for exclusive credential storage at ${lockPath}. Another installer may still be running. Resume from the original folder and contact Bridge support at ${BRIDGE_SUPPORT_EMAIL}; preserve recovery proof.`,
403
420
  };
404
421
  }
405
422
  /**
@@ -593,7 +610,7 @@ async function loadStoreForMutation(deps) {
593
610
  }
594
611
  const fallback = await readCredentialStoreJsonIfPresent(getFallbackCredentialStorePath(deps), deps);
595
612
  if (fallback.state === "error")
596
- return { ok: false, kind: fallback.kind, error: "Recovery storage could not be read. Preserve it and contact installation support." };
613
+ return { ok: false, kind: fallback.kind, error: `Recovery storage could not be read. Preserve it and contact Bridge support at ${BRIDGE_SUPPORT_EMAIL}.` };
597
614
  return { ok: true, base: fallback.state === "present" ? { ...fallback.value } : {} };
598
615
  }
599
616
  /** Does a real (non-empty) `BAPI_API_KEY` already exist for `bapi:<repo>`? */
@@ -701,7 +718,7 @@ function storedPendingIsSelfServe(store, repoName) {
701
718
  * because there the CLI genuinely cannot replay it.
702
719
  */
703
720
  function pendingConflictError(_target, _primaryPath, _storedIsSelfServe) {
704
- return "An earlier installation has saved recovery proof. Resume from its original folder with its original invite or signup flow, or contact installation support. Preserve the saved proof.";
721
+ return `An earlier installation has saved recovery proof. Resume from its original folder with its original invite or signup flow, or contact Bridge support at ${BRIDGE_SUPPORT_EMAIL}. Preserve the saved proof.`;
705
722
  }
706
723
  /**
707
724
  * Generate (or REUSE) the bootstrap `key_secret` and durably persist it as a
@@ -1184,7 +1201,7 @@ export async function lookupSelfServeBootstrapPendingCredential(params, deps) {
1184
1201
  };
1185
1202
  }
1186
1203
  if (challenge.kind === "expired") {
1187
- return { ok: true, state: "challenge-expired", path: primaryPath, target };
1204
+ return { ok: true, state: "challenge-expired", path: primaryPath, target, snapshot: { ...(entry ?? {}) } };
1188
1205
  }
1189
1206
  return { ok: true, state: "none", path: primaryPath, target };
1190
1207
  }
@@ -1590,7 +1607,7 @@ export async function bindEpicLaunchCredential(repoName, targetBaseUrl, deps) {
1590
1607
  return { ok: true, target, action: previous === null ? "bound" : "rebound", path: primaryPath };
1591
1608
  }, (error) => ({ ok: false, target, kind: "lock-error", error }));
1592
1609
  }
1593
- const modernRecoveryError = "Installation recovery is inconsistent or unavailable. Preserve proof and resume with the original folder and invite, or contact installation support.";
1610
+ const modernRecoveryError = `Installation recovery is inconsistent or unavailable. Preserve proof and resume with the original folder and invite, or contact Bridge support at ${BRIDGE_SUPPORT_EMAIL}.`;
1594
1611
  function parseModernBootstrapRecord(entry) {
1595
1612
  if (!entry)
1596
1613
  return null;
@@ -1700,14 +1717,167 @@ export async function lookupLegacyBootstrapInvite(fingerprint, deps) {
1700
1717
  }
1701
1718
  }, () => ({ ok: false, error: modernRecoveryError }));
1702
1719
  }
1720
+ function buildModernSignupRecord(scope, label, email, keySecret) {
1721
+ return { version: 1, mode: "server_assigned", baseUrl: scope.baseUrl, folder: scope.folder,
1722
+ label: validateBootstrapDisplayName(label), keySecret, signupEmail: email };
1723
+ }
1703
1724
  export async function prepareModernBootstrapSignup(scope, label, email, generateKeySecret, deps) {
1704
1725
  return mutateModernBootstrapRecord(scope, deps, record => {
1705
1726
  if (record)
1706
1727
  return record;
1707
- return { version: 1, mode: "server_assigned", baseUrl: scope.baseUrl, folder: scope.folder,
1708
- label: validateBootstrapDisplayName(label), keySecret: generateKeySecret(), signupEmail: email };
1728
+ return buildModernSignupRecord(scope, label, email, generateKeySecret());
1709
1729
  });
1710
1730
  }
1731
+ /**
1732
+ * BAPI-1449: archive prefix. Deliberately NOT {@link BOOTSTRAP_PENDING_TARGET_PREFIX}:
1733
+ * the legacy invite and self-serve lookups scan that prefix, so an archived record
1734
+ * under it would be picked up again. Archived entries are never read back by
1735
+ * install; they exist so support can recover them by hand. Their `RECORD` strings
1736
+ * and sibling values are preserved verbatim, and nothing prunes them.
1737
+ */
1738
+ export const BOOTSTRAP_ARCHIVE_TARGET_PREFIX = "bootstrap-archive:";
1739
+ const archiveRefusal = (kind, detail) => ({
1740
+ ok: false, kind,
1741
+ error: `${detail} Your saved signup was not changed; contact Bridge support at ${BRIDGE_SUPPORT_EMAIL} if this persists.`,
1742
+ });
1743
+ // Presence, not truthiness: an empty-string proof field is still proof.
1744
+ function hasProtectedProofField(record) {
1745
+ return ["fingerprint", "replayToken", "canonical"].some(field => Object.prototype.hasOwnProperty.call(record, field));
1746
+ }
1747
+ function buildArchiveKey(scope, suffix) {
1748
+ const digest = scope.folderReference.startsWith("bootstrap-folder:") ? scope.folderReference.slice("bootstrap-folder:".length) : "";
1749
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(digest) || !/^[A-Za-z0-9_-]{1,128}$/.test(suffix))
1750
+ return null;
1751
+ return `${BOOTSTRAP_ARCHIVE_TARGET_PREFIX}${digest}:${suffix}`;
1752
+ }
1753
+ async function commitArchiveReplacement(updated, record, archiveKey, deps) {
1754
+ const path = getPrimaryCredentialStorePath(deps);
1755
+ // Exactly one durable write carries the archive move and the replacement together.
1756
+ const written = await durablyReplaceCredentialStoreJson(path, updated, deps);
1757
+ if (written.ok)
1758
+ return { ok: true, record, archiveKey, path };
1759
+ if (written.kind === "post-rename-uncertain") {
1760
+ // The new bytes may already exist: never claim the old store is untouched, never retry.
1761
+ return { ok: false, kind: written.kind, error: `The credential store replacement could not be confirmed as durable, so the old and new signup state are uncertain. Rerun install from this folder, or contact Bridge support at ${BRIDGE_SUPPORT_EMAIL}. Preserve recovery proof.` };
1762
+ }
1763
+ return archiveRefusal(written.kind, "The signup could not be replaced.");
1764
+ }
1765
+ /**
1766
+ * Replace an eligible, unresolved modern signup with a fresh one in ONE locked,
1767
+ * durable write that moves the original entry, unchanged, under an archive key.
1768
+ * Fails closed on any change since the caller read the record, on any record that
1769
+ * may hold redeemed proof, and on any ownership or collision problem.
1770
+ */
1771
+ export async function archiveAndReplaceModernBootstrapSignup(params, deps) {
1772
+ const { scope, observed } = params;
1773
+ return withCredentialStoreLock(deps, async () => {
1774
+ try {
1775
+ const loaded = await loadStoreForMutation(deps);
1776
+ if (!loaded.ok)
1777
+ return archiveRefusal("invalid-store", "Recovery storage could not be read.");
1778
+ const store = loaded.base;
1779
+ const reference = store[scope.folderReference];
1780
+ if (!reference || reference.FOLDER !== scope.folder || reference.BASE_URL !== scope.baseUrl || reference.LOCATOR !== scope.locator) {
1781
+ return archiveRefusal("invalid-store", "Recovery storage is inconsistent.");
1782
+ }
1783
+ const entry = store[reference.LOCATOR];
1784
+ const current = parseModernBootstrapRecord(entry);
1785
+ if (!entry || !current || current.folder !== scope.folder || current.baseUrl !== scope.baseUrl) {
1786
+ return archiveRefusal("invalid-store", "Recovery storage is inconsistent.");
1787
+ }
1788
+ if (hasProtectedProofField(current) || hasProtectedProofField(observed)) {
1789
+ return archiveRefusal("protected", "This signup holds proof that must be preserved.");
1790
+ }
1791
+ if (JSON.stringify(current) !== JSON.stringify(observed) || current.keySecret !== observed.keySecret ||
1792
+ current.challengeHandle !== observed.challengeHandle) {
1793
+ return archiveRefusal("changed", "The saved signup changed while it was being replaced.");
1794
+ }
1795
+ let archiveKey;
1796
+ let replacement;
1797
+ try {
1798
+ archiveKey = buildArchiveKey(scope, params.generateArchiveSuffix());
1799
+ const keySecret = params.generateKeySecret();
1800
+ if (!/^[A-Za-z0-9_-]{43}$/.test(keySecret))
1801
+ return archiveRefusal("callback-failed", "A replacement secret could not be generated.");
1802
+ replacement = buildModernSignupRecord(scope, current.label, params.email, keySecret);
1803
+ }
1804
+ catch {
1805
+ return archiveRefusal("callback-failed", "A replacement signup could not be prepared.");
1806
+ }
1807
+ if (archiveKey === null)
1808
+ return archiveRefusal("callback-failed", "An archive location could not be prepared.");
1809
+ if (store[archiveKey])
1810
+ return archiveRefusal("archive-collision", "The archive location is already in use.");
1811
+ const updated = { ...store };
1812
+ delete updated[reference.LOCATOR];
1813
+ delete updated[scope.folderReference];
1814
+ updated[archiveKey] = entry;
1815
+ updated[scope.locator] = { RECORD: JSON.stringify(replacement) };
1816
+ updated[scope.folderReference] = { LOCATOR: scope.locator, FOLDER: scope.folder, BASE_URL: scope.baseUrl };
1817
+ return await commitArchiveReplacement(updated, replacement, archiveKey, deps);
1818
+ }
1819
+ catch {
1820
+ return archiveRefusal("invalid-store", "Recovery storage is inconsistent.");
1821
+ }
1822
+ }, () => archiveRefusal("lock-error", "Exclusive credential storage could not be acquired."));
1823
+ }
1824
+ /**
1825
+ * Archive an expired basename-keyed legacy signup and install the modern signup
1826
+ * that replaces it, in ONE locked, durable write. Refuses unless the whole legacy
1827
+ * entry is unchanged since the caller read it AND still classifies as expired, and
1828
+ * refuses any entry holding a key secret, fingerprint, replay token, or modern
1829
+ * record. The modern destination and folder reference must be unoccupied.
1830
+ */
1831
+ export async function archiveExpiredLegacySignupAndInstallModern(params, deps) {
1832
+ const { scope } = params;
1833
+ const target = getBootstrapPendingTarget(params.repoName);
1834
+ return withCredentialStoreLock(deps, async () => {
1835
+ try {
1836
+ const loaded = await loadStoreForMutation(deps);
1837
+ if (!loaded.ok)
1838
+ return archiveRefusal("invalid-store", "Recovery storage could not be read.");
1839
+ const store = loaded.base;
1840
+ const entry = store[target];
1841
+ if (!entry)
1842
+ return archiveRefusal("changed", "The saved signup changed while it was being replaced.");
1843
+ if ([BOOTSTRAP_PENDING_SECRET_FIELD, BOOTSTRAP_PENDING_FINGERPRINT_FIELD, BOOTSTRAP_PENDING_REPLAY_TOKEN_FIELD, "RECORD"]
1844
+ .some(field => Object.prototype.hasOwnProperty.call(entry, field))) {
1845
+ return archiveRefusal("protected", "This signup holds proof that must be preserved.");
1846
+ }
1847
+ if (JSON.stringify(entry) !== JSON.stringify(params.observed) || classifySignupChallengeRecord(entry, params.now).kind !== "expired") {
1848
+ return archiveRefusal("changed", "The saved signup changed while it was being replaced.");
1849
+ }
1850
+ if (store[scope.locator] || store[scope.folderReference]) {
1851
+ return archiveRefusal("invalid-store", "Recovery storage is inconsistent.");
1852
+ }
1853
+ let archiveKey;
1854
+ let replacement;
1855
+ try {
1856
+ archiveKey = buildArchiveKey(scope, params.generateArchiveSuffix());
1857
+ const keySecret = params.generateKeySecret();
1858
+ if (!/^[A-Za-z0-9_-]{43}$/.test(keySecret))
1859
+ return archiveRefusal("callback-failed", "A replacement secret could not be generated.");
1860
+ replacement = buildModernSignupRecord(scope, params.label, params.email, keySecret);
1861
+ }
1862
+ catch {
1863
+ return archiveRefusal("callback-failed", "A replacement signup could not be prepared.");
1864
+ }
1865
+ if (archiveKey === null)
1866
+ return archiveRefusal("callback-failed", "An archive location could not be prepared.");
1867
+ if (store[archiveKey])
1868
+ return archiveRefusal("archive-collision", "The archive location is already in use.");
1869
+ const updated = { ...store };
1870
+ delete updated[target];
1871
+ updated[archiveKey] = entry;
1872
+ updated[scope.locator] = { RECORD: JSON.stringify(replacement) };
1873
+ updated[scope.folderReference] = { LOCATOR: scope.locator, FOLDER: scope.folder, BASE_URL: scope.baseUrl };
1874
+ return await commitArchiveReplacement(updated, replacement, archiveKey, deps);
1875
+ }
1876
+ catch {
1877
+ return archiveRefusal("invalid-store", "Recovery storage is inconsistent.");
1878
+ }
1879
+ }, () => archiveRefusal("lock-error", "Exclusive credential storage could not be acquired."));
1880
+ }
1711
1881
  export async function transitionModernBootstrapSignup(signupScope, inviteScope, fingerprint, replayToken, challengeHandle, deps) {
1712
1882
  return mutateModernBootstrapRecord(inviteScope, deps, record => {
1713
1883
  if (!record || record.challengeHandle !== challengeHandle)
@@ -1763,3 +1933,176 @@ export async function promoteModernBootstrapCredential(scope, proof, deps, allow
1763
1933
  }
1764
1934
  }, () => ({ ok: false, error: modernRecoveryError }));
1765
1935
  }
1936
+ // ---------------------------------------------------------------------------
1937
+ // Repository-scoped GitHub merge token (BAPI-1461)
1938
+ // ---------------------------------------------------------------------------
1939
+ /**
1940
+ * Logical target prefix for a stored GitHub merge token. The full key is
1941
+ * `github-merge:github.com/<owner>/<repo>`.
1942
+ *
1943
+ * Kept apart from `bapi:<repo>` on purpose. {@link resolveBapiCredentials} reads
1944
+ * the fixed `bapi:<repo>` key and nothing else, so it can never return this
1945
+ * entry, and the merge resolver reads only this entry, so it can never return a
1946
+ * Bridge key. A GitHub write credential and a Bridge credential never share an
1947
+ * entry, a reader, or a fallback.
1948
+ *
1949
+ * Only the local merge kind reads this entry, and only in a run-scoped executor
1950
+ * that `drive-epic` starts in the person's own session (see
1951
+ * `conductor/merge-credential.ts`). A durable-host service executor never reads
1952
+ * it, so the new entry gives no standing GitHub write credential to a service.
1953
+ */
1954
+ export const GITHUB_MERGE_TARGET_PREFIX = "github-merge:";
1955
+ /** The one secret name inside a `github-merge:` entry. */
1956
+ export const GITHUB_MERGE_TOKEN_FIELD = "GITHUB_MERGE_TOKEN";
1957
+ /** The only provider host a stored merge token is scoped to. */
1958
+ export const GITHUB_MERGE_HOST = "github.com";
1959
+ const GITHUB_REPOSITORY_SEGMENT_RE = /^[A-Za-z0-9._-]{1,100}$/;
1960
+ /** Parse a bare `owner/repo` string into an identity, or null when malformed. */
1961
+ function parseOwnerRepo(raw) {
1962
+ if (typeof raw !== "string")
1963
+ return null;
1964
+ const parts = raw.trim().split("/");
1965
+ if (parts.length !== 2)
1966
+ return null;
1967
+ const [owner, repo] = parts;
1968
+ if (!GITHUB_REPOSITORY_SEGMENT_RE.test(owner) || !GITHUB_REPOSITORY_SEGMENT_RE.test(repo))
1969
+ return null;
1970
+ if (owner === "." || owner === ".." || repo === "." || repo === "..")
1971
+ return null;
1972
+ return { host: GITHUB_MERGE_HOST, owner, repo };
1973
+ }
1974
+ /**
1975
+ * Decide which repository a stored merge token belongs to.
1976
+ *
1977
+ * The server's `provider_repository` is preferred; the local github.com remote
1978
+ * (from `parseGithubRemote()`, already `owner/repo`) is the fallback. When both
1979
+ * are present and disagree the answer is `conflict`, so a caller never reads or
1980
+ * writes another repository's entry. GitHub names are case-insensitive, so the
1981
+ * comparison ignores case; the key keeps the server's spelling.
1982
+ */
1983
+ export function resolveGithubMergeRepositoryIdentity(input) {
1984
+ const server = parseOwnerRepo(input.providerRepository);
1985
+ const local = parseOwnerRepo(input.localRemote);
1986
+ if (server && local) {
1987
+ const same = server.owner.toLowerCase() === local.owner.toLowerCase() &&
1988
+ server.repo.toLowerCase() === local.repo.toLowerCase();
1989
+ return same ? { ok: true, identity: server } : { ok: false, reason: "conflict" };
1990
+ }
1991
+ if (server)
1992
+ return { ok: true, identity: server };
1993
+ if (local)
1994
+ return { ok: true, identity: local };
1995
+ return { ok: false, reason: "unresolved" };
1996
+ }
1997
+ /**
1998
+ * The store key for one repository: `github-merge:github.com/<owner>/<repo>`.
1999
+ * GitHub names are case-insensitive, and writers and readers take the name
2000
+ * from different sources (the server's spelling or the local remote), so the
2001
+ * key is always lowercase.
2002
+ */
2003
+ export function getGithubMergeTarget(identity) {
2004
+ return `${GITHUB_MERGE_TARGET_PREFIX}${identity.host}/${identity.owner}/${identity.repo}`.toLowerCase();
2005
+ }
2006
+ /**
2007
+ * Every key in the store that names this repository, matched case-insensitively,
2008
+ * with the canonical lowercase key first when present. A key spelled with
2009
+ * capitals can only come from an earlier build that kept the source's spelling.
2010
+ */
2011
+ function findGithubMergeKeys(store, identity) {
2012
+ const target = getGithubMergeTarget(identity);
2013
+ const keys = Object.keys(store).filter((key) => key.toLowerCase() === target);
2014
+ return keys.sort((a, b) => Number(b === target) - Number(a === target));
2015
+ }
2016
+ /**
2017
+ * Read the stored merge token for one repository. Strictly read-only: it never
2018
+ * creates, migrates, or locks anything. The primary store wins; the legacy
2019
+ * fallback is read only when the primary does not exist, like every other
2020
+ * reader in this module.
2021
+ */
2022
+ export async function readGithubMergeCredential(identity, deps) {
2023
+ try {
2024
+ let state = await readCredentialStoreJsonIfPresent(getPrimaryCredentialStorePath(deps), deps);
2025
+ if (state.state === "missing") {
2026
+ state = await readCredentialStoreJsonIfPresent(getFallbackCredentialStorePath(deps), deps);
2027
+ }
2028
+ if (state.state === "error")
2029
+ return { status: "unavailable" };
2030
+ if (state.state === "missing")
2031
+ return { status: "absent" };
2032
+ for (const key of findGithubMergeKeys(state.value, identity)) {
2033
+ const raw = state.value[key]?.[GITHUB_MERGE_TOKEN_FIELD];
2034
+ const token = typeof raw === "string" ? raw.trim() : "";
2035
+ if (token.length > 0)
2036
+ return { status: "present", token };
2037
+ }
2038
+ return { status: "absent" };
2039
+ }
2040
+ catch {
2041
+ return { status: "unavailable" };
2042
+ }
2043
+ }
2044
+ /**
2045
+ * Store (or replace) the merge token for one repository, under the shared lock
2046
+ * and through the durable replacement. Every other entry is preserved, and the
2047
+ * old token stays in place until the new file is durably published. Results
2048
+ * name the logical target and a fixed failure kind only — never the token,
2049
+ * the path, or a raw I/O message.
2050
+ */
2051
+ export async function upsertGithubMergeCredential(identity, token, deps) {
2052
+ const target = getGithubMergeTarget(identity);
2053
+ const value = (token ?? "").trim();
2054
+ if (value.length === 0)
2055
+ return { ok: false, target, kind: "invalid-input" };
2056
+ const primaryPath = getPrimaryCredentialStorePath(deps);
2057
+ return withCredentialStoreLock(deps, async () => {
2058
+ const loaded = await loadStoreForMutation(deps);
2059
+ if (!loaded.ok)
2060
+ return { ok: false, target, kind: loaded.kind };
2061
+ const keys = findGithubMergeKeys(loaded.base, identity);
2062
+ const previousKey = keys[0];
2063
+ const previous = previousKey === undefined ? undefined : loaded.base[previousKey]?.[GITHUB_MERGE_TOKEN_FIELD];
2064
+ if (previous === value && keys.length === 1 && previousKey === target) {
2065
+ return { ok: true, target, action: "unchanged" };
2066
+ }
2067
+ // Fold any differently-cased copy into the one lowercase key.
2068
+ const next = { ...loaded.base };
2069
+ for (const key of keys)
2070
+ delete next[key];
2071
+ next[target] = {
2072
+ ...(previousKey === undefined ? {} : loaded.base[previousKey]),
2073
+ [GITHUB_MERGE_TOKEN_FIELD]: value,
2074
+ };
2075
+ const written = await durablyReplaceCredentialStoreJson(primaryPath, next, deps);
2076
+ if (!written.ok)
2077
+ return { ok: false, target, kind: written.kind };
2078
+ return {
2079
+ ok: true,
2080
+ target,
2081
+ action: typeof previous === "string" && previous.trim().length > 0 ? "replaced" : "created",
2082
+ };
2083
+ }, () => ({ ok: false, target, kind: "lock-error" }));
2084
+ }
2085
+ /**
2086
+ * Remove the stored merge token for one repository, under the shared lock.
2087
+ * Removing a missing entry is a successful `absent`. This deletes a local copy
2088
+ * only: it does not revoke the token at GitHub.
2089
+ */
2090
+ export async function removeGithubMergeCredential(identity, deps) {
2091
+ const target = getGithubMergeTarget(identity);
2092
+ const primaryPath = getPrimaryCredentialStorePath(deps);
2093
+ return withCredentialStoreLock(deps, async () => {
2094
+ const loaded = await loadStoreForMutation(deps);
2095
+ if (!loaded.ok)
2096
+ return { ok: false, target, kind: loaded.kind };
2097
+ const keys = findGithubMergeKeys(loaded.base, identity);
2098
+ if (keys.length === 0)
2099
+ return { ok: true, target, action: "absent" };
2100
+ const next = { ...loaded.base };
2101
+ for (const key of keys)
2102
+ delete next[key];
2103
+ const written = await durablyReplaceCredentialStoreJson(primaryPath, next, deps);
2104
+ if (!written.ok)
2105
+ return { ok: false, target, kind: written.kind };
2106
+ return { ok: true, target, action: "removed" };
2107
+ }, () => ({ ok: false, target, kind: "lock-error" }));
2108
+ }