bmad-plus 0.12.1 → 0.13.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 (176) hide show
  1. package/CHANGELOG.md +637 -580
  2. package/README.md +123 -120
  3. package/osint-agent-package/agents/osint-investigator.md +12 -0
  4. package/osint-agent-package/skills/bmad-osint-investigate/osint/SKILL.md +491 -482
  5. package/osint-agent-package/skills/bmad-osint-investigate/osint/assets/dossier-template.md +126 -126
  6. package/osint-agent-package/skills/bmad-osint-investigate/osint/assets/lawful-basis-record.md +48 -48
  7. package/osint-agent-package/skills/bmad-osint-investigate/osint/references/content-extraction.md +100 -100
  8. package/osint-agent-package/skills/bmad-osint-investigate/osint/references/gdpr-osint.md +48 -48
  9. package/osint-agent-package/skills/bmad-osint-investigate/osint/references/platforms.md +130 -130
  10. package/osint-agent-package/skills/bmad-osint-investigate/osint/references/psychoprofile.md +69 -69
  11. package/osint-agent-package/skills/bmad-osint-investigate/osint/references/tools.md +281 -281
  12. package/osint-agent-package/skills/bmad-osint-investigate/osint/scripts/mcp-client.py +136 -136
  13. package/package.json +106 -91
  14. package/readme-international/README.de.md +625 -594
  15. package/readme-international/README.es.md +642 -611
  16. package/readme-international/README.fr.md +640 -609
  17. package/src/bmad-plus/agents/agent-orchestrator/SKILL.md +2 -0
  18. package/src/bmad-plus/agents/agent-shadow/SKILL.md +18 -0
  19. package/src/bmad-plus/data/role-triggers.yaml +52 -0
  20. package/src/bmad-plus/module.yaml +333 -283
  21. package/src/bmad-plus/packs/pack-animated/animated-website-agent.md +325 -325
  22. package/src/bmad-plus/packs/pack-animated/templates/animated-website-workflow.md +55 -55
  23. package/src/bmad-plus/packs/pack-backup/backup-agent.md +71 -71
  24. package/src/bmad-plus/packs/pack-backup/templates/backup-workflow.md +51 -51
  25. package/src/bmad-plus/packs/pack-dev-studio/README.md +162 -162
  26. package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/analyst-agent.md +73 -73
  27. package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/document-project.md +61 -61
  28. package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/domain-research.md +95 -95
  29. package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/market-research.md +95 -95
  30. package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/prfaq.md +134 -134
  31. package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/product-brief.md +80 -80
  32. package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/tech-writer-agent.md +73 -73
  33. package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/technical-research.md +95 -95
  34. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/architect-agent.md +73 -73
  35. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/create-architecture.md +73 -73
  36. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/create-epics-stories.md +92 -92
  37. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/generate-project-context.md +80 -80
  38. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/implementation-readiness.md +90 -90
  39. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-01-init.md +153 -153
  40. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-01b-continue.md +173 -173
  41. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-02-context.md +224 -224
  42. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-03-starter.md +329 -329
  43. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-04-decisions.md +318 -318
  44. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-05-patterns.md +359 -359
  45. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-06-structure.md +379 -379
  46. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-07-validation.md +361 -361
  47. package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-08-complete.md +81 -81
  48. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/checkpoint-preview.md +67 -67
  49. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review-steps/step-01-gather-context.md +85 -85
  50. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review-steps/step-02-review.md +35 -35
  51. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review-steps/step-03-triage.md +49 -49
  52. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review-steps/step-04-present.md +131 -131
  53. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review.md +89 -89
  54. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/correct-course.md +300 -300
  55. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/create-story.md +428 -428
  56. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/dev-agent.md +73 -73
  57. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/dev-story-checklist.md +80 -80
  58. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/dev-story.md +484 -484
  59. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/investigate.md +193 -193
  60. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/qa-e2e-tests.md +175 -175
  61. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/quick-dev.md +110 -110
  62. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/retrospective.md +1511 -1511
  63. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/sprint-planning.md +298 -298
  64. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/sprint-status.md +296 -296
  65. package/src/bmad-plus/packs/pack-dev-studio/categories/planning/create-prd.md +29 -29
  66. package/src/bmad-plus/packs/pack-dev-studio/categories/planning/create-ux-design.md +74 -74
  67. package/src/bmad-plus/packs/pack-dev-studio/categories/planning/edit-prd.md +29 -29
  68. package/src/bmad-plus/packs/pack-dev-studio/categories/planning/pm-agent.md +73 -73
  69. package/src/bmad-plus/packs/pack-dev-studio/categories/planning/prd.md +89 -89
  70. package/src/bmad-plus/packs/pack-dev-studio/categories/planning/ux-designer-agent.md +73 -73
  71. package/src/bmad-plus/packs/pack-dev-studio/categories/planning/validate-prd.md +29 -29
  72. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/advanced-elicitation.md +141 -141
  73. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/adversarial-review.md +37 -37
  74. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/bmad-help.md +75 -75
  75. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/brainstorming.md +6 -6
  76. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/customize.md +110 -110
  77. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/distillator.md +176 -176
  78. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/edge-case-hunter.md +67 -67
  79. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/editorial-review-prose.md +86 -86
  80. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/editorial-review-structure.md +179 -179
  81. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/index-docs.md +66 -66
  82. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/party-mode.md +127 -127
  83. package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/shard-doc.md +105 -105
  84. package/src/bmad-plus/packs/pack-dev-studio/dev-studio-orchestrator.md +120 -120
  85. package/src/bmad-plus/packs/pack-dev-studio/shared/architecture-decision-template.md +12 -12
  86. package/src/bmad-plus/packs/pack-dev-studio/shared/bwml-spec.md +328 -328
  87. package/src/bmad-plus/packs/pack-dev-studio/shared/module-help.csv +32 -32
  88. package/src/bmad-plus/packs/pack-dev-studio/upstream-sync.yaml +81 -81
  89. package/src/bmad-plus/packs/pack-seo/SKILL.md +171 -171
  90. package/src/bmad-plus/packs/pack-seo/checklist.md +140 -140
  91. package/src/bmad-plus/packs/pack-seo/pagespeed-playbook.md +320 -320
  92. package/src/bmad-plus/packs/pack-seo/ref/audit-schema.json +187 -187
  93. package/src/bmad-plus/packs/pack-seo/ref/cwv-thresholds.md +87 -87
  94. package/src/bmad-plus/packs/pack-seo/ref/eeat-criteria.md +123 -123
  95. package/src/bmad-plus/packs/pack-seo/ref/geo-signals.md +167 -167
  96. package/src/bmad-plus/packs/pack-seo/ref/hreflang-rules.md +153 -153
  97. package/src/bmad-plus/packs/pack-seo/ref/quality-gates.md +133 -133
  98. package/src/bmad-plus/packs/pack-seo/ref/schema-catalog.md +91 -91
  99. package/src/bmad-plus/packs/pack-seo/ref/schema-templates.json +356 -356
  100. package/src/bmad-plus/packs/pack-seo/requirements.txt +17 -0
  101. package/src/bmad-plus/packs/pack-seo/scripts/seo_apis.py +456 -0
  102. package/src/bmad-plus/packs/pack-seo/scripts/seo_crawl.py +359 -0
  103. package/src/bmad-plus/packs/pack-seo/scripts/seo_fetch.py +303 -0
  104. package/src/bmad-plus/packs/pack-seo/scripts/seo_parse.py +255 -0
  105. package/src/bmad-plus/packs/pack-seo/scripts/seo_report.py +409 -0
  106. package/src/bmad-plus/packs/pack-seo/scripts/seo_screenshot.py +202 -0
  107. package/src/bmad-plus/packs/pack-seo/seo-chief.md +294 -294
  108. package/src/bmad-plus/packs/pack-seo/seo-judge.md +241 -241
  109. package/src/bmad-plus/packs/pack-seo/seo-scout.md +171 -171
  110. package/src/bmad-plus/packs/pack-seo/templates/seo-audit-workflow.md +241 -241
  111. package/src/bmad-plus/packs/pack-shield/README.md +6 -6
  112. package/src/bmad-plus/packs/pack-shield/SKILL.md +2 -2
  113. package/src/bmad-plus/packs/pack-shield/categories/accessibility-esg/csrd-agent.md +11 -11
  114. package/src/bmad-plus/packs/pack-shield/categories/accessibility-esg/section508-agent.md +11 -11
  115. package/src/bmad-plus/packs/pack-shield/categories/accessibility-esg/wcag-agent.md +11 -11
  116. package/src/bmad-plus/packs/pack-shield/categories/ai-governance/eu-ai-act-agent.md +11 -11
  117. package/src/bmad-plus/packs/pack-shield/categories/ai-governance/iso42001-agent.md +11 -11
  118. package/src/bmad-plus/packs/pack-shield/categories/ai-governance/nist-ai-rmf-agent.md +11 -11
  119. package/src/bmad-plus/packs/pack-shield/categories/cybersecurity/cis-controls-agent.md +11 -11
  120. package/src/bmad-plus/packs/pack-shield/categories/cybersecurity/ism-agent.md +11 -11
  121. package/src/bmad-plus/packs/pack-shield/categories/cybersecurity/iso27001-agent.md +11 -11
  122. package/src/bmad-plus/packs/pack-shield/categories/cybersecurity/nis2-agent.md +11 -11
  123. package/src/bmad-plus/packs/pack-shield/categories/cybersecurity/nist-800-53-agent.md +11 -11
  124. package/src/bmad-plus/packs/pack-shield/categories/cybersecurity/nist-csf-agent.md +11 -11
  125. package/src/bmad-plus/packs/pack-shield/categories/defense-export/cmmc-agent.md +11 -11
  126. package/src/bmad-plus/packs/pack-shield/categories/defense-export/ear-agent.md +11 -11
  127. package/src/bmad-plus/packs/pack-shield/categories/defense-export/itar-agent.md +11 -11
  128. package/src/bmad-plus/packs/pack-shield/categories/defense-export/tsa-agent.md +11 -11
  129. package/src/bmad-plus/packs/pack-shield/categories/industry-compliance/dora-agent.md +11 -11
  130. package/src/bmad-plus/packs/pack-shield/categories/industry-compliance/fedramp-agent.md +11 -11
  131. package/src/bmad-plus/packs/pack-shield/categories/industry-compliance/hipaa-agent.md +11 -11
  132. package/src/bmad-plus/packs/pack-shield/categories/industry-compliance/pci-dss-agent.md +11 -11
  133. package/src/bmad-plus/packs/pack-shield/categories/industry-compliance/soc2-agent.md +11 -11
  134. package/src/bmad-plus/packs/pack-shield/categories/industry-compliance/swift-csp-agent.md +11 -11
  135. package/src/bmad-plus/packs/pack-shield/shield-orchestrator.md +1 -1
  136. package/src/bmad-plus/skills/bmad-plus-autopilot/SKILL.md +1 -1
  137. package/tools/bmad-plus-npx.js +4 -2
  138. package/tools/build/adapters.config.js +60 -51
  139. package/tools/build/check-counts.js +626 -0
  140. package/tools/build/check-install-contract.js +298 -0
  141. package/tools/build/generate-adapters.js +252 -56
  142. package/tools/build/generate.js +187 -10
  143. package/tools/build/generated-adapters/.codex/AGENTS.md +20 -7
  144. package/tools/build/generated-adapters/.cursor/rules/bmad-plus.mdc +20 -7
  145. package/tools/build/generated-adapters/.opencode/AGENTS.md +20 -7
  146. package/tools/build/generated-adapters/AGENTS.md +20 -7
  147. package/tools/build/generated-adapters/CLAUDE.md +20 -7
  148. package/tools/build/generated-adapters/CONVENTIONS.md +20 -7
  149. package/tools/build/generated-adapters/GEMINI.md +20 -7
  150. package/tools/build/module.template.yaml +82 -0
  151. package/tools/cli/bmad-plus-cli.js +16 -1
  152. package/tools/cli/commands/autoconfig.js +470 -470
  153. package/tools/cli/commands/doctor.js +205 -233
  154. package/tools/cli/commands/install.js +543 -501
  155. package/tools/cli/commands/memory-journal-cmd.js +311 -311
  156. package/tools/cli/commands/memory.js +195 -195
  157. package/tools/cli/commands/scan.js +348 -348
  158. package/tools/cli/commands/uninstall.js +209 -101
  159. package/tools/cli/commands/update-check.js +31 -0
  160. package/tools/cli/commands/update-policy.js +39 -0
  161. package/tools/cli/commands/update.js +123 -134
  162. package/tools/cli/i18n.js +905 -845
  163. package/tools/cli/lib/README-memory-journal.md +125 -125
  164. package/tools/cli/lib/ide-config.js +10 -259
  165. package/tools/cli/lib/install-manifest.js +17 -0
  166. package/tools/cli/lib/installed-adapters.js +89 -0
  167. package/tools/cli/lib/npm-runner.js +177 -0
  168. package/tools/cli/lib/pack-copy.js +62 -66
  169. package/tools/cli/lib/packs.js +437 -3
  170. package/tools/cli/lib/python-provision.js +508 -508
  171. package/tools/cli/lib/stack-detect.js +102 -102
  172. package/tools/cli/lib/update-check.js +153 -0
  173. package/tools/cli/lib/update-dispatch.js +182 -0
  174. package/tools/cli/lib/update-policy.js +90 -0
  175. package/tools/cli/lib/update-transaction.js +334 -0
  176. package/tools/cli/lib/validate.js +50 -50
