bmad-plus 0.12.2 → 0.14.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 +47 -0
- package/README.md +96 -461
- package/package.json +12 -5
- package/readme-international/README.de.md +42 -9
- package/readme-international/README.es.md +43 -10
- package/readme-international/README.fr.md +42 -9
- package/src/bmad-plus/agents/agent-orchestrator/SKILL.md +5 -3
- package/src/bmad-plus/module.yaml +270 -220
- package/src/bmad-plus/packs/pack-dev-studio/README.md +133 -141
- package/src/bmad-plus/packs/pack-dev-studio/SKILL.md +49 -0
- package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/analyst-agent.md +35 -60
- package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/document-project.md +59 -59
- package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/domain-research.md +55 -93
- package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/market-research.md +58 -93
- package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/prfaq.md +55 -132
- package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/product-brief.md +63 -78
- package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/tech-writer-agent.md +54 -69
- package/src/bmad-plus/packs/pack-dev-studio/categories/analysis/technical-research.md +54 -93
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/architect-agent.md +32 -60
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/create-architecture.md +67 -71
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/create-epics-stories.md +61 -90
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/generate-project-context.md +56 -78
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/implementation-readiness.md +55 -88
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-01-init.md +20 -153
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-01b-continue.md +20 -173
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-02-context.md +14 -220
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-03-starter.md +20 -329
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-04-decisions.md +15 -314
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-05-patterns.md +15 -355
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-06-structure.md +15 -375
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-07-validation.md +14 -357
- package/src/bmad-plus/packs/pack-dev-studio/categories/architecture/steps/step-08-complete.md +13 -78
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/checkpoint-preview.md +52 -65
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review-steps/step-01-gather-context.md +14 -81
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review-steps/step-02-review.md +14 -31
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review-steps/step-03-triage.md +14 -45
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review-steps/step-04-present.md +13 -128
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review.md +61 -87
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/correct-course.md +55 -298
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/create-story.md +52 -426
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/dev-agent.md +48 -69
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/dev-story-checklist.md +22 -80
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/dev-story.md +60 -482
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/investigate.md +50 -184
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/qa-e2e-tests.md +57 -173
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/quick-dev.md +56 -108
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/retrospective.md +54 -1509
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/sprint-planning.md +54 -296
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/sprint-status.md +41 -283
- package/src/bmad-plus/packs/pack-dev-studio/categories/planning/create-prd.md +58 -18
- package/src/bmad-plus/packs/pack-dev-studio/categories/planning/create-ux-design.md +103 -72
- package/src/bmad-plus/packs/pack-dev-studio/categories/planning/edit-prd.md +55 -27
- package/src/bmad-plus/packs/pack-dev-studio/categories/planning/pm-agent.md +34 -60
- package/src/bmad-plus/packs/pack-dev-studio/categories/planning/prd.md +46 -87
- package/src/bmad-plus/packs/pack-dev-studio/categories/planning/steps/step-01-init.md +10 -0
- package/src/bmad-plus/packs/pack-dev-studio/categories/planning/ux-designer-agent.md +30 -60
- package/src/bmad-plus/packs/pack-dev-studio/categories/planning/validate-prd.md +57 -27
- package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/advanced-elicitation.md +47 -138
- package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/adversarial-review.md +48 -34
- package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/bmad-help.md +51 -68
- package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/brainstorming.md +46 -3
- package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/customize.md +68 -109
- package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/distillator.md +53 -174
- package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/edge-case-hunter.md +39 -53
- package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/editorial-review-prose.md +45 -83
- package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/editorial-review-structure.md +45 -176
- package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/index-docs.md +45 -63
- package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/party-mode.md +53 -124
- package/src/bmad-plus/packs/pack-dev-studio/categories/utilities/shard-doc.md +44 -100
- package/src/bmad-plus/packs/pack-dev-studio/dev-studio-orchestrator.md +56 -116
- package/src/bmad-plus/packs/pack-dev-studio/shared/architecture-decision-template.md +49 -12
- package/src/bmad-plus/packs/pack-dev-studio/shared/bwml-spec.md +51 -328
- package/src/bmad-plus/packs/pack-dev-studio/shared/catalog.json +489 -0
- package/src/bmad-plus/packs/pack-dev-studio/shared/execution.md +69 -0
- package/src/bmad-plus/packs/pack-dev-studio/shared/module-help.csv +39 -32
- package/src/bmad-plus/packs/pack-dev-studio/upstream-sync.yaml +85 -14
- package/src/bmad-plus/packs/pack-memory/README.md +6 -0
- package/src/bmad-plus/packs/pack-memory/memory-orchestrator.md +12 -5
- package/src/bmad-plus/packs/pack-seo/SKILL.md +27 -1
- 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/packs/pack-seo/seo-chief.md +16 -1
- package/src/bmad-plus/packs/pack-seo/seo-judge.md +12 -0
- package/src/bmad-plus/packs/pack-seo/seo-scout.md +12 -0
- package/src/bmad-plus/skills/bmad-plus-autopilot/SKILL.md +4 -4
- package/src/bmad-plus/skills/bmad-plus-sync/SKILL.md +5 -3
- package/src/bmad-plus/skills/dev-studio/SKILL.md +19 -0
- 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 +450 -0
- package/tools/build/generate-adapters.js +252 -56
- package/tools/build/generate.js +364 -14
- 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/build/pack-delivery.js +78 -0
- package/tools/cli/bmad-plus-cli.js +16 -1
- package/tools/cli/commands/doctor.js +49 -217
- package/tools/cli/commands/install.js +109 -164
- package/tools/cli/commands/studio.js +68 -0
- 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/installation-health.js +360 -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 +546 -3
- package/tools/cli/lib/python-health.js +233 -0
- package/tools/cli/lib/python-provision.js +2 -2
- package/tools/cli/lib/studio.js +310 -0
- 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
- package/tools/release/publication-content.js +901 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/** Planned, locked framework updates with exact ownership and recoverable backups. */
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
const semver = require('semver');
|
|
6
|
+
const { PACKS, DERIVED } = require('./packs');
|
|
7
|
+
const { listPackFiles, fileHash } = require('./pack-copy');
|
|
8
|
+
const { contentHash, classifyAdapter, INSTALLER_MARKER } = require('./installed-adapters');
|
|
9
|
+
const { generateUserFiles, IDE_CONFIGS, GENERATED_MARKER, USER_CONFIG_MARKER, safeTarget } = require('../../build/generate-adapters');
|
|
10
|
+
const { readInstallManifest } = require('./install-manifest');
|
|
11
|
+
const { isExactVersion } = require('./update-policy');
|
|
12
|
+
|
|
13
|
+
const MANIFEST = '_bmad/.bmad-plus-install.json';
|
|
14
|
+
const LOCK = '.bmad/update.lock';
|
|
15
|
+
const MODULE_FILES = ['_bmad/module.yaml', '_bmad/module-help.csv'];
|
|
16
|
+
const DEFAULT_PACKAGE_ROOT = path.resolve(__dirname, '../../..');
|
|
17
|
+
const relativeFile = (root, target) => path.relative(root, target).split(path.sep).join('/');
|
|
18
|
+
const currentBytes = (root, file) => {
|
|
19
|
+
const target = safeTarget(root, file);
|
|
20
|
+
return fs.existsSync(target) ? fs.readFileSync(target) : null;
|
|
21
|
+
};
|
|
22
|
+
const currentHash = (root, file) => {
|
|
23
|
+
const content = currentBytes(root, file);
|
|
24
|
+
return content === null ? null : fileHash(content);
|
|
25
|
+
};
|
|
26
|
+
const isManagedFile = file => typeof file === 'string' && !file.includes('\\') &&
|
|
27
|
+
path.posix.normalize(file) === file && (MODULE_FILES.includes(file) ||
|
|
28
|
+
file.startsWith('.agents/skills/') || file.startsWith('.agents/data/'));
|
|
29
|
+
|
|
30
|
+
function inventoryOf(manifest) {
|
|
31
|
+
const inventory = manifest.fileInventory;
|
|
32
|
+
if (!inventory || inventory.schemaVersion !== 1 || !inventory.files ||
|
|
33
|
+
typeof inventory.files !== 'object' || Array.isArray(inventory.files)) return null;
|
|
34
|
+
for (const [file, hash] of Object.entries(inventory.files)) {
|
|
35
|
+
if (!isManagedFile(file) || !/^[a-f0-9]{64}$/.test(hash)) return null;
|
|
36
|
+
}
|
|
37
|
+
return inventory.files;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hasCompleteInventory(manifest, inventory) {
|
|
41
|
+
return Boolean(inventory && manifest.fileInventory.complete === true && MODULE_FILES.every(file => typeof inventory[file] === 'string'));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function collectManagedFiles({ projectDir, packageRoot = DEFAULT_PACKAGE_ROOT, packs }) {
|
|
45
|
+
const files = new Map();
|
|
46
|
+
const bmadSrc = path.join(packageRoot, 'src', 'bmad-plus');
|
|
47
|
+
for (const id of packs) {
|
|
48
|
+
if (!Object.hasOwn(PACKS, id)) throw new Error('Unknown installed pack: ' + id);
|
|
49
|
+
const entries = listPackFiles({ bmadSrc, projectRoot: packageRoot,
|
|
50
|
+
targetAgentsDir: path.join(projectDir, '.agents', 'skills'),
|
|
51
|
+
targetDataDir: path.join(projectDir, '.agents', 'data'), pack: PACKS[id] });
|
|
52
|
+
for (const entry of entries.files) files.set(relativeFile(projectDir, entry.target), entry.source);
|
|
53
|
+
}
|
|
54
|
+
for (const file of MODULE_FILES) files.set(file, path.join(bmadSrc, path.basename(file)));
|
|
55
|
+
return [...files].map(([file, source]) => {
|
|
56
|
+
safeTarget(packageRoot, relativeFile(packageRoot, source));
|
|
57
|
+
return { file, content: fs.readFileSync(source), kind: 'pack' };
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function installedTools(projectDir, manifest) {
|
|
62
|
+
if (Array.isArray(manifest.ides)) return manifest.ides.filter(id => Object.hasOwn(IDE_CONFIGS, id));
|
|
63
|
+
const tools = Object.keys(IDE_CONFIGS).filter(id => fs.existsSync(safeTarget(projectDir, IDE_CONFIGS[id].configFile)));
|
|
64
|
+
const spine = currentBytes(projectDir, DERIVED.targets.spine)?.toString('utf8');
|
|
65
|
+
if (spine?.includes(INSTALLER_MARKER) && !spine.includes(GENERATED_MARKER) && !tools.includes('codex-cli')) tools.push('codex-cli');
|
|
66
|
+
return tools;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function evaluateUpdateReadiness({ projectDir, manifest }) {
|
|
70
|
+
try {
|
|
71
|
+
manifest ||= readInstallManifest(safeTarget(projectDir, MANIFEST));
|
|
72
|
+
const inventory = inventoryOf(manifest);
|
|
73
|
+
if (!hasCompleteInventory(manifest, inventory)) return { ready: false, legacy: true, conflicts: [], reason: 'Complete file ownership inventory is required.' };
|
|
74
|
+
const conflicts = [];
|
|
75
|
+
if (fs.existsSync(safeTarget(projectDir, LOCK))) conflicts.push({ file: LOCK, reason: 'update-in-progress' });
|
|
76
|
+
for (const [file, hash] of Object.entries(inventory)) {
|
|
77
|
+
const current = currentHash(projectDir, file);
|
|
78
|
+
if (current !== null && current !== hash) conflicts.push({ file, reason: 'modified-managed-file' });
|
|
79
|
+
}
|
|
80
|
+
const tools = installedTools(projectDir, manifest);
|
|
81
|
+
const expected = tools.length ? generateUserFiles(DERIVED, { packs: manifest.packs, tools }) : [];
|
|
82
|
+
for (const { file } of expected) {
|
|
83
|
+
const bytes = currentBytes(projectDir, file);
|
|
84
|
+
if (bytes !== null && (!bytes.toString('utf8').includes(USER_CONFIG_MARKER) ||
|
|
85
|
+
contentHash(bytes.toString('utf8')) !== manifest.adapterHashes?.[file])) {
|
|
86
|
+
conflicts.push({ file, reason: 'unowned-or-modified-adapter' });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { ready: conflicts.length === 0, legacy: false, conflicts,
|
|
90
|
+
reason: conflicts.length ? 'Local changes or an active update require manual review.' : 'Managed files are unchanged.' };
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return { ready: false, legacy: false, conflicts: [], reason: error.message };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Read-only plan: every destination is checked before the first mutation. */
|
|
97
|
+
function planUpdate({ projectDir, packageRoot = DEFAULT_PACKAGE_ROOT, manifest, version, auto = false }) {
|
|
98
|
+
projectDir = path.resolve(projectDir);
|
|
99
|
+
const snapshot = currentBytes(projectDir, MANIFEST);
|
|
100
|
+
const live = readInstallManifest(safeTarget(projectDir, MANIFEST));
|
|
101
|
+
if (manifest && JSON.stringify(live) !== JSON.stringify(manifest)) throw new Error('Installation manifest changed; check again.');
|
|
102
|
+
manifest = live;
|
|
103
|
+
if (!isExactVersion(version) || !isExactVersion(manifest.version)) throw new Error('Update versions must be strict semantic versions.');
|
|
104
|
+
if (semver.lt(version, manifest.version)) throw new Error('Downgrade refused: ' + manifest.version + ' -> ' + version);
|
|
105
|
+
const owned = inventoryOf(manifest);
|
|
106
|
+
let legacy = !hasCompleteInventory(manifest, owned);
|
|
107
|
+
const nextHashes = { ...(owned || {}) };
|
|
108
|
+
const nextAdapterHashes = { ...(manifest.adapterHashes || {}) };
|
|
109
|
+
const tools = installedTools(projectDir, manifest);
|
|
110
|
+
const expected = collectManagedFiles({ projectDir, packageRoot, packs: manifest.packs });
|
|
111
|
+
if (tools.length) expected.push(...generateUserFiles(DERIVED, {
|
|
112
|
+
packs: manifest.packs, tools, userName: manifest.user || 'Developer', language: manifest.language || 'English',
|
|
113
|
+
}).map(entry => ({ file: entry.file, content: Buffer.from(entry.content), kind: 'adapter' })));
|
|
114
|
+
const observed = new Map([[MANIFEST, fileHash(snapshot)]]);
|
|
115
|
+
const files = [];
|
|
116
|
+
const conflicts = [];
|
|
117
|
+
for (const [file, hash] of Object.entries(owned || {})) {
|
|
118
|
+
const current = currentHash(projectDir, file);
|
|
119
|
+
observed.set(file, current);
|
|
120
|
+
if (!expected.some(entry => entry.file === file) && current !== null && current !== hash) {
|
|
121
|
+
conflicts.push({ file, reason: 'modified-managed-file' });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const entry of expected) {
|
|
125
|
+
const before = currentBytes(projectDir, entry.file);
|
|
126
|
+
const beforeHash = before === null ? null : fileHash(before);
|
|
127
|
+
observed.set(entry.file, beforeHash);
|
|
128
|
+
const afterHash = fileHash(entry.content);
|
|
129
|
+
let backupLegacy = false;
|
|
130
|
+
if (entry.kind === 'adapter') {
|
|
131
|
+
const decision = classifyAdapter({ existing: before?.toString('utf8'), content: entry.content.toString('utf8'),
|
|
132
|
+
previousHash: manifest.adapterHashes?.[entry.file], update: true });
|
|
133
|
+
if (decision.action === 'preserve') {
|
|
134
|
+
conflicts.push({ file: entry.file, reason: decision.reason });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
backupLegacy = decision.backup;
|
|
138
|
+
if (decision.legacy) legacy = true;
|
|
139
|
+
nextAdapterHashes[entry.file] = contentHash(entry.content.toString('utf8'));
|
|
140
|
+
} else {
|
|
141
|
+
if (before !== null && owned?.[entry.file] && beforeHash !== owned[entry.file]) {
|
|
142
|
+
conflicts.push({ file: entry.file, reason: 'modified-managed-file' });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (before !== null && !owned?.[entry.file] && beforeHash !== afterHash) {
|
|
146
|
+
if (owned) {
|
|
147
|
+
conflicts.push({ file: entry.file, reason: 'unowned-file' });
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
legacy = true;
|
|
151
|
+
}
|
|
152
|
+
nextHashes[entry.file] = afterHash;
|
|
153
|
+
}
|
|
154
|
+
if (beforeHash !== afterHash) files.push({ ...entry, beforeHash, afterHash, backupLegacy });
|
|
155
|
+
}
|
|
156
|
+
const nextManifest = { ...manifest, version, lastUpdated: new Date().toISOString(), ides: tools,
|
|
157
|
+
adapterHashes: nextAdapterHashes, fileInventory: { schemaVersion: 1,
|
|
158
|
+
complete: !conflicts.some(entry => entry.reason === 'unowned-file'), files: nextHashes } };
|
|
159
|
+
const manifestContent = Buffer.from(JSON.stringify(nextManifest, null, 2));
|
|
160
|
+
files.push({ file: MANIFEST, kind: 'manifest', content: manifestContent,
|
|
161
|
+
beforeHash: fileHash(snapshot), afterHash: fileHash(manifestContent) });
|
|
162
|
+
for (const { file } of files) safeTarget(projectDir, file);
|
|
163
|
+
if (auto && (legacy || conflicts.length)) throw new Error('Automatic update refused: ' + (legacy ? 'legacy ownership inventory' : 'local file conflicts'));
|
|
164
|
+
return { projectDir, fromVersion: manifest.version, toVersion: version, files, conflicts, legacy,
|
|
165
|
+
inventoryComplete: !legacy, nextManifest, observed: [...observed], auto };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function acquireProjectLock(projectDir, { receiptId = null } = {}) {
|
|
169
|
+
const target = safeTarget(projectDir, LOCK);
|
|
170
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
171
|
+
const id = crypto.randomUUID();
|
|
172
|
+
let fd;
|
|
173
|
+
try { fd = fs.openSync(target, 'wx'); } catch (error) {
|
|
174
|
+
if (error.code === 'EEXIST') throw new Error('Another update is in progress: ' + target, { cause: error });
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
try { fs.writeFileSync(fd, JSON.stringify({ id, pid: process.pid, receiptId })); } catch (error) {
|
|
178
|
+
fs.closeSync(fd);
|
|
179
|
+
fs.unlinkSync(target);
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
fs.closeSync(fd);
|
|
183
|
+
return () => {
|
|
184
|
+
if (fs.existsSync(safeTarget(projectDir, LOCK)) && JSON.parse(fs.readFileSync(target, 'utf8')).id === id) fs.unlinkSync(target);
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function atomicWrite(projectDir, file, content) {
|
|
189
|
+
const target = safeTarget(projectDir, file);
|
|
190
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
191
|
+
const temporaryFile = file + '.update-' + crypto.randomUUID() + '.tmp';
|
|
192
|
+
const temporary = safeTarget(projectDir, temporaryFile);
|
|
193
|
+
try {
|
|
194
|
+
fs.writeFileSync(temporary, content, { flag: 'wx' });
|
|
195
|
+
safeTarget(projectDir, file);
|
|
196
|
+
fs.renameSync(temporary, target);
|
|
197
|
+
} finally {
|
|
198
|
+
if (fs.existsSync(safeTarget(projectDir, temporaryFile))) fs.unlinkSync(temporary);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function rollback(projectDir, receipt) {
|
|
203
|
+
const failures = [];
|
|
204
|
+
// Restore the manifest last, after restoring the content it describes.
|
|
205
|
+
const entries = [...receipt.entries.filter(entry => entry.file !== MANIFEST).reverse(),
|
|
206
|
+
...receipt.entries.filter(entry => entry.file === MANIFEST)];
|
|
207
|
+
for (const entry of entries) {
|
|
208
|
+
try {
|
|
209
|
+
const current = currentHash(projectDir, entry.file);
|
|
210
|
+
if (current === entry.beforeHash) continue;
|
|
211
|
+
if (current !== entry.afterHash && current !== null) throw new Error('File changed since the update; retained');
|
|
212
|
+
if (entry.beforeHash === null) {
|
|
213
|
+
if (current !== null) fs.unlinkSync(safeTarget(projectDir, entry.file));
|
|
214
|
+
} else {
|
|
215
|
+
if (entry.file === MANIFEST && failures.length) throw new Error('Content recovery is incomplete; manifest retained');
|
|
216
|
+
const backup = currentBytes(projectDir, entry.backup);
|
|
217
|
+
if (!backup || fileHash(backup) !== entry.beforeHash) throw new Error('Backup is missing or does not match its recorded hash');
|
|
218
|
+
atomicWrite(projectDir, entry.file, backup);
|
|
219
|
+
}
|
|
220
|
+
} catch (error) { failures.push({ file: entry.file, reason: error.message }); }
|
|
221
|
+
}
|
|
222
|
+
return failures;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function applyPlan(plan, { reauthorize } = {}) {
|
|
226
|
+
const { projectDir } = plan;
|
|
227
|
+
const id = crypto.randomUUID();
|
|
228
|
+
const release = acquireProjectLock(projectDir, { receiptId: id });
|
|
229
|
+
const receiptFile = '.bmad/updates/' + id + '/receipt.json';
|
|
230
|
+
let receipt;
|
|
231
|
+
try {
|
|
232
|
+
if (plan.auto) {
|
|
233
|
+
if (typeof reauthorize !== 'function') throw new Error('Automatic update requires policy reauthorization under the project lock.');
|
|
234
|
+
reauthorize();
|
|
235
|
+
}
|
|
236
|
+
for (const [file, hash] of plan.observed) {
|
|
237
|
+
if (currentHash(projectDir, file) !== hash) throw new Error('File changed after preflight: ' + file);
|
|
238
|
+
}
|
|
239
|
+
receipt = { schemaVersion: 1, id, status: 'prepared', fromVersion: plan.fromVersion, toVersion: plan.toVersion,
|
|
240
|
+
createdAt: new Date().toISOString(), conflicts: plan.conflicts, entries: [] };
|
|
241
|
+
for (const [index, entry] of plan.files.entries()) {
|
|
242
|
+
let backup = null;
|
|
243
|
+
if (entry.beforeHash !== null) {
|
|
244
|
+
backup = '.bmad/updates/' + id + '/backups/' + index;
|
|
245
|
+
const target = safeTarget(projectDir, backup);
|
|
246
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
247
|
+
fs.copyFileSync(safeTarget(projectDir, entry.file), target, fs.constants.COPYFILE_EXCL);
|
|
248
|
+
if (fileHash(fs.readFileSync(target)) !== entry.beforeHash) throw new Error('File changed while backing up: ' + entry.file);
|
|
249
|
+
}
|
|
250
|
+
if (entry.backupLegacy) {
|
|
251
|
+
let legacyBackup = entry.file + '.bak';
|
|
252
|
+
let index = 1;
|
|
253
|
+
while (fs.existsSync(safeTarget(projectDir, legacyBackup))) legacyBackup = entry.file + '.bak.' + index++;
|
|
254
|
+
fs.copyFileSync(safeTarget(projectDir, entry.file), safeTarget(projectDir, legacyBackup), fs.constants.COPYFILE_EXCL);
|
|
255
|
+
}
|
|
256
|
+
receipt.entries.push({ file: entry.file, beforeHash: entry.beforeHash, afterHash: entry.afterHash, backup });
|
|
257
|
+
}
|
|
258
|
+
atomicWrite(projectDir, receiptFile, JSON.stringify(receipt, null, 2));
|
|
259
|
+
for (const entry of plan.files) {
|
|
260
|
+
if (currentHash(projectDir, entry.file) !== entry.beforeHash) throw new Error('File changed during update: ' + entry.file);
|
|
261
|
+
atomicWrite(projectDir, entry.file, entry.content);
|
|
262
|
+
if (currentHash(projectDir, entry.file) !== entry.afterHash) throw new Error('Written file verification failed: ' + entry.file);
|
|
263
|
+
}
|
|
264
|
+
receipt.status = 'committed';
|
|
265
|
+
atomicWrite(projectDir, receiptFile, JSON.stringify(receipt, null, 2));
|
|
266
|
+
const manifest = readInstallManifest(safeTarget(projectDir, MANIFEST));
|
|
267
|
+
return { manifest, receiptPath: path.join(projectDir, receiptFile), receiptId: id,
|
|
268
|
+
changedFiles: plan.files.filter(entry => entry.kind !== 'manifest').map(entry => entry.file), conflicts: plan.conflicts };
|
|
269
|
+
} catch (error) {
|
|
270
|
+
if (receipt) {
|
|
271
|
+
const failures = rollback(projectDir, receipt);
|
|
272
|
+
receipt.status = failures.length ? 'recovery-required' : 'rolled-back';
|
|
273
|
+
receipt.failures = failures;
|
|
274
|
+
try { atomicWrite(projectDir, receiptFile, JSON.stringify(receipt, null, 2)); } catch (writeError) {
|
|
275
|
+
error.message += '; recovery receipt could not be saved: ' + writeError.message;
|
|
276
|
+
}
|
|
277
|
+
error.message += '; ' + receipt.status + '. Recovery: bmad-plus update --restore ' + id + ' --directory "' + projectDir + '" --yes';
|
|
278
|
+
}
|
|
279
|
+
throw error;
|
|
280
|
+
} finally { release(); }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function restoreUpdate({ projectDir, receiptId }) {
|
|
284
|
+
if (!/^[a-f0-9-]{36}$/.test(receiptId)) throw new Error('Invalid update receipt ID');
|
|
285
|
+
const receiptFile = '.bmad/updates/' + receiptId + '/receipt.json';
|
|
286
|
+
const receipt = JSON.parse(currentBytes(projectDir, receiptFile)?.toString('utf8') || 'null');
|
|
287
|
+
if (!receipt || receipt.schemaVersion !== 1 || receipt.id !== receiptId || !Array.isArray(receipt.entries)) throw new Error('Invalid update receipt');
|
|
288
|
+
const adapterPaths = new Set([DERIVED.targets.spine, ...DERIVED.targets.adapters.map(entry => entry.file)]);
|
|
289
|
+
for (const entry of receipt.entries) {
|
|
290
|
+
if (typeof entry.file !== 'string' || !(entry.file === MANIFEST || isManagedFile(entry.file) || adapterPaths.has(entry.file)) ||
|
|
291
|
+
!/^[a-f0-9]{64}$/.test(entry.afterHash) || (entry.beforeHash !== null && !/^[a-f0-9]{64}$/.test(entry.beforeHash))) throw new Error('Invalid recovery file entry');
|
|
292
|
+
safeTarget(projectDir, entry.file);
|
|
293
|
+
if (entry.beforeHash === null ? entry.backup !== null :
|
|
294
|
+
typeof entry.backup !== 'string' || !entry.backup.startsWith('.bmad/updates/' + receiptId + '/backups/') ||
|
|
295
|
+
path.posix.normalize(entry.backup) !== entry.backup || entry.backup.includes('\\')) throw new Error('Invalid backup entry');
|
|
296
|
+
if (entry.backup) safeTarget(projectDir, entry.backup);
|
|
297
|
+
}
|
|
298
|
+
const manifestEntry = receipt.entries.find(entry => entry.file === MANIFEST);
|
|
299
|
+
const installedHash = currentHash(projectDir, MANIFEST);
|
|
300
|
+
if (!manifestEntry || (installedHash !== manifestEntry.beforeHash && installedHash !== manifestEntry.afterHash)) throw new Error('Installation changed since this receipt; recovery refused.');
|
|
301
|
+
const lockPath = safeTarget(projectDir, LOCK);
|
|
302
|
+
if (fs.existsSync(lockPath)) {
|
|
303
|
+
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
304
|
+
if (lock.receiptId !== receiptId || !Number.isInteger(lock.pid) || lock.pid <= 0) throw new Error('Another update owns the project lock.');
|
|
305
|
+
try {
|
|
306
|
+
process.kill(lock.pid, 0);
|
|
307
|
+
throw new Error('The recorded update process is still running.');
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (error.code !== 'ESRCH') throw error;
|
|
310
|
+
}
|
|
311
|
+
fs.unlinkSync(lockPath);
|
|
312
|
+
}
|
|
313
|
+
const release = acquireProjectLock(projectDir);
|
|
314
|
+
try {
|
|
315
|
+
// Validate every required byte before starting a manual recovery.
|
|
316
|
+
for (const entry of receipt.entries) {
|
|
317
|
+
const current = currentHash(projectDir, entry.file);
|
|
318
|
+
if (current !== entry.beforeHash && current !== entry.afterHash && current !== null) throw new Error('File changed since the update: ' + entry.file);
|
|
319
|
+
if (entry.beforeHash !== null) {
|
|
320
|
+
const backup = currentBytes(projectDir, entry.backup);
|
|
321
|
+
if (backup === null || fileHash(backup) !== entry.beforeHash) throw new Error('Backup is missing or corrupt: ' + entry.file);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const failures = rollback(projectDir, receipt);
|
|
325
|
+
receipt.status = failures.length ? 'recovery-required' : 'restored';
|
|
326
|
+
receipt.failures = failures;
|
|
327
|
+
atomicWrite(projectDir, receiptFile, JSON.stringify(receipt, null, 2));
|
|
328
|
+
if (failures.length) throw new Error('Recovery incomplete: ' + failures.map(entry => entry.file + ': ' + entry.reason).join('; '));
|
|
329
|
+
return { receiptPath: path.join(projectDir, receiptFile), restoredVersion: readInstallManifest(safeTarget(projectDir, MANIFEST)).version };
|
|
330
|
+
} finally { release(); }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
module.exports = { planUpdate, applyPlan, restoreUpdate, acquireProjectLock, evaluateUpdateReadiness,
|
|
334
|
+
collectManagedFiles, inventoryOf, MANIFEST, LOCK };
|