cortad 0.3.1-rc.9 → 0.3.2

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.
Files changed (57) hide show
  1. package/README.md +8 -5
  2. package/lib/app-port.mjs +9 -0
  3. package/lib/auto-reach.mjs +88 -0
  4. package/lib/backing.mjs +230 -0
  5. package/lib/calls.mjs +23 -0
  6. package/lib/cli.mjs +115 -30
  7. package/lib/contact-text.mjs +167 -0
  8. package/lib/data.mjs +16 -22
  9. package/lib/dissection-text.mjs +232 -0
  10. package/lib/document.mjs +75 -0
  11. package/lib/env-secrets.mjs +48 -0
  12. package/lib/failed-text.mjs +36 -0
  13. package/lib/fresh.mjs +33 -12
  14. package/lib/health.mjs +61 -0
  15. package/lib/held.mjs +67 -0
  16. package/lib/home.mjs +55 -17
  17. package/lib/hosted.mjs +201 -0
  18. package/lib/identities.mjs +1 -1
  19. package/lib/keeping.mjs +162 -0
  20. package/lib/ledger.mjs +20 -0
  21. package/lib/lock.mjs +6 -1
  22. package/lib/mcp.mjs +52 -18
  23. package/lib/mint.mjs +179 -38
  24. package/lib/mongo.mjs +74 -0
  25. package/lib/numbers-text.mjs +68 -0
  26. package/lib/pg.mjs +118 -0
  27. package/lib/proc.mjs +100 -0
  28. package/lib/proof.mjs +1014 -0
  29. package/lib/proxy.mjs +184 -0
  30. package/lib/pyhook/cortad_sites.py +218 -0
  31. package/lib/pyhook/cortad_tied.py +291 -0
  32. package/lib/pyhook/sitecustomize.py +1175 -166
  33. package/lib/qdrant.mjs +47 -0
  34. package/lib/reach.mjs +223 -0
  35. package/lib/read-text.mjs +78 -24
  36. package/lib/redis.mjs +130 -0
  37. package/lib/register.mjs +36 -27
  38. package/lib/replay.mjs +464 -34
  39. package/lib/runner.mjs +85 -0
  40. package/lib/service.mjs +25 -0
  41. package/lib/spec.mjs +7 -4
  42. package/lib/start.mjs +33 -2
  43. package/lib/stick.mjs +65 -25
  44. package/lib/stores.mjs +347 -0
  45. package/lib/switches.mjs +36 -0
  46. package/lib/tether.mjs +9 -0
  47. package/lib/text.mjs +314 -179
  48. package/lib/tied.cjs +217 -0
  49. package/lib/trace.cjs +386 -514
  50. package/lib/verbs.mjs +155 -38
  51. package/lib/wire.cjs +882 -0
  52. package/lib/words.mjs +2 -0
  53. package/lib/writes.mjs +2 -2
  54. package/local.mjs +661 -297
  55. package/package.json +31 -1
  56. package/skill/SKILL.md +49 -42
  57. package/skill/references/results.md +279 -102
