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
package/lib/proc.mjs ADDED
@@ -0,0 +1,100 @@
1
+ // The app's processes: started tied to the runner, found by descent, stopped for certain, and the
2
+ // holder of a port named.
3
+ import { execFile, spawn } from "node:child_process";
4
+ import { basename } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { promisify } from "node:util";
7
+
8
+ const exec = promisify(execFile);
9
+ const TETHER = fileURLToPath(new URL("./tether.mjs", import.meta.url));
10
+
11
+ // ps's etime, [[dd-]hh:]mm:ss, in milliseconds.
12
+ export function elapsedMs(etime) {
13
+ const [days, clock] = String(etime).includes("-") ? String(etime).split("-") : [0, String(etime)];
14
+ const [s = 0, m = 0, h = 0] = clock.split(":").map(Number).reverse();
15
+ return (((Number(days) * 24 + h) * 60 + m) * 60 + s) * 1000;
16
+ }
17
+
18
+ // Every process descended from pid. By descent, not by process group: nodemon, pm2 and concurrently
19
+ // put the real server in a group of its own, and an app started through one of them was never seen
20
+ // to open its port.
21
+ export async function familyOf(pid) {
22
+ const table = (await exec("ps", ["-axo", "pid=,ppid="])).stdout.trim().split("\n").map((l) => l.trim().split(/\s+/).map(Number));
23
+ const family = new Set([pid]);
24
+ for (let grew = true; grew;) { grew = false; for (const [p, parent] of table) if (family.has(parent) && !family.has(p)) { family.add(p); grew = true; } }
25
+ return [...family];
26
+ }
27
+
28
+ // TCP ports pid and its descendants listen on.
29
+ export async function listening(pid) {
30
+ try {
31
+ const { stdout } = await exec("lsof", ["-nP", "-a", "-p", (await familyOf(pid)).join(","), "-iTCP", "-sTCP:LISTEN", "-Fn"]);
32
+ return [...new Set([...stdout.matchAll(/^n.*:(\d+)$/gm)].map((m) => Number(m[1])).filter((p) => p > 0))];
33
+ } catch { return []; }
34
+ }
35
+
36
+ // The top of the chain that runs the app: nodemon, npm, the sh -c under it. Climbs from the listener
37
+ // while the parent is a runner, never into the person's own shell or terminal.
38
+ const RUNNER = /^(?:\S*\/)?(?:node|npm|npx|pnpm|yarn|bun|deno|python[\d.]*|uvicorn|gunicorn|flask|tsx|ts-node|nodemon|concurrently|pm2)(?:\s|$)|^(?:\/bin\/)?sh -c\b/;
39
+ export async function chainOf(pid) {
40
+ const rows = (await exec("ps", ["-axo", "pid=,ppid=,etime=,args="])).stdout.trim().split("\n").map((l) => l.trim());
41
+ const table = new Map(rows.map((l) => { const m = /^(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(l); return m ? [Number(m[1]), { pid: Number(m[1]), ppid: Number(m[2]), etime: m[3], args: m[4] }] : [0, null]; }));
42
+ const chain = table.get(pid) ? [table.get(pid)] : [];
43
+ for (let i = 0; i < 8 && chain.length; i++) {
44
+ const parent = table.get(chain.at(-1).ppid);
45
+ if (!parent || !RUNNER.test(parent.args)) break;
46
+ chain.push(parent);
47
+ }
48
+ return chain;
49
+ }
50
+ export const supervisorOf = async (pid) => (await chainOf(pid)).at(-1)?.pid ?? pid;
51
+
52
+ // The process listening on a port.
53
+ export async function listenerOn(port) {
54
+ try { return Number((await exec("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"])).stdout.trim().split("\n")[0]) || null; } catch { return null; }
55
+ }
56
+
57
+ // The port a bind failure names, in the app's own words: uvicorn's "('127.0.0.1', 8105): address
58
+ // already in use", Go's "0.0.0.0:8080: bind: address already in use", Node's "EADDRINUSE: address
59
+ // already in use :::3000" or "... 127.0.0.1:3000", Rails' "... for "127.0.0.1" port 3000". 0 when
60
+ // it names none.
61
+ export const portInError = (said) => Number(/[:\s(,](\d{4,5})\)?:?[^\n\d]{0,12}address already in use/i.exec(said)?.[1] ?? /(?:EADDRINUSE|address already in use)[^\n]{0,60}?[:\s](\d{4,5})\b/i.exec(said)?.[1] ?? 0);
62
+
63
+ // Whoever holds a port: its pid, the program and its first argument (never the rest of its command
64
+ // line, which can carry a key), and the folder it runs in.
65
+ export async function holderOf(port) {
66
+ const pid = await listenerOn(port);
67
+ if (!pid) return null;
68
+ const [args, cwd] = await Promise.all([
69
+ exec("ps", ["-o", "args=", "-p", String(pid)]).then((r) => r.stdout.trim(), () => ""),
70
+ exec("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"]).then((r) => /^n(.+)$/m.exec(r.stdout)?.[1] ?? null, () => null),
71
+ ]);
72
+ return { port, pid, command: args.split(/\s+/).filter(Boolean).slice(0, 2).map((w) => basename(w)).join(" "), cwd };
73
+ }
74
+
75
+ // pid and everything it started, stopped: asked first, then made to. The group is signalled too, for
76
+ // what its leader started before it exited.
77
+ export async function stopTree(pid) {
78
+ const family = await familyOf(pid).catch(() => [pid]);
79
+ const signal = (sig) => { for (const p of family) { try { process.kill(p, sig); } catch { /* already gone */ } } try { process.kill(-pid, sig); } catch { /* no group left */ } };
80
+ const alive = () => family.some((p) => { try { process.kill(p, 0); return true; } catch { return false; } });
81
+ signal("SIGTERM");
82
+ for (let i = 0; i < 15 && alive(); i++) await new Promise((r) => setTimeout(r, 200));
83
+ if (alive()) signal("SIGKILL");
84
+ }
85
+
86
+ // A shell command in a process group of its own, stopped with this process however this process
87
+ // ends. A watchdog (lib/tether.mjs) holds the read end of a pipe from here and stops the command's
88
+ // tree when the pipe closes, which the kernel does for a process killed outright. The watchdog runs
89
+ // with a bare environment, so the hooks handed to the app never load into it.
90
+ export function spawnTied(cmd, { cwd, env }) {
91
+ const app = spawn("/bin/sh", ["-c", cmd], { cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
92
+ if (!app.pid) return app;
93
+ const dog = spawn(process.execPath, [TETHER, String(app.pid)], { stdio: ["pipe", "ignore", "ignore"], detached: true, env: { PATH: process.env.PATH ?? "" } });
94
+ dog.unref();
95
+ dog.stdin.unref?.();
96
+ dog.stdin.on("error", () => { /* the watchdog is gone; the runner stops the app itself */ });
97
+ // An app that ended while this process lives is this process's to clean up, not the watchdog's.
98
+ app.once("exit", () => { try { dog.kill("SIGKILL"); } catch { /* gone */ } });
99
+ return app;
100
+ }