bootproof 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/LICENSE +201 -0
- package/README.md +265 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +402 -0
- package/dist/diagnosis.d.ts +7 -0
- package/dist/diagnosis.js +139 -0
- package/dist/exec.d.ts +29 -0
- package/dist/exec.js +125 -0
- package/dist/infer.d.ts +4 -0
- package/dist/infer.js +432 -0
- package/dist/plan.d.ts +7 -0
- package/dist/plan.js +94 -0
- package/dist/platform.d.ts +3 -0
- package/dist/platform.js +23 -0
- package/dist/proof.d.ts +24 -0
- package/dist/proof.js +104 -0
- package/dist/redact.d.ts +4 -0
- package/dist/redact.js +31 -0
- package/dist/registry.d.ts +38 -0
- package/dist/registry.js +70 -0
- package/dist/remote.d.ts +13 -0
- package/dist/remote.js +73 -0
- package/dist/run.d.ts +23 -0
- package/dist/run.js +198 -0
- package/dist/taxonomy.d.ts +6 -0
- package/dist/taxonomy.js +68 -0
- package/dist/types.d.ts +117 -0
- package/dist/types.js +7 -0
- package/docs/CI_ACTION.md +75 -0
- package/docs/FAILURE_TAXONOMY.md +55 -0
- package/docs/HONESTY_CONTRACT.md +86 -0
- package/docs/REAL_REPO_EVIDENCE.md +72 -0
- package/docs/REGISTRY.md +49 -0
- package/docs/RELEASE_CHECKLIST.md +74 -0
- package/package.json +52 -0
package/dist/exec.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
const TAIL = 4000;
|
|
4
|
+
const tail = (s) => (s.length > TAIL ? s.slice(-TAIL) : s);
|
|
5
|
+
export function runToCompletion(command, cwd, timeoutMs, env) {
|
|
6
|
+
return new Promise(resolve => {
|
|
7
|
+
const child = spawn(command, { cwd, shell: true, detached: process.platform !== "win32", env });
|
|
8
|
+
let stdout = "", stderr = "", timedOut = false;
|
|
9
|
+
child.stdout?.on("data", d => (stdout += d));
|
|
10
|
+
child.stderr?.on("data", d => (stderr += d));
|
|
11
|
+
const timer = setTimeout(() => { timedOut = true; killTree(child.pid); }, timeoutMs);
|
|
12
|
+
child.on("close", code => { clearTimeout(timer); resolve({ exitCode: code, timedOut, stdout: tail(stdout), stderr: tail(stderr) }); });
|
|
13
|
+
child.on("error", err => { clearTimeout(timer); resolve({ exitCode: null, timedOut, stdout: tail(stdout), stderr: tail(stderr + String(err)) }); });
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export function superviseApp(command, cwd, env) {
|
|
17
|
+
const child = spawn(command, { cwd, shell: true, detached: process.platform !== "win32", env });
|
|
18
|
+
let out = "", exit = null;
|
|
19
|
+
child.stdout?.on("data", d => (out += d));
|
|
20
|
+
child.stderr?.on("data", d => (out += d));
|
|
21
|
+
child.on("close", code => { exit = { code, early: true }; });
|
|
22
|
+
return {
|
|
23
|
+
output: () => tail(out),
|
|
24
|
+
exited: () => exit,
|
|
25
|
+
stop: async () => {
|
|
26
|
+
if (exit)
|
|
27
|
+
return;
|
|
28
|
+
killTree(child.pid);
|
|
29
|
+
await new Promise(res => {
|
|
30
|
+
const t = setTimeout(() => { killTree(child.pid, "SIGKILL"); res(); }, 5000);
|
|
31
|
+
child.on("close", () => { clearTimeout(t); res(); });
|
|
32
|
+
});
|
|
33
|
+
if (exit)
|
|
34
|
+
exit.early = false;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function killTree(pid, signal = "SIGTERM") {
|
|
39
|
+
if (!pid)
|
|
40
|
+
return;
|
|
41
|
+
try {
|
|
42
|
+
if (process.platform === "win32")
|
|
43
|
+
process.kill(pid, signal);
|
|
44
|
+
else
|
|
45
|
+
process.kill(-pid, signal); // negative pid = whole process group
|
|
46
|
+
}
|
|
47
|
+
catch { /* already gone */ }
|
|
48
|
+
}
|
|
49
|
+
function cleanUrl(value) {
|
|
50
|
+
return value.replace(/[),.;\]}]+$/, "");
|
|
51
|
+
}
|
|
52
|
+
export function extractHealthCandidates(output) {
|
|
53
|
+
const candidates = new Set();
|
|
54
|
+
for (const match of output.matchAll(/https?:\/\/(?:localhost|127\.0\.0\.1):\d{2,5}(?:\/[^\s"'<>]*)?/gi)) {
|
|
55
|
+
candidates.add(cleanUrl(match[0]));
|
|
56
|
+
}
|
|
57
|
+
for (const match of output.matchAll(/\b(?:server\s+)?listening\s+(?:on|at)\s+(?:(?:https?:\/\/)?(?:localhost|127\.0\.0\.1|0\.0\.0\.0)?(?::|port\s+))(\d{2,5})\b/gi)) {
|
|
58
|
+
candidates.add(`http://localhost:${match[1]}/`);
|
|
59
|
+
}
|
|
60
|
+
for (const match of output.matchAll(/\b(?:server\s+)?listening\s+(?:on|at)\s+(\d{2,5})\b/gi)) {
|
|
61
|
+
candidates.add(`http://localhost:${match[1]}/`);
|
|
62
|
+
}
|
|
63
|
+
return [...candidates];
|
|
64
|
+
}
|
|
65
|
+
export async function pollHealthCandidates(initialUrls, timeoutMs, output = () => "", intervalMs = 1000) {
|
|
66
|
+
const started = Date.now();
|
|
67
|
+
let attempts = 0;
|
|
68
|
+
const candidates = new Set(initialUrls);
|
|
69
|
+
const discoveredCandidates = new Set();
|
|
70
|
+
while (Date.now() - started < timeoutMs) {
|
|
71
|
+
for (const candidate of extractHealthCandidates(output())) {
|
|
72
|
+
if (!candidates.has(candidate))
|
|
73
|
+
discoveredCandidates.add(candidate);
|
|
74
|
+
candidates.add(candidate);
|
|
75
|
+
}
|
|
76
|
+
for (const url of candidates) {
|
|
77
|
+
attempts++;
|
|
78
|
+
const status = await probe(url);
|
|
79
|
+
if (status !== null) {
|
|
80
|
+
return {
|
|
81
|
+
responded: true,
|
|
82
|
+
status,
|
|
83
|
+
attempts,
|
|
84
|
+
elapsedMs: Date.now() - started,
|
|
85
|
+
url,
|
|
86
|
+
candidates: [...candidates],
|
|
87
|
+
discoveredCandidates: [...discoveredCandidates],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
await new Promise(r => setTimeout(r, intervalMs));
|
|
92
|
+
}
|
|
93
|
+
for (const candidate of extractHealthCandidates(output())) {
|
|
94
|
+
if (!candidates.has(candidate))
|
|
95
|
+
discoveredCandidates.add(candidate);
|
|
96
|
+
candidates.add(candidate);
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
responded: false,
|
|
100
|
+
status: null,
|
|
101
|
+
attempts,
|
|
102
|
+
elapsedMs: Date.now() - started,
|
|
103
|
+
url: null,
|
|
104
|
+
candidates: [...candidates],
|
|
105
|
+
discoveredCandidates: [...discoveredCandidates],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
export function pollHealth(url, timeoutMs, intervalMs = 1000) {
|
|
109
|
+
return pollHealthCandidates([url], timeoutMs, () => "", intervalMs);
|
|
110
|
+
}
|
|
111
|
+
function probe(url) {
|
|
112
|
+
return new Promise(resolve => {
|
|
113
|
+
const req = http.get(url, { timeout: 3000 }, res => { res.resume(); resolve(res.statusCode ?? null); });
|
|
114
|
+
req.on("timeout", () => { req.destroy(); resolve(null); });
|
|
115
|
+
req.on("error", () => resolve(null));
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
export function minimalEnv(extra = {}) {
|
|
119
|
+
const keep = ["PATH", "HOME", "USER", "SHELL", "TMPDIR", "TEMP", "LANG", "TERM", "NODE_OPTIONS", "COREPACK_HOME", "npm_config_cache"];
|
|
120
|
+
const env = {};
|
|
121
|
+
for (const k of keep)
|
|
122
|
+
if (process.env[k])
|
|
123
|
+
env[k] = process.env[k];
|
|
124
|
+
return { ...env, ...extra, CI: "true", BOOTPROOF: "1" };
|
|
125
|
+
}
|
package/dist/infer.d.ts
ADDED
package/dist/infer.js
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
function readJson(p) {
|
|
4
|
+
try {
|
|
5
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function exists(repo, rel) {
|
|
12
|
+
return fs.existsSync(path.join(repo, rel));
|
|
13
|
+
}
|
|
14
|
+
function isDirectory(repo, rel) {
|
|
15
|
+
try {
|
|
16
|
+
return fs.statSync(path.join(repo, rel)).isDirectory();
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function readText(repo, rel) {
|
|
23
|
+
try {
|
|
24
|
+
return fs.readFileSync(path.join(repo, rel), "utf8");
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return "";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function present(repo, paths) {
|
|
31
|
+
return paths.filter(rel => exists(repo, rel));
|
|
32
|
+
}
|
|
33
|
+
function packageManagerFromField(field) {
|
|
34
|
+
if (!field)
|
|
35
|
+
return null;
|
|
36
|
+
const at = field.lastIndexOf("@");
|
|
37
|
+
const name = (at > 0 ? field.slice(0, at) : field);
|
|
38
|
+
if (!["npm", "pnpm", "yarn", "bun"].includes(name))
|
|
39
|
+
return null;
|
|
40
|
+
return { pm: name, version: at > 0 ? field.slice(at + 1) : null };
|
|
41
|
+
}
|
|
42
|
+
function detectPackageManager(repo, pkg, frontendDir) {
|
|
43
|
+
const rootField = packageManagerFromField(pkg?.packageManager);
|
|
44
|
+
if (rootField)
|
|
45
|
+
return { ...rootField, evidence: `packageManager field: ${pkg.packageManager}`, packageDir: "." };
|
|
46
|
+
const nestedPkg = frontendDir ? readJson(path.join(repo, frontendDir, "package.json")) : null;
|
|
47
|
+
const nestedField = packageManagerFromField(nestedPkg?.packageManager);
|
|
48
|
+
if (nestedField) {
|
|
49
|
+
return { ...nestedField, evidence: `packageManager field in ${frontendDir}/package.json: ${nestedPkg.packageManager}`, packageDir: frontendDir };
|
|
50
|
+
}
|
|
51
|
+
const contexts = [".", ...(frontendDir ? [frontendDir] : [])];
|
|
52
|
+
for (const dir of contexts) {
|
|
53
|
+
const prefix = dir === "." ? "" : `${dir}/`;
|
|
54
|
+
if (exists(repo, `${prefix}pnpm-lock.yaml`))
|
|
55
|
+
return { pm: "pnpm", evidence: `${prefix}pnpm-lock.yaml present`, version: null, packageDir: dir };
|
|
56
|
+
if (exists(repo, `${prefix}yarn.lock`))
|
|
57
|
+
return { pm: "yarn", evidence: `${prefix}yarn.lock present`, version: null, packageDir: dir };
|
|
58
|
+
if (exists(repo, `${prefix}bun.lockb`) || exists(repo, `${prefix}bun.lock`))
|
|
59
|
+
return { pm: "bun", evidence: `${prefix}bun lockfile present`, version: null, packageDir: dir };
|
|
60
|
+
if (exists(repo, `${prefix}package-lock.json`))
|
|
61
|
+
return { pm: "npm", evidence: `${prefix}package-lock.json present`, version: null, packageDir: dir };
|
|
62
|
+
}
|
|
63
|
+
if (pkg)
|
|
64
|
+
return { pm: "npm", evidence: "package.json present, no lockfile; assuming npm", version: null, packageDir: "." };
|
|
65
|
+
if (nestedPkg && frontendDir)
|
|
66
|
+
return { pm: "npm", evidence: `${frontendDir}/package.json present, no lockfile; assuming npm`, version: null, packageDir: frontendDir };
|
|
67
|
+
return { pm: "unknown", evidence: "no package.json found", version: null, packageDir: "." };
|
|
68
|
+
}
|
|
69
|
+
const APP_SCRIPT_ORDER = ["dev", "start", "serve", "preview"];
|
|
70
|
+
function pickAppCommand(pkg, pm) {
|
|
71
|
+
const scripts = pkg?.scripts ?? {};
|
|
72
|
+
for (const name of APP_SCRIPT_ORDER) {
|
|
73
|
+
if (typeof scripts[name] === "string" && scripts[name].trim()) {
|
|
74
|
+
const runner = pm === "yarn" ? `yarn ${name}` : pm === "pnpm" ? `pnpm ${name}` : pm === "bun" ? `bun run ${name}` : `npm run ${name}`;
|
|
75
|
+
return { command: runner, source: `scripts.${name}: ${scripts[name]}`, script: name };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { command: null, source: "no dev/start/serve/preview script found", script: null };
|
|
79
|
+
}
|
|
80
|
+
function detectNestedFrontend(repo) {
|
|
81
|
+
const preferred = ["superset-frontend", "frontend", "web", "ui", "client"];
|
|
82
|
+
for (const dir of preferred) {
|
|
83
|
+
const pkg = readJson(path.join(repo, dir, "package.json"));
|
|
84
|
+
if (pkg)
|
|
85
|
+
return { dir, pkg };
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
function detectArchitecture(repo, pkg, nestedFrontend) {
|
|
90
|
+
const makefile = readText(repo, "Makefile");
|
|
91
|
+
const pyproject = readText(repo, "pyproject.toml");
|
|
92
|
+
const setupPy = readText(repo, "setup.py");
|
|
93
|
+
const compose = readText(repo, "docker-compose.yml") + readText(repo, "docker-compose.yaml") + readText(repo, "compose.yaml");
|
|
94
|
+
const rootDeps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
|
|
95
|
+
const nestedDeps = { ...(nestedFrontend?.pkg?.dependencies ?? {}), ...(nestedFrontend?.pkg?.devDependencies ?? {}) };
|
|
96
|
+
const backendMarkers = present(repo, ["pyproject.toml", "setup.py", "go.mod", "go.work", "Makefile", "superset/app.py", "superset/config.py"]);
|
|
97
|
+
if (isDirectory(repo, "pkg"))
|
|
98
|
+
backendMarkers.push("pkg/");
|
|
99
|
+
const frontendMarkers = present(repo, ["package.json", "yarn.lock", "pnpm-lock.yaml", "nx.json"]);
|
|
100
|
+
if (isDirectory(repo, "public"))
|
|
101
|
+
frontendMarkers.push("public/");
|
|
102
|
+
if (isDirectory(repo, "packages"))
|
|
103
|
+
frontendMarkers.push("packages/");
|
|
104
|
+
if (nestedFrontend)
|
|
105
|
+
frontendMarkers.push(`${nestedFrontend.dir}/package.json`);
|
|
106
|
+
const serviceMarkers = present(repo, ["docker-compose.yml", "docker-compose.yaml", "compose.yaml", "docker-compose-light.yml"]);
|
|
107
|
+
const hasPythonBackend = (exists(repo, "pyproject.toml") || exists(repo, "setup.py")) &&
|
|
108
|
+
(exists(repo, "superset/app.py") || exists(repo, "superset/config.py") || /\bflask\b/i.test(pyproject + setupPy + makefile));
|
|
109
|
+
const hasFlask = hasPythonBackend && (/\bflask\b/i.test(pyproject + setupPy + makefile) || exists(repo, "superset/app.py"));
|
|
110
|
+
const hasGoBackend = exists(repo, "go.mod") || exists(repo, "go.work");
|
|
111
|
+
const hasNodeFrontend = Boolean(pkg) && (isDirectory(repo, "public") || isDirectory(repo, "packages") || exists(repo, "nx.json") || hasGoBackend);
|
|
112
|
+
const hasReact = Boolean(rootDeps.react || nestedDeps.react);
|
|
113
|
+
const hasReactFrontend = Boolean(nestedFrontend && hasReact);
|
|
114
|
+
const hasCelery = /\bcelery\b/i.test(pyproject + setupPy + makefile + compose);
|
|
115
|
+
const hasCompose = serviceMarkers.length > 0;
|
|
116
|
+
const stack = [];
|
|
117
|
+
if (hasPythonBackend)
|
|
118
|
+
stack.push("python-backend");
|
|
119
|
+
if (hasFlask)
|
|
120
|
+
stack.push("flask");
|
|
121
|
+
if (hasGoBackend)
|
|
122
|
+
stack.push("go-backend");
|
|
123
|
+
if (hasNodeFrontend)
|
|
124
|
+
stack.push("node-frontend");
|
|
125
|
+
if (hasReactFrontend)
|
|
126
|
+
stack.push("react-frontend");
|
|
127
|
+
if (hasReact && !hasReactFrontend)
|
|
128
|
+
stack.push("react");
|
|
129
|
+
if (rootDeps.next)
|
|
130
|
+
stack.push("nextjs");
|
|
131
|
+
if (rootDeps.vite)
|
|
132
|
+
stack.push("vite");
|
|
133
|
+
if (rootDeps.express)
|
|
134
|
+
stack.push("express");
|
|
135
|
+
if (rootDeps.fastify)
|
|
136
|
+
stack.push("fastify");
|
|
137
|
+
if (rootDeps["@nestjs/core"])
|
|
138
|
+
stack.push("nestjs");
|
|
139
|
+
if (rootDeps.prisma || rootDeps["@prisma/client"] || exists(repo, "prisma/schema.prisma"))
|
|
140
|
+
stack.push("prisma");
|
|
141
|
+
if (hasCompose)
|
|
142
|
+
stack.push("docker-compose");
|
|
143
|
+
if (hasCelery)
|
|
144
|
+
stack.push("celery");
|
|
145
|
+
const setupSteps = [];
|
|
146
|
+
if (/^\s*superset db upgrade\s*$/m.test(makefile))
|
|
147
|
+
setupSteps.push("superset db upgrade");
|
|
148
|
+
if (/^\s*superset init\s*$/m.test(makefile))
|
|
149
|
+
setupSteps.push("superset init");
|
|
150
|
+
const flaskCommand = makefile.match(/^\s*(flask run[^\n]*)$/m)?.[1].trim() ?? null;
|
|
151
|
+
const frontendMakeCommand = makefile.match(/^\s*(cd\s+[^;\n]+;\s*(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev-server|dev|start)[^\n]*)$/m)?.[1].trim() ?? null;
|
|
152
|
+
const workerCommand = makefile.match(/^\s*(celery\s+--app=[^\n]*\sworker[^\n]*)$/m)?.[1].trim() ?? null;
|
|
153
|
+
const makeRunCommand = hasGoBackend && /^run:\s/m.test(makefile) ? "make run" : null;
|
|
154
|
+
return {
|
|
155
|
+
backendMarkers: [...new Set(backendMarkers)],
|
|
156
|
+
frontendMarkers: [...new Set(frontendMarkers)],
|
|
157
|
+
serviceMarkers: [...new Set(serviceMarkers)],
|
|
158
|
+
stack,
|
|
159
|
+
setupSteps,
|
|
160
|
+
flaskCommand,
|
|
161
|
+
frontendMakeCommand,
|
|
162
|
+
workerCommand,
|
|
163
|
+
makeRunCommand,
|
|
164
|
+
hasPythonBackend,
|
|
165
|
+
hasFlask,
|
|
166
|
+
hasGoBackend,
|
|
167
|
+
hasNodeFrontend,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
function detectPort(pkg, repo, commands) {
|
|
171
|
+
const sources = [JSON.stringify(pkg?.scripts ?? {}), readText(repo, "Makefile"), ...commands.filter((v) => Boolean(v))].join("\n");
|
|
172
|
+
const m = sources.match(/(?:-p|--port)(?:=|\s+|[\\"]+)(\d{2,5})/);
|
|
173
|
+
if (m)
|
|
174
|
+
return { port: Number(m[1]), evidence: `port flag in command evidence: ${m[0].replace(/\\"/g, "").trim()}` };
|
|
175
|
+
const envEx = readText(repo, ".env.example");
|
|
176
|
+
const pm = envEx.match(/^PORT=(\d{2,5})/m);
|
|
177
|
+
if (pm)
|
|
178
|
+
return { port: Number(pm[1]), evidence: "PORT in .env.example" };
|
|
179
|
+
return { port: 3000, evidence: "default assumption (3000); not evidence-based" };
|
|
180
|
+
}
|
|
181
|
+
function detectServices(pkg, repo) {
|
|
182
|
+
const out = [];
|
|
183
|
+
const envEx = readText(repo, ".env.example") + readText(repo, ".env.sample");
|
|
184
|
+
const schema = readText(repo, "prisma/schema.prisma");
|
|
185
|
+
const compose = readText(repo, "docker-compose.yml") + readText(repo, "docker-compose.yaml") + readText(repo, "compose.yaml");
|
|
186
|
+
const pyproject = readText(repo, "pyproject.toml");
|
|
187
|
+
const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
|
|
188
|
+
if (/postgres(ql)?:\/\//i.test(envEx) || /provider\s*=\s*"postgresql"/.test(schema) || deps.pg || /^\s{0,4}postgres:\s*$/m.test(compose))
|
|
189
|
+
out.push({ kind: "postgres", evidence: deps.pg ? "pg dependency" : "postgres evidence in env, schema, or compose" });
|
|
190
|
+
if (/mysql:\/\//i.test(envEx) || /provider\s*=\s*"mysql"/.test(schema) || deps.mysql2 || /^\s{0,4}mysql:\s*$/m.test(compose))
|
|
191
|
+
out.push({ kind: "mysql", evidence: "mysql URL, dependency, or compose service" });
|
|
192
|
+
if (/redis:\/\//i.test(envEx) || deps.ioredis || deps.redis || /^\s{0,4}redis:\s*$/m.test(compose) || /["']redis[<=>~\d]/i.test(pyproject))
|
|
193
|
+
out.push({ kind: "redis", evidence: "redis URL, dependency, pyproject entry, or compose service" });
|
|
194
|
+
if (/mongodb(\+srv)?:\/\//i.test(envEx) || deps.mongoose || /^\s{0,4}mongodb:\s*$/m.test(compose))
|
|
195
|
+
out.push({ kind: "mongodb", evidence: "mongodb URL, dependency, or compose service" });
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
const SECRET_KEY_HINT = /(SECRET|TOKEN|PASSWORD|PRIVATE|API_KEY|_KEY$)/;
|
|
199
|
+
function detectEnv(repo) {
|
|
200
|
+
const src = readText(repo, ".env.example") || readText(repo, ".env.sample");
|
|
201
|
+
const required = [];
|
|
202
|
+
const noSafeDefault = [];
|
|
203
|
+
for (const line of src.split(/\r?\n/)) {
|
|
204
|
+
const m = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);
|
|
205
|
+
if (!m)
|
|
206
|
+
continue;
|
|
207
|
+
required.push(m[1]);
|
|
208
|
+
const hasValue = m[2].trim().length > 0;
|
|
209
|
+
if (!hasValue && SECRET_KEY_HINT.test(m[1]))
|
|
210
|
+
noSafeDefault.push(m[1]);
|
|
211
|
+
}
|
|
212
|
+
return { required, noSafeDefault };
|
|
213
|
+
}
|
|
214
|
+
function workspacePatterns(repo, pkg) {
|
|
215
|
+
const patterns = Array.isArray(pkg?.workspaces) ? [...pkg.workspaces] : [...(pkg?.workspaces?.packages ?? [])];
|
|
216
|
+
const pnpmLines = readText(repo, "pnpm-workspace.yaml").split(/\r?\n/);
|
|
217
|
+
let inPackages = false;
|
|
218
|
+
for (const line of pnpmLines) {
|
|
219
|
+
if (/^packages:\s*$/.test(line)) {
|
|
220
|
+
inPackages = true;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (inPackages && /^\S/.test(line))
|
|
224
|
+
break;
|
|
225
|
+
const match = inPackages ? line.match(/^\s+-\s*['"]?([^'"#]+?)['"]?\s*$/) : null;
|
|
226
|
+
if (match)
|
|
227
|
+
patterns.push(match[1].trim());
|
|
228
|
+
}
|
|
229
|
+
return [...new Set(patterns)];
|
|
230
|
+
}
|
|
231
|
+
function expandWorkspacePattern(repo, pattern) {
|
|
232
|
+
if (!pattern.includes("*"))
|
|
233
|
+
return exists(repo, `${pattern}/package.json`) ? [pattern] : [];
|
|
234
|
+
const beforeStar = pattern.slice(0, pattern.indexOf("*"));
|
|
235
|
+
const base = beforeStar.replace(/\/$/, "");
|
|
236
|
+
const suffix = pattern.slice(pattern.indexOf("*") + 1).replace(/^\//, "");
|
|
237
|
+
const baseAbs = path.join(repo, base);
|
|
238
|
+
try {
|
|
239
|
+
return fs.readdirSync(baseAbs)
|
|
240
|
+
.map(name => path.join(base, name, suffix))
|
|
241
|
+
.filter(dir => exists(repo, `${dir}/package.json`));
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const TEST_PATH = /(^|[-_/])(e2e|tests?|test-plugins|fixtures?|examples?|samples?|demos?|mocks?)([-_/]|$)/i;
|
|
248
|
+
const SCAFFOLD_NAME = /^create-|-(template|example|fixture|sandbox|sample|demo|mock)$/i;
|
|
249
|
+
function scoreWorkspace(repo, dir, wpkg, isRoot) {
|
|
250
|
+
const { command } = pickAppCommand(wpkg, "npm");
|
|
251
|
+
const deps = { ...(wpkg?.dependencies ?? {}), ...(wpkg?.devDependencies ?? {}) };
|
|
252
|
+
let score = 0;
|
|
253
|
+
const reasons = [];
|
|
254
|
+
if (command) {
|
|
255
|
+
score += isRoot ? 6 : 3;
|
|
256
|
+
reasons.push("has runnable script");
|
|
257
|
+
}
|
|
258
|
+
if (deps.next || deps.vite || deps.express || deps.fastify || deps["@nestjs/core"] || deps.react) {
|
|
259
|
+
score += 3;
|
|
260
|
+
reasons.push("app framework dependency");
|
|
261
|
+
}
|
|
262
|
+
if (isRoot) {
|
|
263
|
+
score += 4;
|
|
264
|
+
reasons.push("root application");
|
|
265
|
+
if (exists(repo, "go.mod") || exists(repo, "go.work")) {
|
|
266
|
+
score += 4;
|
|
267
|
+
reasons.push("root Go backend");
|
|
268
|
+
}
|
|
269
|
+
if (/^run:\s/m.test(readText(repo, "Makefile"))) {
|
|
270
|
+
score += 2;
|
|
271
|
+
reasons.push("Makefile run target");
|
|
272
|
+
}
|
|
273
|
+
if (exists(repo, "nx.json") || exists(repo, "project.json")) {
|
|
274
|
+
score += 2;
|
|
275
|
+
reasons.push("root project graph");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
if (/^apps?\//.test(dir)) {
|
|
280
|
+
score += 3;
|
|
281
|
+
reasons.push("under apps/");
|
|
282
|
+
}
|
|
283
|
+
if (/^packages\//.test(dir) && !TEST_PATH.test(dir)) {
|
|
284
|
+
score += 1;
|
|
285
|
+
reasons.push("production-looking package");
|
|
286
|
+
}
|
|
287
|
+
if (exists(repo, `${dir}/project.json`)) {
|
|
288
|
+
score += 2;
|
|
289
|
+
reasons.push("project.json present");
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (TEST_PATH.test(`${dir}/${wpkg?.name ?? ""}`)) {
|
|
293
|
+
score -= 10;
|
|
294
|
+
reasons.push("test/example path downranked");
|
|
295
|
+
}
|
|
296
|
+
if (wpkg?.private !== true && (wpkg?.main || wpkg?.exports) && !command) {
|
|
297
|
+
score -= 2;
|
|
298
|
+
reasons.push("looks like a publishable library");
|
|
299
|
+
}
|
|
300
|
+
if (SCAFFOLD_NAME.test(wpkg?.name ?? "") || SCAFFOLD_NAME.test(path.basename(dir))) {
|
|
301
|
+
score -= 4;
|
|
302
|
+
reasons.push("scaffold/sample name downranked");
|
|
303
|
+
}
|
|
304
|
+
return { dir, name: wpkg?.name ?? dir, score, reason: reasons.join("; ") || "no signals" };
|
|
305
|
+
}
|
|
306
|
+
function rankWorkspaces(repo, pkg) {
|
|
307
|
+
const dirs = new Set();
|
|
308
|
+
for (const pattern of workspacePatterns(repo, pkg))
|
|
309
|
+
for (const dir of expandWorkspacePattern(repo, pattern))
|
|
310
|
+
dirs.add(dir);
|
|
311
|
+
const candidates = [];
|
|
312
|
+
if (pkg && (pickAppCommand(pkg, "npm").command || exists(repo, "go.mod") || exists(repo, "go.work"))) {
|
|
313
|
+
candidates.push(scoreWorkspace(repo, ".", pkg, true));
|
|
314
|
+
}
|
|
315
|
+
for (const dir of dirs) {
|
|
316
|
+
const wpkg = readJson(path.join(repo, dir, "package.json"));
|
|
317
|
+
if (wpkg)
|
|
318
|
+
candidates.push(scoreWorkspace(repo, dir, wpkg, false));
|
|
319
|
+
}
|
|
320
|
+
return candidates.sort((a, b) => b.score - a.score || a.dir.localeCompare(b.dir));
|
|
321
|
+
}
|
|
322
|
+
function looksLikeLibrary(pkg, appCommand, hasWorkspaces, recognizedApplication) {
|
|
323
|
+
if (appCommand || hasWorkspaces || recognizedApplication)
|
|
324
|
+
return null;
|
|
325
|
+
if (!pkg)
|
|
326
|
+
return "no package.json and no recognizable application entrypoint";
|
|
327
|
+
const isPublishable = pkg.private !== true && (pkg.main || pkg.exports || pkg.bin);
|
|
328
|
+
if (isPublishable)
|
|
329
|
+
return "publishable package (main/exports/bin) with no dev/start/serve script — this looks like a library, not a runnable application";
|
|
330
|
+
return "no dev/start/serve/preview script found — nothing to boot";
|
|
331
|
+
}
|
|
332
|
+
function installCommand(pm, packageDir) {
|
|
333
|
+
if (pm === "unknown")
|
|
334
|
+
return null;
|
|
335
|
+
const command = pm === "yarn" ? "yarn install" : pm === "pnpm" ? "pnpm install" : pm === "bun" ? "bun install" : "npm install";
|
|
336
|
+
return packageDir === "." ? command : `cd ${packageDir} && ${command}`;
|
|
337
|
+
}
|
|
338
|
+
export function inferRepo(repoPath, opts = {}) {
|
|
339
|
+
let repo = path.resolve(repoPath);
|
|
340
|
+
if (opts.workspace)
|
|
341
|
+
repo = path.join(repo, opts.workspace);
|
|
342
|
+
const pkg = readJson(path.join(repo, "package.json"));
|
|
343
|
+
const rootRepo = path.resolve(repoPath);
|
|
344
|
+
const rootPkg = opts.workspace ? readJson(path.join(rootRepo, "package.json")) : pkg;
|
|
345
|
+
const nestedFrontend = detectNestedFrontend(repo);
|
|
346
|
+
const architecture = detectArchitecture(repo, pkg, nestedFrontend);
|
|
347
|
+
const pm = detectPackageManager(repo, pkg, nestedFrontend?.dir ?? null);
|
|
348
|
+
const rootApp = pickAppCommand(pkg, pm.pm);
|
|
349
|
+
const backendCommand = architecture.flaskCommand ?? architecture.makeRunCommand;
|
|
350
|
+
const nestedFrontendCommand = architecture.frontendMakeCommand;
|
|
351
|
+
const frontendCommand = nestedFrontendCommand ?? rootApp.command;
|
|
352
|
+
const appCommand = architecture.flaskCommand ?? rootApp.command;
|
|
353
|
+
const appCommandSource = architecture.flaskCommand
|
|
354
|
+
? `Makefile Flask command: ${architecture.flaskCommand}`
|
|
355
|
+
: rootApp.source;
|
|
356
|
+
const recognizedApplication = architecture.hasPythonBackend || architecture.hasGoBackend;
|
|
357
|
+
const workspaces = opts.workspace ? [] : rankWorkspaces(rootRepo, rootPkg);
|
|
358
|
+
const notApp = looksLikeLibrary(pkg, appCommand, workspaces.some(candidate => candidate.dir !== "."), recognizedApplication);
|
|
359
|
+
const { port, evidence: portEvidence } = detectPort(pkg, repo, [backendCommand, frontendCommand]);
|
|
360
|
+
const env = detectEnv(repo);
|
|
361
|
+
const services = detectServices(pkg, repo);
|
|
362
|
+
const rootDeps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
|
|
363
|
+
const dependencyInstallRequired = Boolean(rootApp.command &&
|
|
364
|
+
(Object.keys(rootDeps).length > 0 || exists(repo, "yarn.lock") || exists(repo, "pnpm-lock.yaml") || exists(repo, "package-lock.json") || exists(repo, "nx.json")));
|
|
365
|
+
const incompleteAppCommand = Boolean(architecture.hasGoBackend && architecture.hasNodeFrontend && rootApp.command);
|
|
366
|
+
const multiAppCommand = Boolean(rootApp.command && /\b(?:turbo|nx)\s+run\s+dev\b[^\n]*--parallel\b/i.test(rootApp.source));
|
|
367
|
+
const commandScope = multiAppCommand
|
|
368
|
+
? "multi-workspace development pipeline; no single application health target selected"
|
|
369
|
+
: incompleteAppCommand
|
|
370
|
+
? "frontend/dev pipeline only; Go backend markers also detected"
|
|
371
|
+
: architecture.hasPythonBackend && nestedFrontend
|
|
372
|
+
? "Python/Flask backend command; React frontend and worker require separate orchestration"
|
|
373
|
+
: appCommand
|
|
374
|
+
? "application command"
|
|
375
|
+
: "no runnable command selected";
|
|
376
|
+
const healthCandidates = notApp
|
|
377
|
+
? []
|
|
378
|
+
: architecture.hasGoBackend && architecture.hasNodeFrontend
|
|
379
|
+
? [`http://localhost:${port}/api/health`, `http://localhost:${port}/`]
|
|
380
|
+
: [`http://localhost:${port}/`];
|
|
381
|
+
let confidence = 0;
|
|
382
|
+
if (appCommand)
|
|
383
|
+
confidence += 35;
|
|
384
|
+
if (recognizedApplication)
|
|
385
|
+
confidence += 25;
|
|
386
|
+
if (architecture.stack.length)
|
|
387
|
+
confidence += 15;
|
|
388
|
+
if (pm.pm !== "unknown" && !pm.evidence.includes("assuming"))
|
|
389
|
+
confidence += 10;
|
|
390
|
+
if (!portEvidence.includes("assumption"))
|
|
391
|
+
confidence += 10;
|
|
392
|
+
if (services.length || env.required.length)
|
|
393
|
+
confidence += 5;
|
|
394
|
+
return {
|
|
395
|
+
repoPath: repo,
|
|
396
|
+
isApplication: !notApp,
|
|
397
|
+
notAppReason: notApp ?? undefined,
|
|
398
|
+
stack: architecture.stack,
|
|
399
|
+
backendMarkers: architecture.backendMarkers,
|
|
400
|
+
frontendMarkers: architecture.frontendMarkers,
|
|
401
|
+
serviceMarkers: architecture.serviceMarkers,
|
|
402
|
+
setupSteps: architecture.setupSteps,
|
|
403
|
+
packageManager: pm.pm,
|
|
404
|
+
packageManagerEvidence: pm.evidence,
|
|
405
|
+
packageManagerVersion: pm.version ?? pkg?.engines?.[pm.pm] ?? rootPkg?.engines?.[pm.pm] ?? null,
|
|
406
|
+
installCommand: installCommand(pm.pm, pm.packageDir),
|
|
407
|
+
dependencyInstallRequired,
|
|
408
|
+
appCommand,
|
|
409
|
+
appCommandSource,
|
|
410
|
+
backendCommand,
|
|
411
|
+
frontendCommand,
|
|
412
|
+
workerCommand: architecture.workerCommand,
|
|
413
|
+
commandScope,
|
|
414
|
+
incompleteAppCommand,
|
|
415
|
+
multiAppCommand,
|
|
416
|
+
port,
|
|
417
|
+
portEvidence,
|
|
418
|
+
healthCandidates,
|
|
419
|
+
services,
|
|
420
|
+
requiredEnv: env.required,
|
|
421
|
+
envWithoutSafeDefault: env.noSafeDefault,
|
|
422
|
+
engines: {
|
|
423
|
+
node: pkg?.engines?.node ?? rootPkg?.engines?.node,
|
|
424
|
+
npm: pkg?.engines?.npm ?? rootPkg?.engines?.npm,
|
|
425
|
+
pnpm: pkg?.engines?.pnpm ?? rootPkg?.engines?.pnpm,
|
|
426
|
+
yarn: pkg?.engines?.yarn ?? rootPkg?.engines?.yarn,
|
|
427
|
+
bun: pkg?.engines?.bun ?? rootPkg?.engines?.bun,
|
|
428
|
+
},
|
|
429
|
+
workspaces,
|
|
430
|
+
confidence: Math.min(confidence, 95),
|
|
431
|
+
};
|
|
432
|
+
}
|
package/dist/plan.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Inference, RunPlan } from "./types.js";
|
|
2
|
+
export declare function composeFileFor(inf: Inference): string | null;
|
|
3
|
+
export declare function envExampleFor(inf: Inference): string | null;
|
|
4
|
+
export declare function buildPlan(inf: Inference, provider: "docker" | "local"): RunPlan;
|
|
5
|
+
declare const PROTECTED_ENV: string[];
|
|
6
|
+
export declare function writePlanFiles(inf: Inference, repo: string): string[];
|
|
7
|
+
export { PROTECTED_ENV };
|
package/dist/plan.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const SERVICE_IMAGES = {
|
|
4
|
+
postgres: { image: "postgres:16-alpine", port: 5432, env: { POSTGRES_USER: "bootproof", POSTGRES_PASSWORD: "bootproof", POSTGRES_DB: "app" } },
|
|
5
|
+
mysql: { image: "mysql:8", port: 3306, env: { MYSQL_ROOT_PASSWORD: "bootproof", MYSQL_DATABASE: "app" } },
|
|
6
|
+
redis: { image: "redis:7-alpine", port: 6379, env: {} },
|
|
7
|
+
mongodb: { image: "mongo:7", port: 27017, env: {} },
|
|
8
|
+
};
|
|
9
|
+
export function composeFileFor(inf) {
|
|
10
|
+
if (!inf.services.length)
|
|
11
|
+
return null;
|
|
12
|
+
const lines = ["# Generated by bootproof — review before use. Standard compose; no bootproof runtime required.", "services:"];
|
|
13
|
+
for (const s of inf.services) {
|
|
14
|
+
const spec = SERVICE_IMAGES[s.kind];
|
|
15
|
+
lines.push(` ${s.kind}:`, ` image: ${spec.image}`, ` ports:`, ` - "${spec.port}:${spec.port}"`);
|
|
16
|
+
const envKeys = Object.entries(spec.env);
|
|
17
|
+
if (envKeys.length) {
|
|
18
|
+
lines.push(" environment:");
|
|
19
|
+
for (const [k, v] of envKeys)
|
|
20
|
+
lines.push(` ${k}: ${v}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return lines.join("\n") + "\n";
|
|
24
|
+
}
|
|
25
|
+
export function envExampleFor(inf) {
|
|
26
|
+
if (!inf.requiredEnv.length)
|
|
27
|
+
return null;
|
|
28
|
+
const lines = [
|
|
29
|
+
"# Generated by bootproof from .env.example — local-only suggested values.",
|
|
30
|
+
"# Bootproof NEVER writes .env/.env.local; copy values yourself if they look right.",
|
|
31
|
+
];
|
|
32
|
+
for (const key of inf.requiredEnv) {
|
|
33
|
+
if (inf.envWithoutSafeDefault.includes(key)) {
|
|
34
|
+
lines.push(`# ${key}= (secret with no safe local default — bootproof will not invent one)`);
|
|
35
|
+
}
|
|
36
|
+
else if (/^DATABASE_URL$/.test(key) && inf.services.some(s => s.kind === "postgres")) {
|
|
37
|
+
lines.push(`${key}=postgresql://bootproof:bootproof@localhost:5432/app`);
|
|
38
|
+
}
|
|
39
|
+
else if (/^PORT$/.test(key)) {
|
|
40
|
+
lines.push(`${key}=${inf.port}`);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
lines.push(`${key}=`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return lines.join("\n") + "\n";
|
|
47
|
+
}
|
|
48
|
+
export function buildPlan(inf, provider) {
|
|
49
|
+
const steps = [];
|
|
50
|
+
if (inf.services.length && provider === "docker") {
|
|
51
|
+
steps.push({ id: "services", kind: "service", command: "docker compose -f docker-compose.bootproof.yml up -d", description: `start ${inf.services.map(s => s.kind).join(", ")} in containers`, required: true });
|
|
52
|
+
}
|
|
53
|
+
if (inf.installCommand) {
|
|
54
|
+
steps.push({ id: "install", kind: "install", command: inf.installCommand, description: "install dependencies", required: inf.dependencyInstallRequired });
|
|
55
|
+
}
|
|
56
|
+
if (inf.appCommand) {
|
|
57
|
+
steps.push({ id: "start-app", kind: "start-app", command: inf.appCommand, description: `start app (${inf.appCommandSource})`, required: true });
|
|
58
|
+
}
|
|
59
|
+
const healthCandidates = [...inf.healthCandidates];
|
|
60
|
+
const healthUrl = healthCandidates[0] ?? "";
|
|
61
|
+
if (inf.isApplication && healthUrl) {
|
|
62
|
+
steps.push({ id: "health", kind: "health", description: `poll ${healthUrl} for an HTTP response`, required: true });
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
provider,
|
|
66
|
+
steps,
|
|
67
|
+
healthUrl,
|
|
68
|
+
healthCandidates,
|
|
69
|
+
generatedFiles: [
|
|
70
|
+
...(composeFileFor(inf) ? [{ path: "docker-compose.bootproof.yml", purpose: "service containers" }] : []),
|
|
71
|
+
...(envExampleFor(inf) ? [{ path: ".env.bootproof.example", purpose: "suggested local env values (never auto-applied)" }] : []),
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const PROTECTED_ENV = [".env", ".env.local", ".env.development", ".env.production"];
|
|
76
|
+
export function writePlanFiles(inf, repo) {
|
|
77
|
+
const written = [];
|
|
78
|
+
const compose = composeFileFor(inf);
|
|
79
|
+
if (compose) {
|
|
80
|
+
fs.writeFileSync(path.join(repo, "docker-compose.bootproof.yml"), compose);
|
|
81
|
+
written.push("docker-compose.bootproof.yml");
|
|
82
|
+
}
|
|
83
|
+
const envEx = envExampleFor(inf);
|
|
84
|
+
if (envEx) {
|
|
85
|
+
fs.writeFileSync(path.join(repo, ".env.bootproof.example"), envEx);
|
|
86
|
+
written.push(".env.bootproof.example");
|
|
87
|
+
}
|
|
88
|
+
for (const p of PROTECTED_ENV) {
|
|
89
|
+
if (written.includes(p))
|
|
90
|
+
throw new Error(`honesty contract violation: attempted to write ${p}`);
|
|
91
|
+
}
|
|
92
|
+
return written;
|
|
93
|
+
}
|
|
94
|
+
export { PROTECTED_ENV };
|