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/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 detectArchitecture(repo, pkg, nestedFrontend) {
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 compose = readText(repo, "docker-compose.yml") + readText(repo, "docker-compose.yaml") + readText(repo, "compose.yaml");
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, ["docker-compose.yml", "docker-compose.yaml", "compose.yaml", "docker-compose-light.yml"]);
107
- const hasPythonBackend = (exists(repo, "pyproject.toml") || exists(repo, "setup.py")) &&
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 = hasPythonBackend && (/\bflask\b/i.test(pyproject + setupPy + makefile) || exists(repo, "superset/app.py"));
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 hasNodeFrontend = Boolean(pkg) && (isDirectory(repo, "public") || isDirectory(repo, "packages") || exists(repo, "nx.json") || hasGoBackend);
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
- makeRunCommand,
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 = readText(repo, "docker-compose.yml") + readText(repo, "docker-compose.yaml") + readText(repo, "compose.yaml");
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
- if (!pattern.includes("*"))
233
- return exists(repo, `${pattern}/package.json`) ? [pattern] : [];
234
- const beforeStar = pattern.slice(0, pattern.indexOf("*"));
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 = pattern.slice(pattern.indexOf("*") + 1).replace(/^\//, "");
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 architecture = detectArchitecture(repo, pkg, nestedFrontend);
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 backendCommand = architecture.flaskCommand ?? architecture.makeRunCommand;
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 ?? rootApp.command;
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
- : rootApp.source;
356
- const recognizedApplication = architecture.hasPythonBackend || architecture.hasGoBackend;
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 dependencyInstallRequired = Boolean(rootApp.command &&
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.hasPythonBackend && nestedFrontend
604
+ : architecture.hasFlask && nestedFrontend
372
605
  ? "Python/Flask backend command; React frontend and worker require separate orchestration"
373
- : appCommand
374
- ? "application command"
375
- : "no runnable command selected";
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
- : architecture.hasGoBackend && architecture.hasNodeFrontend
379
- ? [`http://localhost:${port}/api/health`, `http://localhost:${port}/`]
380
- : [`http://localhost:${port}/`];
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: installCommand(pm.pm, pm.packageDir),
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
- 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 });
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.installCommand) {
54
- steps.push({ id: "install", kind: "install", command: inf.installCommand, description: "install dependencies", required: inf.dependencyInstallRequired });
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 (inf.appCommand) {
57
- steps.push({ id: "start-app", kind: "start-app", command: inf.appCommand, description: `start app (${inf.appCommandSource})`, required: true });
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 = [...inf.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.1.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.1.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("remote", "get-url", "origin"),
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 GithubRemote {
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;