@algosuite/vo-mcp 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 +153 -0
- package/bin/vo-mcp +36 -0
- package/dist/autostart-cli.js +167 -0
- package/dist/autostart-cli.js.map +7 -0
- package/dist/cli.js +5730 -0
- package/dist/cli.js.map +7 -0
- package/dist/index.js +4968 -0
- package/dist/index.js.map +7 -0
- package/dist/install-cli.js +603 -0
- package/dist/install-cli.js.map +7 -0
- package/dist/login-cli.js +382 -0
- package/dist/login-cli.js.map +7 -0
- package/package.json +50 -0
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire as __cr } from 'module'; const require = __cr(import.meta.url);
|
|
3
|
+
|
|
4
|
+
// src/install.ts
|
|
5
|
+
import { homedir as homedir3, platform as platform2 } from "node:os";
|
|
6
|
+
import { join as join3, dirname as dirname2 } from "node:path";
|
|
7
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3, copyFileSync as copyFileSync2 } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
|
|
10
|
+
// src/cloud/login.ts
|
|
11
|
+
import { createServer } from "node:http";
|
|
12
|
+
import { randomBytes } from "node:crypto";
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
|
|
15
|
+
// src/cloud/credential-store.ts
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join, dirname } from "node:path";
|
|
18
|
+
import {
|
|
19
|
+
existsSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
readFileSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
chmodSync,
|
|
24
|
+
rmSync
|
|
25
|
+
} from "node:fs";
|
|
26
|
+
|
|
27
|
+
// src/cloud/keychain.ts
|
|
28
|
+
import { createRequire } from "node:module";
|
|
29
|
+
var SERVICE = "vo-mcp";
|
|
30
|
+
var ACCOUNT = "refresh-credential";
|
|
31
|
+
var cached;
|
|
32
|
+
function loadKeyring() {
|
|
33
|
+
if (cached !== void 0) return cached;
|
|
34
|
+
try {
|
|
35
|
+
const req = createRequire(import.meta.url);
|
|
36
|
+
const mod = req("@napi-rs/keyring");
|
|
37
|
+
cached = mod && typeof mod.Entry === "function" ? mod : null;
|
|
38
|
+
} catch {
|
|
39
|
+
cached = null;
|
|
40
|
+
}
|
|
41
|
+
return cached;
|
|
42
|
+
}
|
|
43
|
+
function keychainAvailable() {
|
|
44
|
+
return loadKeyring() !== null;
|
|
45
|
+
}
|
|
46
|
+
function keychainGet() {
|
|
47
|
+
const k = loadKeyring();
|
|
48
|
+
if (!k) return null;
|
|
49
|
+
try {
|
|
50
|
+
return new k.Entry(SERVICE, ACCOUNT).getPassword();
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function keychainSet(secret) {
|
|
56
|
+
const k = loadKeyring();
|
|
57
|
+
if (!k) return false;
|
|
58
|
+
try {
|
|
59
|
+
new k.Entry(SERVICE, ACCOUNT).setPassword(secret);
|
|
60
|
+
return true;
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function keychainDelete() {
|
|
66
|
+
const k = loadKeyring();
|
|
67
|
+
if (!k) return false;
|
|
68
|
+
try {
|
|
69
|
+
return new k.Entry(SERVICE, ACCOUNT).deletePassword();
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// src/cloud/credential-store.ts
|
|
76
|
+
var realKeychain = {
|
|
77
|
+
available: keychainAvailable,
|
|
78
|
+
get: keychainGet,
|
|
79
|
+
set: keychainSet,
|
|
80
|
+
delete: keychainDelete
|
|
81
|
+
};
|
|
82
|
+
var KEYCHAIN_LOCATION = 'OS keychain (service "vo-mcp")';
|
|
83
|
+
function credentialPath(env = process.env) {
|
|
84
|
+
const override = env["VO_MCP_CREDENTIALS_PATH"]?.trim();
|
|
85
|
+
if (override) return override;
|
|
86
|
+
return join(homedir(), ".config", "vo-mcp", "credentials.json");
|
|
87
|
+
}
|
|
88
|
+
function keychainEnabled(env, keychain) {
|
|
89
|
+
const disabled = (env["VO_MCP_DISABLE_KEYCHAIN"] ?? "").trim().toLowerCase();
|
|
90
|
+
if (disabled === "1" || disabled === "true" || disabled === "yes") return false;
|
|
91
|
+
return keychain.available();
|
|
92
|
+
}
|
|
93
|
+
function deleteFile(env) {
|
|
94
|
+
try {
|
|
95
|
+
rmSync(credentialPath(env), { force: true });
|
|
96
|
+
} catch {
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function writeToFile(payload, env) {
|
|
100
|
+
const p = credentialPath(env);
|
|
101
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
102
|
+
writeFileSync(p, `${JSON.stringify(payload, null, 2)}
|
|
103
|
+
`, { mode: 384 });
|
|
104
|
+
try {
|
|
105
|
+
chmodSync(p, 384);
|
|
106
|
+
} catch {
|
|
107
|
+
}
|
|
108
|
+
return p;
|
|
109
|
+
}
|
|
110
|
+
function writeStoredCredential(cred, storedAt, env = process.env, keychain = realKeychain) {
|
|
111
|
+
const payload = {
|
|
112
|
+
...cred.refresh_token ? { refresh_token: cred.refresh_token } : {},
|
|
113
|
+
...cred.api_key ? { api_key: cred.api_key } : {},
|
|
114
|
+
...cred.vo_credential ? { vo_credential: cred.vo_credential } : {},
|
|
115
|
+
...cred.vo_credential_expires_at ? { vo_credential_expires_at: cred.vo_credential_expires_at } : {},
|
|
116
|
+
...cred.email ? { email: cred.email } : {},
|
|
117
|
+
stored_at: cred.stored_at ?? storedAt
|
|
118
|
+
};
|
|
119
|
+
if (keychainEnabled(env, keychain) && keychain.set(JSON.stringify(payload))) {
|
|
120
|
+
deleteFile(env);
|
|
121
|
+
return KEYCHAIN_LOCATION;
|
|
122
|
+
}
|
|
123
|
+
const p = writeToFile(payload, env);
|
|
124
|
+
if (keychainEnabled(env, keychain)) keychain.delete();
|
|
125
|
+
return p;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// src/cloud/login.ts
|
|
129
|
+
var DEFAULT_DASHBOARD_URL = "https://vo-dashboard.web.app";
|
|
130
|
+
var DEFAULT_TIMEOUT_MS = 12e4;
|
|
131
|
+
var MAX_BODY_BYTES = 16384;
|
|
132
|
+
function processCapture(rawBody, expectedState, store) {
|
|
133
|
+
let data;
|
|
134
|
+
try {
|
|
135
|
+
data = JSON.parse(rawBody);
|
|
136
|
+
} catch {
|
|
137
|
+
return { ok: false, httpStatus: 400, error: "invalid JSON body" };
|
|
138
|
+
}
|
|
139
|
+
if (!data || typeof data !== "object") return { ok: false, httpStatus: 400, error: "invalid body" };
|
|
140
|
+
if (typeof data.state !== "string" || data.state !== expectedState) {
|
|
141
|
+
return { ok: false, httpStatus: 403, error: "state mismatch (possible CSRF) \u2014 login aborted" };
|
|
142
|
+
}
|
|
143
|
+
const refresh = typeof data.refresh_token === "string" ? data.refresh_token.trim() : "";
|
|
144
|
+
const apiKey = typeof data.api_key === "string" ? data.api_key.trim() : "";
|
|
145
|
+
if (!refresh || !apiKey) {
|
|
146
|
+
return { ok: false, httpStatus: 400, error: "login response missing refresh_token / api_key" };
|
|
147
|
+
}
|
|
148
|
+
const email = typeof data.email === "string" && data.email.trim() ? data.email.trim() : void 0;
|
|
149
|
+
const path = store({ refresh_token: refresh, api_key: apiKey, ...email ? { email } : {} });
|
|
150
|
+
return {
|
|
151
|
+
ok: true,
|
|
152
|
+
httpStatus: 200,
|
|
153
|
+
result: { ...email ? { email } : {}, credentialPath: path },
|
|
154
|
+
captured: { refresh_token: refresh, api_key: apiKey, ...email ? { email } : {} }
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function captureHtml() {
|
|
158
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>VO login</title></head>
|
|
159
|
+
<body style="font-family:system-ui;max-width:32rem;margin:4rem auto;text-align:center">
|
|
160
|
+
<h2 id="m">Completing sign-in\u2026</h2>
|
|
161
|
+
<script>
|
|
162
|
+
(function(){
|
|
163
|
+
var h=location.hash.replace(/^#/,''), p=new URLSearchParams(h), b={};
|
|
164
|
+
['state','refresh_token','api_key','email'].forEach(function(k){ if(p.get(k)) b[k]=p.get(k); });
|
|
165
|
+
fetch('/capture',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(b)})
|
|
166
|
+
.then(function(r){ document.getElementById('m').textContent = r.ok ? 'Sign-in complete \u2014 you can close this tab.' : 'Sign-in failed \u2014 check the terminal.'; })
|
|
167
|
+
.catch(function(){ document.getElementById('m').textContent = 'Sign-in failed \u2014 check the terminal.'; });
|
|
168
|
+
})();
|
|
169
|
+
</script></body></html>`;
|
|
170
|
+
}
|
|
171
|
+
function defaultOpenBrowser(url) {
|
|
172
|
+
const platform3 = process.platform;
|
|
173
|
+
if (platform3 === "win32") {
|
|
174
|
+
spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
|
|
175
|
+
} else if (platform3 === "darwin") {
|
|
176
|
+
spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
177
|
+
} else {
|
|
178
|
+
spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async function runLogin(opts = {}) {
|
|
182
|
+
const dashboardUrl = (opts.dashboardUrl ?? DEFAULT_DASHBOARD_URL).replace(/\/+$/, "");
|
|
183
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
184
|
+
const env = opts.env ?? process.env;
|
|
185
|
+
const log = opts.log ?? ((m) => console.error(m));
|
|
186
|
+
const nowIso = opts.nowIso ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
187
|
+
const openBrowser = opts.openBrowser ?? defaultOpenBrowser;
|
|
188
|
+
const state = randomBytes(32).toString("base64url");
|
|
189
|
+
return new Promise((resolve2, reject) => {
|
|
190
|
+
let settled = false;
|
|
191
|
+
const finish = (err, result) => {
|
|
192
|
+
if (settled) return;
|
|
193
|
+
settled = true;
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
server.close();
|
|
196
|
+
if (err) reject(err);
|
|
197
|
+
else resolve2(result);
|
|
198
|
+
};
|
|
199
|
+
const server = createServer((req, res) => {
|
|
200
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
201
|
+
if (req.method === "GET" && url.pathname === "/callback") {
|
|
202
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
203
|
+
res.end(captureHtml());
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (req.method === "POST" && url.pathname === "/capture") {
|
|
207
|
+
let body = "";
|
|
208
|
+
req.on("data", (chunk) => {
|
|
209
|
+
body += chunk.toString("utf8");
|
|
210
|
+
if (body.length > MAX_BODY_BYTES) req.destroy();
|
|
211
|
+
});
|
|
212
|
+
req.on("end", () => {
|
|
213
|
+
void (async () => {
|
|
214
|
+
if (body.length > MAX_BODY_BYTES) {
|
|
215
|
+
res.writeHead(413, { "content-type": "text/plain" });
|
|
216
|
+
res.end("payload too large");
|
|
217
|
+
finish(new Error("login request body exceeded the size cap"));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const writeNow = (cred) => writeStoredCredential(cred, nowIso(), env);
|
|
221
|
+
const outcome = processCapture(body, state, opts.exchange ? () => "pending" : writeNow);
|
|
222
|
+
let result = outcome.result;
|
|
223
|
+
if (outcome.ok && outcome.captured && opts.exchange) {
|
|
224
|
+
const capt = outcome.captured;
|
|
225
|
+
let cred = {
|
|
226
|
+
refresh_token: capt.refresh_token,
|
|
227
|
+
api_key: capt.api_key,
|
|
228
|
+
...capt.email ? { email: capt.email } : {}
|
|
229
|
+
};
|
|
230
|
+
try {
|
|
231
|
+
const voc = await opts.exchange(capt.refresh_token, capt.api_key);
|
|
232
|
+
if (voc && voc.vo_credential) {
|
|
233
|
+
cred = {
|
|
234
|
+
vo_credential: voc.vo_credential,
|
|
235
|
+
vo_credential_expires_at: voc.expires_at,
|
|
236
|
+
...capt.email ? { email: capt.email } : {}
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
}
|
|
241
|
+
const path = writeNow(cred);
|
|
242
|
+
result = { ...capt.email ? { email: capt.email } : {}, credentialPath: path };
|
|
243
|
+
}
|
|
244
|
+
res.writeHead(outcome.httpStatus, { "content-type": "text/html; charset=utf-8" });
|
|
245
|
+
res.end(outcome.ok ? "<h2>VO login complete \u2014 you can close this tab.</h2>" : `<h2>Login failed: ${outcome.error}</h2>`);
|
|
246
|
+
finish(outcome.ok ? null : new Error(outcome.error ?? "login failed"), result);
|
|
247
|
+
})();
|
|
248
|
+
});
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
res.writeHead(404);
|
|
252
|
+
res.end("not found");
|
|
253
|
+
});
|
|
254
|
+
const timer = setTimeout(
|
|
255
|
+
() => finish(new Error(`login timed out after ${timeoutMs}ms \u2014 no sign-in captured`)),
|
|
256
|
+
timeoutMs
|
|
257
|
+
);
|
|
258
|
+
server.on("error", (e) => finish(e));
|
|
259
|
+
server.listen(0, "127.0.0.1", () => {
|
|
260
|
+
const addr = server.address();
|
|
261
|
+
const port = addr && typeof addr === "object" ? addr.port : 0;
|
|
262
|
+
if (!port) {
|
|
263
|
+
finish(new Error("failed to bind a loopback port"));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const loginUrl = `${dashboardUrl}/cli-login?port=${port}&state=${encodeURIComponent(state)}`;
|
|
267
|
+
log(`[vo-mcp] Opening your browser to sign in:
|
|
268
|
+
${loginUrl}`);
|
|
269
|
+
log("[vo-mcp] If it did not open, paste that URL into your browser. Waiting for sign-in\u2026");
|
|
270
|
+
try {
|
|
271
|
+
openBrowser(loginUrl);
|
|
272
|
+
} catch {
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// src/cloud/auth-token-source.ts
|
|
279
|
+
var FIREBASE_SECURETOKEN_URL = "https://securetoken.googleapis.com/v1/token";
|
|
280
|
+
var REFRESH_SKEW_MS = 6e4;
|
|
281
|
+
function createFirebaseRefreshTokenSource(opts) {
|
|
282
|
+
const refreshToken = opts.refreshToken.trim();
|
|
283
|
+
const apiKey = opts.apiKey.trim();
|
|
284
|
+
const now = opts.now ?? (() => Date.now());
|
|
285
|
+
const fetchFn = opts.fetchFn ?? globalThis.fetch;
|
|
286
|
+
let cachedToken = null;
|
|
287
|
+
let expiresAtMs = 0;
|
|
288
|
+
let inFlight = null;
|
|
289
|
+
async function refresh() {
|
|
290
|
+
try {
|
|
291
|
+
const res = await fetchFn(`${FIREBASE_SECURETOKEN_URL}?key=${encodeURIComponent(apiKey)}`, {
|
|
292
|
+
method: "POST",
|
|
293
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
294
|
+
body: `grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`
|
|
295
|
+
});
|
|
296
|
+
const text = await res.text();
|
|
297
|
+
if (res.status < 200 || res.status >= 300) {
|
|
298
|
+
cachedToken = null;
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
const parsed = JSON.parse(text);
|
|
302
|
+
const idToken = typeof parsed.id_token === "string" ? parsed.id_token : "";
|
|
303
|
+
if (!idToken) {
|
|
304
|
+
cachedToken = null;
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
const expiresInSec = Number(parsed.expires_in);
|
|
308
|
+
const ttlMs = Number.isFinite(expiresInSec) && expiresInSec > 0 ? expiresInSec * 1e3 : 36e5;
|
|
309
|
+
cachedToken = idToken;
|
|
310
|
+
expiresAtMs = now() + ttlMs;
|
|
311
|
+
return idToken;
|
|
312
|
+
} catch {
|
|
313
|
+
cachedToken = null;
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
kind: "firebase-refresh",
|
|
319
|
+
async getToken() {
|
|
320
|
+
if (cachedToken && now() < expiresAtMs - REFRESH_SKEW_MS) return cachedToken;
|
|
321
|
+
if (!inFlight) {
|
|
322
|
+
inFlight = refresh().finally(() => {
|
|
323
|
+
inFlight = null;
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return inFlight;
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// src/cloud/vo-credential-exchange.ts
|
|
332
|
+
async function exchangeForVoCredential(opts) {
|
|
333
|
+
const base = opts.controlPlaneUrl.replace(/\/+$/, "");
|
|
334
|
+
if (!base) return null;
|
|
335
|
+
const fetchFn = opts.fetchFn ?? globalThis.fetch;
|
|
336
|
+
try {
|
|
337
|
+
const idToken = await createFirebaseRefreshTokenSource({
|
|
338
|
+
refreshToken: opts.refreshToken,
|
|
339
|
+
apiKey: opts.apiKey,
|
|
340
|
+
...opts.fetchFn ? { fetchFn: opts.fetchFn } : {}
|
|
341
|
+
}).getToken();
|
|
342
|
+
if (!idToken) return null;
|
|
343
|
+
const res = await fetchFn(`${base}/api/v1/auth/vo-credential`, {
|
|
344
|
+
method: "POST",
|
|
345
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${idToken}` },
|
|
346
|
+
body: JSON.stringify(opts.label ? { label: opts.label } : {})
|
|
347
|
+
});
|
|
348
|
+
if (res.status !== 200) return null;
|
|
349
|
+
const parsed = JSON.parse(await res.text());
|
|
350
|
+
const token = typeof parsed.token === "string" ? parsed.token : "";
|
|
351
|
+
const expiresAt = typeof parsed.expires_at === "string" ? parsed.expires_at : "";
|
|
352
|
+
if (!token.startsWith("vocred_") || !expiresAt) return null;
|
|
353
|
+
return { vo_credential: token, expires_at: expiresAt };
|
|
354
|
+
} catch {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// src/autostart.ts
|
|
360
|
+
import { homedir as homedir2, platform } from "node:os";
|
|
361
|
+
import { join as join2 } from "node:path";
|
|
362
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, readFileSync as readFileSync2, unlinkSync, copyFileSync } from "node:fs";
|
|
363
|
+
function resolveRunnerCommand(override) {
|
|
364
|
+
return override ?? "vo-mcp runner";
|
|
365
|
+
}
|
|
366
|
+
function installWindowsAutostart(runnerCommand, log, env) {
|
|
367
|
+
const appData = env["APPDATA"] ?? join2(homedir2(), "AppData", "Roaming");
|
|
368
|
+
const startupDir = join2(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup");
|
|
369
|
+
mkdirSync2(startupDir, { recursive: true });
|
|
370
|
+
const launcherPath = join2(startupDir, "vo-runner.cmd");
|
|
371
|
+
if (existsSync2(launcherPath)) {
|
|
372
|
+
const existing = readFileSync2(launcherPath, "utf8");
|
|
373
|
+
if (existing.includes("vo-mcp runner")) {
|
|
374
|
+
log(`\u2713 Auto-start is already configured (Windows Startup folder)`);
|
|
375
|
+
log(` Path: ${launcherPath}`);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const backupPath = `${launcherPath}.backup-${Date.now()}`;
|
|
379
|
+
copyFileSync(launcherPath, backupPath);
|
|
380
|
+
log(` Backed up existing launcher to: ${backupPath}`);
|
|
381
|
+
}
|
|
382
|
+
const launcherContent = `@echo off
|
|
383
|
+
REM Auto-start launcher for vo-mcp runner
|
|
384
|
+
REM Created by vo-mcp autostart installer
|
|
385
|
+
start /min cmd /c "${runnerCommand}"
|
|
386
|
+
`;
|
|
387
|
+
writeFileSync2(launcherPath, launcherContent, "utf8");
|
|
388
|
+
log(`\u2713 Installed Windows auto-start launcher`);
|
|
389
|
+
log(` Path: ${launcherPath}`);
|
|
390
|
+
log(` The runner will start minimized at next login.`);
|
|
391
|
+
}
|
|
392
|
+
async function installMacAutostart(runnerCommand, log) {
|
|
393
|
+
const launchAgentsDir = join2(homedir2(), "Library", "LaunchAgents");
|
|
394
|
+
mkdirSync2(launchAgentsDir, { recursive: true });
|
|
395
|
+
const plistPath = join2(launchAgentsDir, "ai.algosuite.vo-runner.plist");
|
|
396
|
+
if (existsSync2(plistPath)) {
|
|
397
|
+
const existing = readFileSync2(plistPath, "utf8");
|
|
398
|
+
if (existing.includes("vo-mcp runner")) {
|
|
399
|
+
log(`\u2713 Auto-start is already configured (launchd)`);
|
|
400
|
+
log(` Path: ${plistPath}`);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const backupPath = `${plistPath}.backup-${Date.now()}`;
|
|
404
|
+
copyFileSync(plistPath, backupPath);
|
|
405
|
+
log(` Backed up existing plist to: ${backupPath}`);
|
|
406
|
+
}
|
|
407
|
+
const parts = runnerCommand.split(/\s+/);
|
|
408
|
+
const program = parts[0] ?? "vo-mcp";
|
|
409
|
+
const args = parts.length > 1 ? parts.slice(1) : ["runner"];
|
|
410
|
+
const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
|
|
411
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
412
|
+
<plist version="1.0">
|
|
413
|
+
<dict>
|
|
414
|
+
<key>Label</key>
|
|
415
|
+
<string>ai.algosuite.vo-runner</string>
|
|
416
|
+
<key>ProgramArguments</key>
|
|
417
|
+
<array>
|
|
418
|
+
<string>${program}</string>
|
|
419
|
+
${args.map((a) => ` <string>${a}</string>`).join("\n")}
|
|
420
|
+
</array>
|
|
421
|
+
<key>RunAtLoad</key>
|
|
422
|
+
<true/>
|
|
423
|
+
<key>KeepAlive</key>
|
|
424
|
+
<true/>
|
|
425
|
+
<key>StandardOutPath</key>
|
|
426
|
+
<string>${join2(homedir2(), ".claude", "vo-runner.log")}</string>
|
|
427
|
+
<key>StandardErrorPath</key>
|
|
428
|
+
<string>${join2(homedir2(), ".claude", "vo-runner-error.log")}</string>
|
|
429
|
+
</dict>
|
|
430
|
+
</plist>
|
|
431
|
+
`;
|
|
432
|
+
writeFileSync2(plistPath, plistContent, "utf8");
|
|
433
|
+
log(`\u2713 Installed launchd plist`);
|
|
434
|
+
log(` Path: ${plistPath}`);
|
|
435
|
+
try {
|
|
436
|
+
const { execSync } = await import("node:child_process");
|
|
437
|
+
execSync(`launchctl load "${plistPath}"`, { stdio: "ignore" });
|
|
438
|
+
log(`\u2713 Loaded plist with launchctl (runner will start at next login)`);
|
|
439
|
+
log(` Logs: ${join2(homedir2(), ".claude", "vo-runner.log")}`);
|
|
440
|
+
} catch {
|
|
441
|
+
log(`\u26A0 Failed to load plist with launchctl (you may need to load it manually)`);
|
|
442
|
+
log(` Run: launchctl load "${plistPath}"`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
async function installAutostart(opts = {}) {
|
|
446
|
+
const log = opts.log ?? ((m) => console.error(m));
|
|
447
|
+
const env = opts.env ?? process.env;
|
|
448
|
+
const runnerCommand = resolveRunnerCommand(opts.runnerCommand);
|
|
449
|
+
const plat = platform();
|
|
450
|
+
if (plat === "win32") {
|
|
451
|
+
installWindowsAutostart(runnerCommand, log, env);
|
|
452
|
+
} else if (plat === "darwin") {
|
|
453
|
+
await installMacAutostart(runnerCommand, log);
|
|
454
|
+
} else {
|
|
455
|
+
log(`\u2717 Auto-start is not supported on platform: ${plat}`);
|
|
456
|
+
log(` Supported platforms: win32 (Windows), darwin (macOS)`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// src/install.ts
|
|
461
|
+
function resolveClaudeConfigPath() {
|
|
462
|
+
const plat = platform2();
|
|
463
|
+
const codeConfig = join3(homedir3(), ".claude.json");
|
|
464
|
+
if (existsSync3(codeConfig)) return codeConfig;
|
|
465
|
+
if (plat === "win32") {
|
|
466
|
+
const appData = process.env["APPDATA"] ?? join3(homedir3(), "AppData", "Roaming");
|
|
467
|
+
return join3(appData, "Claude", "claude_desktop_config.json");
|
|
468
|
+
}
|
|
469
|
+
if (plat === "darwin") {
|
|
470
|
+
return join3(homedir3(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
471
|
+
}
|
|
472
|
+
return join3(homedir3(), ".config", "claude", "claude_desktop_config.json");
|
|
473
|
+
}
|
|
474
|
+
function readClaudeConfig(path) {
|
|
475
|
+
try {
|
|
476
|
+
if (!existsSync3(path)) return { mcpServers: {} };
|
|
477
|
+
const raw = readFileSync3(path, "utf8");
|
|
478
|
+
const parsed = JSON.parse(raw);
|
|
479
|
+
return { mcpServers: parsed.mcpServers ?? {} };
|
|
480
|
+
} catch {
|
|
481
|
+
return { mcpServers: {} };
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
function writeClaudeConfig(path, config) {
|
|
485
|
+
mkdirSync3(dirname2(path), { recursive: true });
|
|
486
|
+
writeFileSync3(path, `${JSON.stringify(config, null, 2)}
|
|
487
|
+
`, "utf8");
|
|
488
|
+
}
|
|
489
|
+
function resolveVoMcpCliPath() {
|
|
490
|
+
const scriptPath = process.argv[1] ?? "";
|
|
491
|
+
if (scriptPath.includes("install-cli.js") || scriptPath.includes("install-cli")) {
|
|
492
|
+
return scriptPath.replace(/install-cli\.js$/, "cli.js").replace(/install-cli$/, "cli.js");
|
|
493
|
+
}
|
|
494
|
+
return resolve(dirname2(scriptPath), "cli.js");
|
|
495
|
+
}
|
|
496
|
+
function installMcpConfig(log, env) {
|
|
497
|
+
const configPath = resolveClaudeConfigPath();
|
|
498
|
+
const existing = readClaudeConfig(configPath);
|
|
499
|
+
const cliPath = resolveVoMcpCliPath();
|
|
500
|
+
const voEntry = existing.mcpServers?.["vo"] ?? existing.mcpServers?.["vo-mcp"];
|
|
501
|
+
if (voEntry && voEntry.args && voEntry.args.some((a) => a.includes("vo-mcp"))) {
|
|
502
|
+
log(`\u2713 vo-mcp MCP server is already configured at: ${configPath}`);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (existsSync3(configPath)) {
|
|
506
|
+
const backupPath = `${configPath}.backup-${Date.now()}`;
|
|
507
|
+
copyFileSync2(configPath, backupPath);
|
|
508
|
+
log(` Backed up existing config to: ${backupPath}`);
|
|
509
|
+
}
|
|
510
|
+
const controlPlaneUrl = env["VO_CONTROL_PLANE_URL"]?.trim() || DEFAULT_DASHBOARD_URL.replace("vo-dashboard.web.app", "vo-control-plane-bzjphrajaq-uc.a.run.app");
|
|
511
|
+
const merged = {
|
|
512
|
+
mcpServers: {
|
|
513
|
+
...existing.mcpServers,
|
|
514
|
+
"vo-mcp": {
|
|
515
|
+
command: "node",
|
|
516
|
+
args: [cliPath],
|
|
517
|
+
env: {
|
|
518
|
+
VO_CONTROL_PLANE_URL: controlPlaneUrl
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
writeClaudeConfig(configPath, merged);
|
|
524
|
+
log(`\u2713 Wrote vo-mcp MCP server config to: ${configPath}`);
|
|
525
|
+
}
|
|
526
|
+
async function runLoginFlow(log, env) {
|
|
527
|
+
log("\n\u2501\u2501\u2501 Step 2: Link your Claude account \u2501\u2501\u2501");
|
|
528
|
+
log("We'll open your browser to sign in, then mint a scoped credential.");
|
|
529
|
+
log("Your raw token never persists locally (it's exchanged for a vocred_).\n");
|
|
530
|
+
const controlPlaneUrl = env["VO_CONTROL_PLANE_URL"]?.trim() || "https://vo-control-plane-bzjphrajaq-uc.a.run.app";
|
|
531
|
+
const exchange = async (refreshToken, apiKey) => {
|
|
532
|
+
return exchangeForVoCredential({
|
|
533
|
+
refreshToken,
|
|
534
|
+
apiKey,
|
|
535
|
+
controlPlaneUrl,
|
|
536
|
+
label: `vo-mcp install (${platform2()})`
|
|
537
|
+
});
|
|
538
|
+
};
|
|
539
|
+
try {
|
|
540
|
+
const result = await runLogin({ env, log, exchange });
|
|
541
|
+
log(`\u2713 Signed in${result.email ? ` as ${result.email}` : ""}. Credential stored at: ${result.credentialPath}`);
|
|
542
|
+
} catch (err) {
|
|
543
|
+
log(`\u2717 Login failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
544
|
+
log(" You can retry later by running: vo-mcp login");
|
|
545
|
+
throw err;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
function printNextSteps(log, autostartInstalled) {
|
|
549
|
+
log("\n\u2501\u2501\u2501 Installation complete! \u2501\u2501\u2501\n");
|
|
550
|
+
log("What's configured:");
|
|
551
|
+
log(" \u2713 Claude Desktop / Claude Code will load vo-mcp on next restart");
|
|
552
|
+
log(" \u2713 Your scoped credential is stored (revocable via the dashboard)");
|
|
553
|
+
if (autostartInstalled) {
|
|
554
|
+
log(" \u2713 Runner daemon will start automatically at login\n");
|
|
555
|
+
} else {
|
|
556
|
+
log("\n");
|
|
557
|
+
}
|
|
558
|
+
log("Next steps:");
|
|
559
|
+
log(" 1. Restart Claude Desktop / Claude Code (if running).");
|
|
560
|
+
if (autostartInstalled) {
|
|
561
|
+
log(" 2. Log out and back in (or start the runner manually now: vo-mcp runner)");
|
|
562
|
+
} else {
|
|
563
|
+
log(" 2. Start the agent runner in a terminal (keep it running):");
|
|
564
|
+
log(" vo-mcp runner");
|
|
565
|
+
log(" (To set up auto-start at login: vo-mcp runner --install-autostart)");
|
|
566
|
+
}
|
|
567
|
+
log(" 3. Visit the VO dashboard to dispatch your first agent:");
|
|
568
|
+
log(" https://vo-dashboard.web.app\n");
|
|
569
|
+
log("The runner watches for tasks you dispatch and spins up agents in fresh worktrees.");
|
|
570
|
+
log("Agents only run while the runner is connected. Ctrl+C to stop it anytime.\n");
|
|
571
|
+
}
|
|
572
|
+
async function install(opts = {}) {
|
|
573
|
+
const log = opts.log ?? ((m) => console.error(m));
|
|
574
|
+
const env = opts.env ?? process.env;
|
|
575
|
+
log("\u2501\u2501\u2501 vo-mcp installer \u2501\u2501\u2501");
|
|
576
|
+
log("This will set up your machine to dispatch VO agents from anywhere.\n");
|
|
577
|
+
log("\u2501\u2501\u2501 Step 1: Configure Claude Desktop / Claude Code \u2501\u2501\u2501");
|
|
578
|
+
installMcpConfig(log, env);
|
|
579
|
+
if (!opts.skipLogin) {
|
|
580
|
+
await runLoginFlow(log, env);
|
|
581
|
+
} else {
|
|
582
|
+
log("\n(Login skipped \u2014 run `vo-mcp login` manually when ready.)");
|
|
583
|
+
}
|
|
584
|
+
let autostartInstalled = false;
|
|
585
|
+
if (!opts.skipAutostart) {
|
|
586
|
+
const plat = platform2();
|
|
587
|
+
if (plat === "win32" || plat === "darwin") {
|
|
588
|
+
log("\n\u2501\u2501\u2501 Step 3: Set up auto-start \u2501\u2501\u2501");
|
|
589
|
+
log("Would you like the runner daemon to start automatically at login?");
|
|
590
|
+
log("(You can skip this and set it up later with: vo-mcp runner --install-autostart)\n");
|
|
591
|
+
await installAutostart({ log, env });
|
|
592
|
+
autostartInstalled = true;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
printNextSteps(log, autostartInstalled);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// src/install-cli.ts
|
|
599
|
+
install().catch((err) => {
|
|
600
|
+
console.error("[vo-mcp install] fatal:", err);
|
|
601
|
+
process.exit(1);
|
|
602
|
+
});
|
|
603
|
+
//# sourceMappingURL=install-cli.js.map
|