cortad 0.3.10 → 0.3.11
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 +3 -0
- package/lib/cli.mjs +4 -1
- package/lib/commits.mjs +202 -0
- package/lib/container.mjs +60 -0
- package/lib/data.mjs +15 -9
- package/lib/elastic.mjs +88 -0
- package/lib/env-files.mjs +124 -0
- package/lib/env-secrets.mjs +12 -4
- package/lib/intel-text.mjs +47 -0
- package/lib/keeping.mjs +32 -25
- package/lib/keys.mjs +10 -6
- package/lib/lock.mjs +7 -6
- package/lib/mcp.mjs +35 -2
- package/lib/mint.mjs +27 -11
- package/lib/mongo.mjs +64 -3
- package/lib/proof.mjs +8 -1
- package/lib/stores.mjs +116 -61
- package/lib/verbs.mjs +56 -1
- package/local.mjs +163 -81
- package/package.json +6 -1
- package/skill/SKILL.md +5 -0
package/README.md
CHANGED
|
@@ -39,9 +39,12 @@ The hooks run after an edit and on your next message: the first names the prompt
|
|
|
39
39
|
--explain show what would be sent and started, then exit
|
|
40
40
|
--port 3000 use an app that is already running
|
|
41
41
|
--start "make dev" how your app starts
|
|
42
|
+
--data .env.staging copy the databases this settings file names, not the ones your app's settings name
|
|
42
43
|
--verbose print your app's output
|
|
43
44
|
```
|
|
44
45
|
|
|
46
|
+
Your app runs on copies of its databases, made when it starts and deleted when the command ends; the originals are only read. A database it uses that cannot be copied holds Run until you say yes to writing into it.
|
|
47
|
+
|
|
45
48
|
## Files it creates
|
|
46
49
|
|
|
47
50
|
- `~/.cortad/<project>/` the key, the last tree digest, which process holds your app up, your app's output in `app.log`, the run it asked for last, in `news.txt` the line a finished run left for your agent, removed once the agent has it, and in `heard.txt` the ids of the runs your agent has read
|
package/lib/cli.mjs
CHANGED
|
@@ -25,7 +25,7 @@ const WAITER = "run-when-up";
|
|
|
25
25
|
// How long `run` and `verify` wait for an app started again for a change. An MCP call is cut at
|
|
26
26
|
// about 60 seconds by Codex, Cursor CLI and Copilot CLI; a shell command is not.
|
|
27
27
|
const RESTART_MS = { mcp: 45_000, shell: 150_000 };
|
|
28
|
-
export const VERBS = ["status", "reach", "run", "run_status", "findings", "verify", "dispute", "feedback", "field_connect", "field"];
|
|
28
|
+
export const VERBS = ["status", "reach", "run", "run_status", "findings", "verify", "dispute", "feedback", "field_connect", "field", "world", "world_status", "test"];
|
|
29
29
|
export const COMMANDS = new Set(["mcp", ...VERBS, "wait", "news", "run-status", "field-connect", "stick", "unstick", WAITER]);
|
|
30
30
|
// A lowercase word this version has no command for, so it is never read as a connect code.
|
|
31
31
|
export const unknownVerb = (word) => /^[a-z][a-z_]*$/.test(word ?? "") && !COMMANDS.has(word);
|
|
@@ -161,6 +161,9 @@ export function argsOf(command, rest) {
|
|
|
161
161
|
return { about: words[0], kind: words[1], needed: words.slice(2).join(" "), ...(got ? { got } : {}), ...(tried ? { tried } : {}) };
|
|
162
162
|
}
|
|
163
163
|
case "field": return rest[0] ? { days: rest[0] } : {};
|
|
164
|
+
case "world": return rest.length ? { ask: rest.join(" ") } : {};
|
|
165
|
+
case "test": return rest.length ? { ask: rest.join(" ") } : {};
|
|
166
|
+
case "world_status": return rest[0] ? { jobId: rest[0] } : {};
|
|
164
167
|
case "reach": {
|
|
165
168
|
const paths = rest.filter((w) => w.startsWith("/"));
|
|
166
169
|
return paths.length ? { paths } : {};
|
package/lib/commits.mjs
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
// Their commit history, read here and sent after the code: every commit since the newest one Cortad
|
|
4
|
+
// holds, the whole history the first time, oldest first, each with its message, author, dates, the
|
|
5
|
+
// paths it moved and a diff cut to the files this program would upload anyway, with any line that
|
|
6
|
+
// carries a key replaced before it leaves. One connect sends up to a budget; a longer history goes
|
|
7
|
+
// on at the next connect from where this one stopped. Git never reaches the network for this: lazy
|
|
8
|
+
// fetching is off and every call has a time limit, so a blobless clone sends messages alone.
|
|
9
|
+
|
|
10
|
+
export const LIMITS = {
|
|
11
|
+
// Bytes of history one connect sends; the newer commits past it follow at the next connect.
|
|
12
|
+
budget: 64_000_000,
|
|
13
|
+
// Commits whose diffs one git call reads.
|
|
14
|
+
window: 200,
|
|
15
|
+
// A diff larger than this, after filtering, is reported by size and not sent.
|
|
16
|
+
diffBytes: 256_000,
|
|
17
|
+
// One request to the server, under its limits.
|
|
18
|
+
partBytes: 1_500_000,
|
|
19
|
+
partCommits: 500,
|
|
20
|
+
messageChars: 4000,
|
|
21
|
+
paths: 2000,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const SHA = /^[0-9a-f]{40}$/;
|
|
25
|
+
const HEADER = /[0-9a-f]{40}\x1f/;
|
|
26
|
+
const FORMAT = "--format=%H%x1f%P%x1f%an%x1f%ae%x1f%aI%x1f%cI%x1f%B%x1e";
|
|
27
|
+
// GIT_NO_LAZY_FETCH is read by git 2.44 and later; the time limit covers the rest.
|
|
28
|
+
const ENV = { ...process.env, GIT_NO_LAZY_FETCH: "1", GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" };
|
|
29
|
+
|
|
30
|
+
const git = (root, args, timeout = 30_000) =>
|
|
31
|
+
execFileSync("git", ["-C", root, ...args], { env: ENV, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 256 * 1024 * 1024, timeout }).toString("utf8");
|
|
32
|
+
|
|
33
|
+
export function headOf(root) {
|
|
34
|
+
try { const sha = git(root, ["rev-parse", "HEAD"]).trim(); return SHA.test(sha) ? sha : null; } catch { return null; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Everything after `since` while it is still behind HEAD; the whole history when nothing is held or
|
|
38
|
+
// history was rewritten under it.
|
|
39
|
+
export function rangeOf(root, since) {
|
|
40
|
+
if (since && SHA.test(since)) {
|
|
41
|
+
try { git(root, ["merge-base", "--is-ancestor", since, "HEAD"]); return `${since}..HEAD`; } catch { /* unknown here, or rewritten */ }
|
|
42
|
+
}
|
|
43
|
+
return "HEAD";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A rename as numstat writes it, read as the path it ended at: "{a => b}/x", "a => b".
|
|
47
|
+
export const renamedTo = (path) => path.replace(/\{([^{}]*) => ([^{}]*)\}/g, "$2").replace(/^[^{]* => /, "").replace(/\/{2,}/g, "/");
|
|
48
|
+
|
|
49
|
+
function statsOf(text) {
|
|
50
|
+
const changed = [];
|
|
51
|
+
for (const line of text.split("\n")) {
|
|
52
|
+
const m = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line);
|
|
53
|
+
if (m) changed.push({ path: renamedTo(m[3]), added: m[1] === "-" ? null : Number(m[1]), deleted: m[2] === "-" ? null : Number(m[2]) });
|
|
54
|
+
}
|
|
55
|
+
return changed;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The commits of a range, oldest first, each with the paths it moved that `keep` allows; the rest
|
|
59
|
+
// are counted as hidden. One git call for everything; when the stats cannot be read without the
|
|
60
|
+
// network, the commits are sent without them.
|
|
61
|
+
export function listCommits(root, range, { keep }) {
|
|
62
|
+
let out;
|
|
63
|
+
let withStats = true;
|
|
64
|
+
try { out = git(root, ["log", FORMAT, "--numstat", "-M", range], 120_000); } catch {
|
|
65
|
+
withStats = false;
|
|
66
|
+
try { out = git(root, ["log", FORMAT, range], 120_000); } catch { return { commits: [], withStats: false }; }
|
|
67
|
+
}
|
|
68
|
+
const segments = out.split("\x1e");
|
|
69
|
+
const commits = [];
|
|
70
|
+
let header = segments[0] ?? "";
|
|
71
|
+
for (let i = 1; i <= segments.length; i += 1) {
|
|
72
|
+
const rest = segments[i] ?? "";
|
|
73
|
+
const at = rest.search(HEADER);
|
|
74
|
+
const stats = at < 0 ? rest : rest.slice(0, at);
|
|
75
|
+
const [sha, parents = "", name = "", email = "", authoredAt = "", committedAt = "", ...body] = header.replace(/^\n+/, "").split("\x1f");
|
|
76
|
+
if (SHA.test(sha ?? "")) {
|
|
77
|
+
const all = statsOf(stats);
|
|
78
|
+
const shown = all.filter((c) => keep(c.path));
|
|
79
|
+
commits.push({
|
|
80
|
+
sha,
|
|
81
|
+
parents: parents.split(" ").filter((p) => SHA.test(p)),
|
|
82
|
+
author: { name: name.slice(0, 200), email: email.slice(0, 320) },
|
|
83
|
+
authoredAt, committedAt,
|
|
84
|
+
message: body.join("\x1f").trimEnd().slice(0, LIMITS.messageChars),
|
|
85
|
+
stats: { files: all.length, insertions: all.reduce((n, c) => n + (c.added ?? 0), 0), deletions: all.reduce((n, c) => n + (c.deleted ?? 0), 0) },
|
|
86
|
+
changed: shown.slice(0, LIMITS.paths),
|
|
87
|
+
hidden: all.length - shown.length,
|
|
88
|
+
stripped: 0,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
if (at < 0) break;
|
|
92
|
+
header = rest.slice(at);
|
|
93
|
+
}
|
|
94
|
+
commits.reverse();
|
|
95
|
+
return { commits, withStats };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// git writes a path with unusual characters C-quoted; a plain one as it is.
|
|
99
|
+
const unquote = (path) => (path.startsWith('"') && path.endsWith('"') ? JSON.parse(path) : path);
|
|
100
|
+
|
|
101
|
+
// The two paths of one file's diff: before and after. A rename with no edit has only the rename lines.
|
|
102
|
+
function pathsOf(file) {
|
|
103
|
+
const line = (re) => { const m = re.exec(file); return m ? unquote(m[1]) : null; };
|
|
104
|
+
const head = /^diff --git a\/(.*) b\/(.*)$/m.exec(file);
|
|
105
|
+
const a = line(/^--- a\/(.*)$/m) ?? line(/^rename from (.*)$/m) ?? line(/^copy from (.*)$/m) ?? (head ? unquote(head[1]) : null);
|
|
106
|
+
const b = line(/^\+\+\+ b\/(.*)$/m) ?? line(/^rename to (.*)$/m) ?? line(/^copy to (.*)$/m) ?? (head ? unquote(head[2]) : null);
|
|
107
|
+
return { a, b };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const HELD = "[line held back on this machine: it carries a key]";
|
|
111
|
+
|
|
112
|
+
// A patch cut to the files `keep` allows, every added or removed line that carries a key replaced.
|
|
113
|
+
// A patch that grows past the limit is given up at that point: `over` says so, and no line of it
|
|
114
|
+
// is read for keys, since the key detector works line by line and a generated file's one long
|
|
115
|
+
// line would cost minutes.
|
|
116
|
+
export function filterDiff(patch, keep, holdsKey, limit = LIMITS.diffBytes) {
|
|
117
|
+
let stripped = 0;
|
|
118
|
+
let bytes = 0;
|
|
119
|
+
const kept = [];
|
|
120
|
+
for (const file of patch.split(/^(?=diff --git )/m)) {
|
|
121
|
+
if (!file.startsWith("diff --git ")) continue;
|
|
122
|
+
const { a, b } = pathsOf(file);
|
|
123
|
+
if (!a || !b || !keep(a) || !keep(b)) continue;
|
|
124
|
+
bytes += Buffer.byteLength(file);
|
|
125
|
+
if (bytes > limit) return { diff: "", stripped: 0, over: true, bytes: Buffer.byteLength(patch) };
|
|
126
|
+
kept.push(file.split("\n").map((line) => {
|
|
127
|
+
const sign = line[0];
|
|
128
|
+
if ((sign === "+" || sign === "-") && !/^(\+\+\+|---) /.test(line) && holdsKey(line.slice(1))) { stripped += 1; return sign + HELD; }
|
|
129
|
+
return line;
|
|
130
|
+
}).join("\n"));
|
|
131
|
+
}
|
|
132
|
+
const diff = kept.join("");
|
|
133
|
+
return { diff, stripped, over: false, bytes: Buffer.byteLength(diff) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// These commits' diffs in one git call, by sha. A diff over the limit is reported by size and not
|
|
137
|
+
// carried. A merge carries none, as in git log.
|
|
138
|
+
export function diffsOf(root, shas, { keep, holdsKey }) {
|
|
139
|
+
const diffs = new Map();
|
|
140
|
+
if (!shas.length) return diffs;
|
|
141
|
+
let out;
|
|
142
|
+
try { out = git(root, ["log", "--no-walk=unsorted", "-p", "--no-color", "--no-ext-diff", "-M", "--format=%x1eCOMMIT %H", ...shas]); } catch { return diffs; }
|
|
143
|
+
for (const chunk of out.split("\x1eCOMMIT ")) {
|
|
144
|
+
const sha = chunk.slice(0, 40);
|
|
145
|
+
if (!SHA.test(sha)) continue;
|
|
146
|
+
const { diff, stripped, over, bytes } = filterDiff(chunk.slice(41), keep, holdsKey);
|
|
147
|
+
diffs.set(sha, over || !diff ? { bytes, stripped } : { bytes, stripped, diff });
|
|
148
|
+
}
|
|
149
|
+
return diffs;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// A remote address with any credential removed; an scp-like address carries none.
|
|
153
|
+
export function safeRemote(url) {
|
|
154
|
+
if (!url) return null;
|
|
155
|
+
try { const u = new URL(url); return `${u.protocol}//${u.host}${u.pathname}`.slice(0, 300); } catch { return /^[\w.-]+@[\w.-]+:/.test(url) ? url.slice(0, 300) : null; }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// What the repository is: where it pushes, which branch, how many have written to it, when it began.
|
|
159
|
+
export function repoFacts(root) {
|
|
160
|
+
const read = (args, f = (s) => s.trim()) => { try { return f(git(root, args)); } catch { return null; } };
|
|
161
|
+
const facts = {
|
|
162
|
+
remote: safeRemote(read(["config", "--get", "remote.origin.url"])),
|
|
163
|
+
branch: read(["rev-parse", "--abbrev-ref", "HEAD"], (s) => s.trim().slice(0, 200)),
|
|
164
|
+
contributors: read(["log", "-5000", "--format=%ae"], (s) => new Set(s.split("\n").filter(Boolean)).size),
|
|
165
|
+
firstCommitAt: read(["log", "--format=%cI"], (s) => s.trim().split("\n").at(-1) || null),
|
|
166
|
+
};
|
|
167
|
+
return Object.fromEntries(Object.entries(facts).filter(([, v]) => v !== null && v !== undefined));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The commits to send, oldest first, each with its diff, up to this connect's budget, cut into
|
|
171
|
+
// parts under the server's limits.
|
|
172
|
+
export function historyParts(root, since, { keep, holdsKey }) {
|
|
173
|
+
const { commits } = listCommits(root, rangeOf(root, since), { keep });
|
|
174
|
+
const sent = [];
|
|
175
|
+
let spent = 0;
|
|
176
|
+
for (let i = 0; i < commits.length && spent < LIMITS.budget; i += LIMITS.window) {
|
|
177
|
+
const window = commits.slice(i, i + LIMITS.window);
|
|
178
|
+
const diffs = diffsOf(root, window.map((c) => c.sha), { keep, holdsKey });
|
|
179
|
+
for (const c of window) {
|
|
180
|
+
const held = diffs.get(c.sha);
|
|
181
|
+
if (held) {
|
|
182
|
+
c.stripped = held.stripped;
|
|
183
|
+
c.diffBytes = held.bytes;
|
|
184
|
+
if (held.diff) c.diff = held.diff;
|
|
185
|
+
}
|
|
186
|
+
spent += JSON.stringify(c).length;
|
|
187
|
+
if (spent > LIMITS.budget) break;
|
|
188
|
+
sent.push(c);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const parts = [];
|
|
192
|
+
let part = [];
|
|
193
|
+
let bytes = 0;
|
|
194
|
+
for (const c of sent) {
|
|
195
|
+
const size = JSON.stringify(c).length;
|
|
196
|
+
if (part.length && (bytes + size > LIMITS.partBytes || part.length === LIMITS.partCommits)) { parts.push(part); part = []; bytes = 0; }
|
|
197
|
+
part.push(c);
|
|
198
|
+
bytes += size;
|
|
199
|
+
}
|
|
200
|
+
if (part.length) parts.push(part);
|
|
201
|
+
return parts;
|
|
202
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// A store's whole server copied into a Docker container of ours for one session, for an app that
|
|
2
|
+
// names its databases or indexes in its code, so no setting can point it at a copy on the same
|
|
3
|
+
// server. Ours runs the image the original runs where the original is a container, else the
|
|
4
|
+
// engine's own image at the original's version. It listens on one port of this machine's loopback,
|
|
5
|
+
// the same inside the container and out, so an address the server gives of itself (a replica set's
|
|
6
|
+
// member, a search node's) is one the app reaches. It is removed with its volumes at the end.
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { createServer } from "node:net";
|
|
10
|
+
|
|
11
|
+
// `env` reaches the docker command only: `-e NAME` with no value hands a container that value
|
|
12
|
+
// without it ever sitting on a command line another process can read.
|
|
13
|
+
export const docker = (args, { env = {}, timeout = 120_000 } = {}) => new Promise((done) => {
|
|
14
|
+
execFile("docker", args, { timeout, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, ...env } }, (err, out, errText) => done({ ok: !err, code: err?.code === "ENOENT" ? "missing" : err?.code ?? 0, out: String(out ?? ""), err: String(errText ?? err?.message ?? "") }));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
let up = null;
|
|
18
|
+
export const dockerUp = () => (up ??= docker(["info", "--format", "{{.ServerVersion}}"], { timeout: 15_000 }).then((r) => r.ok));
|
|
19
|
+
|
|
20
|
+
export const freePort = () => new Promise((ok, no) => {
|
|
21
|
+
const s = createServer().once("error", no).listen(0, "127.0.0.1", () => { const { port } = s.address(); s.close(() => ok(port)); });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// The container publishing this port of the machine, with the network it sits on and its address
|
|
25
|
+
// there, which ours joins to read from it: a port published only on loopback is out of reach of
|
|
26
|
+
// another container on Linux.
|
|
27
|
+
export async function servedBy(port) {
|
|
28
|
+
const ps = await docker(["ps", "--format", "{{.ID}}\t{{.Image}}\t{{.Ports}}"], { timeout: 15_000 });
|
|
29
|
+
for (const line of ps.out.split("\n")) {
|
|
30
|
+
const [id, image, ports = ""] = line.split("\t");
|
|
31
|
+
const inner = new RegExp(`(?:^|,\\s*)\\S*:${port}->(\\d+)/tcp`).exec(ports)?.[1];
|
|
32
|
+
if (!inner) continue;
|
|
33
|
+
const nets = await docker(["inspect", "-f", "{{range $n, $e := .NetworkSettings.Networks}}{{$n}} {{$e.IPAddress}}\n{{end}}", id], { timeout: 15_000 });
|
|
34
|
+
const [net, ip] = nets.out.trim().split("\n")[0]?.split(" ") ?? [];
|
|
35
|
+
return { image, inner: Number(inner), ...(net && ip ? { net, ip } : {}) };
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const copyName = (engine) => `cortad-${engine}-${process.pid}-${randomBytes(3).toString("hex")}`;
|
|
41
|
+
|
|
42
|
+
// Started detached; a first pull of the image is part of the start. Answers true when it runs.
|
|
43
|
+
// `secrets` reach the container by name only, their values through the docker command's environment.
|
|
44
|
+
export async function runCopy({ name, image, port, env = {}, secrets = {}, entrypoint, args = [], network }) {
|
|
45
|
+
const placed = network ? ["--network", network] : ["--add-host", "host.docker.internal:host-gateway"];
|
|
46
|
+
const settings = [...Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]), ...Object.keys(secrets).flatMap((k) => ["-e", k])];
|
|
47
|
+
return (await docker(["run", "-d", "--name", name, "--label", "cortad.copy=1", ...placed, "-p", `127.0.0.1:${port}:${port}`, ...settings, ...(entrypoint ? ["--entrypoint", entrypoint] : []), image, ...args], { env: secrets, timeout: 900_000 })).ok;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const removeCopy = async (name) => {
|
|
51
|
+
const r = await docker(["rm", "-f", "-v", name], { timeout: 60_000 });
|
|
52
|
+
return r.ok || /No such container/i.test(r.err);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export async function until(ok, ms) {
|
|
56
|
+
for (const end = Date.now() + ms; Date.now() < end; await new Promise((r) => setTimeout(r, 1000))) {
|
|
57
|
+
try { if (await ok()) return true; } catch { /* not yet */ }
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
package/lib/data.mjs
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// after the run. So an app started here runs against a copy of its database file, made in this
|
|
4
4
|
// command's own temp folder and pointed at through the same variable, and the original is never
|
|
5
5
|
// opened. What cannot be copied (a file your code names with no variable in front of it, an app that
|
|
6
|
-
// was already running)
|
|
7
|
-
// on its own server (lib/stores.mjs).
|
|
6
|
+
// was already running) holds Run until the person says yes to writing into it. A database server is
|
|
7
|
+
// copied on its own server (lib/stores.mjs).
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
8
9
|
import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
9
10
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
10
11
|
import { storesOf } from "./stores.mjs";
|
|
@@ -86,15 +87,20 @@ function located(db, dirs, appDir) {
|
|
|
86
87
|
return places.find((p) => existsSync(p)) ?? places[0];
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
// A file the run would write into as it is, as a row that holds Run: named to the API by its setting,
|
|
91
|
+
// or by the file's own name where the code opens it by path, and never by the path itself.
|
|
92
|
+
const heldFile = (path, name, why) => ({ id: createHash("sha256").update(`file:${path}`).digest("hex").slice(0, 16), names: [name.replace(/\W+/g, "_").replace(/^(?=\d)/, "_").slice(0, 100)], why });
|
|
93
|
+
|
|
89
94
|
// values: the environment the app will start with, names to values. from: the env file each name
|
|
90
|
-
// was read from, when there is one. Returns the variables to start the app with, what to say,
|
|
91
|
-
// the settings with the code's own defaults beside them, which the stores are read from.
|
|
95
|
+
// was read from, when there is one. Returns the variables to start the app with, what to say, what
|
|
96
|
+
// holds Run, and the settings with the code's own defaults beside them, which the stores are read from.
|
|
92
97
|
export function keepData({ values, from = {}, appDir, root = appDir, work, sources = [], started = true }) {
|
|
93
98
|
const { defaults, opened } = scan(sources);
|
|
94
99
|
const all = { ...defaults, ...values };
|
|
95
100
|
const env = {};
|
|
96
101
|
const copied = [];
|
|
97
102
|
const said = [];
|
|
103
|
+
const held = [];
|
|
98
104
|
const moved = new Set();
|
|
99
105
|
const rel = (p) => relative(root, p) || basename(p);
|
|
100
106
|
for (const [name, value] of Object.entries(all)) {
|
|
@@ -102,8 +108,8 @@ export function keepData({ values, from = {}, appDir, root = appDir, work, sourc
|
|
|
102
108
|
const folder = db ? null : dataFolder(name, value, from[name] ? [dirname(from[name])] : [], appDir, root);
|
|
103
109
|
if (folder) {
|
|
104
110
|
moved.add(folder);
|
|
105
|
-
if (!started) {
|
|
106
|
-
if (sizeOf(folder, FOLDER_CAP) > FOLDER_CAP) {
|
|
111
|
+
if (!started) { held.push(heldFile(folder, name, `${name} points at your data folder ${rel(folder)}/, and your app was already running when this command started, so test conversations would save into it. Stop your app, then run the connect command again.`)); continue; }
|
|
112
|
+
if (sizeOf(folder, FOLDER_CAP) > FOLDER_CAP) { held.push(heldFile(folder, name, `${name} points at your data folder ${rel(folder)}/, which is over the 1 GB a run copies, so test conversations would save into it. Point ${name} at a smaller copy, then run the connect command again.`)); continue; }
|
|
107
113
|
const to = join(work, "data", String(copied.length), basename(folder));
|
|
108
114
|
cpSync(folder, to, { recursive: true });
|
|
109
115
|
env[name] = to;
|
|
@@ -115,7 +121,7 @@ export function keepData({ values, from = {}, appDir, root = appDir, work, sourc
|
|
|
115
121
|
const original = located(db, from[name] ? [dirname(from[name])] : [], appDir);
|
|
116
122
|
moved.add(original);
|
|
117
123
|
if (!started) {
|
|
118
|
-
|
|
124
|
+
held.push(heldFile(original, name, `${name} points at your database ${rel(original)}, and your app was already running when this command started, so test conversations would save into it. Stop your app, then run the connect command again.`));
|
|
119
125
|
continue;
|
|
120
126
|
}
|
|
121
127
|
const to = join(work, "data", String(copied.length), basename(original));
|
|
@@ -131,7 +137,7 @@ export function keepData({ values, from = {}, appDir, root = appDir, work, sourc
|
|
|
131
137
|
const cwdPath = resolve(appDir, o.path);
|
|
132
138
|
if ([path, cwdPath].some((p) => moved.has(p) || [...moved].some((m) => p.startsWith(m + "/")))) continue;
|
|
133
139
|
moved.add(cwdPath);
|
|
134
|
-
|
|
140
|
+
held.push(heldFile(cwdPath, basename(o.path), `Your app opens ${o.path} by a path written in ${rel(o.file)}, with no setting to point it at a copy, so test conversations would save into that file. Read its path from a setting, then run the connect command again.`));
|
|
135
141
|
}
|
|
136
|
-
return { env, copied, said, values: all };
|
|
142
|
+
return { env, copied, said, held, values: all };
|
|
137
143
|
}
|
package/lib/elastic.mjs
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// An Elasticsearch or OpenSearch server copied for a session into a container of ours
|
|
2
|
+
// (lib/container.mjs): every index the app can see, with its settings, mappings, aliases and
|
|
3
|
+
// documents, read over the server's own HTTP API and written into ours. The app names its indexes in
|
|
4
|
+
// its code, so it is the server that is copied, and the app is pointed at ours through its address.
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { dockerUp, freePort, runCopy, servedBy, until } from "./container.mjs";
|
|
7
|
+
|
|
8
|
+
// A server holding more than this on its primaries is not copied: held instead, so the person decides.
|
|
9
|
+
export const ELASTIC_CAP = 1024 ** 3;
|
|
10
|
+
const PAGE = 500;
|
|
11
|
+
// Index settings the server makes for itself, or that tie the index to the original's cluster.
|
|
12
|
+
const OWN = new Set(["uuid", "creation_date", "creation_date_string", "version", "provided_name", "routing", "resize", "history", "verified_before_close", "blocks", "lifecycle", "replication", "frozen", "store", "shrink", "downsample"]);
|
|
13
|
+
const IMAGE = { elasticsearch: (v) => `docker.elastic.co/elasticsearch/elasticsearch:${v}`, opensearch: (v) => `opensearchproject/opensearch:${v}` };
|
|
14
|
+
// Each kind as one node that asks for no sign-in.
|
|
15
|
+
const SETTINGS = {
|
|
16
|
+
elasticsearch: { "xpack.security.enabled": "false", ES_JAVA_OPTS: "-Xms512m -Xmx512m" },
|
|
17
|
+
opensearch: { DISABLE_SECURITY_PLUGIN: "true", DISABLE_INSTALL_DEMO_CONFIG: "true", OPENSEARCH_JAVA_OPTS: "-Xms512m -Xmx512m" },
|
|
18
|
+
};
|
|
19
|
+
// A server the app signs in to is copied into one that asks for a password too, over plain HTTP on
|
|
20
|
+
// this machine's loopback.
|
|
21
|
+
const SECURED = { "xpack.security.enabled": "true", "xpack.security.http.ssl.enabled": "false", "xpack.security.transport.ssl.enabled": "false" };
|
|
22
|
+
|
|
23
|
+
const headersOf = (conn) => ({
|
|
24
|
+
"content-type": "application/json",
|
|
25
|
+
...(conn.apiKey ? { authorization: `ApiKey ${conn.apiKey}` } : conn.user ? { authorization: `Basic ${Buffer.from(`${conn.user}:${conn.password ?? ""}`).toString("base64")}` } : {}),
|
|
26
|
+
});
|
|
27
|
+
async function call(conn, method, path, body, type) {
|
|
28
|
+
const res = await fetch(`${conn.base}${path}`, {
|
|
29
|
+
method,
|
|
30
|
+
headers: { ...headersOf(conn), ...(type ? { "content-type": type } : {}) },
|
|
31
|
+
body: body === undefined ? undefined : typeof body === "string" ? body : JSON.stringify(body),
|
|
32
|
+
signal: AbortSignal.timeout(60_000),
|
|
33
|
+
});
|
|
34
|
+
const data = await res.json().catch(() => null);
|
|
35
|
+
if (!res.ok) throw Object.assign(new Error(String(res.status)), { why: res.status === 401 || res.status === 403 ? "auth" : "other" });
|
|
36
|
+
return data;
|
|
37
|
+
}
|
|
38
|
+
const whyOf = (e) => e?.why ?? (/ECONNREFUSED|ECONNRESET|fetch failed|timeout/i.test(String(e?.cause?.code ?? e?.message)) ? "down" : "other");
|
|
39
|
+
const at = (index) => `/${encodeURIComponent(index)}`;
|
|
40
|
+
|
|
41
|
+
const ownSettings = (index) => Object.fromEntries(Object.entries(index ?? {}).filter(([k]) => !OWN.has(k)).map(([k, v]) => [k, k === "number_of_replicas" ? "0" : v]));
|
|
42
|
+
|
|
43
|
+
// One index: made on ours as the original holds it, then its documents a page at a time.
|
|
44
|
+
async function copyIndex(from, to, index) {
|
|
45
|
+
const held = (await call(from, "GET", at(index)))[index];
|
|
46
|
+
await call(to, "PUT", at(index), { settings: { index: ownSettings(held.settings?.index) }, mappings: held.mappings ?? {}, aliases: held.aliases ?? {} });
|
|
47
|
+
let page = await call(from, "POST", `${at(index)}/_search?scroll=2m`, { size: PAGE, sort: ["_doc"] });
|
|
48
|
+
try {
|
|
49
|
+
while (page.hits.hits.length) {
|
|
50
|
+
const lines = page.hits.hits.flatMap((h) => [JSON.stringify({ index: { _index: index, _id: h._id, ...(h._routing ? { routing: h._routing } : {}) } }), JSON.stringify(h._source ?? {})]);
|
|
51
|
+
const done = await call(to, "POST", "/_bulk", `${lines.join("\n")}\n`, "application/x-ndjson");
|
|
52
|
+
if (done.errors) throw Object.assign(new Error("bulk"), { why: "other" });
|
|
53
|
+
page = await call(from, "POST", "/_search/scroll", { scroll: "2m", scroll_id: page._scroll_id });
|
|
54
|
+
}
|
|
55
|
+
} finally {
|
|
56
|
+
await call(from, "DELETE", "/_search/scroll", { scroll_id: page._scroll_id }).catch(() => {});
|
|
57
|
+
}
|
|
58
|
+
await call(to, "POST", `${at(index)}/_refresh`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Answers { port } or { why }. `conn`: base and port, and user and password or apiKey when the server asks.
|
|
62
|
+
export async function serveElastic(conn, name) {
|
|
63
|
+
let info, indexes;
|
|
64
|
+
try {
|
|
65
|
+
info = await call(conn, "GET", "/");
|
|
66
|
+
indexes = (await call(conn, "GET", "/_cat/indices?format=json&bytes=b&h=index,pri.store.size&expand_wildcards=open")).filter((i) => !i.index.startsWith("."));
|
|
67
|
+
} catch (e) { return { why: whyOf(e) }; }
|
|
68
|
+
if (indexes.reduce((n, i) => n + Number(i["pri.store.size"] ?? 0), 0) > ELASTIC_CAP) return { why: "big" };
|
|
69
|
+
const kind = info.version?.distribution === "opensearch" ? "opensearch" : "elasticsearch";
|
|
70
|
+
// An API key cannot be made again on another server, and OpenSearch keeps its users in a plugin.
|
|
71
|
+
if (conn.apiKey || (conn.user && kind === "opensearch")) return { why: "login" };
|
|
72
|
+
if (!(await dockerUp())) return { why: "docker" };
|
|
73
|
+
const image = (await servedBy(conn.port))?.image ?? IMAGE[kind](info.version?.number);
|
|
74
|
+
const ours = await freePort();
|
|
75
|
+
// The app's own user and password work on ours: the superuser's where it signs in as that, else
|
|
76
|
+
// a user of the same name made there.
|
|
77
|
+
const elastic = conn.user ? (conn.user === "elastic" ? conn.password : randomBytes(24).toString("hex")) : null;
|
|
78
|
+
// No shard is left unplaced on a laptop whose disk is nearly full.
|
|
79
|
+
const env = { ...SETTINGS[kind], ...(elastic ? SECURED : {}), "discovery.type": "single-node", "http.port": String(ours), "cluster.routing.allocation.disk.threshold_enabled": "false" };
|
|
80
|
+
if (!(await runCopy({ name, image, port: ours, env, secrets: elastic ? { ELASTIC_PASSWORD: elastic } : {} }))) return { why: "container" };
|
|
81
|
+
const to = { base: `http://127.0.0.1:${ours}`, ...(elastic ? { user: "elastic", password: elastic } : {}) };
|
|
82
|
+
if (!(await until(async () => Boolean(await call(to, "GET", "/_cluster/health?wait_for_status=yellow&timeout=1s")), 180_000))) return { why: "other" };
|
|
83
|
+
try {
|
|
84
|
+
if (elastic && conn.user !== "elastic") await call(to, "PUT", `/_security/user/${encodeURIComponent(conn.user)}`, { password: conn.password, roles: ["superuser"] });
|
|
85
|
+
for (const { index } of indexes) await copyIndex(conn, to, index);
|
|
86
|
+
} catch (e) { return { why: whyOf(e) }; }
|
|
87
|
+
return { port: ours };
|
|
88
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, sep } from "node:path";
|
|
3
|
+
import { listingUrl } from "./listing.mjs";
|
|
4
|
+
|
|
5
|
+
// An env file read the way dotenv and python-dotenv read one: comments and `export` dropped, a quoted
|
|
6
|
+
// value kept whole across lines, an unquoted one cut at " #", ${NAME} filled from the names above it
|
|
7
|
+
// and then from `outer`. A bare $ is kept as written, as dotenv keeps it: "pa$$word" and a bcrypt hash
|
|
8
|
+
// are values, not references. A quote that never closes is the line as written. A line-by-line read
|
|
9
|
+
// handed an app the first line of a quoted PEM key and an unquoted value with its comment still on it.
|
|
10
|
+
export function parseEnv(text, outer = {}) {
|
|
11
|
+
const out = {};
|
|
12
|
+
const fill = (value) => value.replace(/(\\?)\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (whole, escaped, name) => (escaped ? whole.slice(1) : out[name] ?? outer[name] ?? ""));
|
|
13
|
+
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
|
14
|
+
for (let i = 0; i < lines.length; i++) {
|
|
15
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(lines[i]);
|
|
16
|
+
if (!m) continue;
|
|
17
|
+
const rest = m[2];
|
|
18
|
+
const quote = rest[0];
|
|
19
|
+
if (quote === '"' || quote === "'") {
|
|
20
|
+
let body = rest.slice(1);
|
|
21
|
+
let j = i;
|
|
22
|
+
while (!closes(body, quote) && j + 1 < lines.length) body += `\n${lines[++j]}`;
|
|
23
|
+
const end = closes(body, quote);
|
|
24
|
+
if (end) {
|
|
25
|
+
i = j;
|
|
26
|
+
const raw = body.slice(0, end - 1);
|
|
27
|
+
out[m[1]] = quote === "'" ? raw : fill(raw).replace(/\\n/g, "\n").replace(/\\(["\\$])/g, "$1");
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
out[m[1]] = fill(rest.replace(/\s+#.*$/, "").trim());
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
// One past the closing quote, or 0 when the value has none yet. A double quote escaped by a backslash
|
|
36
|
+
// does not close it.
|
|
37
|
+
function closes(body, quote) {
|
|
38
|
+
for (let i = 0; i < body.length; i++) {
|
|
39
|
+
if (quote === '"' && body[i] === "\\") { i++; continue; }
|
|
40
|
+
if (body[i] === quote) return i + 1;
|
|
41
|
+
}
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const read = (file, outer) => { try { return parseEnv(readFileSync(file, "utf8"), outer); } catch { return {}; } };
|
|
46
|
+
const PLACEHOLDERS = /\.(?:example|sample)$/;
|
|
47
|
+
|
|
48
|
+
// The folders from the app's own up to the repository root, nearest first.
|
|
49
|
+
const upFrom = (appDir, root) => {
|
|
50
|
+
const dirs = [];
|
|
51
|
+
for (let d = appDir; ; d = dirname(d)) {
|
|
52
|
+
dirs.push(d);
|
|
53
|
+
if (d === root || !d.startsWith(root + sep) || dirname(d) === d) return dirs;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// What a start by hand gives the app from its env files: the `.env` nearest the app, as `source .env`
|
|
58
|
+
// does for an app with no loader of its own. A name another env file there sets to another value is
|
|
59
|
+
// left to the app's own loader, which reads its files by its own mode and order: put in the process,
|
|
60
|
+
// it outranks that loader (the process wins in dotenv, python-dotenv, pydantic-settings, Next and
|
|
61
|
+
// Vite). Nitham's app reads .env.test then .env.local; the test file's key, handed over this way,
|
|
62
|
+
// was refused on every call it made under Cortad and on none started by hand.
|
|
63
|
+
export function appEnv(envFiles, appDir, root, outer = {}) {
|
|
64
|
+
const base = upFrom(appDir, root).map((d) => join(d, ".env")).find((f) => envFiles.includes(f));
|
|
65
|
+
if (!base) return {};
|
|
66
|
+
const values = read(base, outer);
|
|
67
|
+
const beside = envFiles.filter((f) => f !== base && !PLACEHOLDERS.test(f) && (dirname(f) === dirname(base) || dirname(f) === appDir));
|
|
68
|
+
for (const file of beside) {
|
|
69
|
+
for (const [name, value] of Object.entries(read(file, outer))) if (name in values && values[name] !== value) delete values[name];
|
|
70
|
+
}
|
|
71
|
+
return values;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The real env files in the app's folders, lowest first, the way dotenv-flow, Next and Vite rank them
|
|
75
|
+
// in development: .env, then the development file, then .env.local, and the file of the mode the shell
|
|
76
|
+
// names above all. A test, staging or stage file of another mode goes under them all and still counts:
|
|
77
|
+
// Nitham's app reads .env.test in its local mode, and a store only it names is a store the app writes
|
|
78
|
+
// into. Placeholders are left out.
|
|
79
|
+
const RANK = { ".env": 1, ".env.development": 2, ".env.dev": 2, ".env.local": 3, ".env.development.local": 4 };
|
|
80
|
+
const MODE_FILE = /^\.env\.(test|staging|stage)$/;
|
|
81
|
+
export function rankedFiles(envFiles, appDir, root, mode = "") {
|
|
82
|
+
const dirs = upFrom(appDir, root).reverse();
|
|
83
|
+
const want = mode.toLowerCase() === "staging" ? "stage" : mode.toLowerCase();
|
|
84
|
+
const rank = (f) => {
|
|
85
|
+
const name = basename(f);
|
|
86
|
+
const m = MODE_FILE.exec(name);
|
|
87
|
+
if (m) return (m[1] === "staging" ? "stage" : m[1]) === want ? 5 : 0;
|
|
88
|
+
return RANK[name] ?? -1;
|
|
89
|
+
};
|
|
90
|
+
return envFiles.filter((f) => dirs.includes(dirname(f)) && !PLACEHOLDERS.test(f) && rank(f) >= 0)
|
|
91
|
+
.sort((a, b) => dirs.indexOf(dirname(a)) - dirs.indexOf(dirname(b)) || rank(a) - rank(b));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The values the app's settings could settle on, ranked as above: for reading which stores and ports
|
|
95
|
+
// the app names, and for a shell that cannot read the files itself. `outer` names the mode and fills ${NAME}.
|
|
96
|
+
export function localEnv(envFiles, appDir, root, outer = {}) {
|
|
97
|
+
const mode = outer.ENV ?? outer.APP_ENV ?? outer.NODE_ENV ?? outer.ENVIRONMENT ?? "";
|
|
98
|
+
return Object.assign({}, ...rankedFiles(envFiles, appDir, root, mode).map((f) => read(f, outer)));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Each real env file in the app's folders, read whole: a key is only ever sent with the address the
|
|
102
|
+
// same file gives it, or one the app's development files give every key.
|
|
103
|
+
export function fileValues(envFiles, appDir, root, outer = {}) {
|
|
104
|
+
const dirs = upFrom(appDir, root);
|
|
105
|
+
return envFiles.filter((f) => dirs.includes(dirname(f)) && !PLACEHOLDERS.test(f)).map((f) => read(f, outer));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The values a development start reads whatever its mode: .env, the development files and .env.local,
|
|
109
|
+
// never a test, staging or stage file.
|
|
110
|
+
export function devEnv(envFiles, appDir, root, outer = {}) {
|
|
111
|
+
return Object.assign({}, ...rankedFiles(envFiles, appDir, root).filter((f) => !MODE_FILE.test(basename(f))).map((f) => read(f, outer)));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The key and address pairs a provider's model listing is asked with: the shell's key, else each
|
|
115
|
+
// distinct key a file holds. A key goes only to the address the shell or its own file names, else the
|
|
116
|
+
// one the development files name, else the provider's own; where only a test or staging file names
|
|
117
|
+
// an address, which the app may not use, the key is not sent at all.
|
|
118
|
+
export function keyTries(name, canonical, { shell, files, dev }) {
|
|
119
|
+
const prefix = name.replace(/_API_KEY$/, "");
|
|
120
|
+
const named = (v) => Boolean(v[`${prefix}_BASE_URL`] || v[`${prefix}_API_BASE`]);
|
|
121
|
+
const addressFor = (own) => (named(shell) || named(own) || named(dev) || !files.some(named) ? listingUrl(name, canonical, { ...dev, ...own, ...shell }) : null);
|
|
122
|
+
return [...new Map((shell[name] ? [shell] : files.filter((v) => v[name]))
|
|
123
|
+
.map((v) => ({ key: v[name], url: addressFor(v) })).filter((t) => t.url).map((t) => [`${t.url} ${t.key}`, t])).values()];
|
|
124
|
+
}
|
package/lib/env-secrets.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { StringDecoder } from "node:string_decoder";
|
|
3
|
+
import { parseEnv } from "./env-files.mjs";
|
|
3
4
|
|
|
4
5
|
// A model's or provider's name is how the app is configured, not a credential: status names it and
|
|
5
6
|
// prices the run by it. A value under such a name that looks like a key stays masked all the same.
|
|
@@ -7,18 +8,25 @@ const NAMES_A_MODEL = /(?:^|_)(?:MODEL|MODELS|MODEL_NAME|MODEL_ID|PROVIDER)$/i;
|
|
|
7
8
|
const KEY_LIKE = /[A-Za-z0-9]{24,}/;
|
|
8
9
|
|
|
9
10
|
// Values from your env files, read here and only here, so nothing a command prints can carry one.
|
|
11
|
+
// Each value as the line holds it and as the app's own loader reads it (lib/env-files.mjs parseEnv),
|
|
12
|
+
// and each line of one that spans lines: the app is handed the parsed value, so a value that differs
|
|
13
|
+
// from its line (a trailing comment, an escaped quote, a key across lines) must be masked as parsed.
|
|
10
14
|
export function secretValues(envFiles) {
|
|
11
15
|
const values = new Set();
|
|
16
|
+
const add = (name, v) => {
|
|
17
|
+
if (NAMES_A_MODEL.test(name) && !KEY_LIKE.test(v)) return;
|
|
18
|
+
for (const one of v.includes("\n") ? [v, ...v.split("\n").map((l) => l.trim()).filter((l) => l.length >= 12)] : [v]) {
|
|
19
|
+
if (one.length >= 8 && !/^(true|false|localhost|development|production|\d+)$/i.test(one)) values.add(one);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
12
22
|
for (const file of envFiles) {
|
|
13
23
|
let text = "";
|
|
14
24
|
try { text = readFileSync(file, "utf8"); } catch { continue; }
|
|
15
25
|
for (const line of text.split("\n")) {
|
|
16
26
|
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
17
|
-
if (
|
|
18
|
-
const v = m[2].trim().replace(/^(['"])(.*)\1$/, "$2");
|
|
19
|
-
if (NAMES_A_MODEL.test(m[1]) && !KEY_LIKE.test(v)) continue;
|
|
20
|
-
if (v.length >= 8 && !/^(true|false|localhost|development|production|\d+)$/i.test(v)) values.add(v);
|
|
27
|
+
if (m) add(m[1], m[2].trim().replace(/^(['"])(.*)\1$/, "$2"));
|
|
21
28
|
}
|
|
29
|
+
for (const [name, v] of Object.entries(parseEnv(text))) add(name, v);
|
|
22
30
|
}
|
|
23
31
|
return [...values].sort((a, b) => b.length - a.length);
|
|
24
32
|
}
|