@@ -0,0 +1,36 @@
1
+ import { at, clip, plural } from "./words.mjs";
2
+
3
+ // The replies that did not count and the doors that were not sent to, said before the score: one
4
+ // sentence whose parts add up to the count, then the request behind each part that has one. A count
5
+ // on its own ("17 of 33 replies failed") left resumeforge's agent asking what was attempted, whose it
6
+ // was, and how to reproduce it.
7
+
8
+ // A row the run held back names a door and why, or, when no door could take it, only the reason.
9
+ export const heldBackLine = (h) => h.why === "not testable here"
10
+ ? `Set aside: ${plural(h.trials, "conversation")} cannot be played here: ${clip(h.surface, 200).replace(/\.$/, "")}.`
11
+ : `Set aside: ${plural(h.trials, "conversation")} on ${h.surface}${h.why ? `, ${clip(h.why, 200)}` : ""}.`;
12
+
13
+ const HELD_SHOWN = 6;
14
+
15
+ export function heldLines(held) {
16
+ const rows = (held ?? []).filter((h) => h.trials > 0);
17
+ const lines = rows.slice(0, HELD_SHOWN).map(heldBackLine);
18
+ if (rows.length > HELD_SHOWN) lines.push(`Set aside: ${plural(rows.slice(HELD_SHOWN).reduce((n, h) => n + h.trials, 0), "more conversation")} for ${plural(rows.length - HELD_SHOWN, "other reason")}.`);
19
+ return lines;
20
+ }
21
+
22
+ export function failedLines(f) {
23
+ if (!f) return [];
24
+ const lines = f.said ? [f.said] : [];
25
+ for (const row of f.rows ?? []) {
26
+ const sent = row.curl ?? row.request;
27
+ if (!sent && !row.verify) continue;
28
+ const [path, line] = (row.at ?? "").split(/:(?=\d+$)/);
29
+ const where = row.at ? `at ${at(path, Number(line))}` : `from ${row.door}`;
30
+ if (sent) lines.push(` Request for the ${row.count} ${where}: ${clip(sent, 900)}`);
31
+ if (sent && row.response) lines.push(` Answered: ${clip(row.response.replace(/\s+/g, " "), 300)}`);
32
+ if (row.printed) lines.push(` Your app printed: "${clip(row.printed, 300)}"`);
33
+ if (row.verify) lines.push(sent ? ` verify ${row.verify} replays them.` : ` verify ${row.verify} replays the ${row.count} ${where}.`);
34
+ }
35
+ return [...lines, ...heldLines((f.held ?? []).map((h) => ({ surface: h.door, trials: h.trials, why: h.why })))];
36
+ }
package/lib/fresh.mjs CHANGED
@@ -8,7 +8,8 @@ import { createHash } from "node:crypto";
8
8
  import { readFileSync, statSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
  import { TEST } from "./data.mjs";
11
- import { homeOf, readApp, readRunner } from "./home.mjs";
11
+ import { homeOf, readApp } from "./home.mjs";
12
+ import { readRunner } from "./runner.mjs";
12
13
  import { writesDirOf, writtenPaths } from "./writes.mjs";
13
14
 
14
15
  // A command, or an app's own output, that says it picks a saved file up by itself. Not a compiler
@@ -38,13 +39,6 @@ export function commandText(cmd, dir) {
38
39
  return text;
39
40
  }
40
41
 
41
- // ps's etime, [[dd-]hh:]mm:ss, in milliseconds.
42
- export function elapsedMs(etime) {
43
- const [days, clock] = String(etime).includes("-") ? String(etime).split("-") : [0, String(etime)];
44
- const [s = 0, m = 0, h = 0] = clock.split(":").map(Number).reverse();
45
- return (((Number(days) * 24 + h) * 60 + m) * 60 + s) * 1000;
46
- }
47
-
48
42
  // Not the app's code: this command's own line and hooks, the coding agents' folders, and tests.
49
43
  const NOT_APP = /^(?:AGENTS|CLAUDE)\.md$|^\.github\/copilot-instructions\.md$|^\.(?:claude|codex|cursor|agents)\//;
50
44
  const digestOf = (file) => { try { return createHash("sha1").update(readFileSync(file)).digest("hex"); } catch { return ""; } };
@@ -71,6 +65,26 @@ export function changesOf({ root, app, skip = new Set() }) {
71
65
  return { changed: changed.sort((a, b) => b.at - a.at), last };
72
66
  }
73
67
 
68
+ // The app's source files saved after `since`, the start of the run the next one is compared with, or
69
+ // gone since: what a run after a change is scoped by on Cortad's side. A file the app is writing now
70
+ // is not a change, nor one holding the bytes it had when the app started before `since` (the app's
71
+ // own writes, put back after a run). Null when `since` is not a time.
72
+ // `since` is Cortad's clock and a file's time is this machine's: a minute of slack, so a clock running
73
+ // behind still counts a save. A save just before the run that is counted again only widens the run.
74
+ const SKEW_MS = 60_000;
75
+
76
+ export function savedSince({ root, files, since, startedAt, skip = new Set() }) {
77
+ const t = Date.parse(since ?? "");
78
+ if (!Number.isFinite(t)) return null;
79
+ const hashedBefore = Date.parse(startedAt ?? "") <= t;
80
+ return Object.entries(files).filter(([rel, was]) => {
81
+ if (skip.has(rel)) return false;
82
+ let at;
83
+ try { at = statSync(join(root, rel)).mtimeMs; } catch { return true; }
84
+ return at > t - SKEW_MS && !(hashedBefore && was && digestOf(join(root, rel)) === was);
85
+ }).map(([rel]) => rel).sort();
86
+ }
87
+
74
88
  // What the run's text says about the code it played (lib/text.mjs testedLine).
75
89
  export const testedOf = (app, { changed, last }) => ({
76
90
  startedAt: app.startedAt,
@@ -88,6 +102,11 @@ export function makeCode({ project, root, waitMs, base, signal = (pid) => proces
88
102
  };
89
103
  const changes = (app) => changesOf({ root, app, skip: writtenPaths(writesDirOf(homeOf(project, base))) });
90
104
  const tested = () => { const app = current(); return app ? testedOf(app, changes(app)) : null; };
105
+ // Every source file the app was started with, whoever holds it up now.
106
+ const saved = (since) => {
107
+ const app = readApp(project, base);
108
+ return Object.keys(app?.files ?? {}).length ? savedSince({ root, files: app.files, since, startedAt: app.startedAt, skip: writtenPaths(writesDirOf(homeOf(project, base))) }) : null;
109
+ };
91
110
 
92
111
  const fresh = async () => {
93
112
  const app = current();
@@ -97,12 +116,14 @@ export function makeCode({ project, root, waitMs, base, signal = (pid) => proces
97
116
  try { signal(app.runner); } catch { return { error: "The process holding your app up could not be asked to start it again for your change." }; }
98
117
  while (now() - asked < waitMs) {
99
118
  await sleep(500);
119
+ const held = readRunner(project, base);
120
+ if (!held) return { error: "The process holding your app up ended while starting it again for your change." };
121
+ if (held.state === "failed" && Date.parse(held.at) >= asked) return { error: `Your app was started again to play your change and did not come back. ${held.error ?? ""}`.trim() };
122
+ // Started after the ask, or by the command's own restart for the save, which the ask can land in.
100
123
  const next = readApp(project, base);
101
- if (next?.failedAt && Date.parse(next.failedAt) >= asked) return { error: `Your app was started again to play your change and did not come back. ${next.error ?? ""}`.trim() };
102
- if (next?.startedAt && Date.parse(next.startedAt) >= asked) return { restarted: changed.map((c) => c.path) };
103
- if (!readRunner(project, base)) return { error: "The process holding your app up ended while starting it again for your change." };
124
+ if (next?.startedAt && (Date.parse(next.startedAt) >= asked || (next.startedAt !== app.startedAt && !changes(next).changed.length))) return { restarted: changed.map((c) => c.path) };
104
125
  }
105
126
  return { error: `Your app was started again to play your change and has not answered after ${Math.round(waitMs / 1000)} seconds.` };
106
127
  };
107
- return { fresh, tested };
128
+ return { fresh, tested, saved };
108
129
  }
package/lib/health.mjs ADDED
@@ -0,0 +1,61 @@
1
+ // Whether the app a run is using is alive, read the way a person would: is its process still there,
2
+ // does anything still hold its port, and only then does it answer. An app slow to answer because it
3
+ // is answering our own requests is working, not dead: resumeforge held sixteen of them on a database
4
+ // pool of five, missed two probes of 2.5 seconds, and was restarted in the middle of the run.
5
+ import { connect } from "node:net";
6
+
7
+ const FLOOR_MS = 10_000;
8
+ const CAP_MS = 120_000;
9
+ const ASK_EVERY_MS = 60_000;
10
+ const url = (host, port) => `http://${host === "::1" ? "[::1]" : host}:${port}/`;
11
+
12
+ // Opens the socket and sends nothing. Only a refusal means nobody holds the port: a listener too
13
+ // busy to accept still holds it.
14
+ export const portHeld = (host, port, ms = 3000) => new Promise((done) => {
15
+ const socket = connect({ host, port });
16
+ const end = (held) => { socket.destroy(); done(held); };
17
+ socket.once("connect", () => end(true));
18
+ socket.once("error", (e) => end(e.code !== "ECONNREFUSED"));
19
+ socket.setTimeout(ms, () => end(true));
20
+ });
21
+
22
+ // `gone`: the process this command started has exited. `inFlight`: the run's requests the app has
23
+ // not answered yet. Held port is checked every time by a bare connect, which an app does not log;
24
+ // whether it answers is asked with HEAD / once a minute while it is up, and at every check once it
25
+ // was not: a GET / every two seconds put 1,548 "GET / 404" lines in one app's own log. The probe
26
+ // waits three times the app's last reply to it, never under ten seconds or over two minutes.
27
+ export function makeHealth({ host, gone, inFlight, askEveryMs = ASK_EVERY_MS, now = Date.now }) {
28
+ let took = 0;
29
+ let askedAt = -Infinity;
30
+ let last = "up";
31
+ const timeout = () => Math.min(CAP_MS, Math.max(FLOOR_MS, took * 3));
32
+ const state = (s) => (last = s);
33
+ return {
34
+ timeout,
35
+ // "up", "busy" (slow while the run's requests are open: never restarted), or why it is down:
36
+ // "exited", "released" (nothing holds its port) or "quiet" (no answer with nothing of ours open).
37
+ async check(port) {
38
+ if (gone()) return state("exited");
39
+ if (!(await portHeld(host(), port))) return state("released");
40
+ if (last === "up" && now() - askedAt < askEveryMs) return "up";
41
+ const started = now();
42
+ askedAt = started;
43
+ try {
44
+ const res = await fetch(url(host(), port), { method: "HEAD", signal: AbortSignal.timeout(timeout()), redirect: "manual" });
45
+ await res.body?.cancel();
46
+ took = now() - started;
47
+ return state("up");
48
+ } catch {
49
+ return state(inFlight() > 0 ? "busy" : "quiet");
50
+ }
51
+ },
52
+ };
53
+ }
54
+
55
+ // The runner's own line for a restart, in the person's words.
56
+ export function downLine(state, { port, downMs, crashed = false }) {
57
+ if (crashed) return "your app crashed, starting it again";
58
+ if (state === "exited") return "your app exited, starting it again";
59
+ if (state === "released") return `nothing is listening on port ${port} any more, starting your app again`;
60
+ return `your app has not answered for ${Math.round(downMs / 1000)} seconds while no test conversation was waiting on it, starting it again`;
61
+ }
package/lib/held.mjs ADDED
@@ -0,0 +1,67 @@
1
+ // The run's requests this command is holding open at your app, by the bus job that asked for each.
2
+ // A request the run gives up on (its wait ran out, or the run ended) is let go here as well, so
3
+ // your app is not left working for nobody. music-store-support (2026-09-29, job 1c7500f0) is a
4
+ // Gradio app, whose queue serves one event at a time: the run's requests stayed open after it had
5
+ // stopped waiting, and the agent's own test chat waited five minutes behind them.
6
+ //
7
+ // An app's own queue can keep a request after its caller leaves. The hook in the app sees it: a
8
+ // model call that started after its request was let go, for a request the app was not already at
9
+ // work on, in an app never seen working on two requests at once. That is said once, with how many
10
+ // were let go with it and had not reached the model yet, the most that can still be ahead of
11
+ // anything sent.
12
+ const TOGETHER_MS = 10_000;
13
+
14
+ export function makeHeld({ rows = () => [], say = () => {}, now = Date.now, everyMs = 2000 } = {}) {
15
+ const open = new Map();
16
+ const dropped = new Map();
17
+ let settled = false;
18
+ let timer = null;
19
+
20
+ const startOf = (r) => r.at - (r.ms ?? 0);
21
+ // Two requests' model calls under way at once: your app serves requests side by side, so what it
22
+ // goes on with after a cancel is its own work, never a queue ahead of the next request.
23
+ const sideBySide = (calls) => calls.some((a) => calls.some((b) => a.turn !== b.turn && startOf(a) <= startOf(b) && startOf(b) < a.at));
24
+ const settle = () => { settled = true; clearInterval(timer); };
25
+ const check = () => {
26
+ if (settled) return;
27
+ const tagged = rows().filter((r) => typeof r?.turn === "string");
28
+ if (sideBySide(tagged)) return settle();
29
+ const calls = tagged.filter((r) => dropped.has(r.turn));
30
+ // A request your app was already at work on goes on to its next model call with or without a queue.
31
+ const working = new Set(calls.filter((r) => startOf(r) < dropped.get(r.turn)).map((r) => r.turn));
32
+ const taken = calls.find((r) => !working.has(r.turn) && startOf(r) >= dropped.get(r.turn));
33
+ if (!taken) return;
34
+ settle();
35
+ // Let go together with the one taken up, and not already at work when they were.
36
+ const at = dropped.get(taken.turn);
37
+ const n = [...dropped].filter(([turn, t]) => Math.abs(t - at) <= TOGETHER_MS && !working.has(turn)).length;
38
+ say(n > 1
39
+ ? `your app took up a request after the run cancelled it: its own queue keeps requests after the caller leaves, so up to ${n} the run cancelled may be ahead of anything sent to your app until they are done or your app restarts`
40
+ : "your app took up a request after the run cancelled it: its own queue keeps requests after the caller leaves, so anything sent to your app waits behind it until it is done or your app restarts");
41
+ };
42
+
43
+ return {
44
+ // A request going out for bus job `id`, tagged with the run's `turn`: the signal that lets it go.
45
+ hold(id, turn) {
46
+ const stop = new AbortController();
47
+ open.set(id, { stop, turn });
48
+ return stop.signal;
49
+ },
50
+ done(id) { open.delete(id); },
51
+ // Lets go of the requests of these jobs that are still open; how many were.
52
+ letGo(ids) {
53
+ let n = 0;
54
+ for (const id of ids) {
55
+ const held = open.get(id);
56
+ if (!held) continue;
57
+ open.delete(id);
58
+ held.stop.abort(new Error("cancelled by the run"));
59
+ if (held.turn) dropped.set(held.turn, now());
60
+ n += 1;
61
+ }
62
+ if (n && !settled && !timer) { timer = setInterval(check, everyMs); timer.unref?.(); }
63
+ return n;
64
+ },
65
+ check,
66
+ };
67
+ }
package/lib/home.mjs CHANGED
@@ -1,12 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
2
+ import { closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, writeSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
6
  // What this machine keeps between sessions, per project, under ~/.cortad/<project>/: the key the
7
- // first connect left behind, the digest of the tree last uploaded, which process is holding the app
8
- // up, and the run this machine asked for last. The folder's path never leaves the machine; the
9
- // project is a hash of it.
7
+ // first connect left behind, the digest of the tree last uploaded, the app as last started, and the
8
+ // run this machine asked for last. The process holding the app up keeps its own file there
9
+ // (lib/runner.mjs). The folder's path never leaves the machine; the project is a hash of it.
10
10
  export const projectOf = (root) => createHash("sha256").update(realpathSync(root)).digest("hex").slice(0, 16);
11
11
  export const homeOf = (project, base = join(homedir(), ".cortad")) => join(base, project);
12
12
 
@@ -20,19 +20,6 @@ export const writeToken = (project, token, base) => write(homeOf(project, base),
20
20
  export const readDigest = (project, base) => read(join(homeOf(project, base), "digest"));
21
21
  export const writeDigest = (project, digest, base) => write(homeOf(project, base), "digest", digest);
22
22
 
23
- // The process holding this project's app up for runs, if one is alive. A pid that no longer answers
24
- // is a stale file, not a runner.
25
- export function readRunner(project, base) {
26
- const raw = read(join(homeOf(project, base), "runner.json"));
27
- if (!raw) return null;
28
- let runner;
29
- try { runner = JSON.parse(raw); } catch { return null; }
30
- if (!Number.isInteger(runner?.pid)) return null;
31
- try { process.kill(runner.pid, 0); return runner; } catch { return null; }
32
- }
33
- export const writeRunner = (project, runner, base) => write(homeOf(project, base), "runner.json", JSON.stringify(runner));
34
- export const clearRunner = (project, base) => { try { rmSync(join(homeOf(project, base), "runner.json")); } catch { /* gone */ } };
35
-
36
23
  // The app that runner holds up, as it noted it last (lib/fresh.mjs): when it started, whether that
37
24
  // runner started it, and what its source files said then.
38
25
  export const readApp = (project, base) => readJson(join(homeOf(project, base), "app.json"));
@@ -42,3 +29,54 @@ export const readPending = (project, base) => readJson(join(homeOf(project, base
42
29
  export const writePending = (project, pending, base) => write(homeOf(project, base), "pending.json", JSON.stringify(pending));
43
30
 
44
31
  export const hasHome = (base = join(homedir(), ".cortad")) => existsSync(base);
32
+
33
+ // What the server told this machine while no agent was listening (a run ending), kept until the
34
+ // hook hands it to the agent on its next prompt or edit, or the agent reads that run itself. Each
35
+ // line is said once. Where no hook ever takes it, only the latest lines stay.
36
+ //
37
+ // Three processes touch the file: the connector appends, a hook takes, a verb drops a run's line.
38
+ // Nobody reads it in place. It is taken by rename first, so no two of them ever hold the same
39
+ // lines, and what is kept goes back by append, after whatever landed in between.
40
+ const NEWS_KEPT = 20;
41
+ const HEARD_KEPT = 50;
42
+ const newsFile = (project, base) => join(homeOf(project, base), "news.txt");
43
+ const linesOf = (file) => (read(file) ?? "").split("\n").map((l) => l.trim()).filter(Boolean);
44
+ // Appended through a handle that refuses a symbolic link: the lines are the server's, and a link
45
+ // planted at this name must not point them at another file.
46
+ function appendNews(project, base, lines) {
47
+ mkdirSync(homeOf(project, base), { recursive: true, mode: 0o700 });
48
+ const fd = openSync(newsFile(project, base), constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
49
+ try { writeSync(fd, `${lines.join("\n")}\n`); } finally { closeSync(fd); }
50
+ }
51
+ export function takeNews(project, base) {
52
+ const taken = `${newsFile(project, base)}.${process.pid}`;
53
+ try { renameSync(newsFile(project, base), taken); } catch { return []; }
54
+ // A link moved here is removed unread: its target is not news.
55
+ const lines = lstatSync(taken).isSymbolicLink() ? [] : linesOf(taken);
56
+ rmSync(taken, { force: true });
57
+ return lines;
58
+ }
59
+ const rewriteNews = (project, base, keep) => {
60
+ const kept = keep(takeNews(project, base));
61
+ if (kept.length) appendNews(project, base, kept);
62
+ };
63
+ // The runs the agent has read to their end, so a line that names one is not kept for it: the
64
+ // server marks a run finished a moment before it says the line.
65
+ const heardFile = (project, base) => join(homeOf(project, base), "heard.txt");
66
+ export function addNews(project, line, base) {
67
+ if (linesOf(heardFile(project, base)).some((id) => line.includes(id))) return;
68
+ appendNews(project, base, [line]);
69
+ if (linesOf(newsFile(project, base)).length > NEWS_KEPT) rewriteNews(project, base, (lines) => lines.slice(-NEWS_KEPT));
70
+ }
71
+ /** Whether the agent has read this run to its end on this machine. */
72
+ export const wasHeard = (project, runId, base) => linesOf(heardFile(project, base)).includes(runId);
73
+ /** The agent has read this run itself: the line kept to tell it of that run is dropped, and one that arrives later is not kept. */
74
+ export function dropNews(project, runId, base) {
75
+ if (typeof runId !== "string" || runId.length < 8) return;
76
+ rewriteNews(project, base, (lines) => lines.filter((l) => !l.includes(runId)));
77
+ const heard = [...linesOf(heardFile(project, base)).filter((id) => id !== runId), runId].slice(-HEARD_KEPT);
78
+ const next = `${heardFile(project, base)}.${process.pid}`;
79
+ mkdirSync(homeOf(project, base), { recursive: true, mode: 0o700 });
80
+ writeFileSync(next, `${heard.join("\n")}\n`, { mode: 0o600 });
81
+ renameSync(next, heardFile(project, base));
82
+ }
package/lib/hosted.mjs ADDED
@@ -0,0 +1,201 @@
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
+ }
@@ -26,7 +26,7 @@ const SOURCE = /^(?:env:[A-Z_][A-Z0-9_]*(?:\|[A-Z_][A-Z0-9_]*)*|gen:(?:username|
26
26
  // before the program saw it. Still no quote, angle bracket, `$`, backtick or semicolon.
27
27
  const WHERE = /^[\w./:@()[\] -]{1,300}$/;
28
28
  const BASE = /^http:\/\/(?:127\.0\.0\.1|\[::1\]|localhost):\d{2,5}$/;
29
- const KINDS = new Set(["header", "login", "jwt", "upstream", "firebase", "authjs", "supabase", "basic"]);
29
+ const KINDS = new Set(["header", "login", "jwt", "upstream", "firebase", "authjs", "supabase", "basic", "clerk", "supabase-admin"]);
30
30
  const STATUSES = new Set(["minted", "sealed", "absent", "refused", "unreachable", "upstream"]);
31
31
 
32
32
  // A credential the app's own environment names for its own account: a user-side name and a