diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 6e015322a..fc1d2be62 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -3318,6 +3318,52 @@ async function runTests() { 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 // ============================================================ diff --git a/tools/installer/core/python-check.js b/tools/installer/core/python-check.js new file mode 100644 index 000000000..66d43e125 --- /dev/null +++ b/tools/installer/core/python-check.js @@ -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, +}; diff --git a/tools/installer/ui.js b/tools/installer/ui.js index a107fb0fc..17a712a86 100644 --- a/tools/installer/ui.js +++ b/tools/installer/ui.js @@ -161,6 +161,14 @@ class UI { const messageLoader = new MessageLoader(); 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 // are surfaced immediately so the user sees them before any git ops run. const channelOptions = parseChannelOptions(options);