bmad-plus 0.16.0 → 0.17.1
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 +114 -29
- package/README.md +45 -27
- package/SECURITY.md +9 -1
- package/osint-agent-package/README.md +13 -0
- package/osint-agent-package/SETUP_KEYS.md +13 -0
- package/package.json +32 -8
- package/readme-international/README.de.md +134 -537
- package/readme-international/README.es.md +136 -556
- package/readme-international/README.fr.md +135 -553
- package/src/bmad-plus/agents/agent-shadow/SKILL.md +6 -0
- package/src/bmad-plus/packs/pack-memory/README.md +1 -1
- package/src/bmad-plus/packs/pack-memory/memory-orchestrator.md +1 -1
- package/src/bmad-plus/packs/pack-memory/shared/memory-protocol.md +1 -1
- package/src/bmad-plus/packs/pack-shield/README.md +2 -0
- package/src/bmad-plus/packs/pack-shield/SKILL.md +2 -0
- package/src/bmad-plus/packs/pack-shield/shared/audit-report-template.md +6 -0
- package/src/bmad-plus/packs/pack-shield/shield-orchestrator.md +4 -0
- package/src/bmad-plus/skills/bmad-plus-uat/SKILL.md +18 -4
- package/src/bmad-plus/skills/bmad-plus-uat/template/page.html +95 -19
- package/src/bmad-plus/skills/bmad-plus-uat/template/strings.json +90 -10
- package/src/bmad-plus/skills/bmad-plus-uat/templates/README.md +63 -0
- package/src/bmad-plus/skills/bmad-plus-uat/templates/example-uat-spec.json +95 -0
- package/src/bmad-plus/skills/bmad-plus-uat/templates/uat-release-gate.mjs +127 -0
- package/src/bmad-plus/skills/bmad-plus-uat/templates/uat-spec-present.mjs +77 -0
- package/tools/build/adapters.config.js +1 -1
- package/tools/build/generate-adapters.js +186 -60
- package/tools/build/generate.js +49 -13
- package/tools/cli/bmad-plus-cli.js +3 -6
- package/tools/cli/commands/autoconfig.js +66 -24
- package/tools/cli/commands/install.js +620 -356
- package/tools/cli/commands/memory.js +40 -13
- package/tools/cli/commands/scan.js +94 -30
- package/tools/cli/commands/uat.js +21 -5
- package/tools/cli/commands/uninstall.js +65 -16
- package/tools/cli/commands/update-check.js +8 -3
- package/tools/cli/commands/update-policy.js +14 -5
- package/tools/cli/commands/update.js +89 -20
- package/tools/cli/i18n.js +128 -66
- package/tools/cli/lib/ide-config.js +3 -1
- package/tools/cli/lib/install-manifest.js +13 -6
- package/tools/cli/lib/installation-health.js +33 -1
- package/tools/cli/lib/installed-adapters.js +29 -6
- package/tools/cli/lib/memory-init.js +19 -4
- package/tools/cli/lib/packs.js +17 -28
- package/tools/cli/lib/path-hash.js +5 -1
- package/tools/cli/lib/python-provision.js +51 -12
- package/tools/cli/lib/stack-detect.js +51 -25
- package/tools/cli/lib/uat.js +18 -0
- package/tools/cli/lib/update-check.js +132 -34
- package/tools/cli/lib/update-dispatch.js +2 -2
- package/tools/cli/lib/update-policy.js +57 -19
- package/tools/cli/lib/update-transaction.js +439 -85
- package/tools/build/README.md +0 -78
- package/tools/build/check-counts.js +0 -626
- package/tools/build/check-install-contract.js +0 -648
- package/tools/build/generated-adapters/.codex/AGENTS.md +0 -134
- package/tools/build/generated-adapters/.cursor/rules/bmad-plus.mdc +0 -139
- package/tools/build/generated-adapters/.opencode/AGENTS.md +0 -134
- package/tools/build/generated-adapters/AGENTS.md +0 -132
- package/tools/build/generated-adapters/CLAUDE.md +0 -135
- package/tools/build/generated-adapters/CONVENTIONS.md +0 -134
- package/tools/build/generated-adapters/GEMINI.md +0 -139
- package/tools/build/generated-adapters/README.md +0 -79
- package/tools/maintain/upstream-candidate.js +0 -456
- package/tools/release/publication-content.js +0 -903
- package/tools/release/supply-chain.js +0 -282
|
@@ -2,16 +2,26 @@
|
|
|
2
2
|
const fs = require('node:fs');
|
|
3
3
|
const path = require('node:path');
|
|
4
4
|
const crypto = require('node:crypto');
|
|
5
|
+
const os = require('node:os');
|
|
5
6
|
const semver = require('semver');
|
|
6
7
|
const { PACKS, DERIVED } = require('./packs');
|
|
7
8
|
const { listPackFiles, fileHash } = require('./pack-copy');
|
|
8
9
|
const { contentHash, classifyAdapter, INSTALLER_MARKER } = require('./installed-adapters');
|
|
9
|
-
const {
|
|
10
|
+
const {
|
|
11
|
+
generateUserFiles,
|
|
12
|
+
IDE_CONFIGS,
|
|
13
|
+
GENERATED_MARKER,
|
|
14
|
+
USER_CONFIG_MARKER,
|
|
15
|
+
safeTarget,
|
|
16
|
+
} = require('../../build/generate-adapters');
|
|
10
17
|
const { readInstallManifest } = require('./install-manifest');
|
|
11
18
|
const { isExactVersion } = require('./update-policy');
|
|
12
19
|
|
|
13
20
|
const MANIFEST = '_bmad/.bmad-plus-install.json';
|
|
14
21
|
const LOCK = '.bmad/update.lock';
|
|
22
|
+
// No install or update holds the lock this long; older locks are reported as stale.
|
|
23
|
+
const STALE_LOCK_MS = 60 * 60 * 1000;
|
|
24
|
+
const UNREADABLE_LOCK_GRACE_MS = 60 * 1000;
|
|
15
25
|
const MODULE_FILES = ['_bmad/module.yaml', '_bmad/module-help.csv'];
|
|
16
26
|
const DEFAULT_PACKAGE_ROOT = path.resolve(__dirname, '../../..');
|
|
17
27
|
const relativeFile = (root, target) => path.relative(root, target).split(path.sep).join('/');
|
|
@@ -23,14 +33,24 @@ const currentHash = (root, file) => {
|
|
|
23
33
|
const content = currentBytes(root, file);
|
|
24
34
|
return content === null ? null : fileHash(content);
|
|
25
35
|
};
|
|
26
|
-
const isManagedFile = file =>
|
|
27
|
-
|
|
28
|
-
file.
|
|
36
|
+
const isManagedFile = (file) =>
|
|
37
|
+
typeof file === 'string' &&
|
|
38
|
+
!file.includes('\\') &&
|
|
39
|
+
path.posix.normalize(file) === file &&
|
|
40
|
+
(MODULE_FILES.includes(file) ||
|
|
41
|
+
file.startsWith('.agents/skills/') ||
|
|
42
|
+
file.startsWith('.agents/data/'));
|
|
29
43
|
|
|
30
44
|
function inventoryOf(manifest) {
|
|
31
45
|
const inventory = manifest.fileInventory;
|
|
32
|
-
if (
|
|
33
|
-
|
|
46
|
+
if (
|
|
47
|
+
!inventory ||
|
|
48
|
+
inventory.schemaVersion !== 1 ||
|
|
49
|
+
!inventory.files ||
|
|
50
|
+
typeof inventory.files !== 'object' ||
|
|
51
|
+
Array.isArray(inventory.files)
|
|
52
|
+
)
|
|
53
|
+
return null;
|
|
34
54
|
for (const [file, hash] of Object.entries(inventory.files)) {
|
|
35
55
|
if (!isManagedFile(file) || !/^[a-f0-9]{64}$/.test(hash)) return null;
|
|
36
56
|
}
|
|
@@ -38,7 +58,11 @@ function inventoryOf(manifest) {
|
|
|
38
58
|
}
|
|
39
59
|
|
|
40
60
|
function hasCompleteInventory(manifest, inventory) {
|
|
41
|
-
return Boolean(
|
|
61
|
+
return Boolean(
|
|
62
|
+
inventory &&
|
|
63
|
+
manifest.fileInventory.complete === true &&
|
|
64
|
+
MODULE_FILES.every((file) => typeof inventory[file] === 'string')
|
|
65
|
+
);
|
|
42
66
|
}
|
|
43
67
|
|
|
44
68
|
function collectManagedFiles({ projectDir, packageRoot = DEFAULT_PACKAGE_ROOT, packs }) {
|
|
@@ -46,10 +70,15 @@ function collectManagedFiles({ projectDir, packageRoot = DEFAULT_PACKAGE_ROOT, p
|
|
|
46
70
|
const bmadSrc = path.join(packageRoot, 'src', 'bmad-plus');
|
|
47
71
|
for (const id of packs) {
|
|
48
72
|
if (!Object.hasOwn(PACKS, id)) throw new Error('Unknown installed pack: ' + id);
|
|
49
|
-
const entries = listPackFiles({
|
|
73
|
+
const entries = listPackFiles({
|
|
74
|
+
bmadSrc,
|
|
75
|
+
projectRoot: packageRoot,
|
|
50
76
|
targetAgentsDir: path.join(projectDir, '.agents', 'skills'),
|
|
51
|
-
targetDataDir: path.join(projectDir, '.agents', 'data'),
|
|
52
|
-
|
|
77
|
+
targetDataDir: path.join(projectDir, '.agents', 'data'),
|
|
78
|
+
pack: PACKS[id],
|
|
79
|
+
});
|
|
80
|
+
for (const entry of entries.files)
|
|
81
|
+
files.set(relativeFile(projectDir, entry.target), entry.source);
|
|
53
82
|
}
|
|
54
83
|
for (const file of MODULE_FILES) files.set(file, path.join(bmadSrc, path.basename(file)));
|
|
55
84
|
return [...files].map(([file, source]) => {
|
|
@@ -59,10 +88,18 @@ function collectManagedFiles({ projectDir, packageRoot = DEFAULT_PACKAGE_ROOT, p
|
|
|
59
88
|
}
|
|
60
89
|
|
|
61
90
|
function installedTools(projectDir, manifest) {
|
|
62
|
-
if (Array.isArray(manifest.ides))
|
|
63
|
-
|
|
91
|
+
if (Array.isArray(manifest.ides))
|
|
92
|
+
return manifest.ides.filter((id) => Object.hasOwn(IDE_CONFIGS, id));
|
|
93
|
+
const tools = Object.keys(IDE_CONFIGS).filter((id) =>
|
|
94
|
+
fs.existsSync(safeTarget(projectDir, IDE_CONFIGS[id].configFile))
|
|
95
|
+
);
|
|
64
96
|
const spine = currentBytes(projectDir, DERIVED.targets.spine)?.toString('utf8');
|
|
65
|
-
if (
|
|
97
|
+
if (
|
|
98
|
+
spine?.includes(INSTALLER_MARKER) &&
|
|
99
|
+
!spine.includes(GENERATED_MARKER) &&
|
|
100
|
+
!tools.includes('codex-cli')
|
|
101
|
+
)
|
|
102
|
+
tools.push('codex-cli');
|
|
66
103
|
return tools;
|
|
67
104
|
}
|
|
68
105
|
|
|
@@ -70,56 +107,99 @@ function evaluateUpdateReadiness({ projectDir, manifest }) {
|
|
|
70
107
|
try {
|
|
71
108
|
manifest ||= readInstallManifest(safeTarget(projectDir, MANIFEST));
|
|
72
109
|
const inventory = inventoryOf(manifest);
|
|
73
|
-
if (!hasCompleteInventory(manifest, inventory))
|
|
110
|
+
if (!hasCompleteInventory(manifest, inventory))
|
|
111
|
+
return {
|
|
112
|
+
ready: false,
|
|
113
|
+
legacy: true,
|
|
114
|
+
conflicts: [],
|
|
115
|
+
reason: 'Complete file ownership inventory is required.',
|
|
116
|
+
};
|
|
74
117
|
const conflicts = [];
|
|
75
|
-
if (fs.existsSync(safeTarget(projectDir, LOCK)))
|
|
118
|
+
if (fs.existsSync(safeTarget(projectDir, LOCK)))
|
|
119
|
+
conflicts.push({ file: LOCK, reason: 'update-in-progress' });
|
|
76
120
|
for (const [file, hash] of Object.entries(inventory)) {
|
|
77
121
|
const current = currentHash(projectDir, file);
|
|
78
|
-
if (current !== null && current !== hash)
|
|
122
|
+
if (current !== null && current !== hash)
|
|
123
|
+
conflicts.push({ file, reason: 'modified-managed-file' });
|
|
79
124
|
}
|
|
80
125
|
const tools = installedTools(projectDir, manifest);
|
|
81
|
-
const expected = tools.length
|
|
126
|
+
const expected = tools.length
|
|
127
|
+
? generateUserFiles(DERIVED, { packs: manifest.packs, tools })
|
|
128
|
+
: [];
|
|
82
129
|
for (const { file } of expected) {
|
|
83
130
|
const bytes = currentBytes(projectDir, file);
|
|
84
|
-
if (
|
|
85
|
-
|
|
131
|
+
if (
|
|
132
|
+
bytes !== null &&
|
|
133
|
+
(!bytes.toString('utf8').includes(USER_CONFIG_MARKER) ||
|
|
134
|
+
contentHash(bytes.toString('utf8')) !== manifest.adapterHashes?.[file])
|
|
135
|
+
) {
|
|
86
136
|
conflicts.push({ file, reason: 'unowned-or-modified-adapter' });
|
|
87
137
|
}
|
|
88
138
|
}
|
|
89
|
-
return {
|
|
90
|
-
|
|
139
|
+
return {
|
|
140
|
+
ready: conflicts.length === 0,
|
|
141
|
+
legacy: false,
|
|
142
|
+
conflicts,
|
|
143
|
+
reason: conflicts.length
|
|
144
|
+
? 'Local changes or an active update require manual review.'
|
|
145
|
+
: 'Managed files are unchanged.',
|
|
146
|
+
};
|
|
91
147
|
} catch (error) {
|
|
92
148
|
return { ready: false, legacy: false, conflicts: [], reason: error.message };
|
|
93
149
|
}
|
|
94
150
|
}
|
|
95
151
|
|
|
96
152
|
/** Read-only plan: every destination is checked before the first mutation. */
|
|
97
|
-
function planUpdate({
|
|
153
|
+
function planUpdate({
|
|
154
|
+
projectDir,
|
|
155
|
+
packageRoot = DEFAULT_PACKAGE_ROOT,
|
|
156
|
+
manifest,
|
|
157
|
+
version,
|
|
158
|
+
auto = false,
|
|
159
|
+
}) {
|
|
98
160
|
projectDir = path.resolve(projectDir);
|
|
99
161
|
const snapshot = currentBytes(projectDir, MANIFEST);
|
|
100
162
|
const live = readInstallManifest(safeTarget(projectDir, MANIFEST));
|
|
101
|
-
if (manifest && JSON.stringify(live) !== JSON.stringify(manifest))
|
|
163
|
+
if (manifest && JSON.stringify(live) !== JSON.stringify(manifest))
|
|
164
|
+
throw new Error('Installation manifest changed; check again.');
|
|
102
165
|
manifest = live;
|
|
103
|
-
if (!isExactVersion(version) || !isExactVersion(manifest.version))
|
|
104
|
-
|
|
166
|
+
if (!isExactVersion(version) || !isExactVersion(manifest.version))
|
|
167
|
+
throw new Error('Update versions must be strict semantic versions.');
|
|
168
|
+
if (semver.lt(version, manifest.version))
|
|
169
|
+
throw new Error('Downgrade refused: ' + manifest.version + ' -> ' + version);
|
|
105
170
|
const owned = inventoryOf(manifest);
|
|
106
171
|
let legacy = !hasCompleteInventory(manifest, owned);
|
|
107
172
|
const nextHashes = { ...(owned || {}) };
|
|
108
173
|
const nextAdapterHashes = { ...(manifest.adapterHashes || {}) };
|
|
109
174
|
const tools = installedTools(projectDir, manifest);
|
|
110
175
|
const expected = collectManagedFiles({ projectDir, packageRoot, packs: manifest.packs });
|
|
111
|
-
if (tools.length)
|
|
112
|
-
|
|
113
|
-
|
|
176
|
+
if (tools.length)
|
|
177
|
+
expected.push(
|
|
178
|
+
...generateUserFiles(DERIVED, {
|
|
179
|
+
packs: manifest.packs,
|
|
180
|
+
tools,
|
|
181
|
+
userName: manifest.user || 'Developer',
|
|
182
|
+
language: manifest.language || 'English',
|
|
183
|
+
}).map((entry) => ({
|
|
184
|
+
file: entry.file,
|
|
185
|
+
content: Buffer.from(entry.content),
|
|
186
|
+
kind: 'adapter',
|
|
187
|
+
}))
|
|
188
|
+
);
|
|
114
189
|
const observed = new Map([[MANIFEST, fileHash(snapshot)]]);
|
|
115
190
|
const files = [];
|
|
116
191
|
const conflicts = [];
|
|
192
|
+
const expectedFiles = new Set(expected.map((entry) => entry.file));
|
|
193
|
+
// Owned files the new version no longer ships: remove them when unchanged
|
|
194
|
+
// (backed up in the receipt); a locally edited one is kept and handed over.
|
|
117
195
|
for (const [file, hash] of Object.entries(owned || {})) {
|
|
118
196
|
const current = currentHash(projectDir, file);
|
|
119
197
|
observed.set(file, current);
|
|
120
|
-
if (
|
|
121
|
-
|
|
122
|
-
|
|
198
|
+
if (expectedFiles.has(file)) continue;
|
|
199
|
+
delete nextHashes[file];
|
|
200
|
+
if (current === hash)
|
|
201
|
+
files.push({ file, kind: 'delete', content: null, beforeHash: current, afterHash: null });
|
|
202
|
+
else if (current !== null) conflicts.push({ file, reason: 'modified-orphaned-file' });
|
|
123
203
|
}
|
|
124
204
|
for (const entry of expected) {
|
|
125
205
|
const before = currentBytes(projectDir, entry.file);
|
|
@@ -128,8 +208,12 @@ function planUpdate({ projectDir, packageRoot = DEFAULT_PACKAGE_ROOT, manifest,
|
|
|
128
208
|
const afterHash = fileHash(entry.content);
|
|
129
209
|
let backupLegacy = false;
|
|
130
210
|
if (entry.kind === 'adapter') {
|
|
131
|
-
const decision = classifyAdapter({
|
|
132
|
-
|
|
211
|
+
const decision = classifyAdapter({
|
|
212
|
+
existing: before?.toString('utf8'),
|
|
213
|
+
content: entry.content.toString('utf8'),
|
|
214
|
+
previousHash: manifest.adapterHashes?.[entry.file],
|
|
215
|
+
update: true,
|
|
216
|
+
});
|
|
133
217
|
if (decision.action === 'preserve') {
|
|
134
218
|
conflicts.push({ file: entry.file, reason: decision.reason });
|
|
135
219
|
continue;
|
|
@@ -153,16 +237,113 @@ function planUpdate({ projectDir, packageRoot = DEFAULT_PACKAGE_ROOT, manifest,
|
|
|
153
237
|
}
|
|
154
238
|
if (beforeHash !== afterHash) files.push({ ...entry, beforeHash, afterHash, backupLegacy });
|
|
155
239
|
}
|
|
156
|
-
const nextManifest = {
|
|
157
|
-
|
|
158
|
-
|
|
240
|
+
const nextManifest = {
|
|
241
|
+
...manifest,
|
|
242
|
+
version,
|
|
243
|
+
lastUpdated: new Date().toISOString(),
|
|
244
|
+
ides: tools,
|
|
245
|
+
adapterHashes: nextAdapterHashes,
|
|
246
|
+
fileInventory: {
|
|
247
|
+
schemaVersion: 1,
|
|
248
|
+
complete: !conflicts.some((entry) => entry.reason === 'unowned-file'),
|
|
249
|
+
files: nextHashes,
|
|
250
|
+
},
|
|
251
|
+
};
|
|
159
252
|
const manifestContent = Buffer.from(JSON.stringify(nextManifest, null, 2));
|
|
160
|
-
files.push({
|
|
161
|
-
|
|
253
|
+
files.push({
|
|
254
|
+
file: MANIFEST,
|
|
255
|
+
kind: 'manifest',
|
|
256
|
+
content: manifestContent,
|
|
257
|
+
beforeHash: fileHash(snapshot),
|
|
258
|
+
afterHash: fileHash(manifestContent),
|
|
259
|
+
});
|
|
162
260
|
for (const { file } of files) safeTarget(projectDir, file);
|
|
163
|
-
if (auto && (legacy || conflicts.length))
|
|
164
|
-
|
|
165
|
-
|
|
261
|
+
if (auto && (legacy || conflicts.length))
|
|
262
|
+
throw new Error(
|
|
263
|
+
'Automatic update refused: ' +
|
|
264
|
+
(legacy ? 'legacy ownership inventory' : 'local file conflicts')
|
|
265
|
+
);
|
|
266
|
+
return {
|
|
267
|
+
projectDir,
|
|
268
|
+
fromVersion: manifest.version,
|
|
269
|
+
toVersion: version,
|
|
270
|
+
files,
|
|
271
|
+
conflicts,
|
|
272
|
+
legacy,
|
|
273
|
+
inventoryComplete: !legacy,
|
|
274
|
+
nextManifest,
|
|
275
|
+
observed: [...observed],
|
|
276
|
+
auto,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function processAlive(pid) {
|
|
281
|
+
try {
|
|
282
|
+
process.kill(pid, 0);
|
|
283
|
+
return true;
|
|
284
|
+
} catch (error) {
|
|
285
|
+
return error.code === 'EPERM';
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Read-only lock diagnosis: owner, age, and whether it can be proven stale. */
|
|
290
|
+
function inspectProjectLock(projectDir, { now = Date.now() } = {}) {
|
|
291
|
+
const target = safeTarget(projectDir, LOCK);
|
|
292
|
+
let stat;
|
|
293
|
+
try {
|
|
294
|
+
stat = fs.statSync(target);
|
|
295
|
+
} catch (error) {
|
|
296
|
+
if (error.code === 'ENOENT') return null;
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
let lock = null;
|
|
300
|
+
try {
|
|
301
|
+
lock = JSON.parse(fs.readFileSync(target, 'utf8'));
|
|
302
|
+
} catch {
|
|
303
|
+
/* reported as unreadable */
|
|
304
|
+
}
|
|
305
|
+
if (!lock || typeof lock !== 'object' || Array.isArray(lock)) lock = null;
|
|
306
|
+
const createdAt = Date.parse(lock?.createdAt) || stat.mtimeMs;
|
|
307
|
+
const ageMs = Math.max(0, now - createdAt);
|
|
308
|
+
const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null;
|
|
309
|
+
const sameHost = !lock?.hostname || lock.hostname === os.hostname();
|
|
310
|
+
const pidAlive = pid && sameHost ? processAlive(pid) : null;
|
|
311
|
+
let stale = false;
|
|
312
|
+
let reason = 'An install or update is running (pid ' + (pid ?? 'unknown') + ').';
|
|
313
|
+
if (!lock) {
|
|
314
|
+
stale = ageMs > UNREADABLE_LOCK_GRACE_MS;
|
|
315
|
+
reason = 'The lock file is unreadable.';
|
|
316
|
+
} else if (pidAlive === false) {
|
|
317
|
+
stale = true;
|
|
318
|
+
reason = 'The recorded process ' + pid + ' is no longer running.';
|
|
319
|
+
} else if (ageMs > STALE_LOCK_MS) {
|
|
320
|
+
stale = true;
|
|
321
|
+
reason = 'The lock is older than ' + STALE_LOCK_MS / 60000 + ' minutes.';
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
path: target,
|
|
325
|
+
id: lock?.id ?? null,
|
|
326
|
+
pid,
|
|
327
|
+
hostname: lock?.hostname || null,
|
|
328
|
+
receiptId: lock?.receiptId || null,
|
|
329
|
+
createdAt: new Date(createdAt).toISOString(),
|
|
330
|
+
ageMs,
|
|
331
|
+
pidAlive,
|
|
332
|
+
stale,
|
|
333
|
+
reason,
|
|
334
|
+
// Only a dead process on this machine is proof enough to take the lock over.
|
|
335
|
+
recoverable: pidAlive === false,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function lockRemedy(info) {
|
|
340
|
+
return info.stale
|
|
341
|
+
? ' ' +
|
|
342
|
+
info.reason +
|
|
343
|
+
' If no BMAD+ install or update is running, delete ' +
|
|
344
|
+
info.path +
|
|
345
|
+
' and retry.'
|
|
346
|
+
: ' Wait for it to finish; if it was interrupted, `bmad-plus doctor` reports when the lock becomes stale.';
|
|
166
347
|
}
|
|
167
348
|
|
|
168
349
|
function acquireProjectLock(projectDir, { receiptId = null } = {}) {
|
|
@@ -170,18 +351,68 @@ function acquireProjectLock(projectDir, { receiptId = null } = {}) {
|
|
|
170
351
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
171
352
|
const id = crypto.randomUUID();
|
|
172
353
|
let fd;
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
354
|
+
for (let attempt = 0; fd === undefined; attempt++) {
|
|
355
|
+
try {
|
|
356
|
+
fd = fs.openSync(target, 'wx');
|
|
357
|
+
} catch (error) {
|
|
358
|
+
if (error.code !== 'EEXIST') throw error;
|
|
359
|
+
const info = inspectProjectLock(projectDir);
|
|
360
|
+
// Move a provably dead lock aside once (rename is atomic: one contender wins).
|
|
361
|
+
if (attempt === 0 && info?.recoverable) {
|
|
362
|
+
const aside = target + '.stale-' + id;
|
|
363
|
+
try {
|
|
364
|
+
fs.renameSync(target, aside);
|
|
365
|
+
} catch (renameError) {
|
|
366
|
+
if (renameError.code !== 'ENOENT') throw renameError;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
let moved = null;
|
|
370
|
+
try {
|
|
371
|
+
moved = JSON.parse(fs.readFileSync(aside, 'utf8'));
|
|
372
|
+
} catch {
|
|
373
|
+
/* unreadable: not a live lock */
|
|
374
|
+
}
|
|
375
|
+
if ((moved?.id ?? null) !== info.id || moved?.pid !== info.pid) {
|
|
376
|
+
// Another contender took the lock between inspection and rename: give it back.
|
|
377
|
+
try {
|
|
378
|
+
fs.linkSync(aside, target);
|
|
379
|
+
fs.unlinkSync(aside);
|
|
380
|
+
} catch {
|
|
381
|
+
/* keep evidence */
|
|
382
|
+
}
|
|
383
|
+
throw new Error('Another update is in progress: ' + target + '.', { cause: error });
|
|
384
|
+
}
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
throw new Error(
|
|
388
|
+
'Another update is in progress: ' + target + '.' + (info ? lockRemedy(info) : ''),
|
|
389
|
+
{ cause: error }
|
|
390
|
+
);
|
|
391
|
+
}
|
|
176
392
|
}
|
|
177
|
-
try {
|
|
393
|
+
try {
|
|
394
|
+
fs.writeFileSync(
|
|
395
|
+
fd,
|
|
396
|
+
JSON.stringify({
|
|
397
|
+
id,
|
|
398
|
+
pid: process.pid,
|
|
399
|
+
hostname: os.hostname(),
|
|
400
|
+
createdAt: new Date().toISOString(),
|
|
401
|
+
receiptId,
|
|
402
|
+
})
|
|
403
|
+
);
|
|
404
|
+
} catch (error) {
|
|
178
405
|
fs.closeSync(fd);
|
|
179
406
|
fs.unlinkSync(target);
|
|
180
407
|
throw error;
|
|
181
408
|
}
|
|
182
409
|
fs.closeSync(fd);
|
|
183
410
|
return () => {
|
|
184
|
-
if (
|
|
411
|
+
if (
|
|
412
|
+
fs.existsSync(safeTarget(projectDir, LOCK)) &&
|
|
413
|
+
JSON.parse(fs.readFileSync(target, 'utf8')).id === id
|
|
414
|
+
)
|
|
415
|
+
fs.unlinkSync(target);
|
|
185
416
|
};
|
|
186
417
|
}
|
|
187
418
|
|
|
@@ -202,26 +433,49 @@ function atomicWrite(projectDir, file, content) {
|
|
|
202
433
|
function rollback(projectDir, receipt) {
|
|
203
434
|
const failures = [];
|
|
204
435
|
// Restore the manifest last, after restoring the content it describes.
|
|
205
|
-
const entries = [
|
|
206
|
-
...receipt.entries.filter(entry => entry.file
|
|
436
|
+
const entries = [
|
|
437
|
+
...receipt.entries.filter((entry) => entry.file !== MANIFEST).reverse(),
|
|
438
|
+
...receipt.entries.filter((entry) => entry.file === MANIFEST),
|
|
439
|
+
];
|
|
207
440
|
for (const entry of entries) {
|
|
208
441
|
try {
|
|
209
442
|
const current = currentHash(projectDir, entry.file);
|
|
210
443
|
if (current === entry.beforeHash) continue;
|
|
211
|
-
if (current !== entry.afterHash && current !== null)
|
|
444
|
+
if (current !== entry.afterHash && current !== null)
|
|
445
|
+
throw new Error('File changed since the update; retained');
|
|
212
446
|
if (entry.beforeHash === null) {
|
|
213
447
|
if (current !== null) fs.unlinkSync(safeTarget(projectDir, entry.file));
|
|
214
448
|
} else {
|
|
215
|
-
if (entry.file === MANIFEST && failures.length)
|
|
449
|
+
if (entry.file === MANIFEST && failures.length)
|
|
450
|
+
throw new Error('Content recovery is incomplete; manifest retained');
|
|
216
451
|
const backup = currentBytes(projectDir, entry.backup);
|
|
217
|
-
if (!backup || fileHash(backup) !== entry.beforeHash)
|
|
452
|
+
if (!backup || fileHash(backup) !== entry.beforeHash)
|
|
453
|
+
throw new Error('Backup is missing or does not match its recorded hash');
|
|
218
454
|
atomicWrite(projectDir, entry.file, backup);
|
|
219
455
|
}
|
|
220
|
-
} catch (error) {
|
|
456
|
+
} catch (error) {
|
|
457
|
+
failures.push({ file: entry.file, reason: error.message });
|
|
458
|
+
}
|
|
221
459
|
}
|
|
222
460
|
return failures;
|
|
223
461
|
}
|
|
224
462
|
|
|
463
|
+
/** Remove directories emptied by a deletion, never above the managed roots. */
|
|
464
|
+
function pruneEmptyParents(projectDir, file) {
|
|
465
|
+
let directory = path.posix.dirname(file);
|
|
466
|
+
while (directory.startsWith('.agents/skills/') || directory.startsWith('.agents/data/')) {
|
|
467
|
+
try {
|
|
468
|
+
// safeTarget validates files; checking a child path rejects linked parents.
|
|
469
|
+
const target = path.dirname(safeTarget(projectDir, directory + '/.bmad-preflight'));
|
|
470
|
+
if (fs.readdirSync(target).length) return;
|
|
471
|
+
fs.rmdirSync(target);
|
|
472
|
+
} catch {
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
directory = path.posix.dirname(directory);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
225
479
|
function applyPlan(plan, { reauthorize } = {}) {
|
|
226
480
|
const { projectDir } = plan;
|
|
227
481
|
const id = crypto.randomUUID();
|
|
@@ -230,14 +484,24 @@ function applyPlan(plan, { reauthorize } = {}) {
|
|
|
230
484
|
let receipt;
|
|
231
485
|
try {
|
|
232
486
|
if (plan.auto) {
|
|
233
|
-
if (typeof reauthorize !== 'function')
|
|
487
|
+
if (typeof reauthorize !== 'function')
|
|
488
|
+
throw new Error('Automatic update requires policy reauthorization under the project lock.');
|
|
234
489
|
reauthorize();
|
|
235
490
|
}
|
|
236
491
|
for (const [file, hash] of plan.observed) {
|
|
237
|
-
if (currentHash(projectDir, file) !== hash)
|
|
492
|
+
if (currentHash(projectDir, file) !== hash)
|
|
493
|
+
throw new Error('File changed after preflight: ' + file);
|
|
238
494
|
}
|
|
239
|
-
receipt = {
|
|
240
|
-
|
|
495
|
+
receipt = {
|
|
496
|
+
schemaVersion: 1,
|
|
497
|
+
id,
|
|
498
|
+
status: 'prepared',
|
|
499
|
+
fromVersion: plan.fromVersion,
|
|
500
|
+
toVersion: plan.toVersion,
|
|
501
|
+
createdAt: new Date().toISOString(),
|
|
502
|
+
conflicts: plan.conflicts,
|
|
503
|
+
entries: [],
|
|
504
|
+
};
|
|
241
505
|
for (const [index, entry] of plan.files.entries()) {
|
|
242
506
|
let backup = null;
|
|
243
507
|
if (entry.beforeHash !== null) {
|
|
@@ -245,63 +509,130 @@ function applyPlan(plan, { reauthorize } = {}) {
|
|
|
245
509
|
const target = safeTarget(projectDir, backup);
|
|
246
510
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
247
511
|
fs.copyFileSync(safeTarget(projectDir, entry.file), target, fs.constants.COPYFILE_EXCL);
|
|
248
|
-
if (fileHash(fs.readFileSync(target)) !== entry.beforeHash)
|
|
512
|
+
if (fileHash(fs.readFileSync(target)) !== entry.beforeHash)
|
|
513
|
+
throw new Error('File changed while backing up: ' + entry.file);
|
|
249
514
|
}
|
|
250
515
|
if (entry.backupLegacy) {
|
|
251
516
|
let legacyBackup = entry.file + '.bak';
|
|
252
517
|
let index = 1;
|
|
253
|
-
while (fs.existsSync(safeTarget(projectDir, legacyBackup)))
|
|
254
|
-
|
|
518
|
+
while (fs.existsSync(safeTarget(projectDir, legacyBackup)))
|
|
519
|
+
legacyBackup = entry.file + '.bak.' + index++;
|
|
520
|
+
fs.copyFileSync(
|
|
521
|
+
safeTarget(projectDir, entry.file),
|
|
522
|
+
safeTarget(projectDir, legacyBackup),
|
|
523
|
+
fs.constants.COPYFILE_EXCL
|
|
524
|
+
);
|
|
255
525
|
}
|
|
256
|
-
receipt.entries.push({
|
|
526
|
+
receipt.entries.push({
|
|
527
|
+
file: entry.file,
|
|
528
|
+
beforeHash: entry.beforeHash,
|
|
529
|
+
afterHash: entry.afterHash,
|
|
530
|
+
backup,
|
|
531
|
+
});
|
|
257
532
|
}
|
|
258
533
|
atomicWrite(projectDir, receiptFile, JSON.stringify(receipt, null, 2));
|
|
259
534
|
for (const entry of plan.files) {
|
|
260
|
-
if (currentHash(projectDir, entry.file) !== entry.beforeHash)
|
|
261
|
-
|
|
262
|
-
if (
|
|
535
|
+
if (currentHash(projectDir, entry.file) !== entry.beforeHash)
|
|
536
|
+
throw new Error('File changed during update: ' + entry.file);
|
|
537
|
+
if (entry.afterHash === null) fs.unlinkSync(safeTarget(projectDir, entry.file));
|
|
538
|
+
else atomicWrite(projectDir, entry.file, entry.content);
|
|
539
|
+
if (currentHash(projectDir, entry.file) !== entry.afterHash)
|
|
540
|
+
throw new Error('Written file verification failed: ' + entry.file);
|
|
263
541
|
}
|
|
264
542
|
receipt.status = 'committed';
|
|
265
543
|
atomicWrite(projectDir, receiptFile, JSON.stringify(receipt, null, 2));
|
|
544
|
+
const removedFiles = plan.files
|
|
545
|
+
.filter((entry) => entry.afterHash === null)
|
|
546
|
+
.map((entry) => entry.file);
|
|
547
|
+
for (const file of removedFiles) pruneEmptyParents(projectDir, file);
|
|
266
548
|
const manifest = readInstallManifest(safeTarget(projectDir, MANIFEST));
|
|
267
|
-
return {
|
|
268
|
-
|
|
549
|
+
return {
|
|
550
|
+
manifest,
|
|
551
|
+
receiptPath: path.join(projectDir, receiptFile),
|
|
552
|
+
receiptId: id,
|
|
553
|
+
changedFiles: plan.files
|
|
554
|
+
.filter((entry) => entry.kind !== 'manifest')
|
|
555
|
+
.map((entry) => entry.file),
|
|
556
|
+
removedFiles,
|
|
557
|
+
conflicts: plan.conflicts,
|
|
558
|
+
};
|
|
269
559
|
} catch (error) {
|
|
270
560
|
if (receipt) {
|
|
271
561
|
const failures = rollback(projectDir, receipt);
|
|
272
562
|
receipt.status = failures.length ? 'recovery-required' : 'rolled-back';
|
|
273
563
|
receipt.failures = failures;
|
|
274
|
-
try {
|
|
564
|
+
try {
|
|
565
|
+
atomicWrite(projectDir, receiptFile, JSON.stringify(receipt, null, 2));
|
|
566
|
+
} catch (writeError) {
|
|
275
567
|
error.message += '; recovery receipt could not be saved: ' + writeError.message;
|
|
276
568
|
}
|
|
277
|
-
error.message +=
|
|
569
|
+
error.message +=
|
|
570
|
+
'; ' +
|
|
571
|
+
receipt.status +
|
|
572
|
+
'. Recovery: bmad-plus update --restore ' +
|
|
573
|
+
id +
|
|
574
|
+
' --directory "' +
|
|
575
|
+
projectDir +
|
|
576
|
+
'" --yes';
|
|
278
577
|
}
|
|
279
578
|
throw error;
|
|
280
|
-
} finally {
|
|
579
|
+
} finally {
|
|
580
|
+
release();
|
|
581
|
+
}
|
|
281
582
|
}
|
|
282
583
|
|
|
283
584
|
function restoreUpdate({ projectDir, receiptId }) {
|
|
284
585
|
if (!/^[a-f0-9-]{36}$/.test(receiptId)) throw new Error('Invalid update receipt ID');
|
|
285
586
|
const receiptFile = '.bmad/updates/' + receiptId + '/receipt.json';
|
|
286
587
|
const receipt = JSON.parse(currentBytes(projectDir, receiptFile)?.toString('utf8') || 'null');
|
|
287
|
-
if (
|
|
288
|
-
|
|
588
|
+
if (
|
|
589
|
+
!receipt ||
|
|
590
|
+
receipt.schemaVersion !== 1 ||
|
|
591
|
+
receipt.id !== receiptId ||
|
|
592
|
+
!Array.isArray(receipt.entries)
|
|
593
|
+
)
|
|
594
|
+
throw new Error('Invalid update receipt');
|
|
595
|
+
const adapterPaths = new Set([
|
|
596
|
+
DERIVED.targets.spine,
|
|
597
|
+
...DERIVED.targets.adapters.map((entry) => entry.file),
|
|
598
|
+
]);
|
|
599
|
+
const isHash = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
289
600
|
for (const entry of receipt.entries) {
|
|
290
|
-
|
|
291
|
-
|
|
601
|
+
// A null afterHash records a deleted file, which always has a backup.
|
|
602
|
+
if (
|
|
603
|
+
typeof entry.file !== 'string' ||
|
|
604
|
+
!(entry.file === MANIFEST || isManagedFile(entry.file) || adapterPaths.has(entry.file)) ||
|
|
605
|
+
!(
|
|
606
|
+
isHash(entry.afterHash) ||
|
|
607
|
+
(entry.afterHash === null && entry.file !== MANIFEST && isHash(entry.beforeHash))
|
|
608
|
+
) ||
|
|
609
|
+
(entry.beforeHash !== null && !isHash(entry.beforeHash))
|
|
610
|
+
)
|
|
611
|
+
throw new Error('Invalid recovery file entry');
|
|
292
612
|
safeTarget(projectDir, entry.file);
|
|
293
|
-
if (
|
|
294
|
-
|
|
295
|
-
|
|
613
|
+
if (
|
|
614
|
+
entry.beforeHash === null
|
|
615
|
+
? entry.backup !== null
|
|
616
|
+
: typeof entry.backup !== 'string' ||
|
|
617
|
+
!entry.backup.startsWith('.bmad/updates/' + receiptId + '/backups/') ||
|
|
618
|
+
path.posix.normalize(entry.backup) !== entry.backup ||
|
|
619
|
+
entry.backup.includes('\\')
|
|
620
|
+
)
|
|
621
|
+
throw new Error('Invalid backup entry');
|
|
296
622
|
if (entry.backup) safeTarget(projectDir, entry.backup);
|
|
297
623
|
}
|
|
298
|
-
const manifestEntry = receipt.entries.find(entry => entry.file === MANIFEST);
|
|
624
|
+
const manifestEntry = receipt.entries.find((entry) => entry.file === MANIFEST);
|
|
299
625
|
const installedHash = currentHash(projectDir, MANIFEST);
|
|
300
|
-
if (
|
|
626
|
+
if (
|
|
627
|
+
!manifestEntry ||
|
|
628
|
+
(installedHash !== manifestEntry.beforeHash && installedHash !== manifestEntry.afterHash)
|
|
629
|
+
)
|
|
630
|
+
throw new Error('Installation changed since this receipt; recovery refused.');
|
|
301
631
|
const lockPath = safeTarget(projectDir, LOCK);
|
|
302
632
|
if (fs.existsSync(lockPath)) {
|
|
303
633
|
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
304
|
-
if (lock.receiptId !== receiptId || !Number.isInteger(lock.pid) || lock.pid <= 0)
|
|
634
|
+
if (lock.receiptId !== receiptId || !Number.isInteger(lock.pid) || lock.pid <= 0)
|
|
635
|
+
throw new Error('Another update owns the project lock.');
|
|
305
636
|
try {
|
|
306
637
|
process.kill(lock.pid, 0);
|
|
307
638
|
throw new Error('The recorded update process is still running.');
|
|
@@ -315,20 +646,43 @@ function restoreUpdate({ projectDir, receiptId }) {
|
|
|
315
646
|
// Validate every required byte before starting a manual recovery.
|
|
316
647
|
for (const entry of receipt.entries) {
|
|
317
648
|
const current = currentHash(projectDir, entry.file);
|
|
318
|
-
if (current !== entry.beforeHash && current !== entry.afterHash && current !== null)
|
|
649
|
+
if (current !== entry.beforeHash && current !== entry.afterHash && current !== null)
|
|
650
|
+
throw new Error('File changed since the update: ' + entry.file);
|
|
319
651
|
if (entry.beforeHash !== null) {
|
|
320
652
|
const backup = currentBytes(projectDir, entry.backup);
|
|
321
|
-
if (backup === null || fileHash(backup) !== entry.beforeHash)
|
|
653
|
+
if (backup === null || fileHash(backup) !== entry.beforeHash)
|
|
654
|
+
throw new Error('Backup is missing or corrupt: ' + entry.file);
|
|
322
655
|
}
|
|
323
656
|
}
|
|
324
657
|
const failures = rollback(projectDir, receipt);
|
|
325
658
|
receipt.status = failures.length ? 'recovery-required' : 'restored';
|
|
326
659
|
receipt.failures = failures;
|
|
327
660
|
atomicWrite(projectDir, receiptFile, JSON.stringify(receipt, null, 2));
|
|
328
|
-
if (failures.length)
|
|
329
|
-
|
|
330
|
-
|
|
661
|
+
if (failures.length)
|
|
662
|
+
throw new Error(
|
|
663
|
+
'Recovery incomplete: ' +
|
|
664
|
+
failures.map((entry) => entry.file + ': ' + entry.reason).join('; ')
|
|
665
|
+
);
|
|
666
|
+
return {
|
|
667
|
+
receiptPath: path.join(projectDir, receiptFile),
|
|
668
|
+
restoredVersion: readInstallManifest(safeTarget(projectDir, MANIFEST)).version,
|
|
669
|
+
};
|
|
670
|
+
} finally {
|
|
671
|
+
release();
|
|
672
|
+
}
|
|
331
673
|
}
|
|
332
674
|
|
|
333
|
-
module.exports = {
|
|
334
|
-
|
|
675
|
+
module.exports = {
|
|
676
|
+
planUpdate,
|
|
677
|
+
applyPlan,
|
|
678
|
+
restoreUpdate,
|
|
679
|
+
acquireProjectLock,
|
|
680
|
+
inspectProjectLock,
|
|
681
|
+
atomicWrite,
|
|
682
|
+
evaluateUpdateReadiness,
|
|
683
|
+
collectManagedFiles,
|
|
684
|
+
inventoryOf,
|
|
685
|
+
MANIFEST,
|
|
686
|
+
LOCK,
|
|
687
|
+
STALE_LOCK_MS,
|
|
688
|
+
};
|