@@ -1,508 +1,508 @@
1
- /**
2
- * BMAD+ Unified Python Provisioning (Pillar 2 — kills split-brain / broken-on-arrival)
3
- *
4
- * Provisions the Python runtime a pack declares in the registry
5
- * (`runtime: [node, python]` → seo, memory). One code path for every pack:
6
- * 1. detect a Python >= 3.11 interpreter (or a uv that can fetch one)
7
- * 2. choose a provisioner: uv > pipx > venv+pip
8
- * 3. create an isolated env under a given dir (e.g. <project>/.bmad/venv)
9
- * 4. install the pack's requirements.txt into that env
10
- * 5. VERIFY the pack's entry modules import / entry script runs
11
- *
12
- * Design rules:
13
- * - every external command goes through spawnSync with an ARG ARRAY (never a
14
- * shell string) → no injection surface, no quoting bugs.
15
- * - pure functions (version parsing, candidate lists, provisioner choice,
16
- * command construction) are exported separately and unit-tested without
17
- * spawning anything.
18
- * - side-effecting functions take an injectable `runner` for testability.
19
- * - NOTHING here throws for a missing interpreter/tool: callers always get a
20
- * structured { ok, tool, messages[] } with actionable guidance.
21
- *
22
- * Author: Laurent Rochetta
23
- */
24
-
25
- const { spawnSync } = require('node:child_process');
26
- const path = require('node:path');
27
- const fs = require('node:fs');
28
-
29
- /** Minimum Python required by the registry (registry.yaml → runtimes.python). */
30
- const MIN_PYTHON = { major: 3, minor: 11 };
31
-
32
- /** Hard ceiling for any single provisioning step (pip installs can be slow). */
33
- const STEP_TIMEOUT_MS = 10 * 60 * 1000;
34
-
35
- // ─────────────────────────────────────────────────────────────────────────────
36
- // Runner — the ONLY place a real process is spawned.
37
- // ─────────────────────────────────────────────────────────────────────────────
38
-
39
- /**
40
- * Default command runner. Wraps spawnSync with safe defaults.
41
- * @param {string} cmd Executable name (resolved via PATH).
42
- * @param {string[]} args Argument array — NEVER a shell string.
43
- * @param {object} [opts] Extra spawnSync options (cwd, timeout…).
44
- * @returns {{ status: number|null, stdout: string, stderr: string, error: string|null }}
45
- */
46
- function defaultRunner(cmd, args, opts = {}) {
47
- const res = spawnSync(cmd, args, {
48
- encoding: 'utf8',
49
- shell: false,
50
- windowsHide: true,
51
- timeout: STEP_TIMEOUT_MS,
52
- ...opts,
53
- });
54
- return {
55
- status: typeof res.status === 'number' ? res.status : null,
56
- stdout: res.stdout || '',
57
- stderr: res.stderr || '',
58
- error: res.error ? String(res.error.message || res.error) : null,
59
- };
60
- }
61
-
62
- /** True when a runner result represents a successful invocation. */
63
- function ranOk(result) {
64
- return Boolean(result) && result.error === null && result.status === 0;
65
- }
66
-
67
- // ─────────────────────────────────────────────────────────────────────────────
68
- // Pure logic — no I/O, fully unit-testable.
69
- // ─────────────────────────────────────────────────────────────────────────────
70
-
71
- /**
72
- * Parse a `python --version` / `py -3 --version` output line.
73
- * Accepts "Python 3.11.4", "Python 3.13.0rc1", output on stdout OR stderr.
74
- * @param {string} text
75
- * @returns {{ major: number, minor: number, patch: number, raw: string }|null}
76
- */
77
- function parsePythonVersion(text) {
78
- if (typeof text !== 'string') return null;
79
- const m = text.match(/Python\s+(\d+)\.(\d+)(?:\.(\d+))?/i);
80
- if (!m) return null;
81
- return {
82
- major: Number(m[1]),
83
- minor: Number(m[2]),
84
- patch: m[3] !== undefined ? Number(m[3]) : 0,
85
- raw: m[0],
86
- };
87
- }
88
-
89
- /**
90
- * @param {{major:number, minor:number}|null} version
91
- * @param {{major:number, minor:number}} [min]
92
- * @returns {boolean}
93
- */
94
- function meetsMinVersion(version, min = MIN_PYTHON) {
95
- if (!version) return false;
96
- if (version.major !== min.major) return version.major > min.major;
97
- return version.minor >= min.minor;
98
- }
99
-
100
- /**
101
- * Interpreter candidates to probe, in priority order, per platform.
102
- * `baseArgs` are prepended to every use of the command (the Windows `py`
103
- * launcher needs `-3` to pick a Python 3).
104
- * @param {string} [platform] process.platform value ('win32', 'linux', 'darwin')
105
- * @returns {Array<{ cmd: string, baseArgs: string[] }>}
106
- */
107
- function pythonCandidates(platform = process.platform) {
108
- if (platform === 'win32') {
109
- return [
110
- { cmd: 'py', baseArgs: ['-3'] },
111
- { cmd: 'python', baseArgs: [] },
112
- { cmd: 'python3', baseArgs: [] },
113
- ];
114
- }
115
- return [
116
- { cmd: 'python3', baseArgs: [] },
117
- { cmd: 'python', baseArgs: [] },
118
- ];
119
- }
120
-
121
- /**
122
- * Choose the provisioner from availability flags (registry.yaml:
123
- * `provisioner: uv, fallback: pipx`, then plain venv+pip as last resort).
124
- * @param {{ uv?: boolean, pipx?: boolean }} availability
125
- * @returns {'uv'|'pipx'|'venv'}
126
- */
127
- function chooseProvisioner({ uv = false, pipx = false } = {}) {
128
- if (uv) return 'uv';
129
- if (pipx) return 'pipx';
130
- return 'venv';
131
- }
132
-
133
- /**
134
- * Path of the python executable inside a virtualenv.
135
- * @param {string} envDir
136
- * @param {string} [platform]
137
- * @returns {string}
138
- */
139
- function venvPython(envDir, platform = process.platform) {
140
- return platform === 'win32'
141
- ? path.join(envDir, 'Scripts', 'python.exe')
142
- : path.join(envDir, 'bin', 'python');
143
- }
144
-
145
- /**
146
- * Build the exact command steps (arg arrays) to create the env and install
147
- * requirements with the chosen tool. Pure: constructs, never executes.
148
- *
149
- * @param {object} p
150
- * @param {'uv'|'pipx'|'venv'} p.tool Chosen provisioner.
151
- * @param {{cmd:string, baseArgs:string[]}|null} p.python Detected interpreter
152
- * (may be null only for uv, which can fetch its own Python).
153
- * @param {string} p.envDir Target env dir (e.g. .bmad/venv).
154
- * @param {string|null} [p.requirementsPath] requirements.txt to install, or null.
155
- * @param {string} [p.platform]
156
- * @returns {{ create: Array<{cmd:string,args:string[],label:string}>,
157
- * install: Array<{cmd:string,args:string[],label:string}> }}
158
- */
159
- function buildEnvCommands({ tool, python, envDir, requirementsPath = null, platform = process.platform }) {
160
- const envPy = venvPython(envDir, platform);
161
- const create = [];
162
- const install = [];
163
-
164
- if (tool === 'uv') {
165
- create.push({
166
- cmd: 'uv',
167
- // `--python 3.11` lets uv pick (or download) a matching interpreter even
168
- // when the system Python is absent or too old.
169
- args: ['venv', envDir, '--python', `${MIN_PYTHON.major}.${MIN_PYTHON.minor}`],
170
- label: 'uv venv',
171
- });
172
- if (requirementsPath) {
173
- install.push({
174
- cmd: 'uv',
175
- args: ['pip', 'install', '-r', requirementsPath, '--python', envPy],
176
- label: 'uv pip install',
177
- });
178
- }
179
- return { create, install };
180
- }
181
-
182
- if (tool === 'pipx') {
183
- // pipx ships `pipx run` which can execute the virtualenv app in isolation
184
- // without polluting the system Python.
185
- create.push({
186
- cmd: 'pipx',
187
- args: ['run', 'virtualenv', envDir],
188
- label: 'pipx run virtualenv',
189
- });
190
- if (requirementsPath) {
191
- install.push({
192
- cmd: envPy,
193
- args: ['-m', 'pip', 'install', '-r', requirementsPath],
194
- label: 'pip install (pipx env)',
195
- });
196
- }
197
- return { create, install };
198
- }
199
-
200
- // tool === 'venv' — stdlib venv + pip, requires a detected interpreter.
201
- const py = python || { cmd: 'python', baseArgs: [] };
202
- create.push({
203
- cmd: py.cmd,
204
- args: [...py.baseArgs, '-m', 'venv', envDir],
205
- label: 'python -m venv',
206
- });
207
- if (requirementsPath) {
208
- install.push({
209
- cmd: envPy,
210
- args: ['-m', 'pip', 'install', '-r', requirementsPath],
211
- label: 'pip install (venv)',
212
- });
213
- }
214
- return { create, install };
215
- }
216
-
217
- /**
218
- * Human guidance shown when no suitable Python is found.
219
- * @param {string} [platform]
220
- * @returns {string[]}
221
- */
222
- function missingPythonGuidance(platform = process.platform) {
223
- const min = `${MIN_PYTHON.major}.${MIN_PYTHON.minor}`;
224
- const lines = [`Python >= ${min} was not found on PATH.`];
225
- if (platform === 'win32') {
226
- lines.push(`Install it with: winget install Python.Python.3.12 (or from https://www.python.org/downloads/)`);
227
- } else if (platform === 'darwin') {
228
- lines.push(`Install it with: brew install python@3.12 (or from https://www.python.org/downloads/)`);
229
- } else {
230
- lines.push(`Install it with your package manager (e.g. apt install python3.12) or from https://www.python.org/downloads/`);
231
- }
232
- lines.push(`Alternatively install uv (https://docs.astral.sh/uv/) — BMAD+ will use it to fetch Python ${min} automatically.`);
233
- return lines;
234
- }
235
-
236
- // ─────────────────────────────────────────────────────────────────────────────
237
- // Side-effecting steps — all take an injectable runner.
238
- // ─────────────────────────────────────────────────────────────────────────────
239
-
240
- /**
241
- * Probe PATH for a Python >= minVersion.
242
- * Never throws: a missing interpreter yields { ok:false } + guidance.
243
- *
244
- * @param {object} [opts]
245
- * @param {Function} [opts.runner]
246
- * @param {string} [opts.platform]
247
- * @param {{major:number,minor:number}} [opts.minVersion]
248
- * @returns {{ ok: boolean, tool: 'python'|null, command: {cmd:string,baseArgs:string[]}|null,
249
- * version: object|null, messages: string[] }}
250
- */
251
- function detectPython({ runner = defaultRunner, platform = process.platform, minVersion = MIN_PYTHON } = {}) {
252
- const messages = [];
253
- let tooOld = null;
254
-
255
- for (const candidate of pythonCandidates(platform)) {
256
- const res = runner(candidate.cmd, [...candidate.baseArgs, '--version']);
257
- if (!ranOk(res)) continue; // not installed / broken shim — try next
258
- // Python 2 printed the version on stderr; be liberal in what we accept.
259
- const version = parsePythonVersion(`${res.stdout}\n${res.stderr}`);
260
- if (!version) continue;
261
- if (meetsMinVersion(version, minVersion)) {
262
- messages.push(`Found ${version.raw} via \`${[candidate.cmd, ...candidate.baseArgs].join(' ')}\`.`);
263
- return { ok: true, tool: 'python', command: candidate, version, messages };
264
- }
265
- tooOld = { candidate, version };
266
- }
267
-
268
- if (tooOld) {
269
- messages.push(
270
- `Found ${tooOld.version.raw} via \`${tooOld.candidate.cmd}\`, but BMAD+ requires >= ${minVersion.major}.${minVersion.minor}.`
271
- );
272
- }
273
- messages.push(...missingPythonGuidance(platform));
274
- return { ok: false, tool: null, command: null, version: null, messages };
275
- }
276
-
277
- /**
278
- * Detect which provisioner is available (uv > pipx > venv fallback).
279
- * @param {object} [opts]
280
- * @param {Function} [opts.runner]
281
- * @returns {{ ok: true, tool: 'uv'|'pipx'|'venv', messages: string[] }}
282
- */
283
- function detectProvisioner({ runner = defaultRunner } = {}) {
284
- const messages = [];
285
- const uv = ranOk(runner('uv', ['--version']));
286
- const pipx = uv ? false : ranOk(runner('pipx', ['--version']));
287
- const tool = chooseProvisioner({ uv, pipx });
288
- if (tool === 'venv') {
289
- messages.push('Neither uv nor pipx found — falling back to stdlib venv + pip.');
290
- } else {
291
- messages.push(`Using ${tool} as Python provisioner.`);
292
- }
293
- return { ok: true, tool, messages };
294
- }
295
-
296
- /**
297
- * Create the isolated env with the chosen tool.
298
- * @returns {{ ok: boolean, tool: string, envDir: string, messages: string[] }}
299
- */
300
- function createEnv({ tool, python, envDir, runner = defaultRunner, platform = process.platform }) {
301
- const messages = [];
302
- const { create } = buildEnvCommands({ tool, python, envDir, platform });
303
- for (const step of create) {
304
- const res = runner(step.cmd, step.args);
305
- if (!ranOk(res)) {
306
- messages.push(`Failed to create env (${step.label}): ${firstErrorLine(res)}`);
307
- return { ok: false, tool, envDir, messages };
308
- }
309
- }
310
- messages.push(`Created isolated Python env at ${envDir} (${tool}).`);
311
- return { ok: true, tool, envDir, messages };
312
- }
313
-
314
- /**
315
- * Install a requirements.txt into an existing env.
316
- * @returns {{ ok: boolean, tool: string, messages: string[] }}
317
- */
318
- function installRequirements({
319
- tool,
320
- python,
321
- envDir,
322
- requirementsPath,
323
- runner = defaultRunner,
324
- platform = process.platform,
325
- fileExists = fs.existsSync,
326
- }) {
327
- const messages = [];
328
- if (!requirementsPath) {
329
- return { ok: true, tool, messages: ['No requirements.txt declared — nothing to install.'] };
330
- }
331
- if (!fileExists(requirementsPath)) {
332
- messages.push(`requirements file not found: ${requirementsPath}`);
333
- return { ok: false, tool, messages };
334
- }
335
- const { install } = buildEnvCommands({ tool, python, envDir, requirementsPath, platform });
336
- for (const step of install) {
337
- const res = runner(step.cmd, step.args);
338
- if (!ranOk(res)) {
339
- messages.push(`Dependency install failed (${step.label}): ${firstErrorLine(res)}`);
340
- messages.push(`You can retry manually: ${step.cmd} ${step.args.join(' ')}`);
341
- return { ok: false, tool, messages };
342
- }
343
- }
344
- messages.push(`Installed ${path.basename(requirementsPath)} into ${envDir}.`);
345
- return { ok: true, tool, messages };
346
- }
347
-
348
- /**
349
- * Verify that the pack's entry modules import inside the env.
350
- * @param {object} p
351
- * @param {string[]} p.modules e.g. ['requests', 'bs4'] — verified in ONE interpreter call.
352
- * @returns {{ ok: boolean, messages: string[] }}
353
- */
354
- function verifyImports({ envDir, modules, runner = defaultRunner, platform = process.platform }) {
355
- if (!Array.isArray(modules) || modules.length === 0) {
356
- return { ok: true, messages: [] };
357
- }
358
- const envPy = venvPython(envDir, platform);
359
- const res = runner(envPy, ['-c', `import ${modules.join(', ')}`]);
360
- if (!ranOk(res)) {
361
- return {
362
- ok: false,
363
- messages: [`Import check failed for [${modules.join(', ')}]: ${firstErrorLine(res)}`],
364
- };
365
- }
366
- return { ok: true, messages: [`Verified imports: ${modules.join(', ')}.`] };
367
- }
368
-
369
- /**
370
- * Verify that the pack's entry script runs inside the env (default: --help).
371
- * @returns {{ ok: boolean, messages: string[] }}
372
- */
373
- function verifyEntry({ envDir, entryPath, entryArgs = ['--help'], runner = defaultRunner, platform = process.platform }) {
374
- if (!entryPath) return { ok: true, messages: [] };
375
- const envPy = venvPython(envDir, platform);
376
- const res = runner(envPy, [entryPath, ...entryArgs]);
377
- if (!ranOk(res)) {
378
- return {
379
- ok: false,
380
- messages: [`Entry check failed (${path.basename(entryPath)} ${entryArgs.join(' ')}): ${firstErrorLine(res)}`],
381
- };
382
- }
383
- return { ok: true, messages: [`Verified entry: ${path.basename(entryPath)} runs.`] };
384
- }
385
-
386
- /**
387
- * Full pipeline: detect → choose provisioner → create env → install → verify.
388
- * This is the single call site the installer uses for ANY pack whose registry
389
- * runtime includes "python" (seo, memory, …).
390
- *
391
- * Never throws for environmental problems — always returns
392
- * { ok, tool, messages[] } so the installer can degrade gracefully and print
393
- * guidance instead of aborting the whole install.
394
- *
395
- * @param {object} p
396
- * @param {string} p.envDir e.g. path.join(projectDir, '.bmad', 'venv')
397
- * @param {string|null} [p.requirementsPath] pack requirements.txt (absolute)
398
- * @param {string[]} [p.verifyModules] modules that must import post-install
399
- * @param {string|null} [p.entryPath] entry script to smoke-run
400
- * @param {string[]} [p.entryArgs]
401
- * @param {Function} [p.runner] injectable for tests
402
- * @param {string} [p.platform]
403
- * @param {Function} [p.fileExists]
404
- * @param {{major:number,minor:number}} [p.minVersion]
405
- * @returns {{ ok: boolean, tool: string|null, envDir: string, python: object|null, messages: string[] }}
406
- */
407
- function provisionPack({
408
- envDir,
409
- requirementsPath = null,
410
- verifyModules = [],
411
- entryPath = null,
412
- entryArgs = ['--help'],
413
- runner = defaultRunner,
414
- platform = process.platform,
415
- fileExists = fs.existsSync,
416
- minVersion = MIN_PYTHON,
417
- }) {
418
- const messages = [];
419
-
420
- if (!envDir) {
421
- return { ok: false, tool: null, envDir: null, python: null, messages: ['provisionPack: envDir is required.'] };
422
- }
423
-
424
- // 1) provisioner first — uv can operate without a system Python.
425
- const prov = detectProvisioner({ runner });
426
- messages.push(...prov.messages);
427
-
428
- // 2) interpreter
429
- const py = detectPython({ runner, platform, minVersion });
430
- messages.push(...py.messages);
431
- if (!py.ok && prov.tool !== 'uv') {
432
- return { ok: false, tool: prov.tool, envDir, python: null, messages };
433
- }
434
- if (!py.ok && prov.tool === 'uv') {
435
- messages.push(`No system Python >= ${minVersion.major}.${minVersion.minor} — uv will download a managed interpreter.`);
436
- }
437
-
438
- // 3) create isolated env
439
- const created = createEnv({ tool: prov.tool, python: py.command, envDir, runner, platform });
440
- messages.push(...created.messages);
441
- if (!created.ok) {
442
- return { ok: false, tool: prov.tool, envDir, python: py.version, messages };
443
- }
444
-
445
- // 4) install requirements
446
- const installed = installRequirements({
447
- tool: prov.tool,
448
- python: py.command,
449
- envDir,
450
- requirementsPath,
451
- runner,
452
- platform,
453
- fileExists,
454
- });
455
- messages.push(...installed.messages);
456
- if (!installed.ok) {
457
- return { ok: false, tool: prov.tool, envDir, python: py.version, messages };
458
- }
459
-
460
- // 5) verify — a pack is only "installed" when its entry actually works.
461
- const imports = verifyImports({ envDir, modules: verifyModules, runner, platform });
462
- messages.push(...imports.messages);
463
- if (!imports.ok) {
464
- return { ok: false, tool: prov.tool, envDir, python: py.version, messages };
465
- }
466
-
467
- const entry = verifyEntry({ envDir, entryPath, entryArgs, runner, platform });
468
- messages.push(...entry.messages);
469
- if (!entry.ok) {
470
- return { ok: false, tool: prov.tool, envDir, python: py.version, messages };
471
- }
472
-
473
- messages.push('Python runtime provisioned and verified.');
474
- return { ok: true, tool: prov.tool, envDir, python: py.version, messages };
475
- }
476
-
477
- // ─────────────────────────────────────────────────────────────────────────────
478
- // Helpers
479
- // ─────────────────────────────────────────────────────────────────────────────
480
-
481
- /** First non-empty diagnostic line from a runner result, for compact messages. */
482
- function firstErrorLine(res) {
483
- if (!res) return 'no result';
484
- if (res.error) return res.error;
485
- const text = `${res.stderr}\n${res.stdout}`.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
486
- return text.length > 0 ? text[text.length - 1] : `exit code ${res.status}`;
487
- }
488
-
489
- module.exports = {
490
- MIN_PYTHON,
491
- // pure
492
- parsePythonVersion,
493
- meetsMinVersion,
494
- pythonCandidates,
495
- chooseProvisioner,
496
- venvPython,
497
- buildEnvCommands,
498
- missingPythonGuidance,
499
- // side-effecting (injectable runner)
500
- defaultRunner,
501
- detectPython,
502
- detectProvisioner,
503
- createEnv,
504
- installRequirements,
505
- verifyImports,
506
- verifyEntry,
507
- provisionPack,
508
- };
1
+ /**
2
+ * BMAD+ Unified Python Provisioning (Pillar 2 — kills split-brain / broken-on-arrival)
3
+ *
4
+ * Provisions the Python runtime a pack declares in the registry
5
+ * (`runtime: [node, python]` → seo, memory). One code path for every pack:
6
+ * 1. detect a Python >= 3.11 interpreter (or a uv that can fetch one)
7
+ * 2. choose a provisioner: uv > pipx > venv+pip
8
+ * 3. create an isolated env under a given dir (e.g. <project>/.bmad/venv)
9
+ * 4. install the pack's requirements.txt into that env
10
+ * 5. VERIFY the pack's entry modules import / entry script runs
11
+ *
12
+ * Design rules:
13
+ * - every external command goes through spawnSync with an ARG ARRAY (never a
14
+ * shell string) → no injection surface, no quoting bugs.
15
+ * - pure functions (version parsing, candidate lists, provisioner choice,
16
+ * command construction) are exported separately and unit-tested without
17
+ * spawning anything.
18
+ * - side-effecting functions take an injectable `runner` for testability.
19
+ * - NOTHING here throws for a missing interpreter/tool: callers always get a
20
+ * structured { ok, tool, messages[] } with actionable guidance.
21
+ *
22
+ * Author: Laurent Rochetta
23
+ */
24
+
25
+ const { spawnSync } = require('node:child_process');
26
+ const path = require('node:path');
27
+ const fs = require('node:fs');
28
+
29
+ /** Minimum Python required by the registry (registry.yaml → runtimes.python). */
30
+ const MIN_PYTHON = { major: 3, minor: 11 };
31
+
32
+ /** Hard ceiling for any single provisioning step (pip installs can be slow). */
33
+ const STEP_TIMEOUT_MS = 10 * 60 * 1000;
34
+
35
+ // ─────────────────────────────────────────────────────────────────────────────
36
+ // Runner — the ONLY place a real process is spawned.
37
+ // ─────────────────────────────────────────────────────────────────────────────
38
+
39
+ /**
40
+ * Default command runner. Wraps spawnSync with safe defaults.
41
+ * @param {string} cmd Executable name (resolved via PATH).
42
+ * @param {string[]} args Argument array — NEVER a shell string.
43
+ * @param {object} [opts] Extra spawnSync options (cwd, timeout…).
44
+ * @returns {{ status: number|null, stdout: string, stderr: string, error: string|null }}
45
+ */
46
+ function defaultRunner(cmd, args, opts = {}) {
47
+ const res = spawnSync(cmd, args, {
48
+ encoding: 'utf8',
49
+ shell: false,
50
+ windowsHide: true,
51
+ timeout: STEP_TIMEOUT_MS,
52
+ ...opts,
53
+ });
54
+ return {
55
+ status: typeof res.status === 'number' ? res.status : null,
56
+ stdout: res.stdout || '',
57
+ stderr: res.stderr || '',
58
+ error: res.error ? String(res.error.message || res.error) : null,
59
+ };
60
+ }
61
+
62
+ /** True when a runner result represents a successful invocation. */
63
+ function ranOk(result) {
64
+ return Boolean(result) && result.error === null && result.status === 0;
65
+ }
66
+
67
+ // ─────────────────────────────────────────────────────────────────────────────
68
+ // Pure logic — no I/O, fully unit-testable.
69
+ // ─────────────────────────────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Parse a `python --version` / `py -3 --version` output line.
73
+ * Accepts "Python 3.11.4", "Python 3.13.0rc1", output on stdout OR stderr.
74
+ * @param {string} text
75
+ * @returns {{ major: number, minor: number, patch: number, raw: string }|null}
76
+ */
77
+ function parsePythonVersion(text) {
78
+ if (typeof text !== 'string') return null;
79
+ const m = text.match(/Python\s+(\d+)\.(\d+)(?:\.(\d+))?/i);
80
+ if (!m) return null;
81
+ return {
82
+ major: Number(m[1]),
83
+ minor: Number(m[2]),
84
+ patch: m[3] !== undefined ? Number(m[3]) : 0,
85
+ raw: m[0],
86
+ };
87
+ }
88
+
89
+ /**
90
+ * @param {{major:number, minor:number}|null} version
91
+ * @param {{major:number, minor:number}} [min]
92
+ * @returns {boolean}
93
+ */
94
+ function meetsMinVersion(version, min = MIN_PYTHON) {
95
+ if (!version) return false;
96
+ if (version.major !== min.major) return version.major > min.major;
97
+ return version.minor >= min.minor;
98
+ }
99
+
100
+ /**
101
+ * Interpreter candidates to probe, in priority order, per platform.
102
+ * `baseArgs` are prepended to every use of the command (the Windows `py`
103
+ * launcher needs `-3` to pick a Python 3).
104
+ * @param {string} [platform] process.platform value ('win32', 'linux', 'darwin')
105
+ * @returns {Array<{ cmd: string, baseArgs: string[] }>}
106
+ */
107
+ function pythonCandidates(platform = process.platform) {
108
+ if (platform === 'win32') {
109
+ return [
110
+ { cmd: 'py', baseArgs: ['-3'] },
111
+ { cmd: 'python', baseArgs: [] },
112
+ { cmd: 'python3', baseArgs: [] },
113
+ ];
114
+ }
115
+ return [
116
+ { cmd: 'python3', baseArgs: [] },
117
+ { cmd: 'python', baseArgs: [] },
118
+ ];
119
+ }
120
+
121
+ /**
122
+ * Choose the provisioner from availability flags (registry.yaml:
123
+ * `provisioner: uv, fallback: pipx`, then plain venv+pip as last resort).
124
+ * @param {{ uv?: boolean, pipx?: boolean }} availability
125
+ * @returns {'uv'|'pipx'|'venv'}
126
+ */
127
+ function chooseProvisioner({ uv = false, pipx = false } = {}) {
128
+ if (uv) return 'uv';
129
+ if (pipx) return 'pipx';
130
+ return 'venv';
131
+ }
132
+
133
+ /**
134
+ * Path of the python executable inside a virtualenv.
135
+ * @param {string} envDir
136
+ * @param {string} [platform]
137
+ * @returns {string}
138
+ */
139
+ function venvPython(envDir, platform = process.platform) {
140
+ return platform === 'win32'
141
+ ? path.join(envDir, 'Scripts', 'python.exe')
142
+ : path.join(envDir, 'bin', 'python');
143
+ }
144
+
145
+ /**
146
+ * Build the exact command steps (arg arrays) to create the env and install
147
+ * requirements with the chosen tool. Pure: constructs, never executes.
148
+ *
149
+ * @param {object} p
150
+ * @param {'uv'|'pipx'|'venv'} p.tool Chosen provisioner.
151
+ * @param {{cmd:string, baseArgs:string[]}|null} p.python Detected interpreter
152
+ * (may be null only for uv, which can fetch its own Python).
153
+ * @param {string} p.envDir Target env dir (e.g. .bmad/venv).
154
+ * @param {string|null} [p.requirementsPath] requirements.txt to install, or null.
155
+ * @param {string} [p.platform]
156
+ * @returns {{ create: Array<{cmd:string,args:string[],label:string}>,
157
+ * install: Array<{cmd:string,args:string[],label:string}> }}
158
+ */
159
+ function buildEnvCommands({ tool, python, envDir, requirementsPath = null, platform = process.platform }) {
160
+ const envPy = venvPython(envDir, platform);
161
+ const create = [];
162
+ const install = [];
163
+
164
+ if (tool === 'uv') {
165
+ create.push({
166
+ cmd: 'uv',
167
+ // `--python 3.11` lets uv pick (or download) a matching interpreter even
168
+ // when the system Python is absent or too old.
169
+ args: ['venv', envDir, '--python', `${MIN_PYTHON.major}.${MIN_PYTHON.minor}`],
170
+ label: 'uv venv',
171
+ });
172
+ if (requirementsPath) {
173
+ install.push({
174
+ cmd: 'uv',
175
+ args: ['pip', 'install', '-r', requirementsPath, '--python', envPy],
176
+ label: 'uv pip install',
177
+ });
178
+ }
179
+ return { create, install };
180
+ }
181
+
182
+ if (tool === 'pipx') {
183
+ // pipx ships `pipx run` which can execute the virtualenv app in isolation
184
+ // without polluting the system Python.
185
+ create.push({
186
+ cmd: 'pipx',
187
+ args: ['run', 'virtualenv', envDir],
188
+ label: 'pipx run virtualenv',
189
+ });
190
+ if (requirementsPath) {
191
+ install.push({
192
+ cmd: envPy,
193
+ args: ['-m', 'pip', 'install', '-r', requirementsPath],
194
+ label: 'pip install (pipx env)',
195
+ });
196
+ }
197
+ return { create, install };
198
+ }
199
+
200
+ // tool === 'venv' — stdlib venv + pip, requires a detected interpreter.
201
+ const py = python || { cmd: 'python', baseArgs: [] };
202
+ create.push({
203
+ cmd: py.cmd,
204
+ args: [...py.baseArgs, '-m', 'venv', envDir],
205
+ label: 'python -m venv',
206
+ });
207
+ if (requirementsPath) {
208
+ install.push({
209
+ cmd: envPy,
210
+ args: ['-m', 'pip', 'install', '-r', requirementsPath],
211
+ label: 'pip install (venv)',
212
+ });
213
+ }
214
+ return { create, install };
215
+ }
216
+
217
+ /**
218
+ * Human guidance shown when no suitable Python is found.
219
+ * @param {string} [platform]
220
+ * @returns {string[]}
221
+ */
222
+ function missingPythonGuidance(platform = process.platform) {
223
+ const min = `${MIN_PYTHON.major}.${MIN_PYTHON.minor}`;
224
+ const lines = [`Python >= ${min} was not found on PATH.`];
225
+ if (platform === 'win32') {
226
+ lines.push(`Install it with: winget install Python.Python.3.12 (or from https://www.python.org/downloads/)`);
227
+ } else if (platform === 'darwin') {
228
+ lines.push(`Install it with: brew install python@3.12 (or from https://www.python.org/downloads/)`);
229
+ } else {
230
+ lines.push(`Install it with your package manager (e.g. apt install python3.12) or from https://www.python.org/downloads/`);
231
+ }
232
+ lines.push(`Alternatively install uv (https://docs.astral.sh/uv/) — BMAD+ will use it to fetch Python ${min} automatically.`);
233
+ return lines;
234
+ }
235
+
236
+ // ─────────────────────────────────────────────────────────────────────────────
237
+ // Side-effecting steps — all take an injectable runner.
238
+ // ─────────────────────────────────────────────────────────────────────────────
239
+
240
+ /**
241
+ * Probe PATH for a Python >= minVersion.
242
+ * Never throws: a missing interpreter yields { ok:false } + guidance.
243
+ *
244
+ * @param {object} [opts]
245
+ * @param {Function} [opts.runner]
246
+ * @param {string} [opts.platform]
247
+ * @param {{major:number,minor:number}} [opts.minVersion]
248
+ * @returns {{ ok: boolean, tool: 'python'|null, command: {cmd:string,baseArgs:string[]}|null,
249
+ * version: object|null, messages: string[] }}
250
+ */
251
+ function detectPython({ runner = defaultRunner, platform = process.platform, minVersion = MIN_PYTHON } = {}) {
252
+ const messages = [];
253
+ let tooOld = null;
254
+
255
+ for (const candidate of pythonCandidates(platform)) {
256
+ const res = runner(candidate.cmd, [...candidate.baseArgs, '--version']);
257
+ if (!ranOk(res)) continue; // not installed / broken shim — try next
258
+ // Python 2 printed the version on stderr; be liberal in what we accept.
259
+ const version = parsePythonVersion(`${res.stdout}\n${res.stderr}`);
260
+ if (!version) continue;
261
+ if (meetsMinVersion(version, minVersion)) {
262
+ messages.push(`Found ${version.raw} via \`${[candidate.cmd, ...candidate.baseArgs].join(' ')}\`.`);
263
+ return { ok: true, tool: 'python', command: candidate, version, messages };
264
+ }
265
+ tooOld = { candidate, version };
266
+ }
267
+
268
+ if (tooOld) {
269
+ messages.push(
270
+ `Found ${tooOld.version.raw} via \`${tooOld.candidate.cmd}\`, but BMAD+ requires >= ${minVersion.major}.${minVersion.minor}.`
271
+ );
272
+ }
273
+ messages.push(...missingPythonGuidance(platform));
274
+ return { ok: false, tool: null, command: null, version: null, messages };
275
+ }
276
+
277
+ /**
278
+ * Detect which provisioner is available (uv > pipx > venv fallback).
279
+ * @param {object} [opts]
280
+ * @param {Function} [opts.runner]
281
+ * @returns {{ ok: true, tool: 'uv'|'pipx'|'venv', messages: string[] }}
282
+ */
283
+ function detectProvisioner({ runner = defaultRunner } = {}) {
284
+ const messages = [];
285
+ const uv = ranOk(runner('uv', ['--version']));
286
+ const pipx = uv ? false : ranOk(runner('pipx', ['--version']));
287
+ const tool = chooseProvisioner({ uv, pipx });
288
+ if (tool === 'venv') {
289
+ messages.push('Neither uv nor pipx found — falling back to stdlib venv + pip.');
290
+ } else {
291
+ messages.push(`Using ${tool} as Python provisioner.`);
292
+ }
293
+ return { ok: true, tool, messages };
294
+ }
295
+
296
+ /**
297
+ * Create the isolated env with the chosen tool.
298
+ * @returns {{ ok: boolean, tool: string, envDir: string, messages: string[] }}
299
+ */
300
+ function createEnv({ tool, python, envDir, runner = defaultRunner, platform = process.platform }) {
301
+ const messages = [];
302
+ const { create } = buildEnvCommands({ tool, python, envDir, platform });
303
+ for (const step of create) {
304
+ const res = runner(step.cmd, step.args);
305
+ if (!ranOk(res)) {
306
+ messages.push(`Failed to create env (${step.label}): ${firstErrorLine(res)}`);
307
+ return { ok: false, tool, envDir, messages };
308
+ }
309
+ }
310
+ messages.push(`Created isolated Python env at ${envDir} (${tool}).`);
311
+ return { ok: true, tool, envDir, messages };
312
+ }
313
+
314
+ /**
315
+ * Install a requirements.txt into an existing env.
316
+ * @returns {{ ok: boolean, tool: string, messages: string[] }}
317
+ */
318
+ function installRequirements({
319
+ tool,
320
+ python,
321
+ envDir,
322
+ requirementsPath,
323
+ runner = defaultRunner,
324
+ platform = process.platform,
325
+ fileExists = fs.existsSync,
326
+ }) {
327
+ const messages = [];
328
+ if (!requirementsPath) {
329
+ return { ok: true, tool, messages: ['No requirements.txt declared — nothing to install.'] };
330
+ }
331
+ if (!fileExists(requirementsPath)) {
332
+ messages.push(`requirements file not found: ${requirementsPath}`);
333
+ return { ok: false, tool, messages };
334
+ }
335
+ const { install } = buildEnvCommands({ tool, python, envDir, requirementsPath, platform });
336
+ for (const step of install) {
337
+ const res = runner(step.cmd, step.args);
338
+ if (!ranOk(res)) {
339
+ messages.push(`Dependency install failed (${step.label}): ${firstErrorLine(res)}`);
340
+ messages.push(`You can retry manually: ${step.cmd} ${step.args.join(' ')}`);
341
+ return { ok: false, tool, messages };
342
+ }
343
+ }
344
+ messages.push(`Installed ${path.basename(requirementsPath)} into ${envDir}.`);
345
+ return { ok: true, tool, messages };
346
+ }
347
+
348
+ /**
349
+ * Verify that the pack's entry modules import inside the env.
350
+ * @param {object} p
351
+ * @param {string[]} p.modules e.g. ['requests', 'bs4'] — verified in ONE interpreter call.
352
+ * @returns {{ ok: boolean, messages: string[] }}
353
+ */
354
+ function verifyImports({ envDir, modules, runner = defaultRunner, platform = process.platform }) {
355
+ if (!Array.isArray(modules) || modules.length === 0) {
356
+ return { ok: true, messages: [] };
357
+ }
358
+ const envPy = venvPython(envDir, platform);
359
+ const res = runner(envPy, ['-c', `import ${modules.join(', ')}`]);
360
+ if (!ranOk(res)) {
361
+ return {
362
+ ok: false,
363
+ messages: [`Import check failed for [${modules.join(', ')}]: ${firstErrorLine(res)}`],
364
+ };
365
+ }
366
+ return { ok: true, messages: [`Verified imports: ${modules.join(', ')}.`] };
367
+ }
368
+
369
+ /**
370
+ * Verify that the pack's entry script runs inside the env (default: --help).
371
+ * @returns {{ ok: boolean, messages: string[] }}
372
+ */
373
+ function verifyEntry({ envDir, entryPath, entryArgs = ['--help'], runner = defaultRunner, platform = process.platform }) {
374
+ if (!entryPath) return { ok: true, messages: [] };
375
+ const envPy = venvPython(envDir, platform);
376
+ const res = runner(envPy, [entryPath, ...entryArgs]);
377
+ if (!ranOk(res)) {
378
+ return {
379
+ ok: false,
380
+ messages: [`Entry check failed (${path.basename(entryPath)} ${entryArgs.join(' ')}): ${firstErrorLine(res)}`],
381
+ };
382
+ }
383
+ return { ok: true, messages: [`Verified entry: ${path.basename(entryPath)} runs.`] };
384
+ }
385
+
386
+ /**
387
+ * Full pipeline: detect → choose provisioner → create env → install → verify.
388
+ * This is the single call site the installer uses for ANY pack whose registry
389
+ * runtime includes "python" (seo, memory, …).
390
+ *
391
+ * Never throws for environmental problems — always returns
392
+ * { ok, tool, messages[] } so the installer can degrade gracefully and print
393
+ * guidance instead of aborting the whole install.
394
+ *
395
+ * @param {object} p
396
+ * @param {string} p.envDir e.g. path.join(projectDir, '.bmad', 'venv')
397
+ * @param {string|null} [p.requirementsPath] pack requirements.txt (absolute)
398
+ * @param {string[]} [p.verifyModules] modules that must import post-install
399
+ * @param {string|null} [p.entryPath] entry script to smoke-run
400
+ * @param {string[]} [p.entryArgs]
401
+ * @param {Function} [p.runner] injectable for tests
402
+ * @param {string} [p.platform]
403
+ * @param {Function} [p.fileExists]
404
+ * @param {{major:number,minor:number}} [p.minVersion]
405
+ * @returns {{ ok: boolean, tool: string|null, envDir: string, python: object|null, messages: string[] }}
406
+ */
407
+ function provisionPack({
408
+ envDir,
409
+ requirementsPath = null,
410
+ verifyModules = [],
411
+ entryPath = null,
412
+ entryArgs = ['--help'],
413
+ runner = defaultRunner,
414
+ platform = process.platform,
415
+ fileExists = fs.existsSync,
416
+ minVersion = MIN_PYTHON,
417
+ }) {
418
+ const messages = [];
419
+
420
+ if (!envDir) {
421
+ return { ok: false, tool: null, envDir: null, python: null, messages: ['provisionPack: envDir is required.'] };
422
+ }
423
+
424
+ // 1) provisioner first — uv can operate without a system Python.
425
+ const prov = detectProvisioner({ runner });
426
+ messages.push(...prov.messages);
427
+
428
+ // 2) interpreter
429
+ const py = detectPython({ runner, platform, minVersion });
430
+ messages.push(...py.messages);
431
+ if (!py.ok && prov.tool !== 'uv') {
432
+ return { ok: false, tool: prov.tool, envDir, python: null, messages };
433
+ }
434
+ if (!py.ok && prov.tool === 'uv') {
435
+ messages.push(`No system Python >= ${minVersion.major}.${minVersion.minor} — uv will download a managed interpreter.`);
436
+ }
437
+
438
+ // 3) create isolated env
439
+ const created = createEnv({ tool: prov.tool, python: py.command, envDir, runner, platform });
440
+ messages.push(...created.messages);
441
+ if (!created.ok) {
442
+ return { ok: false, tool: prov.tool, envDir, python: py.version, messages };
443
+ }
444
+
445
+ // 4) install requirements
446
+ const installed = installRequirements({
447
+ tool: prov.tool,
448
+ python: py.command,
449
+ envDir,
450
+ requirementsPath,
451
+ runner,
452
+ platform,
453
+ fileExists,
454
+ });
455
+ messages.push(...installed.messages);
456
+ if (!installed.ok) {
457
+ return { ok: false, tool: prov.tool, envDir, python: py.version, messages };
458
+ }
459
+
460
+ // 5) verify — a pack is only "installed" when its entry actually works.
461
+ const imports = verifyImports({ envDir, modules: verifyModules, runner, platform });
462
+ messages.push(...imports.messages);
463
+ if (!imports.ok) {
464
+ return { ok: false, tool: prov.tool, envDir, python: py.version, messages };
465
+ }
466
+
467
+ const entry = verifyEntry({ envDir, entryPath, entryArgs, runner, platform });
468
+ messages.push(...entry.messages);
469
+ if (!entry.ok) {
470
+ return { ok: false, tool: prov.tool, envDir, python: py.version, messages };
471
+ }
472
+
473
+ messages.push('Python runtime provisioned and verified.');
474
+ return { ok: true, tool: prov.tool, envDir, python: py.version, messages };
475
+ }
476
+
477
+ // ─────────────────────────────────────────────────────────────────────────────
478
+ // Helpers
479
+ // ─────────────────────────────────────────────────────────────────────────────
480
+
481
+ /** First non-empty diagnostic line from a runner result, for compact messages. */
482
+ function firstErrorLine(res) {
483
+ if (!res) return 'no result';
484
+ if (res.error) return res.error;
485
+ const text = `${res.stderr}\n${res.stdout}`.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
486
+ return text.length > 0 ? text[text.length - 1] : `exit code ${res.status}`;
487
+ }
488
+
489
+ module.exports = {
490
+ MIN_PYTHON,
491
+ // pure
492
+ parsePythonVersion,
493
+ meetsMinVersion,
494
+ pythonCandidates,
495
+ chooseProvisioner,
496
+ venvPython,
497
+ buildEnvCommands,
498
+ missingPythonGuidance,
499
+ // side-effecting (injectable runner)
500
+ defaultRunner,
501
+ detectPython,
502
+ detectProvisioner,
503
+ createEnv,
504
+ installRequirements,
505
+ verifyImports,
506
+ verifyEntry,
507
+ provisionPack,
508
+ };