200 lines
7.8 KiB
JavaScript
200 lines
7.8 KiB
JavaScript
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 };
|
|
|
|
// Every runtime call site (skill steps, on_complete hooks) invokes a literal
|
|
// `python3`, so only that command's version vouches for BMAD features. The
|
|
// fallback probes exist to tell the user "Python is installed, but not under
|
|
// the name BMAD uses" instead of a misleading "No Python found".
|
|
const RUNTIME_COMMAND = 'python3';
|
|
const PROBE_CANDIDATES =
|
|
process.platform === 'win32'
|
|
? [
|
|
{ command: 'python3', args: ['--version'] },
|
|
{ command: 'py', args: ['-3', '--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';
|
|
}
|
|
|
|
/**
|
|
* Run one probe candidate and return its parsed version, or null.
|
|
* @param {{command: string, args: string[]}} candidate
|
|
* @returns {{major: number, minor: number, patch: number, raw: string}|null}
|
|
*/
|
|
function probeVersion(candidate) {
|
|
const run = (extra = {}) =>
|
|
spawnSync(candidate.command, candidate.args, {
|
|
encoding: 'utf8',
|
|
timeout: 5000,
|
|
windowsHide: true,
|
|
...extra,
|
|
});
|
|
let result = run();
|
|
// Node >=18.20/20.12 refuses to spawn .bat/.cmd without a shell
|
|
// (CVE-2024-27980 hardening) and reports EINVAL — pyenv-win ships its
|
|
// python shims as .bat. Args here are static literals, so a shell retry
|
|
// is injection-safe.
|
|
if (result.error && result.error.code === 'EINVAL' && process.platform === 'win32') {
|
|
result = run({ shell: true });
|
|
}
|
|
if (result.error) return null;
|
|
return parsePythonVersion(`${result.stdout || ''}\n${result.stderr || ''}`);
|
|
}
|
|
|
|
/**
|
|
* Probe the local environment for a Python interpreter.
|
|
* Tries each candidate command and returns the first that reports a version.
|
|
* `isRuntimeCommand` is true only when the match is `python3` — the command
|
|
* BMAD scripts actually invoke.
|
|
* @returns {{command: string, version: {major: number, minor: number, patch: number, raw: string}, isRuntimeCommand: boolean}|null}
|
|
*/
|
|
function detectPython() {
|
|
for (const candidate of PROBE_CANDIDATES) {
|
|
try {
|
|
const version = probeVersion(candidate);
|
|
if (version) {
|
|
const display = candidate.args.length > 1 ? `${candidate.command} ${candidate.args.slice(0, -1).join(' ')}` : candidate.command;
|
|
return { command: display, version, isRuntimeCommand: candidate.command === RUNTIME_COMMAND };
|
|
}
|
|
} catch {
|
|
// Candidate not runnable — try the next one.
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function upgradeHints() {
|
|
return [
|
|
'How to get Python 3.11+ (as `python3`):',
|
|
' macOS: brew install python3',
|
|
' Windows: winget install Python.Python.3.12 (then ensure `python3` resolves, e.g. enable the python3 alias)',
|
|
' 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 runs (--yes, or stdin is not
|
|
* a TTY) the warning is logged and the install continues without a prompt.
|
|
*
|
|
* @param {Object} [options]
|
|
* @param {boolean} [options.nonInteractive=false] - Skip the ack prompt (--yes, or no TTY)
|
|
* @returns {Promise<{status: string, detected: Object|null}>}
|
|
*/
|
|
async function checkPythonEnvironment({ nonInteractive = false } = {}) {
|
|
// Called via module.exports so tests can stub detection.
|
|
const detected = module.exports.detectPython();
|
|
const status = classifyPython(detected ? detected.version : null);
|
|
|
|
if (status === 'full' && detected.isRuntimeCommand) {
|
|
await prompts.log.success(`Python ${detected.version.raw} detected (${detected.command}) — all BMAD features supported.`);
|
|
return { status, detected };
|
|
}
|
|
|
|
if (detected && !detected.isRuntimeCommand) {
|
|
await prompts.log.warn(
|
|
`Python ${detected.version.raw} found via \`${detected.command}\`, but BMAD scripts invoke \`python3\`, which is not on PATH.\n` +
|
|
`Python-powered features (memlog session memory, TOML config resolution) won't run until \`python3\` resolves —\n` +
|
|
`add a python3 alias/shim, or reinstall Python with the python3 launcher enabled.`,
|
|
);
|
|
} else 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 anyway (non-interactive run). You can fix Python later — no reinstall needed.');
|
|
return { status, detected };
|
|
}
|
|
|
|
const choice = await prompts.select({
|
|
message: "BMAD's Python-powered features won't work yet. How do you want to proceed?",
|
|
choices: [
|
|
{
|
|
name: 'Continue install',
|
|
value: 'continue',
|
|
hint: 'BMAD works without Python — you can fix Python later, no reinstall needed',
|
|
},
|
|
{
|
|
name: 'Quit and fix Python first',
|
|
value: 'quit',
|
|
hint: 'make Python 3.11+ available as python3, then re-run the installer',
|
|
},
|
|
],
|
|
default: 'continue',
|
|
});
|
|
|
|
if (choice === 'quit') {
|
|
await prompts.cancel('Make Python 3.11+ available as `python3` (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,
|
|
};
|