@b00dea/cockpit 0.0.0-bootstrap.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (363) hide show
  1. package/CHANGELOG.md +375 -0
  2. package/LICENSE +21 -0
  3. package/README.md +21 -1
  4. package/bin/cockpit.mjs +33 -0
  5. package/cockpit.schema.json +2574 -0
  6. package/dist/autonomy.d.ts +44 -0
  7. package/dist/autonomy.js +156 -0
  8. package/dist/bounded-read.d.ts +14 -0
  9. package/dist/bounded-read.js +42 -0
  10. package/dist/browser/commands.d.ts +62 -0
  11. package/dist/browser/commands.js +349 -0
  12. package/dist/browser/package-paths.d.ts +1 -0
  13. package/dist/browser/package-paths.js +6 -0
  14. package/dist/browser/proxy.d.ts +22 -0
  15. package/dist/browser/proxy.js +37 -0
  16. package/dist/browser/runtime.d.ts +117 -0
  17. package/dist/browser/runtime.js +183 -0
  18. package/dist/browser/surfaces.d.ts +141 -0
  19. package/dist/browser/surfaces.js +556 -0
  20. package/dist/browser/ui-gate.d.ts +15 -0
  21. package/dist/browser/ui-gate.js +18 -0
  22. package/dist/cli-guard.d.ts +5 -0
  23. package/dist/cli-guard.js +71 -0
  24. package/dist/cli-main.d.ts +139 -0
  25. package/dist/cli-main.js +1405 -0
  26. package/dist/cli.d.ts +3 -0
  27. package/dist/cli.js +16 -0
  28. package/dist/command-table.d.ts +113 -0
  29. package/dist/command-table.js +512 -0
  30. package/dist/config/load.d.ts +171 -0
  31. package/dist/config/load.js +345 -0
  32. package/dist/config/sibling-toml.d.ts +48 -0
  33. package/dist/config/sibling-toml.js +160 -0
  34. package/dist/config/surface-tools-toml.d.ts +9 -0
  35. package/dist/config/surface-tools-toml.js +37 -0
  36. package/dist/config/verified-write.d.ts +43 -0
  37. package/dist/config/verified-write.js +89 -0
  38. package/dist/diagnostics.d.ts +26 -0
  39. package/dist/diagnostics.js +335 -0
  40. package/dist/emit.d.ts +22 -0
  41. package/dist/emit.js +43 -0
  42. package/dist/entrypoint-kinds.d.ts +3 -0
  43. package/dist/entrypoint-kinds.js +11 -0
  44. package/dist/environment/application/destructive.d.ts +6 -0
  45. package/dist/environment/application/destructive.js +425 -0
  46. package/dist/environment/application/doctor.d.ts +89 -0
  47. package/dist/environment/application/doctor.js +235 -0
  48. package/dist/environment/application/guard.d.ts +43 -0
  49. package/dist/environment/application/guard.js +622 -0
  50. package/dist/environment/application/health.d.ts +134 -0
  51. package/dist/environment/application/health.js +379 -0
  52. package/dist/environment/application/lifecycle-context.d.ts +228 -0
  53. package/dist/environment/application/lifecycle-context.js +154 -0
  54. package/dist/environment/application/lifecycle-state.d.ts +25 -0
  55. package/dist/environment/application/lifecycle-state.js +201 -0
  56. package/dist/environment/application/lifecycle.d.ts +17 -0
  57. package/dist/environment/application/lifecycle.js +729 -0
  58. package/dist/environment/application/logs.d.ts +45 -0
  59. package/dist/environment/application/logs.js +127 -0
  60. package/dist/environment/application/setup.d.ts +129 -0
  61. package/dist/environment/application/setup.js +741 -0
  62. package/dist/environment/application/shared.d.ts +11 -0
  63. package/dist/environment/application/shared.js +61 -0
  64. package/dist/environment/claims.d.ts +389 -0
  65. package/dist/environment/claims.js +1709 -0
  66. package/dist/environment/codex-hook-trust.d.ts +11 -0
  67. package/dist/environment/codex-hook-trust.js +185 -0
  68. package/dist/environment/compose-environment.d.ts +1 -0
  69. package/dist/environment/compose-environment.js +6 -0
  70. package/dist/environment/compose-ownership.d.ts +21 -0
  71. package/dist/environment/compose-ownership.js +117 -0
  72. package/dist/environment/compose-topology.d.ts +117 -0
  73. package/dist/environment/compose-topology.js +413 -0
  74. package/dist/environment/contract.d.ts +489 -0
  75. package/dist/environment/contract.js +761 -0
  76. package/dist/environment/declaration.d.ts +14 -0
  77. package/dist/environment/declaration.js +39 -0
  78. package/dist/environment/destructive-composition.d.ts +29 -0
  79. package/dist/environment/destructive-composition.js +333 -0
  80. package/dist/environment/docker-daemon.d.ts +30 -0
  81. package/dist/environment/docker-daemon.js +78 -0
  82. package/dist/environment/docker-endpoint.d.ts +10 -0
  83. package/dist/environment/docker-endpoint.js +19 -0
  84. package/dist/environment/env-schema-observation.d.ts +46 -0
  85. package/dist/environment/env-schema-observation.js +241 -0
  86. package/dist/environment/evidence.d.ts +136 -0
  87. package/dist/environment/evidence.js +437 -0
  88. package/dist/environment/executable.d.ts +26 -0
  89. package/dist/environment/executable.js +167 -0
  90. package/dist/environment/generated-application/authorities.d.ts +5 -0
  91. package/dist/environment/generated-application/authorities.js +61 -0
  92. package/dist/environment/generated-application/bootstrap-skill.d.ts +16 -0
  93. package/dist/environment/generated-application/bootstrap-skill.js +328 -0
  94. package/dist/environment/generated-application/check.d.ts +13 -0
  95. package/dist/environment/generated-application/check.js +189 -0
  96. package/dist/environment/generated-application/custom-surface.d.ts +26 -0
  97. package/dist/environment/generated-application/custom-surface.js +289 -0
  98. package/dist/environment/generated-application/prepare.d.ts +29 -0
  99. package/dist/environment/generated-application/prepare.js +276 -0
  100. package/dist/environment/generated-application/publication.d.ts +33 -0
  101. package/dist/environment/generated-application/publication.js +547 -0
  102. package/dist/environment/generated-application/shared.d.ts +484 -0
  103. package/dist/environment/generated-application/shared.js +349 -0
  104. package/dist/environment/generated-application/surface-observation.d.ts +4 -0
  105. package/dist/environment/generated-application/surface-observation.js +69 -0
  106. package/dist/environment/generated-application/surface-outputs.d.ts +71 -0
  107. package/dist/environment/generated-application/surface-outputs.js +389 -0
  108. package/dist/environment/generated-application/surfaces.d.ts +3 -0
  109. package/dist/environment/generated-application/surfaces.js +699 -0
  110. package/dist/environment/generated-application/sync.d.ts +6 -0
  111. package/dist/environment/generated-application/sync.js +109 -0
  112. package/dist/environment/git-observation.d.ts +183 -0
  113. package/dist/environment/git-observation.js +544 -0
  114. package/dist/environment/health-composition.d.ts +11 -0
  115. package/dist/environment/health-composition.js +628 -0
  116. package/dist/environment/host-observation.d.ts +56 -0
  117. package/dist/environment/host-observation.js +52 -0
  118. package/dist/environment/identity.d.ts +48 -0
  119. package/dist/environment/identity.js +85 -0
  120. package/dist/environment/init-application.d.ts +308 -0
  121. package/dist/environment/init-application.js +697 -0
  122. package/dist/environment/install-policy-observation.d.ts +71 -0
  123. package/dist/environment/install-policy-observation.js +284 -0
  124. package/dist/environment/install-policy.d.ts +48 -0
  125. package/dist/environment/install-policy.js +96 -0
  126. package/dist/environment/instance.d.ts +33 -0
  127. package/dist/environment/instance.js +122 -0
  128. package/dist/environment/instruction-pointer.d.ts +19 -0
  129. package/dist/environment/instruction-pointer.js +144 -0
  130. package/dist/environment/lifecycle-composition.d.ts +29 -0
  131. package/dist/environment/lifecycle-composition.js +675 -0
  132. package/dist/environment/lifecycle-stage.d.ts +115 -0
  133. package/dist/environment/lifecycle-stage.js +398 -0
  134. package/dist/environment/lifecycle.d.ts +75 -0
  135. package/dist/environment/lifecycle.js +354 -0
  136. package/dist/environment/logs.d.ts +132 -0
  137. package/dist/environment/logs.js +519 -0
  138. package/dist/environment/observations.d.ts +46 -0
  139. package/dist/environment/observations.js +344 -0
  140. package/dist/environment/operator-application.d.ts +50 -0
  141. package/dist/environment/operator-application.js +155 -0
  142. package/dist/environment/ports.d.ts +182 -0
  143. package/dist/environment/ports.js +1205 -0
  144. package/dist/environment/posture-observation.d.ts +68 -0
  145. package/dist/environment/posture-observation.js +253 -0
  146. package/dist/environment/read-composition.d.ts +27 -0
  147. package/dist/environment/read-composition.js +352 -0
  148. package/dist/environment/readiness-composition.d.ts +38 -0
  149. package/dist/environment/readiness-composition.js +130 -0
  150. package/dist/environment/reclaim.d.ts +333 -0
  151. package/dist/environment/reclaim.js +1835 -0
  152. package/dist/environment/repository-identity.d.ts +13 -0
  153. package/dist/environment/repository-identity.js +108 -0
  154. package/dist/environment/runtime-measurement-record.d.ts +38 -0
  155. package/dist/environment/runtime-measurement-record.js +111 -0
  156. package/dist/environment/runtime-measurement.d.ts +27 -0
  157. package/dist/environment/runtime-measurement.js +224 -0
  158. package/dist/environment/runtime-probe-residue.d.ts +43 -0
  159. package/dist/environment/runtime-probe-residue.js +148 -0
  160. package/dist/environment/runtime-probe.d.ts +72 -0
  161. package/dist/environment/runtime-probe.js +725 -0
  162. package/dist/environment/runtime.d.ts +505 -0
  163. package/dist/environment/runtime.js +2335 -0
  164. package/dist/environment/seed.d.ts +16 -0
  165. package/dist/environment/seed.js +43 -0
  166. package/dist/environment/selftest-application.d.ts +86 -0
  167. package/dist/environment/selftest-application.js +713 -0
  168. package/dist/environment/sensitive.d.ts +3 -0
  169. package/dist/environment/sensitive.js +48 -0
  170. package/dist/environment/setup-observation.d.ts +62 -0
  171. package/dist/environment/setup-observation.js +1473 -0
  172. package/dist/environment/setup.d.ts +43 -0
  173. package/dist/environment/setup.js +176 -0
  174. package/dist/environment/sibling-instructions.d.ts +33 -0
  175. package/dist/environment/sibling-instructions.js +224 -0
  176. package/dist/environment/sibling-registration.d.ts +45 -0
  177. package/dist/environment/sibling-registration.js +538 -0
  178. package/dist/environment/state.d.ts +371 -0
  179. package/dist/environment/state.js +2013 -0
  180. package/dist/environment/templates.d.ts +140 -0
  181. package/dist/environment/templates.js +685 -0
  182. package/dist/environment/tool-registration.d.ts +9 -0
  183. package/dist/environment/tool-registration.js +96 -0
  184. package/dist/environment/upgrade-application.d.ts +19 -0
  185. package/dist/environment/upgrade-application.js +175 -0
  186. package/dist/environment-exec/execute.d.ts +102 -0
  187. package/dist/environment-exec/execute.js +1175 -0
  188. package/dist/environment-exec/libexec-program.d.ts +6 -0
  189. package/dist/environment-exec/libexec-program.js +22 -0
  190. package/dist/environment-exec/verified-digest.d.ts +4 -0
  191. package/dist/environment-exec/verified-digest.js +16 -0
  192. package/dist/exact-keys.d.ts +7 -0
  193. package/dist/exact-keys.js +15 -0
  194. package/dist/fix-catalogue.d.ts +32 -0
  195. package/dist/fix-catalogue.js +60 -0
  196. package/dist/freshness.d.ts +29 -0
  197. package/dist/freshness.js +75 -0
  198. package/dist/generated-environment/adopt.d.ts +16 -0
  199. package/dist/generated-environment/adopt.js +275 -0
  200. package/dist/generated-environment/command.d.ts +47 -0
  201. package/dist/generated-environment/command.js +75 -0
  202. package/dist/generated-environment/transaction.d.ts +186 -0
  203. package/dist/generated-environment/transaction.js +1770 -0
  204. package/dist/grading/evaluate.d.ts +71 -0
  205. package/dist/grading/evaluate.js +403 -0
  206. package/dist/grading/grade.d.ts +33 -0
  207. package/dist/grading/grade.js +57 -0
  208. package/dist/guards/evaluator.d.ts +113 -0
  209. package/dist/guards/evaluator.js +1226 -0
  210. package/dist/guards/harness-adapter.d.ts +17 -0
  211. package/dist/guards/harness-adapter.js +57 -0
  212. package/dist/guards/host.d.ts +8 -0
  213. package/dist/guards/host.js +10 -0
  214. package/dist/guards/invocation-parser.d.ts +66 -0
  215. package/dist/guards/invocation-parser.js +595 -0
  216. package/dist/guards/local-identity.d.ts +40 -0
  217. package/dist/guards/local-identity.js +123 -0
  218. package/dist/guards/policy-pin.d.ts +13 -0
  219. package/dist/guards/policy-pin.js +175 -0
  220. package/dist/guards/protected-locations.d.ts +62 -0
  221. package/dist/guards/protected-locations.js +461 -0
  222. package/dist/guards/read-only.d.ts +37 -0
  223. package/dist/guards/read-only.js +315 -0
  224. package/dist/init/command.d.ts +40 -0
  225. package/dist/init/command.js +310 -0
  226. package/dist/init/derive.d.ts +51 -0
  227. package/dist/init/derive.js +715 -0
  228. package/dist/init/draft.d.ts +138 -0
  229. package/dist/init/draft.js +1205 -0
  230. package/dist/init/render.d.ts +35 -0
  231. package/dist/init/render.js +82 -0
  232. package/dist/init/scaffold.d.ts +53 -0
  233. package/dist/init/scaffold.js +121 -0
  234. package/dist/invocation.d.ts +13 -0
  235. package/dist/invocation.js +20 -0
  236. package/dist/package-metadata.d.ts +9 -0
  237. package/dist/package-metadata.js +19 -0
  238. package/dist/readiness.d.ts +53 -0
  239. package/dist/readiness.js +88 -0
  240. package/dist/references/named.d.ts +24 -0
  241. package/dist/references/named.js +27 -0
  242. package/dist/refusal.d.ts +15 -0
  243. package/dist/refusal.js +7 -0
  244. package/dist/registry-derivation.d.ts +31 -0
  245. package/dist/registry-derivation.js +32 -0
  246. package/dist/registry-resolve/command.d.ts +47 -0
  247. package/dist/registry-resolve/command.js +226 -0
  248. package/dist/registry-resolve/registry.d.ts +68 -0
  249. package/dist/registry-resolve/registry.js +292 -0
  250. package/dist/registry-resolve/render-shell.d.ts +18 -0
  251. package/dist/registry-resolve/render-shell.js +36 -0
  252. package/dist/repair.d.ts +29 -0
  253. package/dist/repair.js +8 -0
  254. package/dist/schema-json.d.ts +1 -0
  255. package/dist/schema-json.js +177 -0
  256. package/dist/schema.d.ts +1322 -0
  257. package/dist/schema.js +934 -0
  258. package/dist/setup/command.d.ts +4 -0
  259. package/dist/setup/command.js +4 -0
  260. package/dist/sha256.d.ts +1 -0
  261. package/dist/sha256.js +4 -0
  262. package/dist/surfaces/adoption.d.ts +41 -0
  263. package/dist/surfaces/adoption.js +217 -0
  264. package/dist/surfaces/consumer-instructions.d.ts +20 -0
  265. package/dist/surfaces/consumer-instructions.js +43 -0
  266. package/dist/surfaces/contract.d.ts +42 -0
  267. package/dist/surfaces/contract.js +169 -0
  268. package/dist/surfaces/core-declarations.d.ts +24 -0
  269. package/dist/surfaces/core-declarations.js +79 -0
  270. package/dist/surfaces/development-markers.d.ts +1 -0
  271. package/dist/surfaces/development-markers.js +18 -0
  272. package/dist/surfaces/generator-adapter.d.ts +71 -0
  273. package/dist/surfaces/generator-adapter.js +809 -0
  274. package/dist/surfaces/harness-registry.d.ts +180 -0
  275. package/dist/surfaces/harness-registry.js +117 -0
  276. package/dist/surfaces/mcp.d.ts +95 -0
  277. package/dist/surfaces/mcp.js +186 -0
  278. package/dist/surfaces/merge.d.ts +30 -0
  279. package/dist/surfaces/merge.js +105 -0
  280. package/dist/surfaces/ownership.d.ts +190 -0
  281. package/dist/surfaces/ownership.js +795 -0
  282. package/dist/surfaces/projection.d.ts +136 -0
  283. package/dist/surfaces/projection.js +681 -0
  284. package/dist/surfaces/render.d.ts +118 -0
  285. package/dist/surfaces/render.js +633 -0
  286. package/dist/surfaces/restrictions.d.ts +130 -0
  287. package/dist/surfaces/restrictions.js +447 -0
  288. package/dist/surfaces/selftest.d.ts +162 -0
  289. package/dist/surfaces/selftest.js +393 -0
  290. package/dist/surfaces/skill-contract.d.ts +85 -0
  291. package/dist/surfaces/skill-contract.js +449 -0
  292. package/dist/surfaces/sync.d.ts +57 -0
  293. package/dist/surfaces/sync.js +164 -0
  294. package/dist/team-metadata.d.ts +41 -0
  295. package/dist/team-metadata.js +512 -0
  296. package/dist/testing/gates.d.ts +91 -0
  297. package/dist/testing/gates.js +182 -0
  298. package/dist/testing/hooks.d.ts +59 -0
  299. package/dist/testing/hooks.js +139 -0
  300. package/dist/testing/verdict.d.ts +29 -0
  301. package/dist/testing/verdict.js +62 -0
  302. package/dist/work-orders/application.d.ts +99 -0
  303. package/dist/work-orders/application.js +383 -0
  304. package/dist/work-orders/composition.d.ts +124 -0
  305. package/dist/work-orders/composition.js +1010 -0
  306. package/dist/work-orders/contract.d.ts +61 -0
  307. package/dist/work-orders/contract.js +122 -0
  308. package/dist/work-orders/store.d.ts +158 -0
  309. package/dist/work-orders/store.js +197 -0
  310. package/docs/README.md +65 -0
  311. package/docs/architecture.md +136 -0
  312. package/docs/capability-matrix.md +50 -0
  313. package/docs/cli-reference.md +599 -0
  314. package/docs/command-list.md +51 -0
  315. package/docs/compose-patterns.md +99 -0
  316. package/docs/concepts.md +171 -0
  317. package/docs/configuration-essentials.md +172 -0
  318. package/docs/configuration-reference.md +621 -0
  319. package/docs/environment.md +760 -0
  320. package/docs/extending.md +43 -0
  321. package/docs/faq.md +111 -0
  322. package/docs/getting-started.md +608 -0
  323. package/docs/harness-surfaces.md +433 -0
  324. package/docs/joining-a-team.md +76 -0
  325. package/docs/release-policy.md +49 -0
  326. package/docs/safety-and-autonomy.md +419 -0
  327. package/docs/troubleshooting.md +361 -0
  328. package/docs/tutorial.md +170 -0
  329. package/docs/work-and-browser.md +254 -0
  330. package/libexec/browser-operation.cjs +186 -0
  331. package/libexec/fence-marker.cjs +101 -0
  332. package/libexec/fence-marker.d.cts +24 -0
  333. package/libexec/fenced-group-runner.cjs +77 -0
  334. package/libexec/fenced-supervisor.cjs +137 -0
  335. package/libexec/generated-filesystem.cjs +731 -0
  336. package/libexec/install-filesystem.cjs +225 -0
  337. package/libexec/rulesync-generator.NOTICE.md +1112 -0
  338. package/libexec/rulesync-generator.cjs +743 -0
  339. package/libexec/surface-generator-worker.cjs +266 -0
  340. package/libexec/surface-output-reader.cjs +99 -0
  341. package/libexec/surface-scratch-cleanup.cjs +14 -0
  342. package/libexec/validate-harness-evidence.d.mts +183 -0
  343. package/libexec/validate-harness-evidence.mjs +1312 -0
  344. package/package.json +76 -1
  345. package/surfaces/README.md +33 -0
  346. package/surfaces/agents/evaluator.agent.md +76 -0
  347. package/surfaces/guards/bash-guard.sh +48 -0
  348. package/surfaces/guards/claude.adapter.json +25 -0
  349. package/surfaces/guards/codex.adapter.json +24 -0
  350. package/surfaces/guards/opencode.adapter.json +25 -0
  351. package/surfaces/references/browser.md +63 -0
  352. package/surfaces/references/test-data-and-personas.md +84 -0
  353. package/surfaces/references/testing-tiers.md +11 -0
  354. package/surfaces/skills/cockpit-browser.skill.md +54 -0
  355. package/surfaces/skills/cockpit-explain.skill.md +32 -0
  356. package/surfaces/skills/cockpit-fix.skill.md +37 -0
  357. package/surfaces/skills/cockpit-setup.skill.md +39 -0
  358. package/surfaces/skills/cockpit-work.skill.md +34 -0
  359. package/surfaces/skills/collection.json +5 -0
  360. package/surfaces/skills/procedures/explain.md +61 -0
  361. package/surfaces/skills/procedures/fix.md +81 -0
  362. package/surfaces/skills/procedures/setup.md +379 -0
  363. package/surfaces/skills/procedures/work.md +105 -0
