cortad 0.1.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 +53 -0
- package/lib/door.mjs +171 -0
- package/lib/identities.mjs +397 -0
- package/lib/lock.mjs +76 -0
- package/lib/mint.mjs +133 -0
- package/lib/pyhook/sitecustomize.py +301 -0
- package/lib/replay.mjs +49 -0
- package/lib/start.mjs +85 -0
- package/lib/trace.cjs +103 -0
- package/local.mjs +620 -0
- package/package.json +29 -0
package/local.mjs
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Brainsless on your own machine. Run from your repository's root with the code the connect
|
|
3
|
+
// screen showed:
|
|
4
|
+
// npx cortad ABCD2345 [--port 3000] [--start "npm run dev"] [--verbose]
|
|
5
|
+
//
|
|
6
|
+
// What it does: signs in with the code, uploads your source files once (never .env, never
|
|
7
|
+
// node_modules) so your code can be read, starts your app the way you start it, then holds one
|
|
8
|
+
// outbound connection open and does what a run asks: a request to your app, a file read, a shell
|
|
9
|
+
// line, an edit. An edit goes through one door (lib/door.mjs) that saves what was there first, so
|
|
10
|
+
// every change can be put back from the browser; the shell is locked by the operating system
|
|
11
|
+
// (lib/lock.mjs) and cannot write your code at all. Nothing here touches git. Your environment
|
|
12
|
+
// never leaves this machine. Ctrl-C ends everything.
|
|
13
|
+
|
|
14
|
+
import { spawn, execFile } from "node:child_process";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, watch, writeFileSync } from "node:fs";
|
|
17
|
+
import { homedir, tmpdir } from "node:os";
|
|
18
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
19
|
+
import { createInterface } from "node:readline";
|
|
20
|
+
import { promisify } from "node:util";
|
|
21
|
+
import { openDoor } from "./lib/door.mjs";
|
|
22
|
+
import { lockHolds, makeLock } from "./lib/lock.mjs";
|
|
23
|
+
import { AS_HEADER, makeIdentities } from "./lib/mint.mjs";
|
|
24
|
+
import { CAPTURED, makeCapture } from "./lib/replay.mjs";
|
|
25
|
+
import { startPlan } from "./lib/start.mjs";
|
|
26
|
+
|
|
27
|
+
const argv = process.argv.slice(2);
|
|
28
|
+
const flag = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
|
|
29
|
+
const verbose = argv.includes("--verbose");
|
|
30
|
+
const code = (argv.find((a) => /^[A-Za-z0-9-]{8,9}$/.test(a) && !a.startsWith("-")) ?? "").toUpperCase().replace(/-/g, "");
|
|
31
|
+
const say = (line) => console.log(`cortad ${line}`);
|
|
32
|
+
const fail = (line) => { console.error(`cortad ${line}`); process.exit(1); };
|
|
33
|
+
|
|
34
|
+
const explain = argv.includes("--explain");
|
|
35
|
+
if (!explain && !/^[A-Z0-9]{8}$/.test(code)) fail("usage: npx cortad <code from the connect screen> [--port N] [--start \"cmd\"] | npx cortad --explain");
|
|
36
|
+
// Where Brainsless is. The host is not on the command line: a code cannot point at an impostor.
|
|
37
|
+
const origin = new URL(process.env.CORTAD_ORIGIN || "https://cortad.com");
|
|
38
|
+
// Ours, and only ours. brainsless.com is the same service under its earlier name and stays trusted
|
|
39
|
+
// while people still hold links to it.
|
|
40
|
+
const OURS = ["cortad.com", "brainsless.com"];
|
|
41
|
+
const trusted = (origin.protocol === "https:" && OURS.some((host) => origin.hostname === host || origin.hostname.endsWith(`.${host}`)))
|
|
42
|
+
|| origin.hostname === "localhost" || origin.hostname === "127.0.0.1";
|
|
43
|
+
if (!trusted) fail(`refusing: ${origin.host} is not Cortad.`);
|
|
44
|
+
const api = `${origin.origin}/api`;
|
|
45
|
+
|
|
46
|
+
const root = process.cwd();
|
|
47
|
+
const MANIFEST = ["package.json", "pyproject.toml", "requirements.txt", "go.mod", "Cargo.toml", "docker-compose.yml", "docker-compose.yaml", "Gemfile", "mix.exs"];
|
|
48
|
+
if (!MANIFEST.some((f) => existsSync(join(root, f)))) fail(`this folder has no package.json or pyproject.toml. Run it from your repository's root: ${root}`);
|
|
49
|
+
|
|
50
|
+
// ---- what leaves the machine: source files only
|
|
51
|
+
const SKIP_DIR = /^(node_modules|\.git|dist|build|out|coverage|vendor|venv|\.venv|env|target|tmp|\.next|\.nuxt|\.turbo|\.cache|__pycache__|\.terraform|\.wrangler|\.svelte-kit|\.output|\.parcel-cache|\.idea|\.vscode)$/;
|
|
52
|
+
const SKIP_FILE = /^\.env(\..*)?$|\.(pem|key|p12|pfx|jks|keystore|sqlite|sqlite3|db|log|lock|map|zip|tar|gz|tgz|7z|rar|png|jpe?g|gif|webp|ico|svg|mp3|mp4|wav|mov|pdf|woff2?|ttf|otf|eot|bin|exe|dll|so|dylib|wasm|onnx|pt|pth|safetensors|parquet|csv|xlsx?|numbers|DS_Store)$/i;
|
|
53
|
+
const MAX_FILE = 1_000_000;
|
|
54
|
+
const MAX_TOTAL = 80_000_000;
|
|
55
|
+
const ENV_FILE = /^\.env(\.(local|staging|stage|development|dev|test|example|sample))?$/;
|
|
56
|
+
|
|
57
|
+
function walk(dir, depth, out, envs, total) {
|
|
58
|
+
let entries;
|
|
59
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return total; }
|
|
60
|
+
for (const e of entries) {
|
|
61
|
+
const full = join(dir, e.name);
|
|
62
|
+
if (e.isSymbolicLink()) continue;
|
|
63
|
+
if (e.isDirectory()) { if (depth < 12 && !SKIP_DIR.test(e.name)) total = walk(full, depth + 1, out, envs, total); continue; }
|
|
64
|
+
if (ENV_FILE.test(e.name)) { envs.push(full); continue; }
|
|
65
|
+
if (SKIP_FILE.test(e.name)) continue;
|
|
66
|
+
let size;
|
|
67
|
+
try { size = statSync(full).size; } catch { continue; }
|
|
68
|
+
if (size > MAX_FILE || total + size > MAX_TOTAL) continue;
|
|
69
|
+
out.push(relative(root, full));
|
|
70
|
+
total += size;
|
|
71
|
+
}
|
|
72
|
+
return total;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Values from your env files, read here and only here, so nothing a command prints can carry one.
|
|
76
|
+
function secretValues(envFiles) {
|
|
77
|
+
const values = new Set();
|
|
78
|
+
for (const file of envFiles) {
|
|
79
|
+
let text = "";
|
|
80
|
+
try { text = readFileSync(file, "utf8"); } catch { continue; }
|
|
81
|
+
for (const line of text.split("\n")) {
|
|
82
|
+
const m = /^\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.*)$/.exec(line);
|
|
83
|
+
if (!m) continue;
|
|
84
|
+
const v = m[1].trim().replace(/^(['"])(.*)\1$/, "$2");
|
|
85
|
+
if (v.length >= 8 && !/^(true|false|localhost|development|production|\d+)$/i.test(v)) values.add(v);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return [...values].sort((a, b) => b.length - a.length);
|
|
89
|
+
}
|
|
90
|
+
// Origins your environment names (FRONTEND_URL, CORS_ORIGIN, ...): an app that trusts a browser
|
|
91
|
+
// Origin is asked as that browser. Sent as origins only, never the variable's full value.
|
|
92
|
+
function envOrigins(envFiles) {
|
|
93
|
+
const out = new Set();
|
|
94
|
+
for (const file of envFiles) {
|
|
95
|
+
let text = "";
|
|
96
|
+
try { text = readFileSync(file, "utf8"); } catch { continue; }
|
|
97
|
+
for (const line of text.split("\n")) {
|
|
98
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
99
|
+
if (!m || !/ORIGIN|URL|HOST|DOMAIN|FRONTEND|CLIENT|SITE|WEB/i.test(m[1])) continue;
|
|
100
|
+
for (const v of m[2].replace(/^(['"])(.*)\1$/, "$2").split(",")) {
|
|
101
|
+
try { const u = new URL(v.trim()); if (/^https?:$/.test(u.protocol)) out.add(u.origin); } catch { /* not an origin */ }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return [...out].slice(0, 8);
|
|
106
|
+
}
|
|
107
|
+
// Your app's own request limits, raised for this session only, the way the sandbox raises them:
|
|
108
|
+
// a run asks in twenty minutes what a person asks in a month, and a limiter that fires answers
|
|
109
|
+
// instead of your AI. Numeric values under a limit-shaped name; switches and guards are left alone.
|
|
110
|
+
const THROUGHPUT = /(RATE_?LIMIT|DAILY_LIMIT|HOURLY_LIMIT|MINUTE_LIMIT|REQUESTS_PER|TOKEN_BUDGET|MAX_SSE|MAX_CONCURRENT|THROTTLE|_RPM$|_RPS$|_QPS$)/;
|
|
111
|
+
const GUARDED = /(AUTH|LOGIN|PASSWORD|BREAKER|LOCKOUT|ATTEMPT|FAIL|BAN|BLOCK)/;
|
|
112
|
+
const SWITCH = /_(?:ENABLED|DISABLED|ENABLE|DISABLE)$|^(?:ENABLE|DISABLE)_/;
|
|
113
|
+
function liftedLimits(envFiles) {
|
|
114
|
+
const lifted = {};
|
|
115
|
+
for (const file of envFiles) {
|
|
116
|
+
let text = "";
|
|
117
|
+
try { text = readFileSync(file, "utf8"); } catch { continue; }
|
|
118
|
+
for (const line of text.split("\n")) {
|
|
119
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
120
|
+
if (!m) continue;
|
|
121
|
+
const [, name, raw] = m;
|
|
122
|
+
const value = raw.trim().replace(/^(['"])(.*)\1$/, "$2");
|
|
123
|
+
if (THROUGHPUT.test(name) && !GUARDED.test(name) && !SWITCH.test(name) && /^\d+$/.test(value)) lifted[name] = /(TOKEN_BUDGET|TOKENS)/.test(name) ? "1000000000" : "1000000";
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return lifted;
|
|
127
|
+
}
|
|
128
|
+
let secrets = [];
|
|
129
|
+
let identities = null;
|
|
130
|
+
let capture = null;
|
|
131
|
+
// The app's life, shared by the code that starts it, watches it and restarts it.
|
|
132
|
+
let closing = false;
|
|
133
|
+
let restarting = false;
|
|
134
|
+
let appGone = false;
|
|
135
|
+
let lastSaid = "";
|
|
136
|
+
let lastOutputAt = Date.now();
|
|
137
|
+
let forgetTold = null;
|
|
138
|
+
const mask = (text) => { let s = String(text ?? ""); for (const v of secrets) s = s.split(v).join("[masked]"); return s; };
|
|
139
|
+
|
|
140
|
+
// ---- the wire
|
|
141
|
+
let box = "";
|
|
142
|
+
let key = "";
|
|
143
|
+
async function call(method, path, body, { raw = false, timeoutMs = 60_000 } = {}) {
|
|
144
|
+
const res = await fetch(`${api}${path}`, {
|
|
145
|
+
method,
|
|
146
|
+
headers: { ...(key ? { "x-local-key": key } : {}), "content-type": raw ? "application/octet-stream" : "application/json" },
|
|
147
|
+
body: body === undefined ? undefined : raw ? body : JSON.stringify(body),
|
|
148
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
149
|
+
});
|
|
150
|
+
const text = await res.text();
|
|
151
|
+
let data = null;
|
|
152
|
+
try { data = text ? JSON.parse(text) : null; } catch { data = { error: text.slice(0, 200) }; }
|
|
153
|
+
return { status: res.status, ok: res.ok, data };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---- the box on this machine: your folder, read where it stands
|
|
157
|
+
const work = join(tmpdir(), `cortad-${process.pid}`);
|
|
158
|
+
mkdirSync(work, { recursive: true });
|
|
159
|
+
const bootLog = join(work, "boot.log");
|
|
160
|
+
writeFileSync(bootLog, "");
|
|
161
|
+
const door = openDoor(root);
|
|
162
|
+
// Files nobody may read through this program: keys, and git's own internals.
|
|
163
|
+
const SECRET_PATH = /(?:^|\/)(?:\.git|\.ssh|\.gnupg|\.aws|\.npmrc|\.netrc|id_(?:rsa|ed25519|ecdsa)[^/]*|[^/]*\.(?:pem|key|p12|pfx|jks|keystore))(?:\/|$)/;
|
|
164
|
+
// The engine's paths, as this machine has them. Its scratch files live in this program's own
|
|
165
|
+
// temp folder, never beside your code.
|
|
166
|
+
const translate = (s) => String(s ?? "").split("/workspace/repo").join(root).split("/tmp/bl-boot.log").join(bootLog).split("/tmp/boot.log").join(bootLog);
|
|
167
|
+
const scratch = (p) => { const r = resolve(translate(p)); return r.startsWith("/tmp/") ? join(work, r.slice(5)) : r.startsWith(work + sep) ? r : null; };
|
|
168
|
+
const readable = (p) => {
|
|
169
|
+
const r = resolve(translate(p));
|
|
170
|
+
if (r.startsWith(work + sep)) return r;
|
|
171
|
+
let landed; try { landed = realpathSync(r); } catch { return null; }
|
|
172
|
+
const home = realpathSync(root);
|
|
173
|
+
return (landed === home || landed.startsWith(home + sep)) && !ENV_FILE.test(basename(landed)) && !SECRET_PATH.test(landed) ? landed : null;
|
|
174
|
+
};
|
|
175
|
+
// A shell line runs with a plain environment: the app's own process reads its .env itself, and
|
|
176
|
+
// nothing a world sends inherits this terminal's keys.
|
|
177
|
+
const plainEnv = { PATH: process.env.PATH, HOME: process.env.HOME, LANG: process.env.LANG ?? "C.UTF-8", TMPDIR: work, TERM: "dumb" };
|
|
178
|
+
let lock = null;
|
|
179
|
+
|
|
180
|
+
let app = null;
|
|
181
|
+
async function verb(job) {
|
|
182
|
+
const b = job.body ?? {};
|
|
183
|
+
switch (job.verb) {
|
|
184
|
+
case "exec": {
|
|
185
|
+
const cmd = translate(b.cmd);
|
|
186
|
+
if (verbose) say(`$ ${cmd.slice(0, 160)}`);
|
|
187
|
+
// No lock, no shell: a world never gets an unlocked one on this machine.
|
|
188
|
+
if (!lock) return { success: false, exitCode: 126, stdout: "", stderr: "refused: this machine has no sandbox tool (sandbox-exec or bubblewrap), so no shell line is run here" };
|
|
189
|
+
const { file, args } = lock.wrap(cmd);
|
|
190
|
+
return new Promise((done) => {
|
|
191
|
+
const child = spawn(file, args, { cwd: root, env: plainEnv, stdio: ["ignore", "pipe", "pipe"] });
|
|
192
|
+
let out = "", err = "";
|
|
193
|
+
const cap = (s, chunk) => (s.length < 20_000_000 ? s + chunk : s);
|
|
194
|
+
child.stdout.on("data", (d) => { out = cap(out, d.toString()); });
|
|
195
|
+
child.stderr.on("data", (d) => { err = cap(err, d.toString()); });
|
|
196
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), Math.min(Number(b.opts?.timeout) || 180_000, 280_000));
|
|
197
|
+
child.on("close", (code) => { clearTimeout(timer); done({ success: code === 0, exitCode: code ?? 1, stdout: mask(out), stderr: mask(err) }); });
|
|
198
|
+
child.on("error", (e) => { clearTimeout(timer); done({ success: false, exitCode: 127, stdout: "", stderr: String(e.message) }); });
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
case "fetch": {
|
|
202
|
+
// Only the app's own port: this machine's other services are not the world.
|
|
203
|
+
const port = Number(b.port) || app?.port;
|
|
204
|
+
if (!app || port !== app.port) return { error: `refused: port ${port} is not your app` };
|
|
205
|
+
const path = typeof b.path === "string" && b.path.startsWith("/") ? b.path : "/";
|
|
206
|
+
const headers = {};
|
|
207
|
+
for (const [k, v] of Object.entries(b.headers ?? {})) if (!/^(host|content-length|connection)$/i.test(k)) headers[k] = String(v);
|
|
208
|
+
// A request that speaks as one of your app's own callers carries the role, not the token:
|
|
209
|
+
// the token was issued on this machine and is put in here, so it never travels.
|
|
210
|
+
const marker = Object.keys(headers).find((k) => k.toLowerCase() === AS_HEADER);
|
|
211
|
+
if (marker) {
|
|
212
|
+
const role = headers[marker];
|
|
213
|
+
delete headers[marker];
|
|
214
|
+
if (role === CAPTURED) {
|
|
215
|
+
// Speaking as the person who sent the message we watched: every header their own client sent.
|
|
216
|
+
for (const [name, value] of Object.entries(capture?.headers() ?? {})) { for (const k of Object.keys(headers)) if (k.toLowerCase() === name) delete headers[k]; headers[name] = value; }
|
|
217
|
+
} else {
|
|
218
|
+
const held = await identities?.headerFor(role);
|
|
219
|
+
if (held) { for (const k of Object.keys(headers)) if (k.toLowerCase() === held.name) delete headers[k]; headers[held.name] = held.value; }
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const method = String(b.method ?? "GET").toUpperCase();
|
|
223
|
+
const init = { method, headers, redirect: "manual" };
|
|
224
|
+
if (b.body !== undefined && method !== "GET" && method !== "HEAD") init.body = typeof b.body === "string" ? b.body : JSON.stringify(b.body);
|
|
225
|
+
// A dev server restarts when a file is saved, and during a run files are saved: by the agent
|
|
226
|
+
// working its plan, and by you. For those seconds nothing is listening. A turn that meets a
|
|
227
|
+
// closed door is held until your app answers again and sent then, once, instead of being
|
|
228
|
+
// counted against your app as a failure it never had.
|
|
229
|
+
const ask = async () => {
|
|
230
|
+
const res = await fetch(`http://127.0.0.1:${port}${path}`, { ...init, signal: AbortSignal.timeout(170_000) });
|
|
231
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
232
|
+
const LIMIT = 262_144;
|
|
233
|
+
return { status: res.status, headers: Object.fromEntries(res.headers), body: mask(buf.subarray(0, LIMIT).toString("utf8")), truncated: buf.length > LIMIT };
|
|
234
|
+
};
|
|
235
|
+
try { return await ask(); }
|
|
236
|
+
catch (e) {
|
|
237
|
+
const code = String(e.cause?.code ?? e.code ?? "");
|
|
238
|
+
if (!/ECONNREFUSED|ECONNRESET|EPIPE|UND_ERR_SOCKET/.test(code)) return { error: `nothing answered at port ${port}: ${code || e.message}` };
|
|
239
|
+
for (let i = 0; i < 45 && !(await answers(port)); i++) await new Promise((r) => setTimeout(r, 1000));
|
|
240
|
+
try { return { ...(await ask()), heldForRestart: true }; }
|
|
241
|
+
catch (again) { return { error: `nothing answered at port ${port}: ${String(again.cause?.code ?? again.message)}` }; }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
case "read": {
|
|
245
|
+
const p = readable(b.path);
|
|
246
|
+
if (!p) return { error: "bad path" };
|
|
247
|
+
try { return { content: mask(readFileSync(p, "utf8")) }; } catch (e) { return { error: String(e.code ?? e.message) }; }
|
|
248
|
+
}
|
|
249
|
+
case "write": {
|
|
250
|
+
const bytes = Buffer.from(String(b.b64 ?? ""), "base64");
|
|
251
|
+
// The engine's own scratch file: this program's temp folder, not your code.
|
|
252
|
+
const mine = scratch(b.path);
|
|
253
|
+
if (mine) {
|
|
254
|
+
try { mkdirSync(dirname(mine), { recursive: true }); if (b.append) appendFileSync(mine, bytes); else writeFileSync(mine, bytes); return { success: true, stderr: "" }; }
|
|
255
|
+
catch (e) { return { success: false, stderr: String(e.message) }; }
|
|
256
|
+
}
|
|
257
|
+
// Your code: the one door, which saves what was there before anything changes.
|
|
258
|
+
return door.write(translate(b.path), bytes, { checkpoint: typeof b.checkpoint === "string" && b.checkpoint ? b.checkpoint : "manual", append: b.append === true, exclusive: b.exclusive === true });
|
|
259
|
+
}
|
|
260
|
+
case "changes": return door.changes();
|
|
261
|
+
case "diff": return door.diff(String(b.path ?? ""));
|
|
262
|
+
case "mint": return identities ? identities.mint(b, app?.port) : { identities: [] };
|
|
263
|
+
case "restore": return door.restore(String(b.checkpoint ?? ""));
|
|
264
|
+
case "keep": return door.keep(typeof b.checkpoint === "string" && b.checkpoint ? b.checkpoint : undefined);
|
|
265
|
+
case "restart": return restartApp();
|
|
266
|
+
case "lock": return { locked: Array.isArray(b.hosts) ? b.hosts.length : 0 };
|
|
267
|
+
case "usage": return { totals: [], rows: [] };
|
|
268
|
+
// A world is ended from this terminal, never from the cloud.
|
|
269
|
+
case "destroy": return { ok: true };
|
|
270
|
+
default: return { ok: true };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const exec = promisify(execFile);
|
|
275
|
+
|
|
276
|
+
// ---- start or attach to the app
|
|
277
|
+
const answers = async (port) => {
|
|
278
|
+
try { await fetch(`http://127.0.0.1:${port}/`, { method: "GET", signal: AbortSignal.timeout(2500), redirect: "manual" }); return true; }
|
|
279
|
+
catch { return false; }
|
|
280
|
+
};
|
|
281
|
+
const onPath = (bin) => (process.env.PATH ?? "").split(":").some((dir) => dir && existsSync(join(dir, bin)));
|
|
282
|
+
// Where their app lives inside this repository, and how it starts. Worked out in lib/start.mjs.
|
|
283
|
+
let appDir = root;
|
|
284
|
+
async function ask(question) {
|
|
285
|
+
if (!process.stdin.isTTY) return null;
|
|
286
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
287
|
+
const line = await new Promise((r) => rl.question(question, r));
|
|
288
|
+
rl.close();
|
|
289
|
+
return line.trim() || null;
|
|
290
|
+
}
|
|
291
|
+
let child = null;
|
|
292
|
+
async function startApp() {
|
|
293
|
+
const wanted = Number(flag("--port"));
|
|
294
|
+
if (wanted) {
|
|
295
|
+
if (!(await answers(wanted))) fail(`nothing is answering on port ${wanted}. Start your app first, then run this again.`);
|
|
296
|
+
return { port: wanted, cmd: null };
|
|
297
|
+
}
|
|
298
|
+
const plan = startPlan({ root, typed: flag("--start"), onPath });
|
|
299
|
+
const cmd = plan?.cmd ?? await ask("How do you start your app? (for example: npm run dev) ");
|
|
300
|
+
if (!cmd) fail("tell me how your app starts: --start \"npm run dev\", or --port 3000 if it is already running.");
|
|
301
|
+
appDir = plan?.cwd ?? root;
|
|
302
|
+
pinned = pinnedNode();
|
|
303
|
+
if (plan?.within) say(`your app is in ${plan.within}, started there with: ${cmd}`);
|
|
304
|
+
const lifted = liftedLimits(envFiles);
|
|
305
|
+
if (Object.keys(lifted).length) say(`your own request limits are raised for this session: ${Object.keys(lifted).join(", ")}`);
|
|
306
|
+
launched = { cmd, lifted };
|
|
307
|
+
const up = await launch(180_000);
|
|
308
|
+
if (up.port) return { port: up.port, cmd, lifted: Object.keys(lifted) };
|
|
309
|
+
// Already running: a second start dies on the port the first one holds. The one that is running
|
|
310
|
+
// is the app, so it is used as it stands rather than treated as a failure.
|
|
311
|
+
if (up.exited !== null && /EADDRINUSE|address already in use|port.{0,40}(?:in use|already used|is taken|unavailable)/i.test(up.tail)) {
|
|
312
|
+
const ports = [...up.tail.matchAll(/(?::|port\s*[:=]?\s*)(\d{4,5})\b/gi)].map((m) => Number(m[1]));
|
|
313
|
+
for (const port of new Set(ports)) {
|
|
314
|
+
if (await answers(port)) { launched = null; child = null; say(`your app is already running on port ${port}, so that one is used`); return { port, cmd: null }; }
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (up.tail) console.error(up.tail);
|
|
318
|
+
const wrongNode = pinned.major && !pinned.bin ? ` This project pins Node ${pinned.major} and this shell runs Node ${shellNode}: switch to ${pinned.major}.` : "";
|
|
319
|
+
return { port: null, said: up.tail, why: `${up.exited !== null ? "your app stopped before it answered" : "your app did not answer within three minutes"}; what it said is above.${wrongNode}` };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Your app, started the way you start it, and watched until one of its own ports answers.
|
|
323
|
+
let launched = null;
|
|
324
|
+
// The Node this project pins, when the shell that ran this command has another. Strapi refuses
|
|
325
|
+
// Node 26 outright, and a version manager that never switched in this shell is the usual reason.
|
|
326
|
+
// ponytail: reads .nvmrc and .node-version only; engines ranges when a project pins no other way.
|
|
327
|
+
const shellNode = Number(process.versions.node.split(".")[0]);
|
|
328
|
+
function pinnedNode() {
|
|
329
|
+
let want = "";
|
|
330
|
+
for (const f of [".nvmrc", ".node-version"].flatMap((n) => [join(appDir, n), join(root, n)])) { try { want = readFileSync(f, "utf8").trim(); } catch { /* not pinned here */ } if (want) break; }
|
|
331
|
+
const major = Number(/^v?(\d+)/.exec(want)?.[1]);
|
|
332
|
+
if (!major || major === shellNode) return { major: null, bin: null };
|
|
333
|
+
const under = (dir, tail = "bin") => { try { return readdirSync(dir).filter((v) => v.replace(/^v/, "").startsWith(`${major}.`)).sort().reverse().map((v) => join(dir, v, tail)); } catch { return []; } };
|
|
334
|
+
const bins = [`/opt/homebrew/opt/node@${major}/bin`, `/usr/local/opt/node@${major}/bin`, ...under(join(homedir(), ".nvm/versions/node")), ...under(join(homedir(), ".local/share/fnm/node-versions"), "installation/bin"), ...under(join(homedir(), ".volta/tools/image/node"))];
|
|
335
|
+
return { major, bin: bins.find((d) => existsSync(join(d, "node"))) ?? null };
|
|
336
|
+
}
|
|
337
|
+
let pinned = { major: null, bin: null };
|
|
338
|
+
|
|
339
|
+
async function launch(waitMs) {
|
|
340
|
+
const { cmd, lifted } = launched;
|
|
341
|
+
child = spawn("/bin/sh", ["-c", cmd], { cwd: appDir, env: { ...process.env, ...lifted, ...(capture ? capture.env(process.env) : {}), FORCE_COLOR: "0", ...(pinned.bin ? { PATH: `${pinned.bin}:${process.env.PATH ?? ""}` } : {}) }, stdio: ["ignore", "pipe", "pipe"], detached: true });
|
|
342
|
+
const mine = child;
|
|
343
|
+
let seen = "";
|
|
344
|
+
const onData = (d) => { const s = d.toString(); appendFileSync(bootLog, s); seen = (seen + s).slice(-20_000); lastSaid = seen; lastOutputAt = Date.now(); if (verbose) process.stdout.write(s); };
|
|
345
|
+
mine.stdout.on("data", onData);
|
|
346
|
+
mine.stderr.on("data", onData);
|
|
347
|
+
let exited = null;
|
|
348
|
+
appGone = false;
|
|
349
|
+
mine.on("exit", (code) => { exited = code ?? 1; if (child === mine) appGone = true; });
|
|
350
|
+
const started = Date.now();
|
|
351
|
+
while (Date.now() - started < waitMs) {
|
|
352
|
+
const tail = () => seen.replace(/\x1b\[[0-9;]*m/g, "").split("\n").filter(Boolean).slice(-25).join("\n");
|
|
353
|
+
if (exited !== null) return { port: null, exited, tail: tail() };
|
|
354
|
+
// nodemon and its kind outlive the app they watch: the app is gone, the process is not, and
|
|
355
|
+
// the wait ran its whole three minutes on planless with the reason sitting in the output.
|
|
356
|
+
// Also: the port is taken, under a watcher that does not exit when its app cannot listen. The app
|
|
357
|
+
// that holds the port is theirs and already running, which startApp turns into attaching to it.
|
|
358
|
+
if (/app crashed - waiting for file changes|waiting for (?:file )?changes before restart|Failed running|EADDRINUSE|address already in use/i.test(seen)) {
|
|
359
|
+
await stopApp(mine.pid);
|
|
360
|
+
return { port: null, exited: 1, tail: tail() };
|
|
361
|
+
}
|
|
362
|
+
// The port is what the app's own process group listens on. Never a guess: a developer's
|
|
363
|
+
// machine has other things on 3000 and 8080, and one of them answered for the app once.
|
|
364
|
+
const ports = await listening(mine.pid);
|
|
365
|
+
// The socket can open before the server answers; the log line is the tiebreak among several.
|
|
366
|
+
const plain = seen.replace(/\x1b\[[0-9;]*m/g, "");
|
|
367
|
+
const said = [...plain.matchAll(/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\]):(\d{2,5})\b|\bport\s*[:=]?\s*(\d{4,5})\b/gi)].map((m) => Number(m[1] || m[2]));
|
|
368
|
+
for (const port of [...new Set([...said.reverse().filter((p) => ports.includes(p)), ...ports])]) {
|
|
369
|
+
if (await answers(port)) return { port, exited: null, tail: "" };
|
|
370
|
+
}
|
|
371
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
372
|
+
}
|
|
373
|
+
return { port: null, exited: null, tail: seen.replace(/\x1b\[[0-9;]*m/g, "").split("\n").filter(Boolean).slice(-12).join("\n") };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// The same command again, for an app that does not reload on save. Only an app this program
|
|
377
|
+
// started: one you started yourself is yours to restart.
|
|
378
|
+
async function restartApp() {
|
|
379
|
+
restarting = true;
|
|
380
|
+
try { return await restartNow(); } finally { restarting = false; }
|
|
381
|
+
}
|
|
382
|
+
async function restartNow() {
|
|
383
|
+
if (!launched || !child) return { error: "You started this app yourself, so restart it in your own terminal. Most dev servers reload on save." };
|
|
384
|
+
const port = app.port;
|
|
385
|
+
await stopApp(child.pid);
|
|
386
|
+
for (let i = 0; i < 25 && (await answers(port)); i++) await new Promise((r) => setTimeout(r, 200));
|
|
387
|
+
const up = await launch(90_000);
|
|
388
|
+
if (!up.port) return { error: up.exited !== null ? `Your app exited ${up.exited} on restart.\n${up.tail}` : "Your app was restarted but did not answer within 90 seconds." };
|
|
389
|
+
if (up.port !== port) return { error: `Your app came back on port ${up.port}, not ${port}. Run the command again to reconnect.` };
|
|
390
|
+
return { restarted: true, port };
|
|
391
|
+
}
|
|
392
|
+
// TCP ports your app is listening on: every process descended from the one this program started.
|
|
393
|
+
// By descent, not by process group: nodemon, pm2 and concurrently put the real server in a group of
|
|
394
|
+
// its own, and an app started through one of them was never seen to open its port.
|
|
395
|
+
async function familyOf(pid) {
|
|
396
|
+
const table = (await exec("ps", ["-axo", "pid=,ppid="])).stdout.trim().split("\n").map((l) => l.trim().split(/\s+/).map(Number));
|
|
397
|
+
const family = new Set([pid]);
|
|
398
|
+
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; } }
|
|
399
|
+
return [...family];
|
|
400
|
+
}
|
|
401
|
+
async function listening(pid) {
|
|
402
|
+
try {
|
|
403
|
+
const { stdout } = await exec("lsof", ["-nP", "-a", "-p", (await familyOf(pid)).join(","), "-iTCP", "-sTCP:LISTEN", "-Fn"]);
|
|
404
|
+
return [...new Set([...stdout.matchAll(/^n.*:(\d+)$/gm)].map((m) => Number(m[1])).filter((p) => p > 0))];
|
|
405
|
+
} catch { return []; }
|
|
406
|
+
}
|
|
407
|
+
// Your app and everything it started, stopped for certain. A process group is not enough: nodemon
|
|
408
|
+
// gives the real server a group of its own, and planless's server outlived this command as an
|
|
409
|
+
// orphan still holding its port and its production connections. Asked first, then made to.
|
|
410
|
+
async function stopApp(pid) {
|
|
411
|
+
const family = await familyOf(pid).catch(() => [pid]);
|
|
412
|
+
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 */ } };
|
|
413
|
+
const alive = () => family.some((p) => { try { process.kill(p, 0); return true; } catch { return false; } });
|
|
414
|
+
signal("SIGTERM");
|
|
415
|
+
for (let i = 0; i < 15 && alive(); i++) await new Promise((r) => setTimeout(r, 200));
|
|
416
|
+
if (alive()) signal("SIGKILL");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---- go
|
|
420
|
+
const envFiles = [];
|
|
421
|
+
const files = [];
|
|
422
|
+
walk(root, 0, files, envFiles, 0);
|
|
423
|
+
// --explain: what this would send and start, from this folder, and nothing else. No network, no
|
|
424
|
+
// app started, nothing written. For the person (or the agent) who reads before running.
|
|
425
|
+
if (explain) {
|
|
426
|
+
const bytes = files.reduce((n, f) => { try { return n + statSync(join(root, f)).size; } catch { return n; } }, 0);
|
|
427
|
+
const plan = startPlan({ root, typed: flag("--start"), onPath });
|
|
428
|
+
const rel = (f) => relative(root, f) || ".";
|
|
429
|
+
console.log([
|
|
430
|
+
`cortad --explain (nothing is sent or started by this)`,
|
|
431
|
+
``,
|
|
432
|
+
`talks to ${origin.origin} and localhost (your app's port only)`,
|
|
433
|
+
`would send ${files.length} source files, ${Math.round(bytes / 1024)} KB, once`,
|
|
434
|
+
`never sent .env* (${envFiles.length} found: ${envFiles.slice(0, 6).map(rel).join(", ") || "none"}), key files, node_modules, .git`,
|
|
435
|
+
`env files read on this machine only: to mask their values in your app's replies, and to sign test requests in`,
|
|
436
|
+
`would start ${flag("--port") ? `nothing: uses your app on port ${flag("--port")}` : plan ? `${plan.cmd} (in ${rel(plan.cwd)})` : "asks you how your app starts"}`,
|
|
437
|
+
`loads into app lib/trace.cjs (Node) or lib/pyhook/sitecustomize.py (Python): records the one request during which your app calls a model`,
|
|
438
|
+
`agent edits in your files, each with an undo kept in ~/.cortad/checkpoints; git is never touched`,
|
|
439
|
+
`agent shell sandboxed by the OS: no network but localhost, no writes outside temp and ignored build folders, no .env reads`,
|
|
440
|
+
``,
|
|
441
|
+
`first files ${files.slice(0, 8).join(", ")}${files.length > 8 ? ", ..." : ""}`,
|
|
442
|
+
].join("\n"));
|
|
443
|
+
process.exit(0);
|
|
444
|
+
}
|
|
445
|
+
secrets = secretValues(envFiles);
|
|
446
|
+
const keepSecret = (v) => { if (v && v.length >= 12 && !secrets.includes(v)) secrets.push(v); };
|
|
447
|
+
identities = makeIdentities({ root, work, envFiles, say, keepSecret, appDir: () => appDir });
|
|
448
|
+
// One message sent in their own app tells us the door for certain. The route and the body go up,
|
|
449
|
+
// masked like everything else; the sign-in that message carried stays here.
|
|
450
|
+
capture = makeCapture({ work, keepSecret, onDoor: (door) => {
|
|
451
|
+
let body = door.body;
|
|
452
|
+
try { body = JSON.parse(mask(JSON.stringify(door.body))); } catch { /* sent as it is */ }
|
|
453
|
+
call("POST", `/local/${box}/captured`, { ...door, body }).catch(() => {});
|
|
454
|
+
} });
|
|
455
|
+
if (!files.length) fail("no source files here to read.");
|
|
456
|
+
|
|
457
|
+
// Which project this is, as a hash of where it lives: the same folder coming back resumes the same
|
|
458
|
+
// connection, and the path itself never leaves this machine.
|
|
459
|
+
const project = createHash("sha256").update(realpathSync(root)).digest("hex").slice(0, 16);
|
|
460
|
+
const attach = await call("POST", "/local/attach", { code, name: basename(root), project });
|
|
461
|
+
if (!attach.ok) fail(attach.data?.error ?? `could not sign in (${attach.status})`);
|
|
462
|
+
box = attach.data.box;
|
|
463
|
+
key = attach.data.key;
|
|
464
|
+
|
|
465
|
+
const list = join(work, "files.txt");
|
|
466
|
+
writeFileSync(list, files.join("\n") + "\n");
|
|
467
|
+
const archive = join(work, "tree.tgz");
|
|
468
|
+
await exec("tar", ["-czf", archive, "-C", root, "-T", list]);
|
|
469
|
+
const bytes = readFileSync(archive);
|
|
470
|
+
const PART = 1_500_000;
|
|
471
|
+
let resumed = false;
|
|
472
|
+
for (let off = 0; off < bytes.length; off += PART) {
|
|
473
|
+
const last = off + PART >= bytes.length;
|
|
474
|
+
const put = await call("PUT", `/local/${box}/tree?last=${last ? 1 : 0}`, bytes.subarray(off, off + PART), { raw: true, timeoutMs: 120_000 });
|
|
475
|
+
if (!put.ok) fail(put.data?.error ?? `upload failed (${put.status})`);
|
|
476
|
+
if (last) resumed = put.data?.resumed === true;
|
|
477
|
+
}
|
|
478
|
+
// Unchanged code has already been read: coming back says so instead of claiming a second read.
|
|
479
|
+
say(resumed ? "connected · your code is unchanged, picking up where you left off" : `connected · reading your code (${files.length} files, ${Math.round(bytes.length / 1024)} KB)`);
|
|
480
|
+
|
|
481
|
+
lock = await makeLock({ root, work });
|
|
482
|
+
if (lock && !lockHolds(lock, root)) lock = null;
|
|
483
|
+
if (!lock) say("no sandbox tool on this machine (sandbox-exec or bubblewrap), so no shell line will be run here. Runs and edits still work.");
|
|
484
|
+
|
|
485
|
+
// A change to their own files, settled: what "I fixed it" looks like from here. Folders an app
|
|
486
|
+
// writes to by itself are not a fix.
|
|
487
|
+
const NOT_A_FIX = /(?:^|[\\/])(?:node_modules|\.git|\.next|\.nuxt|\.turbo|\.cache|dist|build|coverage|logs?|tmp|__pycache__|\.venv|venv)(?:[\\/]|$)|\.log$/;
|
|
488
|
+
function sourceChanged() {
|
|
489
|
+
return new Promise((resolve) => {
|
|
490
|
+
let timer = null;
|
|
491
|
+
let watcher = null;
|
|
492
|
+
const done = () => { try { watcher?.close(); } catch { /* closed */ } resolve(); };
|
|
493
|
+
try {
|
|
494
|
+
watcher = watch(root, { recursive: true }, (_event, file) => {
|
|
495
|
+
if (!file || NOT_A_FIX.test(String(file))) return;
|
|
496
|
+
clearTimeout(timer);
|
|
497
|
+
timer = setTimeout(done, 1200);
|
|
498
|
+
});
|
|
499
|
+
} catch { setTimeout(done, 15_000); }
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
// `watching` says whether a message sent to this app can be seen arriving: only an app this command
|
|
503
|
+
// started carries the hook, and only a runtime the hook exists for.
|
|
504
|
+
const announce = () => call("POST", `/local/${box}/app`, { port: app.port, cmd: app.cmd, origins: envOrigins(envFiles), lifted: app.lifted ?? [], watching: Boolean(launched && capture?.alive()) });
|
|
505
|
+
|
|
506
|
+
// Your app's life beside this connection. It is started; if it stops, or never comes up, this stays
|
|
507
|
+
// and starts it again the moment you save a fix, and the browser is told each time it answers, so a
|
|
508
|
+
// world still looking for your chat asks again by itself. Nothing is ever rerun by hand.
|
|
509
|
+
async function appLife() {
|
|
510
|
+
for (let first = true; ; first = false) {
|
|
511
|
+
const got = await startApp();
|
|
512
|
+
if (got.port) { app = got; break; }
|
|
513
|
+
await call("POST", `/local/${box}/stopped`, { said: mask(got.said ?? "").slice(-2000) }).catch(() => {});
|
|
514
|
+
say(`${got.why} Fix it and save: it is started again by itself.`);
|
|
515
|
+
if (first) say("leave this open. Ctrl-C disconnects.");
|
|
516
|
+
await sourceChanged();
|
|
517
|
+
say("saw your change, starting your app again");
|
|
518
|
+
}
|
|
519
|
+
const told = await announce();
|
|
520
|
+
if (!told.ok) fail(told.data?.error ?? `could not register your app (${told.status})`);
|
|
521
|
+
say(`your app is answering on port ${app.port}${app.cmd ? ` · ${app.cmd}` : ""}`);
|
|
522
|
+
say("leave this open. Go back to the browser; Ctrl-C disconnects.");
|
|
523
|
+
let downSince = 0;
|
|
524
|
+
let toldDown = false;
|
|
525
|
+
let appTold = true;
|
|
526
|
+
forgetTold = () => { appTold = false; };
|
|
527
|
+
for (let up = true; !closing;) {
|
|
528
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
529
|
+
if (closing || restarting) continue;
|
|
530
|
+
const now = await answers(app.port);
|
|
531
|
+
if (now) {
|
|
532
|
+
downSince = 0; toldDown = false;
|
|
533
|
+
// Said until it is heard. Said once, it was lost when their app came back while the network
|
|
534
|
+
// was down, and the screen went on showing an app that had stopped while it answered turns.
|
|
535
|
+
if (!up || !appTold) { up = true; appTold = Boolean((await announce().catch(() => null))?.ok); }
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
if (up) { up = false; downSince = Date.now(); }
|
|
539
|
+
appTold = false;
|
|
540
|
+
const downFor = Date.now() - downSince;
|
|
541
|
+
// Not answering. A dev server restarting on a save is busy and says so in its output: it is
|
|
542
|
+
// left alone, however long its own startup takes.
|
|
543
|
+
// A watcher that has said its app crashed is not busy, it is waiting, and says so.
|
|
544
|
+
const crashed = /app crashed - waiting|Failed running|waiting for (?:file )?changes before restart/i.test(lastSaid.slice(-600));
|
|
545
|
+
const busy = launched && !appGone && !crashed && Date.now() - lastOutputAt < 15_000;
|
|
546
|
+
if (busy || downFor < (crashed ? 4_000 : 10_000)) continue;
|
|
547
|
+
// Down for real. The screen is told, so it never shows an app that is not there.
|
|
548
|
+
if (!toldDown) toldDown = Boolean((await call("POST", `/local/${box}/stopped`, { said: mask(lastSaid || "your app stopped answering").slice(-2000) }).catch(() => null))?.ok);
|
|
549
|
+
// An app that went quiet or exited did not stop because of a save (it was killed, it ran out
|
|
550
|
+
// of memory, it crashed on a request), so waiting for a save would wait forever. It is started
|
|
551
|
+
// here, whoever started it first: an app this command only attached to (yours, from another
|
|
552
|
+
// terminal, or one left behind by a terminal that was killed) is gone now, and this command
|
|
553
|
+
// knows how it starts. Only an app given by --port is left to you, and picked up when it returns.
|
|
554
|
+
if (launched && !appGone && !crashed && downFor < 25_000) continue;
|
|
555
|
+
if (!launched && flag("--port")) continue;
|
|
556
|
+
restarting = true;
|
|
557
|
+
try {
|
|
558
|
+
if (child?.pid) await stopApp(child.pid);
|
|
559
|
+
say("your app stopped answering, starting it again");
|
|
560
|
+
let back = launched ? await launch(180_000) : await startApp();
|
|
561
|
+
while (!back.port && !closing) {
|
|
562
|
+
await call("POST", `/local/${box}/stopped`, { said: mask(back.tail || back.said || lastSaid).slice(-2000) }).catch(() => {});
|
|
563
|
+
say("your app did not come back. Fix it and save: it is started again by itself.");
|
|
564
|
+
await sourceChanged();
|
|
565
|
+
say("saw your change, starting your app again");
|
|
566
|
+
back = launched ? await launch(180_000) : await startApp();
|
|
567
|
+
}
|
|
568
|
+
if (back.port) { app = { ...app, ...(back.cmd !== undefined ? back : {}), port: back.port }; up = true; downSince = 0; toldDown = false; appTold = Boolean((await announce().catch(() => null))?.ok); say(`your app is answering again on port ${app.port}`); }
|
|
569
|
+
} finally { restarting = false; }
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
void appLife().catch((e) => fail(String(e?.message ?? e)));
|
|
573
|
+
|
|
574
|
+
// Keep the laptop awake while a world stands on it.
|
|
575
|
+
if (process.platform === "darwin") spawn("caffeinate", ["-i", "-w", String(process.pid)], { stdio: "ignore", detached: true }).unref();
|
|
576
|
+
|
|
577
|
+
async function close(code = 0) {
|
|
578
|
+
if (closing) return;
|
|
579
|
+
closing = true;
|
|
580
|
+
await call("DELETE", `/local/${box}`).catch(() => {});
|
|
581
|
+
if (child?.pid) await stopApp(child.pid);
|
|
582
|
+
const pending = door.pending();
|
|
583
|
+
if (pending) say(`${pending} file${pending === 1 ? "" : "s"} changed by the agent ${pending === 1 ? "is" : "are"} still in your folder, not yet kept or undone. Run this command again to decide from the browser.`);
|
|
584
|
+
await exec("rm", ["-rf", work]).catch(() => {});
|
|
585
|
+
process.exit(code);
|
|
586
|
+
}
|
|
587
|
+
process.on("SIGINT", () => close(0));
|
|
588
|
+
process.on("SIGTERM", () => close(0));
|
|
589
|
+
|
|
590
|
+
let quiet = 0;
|
|
591
|
+
// Each poll names the jobs the last one brought. A poll's answer can die on the way (the network
|
|
592
|
+
// drops, the lid closes): the server sends again whatever is not named, and a job seen twice is
|
|
593
|
+
// done once.
|
|
594
|
+
let got = [];
|
|
595
|
+
const seenJobs = new Set();
|
|
596
|
+
for (;;) {
|
|
597
|
+
let res;
|
|
598
|
+
try { res = await call("GET", `/local/${box}/jobs${got.length ? `?ack=${got.join(",")}` : ""}`, undefined, { timeoutMs: 40_000 }); }
|
|
599
|
+
catch { quiet += 1; if (quiet === 3) say("reconnecting..."); await new Promise((r) => setTimeout(r, Math.min(quiet, 10) * 1000)); continue; }
|
|
600
|
+
if (closing) break;
|
|
601
|
+
if (res.status === 404) {
|
|
602
|
+
// The server restarted and forgot this terminal. It is the same terminal: it proves so with the
|
|
603
|
+
// key it was given, its box is opened again, and the app it is holding is announced again.
|
|
604
|
+
const back = await call("POST", "/local/reattach", { box, name: basename(root), project }).catch(() => null);
|
|
605
|
+
if (!back?.ok) { say("this session ended on the server. Run the command again for a new one."); await close(1); }
|
|
606
|
+
if (app?.port) await announce().catch(() => {});
|
|
607
|
+
forgetTold?.();
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
if (!res.ok) { quiet += 1; await new Promise((r) => setTimeout(r, 2000)); continue; }
|
|
611
|
+
if (quiet >= 3) say("connected again");
|
|
612
|
+
quiet = 0;
|
|
613
|
+
got = (res.data?.jobs ?? []).map((job) => job.id);
|
|
614
|
+
for (const job of res.data?.jobs ?? []) {
|
|
615
|
+
if (seenJobs.has(job.id)) continue;
|
|
616
|
+
seenJobs.add(job.id);
|
|
617
|
+
if (seenJobs.size > 2000) seenJobs.delete(seenJobs.values().next().value);
|
|
618
|
+
verb(job).catch((e) => ({ error: String(e.message) })).then((out) => call("POST", `/local/${box}/jobs/${job.id}`, out).catch(() => {}));
|
|
619
|
+
}
|
|
620
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cortad",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Runs Cortad test conversations against the app on your machine. Env values never leave it. Zero dependencies, no install scripts.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"cortad": "local.mjs"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"files": [
|
|
10
|
+
"local.mjs",
|
|
11
|
+
"lib/door.mjs",
|
|
12
|
+
"lib/lock.mjs",
|
|
13
|
+
"README.md",
|
|
14
|
+
"lib/identities.mjs",
|
|
15
|
+
"lib/mint.mjs",
|
|
16
|
+
"lib/trace.cjs",
|
|
17
|
+
"lib/replay.mjs",
|
|
18
|
+
"lib/start.mjs",
|
|
19
|
+
"lib/pyhook/sitecustomize.py"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=20"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"homepage": "https://cortad.com",
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
}
|
|
29
|
+
}
|