bootproof 0.1.0 → 0.3.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 +84 -8
- package/dist/cli.js +230 -16
- package/dist/diagnosis.js +13 -1
- package/dist/exec.js +21 -4
- package/dist/infer.js +281 -32
- package/dist/plan.d.ts +2 -0
- package/dist/plan.js +47 -7
- package/dist/proof.d.ts +1 -1
- package/dist/proof.js +2 -2
- package/dist/remote.d.ts +12 -1
- package/dist/remote.js +62 -18
- package/dist/repair.d.ts +110 -0
- package/dist/repair.js +857 -0
- package/dist/run.d.ts +3 -1
- package/dist/run.js +182 -20
- package/dist/taxonomy.d.ts +1 -0
- package/dist/taxonomy.js +28 -4
- package/dist/types.d.ts +18 -2
- package/docs/CI_ACTION.md +4 -3
- package/docs/FAILURE_TAXONOMY.md +3 -1
- package/docs/HONESTY_CONTRACT.md +30 -1
- package/docs/REAL_REPO_EVIDENCE.md +77 -0
- package/docs/RELEASE_CHECKLIST.md +9 -1
- package/docs/REPAIR_RECEIPT.md +178 -0
- package/package.json +6 -3
package/dist/infer.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { parse } from "yaml";
|
|
4
|
+
import { repoComposeRepairFile } from "./plan.js";
|
|
3
5
|
function readJson(p) {
|
|
4
6
|
try {
|
|
5
7
|
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
@@ -30,6 +32,119 @@ function readText(repo, rel) {
|
|
|
30
32
|
function present(repo, paths) {
|
|
31
33
|
return paths.filter(rel => exists(repo, rel));
|
|
32
34
|
}
|
|
35
|
+
const REPO_COMPOSE_FILES = [
|
|
36
|
+
"docker-compose.yml",
|
|
37
|
+
"docker-compose.yaml",
|
|
38
|
+
"compose.yaml",
|
|
39
|
+
"compose.yml",
|
|
40
|
+
"docker-compose.dev.yml",
|
|
41
|
+
"docker-compose.dev.yaml",
|
|
42
|
+
"docker/docker-compose.yml",
|
|
43
|
+
"docker/docker-compose.yaml",
|
|
44
|
+
];
|
|
45
|
+
function detectRepoComposeFile(repo) {
|
|
46
|
+
return REPO_COMPOSE_FILES.find(file => exists(repo, file)) ?? null;
|
|
47
|
+
}
|
|
48
|
+
function composePublishedPort(value) {
|
|
49
|
+
if (typeof value === "number")
|
|
50
|
+
return null;
|
|
51
|
+
if (typeof value === "object" && value !== null) {
|
|
52
|
+
const item = value;
|
|
53
|
+
const host = Number(item.published);
|
|
54
|
+
const container = Number(item.target);
|
|
55
|
+
return Number.isInteger(host) && Number.isInteger(container) ? { host, container } : null;
|
|
56
|
+
}
|
|
57
|
+
if (typeof value !== "string")
|
|
58
|
+
return null;
|
|
59
|
+
const normalized = value.replace(/\$\{[^}:]+:-?(\d+)\}/g, "$1").split("/")[0];
|
|
60
|
+
const parts = normalized.split(":").map(part => part.trim());
|
|
61
|
+
if (parts.length < 2)
|
|
62
|
+
return null;
|
|
63
|
+
const host = Number(parts.at(-2));
|
|
64
|
+
const container = Number(parts.at(-1));
|
|
65
|
+
return Number.isInteger(host) && Number.isInteger(container) ? { host, container } : null;
|
|
66
|
+
}
|
|
67
|
+
function composeHealthPath(service, containerPort) {
|
|
68
|
+
const test = service.healthcheck?.test;
|
|
69
|
+
const text = Array.isArray(test) ? test.join(" ") : typeof test === "string" ? test : "";
|
|
70
|
+
const url = text.match(/https?:\/\/(?:localhost|127\.0\.0\.1):(\d{2,5})(\/[^\s"'\\]*)?/i);
|
|
71
|
+
if (!url || Number(url[1]) !== containerPort)
|
|
72
|
+
return "/";
|
|
73
|
+
return url[2] || "/";
|
|
74
|
+
}
|
|
75
|
+
function sourceBuildUsesRepo(repo, composeFile, build) {
|
|
76
|
+
const context = typeof build === "string"
|
|
77
|
+
? build
|
|
78
|
+
: typeof build === "object" && build !== null
|
|
79
|
+
? String(build.context ?? ".")
|
|
80
|
+
: null;
|
|
81
|
+
if (!context || /^(?:https?|git):/i.test(context))
|
|
82
|
+
return false;
|
|
83
|
+
const composeDir = path.dirname(path.join(repo, composeFile));
|
|
84
|
+
const resolved = path.resolve(composeDir, context);
|
|
85
|
+
const root = path.resolve(repo);
|
|
86
|
+
return resolved === root || resolved.startsWith(`${root}${path.sep}`);
|
|
87
|
+
}
|
|
88
|
+
function detectComposeApplications(repo, composeFile) {
|
|
89
|
+
if (!composeFile)
|
|
90
|
+
return [];
|
|
91
|
+
try {
|
|
92
|
+
const document = parse(readText(repo, composeFile));
|
|
93
|
+
const applications = [];
|
|
94
|
+
for (const [name, service] of Object.entries(document?.services ?? {})) {
|
|
95
|
+
const source = sourceBuildUsesRepo(repo, composeFile, service.build) ? "build" : service.image ? "image" : null;
|
|
96
|
+
if (!source)
|
|
97
|
+
continue;
|
|
98
|
+
const healthCandidates = (Array.isArray(service.ports) ? service.ports : [])
|
|
99
|
+
.map(composePublishedPort)
|
|
100
|
+
.filter((port) => Boolean(port))
|
|
101
|
+
.filter(port => ![3306, 5432, 6379, 27017].includes(port.container))
|
|
102
|
+
.map(port => `http://localhost:${port.host}${composeHealthPath(service, port.container)}`);
|
|
103
|
+
if (healthCandidates.length)
|
|
104
|
+
applications.push({ name, source, healthCandidates: [...new Set(healthCandidates)] });
|
|
105
|
+
}
|
|
106
|
+
return applications;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function applyBootProofComposeOverride(repo, repoComposeFile, applications) {
|
|
113
|
+
if (!repoComposeFile)
|
|
114
|
+
return applications;
|
|
115
|
+
const repairFile = repoComposeRepairFile(repoComposeFile);
|
|
116
|
+
const overridePath = path.join(repo, repairFile);
|
|
117
|
+
if (!fs.existsSync(overridePath))
|
|
118
|
+
return applications;
|
|
119
|
+
try {
|
|
120
|
+
const document = parse(readText(repo, repairFile));
|
|
121
|
+
return applications.map(application => {
|
|
122
|
+
const service = document.services?.[application.name];
|
|
123
|
+
const port = (Array.isArray(service?.ports) ? service.ports : [])
|
|
124
|
+
.map(composePublishedPort)
|
|
125
|
+
.find((candidate) => Boolean(candidate));
|
|
126
|
+
if (!port)
|
|
127
|
+
return application;
|
|
128
|
+
return {
|
|
129
|
+
...application,
|
|
130
|
+
healthCandidates: application.healthCandidates.map(candidate => {
|
|
131
|
+
try {
|
|
132
|
+
const url = new URL(candidate);
|
|
133
|
+
url.hostname = "localhost";
|
|
134
|
+
url.port = String(port.host);
|
|
135
|
+
return url.toString();
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return candidate;
|
|
139
|
+
}
|
|
140
|
+
}),
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return applications;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
33
148
|
function packageManagerFromField(field) {
|
|
34
149
|
if (!field)
|
|
35
150
|
return null;
|
|
@@ -86,14 +201,52 @@ function detectNestedFrontend(repo) {
|
|
|
86
201
|
}
|
|
87
202
|
return null;
|
|
88
203
|
}
|
|
89
|
-
function
|
|
204
|
+
function detectMakeCommand(repo, makefile) {
|
|
205
|
+
for (const target of ["run", "serve", "server", "start", "dev"]) {
|
|
206
|
+
if (new RegExp(`^${target}:`, "m").test(makefile)) {
|
|
207
|
+
return { command: `make ${target}`, source: `Makefile target: ${target}` };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
function detectGoEntrypoint(repo) {
|
|
213
|
+
const candidates = [];
|
|
214
|
+
if (exists(repo, "main.go"))
|
|
215
|
+
candidates.push("main.go");
|
|
216
|
+
try {
|
|
217
|
+
for (const name of fs.readdirSync(path.join(repo, "cmd"))) {
|
|
218
|
+
if (exists(repo, `cmd/${name}/main.go`))
|
|
219
|
+
candidates.push(`cmd/${name}/main.go`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch { /* no cmd directory */ }
|
|
223
|
+
if (candidates.length !== 1)
|
|
224
|
+
return null;
|
|
225
|
+
const entry = candidates[0];
|
|
226
|
+
const sourceText = readText(repo, entry);
|
|
227
|
+
const packagePath = entry === "main.go" ? "." : `./${path.posix.dirname(entry)}`;
|
|
228
|
+
return {
|
|
229
|
+
commandBase: `go run ${packagePath}`,
|
|
230
|
+
source: `Go main package: ${entry}`,
|
|
231
|
+
sourceText,
|
|
232
|
+
dataDirFlag: /(?:String|StringVar)\(\s*["']data["']/m.test(sourceText),
|
|
233
|
+
portFlag: /(?:Int|IntVar)\(\s*["']port["']/m.test(sourceText),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function detectRubyCommand(repo) {
|
|
237
|
+
if (!exists(repo, "Gemfile") || !exists(repo, "bin/rails"))
|
|
238
|
+
return null;
|
|
239
|
+
return { commandBase: "bundle exec rails server -b 127.0.0.1", source: "Rails entrypoint: bin/rails" };
|
|
240
|
+
}
|
|
241
|
+
function detectArchitecture(repo, pkg, nestedFrontend, repoComposeFile) {
|
|
90
242
|
const makefile = readText(repo, "Makefile");
|
|
91
243
|
const pyproject = readText(repo, "pyproject.toml");
|
|
92
244
|
const setupPy = readText(repo, "setup.py");
|
|
93
|
-
const
|
|
245
|
+
const requirements = readText(repo, "requirements.txt");
|
|
246
|
+
const compose = repoComposeFile ? readText(repo, repoComposeFile) : "";
|
|
94
247
|
const rootDeps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
|
|
95
248
|
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"]);
|
|
249
|
+
const backendMarkers = present(repo, ["pyproject.toml", "setup.py", "requirements.txt", "manage.py", "go.mod", "go.work", "Gemfile", "config/database.yml", "Makefile", "superset/app.py", "superset/config.py"]);
|
|
97
250
|
if (isDirectory(repo, "pkg"))
|
|
98
251
|
backendMarkers.push("pkg/");
|
|
99
252
|
const frontendMarkers = present(repo, ["package.json", "yarn.lock", "pnpm-lock.yaml", "nx.json"]);
|
|
@@ -103,12 +256,19 @@ function detectArchitecture(repo, pkg, nestedFrontend) {
|
|
|
103
256
|
frontendMarkers.push("packages/");
|
|
104
257
|
if (nestedFrontend)
|
|
105
258
|
frontendMarkers.push(`${nestedFrontend.dir}/package.json`);
|
|
106
|
-
const serviceMarkers = present(repo, [
|
|
107
|
-
const
|
|
259
|
+
const serviceMarkers = present(repo, [...REPO_COMPOSE_FILES, "docker-compose-light.yml"]);
|
|
260
|
+
const hasFlaskBackend = (exists(repo, "pyproject.toml") || exists(repo, "setup.py")) &&
|
|
108
261
|
(exists(repo, "superset/app.py") || exists(repo, "superset/config.py") || /\bflask\b/i.test(pyproject + setupPy + makefile));
|
|
109
|
-
const hasFlask =
|
|
262
|
+
const hasFlask = hasFlaskBackend && (/\bflask\b/i.test(pyproject + setupPy + makefile) || exists(repo, "superset/app.py"));
|
|
263
|
+
const hasDjango = exists(repo, "manage.py") && /\bdjango\b/i.test(pyproject + requirements);
|
|
264
|
+
const hasPythonBackend = hasFlaskBackend || hasDjango;
|
|
110
265
|
const hasGoBackend = exists(repo, "go.mod") || exists(repo, "go.work");
|
|
111
|
-
const
|
|
266
|
+
const hasRubyBackend = exists(repo, "Gemfile");
|
|
267
|
+
const makeCommand = detectMakeCommand(repo, makefile);
|
|
268
|
+
const goEntrypoint = detectGoEntrypoint(repo);
|
|
269
|
+
const rubyCommand = detectRubyCommand(repo);
|
|
270
|
+
const hasMakeDrivenBackend = Boolean(makefile && /^[A-Za-z0-9_.-]+:\s*(?:[^=]|$)/m.test(makefile));
|
|
271
|
+
const hasNodeFrontend = Boolean(pkg) && (isDirectory(repo, "public") || isDirectory(repo, "packages") || exists(repo, "nx.json") || hasGoBackend || hasRubyBackend);
|
|
112
272
|
const hasReact = Boolean(rootDeps.react || nestedDeps.react);
|
|
113
273
|
const hasReactFrontend = Boolean(nestedFrontend && hasReact);
|
|
114
274
|
const hasCelery = /\bcelery\b/i.test(pyproject + setupPy + makefile + compose);
|
|
@@ -118,8 +278,14 @@ function detectArchitecture(repo, pkg, nestedFrontend) {
|
|
|
118
278
|
stack.push("python-backend");
|
|
119
279
|
if (hasFlask)
|
|
120
280
|
stack.push("flask");
|
|
281
|
+
if (hasDjango)
|
|
282
|
+
stack.push("django");
|
|
121
283
|
if (hasGoBackend)
|
|
122
284
|
stack.push("go-backend");
|
|
285
|
+
if (hasRubyBackend)
|
|
286
|
+
stack.push("ruby-backend");
|
|
287
|
+
if (hasMakeDrivenBackend && !hasPythonBackend && !hasGoBackend && !hasRubyBackend)
|
|
288
|
+
stack.push("make-driven");
|
|
123
289
|
if (hasNodeFrontend)
|
|
124
290
|
stack.push("node-frontend");
|
|
125
291
|
if (hasReactFrontend)
|
|
@@ -150,7 +316,6 @@ function detectArchitecture(repo, pkg, nestedFrontend) {
|
|
|
150
316
|
const flaskCommand = makefile.match(/^\s*(flask run[^\n]*)$/m)?.[1].trim() ?? null;
|
|
151
317
|
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
318
|
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
319
|
return {
|
|
155
320
|
backendMarkers: [...new Set(backendMarkers)],
|
|
156
321
|
frontendMarkers: [...new Set(frontendMarkers)],
|
|
@@ -160,10 +325,15 @@ function detectArchitecture(repo, pkg, nestedFrontend) {
|
|
|
160
325
|
flaskCommand,
|
|
161
326
|
frontendMakeCommand,
|
|
162
327
|
workerCommand,
|
|
163
|
-
|
|
328
|
+
makeCommand,
|
|
329
|
+
goEntrypoint,
|
|
330
|
+
rubyCommand,
|
|
164
331
|
hasPythonBackend,
|
|
165
332
|
hasFlask,
|
|
333
|
+
hasDjango,
|
|
166
334
|
hasGoBackend,
|
|
335
|
+
hasRubyBackend,
|
|
336
|
+
hasMakeDrivenBackend,
|
|
167
337
|
hasNodeFrontend,
|
|
168
338
|
};
|
|
169
339
|
}
|
|
@@ -172,17 +342,23 @@ function detectPort(pkg, repo, commands) {
|
|
|
172
342
|
const m = sources.match(/(?:-p|--port)(?:=|\s+|[\\"]+)(\d{2,5})/);
|
|
173
343
|
if (m)
|
|
174
344
|
return { port: Number(m[1]), evidence: `port flag in command evidence: ${m[0].replace(/\\"/g, "").trim()}` };
|
|
345
|
+
const goDefault = sources.match(/(?:SetDefault\(\s*["']port["']\s*,|(?:Int|IntVar)\(\s*["']port["']\s*,)\s*(\d{2,5})/);
|
|
346
|
+
if (goDefault)
|
|
347
|
+
return { port: Number(goDefault[1]), evidence: "port default in Go entrypoint" };
|
|
348
|
+
const listen = sources.match(/ListenAndServe\(\s*["']:(\d{2,5})["']/);
|
|
349
|
+
if (listen)
|
|
350
|
+
return { port: Number(listen[1]), evidence: "HTTP listen address in source" };
|
|
175
351
|
const envEx = readText(repo, ".env.example");
|
|
176
352
|
const pm = envEx.match(/^PORT=(\d{2,5})/m);
|
|
177
353
|
if (pm)
|
|
178
354
|
return { port: Number(pm[1]), evidence: "PORT in .env.example" };
|
|
179
355
|
return { port: 3000, evidence: "default assumption (3000); not evidence-based" };
|
|
180
356
|
}
|
|
181
|
-
function detectServices(pkg, repo) {
|
|
357
|
+
function detectServices(pkg, repo, repoComposeFile) {
|
|
182
358
|
const out = [];
|
|
183
359
|
const envEx = readText(repo, ".env.example") + readText(repo, ".env.sample");
|
|
184
360
|
const schema = readText(repo, "prisma/schema.prisma");
|
|
185
|
-
const compose =
|
|
361
|
+
const compose = repoComposeFile ? readText(repo, repoComposeFile) : "";
|
|
186
362
|
const pyproject = readText(repo, "pyproject.toml");
|
|
187
363
|
const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
|
|
188
364
|
if (/postgres(ql)?:\/\//i.test(envEx) || /provider\s*=\s*"postgresql"/.test(schema) || deps.pg || /^\s{0,4}postgres:\s*$/m.test(compose))
|
|
@@ -229,15 +405,16 @@ function workspacePatterns(repo, pkg) {
|
|
|
229
405
|
return [...new Set(patterns)];
|
|
230
406
|
}
|
|
231
407
|
function expandWorkspacePattern(repo, pattern) {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
408
|
+
const normalizedPattern = pattern.replace(/\\/g, "/");
|
|
409
|
+
if (!normalizedPattern.includes("*"))
|
|
410
|
+
return exists(repo, `${normalizedPattern}/package.json`) ? [normalizedPattern] : [];
|
|
411
|
+
const beforeStar = normalizedPattern.slice(0, normalizedPattern.indexOf("*"));
|
|
235
412
|
const base = beforeStar.replace(/\/$/, "");
|
|
236
|
-
const suffix =
|
|
413
|
+
const suffix = normalizedPattern.slice(normalizedPattern.indexOf("*") + 1).replace(/^\//, "");
|
|
237
414
|
const baseAbs = path.join(repo, base);
|
|
238
415
|
try {
|
|
239
416
|
return fs.readdirSync(baseAbs)
|
|
240
|
-
.map(name => path.join(base, name, suffix))
|
|
417
|
+
.map(name => path.posix.join(base, name, suffix))
|
|
241
418
|
.filter(dir => exists(repo, `${dir}/package.json`));
|
|
242
419
|
}
|
|
243
420
|
catch {
|
|
@@ -245,6 +422,7 @@ function expandWorkspacePattern(repo, pattern) {
|
|
|
245
422
|
}
|
|
246
423
|
}
|
|
247
424
|
const TEST_PATH = /(^|[-_/])(e2e|tests?|test-plugins|fixtures?|examples?|samples?|demos?|mocks?)([-_/]|$)/i;
|
|
425
|
+
const DOCUMENTATION_PATH = /(^|[\/])(storybook|docs?)([\/]|$)/i;
|
|
248
426
|
const SCAFFOLD_NAME = /^create-|-(template|example|fixture|sandbox|sample|demo|mock)$/i;
|
|
249
427
|
function scoreWorkspace(repo, dir, wpkg, isRoot) {
|
|
250
428
|
const { command } = pickAppCommand(wpkg, "npm");
|
|
@@ -293,6 +471,10 @@ function scoreWorkspace(repo, dir, wpkg, isRoot) {
|
|
|
293
471
|
score -= 10;
|
|
294
472
|
reasons.push("test/example path downranked");
|
|
295
473
|
}
|
|
474
|
+
if (DOCUMENTATION_PATH.test(`${dir}/${wpkg?.name ?? ""}`)) {
|
|
475
|
+
score -= 3;
|
|
476
|
+
reasons.push("documentation/storybook downranked");
|
|
477
|
+
}
|
|
296
478
|
if (wpkg?.private !== true && (wpkg?.main || wpkg?.exports) && !command) {
|
|
297
479
|
score -= 2;
|
|
298
480
|
reasons.push("looks like a publishable library");
|
|
@@ -343,41 +525,104 @@ export function inferRepo(repoPath, opts = {}) {
|
|
|
343
525
|
const rootRepo = path.resolve(repoPath);
|
|
344
526
|
const rootPkg = opts.workspace ? readJson(path.join(rootRepo, "package.json")) : pkg;
|
|
345
527
|
const nestedFrontend = detectNestedFrontend(repo);
|
|
346
|
-
const
|
|
528
|
+
const repoComposeFile = detectRepoComposeFile(repo);
|
|
529
|
+
const composeApplicationServices = applyBootProofComposeOverride(repo, repoComposeFile, detectComposeApplications(repo, repoComposeFile));
|
|
530
|
+
const sourceComposeApplications = composeApplicationServices.filter(service => service.source === "build");
|
|
531
|
+
const composeHealthCandidates = sourceComposeApplications.length === 1
|
|
532
|
+
? sourceComposeApplications[0].healthCandidates
|
|
533
|
+
: [];
|
|
534
|
+
const architecture = detectArchitecture(repo, pkg, nestedFrontend, repoComposeFile);
|
|
347
535
|
const pm = detectPackageManager(repo, pkg, nestedFrontend?.dir ?? null);
|
|
348
536
|
const rootApp = pickAppCommand(pkg, pm.pm);
|
|
349
|
-
const
|
|
537
|
+
const commandEvidence = [
|
|
538
|
+
architecture.flaskCommand,
|
|
539
|
+
architecture.makeCommand?.command ?? null,
|
|
540
|
+
architecture.goEntrypoint?.sourceText ?? null,
|
|
541
|
+
architecture.rubyCommand?.commandBase ?? null,
|
|
542
|
+
];
|
|
543
|
+
const { port, evidence: portEvidence } = detectPort(pkg, repo, commandEvidence);
|
|
544
|
+
const goCommand = architecture.goEntrypoint
|
|
545
|
+
? [
|
|
546
|
+
architecture.goEntrypoint.commandBase,
|
|
547
|
+
architecture.goEntrypoint.portFlag ? `--port ${port}` : "",
|
|
548
|
+
architecture.goEntrypoint.dataDirFlag ? "--data .bootproof/runtime/go-app" : "",
|
|
549
|
+
].filter(Boolean).join(" ")
|
|
550
|
+
: null;
|
|
551
|
+
const rubyCommand = architecture.rubyCommand ? `${architecture.rubyCommand.commandBase} -p ${port}` : null;
|
|
552
|
+
const djangoCommand = architecture.hasDjango ? `python manage.py runserver 127.0.0.1:${port}` : null;
|
|
553
|
+
const repositoryBackendCommand = architecture.makeCommand?.command ?? goCommand ?? rubyCommand;
|
|
554
|
+
const backendCommand = architecture.flaskCommand ?? djangoCommand ?? repositoryBackendCommand;
|
|
350
555
|
const nestedFrontendCommand = architecture.frontendMakeCommand;
|
|
351
556
|
const frontendCommand = nestedFrontendCommand ?? rootApp.command;
|
|
352
|
-
const appCommand = architecture.flaskCommand
|
|
557
|
+
const appCommand = architecture.flaskCommand
|
|
558
|
+
?? djangoCommand
|
|
559
|
+
?? (architecture.hasGoBackend && architecture.hasNodeFrontend && rootApp.command ? rootApp.command : null)
|
|
560
|
+
?? repositoryBackendCommand
|
|
561
|
+
?? rootApp.command;
|
|
353
562
|
const appCommandSource = architecture.flaskCommand
|
|
354
563
|
? `Makefile Flask command: ${architecture.flaskCommand}`
|
|
355
|
-
:
|
|
356
|
-
|
|
564
|
+
: djangoCommand && appCommand === djangoCommand
|
|
565
|
+
? "Django entrypoint: manage.py"
|
|
566
|
+
: architecture.makeCommand && appCommand === architecture.makeCommand.command
|
|
567
|
+
? architecture.makeCommand.source
|
|
568
|
+
: goCommand && appCommand === goCommand
|
|
569
|
+
? architecture.goEntrypoint?.source ?? rootApp.source
|
|
570
|
+
: rubyCommand && appCommand === rubyCommand
|
|
571
|
+
? architecture.rubyCommand?.source ?? rootApp.source
|
|
572
|
+
: rootApp.source;
|
|
573
|
+
const recognizedApplication = architecture.hasPythonBackend ||
|
|
574
|
+
architecture.hasGoBackend ||
|
|
575
|
+
architecture.hasRubyBackend ||
|
|
576
|
+
architecture.hasMakeDrivenBackend ||
|
|
577
|
+
composeApplicationServices.some(service => service.source === "build");
|
|
357
578
|
const workspaces = opts.workspace ? [] : rankWorkspaces(rootRepo, rootPkg);
|
|
358
579
|
const notApp = looksLikeLibrary(pkg, appCommand, workspaces.some(candidate => candidate.dir !== "."), recognizedApplication);
|
|
359
|
-
const { port, evidence: portEvidence } = detectPort(pkg, repo, [backendCommand, frontendCommand]);
|
|
360
580
|
const env = detectEnv(repo);
|
|
361
|
-
const services = detectServices(pkg, repo);
|
|
581
|
+
const services = detectServices(pkg, repo, repoComposeFile);
|
|
362
582
|
const rootDeps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
|
|
363
|
-
const
|
|
583
|
+
const preparationCommands = [];
|
|
584
|
+
const nodePreparationRequired = Boolean(rootApp.command &&
|
|
364
585
|
(Object.keys(rootDeps).length > 0 || exists(repo, "yarn.lock") || exists(repo, "pnpm-lock.yaml") || exists(repo, "package-lock.json") || exists(repo, "nx.json")));
|
|
586
|
+
if (nodePreparationRequired) {
|
|
587
|
+
const command = installCommand(pm.pm, pm.packageDir);
|
|
588
|
+
if (command)
|
|
589
|
+
preparationCommands.push({ id: "install", kind: "install", command, description: "install Node dependencies", source: pm.evidence });
|
|
590
|
+
}
|
|
591
|
+
if (goCommand && appCommand === goCommand && exists(repo, "go.sum")) {
|
|
592
|
+
preparationCommands.push({ id: "go-modules", kind: "install", command: "go mod download", description: "download declared Go modules", source: "go.sum present" });
|
|
593
|
+
}
|
|
594
|
+
if (rubyCommand && appCommand === rubyCommand) {
|
|
595
|
+
preparationCommands.push({ id: "bundle-install", kind: "install", command: "bundle install", description: "install declared Ruby gems", source: "Gemfile and bin/rails present" });
|
|
596
|
+
}
|
|
597
|
+
const dependencyInstallRequired = preparationCommands.length > 0;
|
|
365
598
|
const incompleteAppCommand = Boolean(architecture.hasGoBackend && architecture.hasNodeFrontend && rootApp.command);
|
|
366
599
|
const multiAppCommand = Boolean(rootApp.command && /\b(?:turbo|nx)\s+run\s+dev\b[^\n]*--parallel\b/i.test(rootApp.source));
|
|
367
600
|
const commandScope = multiAppCommand
|
|
368
601
|
? "multi-workspace development pipeline; no single application health target selected"
|
|
369
602
|
: incompleteAppCommand
|
|
370
603
|
? "frontend/dev pipeline only; Go backend markers also detected"
|
|
371
|
-
: architecture.
|
|
604
|
+
: architecture.hasFlask && nestedFrontend
|
|
372
605
|
? "Python/Flask backend command; React frontend and worker require separate orchestration"
|
|
373
|
-
: appCommand
|
|
374
|
-
? "application command"
|
|
375
|
-
:
|
|
606
|
+
: djangoCommand && appCommand === djangoCommand
|
|
607
|
+
? "Django application command"
|
|
608
|
+
: goCommand && appCommand === goCommand && nestedFrontend
|
|
609
|
+
? "Go application command serving repository-embedded frontend assets"
|
|
610
|
+
: rubyCommand && appCommand === rubyCommand
|
|
611
|
+
? "Rails application command"
|
|
612
|
+
: architecture.makeCommand && appCommand === architecture.makeCommand.command
|
|
613
|
+
? "repository-defined Make target"
|
|
614
|
+
: appCommand
|
|
615
|
+
? "application command"
|
|
616
|
+
: "no runnable command selected";
|
|
376
617
|
const healthCandidates = notApp
|
|
377
618
|
? []
|
|
378
|
-
:
|
|
379
|
-
?
|
|
380
|
-
:
|
|
619
|
+
: !appCommand && composeHealthCandidates.length
|
|
620
|
+
? composeHealthCandidates
|
|
621
|
+
: !appCommand
|
|
622
|
+
? []
|
|
623
|
+
: architecture.hasGoBackend && architecture.hasNodeFrontend
|
|
624
|
+
? [`http://localhost:${port}/api/health`, `http://localhost:${port}/`]
|
|
625
|
+
: [`http://localhost:${port}/`];
|
|
381
626
|
let confidence = 0;
|
|
382
627
|
if (appCommand)
|
|
383
628
|
confidence += 35;
|
|
@@ -399,11 +644,15 @@ export function inferRepo(repoPath, opts = {}) {
|
|
|
399
644
|
backendMarkers: architecture.backendMarkers,
|
|
400
645
|
frontendMarkers: architecture.frontendMarkers,
|
|
401
646
|
serviceMarkers: architecture.serviceMarkers,
|
|
647
|
+
repoComposeFile,
|
|
648
|
+
composeApplicationServices,
|
|
649
|
+
composeHealthCandidates,
|
|
402
650
|
setupSteps: architecture.setupSteps,
|
|
403
651
|
packageManager: pm.pm,
|
|
404
652
|
packageManagerEvidence: pm.evidence,
|
|
405
653
|
packageManagerVersion: pm.version ?? pkg?.engines?.[pm.pm] ?? rootPkg?.engines?.[pm.pm] ?? null,
|
|
406
|
-
installCommand:
|
|
654
|
+
installCommand: preparationCommands.find(command => command.kind === "install")?.command ?? null,
|
|
655
|
+
preparationCommands,
|
|
407
656
|
dependencyInstallRequired,
|
|
408
657
|
appCommand,
|
|
409
658
|
appCommandSource,
|
package/dist/plan.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Inference, RunPlan } from "./types.js";
|
|
2
|
+
export declare const REPAIRED_GENERATED_COMPOSE_MARKER = "# BootProof verified repair: remap-conflicting-service-port";
|
|
3
|
+
export declare function repoComposeRepairFile(repoComposeFile: string): string;
|
|
2
4
|
export declare function composeFileFor(inf: Inference): string | null;
|
|
3
5
|
export declare function envExampleFor(inf: Inference): string | null;
|
|
4
6
|
export declare function buildPlan(inf: Inference, provider: "docker" | "local"): RunPlan;
|
package/dist/plan.js
CHANGED
|
@@ -6,9 +6,23 @@ const SERVICE_IMAGES = {
|
|
|
6
6
|
redis: { image: "redis:7-alpine", port: 6379, env: {} },
|
|
7
7
|
mongodb: { image: "mongo:7", port: 27017, env: {} },
|
|
8
8
|
};
|
|
9
|
+
export const REPAIRED_GENERATED_COMPOSE_MARKER = "# BootProof verified repair: remap-conflicting-service-port";
|
|
10
|
+
export function repoComposeRepairFile(repoComposeFile) {
|
|
11
|
+
const directory = path.posix.dirname(repoComposeFile.replace(/\\/g, "/"));
|
|
12
|
+
const file = "docker-compose.bootproof.override.yml";
|
|
13
|
+
return directory === "." ? file : path.posix.join(directory, file);
|
|
14
|
+
}
|
|
9
15
|
export function composeFileFor(inf) {
|
|
16
|
+
if (inf.repoComposeFile)
|
|
17
|
+
return null;
|
|
10
18
|
if (!inf.services.length)
|
|
11
19
|
return null;
|
|
20
|
+
const existingPath = path.join(inf.repoPath, "docker-compose.bootproof.yml");
|
|
21
|
+
if (fs.existsSync(existingPath)) {
|
|
22
|
+
const existing = fs.readFileSync(existingPath, "utf8");
|
|
23
|
+
if (existing.includes(REPAIRED_GENERATED_COMPOSE_MARKER))
|
|
24
|
+
return existing;
|
|
25
|
+
}
|
|
12
26
|
const lines = ["# Generated by bootproof — review before use. Standard compose; no bootproof runtime required.", "services:"];
|
|
13
27
|
for (const s of inf.services) {
|
|
14
28
|
const spec = SERVICE_IMAGES[s.kind];
|
|
@@ -47,16 +61,42 @@ export function envExampleFor(inf) {
|
|
|
47
61
|
}
|
|
48
62
|
export function buildPlan(inf, provider) {
|
|
49
63
|
const steps = [];
|
|
50
|
-
|
|
51
|
-
|
|
64
|
+
const runsSourceComposeApplication = provider === "docker" &&
|
|
65
|
+
Boolean(inf.repoComposeFile) &&
|
|
66
|
+
inf.composeHealthCandidates.length > 0;
|
|
67
|
+
if (provider === "docker" && inf.repoComposeFile) {
|
|
68
|
+
const repairedCompose = repoComposeRepairFile(inf.repoComposeFile);
|
|
69
|
+
const usesRepairedCopy = fs.existsSync(path.join(inf.repoPath, repairedCompose));
|
|
70
|
+
steps.push({
|
|
71
|
+
id: "services",
|
|
72
|
+
kind: "service",
|
|
73
|
+
command: `docker compose -f ${usesRepairedCopy ? repairedCompose : inf.repoComposeFile} up -d`,
|
|
74
|
+
description: usesRepairedCopy
|
|
75
|
+
? "use the BootProof repaired copy of the repository Compose file"
|
|
76
|
+
: "defer to the repository's own compose file",
|
|
77
|
+
required: true,
|
|
78
|
+
});
|
|
52
79
|
}
|
|
53
|
-
if (inf.
|
|
54
|
-
steps.push({ id: "
|
|
80
|
+
else if (inf.services.length && provider === "docker") {
|
|
81
|
+
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 });
|
|
55
82
|
}
|
|
56
|
-
if (
|
|
57
|
-
|
|
83
|
+
if (!runsSourceComposeApplication) {
|
|
84
|
+
for (const preparation of inf.preparationCommands) {
|
|
85
|
+
steps.push({
|
|
86
|
+
id: preparation.id,
|
|
87
|
+
kind: preparation.kind,
|
|
88
|
+
command: preparation.command,
|
|
89
|
+
description: `${preparation.description} (${preparation.source})`,
|
|
90
|
+
required: true,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
if (inf.appCommand) {
|
|
94
|
+
steps.push({ id: "start-app", kind: "start-app", command: inf.appCommand, description: `start app (${inf.appCommandSource})`, required: true });
|
|
95
|
+
}
|
|
58
96
|
}
|
|
59
|
-
const healthCandidates =
|
|
97
|
+
const healthCandidates = runsSourceComposeApplication
|
|
98
|
+
? [...inf.composeHealthCandidates]
|
|
99
|
+
: [...inf.healthCandidates];
|
|
60
100
|
const healthUrl = healthCandidates[0] ?? "";
|
|
61
101
|
if (inf.isApplication && healthUrl) {
|
|
62
102
|
steps.push({ id: "health", kind: "health", description: `poll ${healthUrl} for an HTTP response`, required: true });
|
package/dist/proof.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Attestation, ObservedStep, RunPlan, FailureClass } from "./types.js";
|
|
2
|
-
export declare const TOOL_ID = "bootproof@0.
|
|
2
|
+
export declare const TOOL_ID = "bootproof@0.3.0";
|
|
3
3
|
export declare function gitInfo(repo: string): Attestation["repo"];
|
|
4
4
|
export declare function buildAttestation(input: {
|
|
5
5
|
repo: string;
|
package/dist/proof.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { execFileSync } from "node:child_process";
|
|
6
|
-
export const TOOL_ID = "bootproof@0.
|
|
6
|
+
export const TOOL_ID = "bootproof@0.3.0";
|
|
7
7
|
export function gitInfo(repo) {
|
|
8
8
|
const git = (...args) => {
|
|
9
9
|
try {
|
|
@@ -18,7 +18,7 @@ export function gitInfo(repo) {
|
|
|
18
18
|
const status = git("status", "--porcelain");
|
|
19
19
|
return {
|
|
20
20
|
path: repo,
|
|
21
|
-
remote: git("
|
|
21
|
+
remote: git("config", "--get", "remote.origin.url"),
|
|
22
22
|
commit: git("rev-parse", "HEAD"),
|
|
23
23
|
dirty: status === null ? null : status.length > 0,
|
|
24
24
|
};
|
package/dist/remote.d.ts
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
|
+
export type RemoteProvider = "github" | "gitlab" | "bitbucket" | "codeberg";
|
|
2
|
+
export interface GitRemote {
|
|
3
|
+
originalUrl: string;
|
|
4
|
+
canonicalUrl: string;
|
|
5
|
+
provider: RemoteProvider;
|
|
6
|
+
host: string;
|
|
7
|
+
namespace: string;
|
|
8
|
+
repo: string;
|
|
9
|
+
}
|
|
1
10
|
export interface GithubRemote {
|
|
2
11
|
originalUrl: string;
|
|
3
12
|
canonicalUrl: string;
|
|
4
13
|
owner: string;
|
|
5
14
|
repo: string;
|
|
6
15
|
}
|
|
7
|
-
export interface RemoteClone extends
|
|
16
|
+
export interface RemoteClone extends GitRemote {
|
|
8
17
|
repoPath: string;
|
|
9
18
|
}
|
|
10
19
|
export declare function isRemoteTarget(value: string): boolean;
|
|
20
|
+
export declare function parseRemoteTarget(value: string): GitRemote;
|
|
11
21
|
export declare function parseGithubRemote(value: string): GithubRemote;
|
|
22
|
+
export declare function cloneRemoteTarget(value: string, cwd: string): RemoteClone;
|
|
12
23
|
export declare function cloneGithubRemote(value: string, cwd: string): RemoteClone;
|
|
13
24
|
export declare function managedRemoteSource(repoPath: string): string | null;
|