@@ -0,0 +1,715 @@
1
+ // Proposes interview answers from files a repository already carries. It owns reading those files
2
+ // and attributing each proposed value to its source; it must not decide or write anything. Every
3
+ // fact is inferred: init surfaces it with its source and the operator confirms it, because a
4
+ // config that looks complete but holds guesses is the failure cockpit exists to prevent.
5
+ // Deliberately cheap and exact: Dockerfile, env template variable names, Compose files and CI
6
+ // service containers, test entry points and run scripts from build and package manifests, Node
7
+ // lockfiles, and each repository's origin remote from .git/config. It starts no process. Orchestrator
8
+ // manifests are not parsed; their shape is too varied to read reliably.
9
+ import { lstat, readdir, realpath } from 'node:fs/promises';
10
+ import { basename, isAbsolute, relative as pathRelative, resolve, sep } from 'node:path';
11
+ import { parse as parseToml } from 'smol-toml';
12
+ import { parse as parseYaml } from 'yaml';
13
+ import { readBoundedInput } from '../config/load.js';
14
+ import { errorCause, hasErrorCode } from '../environment/contract.js';
15
+ import { assessComposeTopology, bindHolder, composeBindPath, composeProjectDirectory, composePublishedPorts, splitComposeShort } from '../environment/compose-topology.js';
16
+ import { repositoryIdentity } from '../environment/repository-identity.js';
17
+ import { redactSensitiveText } from '../environment/sensitive.js';
18
+ import { formatShellCommand, initCommand } from '../fix-catalogue.js';
19
+ import { deriveSiblingVariable, TestingName } from '../schema.js';
20
+ const MAX_BYTES = 1024 * 1024;
21
+ const MAX_WORKFLOWS = 32;
22
+ export const COMPOSE_NAMES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
23
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
24
+ const SIBLING_SLUG = /^[a-z][a-z0-9-]*$/;
25
+ // .env.example, .env.staging.example, .env.sample, .env.production.template and the like: files
26
+ // meant to be committed, so their names are safe to read. Their values never are.
27
+ const ENV_TEMPLATE = /^\.env(?:\.[A-Za-z0-9_-]+)*\.(?:example|sample|template)$/;
28
+ const MAX_ENV_TEMPLATES = 16;
29
+ // Names only suggest; the human says which host of which non-local environment each points at.
30
+ const DATABASE_VARIABLE = /(?:^|_)(?:DATABASE|DB|POSTGRES(?:QL)?|PG|MYSQL|MARIADB|MONGO(?:DB)?|REDIS)(?:_[A-Z0-9]+)*_(?:URL|URI|DSN|HOST)$|^(?:DATABASE_URL|PGHOST|MONGO_URL|REDIS_URL)$/;
31
+ const VENDOR_VARIABLE = /_(?:API_)?(?:BASE|BASE_URL|URL|ENDPOINT|HOST)$/;
32
+ async function inspectDirectory(root, directory) {
33
+ const canonicalRoot = resolve(root);
34
+ const path = resolve(canonicalRoot, directory);
35
+ const confined = pathRelative(canonicalRoot, path);
36
+ if (canonicalRoot !== root || confined === '..' || confined.startsWith(`..${sep}`) || isAbsolute(confined)) {
37
+ return { kind: 'skipped', reason: 'escapes the canonical inference root' };
38
+ }
39
+ try {
40
+ const stat = await lstat(path);
41
+ if (!stat.isDirectory() || stat.isSymbolicLink())
42
+ return { kind: 'skipped', reason: 'is a redirected directory' };
43
+ const expected = resolve(await realpath(canonicalRoot), directory);
44
+ if (await realpath(path) !== expected)
45
+ return { kind: 'skipped', reason: 'is a redirected directory' };
46
+ return { kind: 'complete', path, dev: stat.dev, ino: stat.ino };
47
+ }
48
+ catch (error) {
49
+ if (hasErrorCode(error, 'ENOENT'))
50
+ return { kind: 'absent' };
51
+ return { kind: 'skipped', reason: `could not be inspected (${error.code ?? 'unknown I/O error'})` };
52
+ }
53
+ }
54
+ function sameDirectory(left, right) {
55
+ return left.kind === 'complete' && right.kind === 'complete'
56
+ && left.path === right.path && left.dev === right.dev && left.ino === right.ino;
57
+ }
58
+ async function readBounded(root, directory, name) {
59
+ const parent = await inspectDirectory(root, directory);
60
+ if (parent.kind !== 'complete')
61
+ return parent;
62
+ try {
63
+ const read = await readBoundedInput(resolve(parent.path, name));
64
+ if (read.kind === 'too-large' || read.bytes.byteLength > MAX_BYTES) {
65
+ return { kind: 'skipped', reason: `exceeds the ${MAX_BYTES}-byte inference limit` };
66
+ }
67
+ if (!sameDirectory(parent, await inspectDirectory(root, directory))) {
68
+ return { kind: 'skipped', reason: 'directory changed while the file was read' };
69
+ }
70
+ try {
71
+ return { kind: 'text', text: new TextDecoder('utf-8', { fatal: true }).decode(read.bytes) };
72
+ }
73
+ catch {
74
+ return { kind: 'skipped', reason: 'is not valid UTF-8' };
75
+ } // Fatal decoding throws only for invalid UTF-8, which the reason names.
76
+ }
77
+ catch (error) {
78
+ const code = error.code;
79
+ if (code === 'ENOENT')
80
+ return { kind: 'absent' };
81
+ if (code === 'ELOOP' || (error instanceof Error && error.message === 'structured input is not a regular file')) {
82
+ return { kind: 'skipped', reason: 'is not a regular file' };
83
+ }
84
+ return { kind: 'skipped', reason: `could not be read (${code ?? 'unknown I/O error'})` };
85
+ }
86
+ }
87
+ /** Presence only: a lockfile or build marker can exceed the read limit and is never parsed here. */
88
+ async function fileExists(root, directory, name) {
89
+ const parent = await inspectDirectory(root, directory);
90
+ if (parent.kind !== 'complete')
91
+ return parent.kind;
92
+ try {
93
+ return (await lstat(resolve(parent.path, name))).isFile() ? 'present' : 'skipped';
94
+ }
95
+ catch (error) {
96
+ return hasErrorCode(error, 'ENOENT') ? 'absent' : 'skipped';
97
+ }
98
+ }
99
+ function joinLogical(text) {
100
+ const lines = [];
101
+ let pending = '';
102
+ for (const raw of text.split(/\r?\n/)) {
103
+ const line = raw.trim();
104
+ if (pending === '' && (line === '' || line.startsWith('#')))
105
+ continue;
106
+ if (line.endsWith('\\')) {
107
+ pending += `${line.slice(0, -1)} `;
108
+ continue;
109
+ }
110
+ lines.push((pending + line).trim());
111
+ pending = '';
112
+ }
113
+ if (pending.trim() !== '')
114
+ lines.push(pending.trim());
115
+ return lines;
116
+ }
117
+ function instructionArgv(argument) {
118
+ if (argument === '')
119
+ return undefined;
120
+ // Docker reads an instruction that is not a valid JSON array of strings as shell form.
121
+ const shellForm = ['sh', '-c', argument];
122
+ if (!argument.startsWith('['))
123
+ return shellForm;
124
+ try {
125
+ const parsed = JSON.parse(argument);
126
+ return Array.isArray(parsed) && parsed.length > 0 && parsed.every(part => typeof part === 'string') ? parsed : shellForm;
127
+ }
128
+ catch {
129
+ return shellForm;
130
+ } // Docker itself reads an instruction that is not a JSON array as shell form.
131
+ }
132
+ export function readDockerfileFacts(text) {
133
+ // Only the final build stage describes the image that runs.
134
+ let ports = [];
135
+ let entrypoint;
136
+ let cmd;
137
+ for (const line of joinLogical(text)) {
138
+ const [keyword = '', ...rest] = line.split(/\s+/);
139
+ const argument = line.slice(keyword.length).trim();
140
+ switch (keyword.toUpperCase()) {
141
+ case 'FROM':
142
+ ports = [];
143
+ entrypoint = undefined;
144
+ cmd = undefined;
145
+ break;
146
+ case 'EXPOSE':
147
+ for (const token of rest) {
148
+ const match = /^(\d{1,5})(?:\/(?:tcp|udp))?$/i.exec(token);
149
+ if (match && Number(match[1]) >= 1 && Number(match[1]) <= 65535)
150
+ ports.push(Number(match[1]));
151
+ }
152
+ break;
153
+ case 'ENTRYPOINT':
154
+ entrypoint = instructionArgv(argument);
155
+ break;
156
+ case 'CMD':
157
+ cmd = instructionArgv(argument);
158
+ break;
159
+ }
160
+ }
161
+ // A shell-form ENTRYPOINT ignores CMD; an exec-form one receives CMD as arguments.
162
+ const shellEntrypoint = entrypoint?.[0] === 'sh' && entrypoint[1] === '-c' && entrypoint.length === 3;
163
+ const command = entrypoint === undefined ? cmd : shellEntrypoint ? entrypoint : [...entrypoint, ...(cmd ?? [])];
164
+ return { ...(command === undefined ? {} : { command }), ports: [...new Set(ports)] };
165
+ }
166
+ function envNames(text) {
167
+ const names = new Set();
168
+ for (const line of text.split(/\r?\n/)) {
169
+ const match = /^\s*(?:export\s+)?([^=\s#]+)\s*=/.exec(line);
170
+ if (match && ENV_NAME.test(match[1]))
171
+ names.add(match[1]);
172
+ }
173
+ return [...names].sort();
174
+ }
175
+ function workflowServices(document) {
176
+ const jobs = document?.jobs;
177
+ if (!jobs || typeof jobs !== 'object' || Array.isArray(jobs))
178
+ return [];
179
+ const found = [];
180
+ for (const [job, definition] of Object.entries(jobs)) {
181
+ const services = definition?.services;
182
+ if (!services || typeof services !== 'object' || Array.isArray(services))
183
+ continue;
184
+ for (const [name, service] of Object.entries(services)) {
185
+ const image = service?.image;
186
+ if (typeof image === 'string' && image.trim() !== '')
187
+ found.push({ job, name, image: image.trim() });
188
+ }
189
+ }
190
+ return found;
191
+ }
192
+ // Test entry points, in the order they take precedence within one repository. A build tool that
193
+ // selects what a change affects wins over a plain runner, so the targeted gate can rely on it.
194
+ const TEST_ENTRY_LOOKED_FOR = 'MODULE.bazel or WORKSPACE, nx.json, turbo.json with a test task, a Makefile test target, a package.json test script, pytest configuration in pyproject.toml or pytest.ini, go.mod and Cargo.toml';
195
+ const NODE_LOCKFILES = [['pnpm-lock.yaml', 'pnpm'], ['yarn.lock', 'yarn'], ['bun.lock', 'bun'], ['bun.lockb', 'bun'], ['package-lock.json', 'npm']];
196
+ /** The lockfiles a toolchain is proposed from, all of them Node's: the only ones this module maps to a schema toolchain. */
197
+ export const NODE_LOCKFILE_NAMES = NODE_LOCKFILES.map(([name]) => name);
198
+ const PACKAGE_RUN_SCRIPTS = ['start', 'serve', 'dev'];
199
+ async function nodeLockfile(present) {
200
+ for (const [lockfile, manager] of NODE_LOCKFILES)
201
+ if (await present(lockfile))
202
+ return { manager, lockfile };
203
+ return undefined;
204
+ }
205
+ const managerReason = (node) => node ? `${node.lockfile} selects ${node.manager}` : 'no lockfile names a package manager, so npm runs it';
206
+ // `bun test` runs Bun's own runner instead of the script, so Bun runs the script by name.
207
+ const NODE_TEST = { npm: ['npm', 'test'], pnpm: ['pnpm', 'test'], yarn: ['yarn', 'test'], bun: ['bun', 'run', 'test'] };
208
+ // --no keeps npx from downloading a tool the repository does not install.
209
+ const NODE_EXEC = { npm: ['npx', '--no'], pnpm: ['pnpm', 'exec'], yarn: ['yarn'], bun: ['bun', 'x'] };
210
+ // `npm init` writes this placeholder; proposing it would declare a gate that always fails.
211
+ const NPM_PLACEHOLDER_TEST = /no test specified/;
212
+ /** A stage command runs from the cockpit root, so a repository elsewhere is entered first. */
213
+ function stageCommand(directory, argv) {
214
+ return directory === '.' ? argv : ['sh', '-c', `cd ${formatShellCommand([directory])} && exec ${formatShellCommand(argv)}`];
215
+ }
216
+ function jsonObject(text, onInvalid) {
217
+ if (text === undefined)
218
+ return undefined;
219
+ try {
220
+ const parsed = JSON.parse(text);
221
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined;
222
+ }
223
+ catch (error) {
224
+ onInvalid(errorCause(error));
225
+ return undefined;
226
+ }
227
+ }
228
+ const field = (value, key) => value && typeof value === 'object' && !Array.isArray(value) ? value[key] : undefined;
229
+ const PORT_VARIABLE = /^\$\{([A-Z][A-Z0-9_]*)(?:(:?-)(\d{1,5}))?\}$|^\$([A-Z][A-Z0-9_]*)$/;
230
+ function entries(value) {
231
+ return Array.isArray(value) ? value : [];
232
+ }
233
+ /**
234
+ * Reads the bind sources and published host-port variables of the Compose files init proposes.
235
+ * Bind sources resolve as `up` resolves them: against the first file's directory, with each
236
+ * proposed sibling variable set to its directory and any other variable to its own default.
237
+ */
238
+ function composeFacts(files, root, variables, skipped) {
239
+ const binds = [];
240
+ // Each base keeps every file that publishes it, so the proposal names where each value came from.
241
+ const bases = new Map();
242
+ const conflicting = new Set();
243
+ for (const { source, document } of files) {
244
+ const services = document?.services;
245
+ if (!services || typeof services !== 'object' || Array.isArray(services))
246
+ continue;
247
+ for (const service of Object.values(services)) {
248
+ if (!service || typeof service !== 'object')
249
+ continue;
250
+ for (const volume of entries(service.volumes)) {
251
+ const segments = typeof volume === 'string' ? splitComposeShort(volume) : undefined;
252
+ const raw = segments && segments.length >= 2 ? segments[0]
253
+ : volume && typeof volume === 'object' && volume.type === 'bind'
254
+ ? volume.source : undefined;
255
+ const path = typeof raw === 'string' ? composeBindPath(raw, composeProjectDirectory(root, files.map(file => file.source)), variables) : undefined;
256
+ if (path !== undefined && path !== 'volume')
257
+ binds.push({ source, path });
258
+ }
259
+ for (const port of entries(service.ports)) {
260
+ const segments = typeof port === 'string' ? splitComposeShort(port) : undefined;
261
+ const published = segments && segments.length >= 2 ? segments[segments.length - 2]
262
+ : port && typeof port === 'object' ? port.published : undefined;
263
+ // A port with no host side is published on an ephemeral host port, which never collides.
264
+ if (published === undefined || published === '')
265
+ continue;
266
+ const match = typeof published === 'string' ? PORT_VARIABLE.exec(published) : null;
267
+ if (!match) {
268
+ skipped.push({ source, reason: `publishes host port ${String(published)} that is not read from a variable with a default, so every stack would bind the same host port and check refuses it; publish it as \${NAME:-<port>} and declare NAME under [ports.bases]` });
269
+ continue;
270
+ }
271
+ const name = (match[1] ?? match[4]);
272
+ const base = match[3] === undefined ? undefined : Number(match[3]);
273
+ if (base === undefined || base < 1 || base > 65_535) {
274
+ skipped.push({ source, reason: `publishes a host port from \${${name}} with no default, so its base port cannot be read; declare ${name} under [ports.bases]` });
275
+ continue;
276
+ }
277
+ const known = bases.get(name);
278
+ if (known && known.base !== base)
279
+ conflicting.add(name);
280
+ if (!known)
281
+ bases.set(name, { base, sources: [source] });
282
+ else if (!known.sources.includes(source))
283
+ known.sources.push(source);
284
+ }
285
+ }
286
+ }
287
+ for (const name of conflicting) {
288
+ skipped.push({ source: bases.get(name).sources.join(', '), reason: `publishes \${${name}} with different defaults, so its base port cannot be read; declare ${name} under [ports.bases]` });
289
+ bases.delete(name);
290
+ }
291
+ return { binds, bases };
292
+ }
293
+ async function detectTestEntry(read, readJson, present, node) {
294
+ for (const name of ['MODULE.bazel', 'WORKSPACE.bazel', 'WORKSPACE']) {
295
+ if (await present(name))
296
+ return { file: name, tool: 'Bazel', full: ['bazel', 'test', '//...'],
297
+ reason: `${name} makes this a Bazel workspace; Bazel reports cached results for tests whose inputs did not change, so the targeted gate runs the same command` };
298
+ }
299
+ const manager = node?.manager ?? 'npm';
300
+ const exec = NODE_EXEC[manager];
301
+ if (await present('nx.json'))
302
+ return { file: 'nx.json', tool: 'Nx', affected: [...exec, 'nx', 'affected', '-t', 'test'], full: [...exec, 'nx', 'run-many', '-t', 'test'],
303
+ reason: `nx.json makes this an Nx workspace; nx affected compares its defaultBase (main when unset) with the working tree; ${managerReason(node)}` };
304
+ const turbo = await readJson('turbo.json');
305
+ if (turbo && (field(field(turbo, 'tasks'), 'test') !== undefined || field(field(turbo, 'pipeline'), 'test') !== undefined)) {
306
+ return { file: 'turbo.json', tool: 'Turborepo', affected: [...exec, 'turbo', 'run', 'test', '--affected'], full: [...exec, 'turbo', 'run', 'test'],
307
+ reason: `turbo.json declares a test task; --affected compares main...HEAD, so it sees committed changes only; ${managerReason(node)}` };
308
+ }
309
+ const makefile = await read('Makefile');
310
+ if (makefile !== undefined && /^test\s*:(?!=)/m.test(makefile))
311
+ return { file: 'Makefile', tool: 'make', full: ['make', 'test'],
312
+ reason: 'the Makefile has a test target and nothing on disk selects a subset, so the targeted gate runs the same command' };
313
+ const scripts = field(await readJson('package.json'), 'scripts');
314
+ const test = field(scripts, 'test');
315
+ if (typeof test === 'string' && test.trim() !== '' && !NPM_PLACEHOLDER_TEST.test(test)) {
316
+ const others = Object.keys(scripts).filter(name => name.startsWith('test:')).sort();
317
+ return { file: 'package.json', tool: manager, full: NODE_TEST[manager],
318
+ reason: `package.json has a test script; ${managerReason(node)}; nothing on disk selects a subset, so the targeted gate runs the same command${others.length
319
+ ? `; ${others.join(', ')} are not proposed, so declare any that test does not already run` : ''}` };
320
+ }
321
+ const pyproject = await read('pyproject.toml');
322
+ const pytestFile = pyproject !== undefined && /^\[tool\.pytest\.ini_options\]/m.test(pyproject) ? 'pyproject.toml'
323
+ : await present('pytest.ini') ? 'pytest.ini' : undefined;
324
+ if (pytestFile) {
325
+ const uv = await present('uv.lock');
326
+ return { file: pytestFile, tool: 'pytest', full: uv ? ['uv', 'run', 'pytest'] : ['python3', '-m', 'pytest'],
327
+ reason: `${pytestFile} configures pytest${uv ? ' and uv.lock selects uv' : ''}; nothing on disk selects a subset, so the targeted gate runs the same command` };
328
+ }
329
+ if (await present('go.mod'))
330
+ return { file: 'go.mod', tool: 'go', full: ['go', 'test', './...'],
331
+ reason: 'go.mod makes this a Go module; go test caches results for unchanged packages, so the targeted gate runs the same command' };
332
+ if (await present('Cargo.toml'))
333
+ return { file: 'Cargo.toml', tool: 'cargo', full: ['cargo', 'test'],
334
+ reason: 'Cargo.toml makes this a Cargo package; nothing on disk selects a subset, so the targeted gate runs the same command' };
335
+ return undefined;
336
+ }
337
+ /**
338
+ * The first url of [remote "origin"]: what git clones from. include.path files and insteadOf rewrites
339
+ * are not followed, so a proposal depends on this one file only.
340
+ */
341
+ function originUrl(text) {
342
+ let origin = false;
343
+ for (const raw of text.split(/\r?\n/)) {
344
+ const line = raw.trim();
345
+ if (line.startsWith('[')) {
346
+ origin = /^\[\s*remote\s+"origin"\s*\]$/i.test(line);
347
+ continue;
348
+ }
349
+ const match = origin ? /^url\s*=\s*(.*)$/i.exec(line) : null;
350
+ if (!match)
351
+ continue;
352
+ const value = match[1];
353
+ const url = value.startsWith('"') ? /^"([^"\\]*)"/.exec(value)?.[1] : value.split(/[#;]/, 1)[0].trim();
354
+ return url ? url : undefined;
355
+ }
356
+ return undefined;
357
+ }
358
+ /** A password, or any user information in an http(s) URL, which is where access tokens are put. */
359
+ function carriesCredentials(url) {
360
+ if (redactSensitiveText(url) !== url)
361
+ return true;
362
+ const authority = /^([a-z][a-z0-9+.-]*):\/\/([^/?#]*)/i.exec(url);
363
+ if (authority) {
364
+ const at = authority[2].lastIndexOf('@');
365
+ return at >= 0 && (authority[2].slice(0, at).includes(':') || /^https?$/i.test(authority[1]));
366
+ }
367
+ const at = url.indexOf('@');
368
+ return at >= 0 && url.slice(0, at).includes(':');
369
+ }
370
+ // The role leads, so an id starts with a letter even where a sibling slug starts with a digit.
371
+ const TESTING_ROLES = ['test', 'affected', 'targeted', 'full'];
372
+ const testingId = (role, slug) => `${role}-${slug}`;
373
+ /** The [testing] proposal for one repository: a full gate, and a targeted gate on the tool's affected run when it has one. */
374
+ function testingProposal(slug, entry, directory) {
375
+ const full = { id: testingId('test', slug), command: stageCommand(directory, entry.full), requires_stack: false };
376
+ const affected = entry.affected ? { id: testingId('affected', slug), command: stageCommand(directory, entry.affected), requires_stack: false } : undefined;
377
+ return {
378
+ stages: affected ? [affected, full] : [full],
379
+ gates: [
380
+ { id: testingId('targeted', slug), tier: 'targeted', stages: [(affected ?? full).id],
381
+ changes: affected ? `Isolated behavior in ${slug}; ${entry.tool} selects the affected tests` : `Isolated behavior in ${slug}` },
382
+ { id: testingId('full', slug), tier: 'full', stages: [full.id], changes: `Shared or cross-system behavior in ${slug}` },
383
+ ],
384
+ };
385
+ }
386
+ export async function deriveInferredFacts(input) {
387
+ const facts = [];
388
+ const skipped = [];
389
+ const configurationRoot = input.configurationRoot ?? input.root;
390
+ // A repository at "." is the cockpit root itself (an in-repo cockpit), so the root is read once, as that repository.
391
+ const ownRoot = configurationRoot === input.root ? input.repositories.find(repository => repository.path === '.') : undefined;
392
+ const configurationDirectory = pathRelative(input.root, configurationRoot);
393
+ const directories = [{ slug: ownRoot?.slug, path: configurationDirectory },
394
+ ...input.repositories.filter(repository => repository !== ownRoot)
395
+ .map(repository => ({ slug: repository.slug, path: repository.path }))];
396
+ const relative = (directory, name = '') => {
397
+ const source = pathRelative(configurationRoot, resolve(input.root, directory, name));
398
+ return (source === '' ? '.' : source).split(sep).join('/');
399
+ };
400
+ const authorityRelative = (directory, name) => pathRelative(input.root, resolve(input.root, directory, name)) || '.';
401
+ const read = async (directory, name) => {
402
+ const source = relative(directory, name);
403
+ const result = await readBounded(input.root, directory, name);
404
+ if (result.kind === 'skipped')
405
+ skipped.push({ source, reason: result.reason });
406
+ return result.kind === 'text' ? result.text : undefined;
407
+ };
408
+ const parseYamlText = (source, text) => {
409
+ try {
410
+ return parseYaml(text);
411
+ }
412
+ catch (error) {
413
+ skipped.push({ source, reason: `is not valid YAML (${error instanceof Error ? error.name : 'parse error'})` });
414
+ return undefined;
415
+ }
416
+ };
417
+ const composeFiles = [];
418
+ const seenServices = new Set();
419
+ const readOrigin = async (directory) => {
420
+ const marker = relative(directory, '.git');
421
+ const state = await fileExists(input.root, directory, '.git');
422
+ if (state === 'absent')
423
+ return { kind: 'none', reason: `${relative(directory)} has no .git directory to read an origin remote from` };
424
+ if (state === 'present') {
425
+ const reason = `${marker} is a file (a worktree or submodule pointer), so no origin remote was read from it`;
426
+ skipped.push({ source: marker, reason });
427
+ return { kind: 'none', reason };
428
+ }
429
+ const gitDirectory = authorityRelative(directory, '.git');
430
+ const source = relative(gitDirectory, 'config');
431
+ const text = await read(gitDirectory, 'config');
432
+ if (text === undefined)
433
+ return { kind: 'none', reason: `${source} could not be read for an origin remote` };
434
+ const url = originUrl(text);
435
+ if (url === undefined)
436
+ return { kind: 'none', reason: `${source} names no origin remote` };
437
+ if (carriesCredentials(url)) {
438
+ const reason = `the origin URL in ${source} carries credentials, so it is neither proposed nor shown; record the remote without them`;
439
+ skipped.push({ source, reason });
440
+ return { kind: 'none', reason };
441
+ }
442
+ // Preflight compares a sibling's upstream with its declared remote by repository identity,
443
+ // which only https and ssh remotes have; a local path also names this machine's disk, which a
444
+ // teammate's clone of the cockpit cannot reach. Proposing one made every preflight refuse.
445
+ if (repositoryIdentity(url).kind !== 'known')
446
+ return { kind: 'none',
447
+ reason: `the origin remote in ${source} (${url}) is not an https or ssh repository URL, so a teammate's setup cannot clone it and preflight cannot compare the checkout with it; record its hosted remote once it has one` };
448
+ return { kind: 'url', url, source };
449
+ };
450
+ for (const directory of directories) {
451
+ const slug = directory.slug;
452
+ if (slug !== undefined) {
453
+ // Each file is read once per repository, so an unreadable one is reported once.
454
+ const reads = new Map();
455
+ const readHere = (name) => reads.get(name) ?? reads.set(name, read(directory.path, name)).get(name);
456
+ const parsedJson = new Map();
457
+ const readJsonHere = (name) => parsedJson.get(name) ?? parsedJson.set(name, readHere(name).then(text => jsonObject(text, cause => skipped.push({ source: relative(directory.path, name), reason: `is not valid JSON (${cause}), so nothing is inferred from it` })))).get(name);
458
+ const presence = new Map();
459
+ const present = (name) => presence.get(name) ?? presence.set(name, fileExists(input.root, directory.path, name).then(state => {
460
+ if (state === 'skipped')
461
+ skipped.push({ source: relative(directory.path, name), reason: 'is not a readable regular file' });
462
+ return state === 'present';
463
+ })).get(name);
464
+ // An unreadable repository directory is already reported by the reads below.
465
+ const readable = (await inspectDirectory(input.root, directory.path)).kind === 'complete';
466
+ const node = readable ? await nodeLockfile(present) : undefined;
467
+ const entry = readable ? await detectTestEntry(readHere, readJsonHere, present, node) : undefined;
468
+ const invalidId = entry === undefined ? undefined : TESTING_ROLES.map(role => testingId(role, slug)).find(id => !TestingName.safeParse(id).success);
469
+ if (entry && invalidId)
470
+ skipped.push({ source: relative(directory.path, entry.file),
471
+ reason: `${entry.file} has a test entry point, but the [testing] id ${invalidId} formed from the slug ${slug} is not lowercase letters, digits and hyphens of at most 64 characters starting with a letter, so no gate is proposed for ${slug}; declare its stages and gates under [testing] with ids of that form` });
472
+ else if (entry)
473
+ facts.push({ fields: ['testing'], target: `testing.${slug}`, value: testingProposal(slug, entry, relative(directory.path)),
474
+ provenance: { kind: 'inferred', source: relative(directory.path, entry.file),
475
+ reason: `${entry.reason}; requires_stack is false because nothing on disk says these tests need the running stack` } });
476
+ else if (readable)
477
+ skipped.push({ source: relative(directory.path),
478
+ reason: `no test entry point was found, so no [testing] gate is proposed for ${slug}; looked for ${TEST_ENTRY_LOOKED_FOR}` });
479
+ // setup resolves a non-none toolchain to exactly one install policy and refuses without
480
+ // one, so the toolchain is proposed together with the policy row the interview must hold.
481
+ if (node)
482
+ facts.push({ fields: ['sibling', 'environment.install_policies'], target: `${slug}.toolchain`, value: 'node',
483
+ provenance: { kind: 'inferred', source: relative(directory.path, node.lockfile),
484
+ reason: `${node.lockfile} is ${node.manager}'s lockfile, so the node toolchain is proposed; setup then needs exactly one environment.install_policies row with toolchain "node" and lockfile "${node.lockfile}" and refuses INSTALL_POLICY_MISSING without it, or keep toolchain "none", as a cockpit with no runtime environment must, since it is never asked for install policies` } });
485
+ const dockerfile = await readHere('Dockerfile');
486
+ const parsed = dockerfile === undefined ? undefined : readDockerfileFacts(dockerfile);
487
+ if (parsed) {
488
+ const source = relative(directory.path, 'Dockerfile');
489
+ if (parsed.command)
490
+ facts.push({ fields: ['estate.runnable', 'environment.compose_files'], target: `${slug}.command`,
491
+ value: parsed.command, provenance: { kind: 'inferred', source,
492
+ reason: 'the final stage of the Dockerfile sets this ENTRYPOINT/CMD' } });
493
+ if (parsed.ports.length > 0)
494
+ facts.push({ fields: ['environment.compose_files', 'ports.bases'], target: `${slug}.container_ports`,
495
+ value: parsed.ports, provenance: { kind: 'inferred', source,
496
+ reason: 'the final stage of the Dockerfile EXPOSEs these container ports' } });
497
+ }
498
+ // The image's own command wins: a package script is what runs when no Dockerfile says.
499
+ const scripts = parsed?.command ? undefined : field(await readJsonHere('package.json'), 'scripts');
500
+ const runScripts = PACKAGE_RUN_SCRIPTS.filter(name => { const value = field(scripts, name); return typeof value === 'string' && value.trim() !== ''; });
501
+ if (runScripts.length > 0) {
502
+ const [script, ...others] = runScripts;
503
+ facts.push({ fields: ['estate.runnable', 'environment.compose_files'], target: `${slug}.command`,
504
+ value: [node?.manager ?? 'npm', 'run', script], provenance: { kind: 'inferred', source: relative(directory.path, 'package.json'),
505
+ reason: `package.json has a ${script} script and no Dockerfile in this repository sets ENTRYPOINT/CMD; ${managerReason(node)}${others.length
506
+ ? `; ${others.join(' and ')} ${others.length === 1 ? 'is' : 'are'} also declared, so replace the command if the stack should run that instead` : ''}` } });
507
+ }
508
+ }
509
+ let templates = [];
510
+ const templateDirectory = await inspectDirectory(input.root, directory.path);
511
+ if (templateDirectory.kind === 'complete') {
512
+ try {
513
+ templates = (await readdir(templateDirectory.path)).filter(name => ENV_TEMPLATE.test(name)).sort();
514
+ if (!sameDirectory(templateDirectory, await inspectDirectory(input.root, directory.path))) {
515
+ templates = [];
516
+ skipped.push({ source: relative(directory.path), reason: 'directory changed while it was listed' });
517
+ }
518
+ }
519
+ catch (error) {
520
+ skipped.push({ source: relative(directory.path),
521
+ reason: `could not be listed (${error.code ?? 'unknown I/O error'})` });
522
+ }
523
+ }
524
+ else if (templateDirectory.kind === 'skipped') {
525
+ skipped.push({ source: relative(directory.path), reason: templateDirectory.reason });
526
+ }
527
+ if (templates.length > MAX_ENV_TEMPLATES) {
528
+ skipped.push({ source: relative(directory.path),
529
+ reason: `holds more than ${MAX_ENV_TEMPLATES} env template files; only the first ${MAX_ENV_TEMPLATES} were read` });
530
+ templates = templates.slice(0, MAX_ENV_TEMPLATES);
531
+ }
532
+ for (const template of templates) {
533
+ const text = await read(directory.path, template);
534
+ if (text === undefined)
535
+ continue;
536
+ const source = relative(directory.path, template);
537
+ const names = envNames(text);
538
+ if (slug !== undefined && template === '.env.example' && names.length > 0)
539
+ facts.push({ fields: ['environment.compose_files'],
540
+ target: `${slug}.env_names`, value: names,
541
+ provenance: { kind: 'inferred', source, reason: 'these variable names appear in .env.example; their values are never read' } });
542
+ for (const name of names) {
543
+ const target = DATABASE_VARIABLE.test(name) ? 'guards.hosted_db_hosts'
544
+ : VENDOR_VARIABLE.test(name) ? 'guards.vendor_endpoint_hosts' : undefined;
545
+ if (target === undefined)
546
+ continue;
547
+ facts.push({ fields: ['guards'], target, value: { variable: name,
548
+ ask: `which host does ${name} point at in each non-local environment (production, staging, shared)?` },
549
+ provenance: { kind: 'inferred', source,
550
+ reason: `the variable name ${name} suggests ${target === 'guards.hosted_db_hosts' ? 'a database' : 'a vendor API endpoint'}; its value was never read, so the host comes from the human` } });
551
+ }
552
+ }
553
+ for (const name of COMPOSE_NAMES) {
554
+ const source = relative(directory.path, name);
555
+ const text = await read(directory.path, name);
556
+ if (text === undefined)
557
+ continue;
558
+ const document = parseYamlText(source, text);
559
+ if (document !== undefined) {
560
+ if (source === '..' || source.startsWith('../') || isAbsolute(source)) {
561
+ skipped.push({ source,
562
+ reason: `is outside the cockpit root, so environment.compose_files cannot represent it; write a compose.yml at the cockpit root that reaches each checkout through its sibling variable${slug === undefined ? '' : ` (such as \${${deriveSiblingVariable(slug)}})`} and answer environment.compose_files = ["compose.yml"]; then rerun ${initCommand({ root: configurationRoot })} with the options you ran it with` });
563
+ }
564
+ else
565
+ composeFiles.push({ source, document });
566
+ }
567
+ break;
568
+ }
569
+ const workflowDirectory = authorityRelative(directory.path, '.github/workflows');
570
+ const workflowSource = relative(directory.path, '.github/workflows');
571
+ let entries = [];
572
+ const workflow = await inspectDirectory(input.root, workflowDirectory);
573
+ if (workflow.kind === 'complete') {
574
+ try {
575
+ entries = (await readdir(workflow.path)).filter(name => /\.ya?ml$/.test(name)).sort();
576
+ if (!sameDirectory(workflow, await inspectDirectory(input.root, workflowDirectory))) {
577
+ entries = [];
578
+ skipped.push({ source: workflowSource, reason: 'directory changed while it was listed' });
579
+ }
580
+ }
581
+ catch (error) {
582
+ skipped.push({ source: workflowSource, reason: `could not be listed (${error.code ?? 'unknown I/O error'})` });
583
+ }
584
+ }
585
+ else if (workflow.kind === 'skipped') {
586
+ skipped.push({ source: workflowSource, reason: workflow.reason });
587
+ }
588
+ if (entries.length > MAX_WORKFLOWS) {
589
+ skipped.push({ source: workflowSource, reason: `holds more than ${MAX_WORKFLOWS} workflows; only the first ${MAX_WORKFLOWS} were read` });
590
+ entries = entries.slice(0, MAX_WORKFLOWS);
591
+ }
592
+ for (const entry of entries) {
593
+ const source = relative(workflowDirectory, entry);
594
+ const text = await read(workflowDirectory, entry);
595
+ if (text === undefined)
596
+ continue;
597
+ for (const service of workflowServices(parseYamlText(source, text))) {
598
+ const key = `${service.name}\0${service.image}`;
599
+ if (seenServices.has(key))
600
+ continue;
601
+ seenServices.add(key);
602
+ facts.push({ fields: ['environment.compose_files'], target: `services.${service.name}`, value: { image: service.image },
603
+ provenance: { kind: 'inferred', source,
604
+ reason: `workflow job "${service.job}" runs the backing service "${service.name}" from this image` } });
605
+ }
606
+ }
607
+ }
608
+ // With no repository under the root, the cockpit root is the first one: an empty directory
609
+ // the product will grow in, or a repository of documents. The schema needs one sibling, and
610
+ // declaring the root itself (dir ".") says what is true instead of inventing a child directory
611
+ // or allowing a cockpit with no repositories that every sibling consumer would have to special-case.
612
+ const rootName = basename(configurationRoot);
613
+ const proposesRoot = (input.repositories.length === 0 || ownRoot?.slug === rootName) && SIBLING_SLUG.test(rootName);
614
+ const others = input.repositories.filter(repository => repository !== ownRoot && SIBLING_SLUG.test(repository.slug));
615
+ const siblingPaths = new Map([...proposesRoot ? [[deriveSiblingVariable(rootName), configurationRoot]] : [],
616
+ ...others.map(repository => [deriveSiblingVariable(repository.slug), resolve(input.root, repository.path)])]);
617
+ const compose = composeFacts(composeFiles, configurationRoot, Object.fromEntries(siblingPaths), skipped);
618
+ if (proposesRoot) {
619
+ // The agent already works in the root, so mounting it buys nothing by itself; but check and
620
+ // up refuse a bind inside a mount = "none" sibling, so a Compose file that binds the root, or
621
+ // a directory of it, makes it a mount = "always" sibling.
622
+ const bind = compose.binds.find(row => bindHolder(row.path, [...siblingPaths.values()]) === configurationRoot);
623
+ const placed = ownRoot ? 'the cockpit lives inside this product repository, so the repository is its own first sibling'
624
+ : 'no directory under the root has a package manifest, so the cockpit root itself is the first repository';
625
+ facts.push({ fields: ['sibling'], target: `sibling.${rootName}`,
626
+ value: { slug: rootName, dir: '.', mount: bind ? 'always' : 'none', acquire: 'none', var: deriveSiblingVariable(rootName) },
627
+ provenance: { kind: 'inferred', source: bind?.source ?? '.', reason: bind
628
+ ? `${placed}; ${bind.source} bind-mounts ${bind.path === configurationRoot ? 'it' : `${pathRelative(configurationRoot, bind.path).split(sep).join('/')} inside it`}, so it is mounted; add its purpose`
629
+ : `${placed}; add its purpose` } });
630
+ }
631
+ // Each observed repository is a checkout the agent works in from the cockpit, so it is mounted.
632
+ // Its origin remote is recorded so a teammate's setup can clone it; without one, acquire "none"
633
+ // leaves a teammate with nothing, and setup cannot say what is missing. The purpose is the human's.
634
+ for (const repository of others) {
635
+ const origin = await readOrigin(repository.path);
636
+ const placed = 'this repository was observed in the estate, so it is proposed as a sibling with its directory name';
637
+ facts.push({ fields: ['sibling'], target: `sibling.${repository.slug}`,
638
+ value: { slug: repository.slug, dir: repository.path, ...(origin.kind === 'url' ? { remote: origin.url } : {}), mount: 'always',
639
+ acquire: origin.kind === 'url' ? 'auto' : 'none', var: deriveSiblingVariable(repository.slug) },
640
+ provenance: { kind: 'inferred', source: origin.kind === 'url' ? origin.source : relative(repository.path),
641
+ reason: origin.kind === 'url'
642
+ ? `${placed}; acquire "auto" with its origin remote, so setup clones it where a checkout is absent; add its purpose`
643
+ : `${placed}; acquire "none" because ${origin.reason}; add its purpose` } });
644
+ }
645
+ if (compose.bases.size > 0) {
646
+ const sources = [...new Set([...compose.bases.values()].flatMap(row => row.sources))];
647
+ const publishers = sources.map(source => `${source} publishes ${[...compose.bases].filter(([, row]) => row.sources.includes(source)).map(([name]) => name).join(', ')}`);
648
+ facts.push({ fields: ['ports.bases'], target: 'ports.bases', value: Object.fromEntries([...compose.bases].map(([name, row]) => [name, row.base])),
649
+ provenance: { kind: 'inferred', source: sources.join(', '),
650
+ reason: `${publishers.join('; ')} from variables with these defaults; check requires every published host port to come from a [ports.bases] variable, so each stack leases its own` } });
651
+ }
652
+ // compose-first resolves every relative path against the first listed file's directory, so one
653
+ // product Compose file can be run in place but several cannot be listed together.
654
+ if (composeFiles.length === 1)
655
+ facts.unshift({ fields: ['environment.compose_files'], target: 'environment.compose_files', value: [composeFiles[0].source],
656
+ provenance: { kind: 'inferred', source: composeFiles[0].source,
657
+ reason: 'a Compose file already exists, so cockpit can run it as written' } });
658
+ else if (composeFiles.length > 1)
659
+ skipped.push({ source: composeFiles.map(file => file.source).join(', '),
660
+ reason: `are Compose files in different repositories, and compose-first resolves every relative path against the first listed file's directory, so they cannot be listed together; write one compose.yml at the cockpit root that runs their services, reaching each checkout through its sibling variable (${[...siblingPaths.keys()].map(name => `\${${name}}`).join(', ')}), and answer environment.compose_files = ["compose.yml"]` });
661
+ return { facts, skipped };
662
+ }
663
+ /**
664
+ * What `check` would refuse or warn about in the Compose files a drafted cockpit.toml declares,
665
+ * judged by check's own rules over the files as written: a host port outside `ports.bases`, a service
666
+ * with no healthcheck. Init never edits a Compose file, so these are shown beside the draft, with
667
+ * check's fix, to be applied to the file before or right after the draft is accepted. Findings are
668
+ * warnings here because nothing is refused until check runs.
669
+ */
670
+ export async function composeFindings(input) {
671
+ const unreadable = (reference, why) => ({ severity: 'warning', code: 'COMPOSE_FILE_UNREADABLE', target: reference,
672
+ message: `${reference} was not checked for the Compose findings check reports (a host port outside ports.bases, a missing healthcheck): ${why}`,
673
+ fix: 'make environment.compose_files name a readable Compose file inside the cockpit root, then run npx --no cockpit check --config <your cockpit.toml>' });
674
+ let draft;
675
+ try {
676
+ draft = parseToml(input.draft);
677
+ }
678
+ catch (error) {
679
+ return [unreadable('cockpit.toml', `the draft is not valid TOML (${errorCause(error)})`)];
680
+ }
681
+ const references = draft.environment?.compose_files;
682
+ if (!Array.isArray(references) || references.length === 0 || references.some(reference => typeof reference !== 'string'))
683
+ return [];
684
+ const services = new Map();
685
+ const unread = [];
686
+ for (const reference of references) {
687
+ const read = await readBounded(input.root, '.', reference);
688
+ if (read.kind !== 'text') {
689
+ unread.push(unreadable(reference, read.kind === 'absent' ? 'it does not exist' : `it ${read.reason}`));
690
+ continue;
691
+ }
692
+ let document;
693
+ try {
694
+ document = parseYaml(read.text);
695
+ }
696
+ catch (error) {
697
+ unread.push(unreadable(reference, `it is not valid YAML (${error instanceof Error ? error.name : 'parse error'})`));
698
+ continue;
699
+ }
700
+ for (const [id, service] of Object.entries(document?.services ?? {})) {
701
+ const ports = composePublishedPorts(service?.ports);
702
+ if (ports === undefined || services.has(id))
703
+ continue;
704
+ const health = service.healthcheck;
705
+ services.set(id, { id, ports, mountSources: [], build: service.build !== undefined,
706
+ image: typeof service.image === 'string' ? service.image : null,
707
+ healthcheck: health !== undefined && health !== null && health.disable !== true && !(Array.isArray(health.test) && health.test[0] === 'NONE') });
708
+ }
709
+ }
710
+ const bases = draft.ports?.bases;
711
+ const found = await assessComposeTopology([...services.values()], { projectDirectory: composeProjectDirectory(input.root, references),
712
+ portVariables: bases !== null && typeof bases === 'object' ? Object.keys(bases) : [], siblings: [], hostPaths: [] });
713
+ return [...unread, ...found.filter(row => row.code === 'COMPOSE_PORT_NOT_LEASED' || row.code === 'COMPOSE_HEALTHCHECK_MISSING')
714
+ .map(row => ({ ...row, severity: 'warning' }))];
715
+ }