bmad-plus 0.9.2 → 0.12.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.
@@ -0,0 +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
+ };