@bridge_gpt/mcp-server 0.2.69 → 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.
- package/README.md +140 -292
- package/build/agent-launchers/claude-executor-adapter.js +17 -4
- package/build/agents.generated.js +4 -4
- package/build/artifact-diff.js +110 -0
- package/build/bin-entrypoint.js +36 -0
- package/build/bridge-permissions.js +23 -4
- package/build/claude-review-workflow-drift.js +3 -3
- package/build/codex-bridge-profile.js +1 -1
- package/build/commands.generated.js +6 -6
- package/build/conductor/bridge-api-client.js +598 -19
- package/build/conductor/bring-up-facts.js +9 -9
- package/build/conductor/doctor.js +466 -18
- package/build/conductor/epic-child-continuation.js +54 -3
- package/build/conductor/epic-implementer-cli.js +132 -34
- package/build/conductor/epic-runtime.js +2 -2
- package/build/conductor/epic-state.js +1 -1
- package/build/conductor/hook-runtime-qualification.js +1 -1
- package/build/conductor/install-doctor.js +4 -2
- package/build/conductor/launch-need-probes.js +337 -0
- package/build/conductor/local-merge.js +241 -56
- package/build/conductor/merge-credential.js +172 -0
- package/build/conductor/merge-execution-eligibility.js +1 -1
- package/build/conductor/merged-child-check.js +95 -0
- package/build/conductor/plan-trim.js +30 -0
- package/build/conductor/pr-ci-producer.js +1 -1
- package/build/conductor/readiness-cli.js +11 -6
- package/build/conductor/readiness-prerequisites.js +41 -0
- package/build/conductor/readiness-sections.js +345 -55
- package/build/conductor/readiness.js +99 -87
- package/build/conductor/recovery-cli.js +606 -21
- package/build/conductor/recovery-operations.js +306 -7
- package/build/conductor/repair-contract.js +47 -2
- package/build/conductor/repository-workflow-remedy.js +8 -6
- package/build/conductor/stored-merge-credential.js +76 -0
- package/build/conductor/stored-merge-token-probe.js +28 -0
- package/build/conductor/temporary-plan.js +50 -0
- package/build/conductor/wait-run-cli.js +759 -0
- package/build/conductor/worker-database-doctor-inspector.js +49 -0
- package/build/conductor-bin.js +2 -2
- package/build/conductor-claude-hook-bin.js +2 -2
- package/build/conductor-claude-hook-removed-stub-bin.js +2 -5
- package/build/conductor-removed-stub-bin.js +2 -5
- package/build/credential-store.js +356 -13
- package/build/docs.generated.js +26 -13
- package/build/doctor.js +94 -32
- package/build/drive-epic-help.js +212 -0
- package/build/drive-epic.js +1840 -373
- package/build/epic-ci-workflow-drift.js +77 -0
- package/build/epic-ci-workflow-install.js +183 -0
- package/build/epic-ci-workflow.js +157 -0
- package/build/epic-contract-context.js +326 -0
- package/build/epic-implementer-bin.js +57 -41
- package/build/epic-implementer-claude-hook-bin.js +2 -2
- package/build/escalation-reachability.js +134 -0
- package/build/executor/cleanup-report.js +99 -0
- package/build/executor/cli.js +12 -1
- package/build/executor/completion-contract.js +66 -11
- package/build/executor/completion-supervision.js +99 -0
- package/build/executor/delivery-outcome.js +179 -0
- package/build/executor/deps.js +3 -0
- package/build/executor/env.js +71 -0
- package/build/executor/failure-reason-codes.js +126 -0
- package/build/executor/heartbeat.js +38 -0
- package/build/executor/http-client.js +72 -0
- package/build/executor/integration-job.js +298 -5
- package/build/executor/job-errors.js +36 -6
- package/build/executor/job-runner.js +720 -36
- package/build/executor/likely-tests.js +36 -2
- package/build/executor/merge-job.js +145 -2
- package/build/executor/observation.js +73 -4
- package/build/executor/permissions.js +193 -97
- package/build/executor/preflight.js +14 -3
- package/build/executor/process-containment.js +28 -2
- package/build/executor/prompt-spec.js +50 -5
- package/build/executor/rebase-finalization.js +38 -3
- package/build/executor/reconcile-overlap-job.js +35 -7
- package/build/executor/required-work-ledger.js +236 -81
- package/build/executor/resume-pre-spawn.js +38 -1
- package/build/executor/runner.js +91 -3
- package/build/executor/test-clock.js +8 -0
- package/build/executor/types.js +37 -2
- package/build/executor/worker-cli-launcher.js +259 -0
- package/build/executor/worker-command.js +41 -4
- package/build/executor/worker-database-target.js +91 -0
- package/build/executor/worker-database.js +908 -0
- package/build/executor/worker-finalization.js +106 -0
- package/build/executor/worker-guard-hook.js +203 -0
- package/build/executor/worker-resources.js +47 -0
- package/build/executor/worker-verification-guidance.js +15 -2
- package/build/executor/worktree.js +303 -15
- package/build/executor-launch.js +11 -1
- package/build/generated-assets-doctor.js +9 -1
- package/build/generated-assets-health.js +13 -4
- package/build/generated-assets-legacy.js +39 -0
- package/build/generated-assets-paths.js +12 -1
- package/build/generated-assets-repair.js +4 -1
- package/build/generated-assets-test-support.js +12 -0
- package/build/github-remote.js +33 -0
- package/build/github-sign-in.js +62 -0
- package/build/index.js +1067 -536
- package/build/init.js +39 -1
- package/build/install-bridge-conductor-gate.js +78 -0
- package/build/install-bridge-conductor-setup.js +413 -0
- package/build/install-bridge-conductor.js +1226 -267
- package/build/install-bridge.js +376 -60
- package/build/mcp-install-state.js +8 -2
- package/build/merge-pull-request.js +6 -0
- package/build/merge-workflow-install.js +763 -345
- package/build/phase-result-artifacts.js +75 -20
- package/build/pipeline-orchestrator.js +247 -4
- package/build/pipelines.generated.js +35 -35
- package/build/plan-discovery.js +15 -2
- package/build/plan-execution-ledger.js +306 -17
- package/build/plan-phase-routing.js +48 -13
- package/build/plane/cli.js +105 -51
- package/build/plane/defaults.js +3 -0
- package/build/plane/plane-freshness.js +294 -0
- package/build/plane/schema-compatibility.js +137 -0
- package/build/plane/shutdown-deps.js +38 -0
- package/build/plane/shutdown.js +83 -8
- package/build/plane/terminal-run-cleanup.js +303 -0
- package/build/printed-remedy-commands.js +26 -4
- package/build/readiness-check.js +6 -2
- package/build/readiness-prerequisites.generated.js +532 -0
- package/build/readme.generated.js +1 -1
- package/build/setup-epic.js +400 -30
- package/build/shared-resources.generated.js +5 -0
- package/build/start-tickets.js +28 -15
- package/build/test-command-detection.js +247 -0
- package/build/test-command-validation.js +73 -0
- package/build/testing-config-client.js +150 -0
- package/build/ticket-delivery-artifacts.js +258 -0
- package/build/ticket-delivery-enrollment.js +306 -0
- package/build/ticket-delivery.js +205 -0
- package/build/ticket-review-summary.js +14 -0
- package/build/upgrade-cli.js +13 -0
- package/build/v2-readiness-requirements.js +552 -0
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +2 -2
- package/build/worktree-core.js +22 -7
- package/docs/BRIDGE-DIRECTORY-README.md +48 -0
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +12 -867
- package/docs/EPIC-IMPLEMENTER.md +104 -63
- package/docs/epic-implementer/getting-started.md +131 -0
- package/docs/epic-implementer/self-hosting.md +133 -0
- package/package.json +3 -3
- package/pipelines/idea-to-ticket.json +1 -1
- package/pipelines/implement-ticket.json +8 -8
- package/pipelines/review-ticket.json +2 -2
- package/design-assets/favicon/favicon-32x32.png +0 -0
- 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
|
+
}
|
package/build/conductor-bin.js
CHANGED
|
@@ -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)}
|
|
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)}
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
27
|
-
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
28
|
-
if (invokedDirectly) {
|
|
25
|
+
if (isBinEntrypoint(import.meta.url)) {
|
|
29
26
|
runConductorRemovedStubBin();
|
|
30
27
|
}
|
|
@@ -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
|
|
178
|
-
* caller, and the executor's only caller is
|
|
179
|
-
* `bapi:<repo>`. Nothing on the executor path
|
|
180
|
-
*
|
|
181
|
-
*
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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 =
|
|
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
|
|
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
|
+
}
|