bmad-plus 0.17.1 → 0.19.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.
@@ -89,6 +89,13 @@ function serve(spec, paths, options) {
89
89
  response.writeHead(code, { 'content-type': type, 'cache-control': 'no-store' });
90
90
  response.end(body);
91
91
  };
92
+ // Loopback is not enough against the tester's own browser: a name rebound to 127.0.0.1
93
+ // is same-origin for it. Only the two names this server answers to are served.
94
+ const host = String(request.headers.host || '').toLowerCase();
95
+ const bound = server.address() ? server.address().port : port;
96
+ if (host !== `127.0.0.1:${bound}` && host !== `localhost:${bound}`) {
97
+ return send(403, JSON.stringify({ error: 'unexpected host' }));
98
+ }
92
99
  if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
93
100
  return send(200, html, 'text/html; charset=utf-8');
94
101
  }
@@ -116,7 +123,29 @@ function serve(spec, paths, options) {
116
123
  const run = uat.normalizeRun(JSON.parse(Buffer.concat(chunks).toString('utf8')), spec);
117
124
  if (run.runId !== runId || run.specId !== spec.id)
118
125
  fail('run identity does not match the request');
119
- fs.writeFileSync(path.join(resultsDir, `${runId}.json`), JSON.stringify(run, null, 2));
126
+ // The page's rule holds on disk too: a copy older than the file never replaces it.
127
+ const file = path.join(resultsDir, `${runId}.json`);
128
+ if (fs.existsSync(file)) {
129
+ let existing = null;
130
+ try {
131
+ existing = JSON.parse(fs.readFileSync(file, 'utf8'));
132
+ } catch {
133
+ existing = null;
134
+ }
135
+ const was = Date.parse(existing && existing.updatedAt);
136
+ const now = Date.parse(run.updatedAt);
137
+ if (!Number.isNaN(was) && (Number.isNaN(now) || was > now)) {
138
+ send(
139
+ 409,
140
+ JSON.stringify({
141
+ error: 'a newer copy of this run is already stored',
142
+ updatedAt: existing.updatedAt,
143
+ })
144
+ );
145
+ return;
146
+ }
147
+ }
148
+ fs.writeFileSync(file, JSON.stringify(run, null, 2));
120
149
  const s = run.summary;
121
150
  console.log(
122
151
  `${new Date().toISOString().slice(11, 19)} ${runId} — ${s.passed} passed, ${s.failed} failed, ${s.blocked} blocked, ${s.unanswered} to do${run.finishedAt ? ' — finished' : ''}`
@@ -133,7 +162,9 @@ function serve(spec, paths, options) {
133
162
 
134
163
  // Loopback only: a tester on another machine uses the artifact or the file (decision D5).
135
164
  server.listen(port, '127.0.0.1', () => {
136
- console.log(`uat serve: http://127.0.0.1:${port} (${spec.id}, ${spec.steps.length} steps)`);
165
+ console.log(
166
+ `uat serve: http://127.0.0.1:${server.address().port} (${spec.id}, ${spec.steps.length} steps)`
167
+ );
137
168
  console.log(`results → ${resultsDir}`);
138
169
  console.log('Ctrl+C to stop.');
139
170
  });
@@ -188,9 +219,21 @@ module.exports = {
188
219
  process.exitCode = 1;
189
220
  return;
190
221
  }
191
- const page = uat.buildPage(spec, {
192
- language: options.language || projectLanguage(projectDir),
193
- });
222
+ let page;
223
+ try {
224
+ page = uat.buildPage(spec, {
225
+ language: options.language || projectLanguage(projectDir),
226
+ });
227
+ } catch (error) {
228
+ // A refused page reads like a refused lint: same shape, so a machine reader sees a refusal, not a crash.
229
+ print(
230
+ json,
231
+ { action, specId: spec.id, status: 'error', errors: [error.message], guarantees: [] },
232
+ [`${spec.id}: not built`, ` error ${error.message}`]
233
+ );
234
+ process.exitCode = 1;
235
+ return;
236
+ }
194
237
  fs.mkdirSync(paths.pages, { recursive: true });
195
238
  const file = path.join(paths.pages, `uat-${spec.id}.html`);
196
239
  fs.writeFileSync(file, page.html, 'utf8');
@@ -203,11 +246,13 @@ module.exports = {
203
246
  specSha256: page.sha256,
204
247
  language: page.language,
205
248
  languages: Object.keys(uat.STRINGS),
249
+ guarantees: page.guarantees,
206
250
  warnings: report.warnings,
207
251
  },
208
252
  [
209
253
  `${file} — ${spec.steps.length} steps, ${(page.html.length / 1024).toFixed(0)} KiB, spec ${page.sha256.slice(0, 12)}`,
210
254
  `opens in ${uat.STRINGS[page.language].name}; the tester can switch to any of the ${Object.keys(uat.STRINGS).length} languages on the page`,
255
+ `carries the ${page.guarantees.length} page guarantees: ${page.guarantees.join(', ')}`,
211
256
  ...report.warnings.map((line) => ` warning ${line}`),
212
257
  ]
213
258
  );
@@ -1,6 +1,7 @@
1
1
  /** Report release status without applying updates. */
2
2
  const path = require('node:path');
3
3
  const { checkForUpdate } = require('../lib/update-check');
4
+ const { notInstalledMessage, hasInstallManifest } = require('../lib/install-manifest');
4
5
 
5
6
  module.exports = {
6
7
  command: 'update-check',
@@ -12,8 +13,9 @@ module.exports = {
12
13
  ['--json', 'Print machine-readable JSON'],
13
14
  ],
14
15
  action: async (options = {}) => {
16
+ const projectDir = path.resolve(options.directory || process.cwd());
15
17
  const result = await checkForUpdate({
16
- projectDir: path.resolve(options.directory || process.cwd()),
18
+ projectDir,
17
19
  runningVersion: require('../../../package.json').version,
18
20
  refresh: Boolean(options.refresh),
19
21
  offline: Boolean(options.offline),
@@ -23,6 +25,8 @@ module.exports = {
23
25
  },
24
26
  });
25
27
  if (result.status.startsWith('invalid-')) process.exitCode = 1;
28
+ // stderr keeps --json output parseable while still telling a person where to run it.
29
+ if (!hasInstallManifest(projectDir)) console.error(notInstalledMessage(projectDir));
26
30
  if (options.json) console.log(JSON.stringify(result));
27
31
  else {
28
32
  console.log(
@@ -9,7 +9,11 @@ const clack = require('@clack/prompts');
9
9
  const pc = require('picocolors');
10
10
  const { t } = require('../i18n');
11
11
  const { safeTarget } = require('../../build/generate-adapters');
12
- const { readInstallManifest } = require('../lib/install-manifest');
12
+ const {
13
+ readInstallManifest,
14
+ notInstalledMessage,
15
+ hasInstallManifest,
16
+ } = require('../lib/install-manifest');
13
17
  const { isExactVersion } = require('../lib/update-policy');
14
18
  const { planUpdate, applyPlan, restoreUpdate, MANIFEST } = require('../lib/update-transaction');
15
19
 
@@ -51,6 +55,9 @@ module.exports = {
51
55
  if (options.restore && (options.latest || options.expectedVersion)) {
52
56
  throw new Error('--restore cannot be combined with --latest or --expected-version.');
53
57
  }
58
+ if (!options.restore && !hasInstallManifest(projectDir)) {
59
+ throw new Error(notInstalledMessage(projectDir));
60
+ }
54
61
  // The dispatcher resolves the target package; this local package may be older.
55
62
  if (options.latest) {
56
63
  const result = await require('../lib/update-dispatch').runLatestUpdate({
@@ -96,8 +103,7 @@ module.exports = {
96
103
  );
97
104
  }
98
105
  const manifestPath = safeTarget(projectDir, MANIFEST);
99
- if (!fs.existsSync(manifestPath))
100
- throw new Error('BMAD+ is not installed in this directory. Run bmad-plus install first.');
106
+ if (!fs.existsSync(manifestPath)) throw new Error(notInstalledMessage(projectDir));
101
107
  let manifest;
102
108
  try {
103
109
  manifest = readInstallManifest(manifestPath);
@@ -1,5 +1,6 @@
1
1
  /** Read the manifest fields used by maintenance commands without unsafe coercion. */
2
2
  const fs = require('node:fs');
3
+ const path = require('node:path');
3
4
 
4
5
  function readInstallManifest(manifestPath) {
5
6
  const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
@@ -21,4 +22,21 @@ function readInstallManifest(manifestPath) {
21
22
  return manifest;
22
23
  }
23
24
 
24
- module.exports = { readInstallManifest };
25
+ /**
26
+ * Guidance for maintenance commands run outside an installed project. A person who
27
+ * opens a terminal in a drive root or a parent folder must learn where to run them.
28
+ */
29
+ function notInstalledMessage(projectDir) {
30
+ return [
31
+ `No BMAD+ installation found in ${projectDir} (missing _bmad/.bmad-plus-install.json).`,
32
+ 'Run this command from your project folder, for example: cd "path/to/your-project"',
33
+ 'or pass the folder explicitly: --directory "path/to/your-project".',
34
+ 'To install BMAD+ in this folder instead: npx bmad-plus@latest install',
35
+ ].join('\n');
36
+ }
37
+
38
+ function hasInstallManifest(projectDir) {
39
+ return fs.existsSync(path.join(projectDir, '_bmad', '.bmad-plus-install.json'));
40
+ }
41
+
42
+ module.exports = { readInstallManifest, notInstalledMessage, hasInstallManifest };
@@ -191,7 +191,7 @@ const DERIVED = {
191
191
  "product": {
192
192
  "code": "bmad-plus",
193
193
  "displayName": "BMAD+",
194
- "version": "0.17.1",
194
+ "version": "0.19.0",
195
195
  "derivedFrom": "BMAD-METHOD v6.6.0"
196
196
  },
197
197
  "packOrder": [