@miraland-labs/conduit-bridge 0.12.1 → 0.12.3
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/ops.js +35 -6
- package/dist/service.js +62 -9
- package/ops/env.example +3 -0
- package/package.json +1 -1
package/dist/ops.js
CHANGED
|
@@ -310,14 +310,43 @@ export async function runOps(verb, argv = [], deps = {}) {
|
|
|
310
310
|
return;
|
|
311
311
|
}
|
|
312
312
|
if (verb === "connect") {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
313
|
+
const { values } = parseArgs({
|
|
314
|
+
args: argv,
|
|
315
|
+
options: {
|
|
316
|
+
organization: { type: "string", short: "o" },
|
|
317
|
+
url: { type: "string" },
|
|
318
|
+
},
|
|
319
|
+
allowPositionals: true,
|
|
320
|
+
});
|
|
321
|
+
const url = (values.url?.trim() || env.CONDUIT_URL).trim();
|
|
322
|
+
if (!url)
|
|
323
|
+
throw new Error(`Set CONDUIT_URL in ${defaultOpsEnvPath()} or pass --url`);
|
|
324
|
+
// Never silently reuse CONDUIT_ORG from ops.env — a leftover public-org slug skipped
|
|
325
|
+
// "Join which org?" and enrolled the wrong workspace. Pass --organization explicitly, or
|
|
326
|
+
// omit it so join prompts interactively (and suggest the prior slug when present).
|
|
327
|
+
const organization = values.organization?.trim().toLowerCase() || "";
|
|
328
|
+
if (organization && !/^[a-z0-9][a-z0-9-]{0,62}$/.test(organization)) {
|
|
329
|
+
throw new Error("Organization must be its lowercase workspace slug");
|
|
330
|
+
}
|
|
331
|
+
const envPath = env.loadedFrom ?? defaultOpsEnvPath();
|
|
332
|
+
writeOpsEnvFile(envPath, {
|
|
333
|
+
CONDUIT_URL: url,
|
|
334
|
+
...(organization ? { CONDUIT_ORG: organization } : {}),
|
|
335
|
+
});
|
|
336
|
+
const args = ["join", "--url", url];
|
|
337
|
+
if (organization)
|
|
338
|
+
args.push("--organization", organization);
|
|
318
339
|
for (const role of splitOpsList(env.CONDUIT_ROLES))
|
|
319
340
|
args.push("--capability", role);
|
|
320
|
-
console.log(`Connecting to ${
|
|
341
|
+
console.log(`Connecting to ${url} (roles: ${env.CONDUIT_ROLES})`);
|
|
342
|
+
if (organization) {
|
|
343
|
+
console.log(`Organization: ${organization}`);
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
console.log(env.CONDUIT_ORG
|
|
347
|
+
? `Organization not set on the command — join will ask. (ops.env still has ${env.CONDUIT_ORG}; type the slug you want now.)`
|
|
348
|
+
: "Organization not set — join will ask which org to connect.");
|
|
349
|
+
}
|
|
321
350
|
console.log("Approve this computer in Connect when the browser opens.");
|
|
322
351
|
runBridge(args);
|
|
323
352
|
return;
|
package/dist/service.js
CHANGED
|
@@ -128,11 +128,55 @@ function shellQuote(value) {
|
|
|
128
128
|
* the next line never ran, and the machine stayed Binding incomplete with the service unloaded.
|
|
129
129
|
* The swap therefore runs in a detached helper that survives the bootout; failures append to the
|
|
130
130
|
* runner log.
|
|
131
|
+
*
|
|
132
|
+
* Interactive `ops install` from a terminal must NOT use only the detached path: on some macOS
|
|
133
|
+
* hosts `launchctl bootstrap` returns EIO (5) from the helper while an in-process bootstrap of the
|
|
134
|
+
* same plist succeeds. Prefer a synchronous reload when we are not inside the runner service.
|
|
135
|
+
* Deprecated `launchctl load -w` also returns EIO on modern macOS — use enable + kickstart instead.
|
|
131
136
|
*/
|
|
132
137
|
export function launchdSwapCommand(domain, plistPath, log) {
|
|
133
138
|
const plist = shellQuote(plistPath);
|
|
134
139
|
const logQ = shellQuote(log);
|
|
135
|
-
|
|
140
|
+
const label = `${domain}/${SERVICE_LABEL}`;
|
|
141
|
+
return [
|
|
142
|
+
"sleep 1",
|
|
143
|
+
`launchctl bootout ${label} 2>>${logQ}`,
|
|
144
|
+
`launchctl bootstrap ${domain} ${plist} 2>>${logQ}`,
|
|
145
|
+
`launchctl enable ${label} 2>>${logQ}`,
|
|
146
|
+
`launchctl kickstart -k ${label} 2>>${logQ}`,
|
|
147
|
+
].join("; ");
|
|
148
|
+
}
|
|
149
|
+
function launchdServiceLoaded(domain) {
|
|
150
|
+
return spawnSync("launchctl", ["print", `${domain}/${SERVICE_LABEL}`], { encoding: "utf8" }).status === 0;
|
|
151
|
+
}
|
|
152
|
+
function sleepMs(ms) {
|
|
153
|
+
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
154
|
+
}
|
|
155
|
+
/** True when this process is the LaunchAgent we are about to boot out. */
|
|
156
|
+
export function runningInsideRunnerLaunchAgent() {
|
|
157
|
+
return process.env.XPC_SERVICE_NAME === SERVICE_LABEL;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Boot out (ignore miss), bootstrap with retries, then enable + kickstart.
|
|
161
|
+
* Returns whether `launchctl print` sees the service.
|
|
162
|
+
*/
|
|
163
|
+
export function reloadLaunchdRunnerSync(domain, plistPath) {
|
|
164
|
+
const label = `${domain}/${SERVICE_LABEL}`;
|
|
165
|
+
spawnSync("launchctl", ["bootout", label], { encoding: "utf8" });
|
|
166
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
167
|
+
const boot = spawnSync("launchctl", ["bootstrap", domain, plistPath], { encoding: "utf8" });
|
|
168
|
+
if (boot.status === 0 || launchdServiceLoaded(domain))
|
|
169
|
+
break;
|
|
170
|
+
spawnSync("launchctl", ["bootout", label], { encoding: "utf8" });
|
|
171
|
+
// Brief backoff — EIO (5) is often transient right after bootout.
|
|
172
|
+
const start = Date.now();
|
|
173
|
+
while (Date.now() - start < 400 * (attempt + 1)) {
|
|
174
|
+
/* spin */
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
spawnSync("launchctl", ["enable", label], { encoding: "utf8" });
|
|
178
|
+
spawnSync("launchctl", ["kickstart", "-k", label], { encoding: "utf8" });
|
|
179
|
+
return launchdServiceLoaded(domain);
|
|
136
180
|
}
|
|
137
181
|
export async function installRunnerService(options = {}) {
|
|
138
182
|
const host = platform();
|
|
@@ -145,16 +189,25 @@ export async function installRunnerService(options = {}) {
|
|
|
145
189
|
await mkdir(dirname(plistPath), { recursive: true });
|
|
146
190
|
await mkdir(dirname(logPath()), { recursive: true });
|
|
147
191
|
await writeFile(plistPath, launchdPlist(programArguments, logPath()), { mode: 0o644 });
|
|
148
|
-
// Detached helper (bootout → bootstrap → load fallback): see launchdSwapCommand for why the
|
|
149
|
-
// swap must survive this process dying at bootout.
|
|
150
192
|
const domain = `gui/${process.getuid?.() ?? 501}`;
|
|
151
|
-
spawn("/bin/sh", ["-c", launchdSwapCommand(domain, plistPath, logPath())], { detached: true, stdio: "ignore" }).unref();
|
|
152
|
-
// Confirm the reloaded service when this process survives to see it (an interactive install).
|
|
153
|
-
// A self-replacing runner dies at the helper's bootout and the helper still finishes the swap.
|
|
154
193
|
let loaded = false;
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
194
|
+
if (runningInsideRunnerLaunchAgent()) {
|
|
195
|
+
// Detached helper must survive bootout of this process.
|
|
196
|
+
spawn("/bin/sh", ["-c", launchdSwapCommand(domain, plistPath, logPath())], { detached: true, stdio: "ignore" }).unref();
|
|
197
|
+
for (let attempt = 0; attempt < 20 && !loaded; attempt++) {
|
|
198
|
+
await sleepMs(attempt === 0 ? 2000 : 500);
|
|
199
|
+
loaded = launchdServiceLoaded(domain);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
// Interactive ops install: reload in-process (avoids helper EIO flakiness).
|
|
204
|
+
loaded = reloadLaunchdRunnerSync(domain, plistPath);
|
|
205
|
+
for (let attempt = 0; attempt < 10 && !loaded; attempt++) {
|
|
206
|
+
await sleepMs(500);
|
|
207
|
+
loaded = launchdServiceLoaded(domain);
|
|
208
|
+
if (!loaded)
|
|
209
|
+
loaded = reloadLaunchdRunnerSync(domain, plistPath);
|
|
210
|
+
}
|
|
158
211
|
}
|
|
159
212
|
if (!loaded)
|
|
160
213
|
throw new Error(`launchd did not load ${SERVICE_LABEL}; see ${logPath()}`);
|
package/ops/env.example
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
#
|
|
9
9
|
# Replace the placeholders below with YOUR org, local checkout, and (optional) git remote.
|
|
10
10
|
CONDUIT_URL=https://api.conduit.miraland.io
|
|
11
|
+
# Leave CONDUIT_ORG unset so `ops connect` asks "Join which org?".
|
|
12
|
+
# Or pass once: ops connect --organization your-org-slug
|
|
13
|
+
# Prefer the Connect UI join command (includes the org you are signed into).
|
|
11
14
|
# CONDUIT_ORG=your-org-slug
|
|
12
15
|
CONDUIT_WORKSPACE=$HOME/path/to/your-repo
|
|
13
16
|
# CONDUIT_REPO=https://github.com/your-org/your-repo.git
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miraland-labs/conduit-bridge",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.3",
|
|
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": {
|