@miraland-labs/conduit-bridge 0.11.4 → 0.11.9
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/dist/cli.js +2 -2
- package/dist/ensure-pull-request.js +115 -5
- package/dist/ensure-test-evidence.js +125 -0
- package/dist/execution.js +10 -0
- package/dist/ops.js +81 -3
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -334,7 +334,7 @@ async function initOps() {
|
|
|
334
334
|
console.log(`Config file (separate): ${envPath}`);
|
|
335
335
|
console.log("");
|
|
336
336
|
console.log("Works the same on macOS, Linux, and Windows via:");
|
|
337
|
-
console.log(` ${bridgeUsage("ops", "<connect|install|online|offline|status|disconnect|uninstall>")}`);
|
|
337
|
+
console.log(` ${bridgeUsage("ops", "<connect|install|switch|online|offline|status|disconnect|uninstall>")}`);
|
|
338
338
|
console.log("");
|
|
339
339
|
console.log("1. Edit env once:");
|
|
340
340
|
console.log(` Create folder: ${configDir}`);
|
|
@@ -358,7 +358,7 @@ async function initOps() {
|
|
|
358
358
|
async function opsCommand() {
|
|
359
359
|
const verb = process.argv[3];
|
|
360
360
|
if (!verb || !OPS_VERBS.includes(verb)) {
|
|
361
|
-
throw new Error(`Usage: ${bridgeUsage("ops", "<connect|install|online|offline|status|doctor|disconnect|uninstall>", "[driver…]")}`);
|
|
361
|
+
throw new Error(`Usage: ${bridgeUsage("ops", "<connect|install|switch|online|offline|status|doctor|disconnect|uninstall>", "[driver…]")}`);
|
|
362
362
|
}
|
|
363
363
|
await runOps(verb, process.argv.slice(4));
|
|
364
364
|
}
|
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { execFile } from "node:child_process";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
|
+
import { normalizeRepositoryUrl } from "./brief.js";
|
|
8
9
|
const execFileAsync = promisify(execFile);
|
|
10
|
+
/** Mirror execution.ts FORGE_TRANSPORT_PATTERN — keep local to avoid import cycles. */
|
|
11
|
+
const FORGE_TRANSPORT_PATTERN = /unable to access '?https?:\/\/|error in the http2 framing layer|could not resolve host|connection (?:reset|timed out|refused)|\bcurl\b.*\b(?:52|55|56|92)\b|remote end hung up unexpectedly|\brpc failed\b|tls handshake|network is unreachable|operation timed out/i;
|
|
9
12
|
export function needsPullRequest(report, spec, grants) {
|
|
10
13
|
return Boolean(grants.includes("pr_create")
|
|
11
14
|
&& (spec.change_scope?.length ?? 0) > 0
|
|
@@ -28,6 +31,57 @@ export function commitsMatch(reported, actual) {
|
|
|
28
31
|
return false;
|
|
29
32
|
return left === right || left.startsWith(right) || right.startsWith(left);
|
|
30
33
|
}
|
|
34
|
+
/** Forge owner/repo identity from a PR / merge-request URL (host/path form used as fingerprint). */
|
|
35
|
+
export function fingerprintFromChangeRequestUrl(url) {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = new URL(url);
|
|
38
|
+
const github = parsed.pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/\d+\/?$/i);
|
|
39
|
+
if (github)
|
|
40
|
+
return normalizeRepositoryUrl(`${parsed.host}/${github[1]}/${github[2]}`);
|
|
41
|
+
const gitlab = parsed.pathname.match(/^\/(.+)\/-\/merge_requests\/\d+\/?$/i);
|
|
42
|
+
if (gitlab)
|
|
43
|
+
return normalizeRepositoryUrl(`${parsed.host}/${gitlab[1]}`);
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function pullRequestMatchesRepositoryFingerprint(url, repositoryFingerprint) {
|
|
51
|
+
if (!repositoryFingerprint)
|
|
52
|
+
return false;
|
|
53
|
+
const fromUrl = fingerprintFromChangeRequestUrl(url);
|
|
54
|
+
if (!fromUrl)
|
|
55
|
+
return false;
|
|
56
|
+
return fromUrl === normalizeRepositoryUrl(repositoryFingerprint);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Agents often put the PR URL only on evidence.uri and omit pull_request_url.
|
|
60
|
+
* Adopt it so finalize does not re-push and die on a forge blip after the PR already exists —
|
|
61
|
+
* but only when the URI is this work package's repository (Invariant 11).
|
|
62
|
+
*/
|
|
63
|
+
export function adoptPullRequestUrlFromEvidence(report, repositoryFingerprint) {
|
|
64
|
+
if (report.pull_request_url && isAbsoluteHttpsUrl(report.pull_request_url)) {
|
|
65
|
+
if (repositoryFingerprint
|
|
66
|
+
&& !pullRequestMatchesRepositoryFingerprint(report.pull_request_url, repositoryFingerprint)) {
|
|
67
|
+
throw new Error("pull_request_url does not match the work package repository");
|
|
68
|
+
}
|
|
69
|
+
return report;
|
|
70
|
+
}
|
|
71
|
+
if (!repositoryFingerprint)
|
|
72
|
+
return report;
|
|
73
|
+
for (const item of report.evidence) {
|
|
74
|
+
const uri = item.uri?.trim();
|
|
75
|
+
if (!uri || !isAbsoluteHttpsUrl(uri))
|
|
76
|
+
continue;
|
|
77
|
+
if (!/\/(?:pull|merge_requests)\/\d+/i.test(uri))
|
|
78
|
+
continue;
|
|
79
|
+
if (!pullRequestMatchesRepositoryFingerprint(uri, repositoryFingerprint))
|
|
80
|
+
continue;
|
|
81
|
+
return { ...report, pull_request_url: uri };
|
|
82
|
+
}
|
|
83
|
+
return report;
|
|
84
|
+
}
|
|
31
85
|
async function workspaceHeadCommit(workspace) {
|
|
32
86
|
const { stdout } = await execFileAsync("git", ["-C", workspace, "rev-parse", "HEAD"], {
|
|
33
87
|
timeout: 30_000,
|
|
@@ -38,13 +92,72 @@ async function workspaceHeadCommit(workspace) {
|
|
|
38
92
|
throw new Error("Could not determine workspace HEAD commit");
|
|
39
93
|
return head;
|
|
40
94
|
}
|
|
95
|
+
function execErrorMessage(error) {
|
|
96
|
+
if (!(error instanceof Error))
|
|
97
|
+
return String(error);
|
|
98
|
+
const err = error;
|
|
99
|
+
const parts = [err.message, err.stderr, err.stdout].filter((part) => Boolean(part && String(part).trim()));
|
|
100
|
+
return parts.join("\n");
|
|
101
|
+
}
|
|
102
|
+
async function sleep(ms) {
|
|
103
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
104
|
+
}
|
|
105
|
+
/** Push with short forge-transport retries; succeed if remote already has HEAD. */
|
|
106
|
+
export async function pushOriginHead(workspace, options = {}) {
|
|
107
|
+
const attempts = options.attempts ?? 3;
|
|
108
|
+
const execGit = options.execGit ?? (async (args) => {
|
|
109
|
+
const { stdout, stderr } = await execFileAsync("git", ["-C", workspace, ...args], {
|
|
110
|
+
timeout: 120_000,
|
|
111
|
+
maxBuffer: 2_000_000,
|
|
112
|
+
});
|
|
113
|
+
return { stdout: String(stdout), stderr: String(stderr) };
|
|
114
|
+
});
|
|
115
|
+
let lastMessage = "git push failed";
|
|
116
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
117
|
+
try {
|
|
118
|
+
await execGit(["push", "-u", "origin", "HEAD"]);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
lastMessage = execErrorMessage(error);
|
|
123
|
+
// Remote may already have the branch from the agent's own push.
|
|
124
|
+
try {
|
|
125
|
+
const head = (await execGit(["rev-parse", "HEAD"])).stdout.trim().toLowerCase();
|
|
126
|
+
const remote = (await execGit(["ls-remote", "origin", "HEAD"])).stdout.trim().split(/\s+/)[0]?.toLowerCase() ?? "";
|
|
127
|
+
// Also check the current branch tip on origin.
|
|
128
|
+
const branch = (await execGit(["branch", "--show-current"])).stdout.trim();
|
|
129
|
+
const remoteBranch = branch
|
|
130
|
+
? (await execGit(["ls-remote", "origin", `refs/heads/${branch}`])).stdout.trim().split(/\s+/)[0]?.toLowerCase() ?? ""
|
|
131
|
+
: "";
|
|
132
|
+
if (head && (commitsMatch(head, remote) || commitsMatch(head, remoteBranch))) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// fall through to retry / throw
|
|
138
|
+
}
|
|
139
|
+
if (attempt < attempts && FORGE_TRANSPORT_PATTERN.test(lastMessage)) {
|
|
140
|
+
await sleep(750 * attempt);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (attempt < attempts && /Command failed:\s*git\b.*\bpush\b/i.test(lastMessage) && !lastMessage.includes("rejected")) {
|
|
144
|
+
// Empty stderr push failures are often transient HTTP/2; retry once more.
|
|
145
|
+
await sleep(750 * attempt);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
throw new Error(lastMessage);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
throw new Error(lastMessage);
|
|
152
|
+
}
|
|
41
153
|
/**
|
|
42
154
|
* Bind reported head_commit to the checkout HEAD, then open a PR when still needed.
|
|
43
155
|
* Agent-supplied PR URLs skip creation but still require HEAD identity.
|
|
44
156
|
*/
|
|
45
157
|
export async function ensureDeliveryPullRequest(input) {
|
|
46
158
|
const readHead = input.readHeadCommit ?? workspaceHeadCommit;
|
|
47
|
-
|
|
159
|
+
const push = input.pushOriginHead ?? pushOriginHead;
|
|
160
|
+
let report = adoptPullRequestUrlFromEvidence(input.report, input.repositoryFingerprint);
|
|
48
161
|
if (report.head_commit && (input.spec.change_scope?.length ?? 0) > 0) {
|
|
49
162
|
const head = await readHead(input.workspace);
|
|
50
163
|
if (!commitsMatch(report.head_commit, head)) {
|
|
@@ -54,10 +167,7 @@ export async function ensureDeliveryPullRequest(input) {
|
|
|
54
167
|
}
|
|
55
168
|
if (!needsPullRequest(report, input.spec, input.grants))
|
|
56
169
|
return report;
|
|
57
|
-
await
|
|
58
|
-
timeout: 120_000,
|
|
59
|
-
maxBuffer: 2_000_000,
|
|
60
|
-
});
|
|
170
|
+
await push(input.workspace);
|
|
61
171
|
const headAfterPush = await readHead(input.workspace);
|
|
62
172
|
if (!report.head_commit || !commitsMatch(report.head_commit, headAfterPush)) {
|
|
63
173
|
throw new Error(`Workspace HEAD changed during push (${report.head_commit} → ${headAfterPush})`);
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When the contract requires test evidence and the agent forgot to paste command output,
|
|
3
|
+
* Bridge runs one bounded verification command and attaches the verbatim result — same
|
|
4
|
+
* mechanical-land pattern as ensureDeliveryPullRequest.
|
|
5
|
+
*/
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { isBoundedVerificationCommand } from "./execution-class.js";
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
const TEST_EVIDENCE_DETAILS_MIN = 32;
|
|
11
|
+
export function needsTestEvidence(report, spec, grants) {
|
|
12
|
+
if (!grants.includes("test_run"))
|
|
13
|
+
return false;
|
|
14
|
+
if (!(spec.required_evidence ?? []).includes("test"))
|
|
15
|
+
return false;
|
|
16
|
+
const existing = report.evidence.filter((item) => item.kind === "test");
|
|
17
|
+
if (existing.length === 0)
|
|
18
|
+
return true;
|
|
19
|
+
return existing.every((item) => item.details.join("\n").trim().length < TEST_EVIDENCE_DETAILS_MIN);
|
|
20
|
+
}
|
|
21
|
+
export function pickVerificationCommand(commands) {
|
|
22
|
+
for (const command of commands) {
|
|
23
|
+
const trimmed = command.trim();
|
|
24
|
+
if (trimmed && isBoundedVerificationCommand(trimmed))
|
|
25
|
+
return trimmed;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
function argvForBoundedCommand(command) {
|
|
30
|
+
return command.trim().split(/\s+/);
|
|
31
|
+
}
|
|
32
|
+
async function defaultRunCommand(command, workspace) {
|
|
33
|
+
const argv = argvForBoundedCommand(command);
|
|
34
|
+
const bin = argv[0];
|
|
35
|
+
if (!bin)
|
|
36
|
+
throw new Error("Verification command is empty");
|
|
37
|
+
try {
|
|
38
|
+
const { stdout, stderr } = await execFileAsync(bin, argv.slice(1), {
|
|
39
|
+
cwd: workspace,
|
|
40
|
+
timeout: 120_000,
|
|
41
|
+
maxBuffer: 2_000_000,
|
|
42
|
+
env: process.env,
|
|
43
|
+
});
|
|
44
|
+
return { stdout: String(stdout), stderr: String(stderr), code: 0 };
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
const err = error;
|
|
48
|
+
return {
|
|
49
|
+
stdout: String(err.stdout ?? ""),
|
|
50
|
+
stderr: String(err.stderr ?? err.message ?? "verification failed"),
|
|
51
|
+
code: typeof err.code === "number" ? err.code : 1,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Bridge may attach genuine command output; it must not invent which criteria that output proves.
|
|
57
|
+
* Reuse only criteria the agent already mapped onto a test-kind evidence entry (including thin ones
|
|
58
|
+
* we are replacing). Empty mapping lets validateDeliveryReport reject met-without-evidence.
|
|
59
|
+
*/
|
|
60
|
+
export function criteriaForTestEvidence(report) {
|
|
61
|
+
const mapped = new Set();
|
|
62
|
+
for (const item of report.evidence) {
|
|
63
|
+
if (item.kind !== "test")
|
|
64
|
+
continue;
|
|
65
|
+
for (const criterion of item.acceptance_criteria ?? []) {
|
|
66
|
+
if (criterion)
|
|
67
|
+
mapped.add(criterion);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return [...mapped];
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* If test evidence is required and missing/thin, run one bounded verification command
|
|
74
|
+
* in the attempt workspace and attach verbatim stdout/stderr.
|
|
75
|
+
*/
|
|
76
|
+
export async function ensureTestEvidence(input) {
|
|
77
|
+
if (!needsTestEvidence(input.report, input.spec, input.grants))
|
|
78
|
+
return input.report;
|
|
79
|
+
const command = pickVerificationCommand(input.verificationCommands);
|
|
80
|
+
if (!command) {
|
|
81
|
+
throw new Error("Agent report is missing required evidence: test (no bounded verification command available for Bridge to run)");
|
|
82
|
+
}
|
|
83
|
+
const run = input.runCommand ?? defaultRunCommand;
|
|
84
|
+
const result = await run(command, input.workspace);
|
|
85
|
+
// Silent success is valid for bounded targets like `make test` (`@test -f …` prints nothing).
|
|
86
|
+
// Still record an explicit no-output line so control-plane verbatim-detail checks pass.
|
|
87
|
+
const stdoutLines = result.stdout.trim() ? result.stdout.trim().split("\n") : [];
|
|
88
|
+
const stderrLines = result.stderr.trim() ? result.stderr.trim().split("\n") : [];
|
|
89
|
+
const details = [
|
|
90
|
+
`$ ${command}`,
|
|
91
|
+
...stdoutLines,
|
|
92
|
+
...stderrLines,
|
|
93
|
+
...(stdoutLines.length === 0 && stderrLines.length === 0
|
|
94
|
+
? ["(no stdout/stderr — command exited with the status below)"]
|
|
95
|
+
: []),
|
|
96
|
+
`exit ${result.code}`,
|
|
97
|
+
].map((line) => line.slice(0, 4_000)).slice(0, 100);
|
|
98
|
+
if (result.code !== 0) {
|
|
99
|
+
// Prefix must match FINALIZE_CONTRACT_PATTERN ("Agent report") — not Bridge-fault laundering.
|
|
100
|
+
throw new Error(`Agent report: Verification failed (${command}): ${details.join("\n").slice(0, 2_000)}`);
|
|
101
|
+
}
|
|
102
|
+
if (details.join("\n").trim().length < TEST_EVIDENCE_DETAILS_MIN) {
|
|
103
|
+
throw new Error(`Agent report: Verification produced insufficient output for test evidence (${command})`);
|
|
104
|
+
}
|
|
105
|
+
const withoutThinTest = input.report.evidence.filter((item) => {
|
|
106
|
+
if (item.kind !== "test")
|
|
107
|
+
return true;
|
|
108
|
+
return item.details.join("\n").trim().length >= TEST_EVIDENCE_DETAILS_MIN;
|
|
109
|
+
});
|
|
110
|
+
return {
|
|
111
|
+
...input.report,
|
|
112
|
+
verification: input.report.verification.includes(command)
|
|
113
|
+
? input.report.verification
|
|
114
|
+
: [...input.report.verification, `${command} — exit ${result.code}`],
|
|
115
|
+
evidence: [
|
|
116
|
+
...withoutThinTest,
|
|
117
|
+
{
|
|
118
|
+
kind: "test",
|
|
119
|
+
name: command,
|
|
120
|
+
details,
|
|
121
|
+
acceptance_criteria: criteriaForTestEvidence(input.report),
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
}
|
package/dist/execution.js
CHANGED
|
@@ -8,6 +8,7 @@ import { pickDriverForClaim, resolveDriverFuel } from "./drivers.js";
|
|
|
8
8
|
import { attemptWorktreePath, createAttemptWorktree, proveResumeWorktree, quarantineAttemptWorktree, removeAttemptWorktree, } from "./attempt-worktree.js";
|
|
9
9
|
import { buildWorkspaceBrief, ensureCommitAvailable, normalizeRepositoryUrl, resolveAttemptStartCommit } from "./brief.js";
|
|
10
10
|
import { ensureDeliveryPullRequest } from "./ensure-pull-request.js";
|
|
11
|
+
import { ensureTestEvidence } from "./ensure-test-evidence.js";
|
|
11
12
|
/** Feedback text for changes_requested summaries (plain string or `{ feedback }`). */
|
|
12
13
|
function changesRequestedFeedback(summary) {
|
|
13
14
|
if (!summary)
|
|
@@ -543,6 +544,15 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
543
544
|
spec,
|
|
544
545
|
grants,
|
|
545
546
|
title: task.objective,
|
|
547
|
+
repositoryFingerprint: executionContract.repository_fingerprint,
|
|
548
|
+
});
|
|
549
|
+
// Mechanical test path: agent often forgets verbatim make test / npm test output.
|
|
550
|
+
report = await ensureTestEvidence({
|
|
551
|
+
workspace: attemptWorkspace,
|
|
552
|
+
report,
|
|
553
|
+
spec,
|
|
554
|
+
grants,
|
|
555
|
+
verificationCommands: liveBrief?.verification ?? [],
|
|
546
556
|
});
|
|
547
557
|
validateDeliveryReport(report, spec, grants);
|
|
548
558
|
}
|
package/dist/ops.js
CHANGED
|
@@ -3,13 +3,15 @@
|
|
|
3
3
|
* Invoked as: npx @miraland-labs/conduit-bridge ops <connect|install|…>
|
|
4
4
|
*/
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { homedir, platform } from "node:os";
|
|
8
|
-
import { join, resolve } from "node:path";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
import { parseArgs } from "node:util";
|
|
10
|
+
import { ConduitClient } from "./client.js";
|
|
9
11
|
import { loadConfig } from "./config.js";
|
|
10
12
|
import { describePreflightIssue, runBridgePreflight } from "./preflight.js";
|
|
11
13
|
export const OPS_VERBS = [
|
|
12
|
-
"connect", "install", "online", "offline", "status", "doctor", "disconnect", "uninstall",
|
|
14
|
+
"connect", "install", "switch", "online", "offline", "status", "doctor", "disconnect", "uninstall",
|
|
13
15
|
];
|
|
14
16
|
const LOCAL_FUEL_DRIVERS = new Set(["cursor", "pi", "kiro", "antigravity"]);
|
|
15
17
|
export function defaultOpsEnvPath(home = homedir()) {
|
|
@@ -88,6 +90,36 @@ export function requireOpsEnv(env) {
|
|
|
88
90
|
export function splitOpsList(value) {
|
|
89
91
|
return value.split(/[,\s]+/).map((item) => item.trim()).filter(Boolean);
|
|
90
92
|
}
|
|
93
|
+
/** Update keys in an existing ops.env (or create from current values). Preserves comments/unknown keys when possible. */
|
|
94
|
+
export function writeOpsEnvFile(path, patch, existingText) {
|
|
95
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
96
|
+
const keys = ["CONDUIT_URL", "CONDUIT_ORG", "CONDUIT_WORKSPACE", "CONDUIT_REPO", "CONDUIT_DRIVERS", "CONDUIT_ROLES"];
|
|
97
|
+
const current = existingText ?? (existsSync(path) ? readFileSync(path, "utf8") : "");
|
|
98
|
+
const lines = current ? current.split(/\r?\n/) : [];
|
|
99
|
+
const seen = new Set();
|
|
100
|
+
const next = lines.map((raw) => {
|
|
101
|
+
const trimmed = raw.trim();
|
|
102
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
103
|
+
return raw;
|
|
104
|
+
const eq = trimmed.indexOf("=");
|
|
105
|
+
if (eq <= 0)
|
|
106
|
+
return raw;
|
|
107
|
+
const key = trimmed.slice(0, eq).trim();
|
|
108
|
+
if (!keys.includes(key))
|
|
109
|
+
return raw;
|
|
110
|
+
seen.add(key);
|
|
111
|
+
const value = patch[key];
|
|
112
|
+
if (value === undefined)
|
|
113
|
+
return raw;
|
|
114
|
+
return `${key}=${value}`;
|
|
115
|
+
});
|
|
116
|
+
for (const key of keys) {
|
|
117
|
+
if (seen.has(key) || patch[key] === undefined)
|
|
118
|
+
continue;
|
|
119
|
+
next.push(`${key}=${patch[key]}`);
|
|
120
|
+
}
|
|
121
|
+
writeFileSync(path, `${next.filter((line, index) => !(index === next.length - 1 && line === "")).join("\n").replace(/\n*$/, "\n")}`, { mode: 0o600 });
|
|
122
|
+
}
|
|
91
123
|
export function resolveDrivers(env, argv) {
|
|
92
124
|
const fromArgs = argv.map((item) => item.trim()).filter(Boolean);
|
|
93
125
|
const drivers = fromArgs.length ? fromArgs : splitOpsList(env.CONDUIT_DRIVERS);
|
|
@@ -175,6 +207,52 @@ export async function runOps(verb, argv = [], deps = {}) {
|
|
|
175
207
|
return;
|
|
176
208
|
}
|
|
177
209
|
requireOpsEnv(env);
|
|
210
|
+
if (verb === "switch") {
|
|
211
|
+
const { values } = parseArgs({
|
|
212
|
+
args: argv,
|
|
213
|
+
options: {
|
|
214
|
+
project: { type: "string" },
|
|
215
|
+
workspace: { type: "string" },
|
|
216
|
+
repo: { type: "string" },
|
|
217
|
+
},
|
|
218
|
+
allowPositionals: true,
|
|
219
|
+
});
|
|
220
|
+
const projectId = values.project?.trim();
|
|
221
|
+
if (!projectId) {
|
|
222
|
+
throw new Error("Usage: ops switch --project <project-id> [--workspace <path>] [--repo <url>]");
|
|
223
|
+
}
|
|
224
|
+
const workspaceRaw = values.workspace?.trim() || env.CONDUIT_WORKSPACE;
|
|
225
|
+
if (!workspaceRaw)
|
|
226
|
+
throw new Error(`Set CONDUIT_WORKSPACE in ${defaultOpsEnvPath()} or pass --workspace`);
|
|
227
|
+
const workspace = resolve(expandOpsValue(workspaceRaw));
|
|
228
|
+
const repo = (values.repo?.trim() || env.CONDUIT_REPO || "").trim();
|
|
229
|
+
const envPath = env.loadedFrom ?? defaultOpsEnvPath();
|
|
230
|
+
// Control plane first — do not rewrite ops.env if affinity replace fails.
|
|
231
|
+
const config = await (deps.loadBridgeConfig ?? loadConfig)();
|
|
232
|
+
const client = new ConduitClient(config);
|
|
233
|
+
const result = await client.request("/runner/v1/machine/switch-project", {
|
|
234
|
+
method: "POST",
|
|
235
|
+
body: JSON.stringify({ project_id: projectId }),
|
|
236
|
+
});
|
|
237
|
+
console.log(`Control plane on-shift: ${String(result.project_id ?? projectId)} (${String(result.binding_status ?? "pending")})`);
|
|
238
|
+
writeOpsEnvFile(envPath, {
|
|
239
|
+
CONDUIT_WORKSPACE: workspace,
|
|
240
|
+
...(repo ? { CONDUIT_REPO: repo } : {}),
|
|
241
|
+
});
|
|
242
|
+
console.log(`Updated ${envPath}`);
|
|
243
|
+
console.log(`On-shift workspace: ${workspace}${repo ? ` (${repo})` : ""}`);
|
|
244
|
+
// Same install path as ops install — restart runner against the new checkout.
|
|
245
|
+
const switched = loadOpsEnv();
|
|
246
|
+
await runOps("install", [], {
|
|
247
|
+
...deps,
|
|
248
|
+
env: {
|
|
249
|
+
...switched,
|
|
250
|
+
CONDUIT_WORKSPACE: workspace,
|
|
251
|
+
CONDUIT_REPO: repo || switched.CONDUIT_REPO,
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
178
256
|
if (verb === "connect") {
|
|
179
257
|
if (!env.CONDUIT_URL)
|
|
180
258
|
throw new Error(`Set CONDUIT_URL in ${defaultOpsEnvPath()}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miraland-labs/conduit-bridge",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.9",
|
|
4
4
|
"description": "Conduit Bridge CLI — join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Pi / Kiro / Antigravity agents for a Conduit organization",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|