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/run.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Inference, RunPlan, FailureClass, Attestation } from "./types.js";
1
+ import type { Inference, RunPlan, FailureClass, Attestation, PreparationCommand } from "./types.js";
2
2
  export interface UpOptions {
3
3
  provider: "docker" | "local";
4
4
  unsafeLocal: boolean;
@@ -8,6 +8,8 @@ export interface UpOptions {
8
8
  timeoutMs: number;
9
9
  install: boolean;
10
10
  port?: number;
11
+ environment?: Record<string, string>;
12
+ additionalPreparationCommands?: PreparationCommand[];
11
13
  }
12
14
  export interface UpOutcome {
13
15
  inference: Inference;
package/dist/run.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
2
4
  import { inferRepo } from "./infer.js";
3
5
  import { buildPlan, writePlanFiles } from "./plan.js";
4
6
  import { runToCompletion, superviseApp, pollHealthCandidates, minimalEnv } from "./exec.js";
5
- import { classifyFailure } from "./taxonomy.js";
7
+ import { classifyFailure, extractMissingEnvNames } from "./taxonomy.js";
6
8
  import { buildAttestation, writeAttestation } from "./proof.js";
7
9
  function classifyHealthFailure(evidence) {
8
10
  if (/(only HTTP 5\d\d observed|HTTP 5\d\d|status\s*5\d\d|returned 5\d\d)/i.test(evidence)) {
@@ -22,11 +24,21 @@ export function packageManagerVersionMatches(expected, actual) {
22
24
  const actualParts = actualMatch.slice(1, 1 + expectedParts.length);
23
25
  return expectedParts.every((part, index) => part === actualParts[index]);
24
26
  }
25
- function packageManagerVersionEvidence(inference) {
27
+ function commandUsesExecutable(command, executable) {
28
+ if (!command)
29
+ return false;
30
+ const escaped = executable.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
31
+ return new RegExp(`(?:^|&&|\\|\\||;)\\s*${escaped}(?:\\s|$)`).test(command);
32
+ }
33
+ function packageManagerVersionEvidence(inference, plan, env) {
26
34
  if (inference.packageManager === "unknown" || !inference.packageManagerVersion)
27
35
  return null;
36
+ if (!plan.steps.some(planned => commandUsesExecutable(planned.command, inference.packageManager)))
37
+ return null;
28
38
  try {
29
- const actual = execFileSync(inference.packageManager, ["--version"], { cwd: inference.repoPath, encoding: "utf8" }).trim();
39
+ const actual = process.platform === "win32"
40
+ ? execFileSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", `${inference.packageManager} --version`], { cwd: inference.repoPath, encoding: "utf8", env }).trim()
41
+ : execFileSync(inference.packageManager, ["--version"], { cwd: inference.repoPath, encoding: "utf8", env }).trim();
30
42
  if (packageManagerVersionMatches(inference.packageManagerVersion, actual))
31
43
  return null;
32
44
  return `packageManager field or engines.${inference.packageManager} expected version: ${inference.packageManagerVersion}\nGot: ${actual}`;
@@ -35,15 +47,65 @@ function packageManagerVersionEvidence(inference) {
35
47
  return null;
36
48
  }
37
49
  }
50
+ function commandWithPort(command, port) {
51
+ return command
52
+ .replace(/((?:--port(?:=|\s+)|-p\s+))\d{2,5}\b/, `$1${port}`)
53
+ .replace(/(\bmanage\.py\s+runserver\s+(?:127\.0\.0\.1|localhost):)\d{2,5}\b/, `$1${port}`);
54
+ }
55
+ function unsupportedOrchestrationExplanation(inference) {
56
+ if (inference.appCommand)
57
+ return null;
58
+ const sourceComposeServices = inference.composeApplicationServices.filter(service => service.source === "build");
59
+ if (sourceComposeServices.length > 1) {
60
+ return `Detected multiple source-built HTTP services in ${inference.repoComposeFile}: ${sourceComposeServices.map(service => service.name).join(", ")}. BootProof will not treat one responding service as proof that the repository booted. Diagnosis only — no localhost claim.`;
61
+ }
62
+ if (sourceComposeServices.length === 1 && inference.composeHealthCandidates.length === 0) {
63
+ return `Detected source-built service ${sourceComposeServices[0].name} in ${inference.repoComposeFile}, but no unambiguous published HTTP candidate. Diagnosis only — no localhost claim.`;
64
+ }
65
+ const backend = inference.stack.includes("go-backend")
66
+ ? { stack: "go-backend", markers: inference.backendMarkers.filter(marker => marker === "go.mod" || marker === "go.work") }
67
+ : inference.stack.includes("ruby-backend")
68
+ ? { stack: "ruby-backend", markers: inference.backendMarkers.filter(marker => marker === "Gemfile" || marker === "config/database.yml") }
69
+ : inference.stack.includes("make-driven")
70
+ ? { stack: "make-driven", markers: inference.backendMarkers.filter(marker => marker === "Makefile") }
71
+ : null;
72
+ if (!backend)
73
+ return null;
74
+ const frontendStack = inference.stack.includes("react-frontend")
75
+ ? "react-frontend"
76
+ : inference.stack.includes("node-frontend")
77
+ ? "node-frontend"
78
+ : null;
79
+ const frontendMarker = inference.frontendMarkers.find(marker => marker.endsWith("/package.json"))
80
+ ?? inference.frontendMarkers.find(marker => marker === "package.json");
81
+ const frontend = frontendStack && frontendMarker ? ` with ${frontendStack} (${frontendMarker})` : "";
82
+ return `Detected ${backend.stack} (${backend.markers.join(", ")})${frontend}. BootProof can diagnose this stack but does not yet orchestrate its boot. Diagnosis only — no localhost claim.`;
83
+ }
38
84
  export async function up(repoPath, opts) {
39
85
  const startedAt = new Date().toISOString();
40
86
  const inference = inferRepo(repoPath, { workspace: opts.workspace });
87
+ if (opts.additionalPreparationCommands?.length) {
88
+ inference.preparationCommands.push(...opts.additionalPreparationCommands);
89
+ inference.dependencyInstallRequired = true;
90
+ }
41
91
  if (opts.port) {
42
- inference.port = opts.port;
43
- inference.portEvidence = "set by --port flag";
44
- inference.healthCandidates = inference.healthCandidates.map(candidate => candidate.replace(/:\d{2,5}(?=\/)/, `:${opts.port}`));
92
+ if (inference.appCommand) {
93
+ inference.port = opts.port;
94
+ inference.portEvidence = "set by --port flag";
95
+ inference.appCommand = commandWithPort(inference.appCommand, opts.port);
96
+ if (inference.backendCommand)
97
+ inference.backendCommand = commandWithPort(inference.backendCommand, opts.port);
98
+ inference.healthCandidates = inference.healthCandidates.map(candidate => candidate.replace(/:\d{2,5}(?=\/)/, `:${opts.port}`));
99
+ }
100
+ else if (inference.composeHealthCandidates.length) {
101
+ inference.portEvidence = `repository Compose published port retained; --port ${opts.port} was not applied`;
102
+ }
45
103
  }
46
104
  const plan = buildPlan(inference, opts.provider);
105
+ const env = minimalEnv({ PORT: String(inference.port), ...opts.environment });
106
+ const runsSourceComposeApplication = opts.provider === "docker" &&
107
+ Boolean(inference.repoComposeFile) &&
108
+ inference.composeHealthCandidates.length > 0;
47
109
  const base = { inference, plan, writtenFiles: [] };
48
110
  const refuse = (failureClass, explanation, observed = [], failureEvidence = explanation) => {
49
111
  const refusal = { failureClass, explanation };
@@ -77,6 +139,13 @@ export async function up(repoPath, opts) {
77
139
  if (inference.stack.includes("python-backend") && inference.stack.includes("flask") && inference.setupSteps.length > 0) {
78
140
  return refuse("python_flask_setup_required", "BootProof detected a Python/Flask + React application with setup steps. This repository requires database migration/init and service orchestration before it can be verified.");
79
141
  }
142
+ const orchestrationExplanation = unsupportedOrchestrationExplanation(inference);
143
+ if (orchestrationExplanation && !runsSourceComposeApplication) {
144
+ return refuse("orchestration_not_supported", orchestrationExplanation);
145
+ }
146
+ if (!inference.appCommand && inference.composeHealthCandidates.length > 0 && opts.provider !== "docker") {
147
+ return refuse("orchestration_not_supported", `Detected a source-built application in ${inference.repoComposeFile} with published HTTP candidates, but repository Compose requires --provider docker. Diagnosis only — no localhost claim.`);
148
+ }
80
149
  if (!opts.workspace && inference.workspaces.length > 1 && !inference.appCommand) {
81
150
  return refuse("workspace_ambiguous", `This is a monorepo with ${inference.workspaces.length} workspace candidates. Choose one with --workspace <dir> instead of letting bootproof guess.`);
82
151
  }
@@ -88,51 +157,99 @@ export async function up(repoPath, opts) {
88
157
  }
89
158
  if (opts.dryRun)
90
159
  return { ...base, attestation: null, refusal: null };
91
- if (inference.dependencyInstallRequired && !opts.install) {
92
- const skipped = step("install", "install", inference.installCommand ?? undefined, new Date().toISOString(), null, false, "skipped by default dependency-backed application was not started; pass --install to run dependency installation");
160
+ if (inference.multiAppCommand) {
161
+ return refuse("workspace_ambiguous", "BootProof detected a root command that starts multiple workspaces in parallel. Choose a specific application with --workspace <dir>; one responding workspace is not proof that the whole repository booted.");
162
+ }
163
+ const preparationSteps = plan.steps.filter(planned => planned.kind === "install" || planned.kind === "build");
164
+ if (preparationSteps.length > 0 && !opts.install) {
165
+ const skipped = step(preparationSteps[0].id, preparationSteps[0].kind, preparationSteps[0].command, new Date().toISOString(), null, false, "skipped by default — dependency-backed application was not started; pass --install to run dependency installation");
93
166
  return refuse("dependency_install_skipped", "The inferred application command depends on project packages, but dependency installation was not requested. BootProof did not start the partial application pipeline.", [skipped]);
94
167
  }
168
+ const hostExecutionSteps = plan.steps.filter(planned => planned.kind === "install" ||
169
+ planned.kind === "build" ||
170
+ planned.kind === "start-app");
171
+ if (opts.provider === "docker" && !runsSourceComposeApplication && hostExecutionSteps.length > 0) {
172
+ return refuse("orchestration_not_supported", `Docker provider selected, but the inferred plan contains host commands (${hostExecutionSteps.map(planned => planned.command).filter(Boolean).join("; ")}). BootProof will not silently run them on the host. Use a source-built repository Compose application, or explicitly choose --provider local --unsafe-local after review.`);
173
+ }
95
174
  if (opts.install) {
96
- const versionEvidence = packageManagerVersionEvidence(inference);
175
+ const versionEvidence = packageManagerVersionEvidence(inference, plan, env);
97
176
  if (versionEvidence) {
98
177
  const observed = step("package-manager-version", "install", `${inference.packageManager} --version`, new Date().toISOString(), 0, false, `declared ${inference.packageManager} ${inference.packageManagerVersion}, but found ${versionEvidence.split("Got: ")[1]}`, versionEvidence);
99
178
  return refuse("package_manager_version_mismatch", "The repository declares a package manager version that does not match the version available in the current environment. Enable Corepack or install the required package manager version before rerunning BootProof.", [observed], versionEvidence);
100
179
  }
101
180
  }
102
- if (inference.multiAppCommand) {
103
- return refuse("workspace_ambiguous", "BootProof detected a root command that starts multiple workspaces in parallel. Choose a specific application with --workspace <dir>; one responding workspace is not proof that the whole repository booted.");
104
- }
105
181
  if (inference.incompleteAppCommand) {
106
182
  return refuse("unknown_failure", "BootProof detected a hybrid backend/frontend repository, but the inferred command starts only the frontend development pipeline. Complete application orchestration is not implemented, so no boot was attempted.");
107
183
  }
108
184
  const writtenFiles = writePlanFiles(inference, inference.repoPath);
185
+ if (inference.appCommand?.includes(".bootproof/runtime/")) {
186
+ fs.mkdirSync(path.join(inference.repoPath, ".bootproof", "runtime"), { recursive: true });
187
+ }
109
188
  const observed = [];
110
- const env = minimalEnv({ PORT: String(inference.port) });
189
+ const explanationWithMissingEnv = (failureClass, evidence, explanation) => {
190
+ if (failureClass !== "missing_env_var")
191
+ return explanation;
192
+ const names = extractMissingEnvNames(evidence);
193
+ if (!names.length)
194
+ return explanation;
195
+ const generatedExample = path.join(inference.repoPath, ".env.bootproof.example");
196
+ const suffix = fs.existsSync(generatedExample)
197
+ ? `Missing: ${names.join(", ")} — see .env.bootproof.example; bootproof will not invent values.`
198
+ : `Missing: ${names.join(", ")}; bootproof will not invent values.`;
199
+ return `${explanation} ${suffix}`;
200
+ };
111
201
  const fail = (failureClass, evidence, explanation) => {
112
- const att = buildAttestation({ repo: inference.repoPath, plan, observed, startedAt, booted: false, healthVerified: false, healthObservation: null, observedHealthCandidates: [], failureClass, failureEvidence: evidence.slice(-2000), explanation });
202
+ const preciseExplanation = explanationWithMissingEnv(failureClass, evidence, explanation);
203
+ const att = buildAttestation({ repo: inference.repoPath, plan, observed, startedAt, booted: false, healthVerified: false, healthObservation: null, observedHealthCandidates: [], failureClass, failureEvidence: evidence.slice(-2000), explanation: preciseExplanation });
113
204
  writeAttestation(inference.repoPath, att);
114
205
  return { inference, plan, writtenFiles, attestation: att, refusal: null };
115
206
  };
207
+ const composeDiagnostics = async () => {
208
+ if (!inference.repoComposeFile)
209
+ return "";
210
+ const commands = [
211
+ {
212
+ id: "compose-ps",
213
+ command: `docker compose -f ${inference.repoComposeFile} ps --all`,
214
+ observation: "captured repository Compose service state",
215
+ },
216
+ {
217
+ id: "compose-logs",
218
+ command: `docker compose -f ${inference.repoComposeFile} logs --no-color --tail 200`,
219
+ observation: "captured repository Compose logs",
220
+ },
221
+ ];
222
+ const evidence = [];
223
+ for (const diagnostic of commands) {
224
+ const t = new Date().toISOString();
225
+ const result = await runToCompletion(diagnostic.command, inference.repoPath, 30_000, env);
226
+ const text = [result.stdout, result.stderr].filter(Boolean).join("\n");
227
+ observed.push(step(diagnostic.id, "service", diagnostic.command, t, result.exitCode, result.exitCode === 0, result.exitCode === 0 ? diagnostic.observation : `${diagnostic.observation} failed`, text || undefined));
228
+ if (text)
229
+ evidence.push(`${diagnostic.command}\n${text}`);
230
+ }
231
+ return evidence.join("\n");
232
+ };
116
233
  for (const planned of plan.steps) {
117
234
  if (planned.kind === "service" && planned.command) {
118
235
  const t = new Date().toISOString();
119
236
  const r = await runToCompletion(planned.command, inference.repoPath, 120_000, env);
120
237
  const ok = r.exitCode === 0;
121
- observed.push(step(planned.id, "service", planned.command, t, r.exitCode, ok, ok ? "services started (docker compose exit 0)" : "docker compose failed", r.stderr || r.stdout));
238
+ observed.push(step(planned.id, "service", planned.command, t, r.exitCode, ok, ok ? "docker compose accepted the start request (exit 0); HTTP health not yet verified" : "docker compose failed", r.stderr || r.stdout));
122
239
  if (!ok) {
123
240
  const c = classifyFailure(r.stderr + r.stdout);
124
241
  return fail(c.class, r.stderr + r.stdout, c.explanation);
125
242
  }
126
243
  }
127
- if (planned.kind === "install" && planned.command) {
244
+ if ((planned.kind === "install" || planned.kind === "build") && planned.command) {
128
245
  if (!opts.install) {
129
- observed.push(step(planned.id, "install", planned.command, new Date().toISOString(), null, false, "skipped by default — optional install was not needed for the observed boot"));
246
+ observed.push(step(planned.id, planned.kind, planned.command, new Date().toISOString(), null, false, "skipped by default — preparation was not authorized"));
130
247
  continue;
131
248
  }
132
249
  const t = new Date().toISOString();
133
250
  const r = await runToCompletion(planned.command, inference.repoPath, 600_000, env);
134
251
  const ok = r.exitCode === 0 && !r.timedOut;
135
- observed.push(step(planned.id, "install", planned.command, t, r.exitCode, ok, ok ? "dependencies installed (exit 0)" : r.timedOut ? "install timed out" : `install failed (exit ${r.exitCode})`, ok ? undefined : r.stderr || r.stdout));
252
+ observed.push(step(planned.id, planned.kind, planned.command, t, r.exitCode, ok, ok ? `${planned.kind === "install" ? "dependency preparation" : "build"} completed (exit 0)` : r.timedOut ? `${planned.kind} timed out` : `${planned.kind} failed (exit ${r.exitCode})`, ok ? undefined : r.stderr || r.stdout));
136
253
  if (!ok) {
137
254
  const c = classifyFailure(r.stderr + r.stdout);
138
255
  return fail(c.class === "unknown_failure" ? "install_failed" : c.class, r.stderr + r.stdout, c.explanation);
@@ -176,6 +293,7 @@ export async function up(repoPath, opts) {
176
293
  const healthExplanation = healthClass === "health_http_error"
177
294
  ? "The app responded on the configured health URL, but returned HTTP 5xx. BootProof observed a running server, but not a verified healthy boot."
178
295
  : c.explanation;
296
+ const preciseHealthExplanation = explanationWithMissingEnv(healthClass, `${healthFailureMessage}\n${evidence}`, healthExplanation);
179
297
  await app.stop();
180
298
  const att = buildAttestation({
181
299
  repo: inference.repoPath,
@@ -188,11 +306,55 @@ export async function up(repoPath, opts) {
188
306
  observedHealthCandidates: health.discoveredCandidates,
189
307
  failureClass: healthClass,
190
308
  failureEvidence: `${healthFailureMessage}\n${evidence}`.slice(-2000),
191
- explanation: healthExplanation,
309
+ explanation: preciseHealthExplanation,
192
310
  });
193
311
  writeAttestation(inference.repoPath, att);
194
312
  return { inference, plan, writtenFiles, attestation: att, refusal: null };
195
313
  }
314
+ if (planned.kind === "health" && runsSourceComposeApplication) {
315
+ const ht = new Date().toISOString();
316
+ const health = await pollHealthCandidates(plan.healthCandidates, opts.timeoutMs);
317
+ plan.healthCandidates = health.candidates;
318
+ if (health.url)
319
+ plan.healthUrl = health.url;
320
+ if (health.responded && health.status !== null && health.status < 500) {
321
+ const observedUrl = health.url ?? plan.healthUrl;
322
+ observed.push(step("health", "health", undefined, ht, null, true, `observed HTTP ${health.status} at ${observedUrl} after ${health.elapsedMs}ms (${health.attempts} attempts)`));
323
+ const att = buildAttestation({
324
+ repo: inference.repoPath,
325
+ plan,
326
+ observed,
327
+ startedAt,
328
+ booted: true,
329
+ healthVerified: true,
330
+ healthObservation: `HTTP ${health.status} at ${observedUrl}`,
331
+ observedHealthCandidates: health.discoveredCandidates,
332
+ failureClass: null,
333
+ failureEvidence: null,
334
+ explanation: `Verified: repository Compose started and ${observedUrl} responded HTTP ${health.status}. This proves the observed web boot, not every service or feature.`,
335
+ });
336
+ writeAttestation(inference.repoPath, att);
337
+ return { inference, plan, writtenFiles, attestation: att, refusal: null };
338
+ }
339
+ const healthFailureMessage = health.responded
340
+ ? `only HTTP ${health.status} observed at ${health.url ?? plan.healthUrl}`
341
+ : `no HTTP response at candidates ${health.candidates.join(", ")} within ${opts.timeoutMs}ms`;
342
+ observed.push(step("health", "health", undefined, ht, null, false, healthFailureMessage));
343
+ const diagnostics = await composeDiagnostics();
344
+ const evidence = [healthFailureMessage, diagnostics].filter(Boolean).join("\n");
345
+ const classified = classifyFailure(evidence);
346
+ const failureClass = health.responded && health.status !== null && health.status >= 500
347
+ ? "health_http_error"
348
+ : classified.class === "unknown_failure"
349
+ ? classifyHealthFailure(healthFailureMessage)
350
+ : classified.class;
351
+ const explanation = failureClass === "health_http_error"
352
+ ? "The Compose application responded on the configured health URL, but returned HTTP 5xx. BootProof observed a server, but not a verified healthy boot."
353
+ : failureClass === "health_check_timeout"
354
+ ? "Repository Compose accepted the start request, but no HTTP response was observed. Compose service state and logs are preserved in the attestation."
355
+ : classified.explanation;
356
+ return fail(failureClass, evidence, explanation);
357
+ }
196
358
  }
197
- return fail("not_an_application", "", "Plan contained no runnable app step.");
359
+ return fail("unknown_failure", "", "Inference identified an application, but the plan contained no supported runnable app or source-built Compose health step.");
198
360
  }
@@ -1,4 +1,5 @@
1
1
  import type { FailureClass } from "./types.js";
2
+ export declare function extractMissingEnvNames(evidence: string): string[];
2
3
  export declare function classifyFailure(evidence: string): {
3
4
  class: FailureClass;
4
5
  explanation: string;
package/dist/taxonomy.js CHANGED
@@ -12,6 +12,28 @@ function isServicePortAllocatedEvidence(evidence) {
12
12
  lower.includes("ports are not available") ||
13
13
  lower.includes("address already in use"));
14
14
  }
15
+ export function extractMissingEnvNames(evidence) {
16
+ const names = [];
17
+ const seen = new Set();
18
+ const patterns = [
19
+ /\b([A-Z][A-Z0-9_]{2,})\s+is\s+(?:not\s+set|required|missing|undefined)\b/g,
20
+ /\bMissing required secret:\s*([A-Z][A-Z0-9_]{2,})\b/g,
21
+ /^\s+([A-Z][A-Z0-9_]{2,}):\s*Required\b/gm,
22
+ /\bplease set\s+([A-Z][A-Z0-9_]{2,})\b/gi,
23
+ ];
24
+ for (const pattern of patterns) {
25
+ for (const match of evidence.matchAll(pattern)) {
26
+ const name = match[1].toUpperCase();
27
+ if (!seen.has(name)) {
28
+ seen.add(name);
29
+ names.push(name);
30
+ }
31
+ if (names.length === 10)
32
+ return names;
33
+ }
34
+ }
35
+ return names;
36
+ }
15
37
  const RULES = [
16
38
  { class: "package_manager_version_mismatch", pattern: /(ERR_PNPM_UNSUPPORTED_ENGINE|Unsupported environment \(bad (?:pnpm|yarn|npm|bun) and\/or Node\.js version\)|(?:pnpm|yarn|npm|bun)[\s\S]{0,160}Expected version:\s*[^\n]+[\s\S]{0,120}Got:\s*[^\n]+|packageManager field[\s\S]{0,120}(?:version|mismatch)|engines\.(?:pnpm|yarn|npm|bun))/i,
17
39
  explain: () => "The repository declares a package manager version that does not match the version available in the current environment. Enable Corepack or install the required package manager version before rerunning BootProof." },
@@ -19,6 +41,8 @@ const RULES = [
19
41
  explain: () => "The host Node version does not satisfy the project's engines requirement. Switch Node versions (nvm/fnm/corepack) and retry." },
20
42
  { class: "missing_package_manager", pattern: /\b(yarn|pnpm|bun): (command )?not found/i,
21
43
  explain: m => `The project needs ${m[1]} and it is not installed. Enable Corepack (corepack enable) or install ${m[1]} directly.` },
44
+ { class: "missing_runtime_tool", pattern: /(?:(?:^|\s)(go|ruby|bundle|make|python): (?:command )?not found\b|'(go|ruby|bundle|make|python)' is not recognized as an internal or external command|spawn (go|ruby|bundle|make|python) ENOENT)/im,
45
+ explain: m => `The repository's explicit run path requires ${m[1] ?? m[2] ?? m[3]}, but that executable is not available in this environment.` },
22
46
  { class: "private_registry_or_auth", pattern: /(401 Unauthorized|E401|ENEEDAUTH|authentication token not provided|Permission.*registry)/i,
23
47
  explain: () => "Dependency install needs credentials for a private registry. Bootproof will not invent credentials; provide real ones and retry." },
24
48
  { class: "native_build_dependency", pattern: /(node-gyp|gyp ERR|pg_config.*not found|fatal error: .*\.h|prebuild-install)/i,
@@ -28,10 +52,10 @@ const RULES = [
28
52
  { class: "postgres_auth_env_missing", pattern: /(SASL: SCRAM-SERVER-FIRST-MESSAGE|password authentication failed for user|client password must be a string)/i,
29
53
  explain: () => "Postgres was reached but authentication failed — the app's DATABASE_URL credentials don't match the running database. Inspect the repository's own env and compose examples, or rerun after generating BootProof service scaffolding; bootproof will not edit your .env." },
30
54
  { class: "database_unreachable", pattern: /(ECONNREFUSED.*:(5432|3306|6379|27017)|P1001|Can'?t reach database server|Connection refused.*postgres)/i,
31
- explain: () => "The app requires a database that is not reachable. Start the generated docker-compose.bootproof.yml services first." },
32
- { class: "migrations_missing", pattern: /(relation .* does not exist|no such table|Migration.*pending|P3009)/i,
55
+ explain: () => "The app requires a database that is not reachable. Start the repository's required database service and verify its configured address." },
56
+ { class: "migrations_missing", pattern: /(relation .* does not exist|no such table|Migration.*pending|unapplied migrations?|PendingMigrationError|P3009)/i,
33
57
  explain: () => "The database schema is missing or behind. Run the project's migration command against the local database." },
34
- { class: "missing_env_var", pattern: /((Missing|Please set|required) (env(ironment)? var(iable)?s?|.*[A-Z][A-Z0-9_]{3,})|Invalid environment variables)/,
58
+ { class: "missing_env_var", pattern: /([A-Z][A-Z0-9_]{2,}\s+is\s+(?:not\s+set|required|missing|undefined)|Missing required secret:\s*[A-Z][A-Z0-9_]{2,}|^\s+[A-Z][A-Z0-9_]{2,}:\s*Required\b|please set\s+[A-Z][A-Z0-9_]{2,}|Invalid environment variables)/im,
35
59
  explain: () => "The app refuses to start without specific environment variables. See .env.bootproof.example; secrets without safe defaults must come from you." },
36
60
  { class: "tls_or_proxy_interception", pattern: /(SELF_SIGNED_CERT_IN_CHAIN|UNABLE_TO_VERIFY_LEAF_SIGNATURE|unable to get local issuer certificate)/,
37
61
  explain: () => "A TLS-intercepting proxy or self-signed certificate chain is blocking package/tool downloads. Configure your proxy CA (NODE_EXTRA_CA_CERTS) or run outside the intercepting network." },
@@ -60,7 +84,7 @@ export function classifyFailure(evidence) {
60
84
  };
61
85
  }
62
86
  export const TAXONOMY_DOC_CLASSES = [
63
- "not_an_application", "runtime_engine_mismatch", "missing_package_manager", "package_manager_version_mismatch",
87
+ "not_an_application", "orchestration_not_supported", "runtime_engine_mismatch", "missing_package_manager", "missing_runtime_tool", "package_manager_version_mismatch",
64
88
  "dependency_install_skipped", "python_flask_setup_required", "missing_env_var",
65
89
  "database_unreachable", "postgres_auth_env_missing", "migrations_missing", "port_in_use", "native_build_dependency",
66
90
  "private_registry_or_auth", "tls_or_proxy_interception", "service_port_allocated", "docker_unavailable", "install_failed", "app_exited_early",
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export type PackageManager = "npm" | "pnpm" | "yarn" | "bun" | "unknown";
2
- export type FailureClass = "not_an_application" | "runtime_engine_mismatch" | "missing_package_manager" | "package_manager_version_mismatch" | "dependency_install_skipped" | "python_flask_setup_required" | "missing_env_var" | "database_unreachable" | "postgres_auth_env_missing" | "migrations_missing" | "port_in_use" | "native_build_dependency" | "private_registry_or_auth" | "tls_or_proxy_interception" | "service_port_allocated" | "docker_unavailable" | "install_failed" | "app_exited_early" | "health_check_timeout" | "health_http_error" | "workspace_ambiguous" | "unknown_failure";
2
+ export type FailureClass = "not_an_application" | "orchestration_not_supported" | "runtime_engine_mismatch" | "missing_package_manager" | "missing_runtime_tool" | "package_manager_version_mismatch" | "dependency_install_skipped" | "python_flask_setup_required" | "missing_env_var" | "database_unreachable" | "postgres_auth_env_missing" | "migrations_missing" | "port_in_use" | "native_build_dependency" | "private_registry_or_auth" | "tls_or_proxy_interception" | "service_port_allocated" | "docker_unavailable" | "install_failed" | "app_exited_early" | "health_check_timeout" | "health_http_error" | "workspace_ambiguous" | "unknown_failure";
3
3
  export interface ServiceNeed {
4
4
  kind: "postgres" | "mysql" | "redis" | "mongodb";
5
5
  evidence: string;
@@ -10,6 +10,18 @@ export interface WorkspaceCandidate {
10
10
  score: number;
11
11
  reason: string;
12
12
  }
13
+ export interface PreparationCommand {
14
+ id: string;
15
+ kind: "install" | "build";
16
+ command: string;
17
+ description: string;
18
+ source: string;
19
+ }
20
+ export interface ComposeApplicationService {
21
+ name: string;
22
+ source: "build" | "image";
23
+ healthCandidates: string[];
24
+ }
13
25
  export interface Inference {
14
26
  repoPath: string;
15
27
  isApplication: boolean;
@@ -18,11 +30,15 @@ export interface Inference {
18
30
  backendMarkers: string[];
19
31
  frontendMarkers: string[];
20
32
  serviceMarkers: string[];
33
+ repoComposeFile: string | null;
34
+ composeApplicationServices: ComposeApplicationService[];
35
+ composeHealthCandidates: string[];
21
36
  setupSteps: string[];
22
37
  packageManager: PackageManager;
23
38
  packageManagerEvidence: string;
24
39
  packageManagerVersion: string | null;
25
40
  installCommand: string | null;
41
+ preparationCommands: PreparationCommand[];
26
42
  dependencyInstallRequired: boolean;
27
43
  appCommand: string | null;
28
44
  appCommandSource: string;
@@ -50,7 +66,7 @@ export interface Inference {
50
66
  }
51
67
  export interface PlanStep {
52
68
  id: string;
53
- kind: "install" | "service" | "start-app" | "health";
69
+ kind: "install" | "build" | "service" | "start-app" | "health";
54
70
  command?: string;
55
71
  description: string;
56
72
  required: boolean;
package/docs/CI_ACTION.md CHANGED
@@ -26,10 +26,11 @@ jobs:
26
26
  contents: read
27
27
 
28
28
  steps:
29
- - uses: actions/checkout@v4
30
- - uses: actions/setup-node@v4
29
+ - uses: actions/checkout@v6
30
+ - uses: actions/setup-node@v6
31
31
  with:
32
32
  node-version: "22"
33
+ package-manager-cache: false
33
34
 
34
35
  - run: npm install --global bootproof
35
36
 
@@ -48,7 +49,7 @@ jobs:
48
49
 
49
50
  - name: Upload evidence
50
51
  if: always()
51
- uses: actions/upload-artifact@v4
52
+ uses: actions/upload-artifact@v7
52
53
  with:
53
54
  name: bootproof-evidence
54
55
  path: .bootproof/
@@ -14,15 +14,17 @@ Unknown evidence remains `unknown_failure`. BootProof does not pick a more marke
14
14
  | Class | Meaning | Safe next step |
15
15
  |---|---|---|
16
16
  | `not_an_application` | No trustworthy runnable entrypoint was found. | Select a runnable workspace or add an explicit start command. |
17
+ | `orchestration_not_supported` | An application stack was detected, but no explicit supported entrypoint or source-built Compose web service was found. | Use the repository's documented runbook; treat the signed result as diagnosis, not boot proof. |
17
18
  | `runtime_engine_mismatch` | Node.js does not satisfy the declared engine. | Switch to a compatible runtime and rerun. |
18
19
  | `missing_package_manager` | The declared package manager executable is absent. | Enable Corepack or install the required manager. |
20
+ | `missing_runtime_tool` | An explicit Go, Ruby, Bundler, or Make run path was selected, but the executable is absent. | Install the repository-supported runtime or tool and rerun. |
19
21
  | `package_manager_version_mismatch` | The available package-manager version differs from the exact/simple declared version. | Activate the declared version, then rerun. |
20
22
  | `dependency_install_skipped` | A dependency-backed application was not started because install was not requested. | Review the install command and opt in with `--install`. |
21
23
  | `python_flask_setup_required` | Python/Flask setup requires migrations, initialization, workers, frontend, or service orchestration not yet supported safely. | Complete the documented setup manually; do not treat detection as full support. |
22
24
  | `missing_env_var` | Required environment configuration is missing. | Supply real values through the repository's documented path. BootProof will not edit `.env`. |
23
25
  | `database_unreachable` | A required database or cache could not be reached. | Start the real required service and verify its address. |
24
26
  | `postgres_auth_env_missing` | Postgres was reached but authentication or env configuration did not match. | Correct the real database configuration; BootProof will not invent credentials. |
25
- | `migrations_missing` | The database schema is absent or behind. | Run the repository's documented migration flow. |
27
+ | `migrations_missing` | The database schema is absent or behind. | Run the repository's documented migration flow. Repair is attempted only for one unambiguous recognized framework. |
26
28
  | `port_in_use` | The application port is occupied. | Stop the process or use a supported explicit port. |
27
29
  | `native_build_dependency` | An OS toolchain or native dependency is missing. | Install the required build dependency and rerun. |
28
30
  | `private_registry_or_auth` | Package installation requires credentials. | Provide real registry credentials outside BootProof. |
@@ -16,6 +16,12 @@ BootProof's promise is not that every repository boots. Its promise is that the
16
16
  10. Failed attestations preserve available raw local evidence; exported registry entries redact it.
17
17
  11. A process starting is not enough. A health signal must be observed.
18
18
  12. Confidence describes evidence found, not predicted success.
19
+ 13. An image-only Compose service does not prove the checked-out source. Compose application proof requires a repository-local build context, a published HTTP port, and an observed HTTP response.
20
+ 14. A repair is only ever proposed with a verified before and after attestation.
21
+ 15. Repair generation never touches the user's working tree; applying a diff requires the separate explicit `apply-repair` command. This separation is intentional and is not bypassed by `fix`.
22
+ 16. Repair diffs are restricted to boot-plumbing scope; application logic is never edited.
23
+ 17. An unverified or failed remediation is reported as such and is never proposed on hope.
24
+ 18. Explicit repair application requires a valid signed receipt and exact file preimages; stale or tampered receipts write nothing.
19
25
 
20
26
  These behaviors are enforced by tests.
21
27
 
@@ -61,6 +67,25 @@ It does not prove:
61
67
  - every service in a platform is healthy
62
68
  - another machine will produce the same outcome
63
69
 
70
+ A repository Compose receipt is equally narrow. `docker compose up -d` exit 0 proves only that Compose accepted the request. BootProof issues `BOOTED` only after HTTP responds. If health fails, it records Compose service state and logs where available.
71
+
72
+ ## Verified Repairs
73
+
74
+ `bootproof fix` copies the repository to a temporary sandbox, applies one registered deterministic remediation, and reruns the full BootProof verification engine. It reuses an existing failed attestation only when its signature is valid and it names the exact current clean Git commit; otherwise the failure is reproduced in the sandbox first.
75
+
76
+ The original working tree is never used as the repair target. BootProof writes only its evidence under `.bootproof/`:
77
+
78
+ - the signed failed before attestation
79
+ - a human-reviewable patch when files changed
80
+ - the signed healthy after attestation
81
+ - the signed repair receipt linking both hashes
82
+
83
+ If the sandbox does not reach observed HTTP health, no repair receipt or patch is emitted. The failed attempt is appended to signed failure evidence.
84
+
85
+ `bootproof fix` never auto-applies a patch. `bootproof apply-repair` is a separate, explicit mutation command. It verifies the receipt signature, scope whitelist, signed content hashes, and current preimages before writing. A mismatch writes nothing.
86
+
87
+ See [REPAIR_RECEIPT.md](REPAIR_RECEIPT.md).
88
+
64
89
  ## Trust Levels
65
90
 
66
91
  Current local runs use:
@@ -79,7 +104,11 @@ BootProof itself does not upload telemetry or evidence.
79
104
 
80
105
  Commands chosen from a repository, such as package installation or application startup, may perform their own network activity. That behavior belongs to the command being executed and should be reviewed before using `--install` or unsafe local execution.
81
106
 
82
- Remote mode accepts only credential-free public HTTPS GitHub repository URLs. BootProof clones them into a retained `.bootproof/remotes/` workspace. Cloning does not authorize execution: a remote application command runs only with `--provider local --unsafe-local`.
107
+ Selecting `--provider docker` never authorizes host execution. If the inferred plan would require host-side install, build, migration, or application commands and no source-built repository Compose application can contain them, BootProof refuses with `orchestration_not_supported`.
108
+
109
+ Remote mode accepts only credential-free public HTTPS repository URLs from GitHub, GitLab, Bitbucket, and Codeberg. BootProof clones them into a retained `.bootproof/remotes/` workspace. Cloning does not authorize execution: a remote application command runs only with `--provider local --unsafe-local`.
110
+
111
+ The same rule applies to remote repair. Receipts and patches remain inside the retained clone. Applying a file repair requires naming that local clone with the separate `apply-repair` command.
83
112
 
84
113
  Remote `--dry-run` is refused before cloning. A clone writes files, while the dry-run contract promises that nothing is written.
85
114
 
@@ -70,3 +70,80 @@ Useful failure is part of the product:
70
70
  - `NOT VERIFIED` can still provide a precise diagnosis and signed evidence.
71
71
  - Detection is not the same as execution support.
72
72
  - Local proof remains `local_developer_signed`, not enterprise CI/OIDC proof.
73
+
74
+ ## June 11, 2026 Precision Receipts
75
+
76
+ These runs used fresh shallow clones and did not install dependencies.
77
+
78
+ ### Memos
79
+
80
+ Command:
81
+
82
+ ```text
83
+ node dist/cli.js up /tmp/memos --provider local --unsafe-local --timeout 10000
84
+ ```
85
+
86
+ Before (`bootproof@0.1.0`):
87
+
88
+ ```text
89
+ application: yes
90
+ stack: go-backend, react-frontend
91
+ health candidates: http://localhost:3000/
92
+ NOT VERIFIED — not_an_application
93
+ ```
94
+
95
+ After precision pass (`bootproof@0.2.0`):
96
+
97
+ ```text
98
+ application: yes
99
+ stack: go-backend, react-frontend
100
+ NOT VERIFIED — orchestration_not_supported
101
+ Detected go-backend (go.mod) with react-frontend (web/package.json).
102
+ Diagnosis only — no localhost claim.
103
+ ```
104
+
105
+ Observed result: exit code 1, signed `local_developer_signed` attestation, `booted: false`, `healthVerified: false`, empty observations, and no health candidates. This is diagnosis, not boot support.
106
+
107
+ After conservative Go orchestration (`bootproof@0.3.0`):
108
+
109
+ ```text
110
+ go-modules: dependency preparation completed (exit 0)
111
+ start-app: app process started and was supervised
112
+ health: observed HTTP 200 at http://localhost:49865/ after 13047ms
113
+ BOOTED — HTTP 200 at http://localhost:49865/ (observed, signed)
114
+ ```
115
+
116
+ Command:
117
+
118
+ ```text
119
+ node dist/cli.js up /tmp/memos-orchestration --provider local --unsafe-local --install --port 49865 --timeout 120000 --ci
120
+ ```
121
+
122
+ Observed result on June 11, 2026: exit code 0 and a signed `local_developer_signed` attestation. This proves that the checked-out Memos Go entrypoint served HTTP 200 with its embedded frontend assets. It does not prove every Memos feature, integration, or production configuration.
123
+
124
+ ### Formbricks
125
+
126
+ Command:
127
+
128
+ ```text
129
+ node dist/cli.js analyze /tmp/formbricks
130
+ ```
131
+
132
+ Before (`bootproof@0.1.0`):
133
+
134
+ ```text
135
+ services: postgres; redis
136
+ apps/storybook ranked above apps/web
137
+ no repository Compose file reported
138
+ ```
139
+
140
+ After (`bootproof@0.3.0`):
141
+
142
+ ```text
143
+ repo compose: docker-compose.dev.yml (bootproof defers to it)
144
+ compose HTTP services: mailhog (image only); rustfs (image only); hub (image only); cube (image only)
145
+ apps/web ranked above apps/storybook
146
+ apps/storybook: documentation/storybook downranked
147
+ ```
148
+
149
+ `composeFileFor()` returned null, and no `docker-compose.bootproof.yml` was generated. Analysis exited 0; no application boot was attempted or claimed. The selected Compose services use images rather than repository-local build contexts, so their health cannot prove the checked-out Formbricks source. The root `pnpm dev` command is also a parallel multi-workspace pipeline and remains ambiguous without a selected application.
@@ -71,4 +71,12 @@ BootProof publishes compiled JavaScript, not TypeScript source. `dist/` is requi
71
71
  npx bootproof up https://github.com/user/repository
72
72
  ```
73
73
 
74
- Remote URL mode accepts credential-free public HTTPS GitHub repositories. It retains clones under `.bootproof/remotes/` and requires `--provider local --unsafe-local` before executing remote repository code.
74
+ Remote URL mode accepts credential-free public HTTPS repositories from GitHub, GitLab, Bitbucket, and Codeberg. It retains clones under `.bootproof/remotes/` and requires `--provider local --unsafe-local` before executing remote repository code.
75
+
76
+ Remote repair follows the same rule:
77
+
78
+ ```bash
79
+ npx bootproof fix https://github.com/user/repository --provider local --unsafe-local
80
+ ```
81
+
82
+ `fix` never mutates the source tree. `apply-repair` is the separate explicit application step and refuses invalid signatures, disallowed paths, and stale preimages.