@maintainer-pro/ai-bridge 0.1.0
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 +32 -0
- package/bin/cli.js +5 -0
- package/package.json +30 -0
- package/src/daemon.mjs +664 -0
package/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# `@maintainer-pro/ai-bridge`
|
|
2
|
+
|
|
3
|
+
Local bridge daemon that pairs a **developer machine** to a Maintainer Pro **partner**, then configures many client sandboxes from folders you choose in admin.
|
|
4
|
+
|
|
5
|
+
## Flow
|
|
6
|
+
|
|
7
|
+
1. In admin → **Bridges**, generate a pair code
|
|
8
|
+
2. On the machine (any folder):
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npx --yes @maintainer-pro/ai-bridge --pair AB12-CD34 --admin-url https://your-admin
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Credentials are stored in `~/.maintainer-pro/bridge.json` (not the cwd).
|
|
15
|
+
|
|
16
|
+
3. Keep the bridge running (`npx @maintainer-pro/ai-bridge`). It advertises the current folder (and any `--offer-folder` paths).
|
|
17
|
+
4. In admin → Bridges: pick a reported folder + client sandbox → **Setup sandbox in folder**. The bridge writes `.env`, starts `ai-server` on an assigned port, and marks the sandbox setup complete.
|
|
18
|
+
|
|
19
|
+
One bridge process can run many sandboxes (different folders / ports) for different clients.
|
|
20
|
+
|
|
21
|
+
## Flags
|
|
22
|
+
|
|
23
|
+
| Flag | Purpose |
|
|
24
|
+
|------|---------|
|
|
25
|
+
| `--pair <code>` | Claim a pair code (first run) |
|
|
26
|
+
| `--admin-url <url>` | Admin base URL (required when pairing) |
|
|
27
|
+
| `--offer-folder <path>` | Advertise an extra folder in admin |
|
|
28
|
+
| `--no-ai-server` | Do not start sidecars |
|
|
29
|
+
|
|
30
|
+
## Auth
|
|
31
|
+
|
|
32
|
+
After pairing, the daemon uses a long-lived `mp_bridge_…` machine token (not a per-sandbox API key). Sandbox server/client keys are written into each workspace `.env` during setup.
|
package/bin/cli.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@maintainer-pro/ai-bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local bridge daemon that pairs a machine to Maintainer Pro and configures multiple client sandboxes.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"maintainer-pro",
|
|
7
|
+
"bridge",
|
|
8
|
+
"ai-bridge",
|
|
9
|
+
"sandbox",
|
|
10
|
+
"npx",
|
|
11
|
+
"daemon"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"bin": {
|
|
16
|
+
"ai-bridge": "bin/cli.js",
|
|
17
|
+
"maintainer-pro-ai-bridge": "bin/cli.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"bin",
|
|
21
|
+
"src",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=22"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/daemon.mjs
ADDED
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Maintainer Pro bridge — partner machine daemon.
|
|
4
|
+
*
|
|
5
|
+
* Pair once with a code from admin, then manage many sandboxes/folders:
|
|
6
|
+
* npx @maintainer-pro/ai-bridge --pair ABCD-EF01
|
|
7
|
+
* npx @maintainer-pro/ai-bridge
|
|
8
|
+
* npx @maintainer-pro/ai-bridge --admin-url https://… --pair ABCD-EF01
|
|
9
|
+
*/
|
|
10
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import http from "node:http";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import readline from "node:readline";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const PACKAGE_VERSION = readPackageVersion();
|
|
21
|
+
const HEARTBEAT_MS = 15_000;
|
|
22
|
+
|
|
23
|
+
const log = (msg) => console.log(`[bridge] ${msg}`);
|
|
24
|
+
const warn = (msg) => console.warn(`[bridge] ${msg}`);
|
|
25
|
+
const fail = (msg) => {
|
|
26
|
+
console.error(`[bridge] ${msg}`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function readPackageVersion() {
|
|
31
|
+
try {
|
|
32
|
+
const pkg = JSON.parse(
|
|
33
|
+
fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")
|
|
34
|
+
);
|
|
35
|
+
return pkg.version || "0.0.0";
|
|
36
|
+
} catch {
|
|
37
|
+
return "0.0.0";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseArgs(argv) {
|
|
42
|
+
/** @type {Record<string, string | boolean>} */
|
|
43
|
+
const out = {};
|
|
44
|
+
for (let i = 0; i < argv.length; i++) {
|
|
45
|
+
const arg = argv[i];
|
|
46
|
+
if (arg === "--help" || arg === "-h") {
|
|
47
|
+
out.help = true;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (arg === "--no-ai-server") {
|
|
51
|
+
out.noAiServer = true;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (arg === "--pair" && i + 1 < argv.length) {
|
|
55
|
+
out.pair = argv[++i];
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (
|
|
59
|
+
arg.startsWith("--") &&
|
|
60
|
+
i + 1 < argv.length &&
|
|
61
|
+
!argv[i + 1].startsWith("--")
|
|
62
|
+
) {
|
|
63
|
+
const key = arg.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
64
|
+
out[key] = argv[++i];
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function printHelp() {
|
|
72
|
+
console.log(`Maintainer Pro bridge (multi-sandbox)
|
|
73
|
+
|
|
74
|
+
Pair this machine to your partner account, then configure folders for any
|
|
75
|
+
client sandbox from the Maintainer Pro admin UI.
|
|
76
|
+
|
|
77
|
+
Usage:
|
|
78
|
+
npx @maintainer-pro/ai-bridge --pair ABCD-EF01 --admin-url https://admin.example.com
|
|
79
|
+
npx @maintainer-pro/ai-bridge
|
|
80
|
+
npx @maintainer-pro/ai-bridge --offer-folder D:\\apps\\client-a
|
|
81
|
+
|
|
82
|
+
Config is stored in ~/.maintainer-pro/bridge.json (not the cwd).
|
|
83
|
+
|
|
84
|
+
Flags:
|
|
85
|
+
--pair <code> Claim a pair code from admin → Bridges page
|
|
86
|
+
--admin-url <url> Maintainer Pro base URL (required for first pair)
|
|
87
|
+
--offer-folder <path> Suggest this folder in admin (repeatable via config)
|
|
88
|
+
--no-ai-server Do not start sidecars for workspaces
|
|
89
|
+
--help
|
|
90
|
+
`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function configDir() {
|
|
94
|
+
return path.join(os.homedir(), ".maintainer-pro");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function configPath() {
|
|
98
|
+
return path.join(configDir(), "bridge.json");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function loadConfig() {
|
|
102
|
+
const file = configPath();
|
|
103
|
+
if (!fs.existsSync(file)) return null;
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function saveConfig(cfg) {
|
|
112
|
+
const dir = configDir();
|
|
113
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
114
|
+
fs.writeFileSync(configPath(), JSON.stringify(cfg, null, 2) + "\n", "utf8");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function ensureMachineId(cfg) {
|
|
118
|
+
if (cfg.machineId && String(cfg.machineId).length >= 8) return cfg.machineId;
|
|
119
|
+
const machineId = `mpm_${randomBytes(16).toString("hex")}`;
|
|
120
|
+
cfg.machineId = machineId;
|
|
121
|
+
return machineId;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function ask(question) {
|
|
125
|
+
const rl = readline.createInterface({
|
|
126
|
+
input: process.stdin,
|
|
127
|
+
output: process.stdout,
|
|
128
|
+
});
|
|
129
|
+
return new Promise((resolve) => {
|
|
130
|
+
rl.question(question, (answer) => {
|
|
131
|
+
rl.close();
|
|
132
|
+
resolve(answer.trim());
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function api(baseUrl, token, method, pathname, body) {
|
|
138
|
+
const url = `${baseUrl.replace(/\/$/, "")}${pathname}`;
|
|
139
|
+
const headers = {
|
|
140
|
+
Accept: "application/json",
|
|
141
|
+
"Content-Type": "application/json",
|
|
142
|
+
};
|
|
143
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
144
|
+
const res = await fetch(url, {
|
|
145
|
+
method,
|
|
146
|
+
headers,
|
|
147
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
148
|
+
});
|
|
149
|
+
const text = await res.text();
|
|
150
|
+
let data = null;
|
|
151
|
+
try {
|
|
152
|
+
data = text ? JSON.parse(text) : null;
|
|
153
|
+
} catch {
|
|
154
|
+
data = { raw: text };
|
|
155
|
+
}
|
|
156
|
+
if (!res.ok) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
data?.error || `Bridge API ${method} ${pathname} failed (${res.status})`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return data;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function commandExists(cmd) {
|
|
165
|
+
try {
|
|
166
|
+
if (process.platform === "win32") {
|
|
167
|
+
execFileSync("where", [cmd], { stdio: "ignore" });
|
|
168
|
+
} else {
|
|
169
|
+
execFileSync("which", [cmd], { stdio: "ignore" });
|
|
170
|
+
}
|
|
171
|
+
return true;
|
|
172
|
+
} catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function detectCliProviders() {
|
|
178
|
+
/** @type {string[]} */
|
|
179
|
+
const found = [];
|
|
180
|
+
if (commandExists("agent")) found.push("agent");
|
|
181
|
+
if (commandExists("claude")) found.push("claude");
|
|
182
|
+
if (commandExists("agy")) found.push("agy");
|
|
183
|
+
return found;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function probeUrl(url, timeoutMs = 2500) {
|
|
187
|
+
return new Promise((resolve) => {
|
|
188
|
+
let settled = false;
|
|
189
|
+
const done = (ok) => {
|
|
190
|
+
if (settled) return;
|
|
191
|
+
settled = true;
|
|
192
|
+
resolve(ok);
|
|
193
|
+
};
|
|
194
|
+
try {
|
|
195
|
+
const req = http.get(url, { timeout: timeoutMs }, (res) => {
|
|
196
|
+
res.resume();
|
|
197
|
+
done(Boolean(res.statusCode && res.statusCode < 500));
|
|
198
|
+
});
|
|
199
|
+
req.on("error", () => done(false));
|
|
200
|
+
req.on("timeout", () => {
|
|
201
|
+
req.destroy();
|
|
202
|
+
done(false);
|
|
203
|
+
});
|
|
204
|
+
} catch {
|
|
205
|
+
done(false);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function listDirEntries(dirPath) {
|
|
211
|
+
const resolved = path.resolve(dirPath);
|
|
212
|
+
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
|
|
213
|
+
return { error: "Not a directory", path: resolved, entries: [] };
|
|
214
|
+
}
|
|
215
|
+
const names = fs.readdirSync(resolved);
|
|
216
|
+
const entries = [];
|
|
217
|
+
for (const name of names) {
|
|
218
|
+
if (name === "node_modules" || name === ".git") continue;
|
|
219
|
+
const full = path.join(resolved, name);
|
|
220
|
+
try {
|
|
221
|
+
const st = fs.statSync(full);
|
|
222
|
+
entries.push({
|
|
223
|
+
name,
|
|
224
|
+
path: full,
|
|
225
|
+
isDir: st.isDirectory(),
|
|
226
|
+
});
|
|
227
|
+
} catch {
|
|
228
|
+
/* skip */
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
entries.sort((a, b) => {
|
|
232
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
233
|
+
return a.name.localeCompare(b.name);
|
|
234
|
+
});
|
|
235
|
+
return { path: resolved, entries: entries.slice(0, 200) };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function mergeEnvFile(file, values) {
|
|
239
|
+
/** @type {Record<string, string>} */
|
|
240
|
+
const map = {};
|
|
241
|
+
if (fs.existsSync(file)) {
|
|
242
|
+
for (const raw of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
243
|
+
const line = raw.trim();
|
|
244
|
+
if (!line || line.startsWith("#")) continue;
|
|
245
|
+
const eq = line.indexOf("=");
|
|
246
|
+
if (eq < 1) continue;
|
|
247
|
+
map[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
for (const [k, v] of Object.entries(values)) {
|
|
251
|
+
map[k] = String(v);
|
|
252
|
+
}
|
|
253
|
+
const body = Object.entries(map)
|
|
254
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
255
|
+
.join("\n");
|
|
256
|
+
fs.writeFileSync(file, body + "\n", "utf8");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function collectOfferedFolders(cfg) {
|
|
260
|
+
/** @type {string[]} */
|
|
261
|
+
const folders = [];
|
|
262
|
+
const add = (p) => {
|
|
263
|
+
if (!p) return;
|
|
264
|
+
const resolved = path.resolve(p);
|
|
265
|
+
if (!folders.includes(resolved)) folders.push(resolved);
|
|
266
|
+
};
|
|
267
|
+
add(process.cwd());
|
|
268
|
+
for (const f of cfg.offeredFolders || []) add(f);
|
|
269
|
+
for (const w of cfg.workspaces || []) add(w.folderPath);
|
|
270
|
+
return folders;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** @type {Map<string, import('node:child_process').ChildProcess>} */
|
|
274
|
+
const children = new Map();
|
|
275
|
+
|
|
276
|
+
function stopChild(sandboxId) {
|
|
277
|
+
const child = children.get(sandboxId);
|
|
278
|
+
if (!child || child.killed) {
|
|
279
|
+
children.delete(sandboxId);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
child.kill("SIGTERM");
|
|
284
|
+
} catch {
|
|
285
|
+
/* ignore */
|
|
286
|
+
}
|
|
287
|
+
children.delete(sandboxId);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function startAiServerForWorkspace(ws) {
|
|
291
|
+
const key = ws.sandboxId;
|
|
292
|
+
if (children.has(key) && !children.get(key)?.killed) return;
|
|
293
|
+
|
|
294
|
+
const localCli = path.resolve(
|
|
295
|
+
__dirname,
|
|
296
|
+
"..",
|
|
297
|
+
"..",
|
|
298
|
+
"ai-server",
|
|
299
|
+
"bin",
|
|
300
|
+
"cli.js"
|
|
301
|
+
);
|
|
302
|
+
const useLocal = fs.existsSync(localCli);
|
|
303
|
+
const env = {
|
|
304
|
+
...process.env,
|
|
305
|
+
PORT: String(ws.port),
|
|
306
|
+
AI_SERVER_URL: `http://localhost:${ws.port}`,
|
|
307
|
+
NEXT_PUBLIC_AI_SERVER_URL: `http://localhost:${ws.port}`,
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
log(`starting ai-server for ${ws.sandboxName || ws.sandboxId} on :${ws.port}`);
|
|
311
|
+
const child = spawn(
|
|
312
|
+
useLocal
|
|
313
|
+
? process.execPath
|
|
314
|
+
: process.platform === "win32"
|
|
315
|
+
? "npx.cmd"
|
|
316
|
+
: "npx",
|
|
317
|
+
useLocal ? [localCli] : ["--yes", "@maintainer-pro/ai-server"],
|
|
318
|
+
{
|
|
319
|
+
cwd: ws.folderPath,
|
|
320
|
+
env,
|
|
321
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
322
|
+
shell: process.platform === "win32" && !useLocal,
|
|
323
|
+
}
|
|
324
|
+
);
|
|
325
|
+
const tag = ws.sandboxName || ws.sandboxId.slice(0, 8);
|
|
326
|
+
child.stdout?.on("data", (buf) => {
|
|
327
|
+
const line = String(buf).trimEnd();
|
|
328
|
+
if (line) console.log(`[ai-server:${tag}] ${line}`);
|
|
329
|
+
});
|
|
330
|
+
child.stderr?.on("data", (buf) => {
|
|
331
|
+
const line = String(buf).trimEnd();
|
|
332
|
+
if (line) console.error(`[ai-server:${tag}] ${line}`);
|
|
333
|
+
});
|
|
334
|
+
child.on("exit", (code, signal) => {
|
|
335
|
+
log(`ai-server ${tag} exited (code=${code}, signal=${signal})`);
|
|
336
|
+
children.delete(key);
|
|
337
|
+
});
|
|
338
|
+
children.set(key, child);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function setupWorkspace(cfg, action) {
|
|
342
|
+
const folderPath = String(action.payload?.folderPath || "");
|
|
343
|
+
const sandboxId = String(
|
|
344
|
+
action.sandboxId || action.payload?.sandboxId || ""
|
|
345
|
+
);
|
|
346
|
+
const port = Number(action.payload?.port) || 3100;
|
|
347
|
+
if (!folderPath || !sandboxId) {
|
|
348
|
+
throw new Error("setup_workspace requires folderPath and sandboxId");
|
|
349
|
+
}
|
|
350
|
+
const resolved = path.resolve(folderPath);
|
|
351
|
+
fs.mkdirSync(resolved, { recursive: true });
|
|
352
|
+
|
|
353
|
+
const config = await api(
|
|
354
|
+
cfg.adminUrl,
|
|
355
|
+
cfg.token,
|
|
356
|
+
"GET",
|
|
357
|
+
`/api/v1/bridge/machine/sandboxes/${sandboxId}/setup-config?port=${port}`
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
const envPath = path.join(resolved, ".env");
|
|
361
|
+
const envValues = {
|
|
362
|
+
...config.env,
|
|
363
|
+
AI_CLI_WORKSPACE: ".",
|
|
364
|
+
AI_SERVER_UI: ".",
|
|
365
|
+
CORS_ORIGIN: process.env.CORS_ORIGIN || `http://localhost:${port}`,
|
|
366
|
+
APP_URL: process.env.APP_URL || `http://localhost:3000`,
|
|
367
|
+
};
|
|
368
|
+
mergeEnvFile(envPath, envValues);
|
|
369
|
+
|
|
370
|
+
cfg.workspaces = cfg.workspaces || [];
|
|
371
|
+
const existing = cfg.workspaces.findIndex((w) => w.sandboxId === sandboxId);
|
|
372
|
+
const entry = {
|
|
373
|
+
sandboxId,
|
|
374
|
+
folderPath: resolved,
|
|
375
|
+
port,
|
|
376
|
+
sandboxName: config.sandbox?.name,
|
|
377
|
+
applicationName: config.sandbox?.applicationName,
|
|
378
|
+
};
|
|
379
|
+
if (existing >= 0) cfg.workspaces[existing] = entry;
|
|
380
|
+
else cfg.workspaces.push(entry);
|
|
381
|
+
if (!cfg.offeredFolders) cfg.offeredFolders = [];
|
|
382
|
+
if (!cfg.offeredFolders.includes(resolved)) cfg.offeredFolders.push(resolved);
|
|
383
|
+
saveConfig(cfg);
|
|
384
|
+
|
|
385
|
+
if (!cfg.noAiServer) {
|
|
386
|
+
startAiServerForWorkspace(entry);
|
|
387
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
sandboxId,
|
|
392
|
+
folderPath: resolved,
|
|
393
|
+
port,
|
|
394
|
+
appUrl: envValues.APP_URL || null,
|
|
395
|
+
origins: envValues.CORS_ORIGIN ? [envValues.CORS_ORIGIN] : [],
|
|
396
|
+
wroteEnv: true,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function runActions(cfg, actions) {
|
|
401
|
+
for (const action of actions) {
|
|
402
|
+
log(`action ${action.code} (${action.id})`);
|
|
403
|
+
let ok = true;
|
|
404
|
+
/** @type {Record<string, unknown>} */
|
|
405
|
+
let result = {};
|
|
406
|
+
try {
|
|
407
|
+
if (action.code === "browse") {
|
|
408
|
+
const p = String(action.payload?.path || process.cwd());
|
|
409
|
+
result = listDirEntries(p);
|
|
410
|
+
} else if (action.code === "setup_workspace") {
|
|
411
|
+
result = await setupWorkspace(cfg, action);
|
|
412
|
+
} else if (action.code === "recheck") {
|
|
413
|
+
result = { recheckedAt: new Date().toISOString() };
|
|
414
|
+
} else if (action.code === "start_ai_server") {
|
|
415
|
+
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
416
|
+
const ws =
|
|
417
|
+
(cfg.workspaces || []).find((w) => w.sandboxId === sandboxId) ||
|
|
418
|
+
(action.payload?.folderPath
|
|
419
|
+
? {
|
|
420
|
+
sandboxId,
|
|
421
|
+
folderPath: String(action.payload.folderPath),
|
|
422
|
+
port: Number(action.payload.port) || 3100,
|
|
423
|
+
}
|
|
424
|
+
: null);
|
|
425
|
+
if (!ws || cfg.noAiServer) {
|
|
426
|
+
ok = false;
|
|
427
|
+
result = { error: "No workspace or --no-ai-server" };
|
|
428
|
+
} else {
|
|
429
|
+
startAiServerForWorkspace(ws);
|
|
430
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
431
|
+
result = {
|
|
432
|
+
up: await probeUrl(
|
|
433
|
+
`http://127.0.0.1:${ws.port}/embed-config.js`
|
|
434
|
+
),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
} else if (action.code === "refresh_public_url") {
|
|
438
|
+
result = {
|
|
439
|
+
appUrl: process.env.APP_URL || process.env.PUBLIC_URL || null,
|
|
440
|
+
};
|
|
441
|
+
} else if (action.code === "remove_workspace") {
|
|
442
|
+
const sandboxId = String(
|
|
443
|
+
action.sandboxId || action.payload?.sandboxId || ""
|
|
444
|
+
);
|
|
445
|
+
stopChild(sandboxId);
|
|
446
|
+
cfg.workspaces = (cfg.workspaces || []).filter(
|
|
447
|
+
(w) => w.sandboxId !== sandboxId
|
|
448
|
+
);
|
|
449
|
+
saveConfig(cfg);
|
|
450
|
+
result = { removedSandboxId: sandboxId };
|
|
451
|
+
} else {
|
|
452
|
+
ok = false;
|
|
453
|
+
result = { error: `Unknown action ${action.code}` };
|
|
454
|
+
}
|
|
455
|
+
} catch (err) {
|
|
456
|
+
ok = false;
|
|
457
|
+
result = { error: err instanceof Error ? err.message : String(err) };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
try {
|
|
461
|
+
await api(
|
|
462
|
+
cfg.adminUrl,
|
|
463
|
+
cfg.token,
|
|
464
|
+
"POST",
|
|
465
|
+
`/api/v1/bridge/machine/actions/${action.id}/complete`,
|
|
466
|
+
{ ok, result }
|
|
467
|
+
);
|
|
468
|
+
} catch (err) {
|
|
469
|
+
warn(
|
|
470
|
+
`failed to complete action: ${
|
|
471
|
+
err instanceof Error ? err.message : String(err)
|
|
472
|
+
}`
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function buildIssues(cfg, workspaceStates) {
|
|
479
|
+
/** @type {Array<Record<string, unknown>>} */
|
|
480
|
+
const issues = [];
|
|
481
|
+
const cli = detectCliProviders();
|
|
482
|
+
if (!cli.length) {
|
|
483
|
+
issues.push({
|
|
484
|
+
code: "missing_cli",
|
|
485
|
+
severity: "error",
|
|
486
|
+
title: "No coding agent CLI found",
|
|
487
|
+
message:
|
|
488
|
+
"PATH has no agent, claude, or agy. Chat turns will fail until one is installed.",
|
|
489
|
+
resolution: "Install and authenticate a CLI, then Recheck.",
|
|
490
|
+
actionCode: "recheck",
|
|
491
|
+
sandboxId: null,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
for (const st of workspaceStates) {
|
|
495
|
+
if (!st.aiServerUp) {
|
|
496
|
+
issues.push({
|
|
497
|
+
code: "ai_server_down",
|
|
498
|
+
severity: "error",
|
|
499
|
+
title: `AI server down (${st.sandboxName || st.sandboxId})`,
|
|
500
|
+
message: `Cannot reach http://localhost:${st.port}`,
|
|
501
|
+
resolution: "Start ai-server from the Bridges page or restart the bridge.",
|
|
502
|
+
actionCode: "start_ai_server",
|
|
503
|
+
sandboxId: st.sandboxId,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return issues;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function pairFlow(args) {
|
|
511
|
+
let cfg = loadConfig() || {};
|
|
512
|
+
ensureMachineId(cfg);
|
|
513
|
+
|
|
514
|
+
let adminUrl =
|
|
515
|
+
(typeof args.adminUrl === "string" && args.adminUrl) ||
|
|
516
|
+
cfg.adminUrl ||
|
|
517
|
+
process.env.MAINTAINER_PRO_URL ||
|
|
518
|
+
"";
|
|
519
|
+
let code = typeof args.pair === "string" ? args.pair : "";
|
|
520
|
+
|
|
521
|
+
if (!adminUrl) {
|
|
522
|
+
adminUrl = await ask("Maintainer Pro admin URL: ");
|
|
523
|
+
}
|
|
524
|
+
if (!code) {
|
|
525
|
+
code = await ask("Pair code (from admin → Bridges): ");
|
|
526
|
+
}
|
|
527
|
+
if (!adminUrl || !code) fail("admin URL and pair code are required");
|
|
528
|
+
|
|
529
|
+
const result = await api(adminUrl, null, "POST", "/api/v1/bridge/pair", {
|
|
530
|
+
code,
|
|
531
|
+
machineId: cfg.machineId,
|
|
532
|
+
hostname: os.hostname(),
|
|
533
|
+
platform: `${os.platform()}-${os.arch()}`,
|
|
534
|
+
bridgeVersion: PACKAGE_VERSION,
|
|
535
|
+
name: os.hostname(),
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
cfg.adminUrl = result.adminUrl || adminUrl.replace(/\/$/, "");
|
|
539
|
+
cfg.token = result.token;
|
|
540
|
+
cfg.partnerId = result.machine?.partnerId;
|
|
541
|
+
cfg.pairedAt = new Date().toISOString();
|
|
542
|
+
saveConfig(cfg);
|
|
543
|
+
log(`paired as ${result.machine?.name || cfg.machineId}`);
|
|
544
|
+
log(`config ${configPath()}`);
|
|
545
|
+
return cfg;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function main() {
|
|
549
|
+
const args = parseArgs(process.argv.slice(2));
|
|
550
|
+
if (args.help) {
|
|
551
|
+
printHelp();
|
|
552
|
+
process.exit(0);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
let cfg = loadConfig() || {};
|
|
556
|
+
ensureMachineId(cfg);
|
|
557
|
+
|
|
558
|
+
if (typeof args.offerFolder === "string") {
|
|
559
|
+
cfg.offeredFolders = cfg.offeredFolders || [];
|
|
560
|
+
const resolved = path.resolve(args.offerFolder);
|
|
561
|
+
if (!cfg.offeredFolders.includes(resolved)) {
|
|
562
|
+
cfg.offeredFolders.push(resolved);
|
|
563
|
+
saveConfig(cfg);
|
|
564
|
+
log(`offering folder ${resolved}`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (args.pair || !cfg.token || !cfg.adminUrl) {
|
|
569
|
+
cfg = await pairFlow(args);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
cfg.noAiServer = Boolean(args.noAiServer);
|
|
573
|
+
saveConfig(cfg);
|
|
574
|
+
|
|
575
|
+
log(`machine ${cfg.machineId}`);
|
|
576
|
+
log(`admin ${cfg.adminUrl}`);
|
|
577
|
+
log(`config ${configPath()}`);
|
|
578
|
+
|
|
579
|
+
const tick = async () => {
|
|
580
|
+
const remoteWorkspaces = [];
|
|
581
|
+
try {
|
|
582
|
+
// Heartbeat first — server returns assigned workspaces
|
|
583
|
+
const folders = collectOfferedFolders(cfg);
|
|
584
|
+
/** @type {Array<{sandboxId:string,sandboxName?:string,port:number,aiServerUp:boolean,folderPath:string}>} */
|
|
585
|
+
const localStates = [];
|
|
586
|
+
|
|
587
|
+
for (const ws of cfg.workspaces || []) {
|
|
588
|
+
const up = await probeUrl(
|
|
589
|
+
`http://127.0.0.1:${ws.port}/embed-config.js`
|
|
590
|
+
);
|
|
591
|
+
localStates.push({
|
|
592
|
+
sandboxId: ws.sandboxId,
|
|
593
|
+
sandboxName: ws.sandboxName,
|
|
594
|
+
port: ws.port,
|
|
595
|
+
folderPath: ws.folderPath,
|
|
596
|
+
aiServerUp: up,
|
|
597
|
+
});
|
|
598
|
+
if (!cfg.noAiServer && !up) {
|
|
599
|
+
startAiServerForWorkspace(ws);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const hb = await api(
|
|
604
|
+
cfg.adminUrl,
|
|
605
|
+
cfg.token,
|
|
606
|
+
"POST",
|
|
607
|
+
"/api/v1/bridge/machine/heartbeat",
|
|
608
|
+
{
|
|
609
|
+
hostname: os.hostname(),
|
|
610
|
+
platform: `${os.platform()}-${os.arch()}`,
|
|
611
|
+
bridgeVersion: PACKAGE_VERSION,
|
|
612
|
+
folders,
|
|
613
|
+
issues: buildIssues(cfg, localStates),
|
|
614
|
+
}
|
|
615
|
+
);
|
|
616
|
+
|
|
617
|
+
// Sync local workspace list from server assignments
|
|
618
|
+
if (Array.isArray(hb.workspaces)) {
|
|
619
|
+
for (const remote of hb.workspaces) {
|
|
620
|
+
remoteWorkspaces.push(remote);
|
|
621
|
+
const local = (cfg.workspaces || []).find(
|
|
622
|
+
(w) => w.sandboxId === remote.sandboxId
|
|
623
|
+
);
|
|
624
|
+
if (!local) {
|
|
625
|
+
cfg.workspaces = cfg.workspaces || [];
|
|
626
|
+
cfg.workspaces.push({
|
|
627
|
+
sandboxId: remote.sandboxId,
|
|
628
|
+
folderPath: remote.folderPath,
|
|
629
|
+
port: remote.port,
|
|
630
|
+
sandboxName: remote.sandboxName,
|
|
631
|
+
applicationName: remote.applicationName,
|
|
632
|
+
});
|
|
633
|
+
saveConfig(cfg);
|
|
634
|
+
} else if (local.folderPath !== remote.folderPath) {
|
|
635
|
+
local.folderPath = remote.folderPath;
|
|
636
|
+
local.port = remote.port;
|
|
637
|
+
saveConfig(cfg);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (Array.isArray(hb.actions) && hb.actions.length > 0) {
|
|
643
|
+
await runActions(cfg, hb.actions);
|
|
644
|
+
}
|
|
645
|
+
} catch (err) {
|
|
646
|
+
warn(err instanceof Error ? err.message : String(err));
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
await tick();
|
|
651
|
+
setInterval(() => {
|
|
652
|
+
void tick();
|
|
653
|
+
}, HEARTBEAT_MS);
|
|
654
|
+
|
|
655
|
+
const shutdown = () => {
|
|
656
|
+
log("shutting down");
|
|
657
|
+
for (const id of [...children.keys()]) stopChild(id);
|
|
658
|
+
process.exit(0);
|
|
659
|
+
};
|
|
660
|
+
process.on("SIGINT", shutdown);
|
|
661
|
+
process.on("SIGTERM", shutdown);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
|