@kyleroche/relay 0.2.0 → 0.2.1
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/relay-mcp.js +193 -11
- package/dist/relay.js +447 -22
- package/package.json +3 -3
package/dist/relay-mcp.js
CHANGED
|
@@ -4231,9 +4231,9 @@ import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as
|
|
|
4231
4231
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
4232
4232
|
import { dirname as dirname3, join as join7 } from "path";
|
|
4233
4233
|
var AFTER_APPLY = {
|
|
4234
|
-
10: async (db,
|
|
4234
|
+
10: async (db, log2) => {
|
|
4235
4235
|
const n = await backfillTaskState(db);
|
|
4236
|
-
if (n)
|
|
4236
|
+
if (n) log2(`schema: cached the state of ${n} task${n === 1 ? "" : "s"}`);
|
|
4237
4237
|
}
|
|
4238
4238
|
};
|
|
4239
4239
|
var beside = join7(dirname3(fileURLToPath2(import.meta.url)), "migrations");
|
|
@@ -4264,12 +4264,12 @@ async function currentVersion(db) {
|
|
|
4264
4264
|
return typeof v === "number" ? v : typeof v === "string" ? Number(v) || 0 : 0;
|
|
4265
4265
|
}
|
|
4266
4266
|
async function migrate(db, opts = {}) {
|
|
4267
|
-
const
|
|
4267
|
+
const log2 = opts.log ?? noopLog;
|
|
4268
4268
|
const have = await currentVersion(db);
|
|
4269
4269
|
let applied = have;
|
|
4270
4270
|
for (const m of loadMigrations(opts.dir)) {
|
|
4271
4271
|
if (m.version <= have) continue;
|
|
4272
|
-
|
|
4272
|
+
log2(`schema: applying ${String(m.version).padStart(3, "0")}_${m.name}`);
|
|
4273
4273
|
await db.transaction(async (tx) => {
|
|
4274
4274
|
await tx.exec(m.sql);
|
|
4275
4275
|
await tx.query(
|
|
@@ -4278,7 +4278,7 @@ async function migrate(db, opts = {}) {
|
|
|
4278
4278
|
[JSON.stringify(m.version)]
|
|
4279
4279
|
);
|
|
4280
4280
|
});
|
|
4281
|
-
await AFTER_APPLY[m.version]?.(db,
|
|
4281
|
+
await AFTER_APPLY[m.version]?.(db, log2);
|
|
4282
4282
|
applied = m.version;
|
|
4283
4283
|
}
|
|
4284
4284
|
return applied;
|
|
@@ -5134,6 +5134,11 @@ var defaultActor = () => `human:${process.env.RELAY_ACTOR ?? process.env.USER ??
|
|
|
5134
5134
|
async function refreshSiteTokenIfStale(cfg, now = Date.now()) {
|
|
5135
5135
|
if (!cfg.site || !cfg.siteToken || !cfg.siteRefreshToken || !tokenIsStale(cfg.siteTokenExpiresAt, now)) return cfg;
|
|
5136
5136
|
if (cfg.siteTokenSource === "env") return cfg;
|
|
5137
|
+
return refreshSiteToken(cfg, now);
|
|
5138
|
+
}
|
|
5139
|
+
var canRefreshSiteToken = (cfg) => !!cfg.site && !!cfg.siteRefreshToken && cfg.siteTokenSource !== "env";
|
|
5140
|
+
async function refreshSiteToken(cfg, now = Date.now()) {
|
|
5141
|
+
if (!cfg.site || !cfg.siteRefreshToken) throw new SiteModeError("refreshing the site token", `no refresh token is stored; run \`relay setup --site ${cfg.site ?? "<url>"}\` to approve this machine again`);
|
|
5137
5142
|
try {
|
|
5138
5143
|
const r = await refreshAccessToken({ site: cfg.site, refreshToken: cfg.siteRefreshToken });
|
|
5139
5144
|
const c = credentialsFromToken(r, now);
|
|
@@ -5480,6 +5485,9 @@ var IDLE_HEARTBEAT_MS = 5 * 6e4;
|
|
|
5480
5485
|
// ../core/src/work/service.ts
|
|
5481
5486
|
import { join as join12 } from "path";
|
|
5482
5487
|
|
|
5488
|
+
// ../core/src/work/login.ts
|
|
5489
|
+
var LOGIN_TIMEOUT_MS = 5 * 6e4;
|
|
5490
|
+
|
|
5483
5491
|
// ../core/src/work/container.ts
|
|
5484
5492
|
var CONTAINER_HOME = "/home/relay";
|
|
5485
5493
|
var CONTAINER_RELAY_HOME = `${CONTAINER_HOME}/.harness`;
|
|
@@ -6092,7 +6100,7 @@ function parseUploadKey(key) {
|
|
|
6092
6100
|
return { project: project === "_" ? null : project, id, filename };
|
|
6093
6101
|
}
|
|
6094
6102
|
async function finalizeFromKey(db, store, input) {
|
|
6095
|
-
const
|
|
6103
|
+
const log2 = input.log ?? (() => {
|
|
6096
6104
|
});
|
|
6097
6105
|
const parsed = parseUploadKey(input.key);
|
|
6098
6106
|
if (!parsed) throw new IngestError(`not an upload key: ${input.key}`);
|
|
@@ -6106,7 +6114,7 @@ async function finalizeFromKey(db, store, input) {
|
|
|
6106
6114
|
}
|
|
6107
6115
|
const probe = await probeBytes(bytes, parsed.filename);
|
|
6108
6116
|
const prefix = `${parsed.project ?? "_"}/${parsed.id}`;
|
|
6109
|
-
const thumbKey = await storeThumbnail(store, prefix, probe, bytes, parsed.filename, { thumbSize: input.thumbSize, log });
|
|
6117
|
+
const thumbKey = await storeThumbnail(store, prefix, probe, bytes, parsed.filename, { thumbSize: input.thumbSize, log: log2 });
|
|
6110
6118
|
try {
|
|
6111
6119
|
return await recordRow(db, { id: parsed.id, input, probe, storageKey: input.key, thumbKey, filename: parsed.filename });
|
|
6112
6120
|
} catch (e) {
|
|
@@ -6144,6 +6152,148 @@ import { join as join21 } from "path";
|
|
|
6144
6152
|
import { readFileSync as readFileSync14 } from "fs";
|
|
6145
6153
|
import { spawnSync } from "child_process";
|
|
6146
6154
|
|
|
6155
|
+
// ../mcp/src/bridge.ts
|
|
6156
|
+
import { isInitializeRequest, isJSONRPCRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
6157
|
+
var BRIDGE_UNREACHABLE = -32e3;
|
|
6158
|
+
var BRIDGE_UNAUTHORIZED = -32001;
|
|
6159
|
+
function parseMcpBody(text2, contentType) {
|
|
6160
|
+
const t = text2.trim();
|
|
6161
|
+
if (!t) return [];
|
|
6162
|
+
if (/text\/event-stream/i.test(contentType ?? "") || !t.startsWith("{") && !t.startsWith("[")) {
|
|
6163
|
+
const out = [];
|
|
6164
|
+
for (const event of t.split(/\r?\n\r?\n/)) {
|
|
6165
|
+
const data = event.split(/\r?\n/).filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()).join("\n");
|
|
6166
|
+
if (!data) continue;
|
|
6167
|
+
const parsed2 = JSON.parse(data);
|
|
6168
|
+
out.push(...Array.isArray(parsed2) ? parsed2 : [parsed2]);
|
|
6169
|
+
}
|
|
6170
|
+
return out;
|
|
6171
|
+
}
|
|
6172
|
+
const parsed = JSON.parse(t);
|
|
6173
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
6174
|
+
}
|
|
6175
|
+
function errorOf(text2) {
|
|
6176
|
+
try {
|
|
6177
|
+
const j = JSON.parse(text2);
|
|
6178
|
+
if (j && typeof j.error?.message === "string") return { message: j.error.message, ...j.error.data !== void 0 ? { data: j.error.data } : {} };
|
|
6179
|
+
} catch {
|
|
6180
|
+
}
|
|
6181
|
+
return void 0;
|
|
6182
|
+
}
|
|
6183
|
+
var describe3 = (e) => e instanceof Error ? e.message : String(e);
|
|
6184
|
+
function createSiteBridge(opts) {
|
|
6185
|
+
const site = normalizeSite(opts.site);
|
|
6186
|
+
const doFetch = opts.fetch ?? fetch;
|
|
6187
|
+
let protocolVersion;
|
|
6188
|
+
let sessionId;
|
|
6189
|
+
const post2 = (msg, token) => {
|
|
6190
|
+
const headers = {
|
|
6191
|
+
authorization: `Bearer ${token}`,
|
|
6192
|
+
"content-type": "application/json",
|
|
6193
|
+
accept: "application/json, text/event-stream",
|
|
6194
|
+
"user-agent": "relay-mcp"
|
|
6195
|
+
};
|
|
6196
|
+
if (protocolVersion && !isInitializeRequest(msg)) headers["mcp-protocol-version"] = protocolVersion;
|
|
6197
|
+
if (sessionId) headers["mcp-session-id"] = sessionId;
|
|
6198
|
+
return doFetch(`${site}/mcp`, { method: "POST", headers, body: JSON.stringify(msg) });
|
|
6199
|
+
};
|
|
6200
|
+
return {
|
|
6201
|
+
site,
|
|
6202
|
+
async forward(msg) {
|
|
6203
|
+
const id = isJSONRPCRequest(msg) ? msg.id : void 0;
|
|
6204
|
+
const fail2 = (code, message2, data) => id === void 0 ? [] : [{ jsonrpc: "2.0", id, error: { code, message: message2, ...data !== void 0 ? { data } : {} } }];
|
|
6205
|
+
let token;
|
|
6206
|
+
try {
|
|
6207
|
+
token = await opts.auth.token();
|
|
6208
|
+
} catch (e) {
|
|
6209
|
+
return fail2(BRIDGE_UNAUTHORIZED, `relay-mcp: no usable token for ${site}: ${describe3(e)}`);
|
|
6210
|
+
}
|
|
6211
|
+
let res;
|
|
6212
|
+
let renewFailure = "";
|
|
6213
|
+
try {
|
|
6214
|
+
res = await post2(msg, token);
|
|
6215
|
+
if (res.status === 401) {
|
|
6216
|
+
let next;
|
|
6217
|
+
try {
|
|
6218
|
+
next = await opts.auth.renew(token);
|
|
6219
|
+
} catch (e) {
|
|
6220
|
+
renewFailure = describe3(e);
|
|
6221
|
+
}
|
|
6222
|
+
if (next) {
|
|
6223
|
+
opts.log?.(`${site} refused the token (401); renewed it and retrying`);
|
|
6224
|
+
res = await post2(msg, next);
|
|
6225
|
+
}
|
|
6226
|
+
}
|
|
6227
|
+
} catch (e) {
|
|
6228
|
+
return fail2(BRIDGE_UNREACHABLE, `relay-mcp: cannot reach ${site}/mcp: ${describe3(e)}`);
|
|
6229
|
+
}
|
|
6230
|
+
const text2 = await res.text();
|
|
6231
|
+
const sid = res.headers.get("mcp-session-id");
|
|
6232
|
+
if (sid) sessionId = sid;
|
|
6233
|
+
if (res.status === 401) {
|
|
6234
|
+
const err = errorOf(text2);
|
|
6235
|
+
opts.log?.(`${site} refused the token (401)${renewFailure ? `; renewing it failed: ${renewFailure}` : ""}`);
|
|
6236
|
+
return fail2(BRIDGE_UNAUTHORIZED, `${err?.message ?? `relay-mcp: ${site} refused the token (401)`}${renewFailure ? ` Renewing the token failed: ${renewFailure}` : ""}`, err?.data);
|
|
6237
|
+
}
|
|
6238
|
+
if (!res.ok) {
|
|
6239
|
+
const err = errorOf(text2);
|
|
6240
|
+
return fail2(BRIDGE_UNREACHABLE, err?.message ?? `relay-mcp: ${site}/mcp answered HTTP ${res.status}${text2.trim() ? `: ${text2.trim().slice(0, 200)}` : ""}`, err?.data);
|
|
6241
|
+
}
|
|
6242
|
+
let out;
|
|
6243
|
+
try {
|
|
6244
|
+
out = parseMcpBody(text2, res.headers.get("content-type"));
|
|
6245
|
+
} catch (e) {
|
|
6246
|
+
return fail2(BRIDGE_UNREACHABLE, `relay-mcp: unreadable answer from ${site}/mcp: ${describe3(e)}`);
|
|
6247
|
+
}
|
|
6248
|
+
if (isInitializeRequest(msg)) {
|
|
6249
|
+
for (const m of out) {
|
|
6250
|
+
const v = m.result?.protocolVersion;
|
|
6251
|
+
if (typeof v === "string") protocolVersion = v;
|
|
6252
|
+
}
|
|
6253
|
+
}
|
|
6254
|
+
return out;
|
|
6255
|
+
}
|
|
6256
|
+
};
|
|
6257
|
+
}
|
|
6258
|
+
async function serveSiteBridge(transport, bridge, onError) {
|
|
6259
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
6260
|
+
transport.onmessage = (msg) => {
|
|
6261
|
+
const p = bridge.forward(msg).then(async (out) => {
|
|
6262
|
+
for (const m of out) await transport.send(m);
|
|
6263
|
+
}).catch((e) => onError?.(e)).finally(() => inFlight.delete(p));
|
|
6264
|
+
inFlight.add(p);
|
|
6265
|
+
};
|
|
6266
|
+
transport.onerror = (e) => onError?.(e);
|
|
6267
|
+
await transport.start();
|
|
6268
|
+
return {
|
|
6269
|
+
/** Resolves once every message received so far has been answered (stdin closed: finish, then exit). */
|
|
6270
|
+
async drain() {
|
|
6271
|
+
while (inFlight.size) await Promise.allSettled([...inFlight]);
|
|
6272
|
+
}
|
|
6273
|
+
};
|
|
6274
|
+
}
|
|
6275
|
+
var tokenLabel = (cfg) => cfg.siteTokenName ?? { device: "an unnamed device-flow token", invite: "an unnamed invite token", manual: "a pasted token", env: "RELAY_TOKEN" }[cfg.siteTokenSource ?? "manual"];
|
|
6276
|
+
function siteAuth(initial) {
|
|
6277
|
+
let cfg = initial;
|
|
6278
|
+
const reload = () => {
|
|
6279
|
+
const fresh = loadConfig();
|
|
6280
|
+
return fresh.site === cfg.site && fresh.siteToken ? fresh : cfg;
|
|
6281
|
+
};
|
|
6282
|
+
return {
|
|
6283
|
+
async token() {
|
|
6284
|
+
cfg = await refreshSiteTokenIfStale(reload());
|
|
6285
|
+
return cfg.siteToken;
|
|
6286
|
+
},
|
|
6287
|
+
async renew(rejected) {
|
|
6288
|
+
cfg = reload();
|
|
6289
|
+
if (cfg.siteToken && cfg.siteToken !== rejected) return cfg.siteToken;
|
|
6290
|
+
if (!canRefreshSiteToken(cfg)) return void 0;
|
|
6291
|
+
cfg = await refreshSiteToken(cfg);
|
|
6292
|
+
return cfg.siteToken;
|
|
6293
|
+
}
|
|
6294
|
+
};
|
|
6295
|
+
}
|
|
6296
|
+
|
|
6147
6297
|
// ../mcp/src/server.ts
|
|
6148
6298
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6149
6299
|
import { z } from "zod";
|
|
@@ -6849,8 +6999,34 @@ ${p.summary}` : head);
|
|
|
6849
6999
|
}
|
|
6850
7000
|
|
|
6851
7001
|
// ../mcp/src/stdio.ts
|
|
6852
|
-
|
|
6853
|
-
|
|
7002
|
+
var log = (m) => console.error(`relay-mcp: ${m}`);
|
|
7003
|
+
var describe4 = (e) => e instanceof Error ? e.message : String(e);
|
|
7004
|
+
async function serveSite(cfg) {
|
|
7005
|
+
const site = cfg.site;
|
|
7006
|
+
try {
|
|
7007
|
+
cfg = await refreshSiteTokenIfStale(cfg);
|
|
7008
|
+
} catch (e) {
|
|
7009
|
+
log(describe4(e));
|
|
7010
|
+
}
|
|
7011
|
+
const bridge = createSiteBridge({ site, auth: siteAuth(cfg), log });
|
|
7012
|
+
const transport = new StdioServerTransport();
|
|
7013
|
+
const served = await serveSiteBridge(transport, bridge, (e) => log(describe4(e)));
|
|
7014
|
+
let closing = false;
|
|
7015
|
+
const finish = async () => {
|
|
7016
|
+
if (closing) return;
|
|
7017
|
+
closing = true;
|
|
7018
|
+
await served.drain();
|
|
7019
|
+
await transport.close().catch(() => {
|
|
7020
|
+
});
|
|
7021
|
+
process.exit(0);
|
|
7022
|
+
};
|
|
7023
|
+
process.stdin.on("end", () => void finish());
|
|
7024
|
+
process.on("SIGINT", () => void finish());
|
|
7025
|
+
process.on("SIGTERM", () => void finish());
|
|
7026
|
+
log(`site mode, forwarding to ${site} as ${tokenLabel(cfg)}`);
|
|
7027
|
+
}
|
|
7028
|
+
async function serveLocal(cfg) {
|
|
7029
|
+
const h = await openHarness(cfg, { local: true });
|
|
6854
7030
|
await migrate(h.db);
|
|
6855
7031
|
const embed = briefEmbedder(h);
|
|
6856
7032
|
const actor = `agent:${process.env.RELAY_MCP_CLIENT ?? "mcp"}`;
|
|
@@ -6879,10 +7055,16 @@ async function main() {
|
|
|
6879
7055
|
process.on("SIGINT", onSignal);
|
|
6880
7056
|
process.on("SIGTERM", onSignal);
|
|
6881
7057
|
await server.connect(transport);
|
|
6882
|
-
|
|
7058
|
+
log(`local mode, ${h.db.driver === "pg" ? "postgres via DATABASE_URL" : cfg.dbFile}`);
|
|
7059
|
+
}
|
|
7060
|
+
async function main() {
|
|
7061
|
+
const cfg = loadConfig();
|
|
7062
|
+
ensureHome(cfg);
|
|
7063
|
+
if (cfg.site && cfg.siteToken) await serveSite(cfg);
|
|
7064
|
+
else await serveLocal(cfg);
|
|
6883
7065
|
}
|
|
6884
7066
|
main().catch((e) => {
|
|
6885
|
-
console.error(`
|
|
7067
|
+
console.error(`relay-mcp: ${e instanceof Error ? e.stack ?? e.message : String(e)}`);
|
|
6886
7068
|
process.exit(1);
|
|
6887
7069
|
});
|
|
6888
7070
|
export {
|
package/dist/relay.js
CHANGED
|
@@ -5343,6 +5343,10 @@ var defaultActor = () => `human:${process.env.RELAY_ACTOR ?? process.env.USER ??
|
|
|
5343
5343
|
async function refreshSiteTokenIfStale(cfg, now = Date.now()) {
|
|
5344
5344
|
if (!cfg.site || !cfg.siteToken || !cfg.siteRefreshToken || !tokenIsStale(cfg.siteTokenExpiresAt, now)) return cfg;
|
|
5345
5345
|
if (cfg.siteTokenSource === "env") return cfg;
|
|
5346
|
+
return refreshSiteToken(cfg, now);
|
|
5347
|
+
}
|
|
5348
|
+
async function refreshSiteToken(cfg, now = Date.now()) {
|
|
5349
|
+
if (!cfg.site || !cfg.siteRefreshToken) throw new SiteModeError("refreshing the site token", `no refresh token is stored; run \`relay setup --site ${cfg.site ?? "<url>"}\` to approve this machine again`);
|
|
5346
5350
|
try {
|
|
5347
5351
|
const r = await refreshAccessToken({ site: cfg.site, refreshToken: cfg.siteRefreshToken });
|
|
5348
5352
|
const c = credentialsFromToken(r, now);
|
|
@@ -5911,6 +5915,8 @@ var run = (bin, args, opts = {}) => new Promise((res) => {
|
|
|
5911
5915
|
{ cwd: opts.cwd, env: opts.env ?? cleanEnv(), timeout: opts.timeoutMs ?? 12e4, maxBuffer: 16 * 1024 * 1024 },
|
|
5912
5916
|
(err, stdout, stderr) => res({ code: err && "code" in err && typeof err.code === "number" ? err.code : err ? 1 : 0, stdout: String(stdout), stderr: String(stderr) })
|
|
5913
5917
|
);
|
|
5918
|
+
child.stdin?.on("error", () => {
|
|
5919
|
+
});
|
|
5914
5920
|
child.stdin?.end(opts.input ?? "");
|
|
5915
5921
|
});
|
|
5916
5922
|
async function must(bin, args, opts = {}) {
|
|
@@ -6363,6 +6369,78 @@ async function runWorker(opts) {
|
|
|
6363
6369
|
// ../core/src/work/service.ts
|
|
6364
6370
|
import { join as join12 } from "path";
|
|
6365
6371
|
|
|
6372
|
+
// ../core/src/work/login.ts
|
|
6373
|
+
var LOGIN_PROVIDERS = ["claude", "gh", "codex", "grok"];
|
|
6374
|
+
var isLoginProvider = (s) => LOGIN_PROVIDERS.includes(s);
|
|
6375
|
+
var LOGIN_TIMEOUT_MS = 5 * 6e4;
|
|
6376
|
+
var fxLoginWords = (provider) => ["env", "FX_NO_OPEN_BROWSER=1", "fx", "login", provider];
|
|
6377
|
+
var URL_RE = /https?:\/\/[^\s"'<>)\]]+/g;
|
|
6378
|
+
function parseLoginUrl(text) {
|
|
6379
|
+
for (const m of text.matchAll(URL_RE)) {
|
|
6380
|
+
const raw = m[0].replace(/[.,;:!?]+$/, "");
|
|
6381
|
+
let u;
|
|
6382
|
+
try {
|
|
6383
|
+
u = new URL(raw);
|
|
6384
|
+
} catch {
|
|
6385
|
+
continue;
|
|
6386
|
+
}
|
|
6387
|
+
const redirect = u.searchParams.get("redirect_uri");
|
|
6388
|
+
if (!redirect) continue;
|
|
6389
|
+
let r;
|
|
6390
|
+
try {
|
|
6391
|
+
r = new URL(redirect);
|
|
6392
|
+
} catch {
|
|
6393
|
+
continue;
|
|
6394
|
+
}
|
|
6395
|
+
if (!/^(localhost|127\.0\.0\.1|\[::1\])$/.test(r.hostname)) continue;
|
|
6396
|
+
const port = r.port ? Number(r.port) : r.protocol === "https:" ? 443 : 80;
|
|
6397
|
+
if (!Number.isInteger(port) || port <= 0) continue;
|
|
6398
|
+
const state = u.searchParams.get("state");
|
|
6399
|
+
return { url: raw, redirectUri: redirect, host: r.hostname, port, path: r.pathname || "/", ...state ? { state } : {} };
|
|
6400
|
+
}
|
|
6401
|
+
return void 0;
|
|
6402
|
+
}
|
|
6403
|
+
function parseLoginCallback(requestUrl) {
|
|
6404
|
+
const u = new URL(requestUrl, "http://127.0.0.1");
|
|
6405
|
+
const q2 = u.searchParams;
|
|
6406
|
+
const out = {};
|
|
6407
|
+
const code = q2.get("code");
|
|
6408
|
+
const state = q2.get("state");
|
|
6409
|
+
const error = q2.get("error");
|
|
6410
|
+
const desc = q2.get("error_description");
|
|
6411
|
+
if (code) out.code = code;
|
|
6412
|
+
if (state) out.state = state;
|
|
6413
|
+
if (error) out.error = error;
|
|
6414
|
+
if (desc) out.errorDescription = desc;
|
|
6415
|
+
return out;
|
|
6416
|
+
}
|
|
6417
|
+
function replayUrl(redirect, requestUrl) {
|
|
6418
|
+
const u = new URL(requestUrl, "http://127.0.0.1");
|
|
6419
|
+
return `http://${redirect.host}:${redirect.port}${redirect.path}${u.search}`;
|
|
6420
|
+
}
|
|
6421
|
+
function loginDonePage(provider, ok, detail) {
|
|
6422
|
+
const title = ok ? `${provider}: signed in` : `${provider}: sign-in failed`;
|
|
6423
|
+
const body = ok ? "relay has the code and is finishing the login inside the container. You can close this tab." : `${detail ?? "the provider returned an error"}. Go back to the terminal.`;
|
|
6424
|
+
const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
6425
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>${esc(title)}</title><style>body{font:16px/1.5 -apple-system,system-ui,sans-serif;margin:3rem auto;max-width:36rem;padding:0 1rem;color:#222}h1{font-size:1.25rem}</style></head><body><h1>${esc(title)}</h1><p>${esc(body)}</p></body></html>
|
|
6426
|
+
`;
|
|
6427
|
+
}
|
|
6428
|
+
function manualLoginSteps(provider) {
|
|
6429
|
+
switch (provider) {
|
|
6430
|
+
case "claude":
|
|
6431
|
+
return ["relay work container shell", "claude # then /login; open the URL it prints on this Mac, paste the code back, then /exit"];
|
|
6432
|
+
case "gh":
|
|
6433
|
+
return ["relay work container shell", "gh auth login --git-protocol https --web && gh auth setup-git # the device code prints here; enter it at github.com/login/device"];
|
|
6434
|
+
case "codex":
|
|
6435
|
+
return ["relay work container shell", "fx login codex # open the printed URL on this Mac; the redirect to localhost:1455 reaches the container through its published port"];
|
|
6436
|
+
case "grok":
|
|
6437
|
+
return [
|
|
6438
|
+
"relay work container shell",
|
|
6439
|
+
"FX_NO_OPEN_BROWSER=1 fx login grok # open the printed URL on this Mac; when the browser ends on an unreachable 127.0.0.1:<port>/callback?state=\u2026&code=\u2026 page, paste only the code value into fx's prompt (the whole address fails; each code is single-use)"
|
|
6440
|
+
];
|
|
6441
|
+
}
|
|
6442
|
+
}
|
|
6443
|
+
|
|
6366
6444
|
// ../core/src/work/container.ts
|
|
6367
6445
|
var CONTAINER_NAME = "relay-work";
|
|
6368
6446
|
var CONTAINER_VOLUME = "relay-work-home";
|
|
@@ -6387,6 +6465,7 @@ var LOGIN_CALLBACK_PORTS = [FX_CALLBACK_PORT, CODEX_CALLBACK_PORT];
|
|
|
6387
6465
|
var LOGIN_PORTS_ENV = "RELAY_LOGIN_PORTS";
|
|
6388
6466
|
var CONTAINER_CPUS = 4;
|
|
6389
6467
|
var execWords = (bin, interactive) => [bin, "exec", "--user", CONTAINER_USER, ...interactive ? ["-it"] : [], CONTAINER_NAME];
|
|
6468
|
+
var execStdinWords = (bin) => [bin, "exec", "--user", CONTAINER_USER, "-i", CONTAINER_NAME];
|
|
6390
6469
|
function containerPlan(s) {
|
|
6391
6470
|
const exec = (argv, interactive = false) => [...execWords(s.bin, interactive), ...argv];
|
|
6392
6471
|
const env = {
|
|
@@ -6439,17 +6518,26 @@ function containerPlan(s) {
|
|
|
6439
6518
|
doctor: exec(["relay", "doctor"]),
|
|
6440
6519
|
credentials: exec(["test", "-f", CONTAINER_CREDENTIALS]),
|
|
6441
6520
|
logs: (follow, lines) => [s.bin, "logs", ...follow ? ["--follow"] : [], ...lines ? ["-n", String(lines)] : [], CONTAINER_NAME],
|
|
6442
|
-
exec
|
|
6521
|
+
exec,
|
|
6522
|
+
login: {
|
|
6523
|
+
fx: (provider) => [...execStdinWords(s.bin), ...fxLoginWords(provider)],
|
|
6524
|
+
claude: exec(["claude"], true),
|
|
6525
|
+
gh: exec(["gh", "auth", "login", "--git-protocol", "https", "--web"], true),
|
|
6526
|
+
ghSetupGit: exec(["gh", "auth", "setup-git"]),
|
|
6527
|
+
replay: (url) => exec(["curl", "-fsS", "-o", "/dev/null", url])
|
|
6528
|
+
}
|
|
6443
6529
|
};
|
|
6444
6530
|
}
|
|
6531
|
+
var CONTAINER_LOGIN_COMMAND = "relay work container login all";
|
|
6532
|
+
var CONTAINER_LOGIN_COMMAND_WHY = "one command; opens this Mac's browser for each login and skips the ones already in the volume (claude, gh, then the optional codex and grok)";
|
|
6445
6533
|
var CONTAINER_LOGIN_STEPS = [
|
|
6446
6534
|
{ cmd: "relay work container shell", why: "a shell inside the container, as the relay user" },
|
|
6447
|
-
{ cmd: "claude", why: "then /login: the OAuth paste-code flow (open the URL on this Mac, paste the code back)", login: "claude" },
|
|
6448
|
-
{ cmd: "gh auth login && gh auth setup-git", why: "the account the worker clones, pushes and opens PRs as", login: "gh" },
|
|
6535
|
+
{ cmd: "claude", why: "then /login: the OAuth paste-code flow (open the URL on this Mac, paste the code back, /exit)", login: "claude" },
|
|
6536
|
+
{ cmd: "gh auth login && gh auth setup-git", why: "the account the worker clones, pushes and opens PRs as (device code)", login: "gh" },
|
|
6449
6537
|
{ cmd: "fx login codex", why: "optional: the codex lane (the browser's redirect to localhost:1455 reaches the container through the published port)", login: "codex" },
|
|
6450
6538
|
{
|
|
6451
|
-
cmd: "fx login
|
|
6452
|
-
why: "optional: the grok lane; open the printed
|
|
6539
|
+
cmd: "FX_NO_OPEN_BROWSER=1 fx login grok",
|
|
6540
|
+
why: "optional: the grok lane; open the printed URL on this Mac, and when the browser ends on an unreachable 127.0.0.1:<port>/callback?\u2026&code=\u2026 page, paste only the code value into fx's prompt",
|
|
6453
6541
|
login: "grok"
|
|
6454
6542
|
},
|
|
6455
6543
|
{ cmd: "exit", why: "then on the Mac: relay work container status" }
|
|
@@ -8783,9 +8871,11 @@ ${p.cli}${p.detected ? "" : " (not detected)"}`);
|
|
|
8783
8871
|
}
|
|
8784
8872
|
for (const n of p.notes) console.log(` note: ${n}`);
|
|
8785
8873
|
}
|
|
8874
|
+
const mcpNote = siteCfg.site ? `memory MCP server: forwards to ${siteCfg.site} in site mode; local database otherwise` : "memory MCP server: local database (no site configured; it forwards to the site once relay setup --site <url> has run)";
|
|
8786
8875
|
console.log(`
|
|
8787
8876
|
hook scripts: ${join23(RELAY_ROOT, "packages", "cli", "hooks")}
|
|
8788
8877
|
MCP entry: ${join23(RELAY_ROOT, "packages", "mcp", "bin", "relay-mcp.mjs")}
|
|
8878
|
+
${mcpNote}
|
|
8789
8879
|
node: ${ctx.node}`);
|
|
8790
8880
|
const major = Number(process.versions.node.split(".")[0]);
|
|
8791
8881
|
if (major < 24) console.log(`note: hooks will run with Node ${process.versions.node}; relay pins Node 24. Run this through \`pnpm relay setup\` so the pinned binary is recorded instead.`);
|
|
@@ -8856,7 +8946,7 @@ async function connectSite(o) {
|
|
|
8856
8946
|
} else log2(`check: ${probe.detail}`);
|
|
8857
8947
|
const paths = writeSiteConfig({ site, token: credential });
|
|
8858
8948
|
log2(`credential saved to ${paths.credentialsPath} (mode 0600, source: ${credential.source})`);
|
|
8859
|
-
log2(`site mode on: ${site} (${paths.configPath}); memory, feed, token, invite, task and webhook commands and the
|
|
8949
|
+
log2(`site mode on: ${site} (${paths.configPath}); memory, feed, token, invite, task and webhook commands, the hooks and the stdio MCP server now use the site. relay link still digests locally and uploads.`);
|
|
8860
8950
|
}
|
|
8861
8951
|
|
|
8862
8952
|
// src/commands/link.ts
|
|
@@ -9581,14 +9671,139 @@ function registerWebhook(program2) {
|
|
|
9581
9671
|
}
|
|
9582
9672
|
|
|
9583
9673
|
// src/commands/work.ts
|
|
9584
|
-
import { execFileSync as
|
|
9674
|
+
import { execFileSync as execFileSync6, spawn, spawnSync as spawnSync2 } from "child_process";
|
|
9585
9675
|
import { appendFileSync as appendFileSync2, copyFileSync, existsSync as existsSync19, mkdirSync as mkdirSync15, readFileSync as readFileSync18, rmSync as rmSync7, writeFileSync as writeFileSync11 } from "fs";
|
|
9676
|
+
import { createServer } from "http";
|
|
9586
9677
|
import { homedir as homedir10, hostname as hostname2 } from "os";
|
|
9587
9678
|
import { dirname as dirname8, join as join27 } from "path";
|
|
9588
9679
|
|
|
9589
9680
|
// src/commands/probes.ts
|
|
9590
|
-
import { execFile as execFile8 } from "child_process";
|
|
9591
|
-
import { join as join26 } from "path";
|
|
9681
|
+
import { execFile as execFile8, execFileSync as execFileSync5 } from "child_process";
|
|
9682
|
+
import { delimiter as delimiter5, join as join26 } from "path";
|
|
9683
|
+
|
|
9684
|
+
// ../mcp/src/server.ts
|
|
9685
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
9686
|
+
import { z } from "zod";
|
|
9687
|
+
var ALL_SCOPES = [...TOKEN_SCOPES];
|
|
9688
|
+
|
|
9689
|
+
// ../mcp/src/http.ts
|
|
9690
|
+
import { timingSafeEqual as timingSafeEqual4 } from "crypto";
|
|
9691
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
9692
|
+
|
|
9693
|
+
// ../mcp/src/bridge.ts
|
|
9694
|
+
import { isInitializeRequest, isJSONRPCRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
9695
|
+
var BRIDGE_UNREACHABLE = -32e3;
|
|
9696
|
+
var BRIDGE_UNAUTHORIZED = -32001;
|
|
9697
|
+
function parseMcpBody(text, contentType) {
|
|
9698
|
+
const t = text.trim();
|
|
9699
|
+
if (!t) return [];
|
|
9700
|
+
if (/text\/event-stream/i.test(contentType ?? "") || !t.startsWith("{") && !t.startsWith("[")) {
|
|
9701
|
+
const out = [];
|
|
9702
|
+
for (const event of t.split(/\r?\n\r?\n/)) {
|
|
9703
|
+
const data = event.split(/\r?\n/).filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()).join("\n");
|
|
9704
|
+
if (!data) continue;
|
|
9705
|
+
const parsed2 = JSON.parse(data);
|
|
9706
|
+
out.push(...Array.isArray(parsed2) ? parsed2 : [parsed2]);
|
|
9707
|
+
}
|
|
9708
|
+
return out;
|
|
9709
|
+
}
|
|
9710
|
+
const parsed = JSON.parse(t);
|
|
9711
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
9712
|
+
}
|
|
9713
|
+
function errorOf(text) {
|
|
9714
|
+
try {
|
|
9715
|
+
const j = JSON.parse(text);
|
|
9716
|
+
if (j && typeof j.error?.message === "string") return { message: j.error.message, ...j.error.data !== void 0 ? { data: j.error.data } : {} };
|
|
9717
|
+
} catch {
|
|
9718
|
+
}
|
|
9719
|
+
return void 0;
|
|
9720
|
+
}
|
|
9721
|
+
var describe3 = (e) => e instanceof Error ? e.message : String(e);
|
|
9722
|
+
function createSiteBridge(opts) {
|
|
9723
|
+
const site = normalizeSite(opts.site);
|
|
9724
|
+
const doFetch = opts.fetch ?? fetch;
|
|
9725
|
+
let protocolVersion;
|
|
9726
|
+
let sessionId;
|
|
9727
|
+
const post2 = (msg, token) => {
|
|
9728
|
+
const headers = {
|
|
9729
|
+
authorization: `Bearer ${token}`,
|
|
9730
|
+
"content-type": "application/json",
|
|
9731
|
+
accept: "application/json, text/event-stream",
|
|
9732
|
+
"user-agent": "relay-mcp"
|
|
9733
|
+
};
|
|
9734
|
+
if (protocolVersion && !isInitializeRequest(msg)) headers["mcp-protocol-version"] = protocolVersion;
|
|
9735
|
+
if (sessionId) headers["mcp-session-id"] = sessionId;
|
|
9736
|
+
return doFetch(`${site}/mcp`, { method: "POST", headers, body: JSON.stringify(msg) });
|
|
9737
|
+
};
|
|
9738
|
+
return {
|
|
9739
|
+
site,
|
|
9740
|
+
async forward(msg) {
|
|
9741
|
+
const id = isJSONRPCRequest(msg) ? msg.id : void 0;
|
|
9742
|
+
const fail = (code, message2, data) => id === void 0 ? [] : [{ jsonrpc: "2.0", id, error: { code, message: message2, ...data !== void 0 ? { data } : {} } }];
|
|
9743
|
+
let token;
|
|
9744
|
+
try {
|
|
9745
|
+
token = await opts.auth.token();
|
|
9746
|
+
} catch (e) {
|
|
9747
|
+
return fail(BRIDGE_UNAUTHORIZED, `relay-mcp: no usable token for ${site}: ${describe3(e)}`);
|
|
9748
|
+
}
|
|
9749
|
+
let res;
|
|
9750
|
+
let renewFailure = "";
|
|
9751
|
+
try {
|
|
9752
|
+
res = await post2(msg, token);
|
|
9753
|
+
if (res.status === 401) {
|
|
9754
|
+
let next;
|
|
9755
|
+
try {
|
|
9756
|
+
next = await opts.auth.renew(token);
|
|
9757
|
+
} catch (e) {
|
|
9758
|
+
renewFailure = describe3(e);
|
|
9759
|
+
}
|
|
9760
|
+
if (next) {
|
|
9761
|
+
opts.log?.(`${site} refused the token (401); renewed it and retrying`);
|
|
9762
|
+
res = await post2(msg, next);
|
|
9763
|
+
}
|
|
9764
|
+
}
|
|
9765
|
+
} catch (e) {
|
|
9766
|
+
return fail(BRIDGE_UNREACHABLE, `relay-mcp: cannot reach ${site}/mcp: ${describe3(e)}`);
|
|
9767
|
+
}
|
|
9768
|
+
const text = await res.text();
|
|
9769
|
+
const sid = res.headers.get("mcp-session-id");
|
|
9770
|
+
if (sid) sessionId = sid;
|
|
9771
|
+
if (res.status === 401) {
|
|
9772
|
+
const err = errorOf(text);
|
|
9773
|
+
opts.log?.(`${site} refused the token (401)${renewFailure ? `; renewing it failed: ${renewFailure}` : ""}`);
|
|
9774
|
+
return fail(BRIDGE_UNAUTHORIZED, `${err?.message ?? `relay-mcp: ${site} refused the token (401)`}${renewFailure ? ` Renewing the token failed: ${renewFailure}` : ""}`, err?.data);
|
|
9775
|
+
}
|
|
9776
|
+
if (!res.ok) {
|
|
9777
|
+
const err = errorOf(text);
|
|
9778
|
+
return fail(BRIDGE_UNREACHABLE, err?.message ?? `relay-mcp: ${site}/mcp answered HTTP ${res.status}${text.trim() ? `: ${text.trim().slice(0, 200)}` : ""}`, err?.data);
|
|
9779
|
+
}
|
|
9780
|
+
let out;
|
|
9781
|
+
try {
|
|
9782
|
+
out = parseMcpBody(text, res.headers.get("content-type"));
|
|
9783
|
+
} catch (e) {
|
|
9784
|
+
return fail(BRIDGE_UNREACHABLE, `relay-mcp: unreadable answer from ${site}/mcp: ${describe3(e)}`);
|
|
9785
|
+
}
|
|
9786
|
+
if (isInitializeRequest(msg)) {
|
|
9787
|
+
for (const m of out) {
|
|
9788
|
+
const v = m.result?.protocolVersion;
|
|
9789
|
+
if (typeof v === "string") protocolVersion = v;
|
|
9790
|
+
}
|
|
9791
|
+
}
|
|
9792
|
+
return out;
|
|
9793
|
+
}
|
|
9794
|
+
};
|
|
9795
|
+
}
|
|
9796
|
+
async function listSiteTools(opts) {
|
|
9797
|
+
const bridge = createSiteBridge({ site: opts.site, auth: { token: async () => opts.token, renew: async () => void 0 }, ...opts.fetch ? { fetch: opts.fetch } : {} });
|
|
9798
|
+
const [answer] = await bridge.forward({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} });
|
|
9799
|
+
if (!answer) throw new Error(`${bridge.site}/mcp returned no answer to tools/list`);
|
|
9800
|
+
if ("error" in answer) throw new Error(answer.error.message);
|
|
9801
|
+
const tools = (answer.result?.tools ?? []).map((t) => t.name);
|
|
9802
|
+
return { tools };
|
|
9803
|
+
}
|
|
9804
|
+
var tokenLabel = (cfg) => cfg.siteTokenName ?? { device: "an unnamed device-flow token", invite: "an unnamed invite token", manual: "a pasted token", env: "RELAY_TOKEN" }[cfg.siteTokenSource ?? "manual"];
|
|
9805
|
+
|
|
9806
|
+
// src/commands/probes.ts
|
|
9592
9807
|
var PROBE_TOOLS = ["claude", "codex", "fx", "grok", "gh"];
|
|
9593
9808
|
function run3(bin, args, timeoutMs = 15e3) {
|
|
9594
9809
|
return new Promise((res) => {
|
|
@@ -9664,6 +9879,31 @@ async function sandboxCheck(platform = process.platform) {
|
|
|
9664
9879
|
const fix = inContainer() ? "the container is the boundary: the Claude lane runs with the allowlist alone, advertised as sandbox:container (recreate the container with `relay work --isolation container --install` to get the runtime flags it needs)" : "Claude jobs fail here (relay work never runs them unsandboxed); the kernel must allow unprivileged user namespaces";
|
|
9665
9880
|
return { status: "warn", name: "sandbox", detail: `bubblewrap and socat on PATH but ${probe.detail}; ${fix}` };
|
|
9666
9881
|
}
|
|
9882
|
+
function pathCheck(opts = {}) {
|
|
9883
|
+
const find = opts.onPath ?? onPath2;
|
|
9884
|
+
const found = find("relay");
|
|
9885
|
+
if (found) return { status: "ok", name: "path", detail: `relay on PATH: ${found}` };
|
|
9886
|
+
const prefix = (opts.npmPrefix ?? defaultNpmPrefix)();
|
|
9887
|
+
const bin = prefix ? join26(prefix, "bin") : void 0;
|
|
9888
|
+
const shell = opts.shell ?? process.env.SHELL ?? "";
|
|
9889
|
+
const rc = shell.endsWith("zsh") ? "~/.zshrc" : shell.endsWith("fish") ? "~/.config/fish/config.fish" : "~/.bashrc";
|
|
9890
|
+
const running = opts.running ?? process.argv[1] ?? "";
|
|
9891
|
+
const viaNpx = /\/_npx\//.test(running);
|
|
9892
|
+
const install = viaNpx ? "you are running through npx, which installs no command; run `npm install -g @kyleroche/relay`, then " : "";
|
|
9893
|
+
const pathDirs = (opts.pathEnv ?? process.env.PATH ?? "").split(delimiter5).filter(Boolean);
|
|
9894
|
+
if (bin && pathDirs.includes(bin)) {
|
|
9895
|
+
return { status: "warn", name: "path", detail: `relay is not installed as a command: npm install -g @kyleroche/relay (a checkout uses its bin/relay)` };
|
|
9896
|
+
}
|
|
9897
|
+
const fix = bin ? `${install}add npm's bin directory to PATH: echo 'export PATH="${bin}:$PATH"' >> ${rc} and open a new shell` : `${install}add the directory \`npm prefix -g\`/bin prints to PATH in ${rc} and open a new shell`;
|
|
9898
|
+
return { status: "warn", name: "path", detail: `relay is not on PATH; ${fix}` };
|
|
9899
|
+
}
|
|
9900
|
+
function defaultNpmPrefix() {
|
|
9901
|
+
try {
|
|
9902
|
+
return execFileSync5("npm", ["prefix", "-g"], { encoding: "utf8", timeout: 5e3 }).trim() || void 0;
|
|
9903
|
+
} catch {
|
|
9904
|
+
return void 0;
|
|
9905
|
+
}
|
|
9906
|
+
}
|
|
9667
9907
|
async function containerReport(opts = {}) {
|
|
9668
9908
|
const bin = onPath2("container");
|
|
9669
9909
|
const out = { serviceRunning: false, container: { exists: false, running: false } };
|
|
@@ -9689,7 +9929,7 @@ function containerCheck(r) {
|
|
|
9689
9929
|
if (!r.serviceRunning) return { status: "warn", name, detail: `${r.bin} installed, services not running; run: container system start` };
|
|
9690
9930
|
if (!r.container.exists) return { status: "ok", name, detail: `installed and running; no ${CONTAINER_NAME} container yet (relay work --isolation container --install)` };
|
|
9691
9931
|
if (!r.container.running) return { status: "warn", name, detail: `${CONTAINER_NAME} exists (${r.container.state ?? "stopped"}), not running; run: relay work container start` };
|
|
9692
|
-
const lanes = r.lanes === void 0 ? "lanes unknown (relay doctor inside gave no lanes line)" : r.lanes.length ? `serving ${r.lanes.join(", ")} inside` : "no lanes inside yet (log in: relay work container
|
|
9932
|
+
const lanes = r.lanes === void 0 ? "lanes unknown (relay doctor inside gave no lanes line)" : r.lanes.length ? `serving ${r.lanes.join(", ")} inside` : "no lanes inside yet (log in: relay work container login all)";
|
|
9693
9933
|
return { status: "ok", name, detail: `${CONTAINER_NAME} running${r.container.image ? ` (${r.container.image})` : ""}; ${lanes}` };
|
|
9694
9934
|
}
|
|
9695
9935
|
function fmtAge(ms) {
|
|
@@ -9731,8 +9971,22 @@ function lanesFromChecks(checks) {
|
|
|
9731
9971
|
return { lanes, reasons };
|
|
9732
9972
|
}
|
|
9733
9973
|
var detectLanes = async () => lanesFromChecks(await probeTools());
|
|
9974
|
+
async function mcpCheck(cfg, opts) {
|
|
9975
|
+
const name = "mcp";
|
|
9976
|
+
if (!(cfg.site && cfg.siteToken)) return { status: "ok", name, detail: `local mode: ${opts.entry} serves ${cfg.databaseUrl ? "postgres via DATABASE_URL" : `pglite at ${cfg.dbFile}`}` };
|
|
9977
|
+
const now = opts.now ?? Date.now;
|
|
9978
|
+
const t0 = now();
|
|
9979
|
+
const where = `site mode: ${opts.entry} forwards to ${cfg.site} as ${tokenLabel(cfg)}`;
|
|
9980
|
+
try {
|
|
9981
|
+
const { tools } = await (opts.listTools ?? listSiteTools)({ site: cfg.site, token: cfg.siteToken });
|
|
9982
|
+
return { status: "ok", name, detail: `${where}; tools/list answered with ${tools.length} tools, ${now() - t0} ms` };
|
|
9983
|
+
} catch (e) {
|
|
9984
|
+
return { status: "fail", name, detail: `${where}; tools/list failed: ${(e instanceof Error ? e.message : String(e)).slice(0, 200)}` };
|
|
9985
|
+
}
|
|
9986
|
+
}
|
|
9734
9987
|
|
|
9735
9988
|
// src/commands/work.ts
|
|
9989
|
+
var defaultSpawn = (words) => spawn(words[0], words.slice(1), { stdio: ["pipe", "pipe", "pipe"] });
|
|
9736
9990
|
var LANES = ["claude", "codex", "grok"];
|
|
9737
9991
|
var list = (s) => (s ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
9738
9992
|
var workLogPath = () => join27(relayHome(), "work.log");
|
|
@@ -9783,7 +10037,7 @@ function plan(o, deps, containerBin) {
|
|
|
9783
10037
|
var defaultDeps = {
|
|
9784
10038
|
sh(words, opts) {
|
|
9785
10039
|
try {
|
|
9786
|
-
return { ok: true, out:
|
|
10040
|
+
return { ok: true, out: execFileSync6(words[0], words.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: opts?.timeoutMs ?? 3e4 }).trim() };
|
|
9787
10041
|
} catch (e) {
|
|
9788
10042
|
const err = e;
|
|
9789
10043
|
const out = [err.stderr, err.stdout].map((s) => (s ?? "").toString().trim()).filter(Boolean).join("\n");
|
|
@@ -9833,7 +10087,7 @@ async function waitForReadiness(out, forced, signal) {
|
|
|
9833
10087
|
const d = forced ? { lanes: forced, reasons: [] } : await detectLanes();
|
|
9834
10088
|
const missing = [];
|
|
9835
10089
|
if (!site.site || !site.token) missing.push("no site yet: on the Mac run `relay work --isolation container --install`, or inside: relay setup --site <url>");
|
|
9836
|
-
if (!d.lanes.length) missing.push(`no lanes yet: ${d.reasons.join("; ")}; log in
|
|
10090
|
+
if (!d.lanes.length) missing.push(`no lanes yet: ${d.reasons.join("; ")}; log in from the Mac with: ${CONTAINER_LOGIN_COMMAND}`);
|
|
9837
10091
|
if (!missing.length) return d.lanes;
|
|
9838
10092
|
const msg = `waiting: ${missing.join(" | ")}`;
|
|
9839
10093
|
if (msg !== said) out(msg);
|
|
@@ -9952,7 +10206,7 @@ function registerWork(program2, version2, deps = defaultDeps) {
|
|
|
9952
10206
|
}
|
|
9953
10207
|
});
|
|
9954
10208
|
const container = work.command("container").enablePositionalOptions().description(`The ${CONTAINER_NAME} container (relay work --isolation container): a shell inside, its status, stop/start/rm, logs (docs/WORKERS.md "Isolation")`);
|
|
9955
|
-
container.command("shell").description(`a shell inside ${CONTAINER_NAME} as the relay user (container exec -it);
|
|
10209
|
+
container.command("shell").description(`a shell inside ${CONTAINER_NAME} as the relay user (container exec -it); the logins by hand happen here (relay work container login does them for you)`).action(() => {
|
|
9956
10210
|
const c = needContainer();
|
|
9957
10211
|
process.exitCode = deps.run(c.plan.shell);
|
|
9958
10212
|
});
|
|
@@ -9965,8 +10219,7 @@ function registerWork(program2, version2, deps = defaultDeps) {
|
|
|
9965
10219
|
if (!state.running) return console.log("start it with: relay work container start");
|
|
9966
10220
|
const doctor = deps.sh(c.plan.doctor, { timeoutMs: 12e4 }).out;
|
|
9967
10221
|
if (doctor) console.log(doctor.replace(/^/gm, " "));
|
|
9968
|
-
|
|
9969
|
-
console.log(lanes === void 0 ? "lanes: unknown (no lanes line from relay doctor inside)" : lanes.length ? `lanes: ${lanes.join(", ")} (the container worker serves these)` : "lanes: none yet; log in with: relay work container shell");
|
|
10222
|
+
console.log(lanesLine(doctor));
|
|
9970
10223
|
});
|
|
9971
10224
|
container.command("stop").description(`stop ${CONTAINER_NAME} (and unload the service that keeps it running, so it stays stopped)`).action(() => {
|
|
9972
10225
|
const c = needContainer();
|
|
@@ -10008,6 +10261,172 @@ function registerWork(program2, version2, deps = defaultDeps) {
|
|
|
10008
10261
|
process.exitCode = deps.run(c.plan.logs(!!o.follow, Number(o.n) || void 0));
|
|
10009
10262
|
});
|
|
10010
10263
|
container.command("update").description(`rebuild the ${CONTAINER_IMAGE} image from this relay and recreate ${CONTAINER_NAME} on it with today's flags; the ${CONTAINER_VOLUME} volume, its site token and its logins stay. Run after git pull or a package upgrade; prints the plan, --yes does it`).option("--site <url>", "the hosted relay to join inside, only when the volume holds no site token (default: the site this machine is in)").option("--invite <code>", "redeem an invite inside instead of the device flow, only when the volume holds no site token").option("--memory <size>", "the VM's memory (default: what the container has now)").option("--cpus <n>", "the VM's CPUs (default: what the container has now)").option("--yes", "rebuild the image and recreate the container").action((o) => containerInstall({ jobs: "1", interval: String(POLL_MS / 1e3), maxMinutes: "60", sandbox: true, ...o }, "update"));
|
|
10264
|
+
container.command("login [provider]").description(
|
|
10265
|
+
`make the vendor logins inside ${CONTAINER_NAME} from this Mac: ${LOGIN_PROVIDERS.join(", ")} or all (default). The ones relay doctor inside already reports are skipped. codex and grok: fx's authorize URL opens in this Mac's browser and the redirect is caught here; claude and gh: the CLI runs in this terminal (paste a code, device code)`
|
|
10266
|
+
).option("--force", "log in again even when the volume already holds that login").option("--no-open", "print each authorize URL instead of opening this Mac's browser").option("--timeout <seconds>", "how long to wait for each browser login", String(LOGIN_TIMEOUT_MS / 1e3)).action((provider, o) => containerLogin(provider ?? "all", o));
|
|
10267
|
+
function lanesLine(doctor) {
|
|
10268
|
+
const lanes = lanesFromDoctorOutput(doctor);
|
|
10269
|
+
if (lanes === void 0) return "lanes: unknown (no lanes line from relay doctor inside)";
|
|
10270
|
+
return lanes.length ? `lanes: ${lanes.join(", ")} (the container worker serves these)` : `lanes: none yet; log in with: ${CONTAINER_LOGIN_COMMAND}`;
|
|
10271
|
+
}
|
|
10272
|
+
async function containerLogin(which, o) {
|
|
10273
|
+
const c = needContainer();
|
|
10274
|
+
if (which !== "all" && !isLoginProvider(which)) throw new Error(`login: '${which}' is not one of ${[...LOGIN_PROVIDERS, "all"].join(", ")}`);
|
|
10275
|
+
const wanted = which === "all" ? [...LOGIN_PROVIDERS] : [which];
|
|
10276
|
+
const timeoutMs = Math.max(1, Number(o.timeout) || LOGIN_TIMEOUT_MS / 1e3) * 1e3;
|
|
10277
|
+
const state = containerStateFrom(deps.sh([c.bin, "list", "--all", "--format", "json"]).out);
|
|
10278
|
+
if (!state.exists) throw new Error(`no ${CONTAINER_NAME} container; create it with: relay work --isolation container --install --yes`);
|
|
10279
|
+
if (!state.running) throw new Error(`${CONTAINER_NAME} is not running; start it with: relay work container start`);
|
|
10280
|
+
const have = loginStatesFromDoctorOutput(deps.sh(c.plan.doctor, { timeoutMs: 12e4 }).out);
|
|
10281
|
+
const results = [];
|
|
10282
|
+
for (const p of wanted) {
|
|
10283
|
+
if (have[p] && !o.force) {
|
|
10284
|
+
console.log(`${p.padEnd(7)} already in the volume (relay doctor inside); skipped (--force logs in again)`);
|
|
10285
|
+
results.push({ provider: p, outcome: "skipped" });
|
|
10286
|
+
continue;
|
|
10287
|
+
}
|
|
10288
|
+
const r = p === "claude" || p === "gh" ? ttyLogin(c.plan, p) : await oauthLogin(c.plan, p, { open: o.open, timeoutMs });
|
|
10289
|
+
results.push(r);
|
|
10290
|
+
console.log(`${p.padEnd(7)} ${r.outcome === "ok" ? "signed in" : `FAILED: ${r.detail ?? ""}`}${r.outcome === "ok" && r.detail ? ` (${r.detail})` : ""}`);
|
|
10291
|
+
if (r.outcome === "failed" && r.manual) {
|
|
10292
|
+
console.log(" by hand:");
|
|
10293
|
+
for (const step of manualLoginSteps(p)) console.log(` ${step}`);
|
|
10294
|
+
}
|
|
10295
|
+
}
|
|
10296
|
+
const after2 = deps.sh(c.plan.doctor, { timeoutMs: 12e4 }).out;
|
|
10297
|
+
const now = loginStatesFromDoctorOutput(after2);
|
|
10298
|
+
console.log(`logins: ${LOGIN_PROVIDERS.map((p) => `${p} ${now[p] ? "ok" : "missing"}`).join(", ")} (relay doctor inside)`);
|
|
10299
|
+
console.log(lanesLine(after2));
|
|
10300
|
+
if (results.some((r) => r.outcome === "failed")) process.exitCode = 1;
|
|
10301
|
+
}
|
|
10302
|
+
function ttyLogin(p, provider) {
|
|
10303
|
+
if (provider === "claude") {
|
|
10304
|
+
console.log(`claude ${p.login.claude.join(" ")}`);
|
|
10305
|
+
console.log(" type /login, open the URL it prints on this Mac, paste the code back, then /exit");
|
|
10306
|
+
const code2 = deps.run(p.login.claude);
|
|
10307
|
+
return code2 === 0 ? { provider, outcome: "ok" } : { provider, outcome: "failed", detail: `claude exited ${code2}`, manual: true };
|
|
10308
|
+
}
|
|
10309
|
+
console.log(`gh ${p.login.gh.join(" ")}`);
|
|
10310
|
+
console.log(" the device code prints below; enter it at github.com/login/device on this Mac");
|
|
10311
|
+
const code = deps.run(p.login.gh);
|
|
10312
|
+
if (code !== 0) return { provider, outcome: "failed", detail: `gh auth login exited ${code}`, manual: true };
|
|
10313
|
+
const r = deps.sh(p.login.ghSetupGit, { timeoutMs: 6e4 });
|
|
10314
|
+
if (!r.ok) return { provider, outcome: "failed", detail: `gh auth setup-git failed: ${r.out.split("\n")[0] ?? ""}`, manual: true };
|
|
10315
|
+
return { provider, outcome: "ok", detail: "gh auth setup-git done" };
|
|
10316
|
+
}
|
|
10317
|
+
function oauthLogin(p, provider, o) {
|
|
10318
|
+
const words = p.login.fx(provider);
|
|
10319
|
+
const published = deps.publishedPorts ?? LOGIN_CALLBACK_PORTS;
|
|
10320
|
+
const pad = " ";
|
|
10321
|
+
console.log(`${provider.padEnd(7)} ${words.join(" ")}`);
|
|
10322
|
+
return new Promise((resolve5) => {
|
|
10323
|
+
const child = (deps.spawn ?? defaultSpawn)(words);
|
|
10324
|
+
let text = "";
|
|
10325
|
+
let pending = "";
|
|
10326
|
+
let redirect;
|
|
10327
|
+
let server;
|
|
10328
|
+
let settled = false;
|
|
10329
|
+
let handedOver = false;
|
|
10330
|
+
const flush = () => {
|
|
10331
|
+
if (pending.trim()) console.log(`${pad}fx ${pending}`);
|
|
10332
|
+
pending = "";
|
|
10333
|
+
};
|
|
10334
|
+
const finish = (r) => {
|
|
10335
|
+
if (settled) return;
|
|
10336
|
+
settled = true;
|
|
10337
|
+
clearTimeout(timer);
|
|
10338
|
+
server?.close();
|
|
10339
|
+
server?.closeAllConnections();
|
|
10340
|
+
if (child.exitCode === null && child.signalCode === null) child.kill();
|
|
10341
|
+
child.stdin?.end();
|
|
10342
|
+
flush();
|
|
10343
|
+
resolve5({ provider, ...r });
|
|
10344
|
+
};
|
|
10345
|
+
const timer = setTimeout(() => finish({ outcome: "failed", detail: `timed out after ${Math.round(o.timeoutMs / 1e3)}s waiting for the browser login`, manual: true }), o.timeoutMs);
|
|
10346
|
+
const onData = (chunk) => {
|
|
10347
|
+
const s = chunk.toString();
|
|
10348
|
+
text += s;
|
|
10349
|
+
pending += s;
|
|
10350
|
+
const lines = pending.split("\n");
|
|
10351
|
+
pending = lines.pop();
|
|
10352
|
+
for (const l of lines) if (l.trim()) console.log(`${pad}fx ${l}`);
|
|
10353
|
+
if (!redirect) {
|
|
10354
|
+
const r = parseLoginUrl(text);
|
|
10355
|
+
if (r) {
|
|
10356
|
+
redirect = r;
|
|
10357
|
+
listen(r);
|
|
10358
|
+
}
|
|
10359
|
+
}
|
|
10360
|
+
};
|
|
10361
|
+
child.stdout?.on("data", onData);
|
|
10362
|
+
child.stderr?.on("data", onData);
|
|
10363
|
+
child.on("error", (e) => finish({ outcome: "failed", detail: `could not run ${words[0]}: ${e.message}`, manual: true }));
|
|
10364
|
+
child.on("exit", (code, signal) => {
|
|
10365
|
+
if (settled) return;
|
|
10366
|
+
if (code === 0) return finish({ outcome: "ok", ...redirect ? {} : { detail: "fx needed no browser" } });
|
|
10367
|
+
finish({ outcome: "failed", detail: `fx login ${provider} exited ${code ?? signal}${redirect ? "" : " before printing an authorize URL"}`, manual: true });
|
|
10368
|
+
});
|
|
10369
|
+
function listen(r) {
|
|
10370
|
+
const srv = createServer((req, res) => handle(r, req, res));
|
|
10371
|
+
srv.once("error", (e) => {
|
|
10372
|
+
if (e.code === "EADDRINUSE" && published.includes(r.port)) {
|
|
10373
|
+
console.log(`${pad}127.0.0.1:${r.port} is the container's published port: the browser's redirect reaches the login server inside through it; waiting for fx to finish`);
|
|
10374
|
+
return openBrowser(r);
|
|
10375
|
+
}
|
|
10376
|
+
finish({ outcome: "failed", detail: `cannot listen on 127.0.0.1:${r.port} for the browser's redirect (${e.code === "EADDRINUSE" ? "the port is in use on this Mac" : e.message})`, manual: true });
|
|
10377
|
+
});
|
|
10378
|
+
srv.listen(r.port, "127.0.0.1", () => {
|
|
10379
|
+
server = srv;
|
|
10380
|
+
console.log(`${pad}listening on 127.0.0.1:${r.port} for the browser's redirect`);
|
|
10381
|
+
openBrowser(r);
|
|
10382
|
+
});
|
|
10383
|
+
}
|
|
10384
|
+
function openBrowser(r) {
|
|
10385
|
+
if (!o.open) return console.log(`${pad}open this URL on this Mac:
|
|
10386
|
+
${pad}${r.url}`);
|
|
10387
|
+
const opener = deps.platform === "darwin" ? "open" : "xdg-open";
|
|
10388
|
+
const res = deps.sh([opener, r.url]);
|
|
10389
|
+
console.log(res.ok ? `${pad}opened ${r.url.split("?")[0]} in this Mac's browser; finish the sign-in there` : `${pad}could not open the browser (${res.out.split("\n")[0] ?? ""}); open this URL yourself:
|
|
10390
|
+
${pad}${r.url}`);
|
|
10391
|
+
}
|
|
10392
|
+
function handle(r, req, res) {
|
|
10393
|
+
const url = req.url ?? "/";
|
|
10394
|
+
if (!url.startsWith(r.path)) {
|
|
10395
|
+
res.writeHead(404);
|
|
10396
|
+
res.end();
|
|
10397
|
+
return;
|
|
10398
|
+
}
|
|
10399
|
+
const html = { "content-type": "text/html; charset=utf-8" };
|
|
10400
|
+
const cb = parseLoginCallback(url);
|
|
10401
|
+
if (cb.error) {
|
|
10402
|
+
const detail = `${cb.error}${cb.errorDescription ? `: ${cb.errorDescription}` : ""}`;
|
|
10403
|
+
res.writeHead(200, html);
|
|
10404
|
+
res.end(loginDonePage(provider, false, detail));
|
|
10405
|
+
return finish({ outcome: "failed", detail: `the provider answered ${detail}`, manual: true });
|
|
10406
|
+
}
|
|
10407
|
+
if (!cb.code) {
|
|
10408
|
+
res.writeHead(400, html);
|
|
10409
|
+
res.end(loginDonePage(provider, false, "the redirect carried no code"));
|
|
10410
|
+
return;
|
|
10411
|
+
}
|
|
10412
|
+
if (r.state && cb.state && cb.state !== r.state) {
|
|
10413
|
+
res.writeHead(400, html);
|
|
10414
|
+
res.end(loginDonePage(provider, false, "the state does not match this login"));
|
|
10415
|
+
return finish({ outcome: "failed", detail: "the redirect's state does not match the one fx sent (another login in flight?)", manual: true });
|
|
10416
|
+
}
|
|
10417
|
+
res.writeHead(200, html);
|
|
10418
|
+
res.end(loginDonePage(provider, true));
|
|
10419
|
+
if (handedOver) return;
|
|
10420
|
+
handedOver = true;
|
|
10421
|
+
if (provider === "codex") {
|
|
10422
|
+
const rr = deps.sh(p.login.replay(replayUrl(r, url)), { timeoutMs: 3e4 });
|
|
10423
|
+
console.log(rr.ok ? `${pad}redirect received; replayed to the Codex listener inside (${r.host}:${r.port}) and the code pasted to fx` : `${pad}redirect received; the replay inside failed (${rr.out.split("\n")[0] ?? ""}); pasting the code to fx`);
|
|
10424
|
+
} else console.log(`${pad}redirect received; pasting the code to fx`);
|
|
10425
|
+
child.stdin?.write(`${cb.code}
|
|
10426
|
+
`);
|
|
10427
|
+
}
|
|
10428
|
+
});
|
|
10429
|
+
}
|
|
10011
10430
|
function needContainer() {
|
|
10012
10431
|
const bin = deps.onPath("container");
|
|
10013
10432
|
if (!bin) throw new Error("Apple's container CLI is not on PATH. Install it from https://github.com/apple/container/releases, then: container system start");
|
|
@@ -10108,7 +10527,9 @@ function registerWork(program2, version2, deps = defaultDeps) {
|
|
|
10108
10527
|
const { steps, allKept } = containerLoginSteps(doctorText);
|
|
10109
10528
|
if (allKept) return console.log(`logins kept in the volume: ${steps.filter((s) => s.kept).map((s) => s.login).join(", ")} (relay doctor inside)`);
|
|
10110
10529
|
console.log(intro);
|
|
10111
|
-
|
|
10530
|
+
console.log(`${indent}${CONTAINER_LOGIN_COMMAND.padEnd(36)} ${CONTAINER_LOGIN_COMMAND_WHY}`);
|
|
10531
|
+
console.log(`${indent}or by hand, inside, in this order:`);
|
|
10532
|
+
for (const s of steps) console.log(`${indent} ${s.cmd.padEnd(34)} ${s.kept ? "kept (already in the volume)" : s.why}`);
|
|
10112
10533
|
if (doctorText === void 0) console.log(`${indent}(the logins already in the volume are kept; relay work container status says which)`);
|
|
10113
10534
|
}
|
|
10114
10535
|
function containerInstall(o, mode) {
|
|
@@ -10172,7 +10593,7 @@ function registerWork(program2, version2, deps = defaultDeps) {
|
|
|
10172
10593
|
console.log(service.content.replace(/^/gm, " "));
|
|
10173
10594
|
for (const w of service.load) console.log(`would run ${w.join(" ")}`);
|
|
10174
10595
|
console.log(`log file ${service.logPath} (the container's output; inside: ${CONTAINER_NAME}:${CONTAINER_RELAY_HOME}/work.log)`);
|
|
10175
|
-
printLogins(" ", now?.doctor, "then log in inside
|
|
10596
|
+
printLogins(" ", now?.doctor, "then log in inside:");
|
|
10176
10597
|
if (deps.platform === "darwin") console.log("note a LaunchAgent runs while this user is logged in; enable automatic login or keep a session open on a machine that must stay available");
|
|
10177
10598
|
if (!o.yes) return console.log(`
|
|
10178
10599
|
re-run with --yes to ${mode === "update" ? "rebuild the image, recreate the container and restart the service" : "build the image, create the container and install the service"}`);
|
|
@@ -10228,7 +10649,7 @@ re-run with --yes to ${mode === "update" ? "rebuild the image, recreate the cont
|
|
|
10228
10649
|
}
|
|
10229
10650
|
console.log(`
|
|
10230
10651
|
${CONTAINER_NAME} is running ${image} as worker ${workerName}.`);
|
|
10231
|
-
printLogins(" ", after2.doctor, "Log in inside
|
|
10652
|
+
printLogins(" ", after2.doctor, "Log in inside:\n");
|
|
10232
10653
|
console.log(`
|
|
10233
10654
|
The logins land in the ${CONTAINER_VOLUME} volume, never on this Mac's home. Check with: relay work container status; the worker's log: relay work container logs -f`);
|
|
10234
10655
|
}
|
|
@@ -10267,7 +10688,7 @@ program.command("init").description("Create ~/.harness/config.json if missing, r
|
|
|
10267
10688
|
const h = await openHarness(cfg, { local: true });
|
|
10268
10689
|
try {
|
|
10269
10690
|
log3(`database: ${h.db.driver}${h.db.driver === "pglite" ? ` at ${cfg.dbFile}` : " via DATABASE_URL"}`);
|
|
10270
|
-
if (cfg.site) log3(`site mode: commands talk to ${cfg.site}; this local database serves relay link's digest cache
|
|
10691
|
+
if (cfg.site) log3(`site mode: commands and the stdio MCP server talk to ${cfg.site}; this local database serves relay link's digest cache`);
|
|
10271
10692
|
const v = await migrate(h.db, { log: log3 });
|
|
10272
10693
|
log3(`schema at v${v}`);
|
|
10273
10694
|
await seedRegistry(h, log3);
|
|
@@ -10275,10 +10696,11 @@ program.command("init").description("Create ~/.harness/config.json if missing, r
|
|
|
10275
10696
|
await h.close();
|
|
10276
10697
|
}
|
|
10277
10698
|
});
|
|
10278
|
-
program.command("doctor").description("Report Node version, which CLIs are installed and logged in, the lanes this machine can serve, whether Claude's OS sandbox is available, the container worker tier (macOS), whether the database or site is reachable, how this machine authenticates, and (site mode) which linked repos still lack the GitHub App").action(async () => {
|
|
10699
|
+
program.command("doctor").description("Report Node version, which CLIs are installed and logged in, the lanes this machine can serve, whether Claude's OS sandbox is available, the container worker tier (macOS), whether the database or site is reachable, how this machine authenticates, which mode the installed MCP server runs in, and (site mode) which linked repos still lack the GitHub App").action(async () => {
|
|
10279
10700
|
const checks = [];
|
|
10280
10701
|
const major = Number(process.versions.node.split(".")[0]);
|
|
10281
10702
|
checks.push({ status: major >= 24 ? "ok" : "fail", name: "node", detail: `v${process.versions.node}${major >= 24 ? "" : " (need 24+)"}` });
|
|
10703
|
+
checks.push(pathCheck());
|
|
10282
10704
|
const tools = await probeTools();
|
|
10283
10705
|
checks.push(...tools);
|
|
10284
10706
|
const lanes = lanesFromChecks(tools);
|
|
@@ -10287,8 +10709,9 @@ program.command("doctor").description("Report Node version, which CLIs are insta
|
|
|
10287
10709
|
if (process.platform === "darwin" && !inContainer()) checks.push(containerCheck(await containerReport()));
|
|
10288
10710
|
const cfg = loadConfig();
|
|
10289
10711
|
const siteMode = !!cfg.site && !!cfg.siteToken;
|
|
10712
|
+
let live = cfg;
|
|
10290
10713
|
if (cfg.site && !cfg.siteToken) checks.push({ status: "fail", name: "mode", detail: `site ${cfg.site} is configured without a token; run: relay setup --site ${cfg.site}` });
|
|
10291
|
-
else checks.push({ status: "ok", name: "mode", detail: siteMode ? `site: ${cfg.site} (memory, feed, token, invite, task and webhook commands and the
|
|
10714
|
+
else checks.push({ status: "ok", name: "mode", detail: siteMode ? `site: ${cfg.site} (memory, feed, token, invite, task and webhook commands, the hooks and the MCP server use it)` : "local: the database on this machine" });
|
|
10292
10715
|
if (siteMode) {
|
|
10293
10716
|
const t02 = Date.now();
|
|
10294
10717
|
const sourceLabel = { device: "approved on /activate (device flow)", invite: "redeemed an invite", manual: "pasted with --token", env: "RELAY_TOKEN in the environment" }[cfg.siteTokenSource ?? "manual"];
|
|
@@ -10296,6 +10719,7 @@ program.command("doctor").description("Report Node version, which CLIs are insta
|
|
|
10296
10719
|
checks.push({ status: "ok", name: "auth", detail: `${sourceLabel}${cfg.siteTokenName ? ` as ${cfg.siteTokenName}` : ""}${cfg.siteTokenExpiresAt ? `, expires ${cfg.siteTokenExpiresAt}` : ", no expiry"}${renews}` });
|
|
10297
10720
|
try {
|
|
10298
10721
|
const h = await openReady();
|
|
10722
|
+
live = h.cfg;
|
|
10299
10723
|
try {
|
|
10300
10724
|
const health = await h.client.health();
|
|
10301
10725
|
checks.push({ status: health.ok ? "ok" : "fail", name: "site", detail: `${health.detail}, ${Date.now() - t02} ms` });
|
|
@@ -10323,13 +10747,14 @@ program.command("doctor").description("Report Node version, which CLIs are insta
|
|
|
10323
10747
|
await db.query("SELECT 1");
|
|
10324
10748
|
const v = await currentVersion(db);
|
|
10325
10749
|
const where = db.driver === "pg" ? "postgres via DATABASE_URL" : `pglite at ${cfg.dbFile}`;
|
|
10326
|
-
checks.push({ status: v > 0 ? "ok" : siteMode ? "ok" : "warn", name: "database", detail: `${where}, schema v${v}${v > 0 || siteMode ? "" : " (run: relay init)"}, ${Date.now() - t0} ms${siteMode ? " (digest cache
|
|
10750
|
+
checks.push({ status: v > 0 ? "ok" : siteMode ? "ok" : "warn", name: "database", detail: `${where}, schema v${v}${v > 0 || siteMode ? "" : " (run: relay init)"}, ${Date.now() - t0} ms${siteMode ? " (relay link's digest cache only)" : ""}` });
|
|
10327
10751
|
} finally {
|
|
10328
10752
|
await db.close();
|
|
10329
10753
|
}
|
|
10330
10754
|
} catch (e) {
|
|
10331
10755
|
checks.push({ status: siteMode ? "warn" : "fail", name: "database", detail: `unreachable: ${(e instanceof Error ? e.message : String(e)).slice(0, 120)}` });
|
|
10332
10756
|
}
|
|
10757
|
+
checks.push(await mcpCheck(live, { entry: hookPaths(defaultHookContext(RELAY_ROOT)).mcpBin }));
|
|
10333
10758
|
checks.push({ status: cfg.gateway.apiKey ? "ok" : "warn", name: "gateway", detail: cfg.gateway.apiKey ? "AI_GATEWAY_API_KEY set (embeddings on)" : "AI_GATEWAY_API_KEY unset; retrieval uses full-text only" });
|
|
10334
10759
|
checks.push(metersCheck(cfg.home));
|
|
10335
10760
|
for (const c of checks) console.log(`${c.status.padEnd(5)} ${c.name.padEnd(13)} ${c.detail}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kyleroche/relay",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "relay: your coding-agent subscriptions (Claude Code, Codex, Grok) as one system. The CLI, the session hooks and the memory MCP server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -56,8 +56,8 @@
|
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"tsup": "^8.5.0",
|
|
59
|
-
"@relay/
|
|
60
|
-
"@relay/
|
|
59
|
+
"@relay/core": "0.0.1",
|
|
60
|
+
"@relay/mcp": "0.0.1"
|
|
61
61
|
},
|
|
62
62
|
"scripts": {
|
|
63
63
|
"build": "tsup",
|