cortad 0.3.10 → 0.4.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 +14 -7
- package/lib/auto-reach.mjs +6 -5
- package/lib/cli.mjs +4 -1
- package/lib/commits.mjs +202 -0
- package/lib/contact-text.mjs +3 -4
- package/lib/env-files.mjs +39 -0
- package/lib/env-secrets.mjs +12 -4
- package/lib/files.mjs +5 -0
- package/lib/fresh.mjs +1 -1
- package/lib/intel-text.mjs +47 -0
- package/lib/keys.mjs +10 -6
- package/lib/lock.mjs +7 -6
- package/lib/mcp.mjs +38 -5
- package/lib/proc.mjs +3 -1
- package/lib/proof.mjs +8 -1
- package/lib/proxy.mjs +14 -3
- package/lib/pyhook/sitecustomize.py +1 -500
- package/lib/reach.mjs +7 -7
- package/lib/read-text.mjs +0 -1
- package/lib/replay.mjs +6 -11
- package/lib/start.mjs +13 -34
- package/lib/switches.mjs +9 -69
- package/lib/trace.cjs +2 -320
- package/lib/verbs.mjs +56 -1
- package/local.mjs +252 -520
- package/package.json +5 -15
- package/skill/SKILL.md +16 -9
- package/skill/references/results.md +5 -5
- package/lib/backing.mjs +0 -230
- package/lib/data.mjs +0 -137
- package/lib/hosted.mjs +0 -201
- package/lib/identities.mjs +0 -689
- package/lib/keeping.mjs +0 -174
- package/lib/ledger.mjs +0 -20
- package/lib/listing.mjs +0 -12
- package/lib/mint.mjs +0 -334
- package/lib/mongo.mjs +0 -74
- package/lib/pg.mjs +0 -118
- package/lib/qdrant.mjs +0 -48
- package/lib/redis.mjs +0 -130
- package/lib/service.mjs +0 -96
- package/lib/stores.mjs +0 -378
package/lib/pg.mjs
DELETED
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
// A Postgres database copied for a session on the server it lives on, with the credentials the
|
|
2
|
-
// app's own settings carry, and dropped after. Postgres's own tools do the work (psql, and pg_dump
|
|
3
|
-
// when the database is in use); the password reaches them through their environment, never a
|
|
4
|
-
// command line another process on this machine can read.
|
|
5
|
-
import { spawn } from "node:child_process";
|
|
6
|
-
|
|
7
|
-
// A database bigger than this is not copied: said instead, so the person decides.
|
|
8
|
-
export const PG_CAP = 1024 ** 3;
|
|
9
|
-
const quote = (name) => `"${String(name).replace(/"/g, '""')}"`;
|
|
10
|
-
const literal = (text) => `'${String(text).replace(/'/g, "''")}'`;
|
|
11
|
-
|
|
12
|
-
const envOf = (conn, db) => ({
|
|
13
|
-
PATH: process.env.PATH ?? "",
|
|
14
|
-
HOME: process.env.HOME ?? "",
|
|
15
|
-
PGDATABASE: db,
|
|
16
|
-
PGCONNECT_TIMEOUT: "10",
|
|
17
|
-
PGAPPNAME: "cortad",
|
|
18
|
-
...(conn.host ? { PGHOST: conn.host } : {}),
|
|
19
|
-
...(conn.port ? { PGPORT: String(conn.port) } : {}),
|
|
20
|
-
...(conn.user ? { PGUSER: conn.user } : {}),
|
|
21
|
-
...(conn.password ? { PGPASSWORD: conn.password } : {}),
|
|
22
|
-
...(conn.sslmode ? { PGSSLMODE: conn.sslmode } : {}),
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
function run(bin, args, env, input = null) {
|
|
26
|
-
return new Promise((done) => {
|
|
27
|
-
const child = spawn(bin, args, { env, stdio: [input ? "pipe" : "ignore", "pipe", "pipe"] });
|
|
28
|
-
let out = "", err = "";
|
|
29
|
-
child.stdout.on("data", (d) => { out += d; });
|
|
30
|
-
child.stderr.on("data", (d) => { err = (err + d).slice(-4000); });
|
|
31
|
-
if (input) input.pipe(child.stdin);
|
|
32
|
-
child.on("error", (e) => done({ code: e.code === "ENOENT" ? "missing" : 1, out, err: String(e.message) }));
|
|
33
|
-
child.on("close", (code) => done({ code, out, err }));
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// One statement per -c: CREATE and DROP DATABASE refuse to run inside a transaction, and several
|
|
38
|
-
// statements in one string are one.
|
|
39
|
-
const psql = (conn, db, ...statements) =>
|
|
40
|
-
run("psql", ["-X", "-q", "-A", "-t", "-v", "ON_ERROR_STOP=1", "-v", "VERBOSITY=verbose", ...statements.flatMap((s) => ["-c", s])], envOf(conn, db));
|
|
41
|
-
|
|
42
|
-
// Why psql refused, as a class the sentence is written from. Never the server's own words: they
|
|
43
|
-
// name the user and the host, and the sentence leaves this machine.
|
|
44
|
-
export function refusal(err) {
|
|
45
|
-
const code = /(?:ERROR|FATAL):\s+([0-9A-Z]{5}):/.exec(err)?.[1];
|
|
46
|
-
// A refusal while connecting carries no code, only the server's words.
|
|
47
|
-
if (code === "28P01" || code === "28000" || /password authentication failed|no pg_hba\.conf entry|role "[^"]*" does not exist|no password supplied/i.test(err)) return "auth";
|
|
48
|
-
if (code === "42501") return "rights";
|
|
49
|
-
if (code === "55006") return "busy";
|
|
50
|
-
if (code === "3D000" || /database "[^"]*" does not exist/i.test(err)) return "gone";
|
|
51
|
-
if (/server version mismatch|aborting because of server version/i.test(err)) return "version";
|
|
52
|
-
if (/Connection refused|could not connect|No such file or directory|timeout expired|could not translate host/i.test(err)) return "down";
|
|
53
|
-
return "other";
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// The database to stand in while a copy is made: never the source, which a template copy needs
|
|
57
|
-
// free of every connection, ours included.
|
|
58
|
-
async function maintenance(conn, source) {
|
|
59
|
-
for (const db of ["postgres", "template1"].filter((d) => d !== source)) {
|
|
60
|
-
const r = await psql(conn, db, "select 1");
|
|
61
|
-
if (r.code === 0) return { db };
|
|
62
|
-
if (r.code === "missing") return { why: "missing" };
|
|
63
|
-
const why = refusal(r.err);
|
|
64
|
-
if (why !== "gone") return { why };
|
|
65
|
-
}
|
|
66
|
-
return { why: "other" };
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// The copy: a template copy first, which Postgres makes file by file; when the database is in use,
|
|
70
|
-
// which a template copy refuses, a dump of it restored into an empty one. Answers { how } or { why }.
|
|
71
|
-
export async function clonePg(conn, source, clone) {
|
|
72
|
-
const home = await maintenance(conn, source);
|
|
73
|
-
if (!home.db) return { why: home.why };
|
|
74
|
-
const size = await psql(conn, home.db, `select pg_database_size(${literal(source)})`);
|
|
75
|
-
if (size.code !== 0) return { why: refusal(size.err) };
|
|
76
|
-
if (Number(size.out.trim()) > PG_CAP) return { why: "big" };
|
|
77
|
-
const made = await psql(conn, home.db, `CREATE DATABASE ${quote(clone)} TEMPLATE ${quote(source)}`);
|
|
78
|
-
if (made.code === 0) return { how: "template" };
|
|
79
|
-
const why = refusal(made.err);
|
|
80
|
-
if (why !== "busy") return { why };
|
|
81
|
-
const empty = await psql(conn, home.db, `CREATE DATABASE ${quote(clone)}`);
|
|
82
|
-
if (empty.code !== 0) return { why: refusal(empty.err) };
|
|
83
|
-
const dump = spawn("pg_dump", ["--no-owner", "--no-privileges"], { env: envOf(conn, source), stdio: ["ignore", "pipe", "pipe"] });
|
|
84
|
-
let dumpErr = "";
|
|
85
|
-
dump.stderr.on("data", (d) => { dumpErr = (dumpErr + d).slice(-4000); });
|
|
86
|
-
const dumped = new Promise((done) => { dump.on("error", (e) => done(e.code === "ENOENT" ? "missing" : 1)); dump.on("close", done); });
|
|
87
|
-
const restored = await run("psql", ["-X", "-q", "-v", "ON_ERROR_STOP=1", "-v", "VERBOSITY=verbose"], envOf(conn, clone), dump.stdout);
|
|
88
|
-
const dumpCode = await dumped;
|
|
89
|
-
if (dumpCode === 0 && restored.code === 0) return { how: "dump" };
|
|
90
|
-
await dropPg(conn, clone);
|
|
91
|
-
if (dumpCode === "missing") return { why: "missing" };
|
|
92
|
-
// Either side ending stops the other, which then fails for that reason alone: the telling one wins.
|
|
93
|
-
const whys = [dumpCode === 0 ? null : refusal(dumpErr), restored.code === 0 ? null : refusal(restored.err)].filter(Boolean);
|
|
94
|
-
return { why: whys.find((w) => w !== "other") ?? "other" };
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// An empty database on a server of this machine, for a stand-in of one kept elsewhere: the app's own
|
|
98
|
-
// migrations fill it. Answers { how } or { why }.
|
|
99
|
-
export async function emptyPg(conn, name) {
|
|
100
|
-
const home = await maintenance(conn);
|
|
101
|
-
if (!home.db) return { why: home.why };
|
|
102
|
-
const made = await psql(conn, home.db, `CREATE DATABASE ${quote(name)}`);
|
|
103
|
-
return made.code === 0 ? { how: "empty" } : { why: refusal(made.err) };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// One migration file, run whole: a failed statement stops it.
|
|
107
|
-
export const applySql = async (conn, db, file) =>
|
|
108
|
-
(await run("psql", ["-X", "-q", "-v", "ON_ERROR_STOP=1", "-f", file], envOf(conn, db))).code === 0;
|
|
109
|
-
|
|
110
|
-
// The copy's own connections are ended first: the app is stopped by then, but a pool can outlive it.
|
|
111
|
-
export async function dropPg(conn, clone) {
|
|
112
|
-
const home = await maintenance(conn);
|
|
113
|
-
if (!home.db) return false;
|
|
114
|
-
const r = await psql(conn, home.db,
|
|
115
|
-
`select pg_terminate_backend(pid) from pg_stat_activity where datname = ${literal(clone)} and pid <> pg_backend_pid()`,
|
|
116
|
-
`DROP DATABASE IF EXISTS ${quote(clone)}`);
|
|
117
|
-
return r.code === 0;
|
|
118
|
-
}
|
package/lib/qdrant.mjs
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
// A Qdrant collection copied for a session into a new collection of the same server, point by point
|
|
2
|
-
// over Qdrant's own HTTP API with the vectors, payloads and payload indexes it holds, and deleted
|
|
3
|
-
// after. The key, when the server has one, travels in its header.
|
|
4
|
-
export const QDRANT_CAP = 200_000;
|
|
5
|
-
const BATCH = 256;
|
|
6
|
-
const at = (name) => `/collections/${encodeURIComponent(name)}`;
|
|
7
|
-
|
|
8
|
-
// `conn.fetch`: the fetch to use, when the caller's global one is wrapped (the hook in the app).
|
|
9
|
-
async function call(conn, method, path, body) {
|
|
10
|
-
const res = await (conn.fetch ?? fetch)(`${conn.base}${path}`, {
|
|
11
|
-
method,
|
|
12
|
-
headers: { "content-type": "application/json", ...(conn.apiKey ? { "api-key": conn.apiKey } : {}) },
|
|
13
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
14
|
-
signal: AbortSignal.timeout(30_000),
|
|
15
|
-
});
|
|
16
|
-
const data = await res.json().catch(() => null);
|
|
17
|
-
if (!res.ok) throw Object.assign(new Error(String(res.status)), { why: res.status === 401 || res.status === 403 ? "auth" : res.status === 404 ? "gone" : "other" });
|
|
18
|
-
return data?.result;
|
|
19
|
-
}
|
|
20
|
-
const whyOf = (e) => e?.why ?? (/ECONNREFUSED|fetch failed|timeout/i.test(String(e?.cause?.code ?? e?.message)) ? "down" : "other");
|
|
21
|
-
|
|
22
|
-
// A collection the server does not hold yet is one the app makes on first use: pointed at the copy's
|
|
23
|
-
// name, it makes that one instead, and it is deleted like any copy.
|
|
24
|
-
export async function cloneQdrant(conn, source, clone) {
|
|
25
|
-
let info;
|
|
26
|
-
try { info = await call(conn, "GET", at(source)); } catch (e) { return e?.why === "gone" ? { how: "empty" } : { why: whyOf(e) }; }
|
|
27
|
-
if ((info.points_count ?? 0) > QDRANT_CAP) return { why: "big" };
|
|
28
|
-
let made = false;
|
|
29
|
-
try {
|
|
30
|
-
const { vectors, sparse_vectors, on_disk_payload } = info.config.params;
|
|
31
|
-
await call(conn, "PUT", at(clone), { vectors, ...(sparse_vectors ? { sparse_vectors } : {}), ...(on_disk_payload === undefined ? {} : { on_disk_payload }) });
|
|
32
|
-
made = true;
|
|
33
|
-
for (const [field_name, schema] of Object.entries(info.payload_schema ?? {})) await call(conn, "PUT", `${at(clone)}/index?wait=true`, { field_name, field_schema: schema.params ?? schema.data_type });
|
|
34
|
-
for (let offset; ;) {
|
|
35
|
-
const page = await call(conn, "POST", `${at(source)}/points/scroll`, { limit: BATCH, with_payload: true, with_vector: true, ...(offset === undefined ? {} : { offset }) });
|
|
36
|
-
if (page.points.length) await call(conn, "PUT", `${at(clone)}/points?wait=true`, { points: page.points.map(({ id, vector, payload }) => ({ id, vector, payload })) });
|
|
37
|
-
offset = page.next_page_offset;
|
|
38
|
-
if (offset === null || offset === undefined) return { how: "points", points: info.points_count ?? 0 };
|
|
39
|
-
}
|
|
40
|
-
} catch (e) {
|
|
41
|
-
if (made) await dropQdrant(conn, clone);
|
|
42
|
-
return { why: whyOf(e) };
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export async function dropQdrant(conn, clone) {
|
|
47
|
-
try { await call(conn, "DELETE", at(clone)); return true; } catch (e) { return e?.why === "gone"; }
|
|
48
|
-
}
|
package/lib/redis.mjs
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
// A Redis database copied for a session into an empty logical database of the same server, and
|
|
2
|
-
// emptied after. Spoken over the wire here (RESP), so nothing needs redis-cli on this machine and
|
|
3
|
-
// the password never sits on a command line.
|
|
4
|
-
import { connect as tcp } from "node:net";
|
|
5
|
-
import { connect as tls } from "node:tls";
|
|
6
|
-
|
|
7
|
-
// A database with more keys than this is not copied: said instead.
|
|
8
|
-
export const REDIS_CAP = 200_000;
|
|
9
|
-
// Written into the copy, so an emptying after a crash only ever empties a database this command
|
|
10
|
-
// filled: a database index can be taken by something else between two sessions.
|
|
11
|
-
export const MARKER = "__cortad_copy__";
|
|
12
|
-
const BATCH = 500;
|
|
13
|
-
|
|
14
|
-
// One connection, commands sent in batches and answered in order.
|
|
15
|
-
async function open({ host, port, tlsOn, user, password }) {
|
|
16
|
-
const socket = await new Promise((ok, no) => {
|
|
17
|
-
const s = (tlsOn ? tls : tcp)({ host: host || "127.0.0.1", port, ...(tlsOn ? { servername: host } : {}) });
|
|
18
|
-
s.setTimeout(10_000, () => s.destroy(new Error("timeout")));
|
|
19
|
-
s.once(tlsOn ? "secureConnect" : "connect", () => ok(s));
|
|
20
|
-
s.once("error", no);
|
|
21
|
-
});
|
|
22
|
-
let buf = Buffer.alloc(0);
|
|
23
|
-
const waiting = [];
|
|
24
|
-
const replies = [];
|
|
25
|
-
socket.on("data", (chunk) => {
|
|
26
|
-
buf = Buffer.concat([buf, chunk]);
|
|
27
|
-
for (let r; (r = parse(buf, 0)); ) { buf = buf.subarray(r.end); replies.push(r.value); }
|
|
28
|
-
while (waiting.length && replies.length >= waiting[0].n) { const w = waiting.shift(); w.ok(replies.splice(0, w.n)); }
|
|
29
|
-
});
|
|
30
|
-
socket.on("error", (e) => { for (const w of waiting.splice(0)) w.no(e); });
|
|
31
|
-
const send = (cmds) => new Promise((ok, no) => {
|
|
32
|
-
waiting.push({ n: cmds.length, ok, no });
|
|
33
|
-
socket.write(Buffer.concat(cmds.map(encode)));
|
|
34
|
-
});
|
|
35
|
-
const client = { send, one: async (...cmd) => (await send([cmd]))[0], close: () => socket.end() };
|
|
36
|
-
if (password) {
|
|
37
|
-
const said = await client.one(...(user ? ["AUTH", user, password] : ["AUTH", password]));
|
|
38
|
-
// A development server with no password set refuses AUTH itself; the app connects the same way.
|
|
39
|
-
if (said instanceof Error && !/without any password configured|no password is set/i.test(said.message)) { client.close(); throw Object.assign(new Error("auth"), { why: "auth" }); }
|
|
40
|
-
}
|
|
41
|
-
return client;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Arguments go as bytes: a key is whatever bytes the app named it with, and it is copied as such.
|
|
45
|
-
const encode = (cmd) => Buffer.concat([Buffer.from(`*${cmd.length}\r\n`), ...cmd.flatMap((a) => {
|
|
46
|
-
const b = Buffer.isBuffer(a) ? a : Buffer.from(String(a));
|
|
47
|
-
return [Buffer.from(`$${b.length}\r\n`), b, Buffer.from("\r\n")];
|
|
48
|
-
})]);
|
|
49
|
-
|
|
50
|
-
// One reply from buf at i, or null until the whole of it has arrived.
|
|
51
|
-
function parse(buf, i) {
|
|
52
|
-
const eol = buf.indexOf("\r\n", i);
|
|
53
|
-
if (eol < 0) return null;
|
|
54
|
-
const type = String.fromCharCode(buf[i]);
|
|
55
|
-
const line = buf.subarray(i + 1, eol).toString();
|
|
56
|
-
if (type === "+") return { value: line, end: eol + 2 };
|
|
57
|
-
if (type === "-") return { value: new Error(line), end: eol + 2 };
|
|
58
|
-
if (type === ":") return { value: Number(line), end: eol + 2 };
|
|
59
|
-
if (type === "$") {
|
|
60
|
-
const n = Number(line);
|
|
61
|
-
if (n < 0) return { value: null, end: eol + 2 };
|
|
62
|
-
if (buf.length < eol + 2 + n + 2) return null;
|
|
63
|
-
return { value: Buffer.from(buf.subarray(eol + 2, eol + 2 + n)), end: eol + 2 + n + 2 };
|
|
64
|
-
}
|
|
65
|
-
if (type === "*") {
|
|
66
|
-
const n = Number(line);
|
|
67
|
-
const items = [];
|
|
68
|
-
let at = eol + 2;
|
|
69
|
-
for (let k = 0; k < n; k++) { const r = parse(buf, at); if (!r) return null; items.push(r.value); at = r.end; }
|
|
70
|
-
return { value: n < 0 ? null : items, end: at };
|
|
71
|
-
}
|
|
72
|
-
return { value: new Error(`unreadable reply ${type}`), end: eol + 2 };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const why = (e) => e?.why ?? (/NOAUTH|WRONGPASS|invalid password/i.test(String(e?.message)) ? "auth" : /ECONNREFUSED|ENOTFOUND|timeout|EHOSTUNREACH/i.test(String(e?.code ?? e?.message)) ? "down" : "other");
|
|
76
|
-
|
|
77
|
-
// COPY came in Redis 6.2. A server that hides INFO is let try: a refused COPY is said then.
|
|
78
|
-
export function tooOld(info) {
|
|
79
|
-
const m = /redis_version:(\d+)\.(\d+)/.exec(Buffer.isBuffer(info) ? info.toString() : "");
|
|
80
|
-
return Boolean(m) && (Number(m[1]) < 6 || (Number(m[1]) === 6 && Number(m[2]) < 2));
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// The copy: the first empty database from the top of the server's range, claimed with the marker,
|
|
84
|
-
// then every key of the source copied into it with COPY. Answers { index } or { why }.
|
|
85
|
-
export async function cloneRedis(conn, source, marker) {
|
|
86
|
-
let c;
|
|
87
|
-
try { c = await open(conn); } catch (e) { return { why: why(e) }; }
|
|
88
|
-
try {
|
|
89
|
-
const [picked, size, info] = await c.send([["SELECT", source], ["DBSIZE"], ["INFO", "server"]]);
|
|
90
|
-
if (picked instanceof Error) return { why: /cluster/i.test(picked.message) ? "cluster" : why(picked) };
|
|
91
|
-
if (tooOld(info)) return { why: "oldServer" };
|
|
92
|
-
if (size > REDIS_CAP) return { why: "big" };
|
|
93
|
-
let index = null;
|
|
94
|
-
for (let n = 15; n >= 1 && index === null; n--) {
|
|
95
|
-
if (n === source) continue;
|
|
96
|
-
const [sel, count] = await c.send([["SELECT", n], ["DBSIZE"]]);
|
|
97
|
-
if (sel instanceof Error) continue;
|
|
98
|
-
if (count === 0) index = n;
|
|
99
|
-
}
|
|
100
|
-
if (index === null) return { why: "full" };
|
|
101
|
-
await c.send([["SELECT", index], ["SET", MARKER, marker], ["SELECT", source]]);
|
|
102
|
-
let cursor = "0";
|
|
103
|
-
do {
|
|
104
|
-
const [next, keys] = await c.one("SCAN", cursor, "COUNT", BATCH);
|
|
105
|
-
cursor = String(next);
|
|
106
|
-
const copied = keys.length ? await c.send(keys.map((k) => ["COPY", k, k, "DB", index])) : [];
|
|
107
|
-
const refused = copied.find((r) => r instanceof Error);
|
|
108
|
-
if (refused) { await c.send([["SELECT", index], ["FLUSHDB"]]); return { why: "other" }; }
|
|
109
|
-
} while (cursor !== "0");
|
|
110
|
-
return { index };
|
|
111
|
-
} catch (e) {
|
|
112
|
-
return { why: why(e) };
|
|
113
|
-
} finally {
|
|
114
|
-
c.close();
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Emptied only when the marker in it is this session's. With no index, the database is found by its
|
|
119
|
-
// marker: a session killed mid-copy recorded the copy before it knew which database it claimed.
|
|
120
|
-
export async function dropRedis(conn, index, marker) {
|
|
121
|
-
let c;
|
|
122
|
-
try { c = await open(conn); } catch { return false; }
|
|
123
|
-
try {
|
|
124
|
-
for (const n of index == null ? Array.from({ length: 15 }, (_, i) => 15 - i) : [index]) {
|
|
125
|
-
const [sel, held] = await c.send([["SELECT", n], ["GET", MARKER]]);
|
|
126
|
-
if (!(sel instanceof Error) && String(held) === marker) return (await c.one("FLUSHDB")) === "OK";
|
|
127
|
-
}
|
|
128
|
-
return index == null;
|
|
129
|
-
} catch { return false; } finally { c.close(); }
|
|
130
|
-
}
|
package/lib/service.mjs
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
// Some apps keep their sign-in on a different service than the one that serves the AI: one app
|
|
2
|
-
// serves chat from apps/api and mounts better-auth in apps/dashboard, and the command starts only
|
|
3
|
-
// apps/api. A recipe that names such a service (src/setup/road-identities.ts) is minted against
|
|
4
|
-
// that service instead, so nobody is asked to send a message by hand. Cookies set on localhost
|
|
5
|
-
// apply to every port, which is what makes the session work on the AI service after.
|
|
6
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
-
import { relative, join } from "node:path";
|
|
8
|
-
import { startPlan } from "./start.mjs";
|
|
9
|
-
|
|
10
|
-
export const serviceBase = (port) => `http://127.0.0.1:${port}`;
|
|
11
|
-
|
|
12
|
-
const PORT_IN_SCRIPT = /(?:^|\s)(?:PORT=|-p[ =]|--port[ =])(\d{2,5})\b/;
|
|
13
|
-
// The port a framework serves on when nobody names one. sveltekit and astro before vite: both bring
|
|
14
|
-
// vite with them and neither uses its port.
|
|
15
|
-
const FRAMEWORK_PORT = [["next", 3000], ["nuxt", 3000], ["@remix-run/serve", 3000], ["@remix-run/dev", 3000], ["@sveltejs/kit", 5173], ["astro", 4321], ["vite", 5173]];
|
|
16
|
-
// No scan of the usual ports: whatever answers on 3000 or 8080 on this machine is very often not
|
|
17
|
-
// this workspace, and a test account made against somebody else's service is the worst kind of
|
|
18
|
-
// wrong. Only the port this workspace itself names, and if nothing is there, it is started.
|
|
19
|
-
|
|
20
|
-
// What a workspace serves on: its own script says so, or the framework it is written in does.
|
|
21
|
-
export function servicePort(cwd) {
|
|
22
|
-
let pkg = null;
|
|
23
|
-
try { pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")); } catch { /* not a Node workspace */ }
|
|
24
|
-
const script = ["dev", "develop", "start:dev", "serve", "start"].map((s) => pkg?.scripts?.[s]).find(Boolean) ?? "";
|
|
25
|
-
const named = Number(PORT_IN_SCRIPT.exec(script)?.[1]);
|
|
26
|
-
if (named) return named;
|
|
27
|
-
const deps = Object.keys({ ...pkg?.dependencies, ...pkg?.devDependencies });
|
|
28
|
-
const framework = FRAMEWORK_PORT.find(([dep]) => deps.includes(dep))?.[1];
|
|
29
|
-
if (framework) return framework;
|
|
30
|
-
if (existsSync(join(cwd, "manage.py"))) return 8000;
|
|
31
|
-
if (existsSync(join(cwd, "Gemfile"))) return 3000;
|
|
32
|
-
const py = ["requirements.txt", "pyproject.toml"].map((f) => { try { return readFileSync(join(cwd, f), "utf8"); } catch { return ""; } }).join("\n");
|
|
33
|
-
return /\bdjango\b/i.test(py) ? 8000 : /\bflask\b/i.test(py) ? 5000 : null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// The recipes to mint against a service other than the running app, grouped by that service's
|
|
37
|
-
// workspace, each with how it starts itself. A recipe whose service is the app's own dir stays with
|
|
38
|
-
// the app and is not returned here.
|
|
39
|
-
export function servicesFor({ recipes, root, appDir, onPath }) {
|
|
40
|
-
const here = relative(root, appDir) || ".";
|
|
41
|
-
const byDir = new Map();
|
|
42
|
-
for (const recipe of Array.isArray(recipes) ? recipes : []) {
|
|
43
|
-
const dir = typeof recipe?.service === "string" ? recipe.service.replace(/^\.\//, "").replace(/\/+$/, "") : "";
|
|
44
|
-
if (!dir || dir === here) continue;
|
|
45
|
-
if (!byDir.has(dir)) byDir.set(dir, []);
|
|
46
|
-
byDir.get(dir).push(recipe);
|
|
47
|
-
}
|
|
48
|
-
return [...byDir].map(([dir, group]) => ({
|
|
49
|
-
dir,
|
|
50
|
-
cwd: join(root, dir),
|
|
51
|
-
plan: startPlan({ root: join(root, dir), onPath }),
|
|
52
|
-
recipes: group,
|
|
53
|
-
}));
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// One sign-in per port: recipes whose sign-in is mounted in another workspace are minted against
|
|
57
|
-
// that workspace, the rest against the app, and the rows come back as one list. A repository whose
|
|
58
|
-
// sign-in is where the app is mints once, against the app, as it always did.
|
|
59
|
-
export async function mintAcross({ recipes, root, appDir, onPath, appPort, portFor, mint, originFor = () => undefined }) {
|
|
60
|
-
const groups = servicesFor({ recipes, root, appDir, onPath });
|
|
61
|
-
if (!groups.length) return mint(recipes, appPort);
|
|
62
|
-
const elsewhere = new Set(groups.flatMap((g) => g.recipes));
|
|
63
|
-
const rows = [];
|
|
64
|
-
for (const group of groups) {
|
|
65
|
-
const port = (await portFor(group)) ?? appPort;
|
|
66
|
-
rows.push(...((await mint(group.recipes, port, originFor(port)))?.identities ?? []));
|
|
67
|
-
}
|
|
68
|
-
const rest = (Array.isArray(recipes) ? recipes : []).filter((r) => !elsewhere.has(r));
|
|
69
|
-
if (rest.length) rows.push(...((await mint(rest, appPort))?.identities ?? []));
|
|
70
|
-
return { identities: rows };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Wait for a service to answer on its port, bounded so a service that never comes up cannot hold
|
|
74
|
-
// the whole raise. Any HTTP reply means the port is up; a connection refused means keep waiting.
|
|
75
|
-
export async function waitForPort(port, deadlineMs = 90_000) {
|
|
76
|
-
const until = Date.now() + deadlineMs;
|
|
77
|
-
while (Date.now() < until) {
|
|
78
|
-
try {
|
|
79
|
-
await fetch(serviceBase(port) + "/", { method: "GET", signal: AbortSignal.timeout(2500), redirect: "manual" });
|
|
80
|
-
return true;
|
|
81
|
-
} catch (e) {
|
|
82
|
-
if (!/aborted|timeout|ECONNREFUSED|ECONNRESET|fetch failed|other side closed/i.test(String(e?.cause?.code ?? e?.message ?? e))) return false;
|
|
83
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
return false;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// The page a sign-in believes it is being asked from. Nearly every auth library refuses a request
|
|
90
|
-
// whose Origin it does not trust: better-auth answered one app's own sign-up with 403
|
|
91
|
-
// INVALID_ORIGIN because the ask went to 127.0.0.1 with no page behind it. Their own settings name
|
|
92
|
-
// the page; where they do not, it is that port on localhost, which is what their own browser sends.
|
|
93
|
-
export function originFor(port, origins = []) {
|
|
94
|
-
const named = origins.find((o) => { try { return new URL(o).port === String(port); } catch { return false; } });
|
|
95
|
-
return named ?? `http://localhost:${port}`;
|
|
96
|
-
}
|