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/hosted.mjs
DELETED
|
@@ -1,201 +0,0 @@
|
|
|
1
|
-
// A store the app's settings name on a server off this machine. Trials write for real, so a run
|
|
2
|
-
// never writes into one without the person's yes: the app is pointed at a copy made where the
|
|
3
|
-
// store's maker makes one (a Neon branch, a local Supabase started from the repository's own
|
|
4
|
-
// config), or at an empty database on this machine built by the app's own migrations. keep()
|
|
5
|
-
// answers { env, pass, how } or { why } with every reason no copy could be made, most useful first.
|
|
6
|
-
// Nothing here copies a row out of the hosted store.
|
|
7
|
-
import { spawn } from "node:child_process";
|
|
8
|
-
import { existsSync, readdirSync } from "node:fs";
|
|
9
|
-
import { userInfo } from "node:os";
|
|
10
|
-
import { join } from "node:path";
|
|
11
|
-
import { applySql, dropPg, emptyPg } from "./pg.mjs";
|
|
12
|
-
|
|
13
|
-
const NEON_API = "https://console.neon.tech/api/v2";
|
|
14
|
-
const NEON_HOST = /\.neon\.tech$/i;
|
|
15
|
-
// The endpoint a Neon host serves, pooled or not: ep-cool-darkness-123456(-pooler).region.aws.neon.tech.
|
|
16
|
-
const ENDPOINT = /^(ep-[a-z0-9-]+?)(?:-pooler)?\./i;
|
|
17
|
-
const SUPABASE_HOST = /\.supabase\.(?:co|in|com)$/i;
|
|
18
|
-
const READY_MS = 90_000;
|
|
19
|
-
const MIGRATE_MS = 180_000;
|
|
20
|
-
const PROJECTS_MAX = 100;
|
|
21
|
-
|
|
22
|
-
// A command of the repository's own, with the settings the app starts with. Never a shell.
|
|
23
|
-
export function runCmd(bin, args, { cwd, env = process.env, timeout = 60_000 } = {}) {
|
|
24
|
-
return new Promise((done) => {
|
|
25
|
-
let out = "";
|
|
26
|
-
const child = spawn(bin, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
27
|
-
const timer = setTimeout(() => child.kill("SIGKILL"), timeout);
|
|
28
|
-
child.stdout.on("data", (d) => { out = (out + d).slice(-20_000); });
|
|
29
|
-
child.stderr.on("data", () => {});
|
|
30
|
-
child.on("error", () => { clearTimeout(timer); done({ ok: false, out }); });
|
|
31
|
-
child.on("close", (code) => { clearTimeout(timer); done({ ok: code === 0, out }); });
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const URL_SETTING = /^[a-z][\w+.-]*:\/\//i;
|
|
36
|
-
const settingWith = (values, text) => Object.keys(values).filter((k) => String(values[k] ?? "").includes(text));
|
|
37
|
-
const swapped = (values, names, from, to) => Object.fromEntries(names.map((k) => [k, String(values[k]).split(from).join(to)]));
|
|
38
|
-
|
|
39
|
-
function neonApi(key, fetchFn) {
|
|
40
|
-
return async (method, path, body) => {
|
|
41
|
-
const res = await fetchFn(`${NEON_API}${path}`, {
|
|
42
|
-
method,
|
|
43
|
-
headers: { authorization: `Bearer ${key}`, accept: "application/json", ...(body ? { "content-type": "application/json" } : {}) },
|
|
44
|
-
...(body ? { body: JSON.stringify(body) } : {}),
|
|
45
|
-
signal: AbortSignal.timeout(20_000),
|
|
46
|
-
});
|
|
47
|
-
if (!res.ok) throw new Error(`neon answered ${res.status}`);
|
|
48
|
-
return res.status === 204 ? {} : res.json();
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// The project and branch behind an endpoint: the API has no lookup by endpoint, so each project is asked.
|
|
53
|
-
async function neonParent(api, endpoint) {
|
|
54
|
-
let cursor = "";
|
|
55
|
-
for (let seen = 0; seen < PROJECTS_MAX;) {
|
|
56
|
-
const page = await api("GET", `/projects?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`);
|
|
57
|
-
const projects = page.projects ?? [];
|
|
58
|
-
for (const p of projects) {
|
|
59
|
-
const { endpoints = [] } = await api("GET", `/projects/${encodeURIComponent(p.id)}/endpoints`);
|
|
60
|
-
const at = endpoints.find((e) => e.id === endpoint);
|
|
61
|
-
if (at) return { project: p.id, branch: at.branch_id };
|
|
62
|
-
}
|
|
63
|
-
seen += projects.length;
|
|
64
|
-
cursor = page.pagination?.cursor ?? "";
|
|
65
|
-
if (!projects.length || !cursor) break;
|
|
66
|
-
}
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
async function settled(api, project, branch) {
|
|
71
|
-
for (const until = Date.now() + READY_MS; Date.now() < until; await new Promise((r) => setTimeout(r, 1000))) {
|
|
72
|
-
const got = await api("GET", `/projects/${project}/branches/${branch}`);
|
|
73
|
-
if (got.branch?.current_state === "ready") return true;
|
|
74
|
-
}
|
|
75
|
-
return false;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// How the app's schema is built where it has none yet, by the tool the repository already uses.
|
|
79
|
-
const venvBin = (dir, bin) => ["venv", ".venv"].map((v) => join(dir, v, "bin", bin)).find(existsSync) ?? bin;
|
|
80
|
-
const MIGRATORS = [
|
|
81
|
-
{ tool: "Prisma", finds: (d) => existsSync(join(d, "prisma", "schema.prisma")),
|
|
82
|
-
cmd: (d) => ["npx", existsSync(join(d, "prisma", "migrations")) ? ["--no-install", "prisma", "migrate", "deploy"] : ["--no-install", "prisma", "db", "push", "--skip-generate"]] },
|
|
83
|
-
{ tool: "Drizzle", finds: (d) => ["ts", "js", "mjs", "cjs", "json"].some((x) => existsSync(join(d, `drizzle.config.${x}`))), cmd: () => ["npx", ["--no-install", "drizzle-kit", "push"]] },
|
|
84
|
-
{ tool: "Alembic", finds: (d) => existsSync(join(d, "alembic.ini")), cmd: (d) => [venvBin(d, "alembic"), ["upgrade", "head"]] },
|
|
85
|
-
{ tool: "Django", finds: (d) => existsSync(join(d, "manage.py")), cmd: (d) => [venvBin(d, "python3"), ["manage.py", "migrate", "--noinput"]] },
|
|
86
|
-
{ tool: "Supabase", finds: (d) => existsSync(join(d, "supabase", "migrations")), files: (d) => readdirSync(join(d, "supabase", "migrations")).filter((f) => f.endsWith(".sql")).sort().map((f) => join(d, "supabase", "migrations", f)) },
|
|
87
|
-
];
|
|
88
|
-
|
|
89
|
-
// `values`: every setting the app starts with. `dirs`: the app's folder, then the repository's.
|
|
90
|
-
// `local`: the Postgres on this machine a stand-in is made on. `book`: the ledger, so what a session
|
|
91
|
-
// that died made is undone by the next one.
|
|
92
|
-
export function hostedKeeper({ values, dirs, book, fetchFn = globalThis.fetch, run = runCmd, local = null }) {
|
|
93
|
-
const made = [];
|
|
94
|
-
const neonKey = String(values.NEON_API_KEY ?? "").trim();
|
|
95
|
-
const api = neonKey ? neonApi(neonKey, fetchFn) : null;
|
|
96
|
-
const here = local ?? { host: "localhost", port: 5432, user: process.env.PGUSER || userInfo().username, password: process.env.PGPASSWORD ?? "" };
|
|
97
|
-
let supabase = null;
|
|
98
|
-
// Only a setting that is a whole address can be pointed at a copy: one naming a part of the store
|
|
99
|
-
// would keep pointing at the hosted one.
|
|
100
|
-
const inParts = (s) => !s.names.length || !s.names.every((n) => URL_SETTING.test(String(values[n] ?? "")));
|
|
101
|
-
|
|
102
|
-
const undo = {
|
|
103
|
-
branch: async (row) => { if (!api) return false; await api("DELETE", `/projects/${row.made}`); return true; },
|
|
104
|
-
shadow: (row) => dropPg(here, row.made),
|
|
105
|
-
// Their volumes are kept: a local Supabase they stopped themselves comes back with its data.
|
|
106
|
-
supabase: async (row) => (await run("supabase", ["stop"], { cwd: row.made, timeout: 120_000 })).ok,
|
|
107
|
-
};
|
|
108
|
-
const undone = async (row) => { if (await undo[row.kind](row).catch(() => false)) book.remove(row); };
|
|
109
|
-
|
|
110
|
-
async function neon(s) {
|
|
111
|
-
const endpoint = ENDPOINT.exec(s.host)?.[1];
|
|
112
|
-
const parent = endpoint && (await neonParent(api, endpoint));
|
|
113
|
-
if (!parent) return null;
|
|
114
|
-
const got = await api("POST", `/projects/${parent.project}/branches`, { branch: { parent_id: parent.branch, name: `cortad-${process.pid}-${Date.now()}` }, endpoints: [{ type: "read_write" }] });
|
|
115
|
-
const branch = got.branch?.id;
|
|
116
|
-
const host = got.endpoints?.[0]?.host ?? "";
|
|
117
|
-
const fresh = ENDPOINT.exec(host)?.[1];
|
|
118
|
-
if (!branch) return null;
|
|
119
|
-
const row = { kind: "branch", key: s.key, made: `${parent.project}/branches/${branch}` };
|
|
120
|
-
book.add(row);
|
|
121
|
-
made.push(row);
|
|
122
|
-
if (!fresh || !(await settled(api, parent.project, branch))) return null;
|
|
123
|
-
// Every setting naming the endpoint, pooled or direct, names the branch's: roles and passwords come with it.
|
|
124
|
-
return { how: "neon", env: swapped(values, settingWith(values, endpoint), endpoint, fresh), pass: [host, host.replace(fresh, `${fresh}-pooler`)] };
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// One local Supabase per session, whichever store asks first; stopped at the end only when started here.
|
|
128
|
-
async function localSupabase() {
|
|
129
|
-
const dir = dirs.find((d) => existsSync(join(d, "supabase", "config.toml")));
|
|
130
|
-
if (!dir || !(await run("supabase", ["--version"], { timeout: 15_000 })).ok || !(await run("docker", ["info"], { timeout: 15_000 })).ok) return null;
|
|
131
|
-
let status = await run("supabase", ["status", "-o", "env"], { cwd: dir, timeout: 30_000 });
|
|
132
|
-
let started = false;
|
|
133
|
-
if (!status.ok) {
|
|
134
|
-
const row = { kind: "supabase", key: dir, made: dir };
|
|
135
|
-
book.add(row);
|
|
136
|
-
made.push(row);
|
|
137
|
-
started = true;
|
|
138
|
-
if (!(await run("supabase", ["start"], { cwd: dir, timeout: 600_000 })).ok) return null;
|
|
139
|
-
status = await run("supabase", ["status", "-o", "env"], { cwd: dir, timeout: 30_000 });
|
|
140
|
-
if (!status.ok) return null;
|
|
141
|
-
}
|
|
142
|
-
const vars = Object.fromEntries(status.out.split("\n").map((l) => /^([A-Z_]+)="?([^"]*)"?$/.exec(l.trim())).filter(Boolean).map((m) => [m[1], m[2]]));
|
|
143
|
-
return vars.API_URL ? { vars, started } : null;
|
|
144
|
-
}
|
|
145
|
-
async function supabaseCopy(s) {
|
|
146
|
-
const got = await (supabase ??= localSupabase().catch(() => null));
|
|
147
|
-
if (!got) return null;
|
|
148
|
-
const { vars } = got;
|
|
149
|
-
const env = {};
|
|
150
|
-
if (s.engine === "postgres") for (const n of s.names) env[n] = vars.DB_URL;
|
|
151
|
-
if (s.engine === "supabase") {
|
|
152
|
-
for (const n of s.names) env[n] = vars.API_URL;
|
|
153
|
-
for (const k of Object.keys(values).filter((k) => /SUPABASE/i.test(k))) {
|
|
154
|
-
const v = /ANON|PUBLISHABLE/i.test(k) ? vars.ANON_KEY ?? vars.PUBLISHABLE_KEY : /SERVICE_ROLE|SECRET_KEY/i.test(k) ? vars.SERVICE_ROLE_KEY ?? vars.SECRET_KEY : /JWT_SECRET/i.test(k) ? vars.JWT_SECRET : undefined;
|
|
155
|
-
if (v) env[k] = v;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
return Object.values(env).every(Boolean) && Object.keys(env).length ? { how: "supabase", env, pass: [], started: got.started } : null;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// An empty database on this machine, built by the app's own migrations.
|
|
162
|
-
async function shadow(s) {
|
|
163
|
-
if (inParts(s)) return { why: "parts" };
|
|
164
|
-
const dir = dirs.find((d) => MIGRATORS.some((m) => m.finds(d)));
|
|
165
|
-
const migrator = dir && MIGRATORS.find((m) => m.finds(dir));
|
|
166
|
-
if (!migrator) return { why: "no-migrations" };
|
|
167
|
-
const name = `cortad_shadow_${process.pid}_${Date.now().toString(36)}`;
|
|
168
|
-
const row = { kind: "shadow", key: s.key, made: name };
|
|
169
|
-
book.add(row);
|
|
170
|
-
const got = await emptyPg(here, name);
|
|
171
|
-
if (got.why) { book.remove(row); return { why: got.why === "down" || got.why === "auth" || got.why === "missing" ? "no-local" : "other" }; }
|
|
172
|
-
made.push(row);
|
|
173
|
-
const address = `${encodeURIComponent(here.user)}${here.password ? `:${encodeURIComponent(here.password)}` : ""}@${here.host}:${here.port}/${name}`;
|
|
174
|
-
const env = Object.fromEntries(s.names.map((n) => [n, `${/^([a-z][\w+.-]*):\/\//i.exec(values[n])[1]}://${address}`]));
|
|
175
|
-
const ok = migrator.files
|
|
176
|
-
? await (async () => { for (const f of migrator.files(dir)) if (!(await applySql(here, name, f))) return false; return true; })()
|
|
177
|
-
: (await run(...migrator.cmd(dir), { cwd: dir, env: { ...process.env, ...values, ...env }, timeout: MIGRATE_MS })).ok;
|
|
178
|
-
return ok ? { how: "shadow", tool: migrator.tool, env, pass: [] } : { why: "migrations", tool: migrator.tool };
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
return {
|
|
182
|
-
// Copies a session that died left behind.
|
|
183
|
-
orphans: async () => { for (const kind of Object.keys(undo)) for (const row of book.orphans(kind)) await undone(row); },
|
|
184
|
-
async keep(s) {
|
|
185
|
-
const why = [];
|
|
186
|
-
if (NEON_HOST.test(s.host)) {
|
|
187
|
-
if (!api) why.push("neon-key");
|
|
188
|
-
else { const got = await neon(s).catch(() => null); if (got) return got; why.push("neon"); }
|
|
189
|
-
}
|
|
190
|
-
if (SUPABASE_HOST.test(s.host) || s.engine === "supabase") {
|
|
191
|
-
const got = inParts(s) ? null : await supabaseCopy(s).catch(() => null);
|
|
192
|
-
if (got) return got;
|
|
193
|
-
why.push(inParts(s) ? "parts" : "supabase");
|
|
194
|
-
}
|
|
195
|
-
if (s.engine !== "postgres") return { why: [...why, "engine"] };
|
|
196
|
-
const got = await shadow(s).catch(() => ({ why: "other" }));
|
|
197
|
-
return got.env ? got : { why: [...why, got.why], tool: got.tool };
|
|
198
|
-
},
|
|
199
|
-
drop: async () => { for (const row of made.splice(0).reverse()) await undone(row); },
|
|
200
|
-
};
|
|
201
|
-
}
|