bmad-plus 0.9.1 → 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.
- package/CHANGELOG.md +60 -0
- package/README.md +1 -1
- package/osint-agent-package/skills/bmad-osint-investigate/osint/SKILL.md +30 -0
- package/osint-agent-package/skills/bmad-osint-investigate/osint/assets/dossier-template.md +10 -0
- package/osint-agent-package/skills/bmad-osint-investigate/osint/assets/lawful-basis-record.md +48 -0
- package/osint-agent-package/skills/bmad-osint-investigate/osint/references/gdpr-osint.md +48 -0
- package/package.json +3 -1
- package/tools/build/README.md +78 -0
- package/tools/build/adapters.config.js +117 -0
- package/tools/build/generate-adapters.js +485 -0
- package/tools/build/generate.js +284 -0
- package/tools/build/generated-adapters/.codex/AGENTS.md +121 -0
- package/tools/build/generated-adapters/.cursor/rules/bmad-plus.mdc +126 -0
- package/tools/build/generated-adapters/.opencode/AGENTS.md +121 -0
- package/tools/build/generated-adapters/AGENTS.md +119 -0
- package/tools/build/generated-adapters/CLAUDE.md +122 -0
- package/tools/build/generated-adapters/CONVENTIONS.md +121 -0
- package/tools/build/generated-adapters/GEMINI.md +126 -0
- package/tools/build/generated-adapters/README.md +79 -0
- package/tools/cli/bmad-plus-cli.js +11 -0
- package/tools/cli/commands/autoconfig.js +18 -1
- package/tools/cli/commands/doctor.js +12 -0
- package/tools/cli/commands/install.js +66 -0
- package/tools/cli/commands/memory-journal-cmd.js +311 -0
- package/tools/cli/commands/scan.js +18 -1
- package/tools/cli/commands/uninstall.js +3 -1
- package/tools/cli/commands/update.js +19 -2
- package/tools/cli/lib/README-memory-journal.md +125 -0
- package/tools/cli/lib/memory-journal.js +0 -0
- package/tools/cli/lib/packs.js +209 -114
- package/tools/cli/lib/python-provision.js +508 -0
- package/tools/cli/lib/validate.js +8 -3
|
@@ -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
|
+
};
|
|
@@ -3,8 +3,12 @@
|
|
|
3
3
|
* Extracted from install.js for modularity and reuse.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
// Shell metacharacters that are dangerous in user-provided names
|
|
7
|
-
|
|
6
|
+
// Shell/quote metacharacters that are dangerous in user-provided names.
|
|
7
|
+
// Includes single/double quotes so a name cannot break out of a quoted context.
|
|
8
|
+
const SHELL_META = /[;&|`$(){}[\]!#~<>*?\\'"\n\r]/;
|
|
9
|
+
// Global variant used to strip EVERY occurrence during sanitization.
|
|
10
|
+
// (A non-global regex in String.replace only removes the first match.)
|
|
11
|
+
const SHELL_META_GLOBAL = /[;&|`$(){}[\]!#~<>*?\\'"\n\r]/g;
|
|
8
12
|
|
|
9
13
|
/**
|
|
10
14
|
* Validate and sanitize a user name.
|
|
@@ -31,7 +35,7 @@ function validateUserName(rawName, fallback) {
|
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
if (SHELL_META.test(rawName)) {
|
|
34
|
-
const sanitized = rawName.replace(
|
|
38
|
+
const sanitized = rawName.replace(SHELL_META_GLOBAL, '').trim() || 'Developer';
|
|
35
39
|
warnings.push('Name contains shell metacharacters. Using sanitized version.');
|
|
36
40
|
return { name: sanitized, warnings };
|
|
37
41
|
}
|
|
@@ -42,4 +46,5 @@ function validateUserName(rawName, fallback) {
|
|
|
42
46
|
module.exports = {
|
|
43
47
|
validateUserName,
|
|
44
48
|
SHELL_META,
|
|
49
|
+
SHELL_META_GLOBAL,
|
|
45
50
|
};
|