feat: installer detects Python version and warns when 3.11+ (tomllib) is missing

Several BMAD features need Python at runtime: memlog (3.8+) and the TOML
config resolution scripts (3.11+ for stdlib tomllib). Users install into
varied environments (Linux, Windows, WSL, Docker) where Python may be
missing or too old, and previously only found out via runtime errors.

The installer now probes PATH at startup (py -3 / python3 / python) and
classifies the result: 3.11+ passes silently with a success line; 3.8-3.10
or missing/too-old Python gets a warning naming exactly which features
degrade, plus per-platform install hints. The warning requires an explicit
ack — continue (fix later, no reinstall needed) or quit and re-run after
installing Python. Warn-don't-block: most of BMAD works without Python, so
the install is never refused. In --yes mode the warning logs and continues
without prompting.
This commit is contained in:
Brian Madison 2026-06-11 12:42:33 -05:00
parent fbb48ed711
commit a8439bf196
3 changed files with 223 additions and 0 deletions

View File

@ -3318,6 +3318,52 @@ async function runTests() {
console.log(''); console.log('');
// ============================================================
// Test Suite 46: Python environment check (version parsing + classification)
// ============================================================
console.log(`${colors.yellow}Test Suite 46: python-check version parsing and classification${colors.reset}\n`);
try {
const { parsePythonVersion, classifyPython, detectPython } = require('../tools/installer/core/python-check');
// Version parsing
const v312 = parsePythonVersion('Python 3.12.1');
assert(v312 && v312.major === 3 && v312.minor === 12 && v312.patch === 1, 'parses "Python 3.12.1"');
const v311 = parsePythonVersion('Python 3.11.0\n');
assert(v311 && v311.raw === '3.11.0', 'parses with trailing newline');
const v2 = parsePythonVersion('\nPython 2.7.18');
assert(v2 && v2.major === 2, 'parses Python 2 output (stderr-style)');
const noPatch = parsePythonVersion('Python 3.13');
assert(noPatch && noPatch.patch === 0, 'missing patch defaults to 0');
assert(parsePythonVersion('') === null, 'empty output returns null');
assert(parsePythonVersion('command not found: python3') === null, 'non-version output returns null');
assert(parsePythonVersion(null) === null, 'null output returns null');
// Classification against feature requirements
assert(classifyPython({ major: 3, minor: 11 }) === 'full', '3.11 is full support (tomllib floor)');
assert(classifyPython({ major: 3, minor: 13 }) === 'full', '3.13 is full support');
assert(classifyPython({ major: 4, minor: 0 }) === 'full', 'hypothetical 4.0 is full support');
assert(classifyPython({ major: 3, minor: 10 }) === 'partial', '3.10 is partial (memlog yes, tomllib no)');
assert(classifyPython({ major: 3, minor: 8 }) === 'partial', '3.8 is partial (memlog floor)');
assert(classifyPython({ major: 3, minor: 7 }) === 'unsupported', '3.7 is unsupported');
assert(classifyPython({ major: 2, minor: 7 }) === 'unsupported', '2.7 is unsupported');
assert(classifyPython(null) === 'none', 'no python is none');
// Detection smoke test — must not throw, and if it finds a Python the
// result must be well-formed. (CI machines may or may not have Python.)
const detected = detectPython();
assert(
detected === null || (typeof detected.command === 'string' && typeof detected.version.raw === 'string'),
'detectPython returns null or a well-formed result',
);
} catch (error) {
console.log(`${colors.red}Test Suite 46 setup failed: ${error.message}${colors.reset}`);
console.log(error.stack);
failed++;
}
console.log('');
// ============================================================ // ============================================================
// Summary // Summary
// ============================================================ // ============================================================

View File

@ -0,0 +1,169 @@
const { spawnSync } = require('node:child_process');
const prompts = require('../prompts');
// Python 3.11 added stdlib `tomllib` (PEP 680), which the shared scripts in
// src/scripts/ (resolve_config.py, resolve_customization.py) require to read
// BMAD's TOML config files. memlog.py is more lenient and runs on 3.8+.
const PYTHON_FULL_SUPPORT = { major: 3, minor: 11 };
const PYTHON_PARTIAL_SUPPORT = { major: 3, minor: 8 };
// Probe order matters: on Windows the `py` launcher is the most reliable way
// to find Python 3 (a bare `python` is often the Microsoft Store alias that
// exits without printing a version). On POSIX, `python3` is canonical.
const PROBE_CANDIDATES =
process.platform === 'win32'
? [
{ command: 'py', args: ['-3', '--version'] },
{ command: 'python3', args: ['--version'] },
{ command: 'python', args: ['--version'] },
]
: [
{ command: 'python3', args: ['--version'] },
{ command: 'python', args: ['--version'] },
];
/**
* Parse a `python --version` output line into version parts.
* Python 3 prints to stdout; Python 2 printed to stderr callers pass both.
* @param {string} output - Combined stdout/stderr from `python --version`
* @returns {{major: number, minor: number, patch: number, raw: string}|null}
*/
function parsePythonVersion(output) {
if (!output) return null;
const match = output.match(/Python\s+(\d+)\.(\d+)(?:\.(\d+))?/);
if (!match) return null;
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3] || 0),
raw: `${match[1]}.${match[2]}.${match[3] || 0}`,
};
}
/**
* Classify a detected Python version against BMAD's feature requirements.
* @param {{major: number, minor: number}|null} version
* @returns {'full'|'partial'|'unsupported'|'none'}
*/
function classifyPython(version) {
if (!version) return 'none';
const { major, minor } = version;
if (major > PYTHON_FULL_SUPPORT.major || (major === PYTHON_FULL_SUPPORT.major && minor >= PYTHON_FULL_SUPPORT.minor)) {
return 'full';
}
if (major === PYTHON_PARTIAL_SUPPORT.major && minor >= PYTHON_PARTIAL_SUPPORT.minor) {
return 'partial';
}
return 'unsupported';
}
/**
* Probe the local environment for a Python interpreter.
* Tries each candidate command and returns the first that reports a version.
* @returns {{command: string, version: {major: number, minor: number, patch: number, raw: string}}|null}
*/
function detectPython() {
for (const candidate of PROBE_CANDIDATES) {
try {
const result = spawnSync(candidate.command, candidate.args, {
encoding: 'utf8',
timeout: 5000,
windowsHide: true,
});
if (result.error) continue;
const version = parsePythonVersion(`${result.stdout || ''}\n${result.stderr || ''}`);
if (version) {
const display = candidate.args.length > 1 ? `${candidate.command} ${candidate.args.slice(0, -1).join(' ')}` : candidate.command;
return { command: display, version };
}
} catch {
// Candidate not runnable — try the next one.
}
}
return null;
}
function upgradeHints() {
return [
'How to get Python 3.11+:',
' macOS: brew install python3',
' Windows: winget install Python.Python.3.12',
' Linux/WSL: sudo apt install python3 (Ubuntu 24.04+ ships 3.12; older distros: use pyenv or deadsnakes)',
' Docker: add python3 to your image (e.g. apk add python3 / apt-get install -y python3)',
].join('\n');
}
/**
* Check the local Python environment and warn about degraded BMAD features.
*
* Warn-don't-block: most of BMAD works without Python, so the install always
* may proceed but the user must explicitly acknowledge the warning so it
* can't scroll past unseen. In non-interactive mode (--yes) the warning is
* logged and the install continues without a prompt.
*
* @param {Object} [options]
* @param {boolean} [options.nonInteractive=false] - Skip the ack prompt (--yes mode)
* @returns {Promise<{status: string, detected: Object|null}>}
*/
async function checkPythonEnvironment({ nonInteractive = false } = {}) {
const detected = detectPython();
const status = classifyPython(detected ? detected.version : null);
if (status === 'full') {
await prompts.log.success(`Python ${detected.version.raw} detected (${detected.command}) — all BMAD features supported.`);
return { status, detected };
}
if (status === 'partial') {
await prompts.log.warn(
`Python ${detected.version.raw} detected (${detected.command}) — BMAD's TOML config tools need Python 3.11+ (stdlib tomllib).\n` +
`Works: memlog session memory. Won't work: config/customization resolution scripts.`,
);
} else {
const found =
status === 'unsupported' ? `Python ${detected.version.raw} detected (${detected.command}) — too old.` : 'No Python found on PATH.';
await prompts.log.warn(
`${found} BMAD installs fine without it, but Python-powered features\n` +
`(memlog session memory, TOML config resolution) won't run until Python 3.11+ is available.`,
);
}
await prompts.note(upgradeHints(), 'Python 3.11+ recommended');
if (nonInteractive) {
await prompts.log.info('Continuing without Python 3.11+ (--yes mode). You can install Python later — no reinstall needed.');
return { status, detected };
}
const choice = await prompts.select({
message: 'Python 3.11+ was not found. How do you want to proceed?',
choices: [
{
name: 'Continue install',
value: 'continue',
hint: 'BMAD works without Python — you can add Python 3.11+ later, no reinstall needed',
},
{
name: 'Quit and fix Python first',
value: 'quit',
hint: 'install Python 3.11+, then re-run the installer',
},
],
default: 'continue',
});
if (choice === 'quit') {
await prompts.cancel('Install Python 3.11+ (see hints above), then re-run the installer.');
process.exit(0);
}
return { status, detected };
}
module.exports = {
checkPythonEnvironment,
detectPython,
parsePythonVersion,
classifyPython,
PYTHON_FULL_SUPPORT,
PYTHON_PARTIAL_SUPPORT,
};

View File

@ -161,6 +161,14 @@ class UI {
const messageLoader = new MessageLoader(); const messageLoader = new MessageLoader();
await messageLoader.displayStartMessage(); await messageLoader.displayStartMessage();
// Probe the local Python before any other prompts: several BMAD features
// (memlog session memory, TOML config resolution) need Python 3.11+ at
// runtime. Warn-don't-block, but require an explicit ack so the warning
// can't scroll past unseen. The installer runs in the destination
// environment, so probing PATH here tests the right machine.
const { checkPythonEnvironment } = require('./core/python-check');
await checkPythonEnvironment({ nonInteractive: !!options.yes });
// Parse channel flags (--channel/--all-*/--next=/--pin) once. Warnings // Parse channel flags (--channel/--all-*/--next=/--pin) once. Warnings
// are surfaced immediately so the user sees them before any git ops run. // are surfaced immediately so the user sees them before any git ops run.
const channelOptions = parseChannelOptions(options); const channelOptions = parseChannelOptions(options);