bmad-plus 0.12.2 → 0.13.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 +25 -0
- package/README.md +36 -8
- package/package.json +6 -4
- package/readme-international/README.de.md +37 -8
- package/readme-international/README.es.md +38 -9
- package/readme-international/README.fr.md +37 -8
- package/src/bmad-plus/agents/agent-orchestrator/SKILL.md +2 -0
- package/src/bmad-plus/module.yaml +270 -220
- package/src/bmad-plus/packs/pack-seo/scripts/seo_apis.py +8 -8
- package/src/bmad-plus/packs/pack-seo/scripts/seo_fetch.py +1 -2
- package/src/bmad-plus/packs/pack-seo/scripts/seo_report.py +0 -1
- package/src/bmad-plus/skills/bmad-plus-autopilot/SKILL.md +1 -1
- package/tools/bmad-plus-npx.js +4 -2
- package/tools/build/adapters.config.js +60 -51
- package/tools/build/check-counts.js +52 -54
- package/tools/build/check-install-contract.js +298 -0
- package/tools/build/generate-adapters.js +252 -56
- package/tools/build/generate.js +187 -10
- package/tools/build/generated-adapters/.codex/AGENTS.md +20 -7
- package/tools/build/generated-adapters/.cursor/rules/bmad-plus.mdc +20 -7
- package/tools/build/generated-adapters/.opencode/AGENTS.md +20 -7
- package/tools/build/generated-adapters/AGENTS.md +20 -7
- package/tools/build/generated-adapters/CLAUDE.md +20 -7
- package/tools/build/generated-adapters/CONVENTIONS.md +20 -7
- package/tools/build/generated-adapters/GEMINI.md +20 -7
- package/tools/build/module.template.yaml +82 -0
- package/tools/cli/bmad-plus-cli.js +16 -1
- package/tools/cli/commands/doctor.js +12 -40
- package/tools/cli/commands/install.js +108 -163
- package/tools/cli/commands/uninstall.js +173 -65
- package/tools/cli/commands/update-check.js +31 -0
- package/tools/cli/commands/update-policy.js +39 -0
- package/tools/cli/commands/update.js +102 -113
- package/tools/cli/i18n.js +60 -0
- package/tools/cli/lib/ide-config.js +4 -261
- package/tools/cli/lib/install-manifest.js +17 -0
- package/tools/cli/lib/installed-adapters.js +89 -0
- package/tools/cli/lib/npm-runner.js +177 -0
- package/tools/cli/lib/pack-copy.js +62 -66
- package/tools/cli/lib/packs.js +437 -3
- package/tools/cli/lib/update-check.js +153 -0
- package/tools/cli/lib/update-dispatch.js +182 -0
- package/tools/cli/lib/update-policy.js +90 -0
- package/tools/cli/lib/update-transaction.js +334 -0
|
@@ -12,59 +12,18 @@ const fsExtra = require('fs-extra');
|
|
|
12
12
|
const clack = require('@clack/prompts');
|
|
13
13
|
const pc = require('picocolors');
|
|
14
14
|
const { t, getLanguageOptions, getCommLanguageOptions } = require('../i18n');
|
|
15
|
-
const { PACKS } = require('../lib/packs');
|
|
16
|
-
const { copyPackFiles } = require('../lib/pack-copy');
|
|
15
|
+
const { PACKS, DERIVED } = require('../lib/packs');
|
|
16
|
+
const { copyPackFiles, copyManagedFile } = require('../lib/pack-copy');
|
|
17
|
+
const { acquireProjectLock, inventoryOf, collectManagedFiles } = require('../lib/update-transaction');
|
|
17
18
|
const { initMemory } = require('../lib/memory-init');
|
|
18
|
-
const {
|
|
19
|
+
const { generateUserFiles, IDE_CONFIGS } = require('../../build/generate-adapters');
|
|
20
|
+
const { writeIDEConfigs, safeAdapterPath } = require('../lib/installed-adapters');
|
|
21
|
+
const { readInstallManifest } = require('../lib/install-manifest');
|
|
22
|
+
const yaml = require('js-yaml');
|
|
19
23
|
const { validateUserName } = require('../lib/validate');
|
|
20
24
|
const { provisionPack } = require('../lib/python-provision');
|
|
21
25
|
|
|
22
|
-
|
|
23
|
-
// Provisioned only when the user opts in with --provision-python, so the default
|
|
24
|
-
// install (and the test suite) never spawns Python. Kills SEO "broken on arrival".
|
|
25
|
-
// Paths are resolved relative to the PACKAGE root (projectRoot below), so they
|
|
26
|
-
// must live under a directory shipped in package.json "files". The SEO toolkit
|
|
27
|
-
// is embarked in-pack (src/bmad-plus/packs/pack-seo) precisely so npm consumers
|
|
28
|
-
// receive the scripts + requirements the SKILL.md commands them to run.
|
|
29
|
-
const PYTHON_PACKS = {
|
|
30
|
-
seo: {
|
|
31
|
-
requirements: ['src', 'bmad-plus', 'packs', 'pack-seo', 'requirements.txt'],
|
|
32
|
-
verifyModules: ['requests', 'bs4', 'defusedxml', 'lxml'],
|
|
33
|
-
},
|
|
34
|
-
memory: {
|
|
35
|
-
// NOTE: mcp-server is not in package.json "files" → this still warn-skips for
|
|
36
|
-
// npm consumers (memory RAG provisioning is a separate, pre-existing gap).
|
|
37
|
-
requirements: ['mcp-server', 'requirements.txt'],
|
|
38
|
-
verifyModules: ['chromadb'],
|
|
39
|
-
},
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
// Pack definitions are imported from the shared module: require('../lib/packs').PACKS
|
|
43
|
-
|
|
44
|
-
// ── Root-config ownership markers (PROD-01) ──
|
|
45
|
-
//
|
|
46
|
-
// install writes CLAUDE.md / GEMINI.md / AGENTS.md, but TWO producers own those
|
|
47
|
-
// same paths, each stamping its own marker:
|
|
48
|
-
// - lib/ide-config.js → INSTALLER_MARKER (this installer's template)
|
|
49
|
-
// - tools/build/generate-adapters.js → GENERATED_MARKER (registry-generated spine)
|
|
50
|
-
// Recognizing only the first is what let `install --yes` clobber a generated spine.
|
|
51
|
-
const INSTALLER_MARKER = 'BMAD+ — AI Agent Configuration';
|
|
52
|
-
const GENERATOR_MARKER_FALLBACK = 'AUTO-GENERATED by tools/build/generate-adapters.js';
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* The marker the adapter generator stamps on every root file it owns.
|
|
56
|
-
* Read from the generator's own export so the two cannot drift apart — a
|
|
57
|
-
* hand-typed copy is how INSTALLER_MARKER drifted from the spine in the first
|
|
58
|
-
* place. The fallback only covers trees published without the build tooling;
|
|
59
|
-
* tests/unit/install-spine.test.js pins both values to their real producer.
|
|
60
|
-
*/
|
|
61
|
-
function resolveGeneratorMarker() {
|
|
62
|
-
try {
|
|
63
|
-
return require('../../build/generate-adapters').GENERATED_MARKER || GENERATOR_MARKER_FALLBACK;
|
|
64
|
-
} catch {
|
|
65
|
-
return GENERATOR_MARKER_FALLBACK;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
26
|
+
const PYTHON_PACKS = DERIVED.pythonPacks;
|
|
68
27
|
|
|
69
28
|
module.exports = {
|
|
70
29
|
command: 'install',
|
|
@@ -74,12 +33,34 @@ module.exports = {
|
|
|
74
33
|
['-p, --packs <packs>', 'Comma-separated pack IDs: core,osint,all (default: interactive)'],
|
|
75
34
|
['-y, --yes', 'Accept all defaults, skip prompts'],
|
|
76
35
|
['-l, --lang <code>', 'Language code: en, fr, es, de, pt-br, ru, zh, he, ja, it (overrides auto-detection)'],
|
|
77
|
-
['--tools <tools>', 'Comma-separated IDE IDs (default: auto-detect)'],
|
|
36
|
+
['--tools <tools>', 'Comma-separated IDE IDs, all, or none (default: auto-detect)'],
|
|
37
|
+
['--mode <mode>', 'Execution mode: manual, autopilot, hybrid (default: manual)'],
|
|
78
38
|
['--provision-python', 'Provision an isolated Python env for packs that need one (seo, memory)'],
|
|
79
39
|
],
|
|
80
40
|
action: async (options) => {
|
|
41
|
+
// A closed/piped stdin cannot answer prompts. Require explicit defaults
|
|
42
|
+
// before creating files, instead of silently exiting halfway through UI.
|
|
43
|
+
if (!options.yes && !process.stdin.isTTY) {
|
|
44
|
+
const i = t(options.lang || 'en');
|
|
45
|
+
clack.log.error(i.noninteractive_requires_yes('install'));
|
|
46
|
+
process.exitCode = 1;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
81
50
|
const projectDir = path.resolve(options.directory || process.cwd());
|
|
82
51
|
const bmadSrc = path.join(__dirname, '..', '..', '..', 'src', 'bmad-plus');
|
|
52
|
+
const previousManifestPath = safeAdapterPath(projectDir, '_bmad/.bmad-plus-install.json');
|
|
53
|
+
const previousManifestBytes = fs.existsSync(previousManifestPath) ? fs.readFileSync(previousManifestPath) : null;
|
|
54
|
+
let previousManifest = null;
|
|
55
|
+
let manifestError;
|
|
56
|
+
if (fs.existsSync(previousManifestPath)) {
|
|
57
|
+
try {
|
|
58
|
+
previousManifest = readInstallManifest(previousManifestPath);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
manifestError = error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (options.mode && !['manual', 'autopilot', 'hybrid'].includes(options.mode)) throw new Error('Unknown execution mode: ' + options.mode);
|
|
83
64
|
|
|
84
65
|
// ── Step 0: Language Selection ──
|
|
85
66
|
const pkgJson = require('../../../package.json');
|
|
@@ -151,6 +132,8 @@ module.exports = {
|
|
|
151
132
|
if (options.tools) {
|
|
152
133
|
if (options.tools === 'none' || options.tools === 'skip') {
|
|
153
134
|
detectedIDEs = [];
|
|
135
|
+
} else if (options.tools === 'all') {
|
|
136
|
+
detectedIDEs = Object.keys(IDE_CONFIGS);
|
|
154
137
|
} else {
|
|
155
138
|
detectedIDEs = options.tools.split(',').map(t => t.trim()).filter(t => IDE_CONFIGS[t]);
|
|
156
139
|
}
|
|
@@ -196,6 +179,7 @@ module.exports = {
|
|
|
196
179
|
// ── Step 3: User Config ──
|
|
197
180
|
let userName = process.env.USER || process.env.USERNAME || 'Developer';
|
|
198
181
|
let commLang = 'French';
|
|
182
|
+
let execMode = options.mode || 'manual';
|
|
199
183
|
|
|
200
184
|
if (!options.yes) {
|
|
201
185
|
const userConfig = await clack.group({
|
|
@@ -230,6 +214,37 @@ module.exports = {
|
|
|
230
214
|
for (const w of warnings) clack.log.warn(w);
|
|
231
215
|
userName = validatedName;
|
|
232
216
|
commLang = userConfig.commLang;
|
|
217
|
+
execMode = options.mode || userConfig.execMode;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const releaseLock = acquireProjectLock(projectDir);
|
|
221
|
+
try {
|
|
222
|
+
const { safeTarget } = require('../../build/generate-adapters');
|
|
223
|
+
const plannedFiles = collectManagedFiles({ projectDir, packs: selectedPacks });
|
|
224
|
+
if (detectedIDEs.length) plannedFiles.push(...generateUserFiles(DERIVED, {
|
|
225
|
+
packs: selectedPacks, tools: detectedIDEs, userName, language: commLang,
|
|
226
|
+
}));
|
|
227
|
+
for (const { file } of plannedFiles) safeTarget(projectDir, file);
|
|
228
|
+
safeTarget(projectDir, '_bmad/config.yaml');
|
|
229
|
+
safeTarget(projectDir, '_bmad/.bmad-plus-install.json');
|
|
230
|
+
for (const directory of ['.agents/skills', '.agents/data', '_bmad', '_bmad-output/discovery', '_bmad-output/build', 'docs']) {
|
|
231
|
+
safeTarget(projectDir, directory + '/.bmad-preflight');
|
|
232
|
+
}
|
|
233
|
+
const currentManifestBytes = fs.existsSync(previousManifestPath) ? fs.readFileSync(previousManifestPath) : null;
|
|
234
|
+
if (previousManifestBytes === null ? currentManifestBytes !== null :
|
|
235
|
+
currentManifestBytes === null || !previousManifestBytes.equals(currentManifestBytes)) {
|
|
236
|
+
throw new Error('Installation changed while preparing install. Please retry.');
|
|
237
|
+
}
|
|
238
|
+
// Preserve the unreadable manifest before repair; do not trust its ownership
|
|
239
|
+
// hashes. The adapter writer will retain locally edited user instructions.
|
|
240
|
+
if (manifestError) {
|
|
241
|
+
const backupBase = '_bmad/.bmad-plus-install.json.corrupt.bak';
|
|
242
|
+
let backupFile = backupBase;
|
|
243
|
+
let index = 1;
|
|
244
|
+
while (fs.existsSync(safeAdapterPath(projectDir, backupFile))) backupFile = `${backupBase}.${index++}`;
|
|
245
|
+
const backupPath = safeAdapterPath(projectDir, backupFile);
|
|
246
|
+
fs.copyFileSync(safeAdapterPath(projectDir, '_bmad/.bmad-plus-install.json'), backupPath, fs.constants.COPYFILE_EXCL);
|
|
247
|
+
clack.log.warn(`${i.manifest_invalid(manifestError.message)}\nBackup: ${backupPath}`);
|
|
233
248
|
}
|
|
234
249
|
|
|
235
250
|
// ── Step 4: Install Files ──
|
|
@@ -248,6 +263,13 @@ module.exports = {
|
|
|
248
263
|
let copiedAgents = 0;
|
|
249
264
|
let copiedSkills = 0;
|
|
250
265
|
let copiedFiles = 0;
|
|
266
|
+
const previousInventory = inventoryOf(previousManifest || {}) || {};
|
|
267
|
+
const fileInventory = { ...previousInventory };
|
|
268
|
+
let inventoryComplete = true;
|
|
269
|
+
const onFileConflict = file => {
|
|
270
|
+
if (!previousInventory[file]) inventoryComplete = false;
|
|
271
|
+
clack.log.warn(`${file}: locally changed or unowned content preserved.`);
|
|
272
|
+
};
|
|
251
273
|
|
|
252
274
|
const projectRoot = path.join(bmadSrc, '..', '..');
|
|
253
275
|
|
|
@@ -261,6 +283,7 @@ module.exports = {
|
|
|
261
283
|
targetDataDir,
|
|
262
284
|
projectRoot,
|
|
263
285
|
pack,
|
|
286
|
+
projectDir, inventory: fileInventory, previousInventory, onConflict: onFileConflict,
|
|
264
287
|
});
|
|
265
288
|
copiedAgents += result.copiedAgents;
|
|
266
289
|
copiedSkills += result.copiedSkills;
|
|
@@ -311,35 +334,39 @@ module.exports = {
|
|
|
311
334
|
// Copy module config
|
|
312
335
|
const moduleYaml = path.join(bmadSrc, 'module.yaml');
|
|
313
336
|
if (fs.existsSync(moduleYaml)) {
|
|
314
|
-
|
|
337
|
+
copyManagedFile({ source: moduleYaml, target: path.join(targetBmadDir, 'module.yaml'),
|
|
338
|
+
projectDir, inventory: fileInventory, previousInventory, onConflict: onFileConflict });
|
|
315
339
|
copiedFiles++;
|
|
316
340
|
}
|
|
317
341
|
|
|
318
342
|
const helpCsv = path.join(bmadSrc, 'module-help.csv');
|
|
319
343
|
if (fs.existsSync(helpCsv)) {
|
|
320
|
-
|
|
344
|
+
copyManagedFile({ source: helpCsv, target: path.join(targetBmadDir, 'module-help.csv'),
|
|
345
|
+
projectDir, inventory: fileInventory, previousInventory, onConflict: onFileConflict });
|
|
321
346
|
copiedFiles++;
|
|
322
347
|
}
|
|
323
348
|
|
|
324
349
|
spinner.stop(i.installed_summary(copiedAgents, copiedSkills, copiedFiles));
|
|
325
350
|
|
|
326
351
|
// ── Step 5: Generate IDE Configs ──
|
|
352
|
+
let adapterHashes = previousManifest?.adapterHashes || {};
|
|
327
353
|
if (detectedIDEs.length > 0) {
|
|
328
354
|
const ideSpinner = clack.spinner();
|
|
329
355
|
ideSpinner.start(i.configuring_ides);
|
|
330
356
|
|
|
331
|
-
writeIDEConfigs({
|
|
357
|
+
const outcome = writeIDEConfigs({
|
|
332
358
|
projectDir,
|
|
333
|
-
detectedIDEs,
|
|
334
|
-
|
|
359
|
+
files: generateUserFiles(DERIVED, { packs: selectedPacks, userName, language: commLang, tools: detectedIDEs }),
|
|
360
|
+
adapterHashes,
|
|
335
361
|
yes: options.yes,
|
|
336
362
|
});
|
|
363
|
+
adapterHashes = { ...adapterHashes, ...outcome.adapterHashes };
|
|
337
364
|
|
|
338
365
|
ideSpinner.stop(i.ide_configured(detectedIDEs.length));
|
|
339
366
|
}
|
|
340
367
|
|
|
341
368
|
// ── Step 6: Create config.yaml ──
|
|
342
|
-
const configYaml = generateConfigYaml(userName, commLang, projectDir);
|
|
369
|
+
const configYaml = generateConfigYaml(userName, commLang, projectDir, execMode);
|
|
343
370
|
const configPath = path.join(targetBmadDir, 'config.yaml');
|
|
344
371
|
fs.writeFileSync(configPath, configYaml, 'utf8');
|
|
345
372
|
|
|
@@ -357,6 +384,9 @@ module.exports = {
|
|
|
357
384
|
installed: new Date().toISOString(),
|
|
358
385
|
packs: selectedPacks,
|
|
359
386
|
ides: detectedIDEs,
|
|
387
|
+
adapterHashes,
|
|
388
|
+
fileInventory: { schemaVersion: 1, complete: inventoryComplete, files: fileInventory },
|
|
389
|
+
executionMode: execMode,
|
|
360
390
|
user: userName,
|
|
361
391
|
language: commLang,
|
|
362
392
|
};
|
|
@@ -484,115 +514,30 @@ module.exports = {
|
|
|
484
514
|
clack.note(agentGuide.join('\n'), i.guide_title);
|
|
485
515
|
|
|
486
516
|
clack.outro(pc.green(i.guide_ready));
|
|
517
|
+
} finally {
|
|
518
|
+
releaseLock();
|
|
519
|
+
}
|
|
487
520
|
},
|
|
488
521
|
// Exported for functional testing (not part of the public API)
|
|
489
|
-
_internal: {
|
|
490
|
-
INSTALLER_MARKER,
|
|
491
|
-
GENERATOR_MARKER_FALLBACK,
|
|
492
|
-
resolveGeneratorMarker,
|
|
493
|
-
writeIDEConfigs,
|
|
494
|
-
},
|
|
522
|
+
_internal: { writeIDEConfigs, generateConfigYaml },
|
|
495
523
|
};
|
|
496
524
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
*
|
|
516
|
-
* @param {object} args
|
|
517
|
-
* @param {string} args.projectDir - Absolute path to the target project
|
|
518
|
-
* @param {string[]} args.detectedIDEs - IDE ids to configure (keys of IDE_CONFIGS)
|
|
519
|
-
* @param {string} args.configContent - Rendered config body to write
|
|
520
|
-
* @param {boolean} [args.yes] - Non-interactive mode (--yes)
|
|
521
|
-
* @returns {{written: string[], skipped: string[], backedUp: string[]}} config file names per outcome
|
|
522
|
-
*/
|
|
523
|
-
function writeIDEConfigs({ projectDir, detectedIDEs, configContent, yes }) {
|
|
524
|
-
const generatorMarker = resolveGeneratorMarker();
|
|
525
|
-
// Several IDEs share one file (gemini-cli + antigravity → GEMINI.md): dedupe
|
|
526
|
-
// outcomes by file name so a shared file is reported — and warned about — once.
|
|
527
|
-
const outcome = { written: new Set(), skipped: new Set(), backedUp: new Set() };
|
|
528
|
-
|
|
529
|
-
for (const ideId of detectedIDEs) {
|
|
530
|
-
const ide = IDE_CONFIGS[ideId];
|
|
531
|
-
if (!ide) continue;
|
|
532
|
-
|
|
533
|
-
const configPath = path.join(projectDir, ide.configFile);
|
|
534
|
-
|
|
535
|
-
if (fs.existsSync(configPath)) {
|
|
536
|
-
const existing = fs.readFileSync(configPath, 'utf8');
|
|
537
|
-
|
|
538
|
-
// PROD-01: the adapter generator owns this file — defer to it.
|
|
539
|
-
if (existing.includes(generatorMarker)) {
|
|
540
|
-
if (!outcome.skipped.has(ide.configFile)) {
|
|
541
|
-
clack.log.warn(
|
|
542
|
-
`⚠️ ${ide.configFile} is generated by tools/build/generate-adapters.js — left untouched. ` +
|
|
543
|
-
`Run \`node tools/build/generate-adapters.js --adopt\` to refresh it from registry.yaml.`
|
|
544
|
-
);
|
|
545
|
-
}
|
|
546
|
-
outcome.skipped.add(ide.configFile);
|
|
547
|
-
continue;
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
// NODE-02: never clobber a hand-authored config without protecting it.
|
|
551
|
-
if (!existing.includes(INSTALLER_MARKER)) {
|
|
552
|
-
if (!yes) {
|
|
553
|
-
// Interactive: keep the user's file untouched.
|
|
554
|
-
if (!outcome.skipped.has(ide.configFile)) {
|
|
555
|
-
clack.log.warn(`⚠️ ${ide.configFile} already exists and was not created by BMAD+ — kept your file (skipped). Re-run with --yes to back it up and overwrite.`);
|
|
556
|
-
}
|
|
557
|
-
outcome.skipped.add(ide.configFile);
|
|
558
|
-
continue;
|
|
559
|
-
}
|
|
560
|
-
// Non-interactive: back up before overwriting, never destroy data.
|
|
561
|
-
if (!outcome.backedUp.has(ide.configFile)) {
|
|
562
|
-
let backupPath = `${configPath}.bak`;
|
|
563
|
-
if (fs.existsSync(backupPath)) backupPath = `${configPath}.${Date.now()}.bak`;
|
|
564
|
-
fs.copyFileSync(configPath, backupPath);
|
|
565
|
-
clack.log.warn(`⚠️ Backed up existing ${ide.configFile} → ${path.basename(backupPath)}`);
|
|
566
|
-
outcome.backedUp.add(ide.configFile);
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
fs.writeFileSync(configPath, configContent, 'utf8');
|
|
572
|
-
outcome.written.add(ide.configFile);
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
return {
|
|
576
|
-
written: [...outcome.written],
|
|
577
|
-
skipped: [...outcome.skipped],
|
|
578
|
-
backedUp: [...outcome.backedUp],
|
|
579
|
-
};
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
function generateConfigYaml(userName, language, projectDir) {
|
|
583
|
-
const projectName = path.basename(projectDir);
|
|
584
|
-
return `# BMAD+ Project Configuration
|
|
585
|
-
# Generated by bmad-plus install
|
|
586
|
-
|
|
587
|
-
user_name: "${userName}"
|
|
588
|
-
communication_language: "${language}"
|
|
589
|
-
document_output_language: "${language}"
|
|
590
|
-
output_folder: "_bmad-output"
|
|
591
|
-
project_name: "${projectName}"
|
|
592
|
-
|
|
593
|
-
# Execution settings
|
|
594
|
-
execution_mode: "manual"
|
|
595
|
-
auto_role_activation: true
|
|
596
|
-
parallel_execution: true
|
|
597
|
-
`;
|
|
525
|
+
function generateConfigYaml(userName, language, projectDir, execMode = 'manual') {
|
|
526
|
+
if (!['manual', 'autopilot', 'hybrid'].includes(execMode)) throw new Error('Unknown execution mode: ' + execMode);
|
|
527
|
+
return '# BMAD+ Project Configuration\n# Generated by bmad-plus install\n\n' + yaml.dump({
|
|
528
|
+
user_name: userName,
|
|
529
|
+
communication_language: language,
|
|
530
|
+
document_output_language: language,
|
|
531
|
+
output_folder: '_bmad-output',
|
|
532
|
+
project_name: path.basename(projectDir),
|
|
533
|
+
execution_mode: execMode,
|
|
534
|
+
auto_role_activation: true,
|
|
535
|
+
parallel_execution: true,
|
|
536
|
+
checkpoints: {
|
|
537
|
+
discovery: 'require_approval',
|
|
538
|
+
architecture: 'require_approval',
|
|
539
|
+
story: execMode === 'autopilot' ? 'notify_only' : 'require_approval',
|
|
540
|
+
delivery: 'require_approval',
|
|
541
|
+
},
|
|
542
|
+
}, { lineWidth: -1, forceQuotes: true, quotingType: '"' });
|
|
598
543
|
}
|
|
@@ -1,101 +1,209 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* BMAD+ Uninstall Command
|
|
3
|
-
* Removes BMAD+
|
|
4
|
-
* Internationalized — uses i18n system
|
|
5
|
-
*
|
|
3
|
+
* Removes identifiable BMAD+ files while preserving project memory and edits.
|
|
6
4
|
* Author: Laurent Rochetta
|
|
7
5
|
*/
|
|
8
6
|
|
|
9
7
|
const path = require('node:path');
|
|
10
8
|
const fs = require('node:fs');
|
|
11
|
-
const fsExtra = require('fs-extra');
|
|
12
9
|
const clack = require('@clack/prompts');
|
|
13
10
|
const pc = require('picocolors');
|
|
14
11
|
const { t } = require('../i18n');
|
|
12
|
+
const { PACKS, DERIVED } = require('../lib/packs');
|
|
13
|
+
const { readInstallManifest } = require('../lib/install-manifest');
|
|
14
|
+
const { generateUserFiles, USER_CONFIG_MARKER } = require('../../build/generate-adapters');
|
|
15
|
+
const { contentHash } = require('../lib/installed-adapters');
|
|
16
|
+
|
|
17
|
+
// Never follow a junction/symlink in a path used for deletion, including parents.
|
|
18
|
+
function safeStat(projectDir, target) {
|
|
19
|
+
const relative = path.relative(projectDir, target);
|
|
20
|
+
if (!relative || relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) {
|
|
21
|
+
throw new Error('Path outside the project: ' + target);
|
|
22
|
+
}
|
|
23
|
+
let current = projectDir;
|
|
24
|
+
let stat;
|
|
25
|
+
for (const part of relative.split(path.sep)) {
|
|
26
|
+
current = path.join(current, part);
|
|
27
|
+
try {
|
|
28
|
+
stat = fs.lstatSync(current);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (err.code === 'ENOENT') return undefined;
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
if (stat.isSymbolicLink()) {
|
|
34
|
+
const error = new Error('Symbolic link or junction: ' + current);
|
|
35
|
+
error.code = 'BMAD_UNSAFE_LINK';
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return stat;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function removalPlan(projectDir, manifest, manifestPath) {
|
|
43
|
+
const packageRoot = path.join(__dirname, '..', '..', '..');
|
|
44
|
+
const sourceRoot = path.join(packageRoot, 'src', 'bmad-plus');
|
|
45
|
+
const files = new Map();
|
|
46
|
+
const directories = new Set();
|
|
47
|
+
const targetPath = (...parts) => path.join(projectDir, ...parts);
|
|
48
|
+
|
|
49
|
+
// Old manifests contain pack IDs, not file hashes. Match shipped files byte for
|
|
50
|
+
// byte; anything unknown or modified stays, including files inside a pack.
|
|
51
|
+
function matchSource(source, target) {
|
|
52
|
+
const targetStat = safeStat(projectDir, target);
|
|
53
|
+
if (!targetStat || !fs.existsSync(source)) return;
|
|
54
|
+
const sourceStat = fs.lstatSync(source);
|
|
55
|
+
if (sourceStat.isDirectory() && targetStat.isDirectory()) {
|
|
56
|
+
directories.add(target);
|
|
57
|
+
for (const name of fs.readdirSync(source)) matchSource(path.join(source, name), path.join(target, name));
|
|
58
|
+
} else if (sourceStat.isFile() && targetStat.isFile()) {
|
|
59
|
+
const expected = fs.readFileSync(source);
|
|
60
|
+
if (fs.readFileSync(target).equals(expected)) files.set(target, expected);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const relative of ['.agents', '.agents/skills', '.agents/data', '_bmad', '_bmad-output']) {
|
|
65
|
+
const target = targetPath(relative);
|
|
66
|
+
if (safeStat(projectDir, target)?.isDirectory()) directories.add(target);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const packId of manifest.packs) {
|
|
70
|
+
if (!Object.hasOwn(PACKS, packId)) throw new Error('Unknown installed pack: ' + packId);
|
|
71
|
+
const pack = PACKS[packId];
|
|
72
|
+
for (const name of pack.agents || []) matchSource(path.join(sourceRoot, 'agents', name), targetPath('.agents', 'skills', name));
|
|
73
|
+
for (const name of pack.skills || []) matchSource(path.join(sourceRoot, 'skills', name), targetPath('.agents', 'skills', name));
|
|
74
|
+
for (const name of pack.data || []) matchSource(path.join(sourceRoot, 'data', name), targetPath('.agents', 'data', name));
|
|
75
|
+
if (pack.packDir) matchSource(path.join(sourceRoot, pack.packSrcDir || 'agents', pack.packDir), targetPath('.agents', 'skills', pack.packDir));
|
|
76
|
+
if (pack.externalPackage) matchSource(path.join(packageRoot, pack.externalPackage, 'skills'), targetPath('.agents', 'skills'));
|
|
77
|
+
}
|
|
78
|
+
for (const name of ['module.yaml', 'module-help.csv']) matchSource(path.join(sourceRoot, name), targetPath('_bmad', name));
|
|
79
|
+
const renderOptions = {
|
|
80
|
+
packs: manifest.packs,
|
|
81
|
+
userName: typeof manifest.user === 'string' ? manifest.user : 'Developer',
|
|
82
|
+
language: typeof manifest.language === 'string' ? manifest.language : 'English',
|
|
83
|
+
};
|
|
84
|
+
const expectedFiles = new Map(generateUserFiles(DERIVED, renderOptions).map(entry => [entry.file, entry.content]));
|
|
85
|
+
const hashes = manifest.adapterHashes && typeof manifest.adapterHashes === 'object' && !Array.isArray(manifest.adapterHashes)
|
|
86
|
+
? manifest.adapterHashes : {};
|
|
87
|
+
// Only registry-declared paths can become deletion candidates, even when the
|
|
88
|
+
// manifest contains arbitrary hash keys. Keep previously owned tool targets.
|
|
89
|
+
const claimed = new Set(Object.keys(hashes).filter(file => expectedFiles.has(file)));
|
|
90
|
+
if (Array.isArray(manifest.ides)) {
|
|
91
|
+
const tools = manifest.ides.filter(id => DERIVED.targets.adapters.some(adapter => adapter.tool === id));
|
|
92
|
+
if (tools.length) {
|
|
93
|
+
for (const entry of generateUserFiles(DERIVED, { ...renderOptions, tools })) {
|
|
94
|
+
claimed.add(entry.file);
|
|
95
|
+
expectedFiles.set(entry.file, entry.content);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const legacyFallback = !Array.isArray(manifest.ides) && Object.keys(hashes).length === 0;
|
|
100
|
+
for (const [file, rendered] of expectedFiles) {
|
|
101
|
+
if (!legacyFallback && !claimed.has(file)) continue;
|
|
102
|
+
const target = targetPath(file);
|
|
103
|
+
let stat;
|
|
104
|
+
try {
|
|
105
|
+
stat = safeStat(projectDir, target);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
// A link with no ownership evidence is user content, not an uninstall target.
|
|
108
|
+
if (legacyFallback && error.code === 'BMAD_UNSAFE_LINK') continue;
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
if (stat?.isFile()) {
|
|
112
|
+
const content = fs.readFileSync(target);
|
|
113
|
+
const text = content.toString('utf8');
|
|
114
|
+
const expectedHash = hashes[file];
|
|
115
|
+
const matches = expectedHash ? contentHash(text) === expectedHash : text === rendered;
|
|
116
|
+
if (text.includes(USER_CONFIG_MARKER) && matches) {
|
|
117
|
+
files.set(target, content);
|
|
118
|
+
let parent = path.dirname(target);
|
|
119
|
+
while (parent !== projectDir) {
|
|
120
|
+
directories.add(parent);
|
|
121
|
+
parent = path.dirname(parent);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// Keep config.yaml: it contains the user's settings and has no ownership hash.
|
|
127
|
+
files.set(manifestPath, fs.readFileSync(manifestPath));
|
|
128
|
+
return { files, directories };
|
|
129
|
+
}
|
|
15
130
|
|
|
16
131
|
module.exports = {
|
|
17
132
|
command: 'uninstall',
|
|
18
133
|
description: 'Remove BMAD+ from your project',
|
|
19
134
|
options: [
|
|
135
|
+
['-y, --yes', 'Confirm removal without prompting'],
|
|
20
136
|
['-l, --lang <code>', 'Language code: en, fr, es, de, pt-br, ru, zh, he, ja, it'],
|
|
21
137
|
],
|
|
22
138
|
action: async (options) => {
|
|
23
|
-
const projectDir = process.cwd();
|
|
24
|
-
|
|
139
|
+
const projectDir = fs.realpathSync(process.cwd());
|
|
140
|
+
let i = t(options.lang || 'en');
|
|
25
141
|
clack.intro(pc.bgRed(pc.white(' BMAD+ Uninstaller ')));
|
|
26
142
|
|
|
27
|
-
// Check if installed
|
|
28
143
|
const manifestPath = path.join(projectDir, '_bmad', '.bmad-plus-install.json');
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
144
|
+
let manifest;
|
|
145
|
+
try {
|
|
146
|
+
if (!safeStat(projectDir, manifestPath)) {
|
|
147
|
+
clack.log.warn(i.uninstall_not_installed);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
manifest = readInstallManifest(manifestPath);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
clack.log.error(i.manifest_invalid(err.message));
|
|
153
|
+
clack.outro(pc.red(i.manifest_repair));
|
|
154
|
+
process.exitCode = 1;
|
|
32
155
|
return;
|
|
33
156
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
let lang = options.lang || manifest.uiLanguage || 'en';
|
|
39
|
-
const i = t(lang);
|
|
40
|
-
|
|
41
|
-
clack.log.info(`BMAD+ v${manifest.version} (${i.installed_on || 'installed'} ${manifest.installed.split('T')[0]})`);
|
|
42
|
-
clack.log.info(`${i.selected_packs}: ${(manifest.packs || ['core']).join(', ')}`);
|
|
43
|
-
|
|
44
|
-
const confirm = await clack.confirm({
|
|
45
|
-
message: i.uninstall_confirm || 'Remove BMAD+ from this project?',
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
if (!confirm || clack.isCancel(confirm)) {
|
|
49
|
-
clack.cancel(i.cancelled);
|
|
157
|
+
i = t(options.lang || manifest.uiLanguage || 'en');
|
|
158
|
+
if (!options.yes && !process.stdin.isTTY) {
|
|
159
|
+
clack.log.error(i.noninteractive_requires_yes('uninstall'));
|
|
160
|
+
process.exitCode = 1;
|
|
50
161
|
return;
|
|
51
162
|
}
|
|
52
163
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (fs.existsSync(agentsDir)) {
|
|
61
|
-
fsExtra.removeSync(agentsDir);
|
|
62
|
-
removed++;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Remove _bmad/ config
|
|
66
|
-
const bmadDir = path.join(projectDir, '_bmad');
|
|
67
|
-
if (fs.existsSync(bmadDir)) {
|
|
68
|
-
fsExtra.removeSync(bmadDir);
|
|
69
|
-
removed++;
|
|
164
|
+
let plan;
|
|
165
|
+
try {
|
|
166
|
+
plan = removalPlan(projectDir, manifest, manifestPath);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
clack.log.error(i.uninstall_aborted(err.message));
|
|
169
|
+
process.exitCode = 1;
|
|
170
|
+
return;
|
|
70
171
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
removed++;
|
|
79
|
-
} else {
|
|
80
|
-
clack.log.info(i.uninstall_output_kept || '📁 _bmad-output/ kept (contains files)');
|
|
172
|
+
clack.log.info('BMAD+ v' + manifest.version + ' (' + i.installed_on + ' ' + manifest.installed.split('T')[0] + ')');
|
|
173
|
+
clack.log.info(i.selected_packs + ': ' + manifest.packs.join(', '));
|
|
174
|
+
if (!options.yes) {
|
|
175
|
+
const confirm = await clack.confirm({ message: i.uninstall_confirm });
|
|
176
|
+
if (!confirm || clack.isCancel(confirm)) {
|
|
177
|
+
clack.cancel(i.cancelled);
|
|
178
|
+
return;
|
|
81
179
|
}
|
|
82
180
|
}
|
|
83
181
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (
|
|
92
|
-
fs.unlinkSync(
|
|
182
|
+
const spinner = clack.spinner();
|
|
183
|
+
spinner.start(i.uninstall_removing);
|
|
184
|
+
let removed = 0;
|
|
185
|
+
try {
|
|
186
|
+
// Revalidate after the prompt, before deleting any file.
|
|
187
|
+
for (const target of [...plan.files.keys(), ...plan.directories]) safeStat(projectDir, target);
|
|
188
|
+
for (const [target, expected] of plan.files) {
|
|
189
|
+
if (safeStat(projectDir, target)?.isFile() && fs.readFileSync(target).equals(expected)) {
|
|
190
|
+
fs.unlinkSync(target);
|
|
93
191
|
removed++;
|
|
94
192
|
}
|
|
95
193
|
}
|
|
194
|
+
// No recursive deletion: hidden files, nested user content, and memory stay.
|
|
195
|
+
for (const target of [...plan.directories].sort((a, b) => b.length - a.length)) {
|
|
196
|
+
if (safeStat(projectDir, target)?.isDirectory() && fs.readdirSync(target).length === 0) {
|
|
197
|
+
fs.rmdirSync(target);
|
|
198
|
+
removed++;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch (err) {
|
|
202
|
+
spinner.stop(i.uninstall_aborted(err.message));
|
|
203
|
+
process.exitCode = 1;
|
|
204
|
+
return;
|
|
96
205
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
clack.outro(pc.green(i.guide_ready ? '👋 Done!' : 'Done!'));
|
|
206
|
+
spinner.stop(i.uninstall_done(removed));
|
|
207
|
+
clack.outro(i.uninstall_preserved);
|
|
100
208
|
},
|
|
101
209
|
};
|