@electriccitizen/bolt 0.1.0 → 0.2.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.
Files changed (84) hide show
  1. package/README.md +93 -14
  2. package/dist/ai/agent.d.ts +66 -0
  3. package/dist/ai/agent.js +232 -0
  4. package/dist/ai/agent.js.map +1 -0
  5. package/dist/ai/knowledge/composer.md +90 -0
  6. package/dist/ai/knowledge/config-safety.md +46 -0
  7. package/dist/ai/knowledge/ddev-operations.md +41 -0
  8. package/dist/ai/knowledge/drupal-internals.md +52 -0
  9. package/dist/ai/knowledge/drupal-updates.md +90 -0
  10. package/dist/ai/knowledge/knowledge/composer.md +90 -0
  11. package/dist/ai/knowledge/knowledge/config-safety.md +46 -0
  12. package/dist/ai/knowledge/knowledge/ddev-operations.md +41 -0
  13. package/dist/ai/knowledge/knowledge/drupal-debugging.md +89 -0
  14. package/dist/ai/knowledge/knowledge/drupal-internals.md +52 -0
  15. package/dist/ai/knowledge/knowledge/drupal-updates.md +90 -0
  16. package/dist/ai/prompts/analyze-ticket.d.ts +30 -0
  17. package/dist/ai/prompts/analyze-ticket.js +116 -0
  18. package/dist/ai/prompts/analyze-ticket.js.map +1 -0
  19. package/dist/ai/prompts/fix-ticket.d.ts +27 -0
  20. package/dist/ai/prompts/fix-ticket.js +129 -0
  21. package/dist/ai/prompts/fix-ticket.js.map +1 -0
  22. package/dist/ai/prompts/pr-description.d.ts +19 -0
  23. package/dist/ai/prompts/pr-description.js +56 -0
  24. package/dist/ai/prompts/pr-description.js.map +1 -0
  25. package/dist/ai/prompts/update-package.d.ts +25 -0
  26. package/dist/ai/prompts/update-package.js +87 -0
  27. package/dist/ai/prompts/update-package.js.map +1 -0
  28. package/dist/ai/prompts/update-plan.d.ts +20 -0
  29. package/dist/ai/prompts/update-plan.js +66 -0
  30. package/dist/ai/prompts/update-plan.js.map +1 -0
  31. package/dist/ai/schemas/analysis-result.d.ts +44 -0
  32. package/dist/ai/schemas/analysis-result.js +101 -0
  33. package/dist/ai/schemas/analysis-result.js.map +1 -0
  34. package/dist/ai/schemas/fix-result.d.ts +34 -0
  35. package/dist/ai/schemas/fix-result.js +55 -0
  36. package/dist/ai/schemas/fix-result.js.map +1 -0
  37. package/dist/ai/schemas/pr-body.d.ts +12 -0
  38. package/dist/ai/schemas/pr-body.js +18 -0
  39. package/dist/ai/schemas/pr-body.js.map +1 -0
  40. package/dist/ai/schemas/update-plan.d.ts +20 -0
  41. package/dist/ai/schemas/update-plan.js +33 -0
  42. package/dist/ai/schemas/update-plan.js.map +1 -0
  43. package/dist/ai/schemas/update-result.d.ts +22 -0
  44. package/dist/ai/schemas/update-result.js +30 -0
  45. package/dist/ai/schemas/update-result.js.map +1 -0
  46. package/dist/cli.js +63 -1
  47. package/dist/cli.js.map +1 -1
  48. package/dist/commands/analyze.d.ts +25 -0
  49. package/dist/commands/analyze.js +377 -0
  50. package/dist/commands/analyze.js.map +1 -0
  51. package/dist/commands/doctor.js +61 -13
  52. package/dist/commands/doctor.js.map +1 -1
  53. package/dist/commands/fix.d.ts +35 -0
  54. package/dist/commands/fix.js +480 -0
  55. package/dist/commands/fix.js.map +1 -0
  56. package/dist/commands/init.d.ts +3 -2
  57. package/dist/commands/init.js +117 -160
  58. package/dist/commands/init.js.map +1 -1
  59. package/dist/commands/pr.d.ts +4 -0
  60. package/dist/commands/pr.js +121 -3
  61. package/dist/commands/pr.js.map +1 -1
  62. package/dist/commands/refresh.js +10 -57
  63. package/dist/commands/refresh.js.map +1 -1
  64. package/dist/commands/update.d.ts +2 -0
  65. package/dist/commands/update.js +463 -64
  66. package/dist/commands/update.js.map +1 -1
  67. package/dist/config.d.ts +16 -0
  68. package/dist/config.js +57 -0
  69. package/dist/config.js.map +1 -1
  70. package/dist/runner.js +12 -0
  71. package/dist/runner.js.map +1 -1
  72. package/dist/safety.d.ts +63 -0
  73. package/dist/safety.js +192 -0
  74. package/dist/safety.js.map +1 -0
  75. package/dist/types.d.ts +2 -0
  76. package/package.json +2 -3
  77. package/modules/bolt_inspect/bolt_inspect.info.yml +0 -6
  78. package/modules/bolt_inspect/bolt_inspect.services.yml +0 -22
  79. package/modules/bolt_inspect/composer.json +0 -16
  80. package/modules/bolt_inspect/drush.services.yml +0 -10
  81. package/modules/bolt_inspect/src/Drush/Commands/BoltInspectCommands.php +0 -203
  82. package/modules/bolt_inspect/src/Service/ContentGenerator.php +0 -586
  83. package/modules/bolt_inspect/src/Service/SiteProfiler.php +0 -362
  84. package/modules/bolt_inspect/src/Service/TestEntityTracker.php +0 -98
@@ -6,10 +6,16 @@
6
6
  */
7
7
  import { execFile } from 'child_process';
8
8
  import { promisify } from 'util';
9
- import { createInterface } from 'readline';
9
+ import { mkdtempSync, rmSync, existsSync } from 'fs';
10
+ import { join } from 'path';
11
+ import { tmpdir } from 'os';
10
12
  import { DdevAdapter } from '../adapters/ddev.js';
11
13
  import { runTests } from '../runner.js';
12
14
  import { loadConfig } from '../config.js';
15
+ import { isClaudeAvailable, runAgent } from '../ai/agent.js';
16
+ import { buildUpdatePackageTask } from '../ai/prompts/update-package.js';
17
+ import { validateUpdateOutput } from '../ai/schemas/update-result.js';
18
+ import { checkGitState, requireCleanTree, warnUnpushedCommits, confirm } from '../safety.js';
13
19
  const execFileAsync = promisify(execFile);
14
20
  /** Write progress messages to stderr. */
15
21
  function log(msg) {
@@ -24,6 +30,37 @@ function logFail(msg) {
24
30
  function logWarn(msg) {
25
31
  process.stderr.write(` ⚠ ${msg}\n`);
26
32
  }
33
+ /**
34
+ * Ensure bolt_inspect module is available after git/composer operations.
35
+ * Composer update or git checkout can remove the path repo, so we
36
+ * re-add it if needed.
37
+ */
38
+ async function ensureBoltInspect() {
39
+ // Quick check: is the module already enabled?
40
+ const check = await exec('ddev', ['drush', 'pm:list', '--status=enabled', '--filter=bolt_inspect', '--format=json'], 30_000);
41
+ if (check.exitCode === 0 && check.stdout.includes('bolt_inspect')) {
42
+ return; // Module is still enabled.
43
+ }
44
+ log('Restoring bolt_inspect module...');
45
+ // Check if the package is still in composer.
46
+ const showResult = await exec('ddev', ['composer', 'show', 'electriccitizen/bolt-inspect'], 10_000);
47
+ if (showResult.exitCode !== 0) {
48
+ // Package not installed — require it from Packagist.
49
+ const requireResult = await exec('ddev', ['composer', 'require', 'electriccitizen/bolt-inspect', '--no-interaction'], 120_000);
50
+ if (requireResult.exitCode !== 0) {
51
+ logWarn(`Failed to install bolt-inspect: ${requireResult.stderr.trim()}`);
52
+ return;
53
+ }
54
+ }
55
+ // Enable the module.
56
+ const enResult = await exec('ddev', ['drush', 'en', 'bolt_inspect', '-y'], 30_000);
57
+ if (enResult.exitCode !== 0) {
58
+ logWarn(`Failed to enable bolt_inspect: ${enResult.stderr.trim()}`);
59
+ }
60
+ else {
61
+ logOk('bolt_inspect restored');
62
+ }
63
+ }
27
64
  function formatDuration(ms) {
28
65
  if (ms < 1000)
29
66
  return `${ms}ms`;
@@ -51,15 +88,6 @@ async function exec(command, args, timeoutMs = 300_000) {
51
88
  return { stdout: '', stderr: String(err), exitCode: 1 };
52
89
  }
53
90
  }
54
- async function confirm(question) {
55
- const rl = createInterface({ input: process.stdin, output: process.stderr });
56
- return new Promise((resolve) => {
57
- rl.question(` ${question} (y/N) `, (answer) => {
58
- rl.close();
59
- resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
60
- });
61
- });
62
- }
63
91
  /**
64
92
  * Get the current git branch name.
65
93
  */
@@ -170,6 +198,221 @@ function filterCandidates(candidates, options, config) {
170
198
  });
171
199
  return filtered;
172
200
  }
201
+ /**
202
+ * Try multiple strategies to resolve a Composer update.
203
+ * Returns the successful attempt or null if all failed.
204
+ */
205
+ async function resolveComposerUpdate(packageName, targetVersion) {
206
+ const attempts = [];
207
+ // Strategy 1: Standard update with dependencies.
208
+ {
209
+ const args = ['composer', 'update', packageName, '--with-dependencies'];
210
+ log(` Strategy 1: composer update ${packageName} --with-dependencies`);
211
+ const result = await exec('ddev', args, 300_000);
212
+ attempts.push({
213
+ strategy: 'update --with-dependencies',
214
+ command: args,
215
+ succeeded: result.exitCode === 0,
216
+ output: result.stderr,
217
+ });
218
+ if (result.exitCode === 0) {
219
+ const version = await getInstalledVersion(packageName);
220
+ return { success: true, attempts, resolvedVersion: version };
221
+ }
222
+ log(' → Failed, diagnosing...');
223
+ }
224
+ // Diagnose: run composer why-not to understand the constraint.
225
+ const whyNotResult = await exec('ddev', ['composer', 'why-not', packageName, targetVersion], 30_000);
226
+ if (whyNotResult.stdout.trim()) {
227
+ log(' Constraint analysis:');
228
+ const lines = whyNotResult.stdout.trim().split('\n').slice(0, 5);
229
+ for (const line of lines) {
230
+ log(` ${line.trim()}`);
231
+ }
232
+ }
233
+ // Strategy 2: Update with all dependencies (broader resolution).
234
+ {
235
+ // Reset any partial changes from strategy 1.
236
+ await exec('git', ['checkout', '.']);
237
+ await exec('git', ['clean', '-fd', '-e', '.boltrc.yml', '-e', '.bolt/']);
238
+ const args = ['composer', 'update', packageName, '--with-all-dependencies'];
239
+ log(` Strategy 2: composer update ${packageName} --with-all-dependencies`);
240
+ const result = await exec('ddev', args, 300_000);
241
+ attempts.push({
242
+ strategy: 'update --with-all-dependencies',
243
+ command: args,
244
+ succeeded: result.exitCode === 0,
245
+ output: result.stderr,
246
+ });
247
+ if (result.exitCode === 0) {
248
+ const version = await getInstalledVersion(packageName);
249
+ return { success: true, attempts, resolvedVersion: version };
250
+ }
251
+ log(' → Failed, trying broader resolution...');
252
+ }
253
+ // Strategy 3: Require with a caret constraint (let Composer find the best compatible version).
254
+ {
255
+ await exec('git', ['checkout', '.']);
256
+ await exec('git', ['clean', '-fd', '-e', '.boltrc.yml', '-e', '.bolt/']);
257
+ // Parse the major.minor from target to build a caret constraint.
258
+ const caretConstraint = buildCaretConstraint(targetVersion);
259
+ const args = ['composer', 'require', `${packageName}:${caretConstraint}`, '--update-with-all-dependencies'];
260
+ log(` Strategy 3: composer require ${packageName}:${caretConstraint} --update-with-all-dependencies`);
261
+ const result = await exec('ddev', args, 300_000);
262
+ attempts.push({
263
+ strategy: `require ${caretConstraint} --update-with-all-dependencies`,
264
+ command: args,
265
+ succeeded: result.exitCode === 0,
266
+ output: result.stderr,
267
+ });
268
+ if (result.exitCode === 0) {
269
+ const version = await getInstalledVersion(packageName);
270
+ return { success: true, attempts, resolvedVersion: version };
271
+ }
272
+ log(' → Failed, trying minor version range...');
273
+ }
274
+ // Strategy 4: Try requiring with a tilde constraint (more conservative — same minor).
275
+ {
276
+ await exec('git', ['checkout', '.']);
277
+ await exec('git', ['clean', '-fd', '-e', '.boltrc.yml', '-e', '.bolt/']);
278
+ const tildeConstraint = buildTildeConstraint(targetVersion);
279
+ if (tildeConstraint !== buildCaretConstraint(targetVersion)) {
280
+ const args = ['composer', 'require', `${packageName}:${tildeConstraint}`, '--update-with-all-dependencies'];
281
+ log(` Strategy 4: composer require ${packageName}:${tildeConstraint} --update-with-all-dependencies`);
282
+ const result = await exec('ddev', args, 300_000);
283
+ attempts.push({
284
+ strategy: `require ${tildeConstraint} --update-with-all-dependencies`,
285
+ command: args,
286
+ succeeded: result.exitCode === 0,
287
+ output: result.stderr,
288
+ });
289
+ if (result.exitCode === 0) {
290
+ const version = await getInstalledVersion(packageName);
291
+ return { success: true, attempts, resolvedVersion: version };
292
+ }
293
+ log(' → Failed, trying with core update...');
294
+ }
295
+ }
296
+ // Strategy 5: Update the package alongside drupal/core-* (common Drupal constraint pattern).
297
+ {
298
+ await exec('git', ['checkout', '.']);
299
+ await exec('git', ['clean', '-fd', '-e', '.boltrc.yml', '-e', '.bolt/']);
300
+ const args = [
301
+ 'composer', 'update', packageName,
302
+ 'drupal/core-recommended', 'drupal/core-composer-scaffold', 'drupal/core-project-message',
303
+ '--with-all-dependencies',
304
+ ];
305
+ log(` Strategy 5: update ${packageName} alongside drupal/core-*`);
306
+ const result = await exec('ddev', args, 300_000);
307
+ attempts.push({
308
+ strategy: 'update with drupal/core-* --with-all-dependencies',
309
+ command: args,
310
+ succeeded: result.exitCode === 0,
311
+ output: result.stderr,
312
+ });
313
+ if (result.exitCode === 0) {
314
+ const version = await getInstalledVersion(packageName);
315
+ return { success: true, attempts, resolvedVersion: version };
316
+ }
317
+ }
318
+ // All strategies exhausted.
319
+ log(' All resolution strategies exhausted');
320
+ await exec('git', ['checkout', '.']);
321
+ await exec('git', ['clean', '-fd', '-e', '.boltrc.yml', '-e', '.bolt/']);
322
+ return { success: false, attempts };
323
+ }
324
+ /**
325
+ * AI-powered update: hand the package to Claude for intelligent conflict resolution.
326
+ * Claude handles: composer update, conflict diagnosis, drush updb, watchdog, config export.
327
+ * Returns a structured result or null if the agent fails to produce one.
328
+ */
329
+ async function aiUpdate(candidate, coreVersion, branchName, siteUrl, baselineTestCount, baselineSuppressedCount) {
330
+ const task = buildUpdatePackageTask({
331
+ candidate,
332
+ coreVersion,
333
+ branchName,
334
+ siteUrl,
335
+ baselineTestCount,
336
+ baselineSuppressedCount,
337
+ });
338
+ log('Using AI reasoning for update...');
339
+ const result = await runAgent(task);
340
+ if (!result.success) {
341
+ logWarn(`AI agent did not return structured output`);
342
+ if (result.rawResponse) {
343
+ // Log first few lines of the raw response for debugging.
344
+ const lines = result.rawResponse.split('\n').slice(0, 5);
345
+ for (const line of lines) {
346
+ log(` ${line}`);
347
+ }
348
+ }
349
+ return null;
350
+ }
351
+ const validated = validateUpdateOutput(result.output);
352
+ if (!validated) {
353
+ logWarn('AI agent output failed schema validation');
354
+ return null;
355
+ }
356
+ if (result.costUsd) {
357
+ log(` AI cost: $${result.costUsd.toFixed(4)} (${result.numTurns} turns, ${formatDuration(result.duration)})`);
358
+ }
359
+ return validated;
360
+ }
361
+ /**
362
+ * Get the current Drupal core version from composer.
363
+ */
364
+ async function getCoreVersion() {
365
+ const result = await exec('ddev', ['composer', 'show', 'drupal/core', '--format=json'], 15_000);
366
+ if (result.exitCode === 0) {
367
+ try {
368
+ const data = JSON.parse(result.stdout);
369
+ return data.versions?.[0] ?? data.version ?? 'unknown';
370
+ }
371
+ catch {
372
+ // Fall through.
373
+ }
374
+ }
375
+ return 'unknown';
376
+ }
377
+ /**
378
+ * Get the currently installed version of a package from composer.lock.
379
+ */
380
+ async function getInstalledVersion(packageName) {
381
+ const result = await exec('ddev', ['composer', 'show', packageName, '--format=json'], 15_000);
382
+ if (result.exitCode !== 0)
383
+ return undefined;
384
+ try {
385
+ const data = JSON.parse(result.stdout);
386
+ return data.versions?.[0] ?? data.version;
387
+ }
388
+ catch {
389
+ // Try parsing the version line from non-JSON output.
390
+ const match = result.stdout.match(/versions?\s*:\s*\*?\s*([\d.]+)/);
391
+ return match?.[1];
392
+ }
393
+ }
394
+ /**
395
+ * Build a caret constraint from a version string.
396
+ * "1.15.0" → "^1.15"
397
+ */
398
+ function buildCaretConstraint(version) {
399
+ const parts = version.replace(/^v/, '').split('.');
400
+ if (parts.length >= 2) {
401
+ return `^${parts[0]}.${parts[1]}`;
402
+ }
403
+ return `^${parts[0]}`;
404
+ }
405
+ /**
406
+ * Build a tilde constraint from a version string.
407
+ * "1.15.0" → "~1.15"
408
+ */
409
+ function buildTildeConstraint(version) {
410
+ const parts = version.replace(/^v/, '').split('.');
411
+ if (parts.length >= 2) {
412
+ return `~${parts[0]}.${parts[1]}`;
413
+ }
414
+ return `~${parts[0]}`;
415
+ }
173
416
  /**
174
417
  * Sanitize a package name for use in a branch name.
175
418
  */
@@ -199,11 +442,56 @@ async function runBaselineTest(adapter, config, siteUrl) {
199
442
  return { passed: failCount === 0, failCount, passCount, suppressedCount };
200
443
  }
201
444
  /**
202
- * Run a quick test suite for a single module update verification.
445
+ * Capture VR baselines for the current site state.
446
+ * Returns the temp directory path containing baseline screenshots.
203
447
  */
204
- async function runModuleTest(adapter, config, siteUrl) {
205
- // Same as baseline — run the full test suite to catch regressions.
206
- return runBaselineTest(adapter, config, siteUrl);
448
+ async function captureVrBaselines(adapter, config, siteUrl) {
449
+ const baselineDir = mkdtempSync(join(tmpdir(), 'bolt-vr-'));
450
+ // Run VR plugin only, capturing baselines (no comparison — no prior baseline exists).
451
+ // Remove visual-regression from skip list so it runs even if user has it skipped.
452
+ const skipList = (config.plugins.skip ?? []).filter((p) => p !== 'visual-regression');
453
+ const testOptions = {
454
+ url: siteUrl,
455
+ mode: 'read-only', // VR is read-only, no need for full mode just for screenshots.
456
+ plugins: ['visual-regression'],
457
+ output: 'json',
458
+ exitCode: false,
459
+ failOn: 'major',
460
+ headed: false,
461
+ vrBaseline: baselineDir,
462
+ screenshots: join(baselineDir, 'current'),
463
+ configSkip: skipList.length > 0 ? skipList : undefined,
464
+ boltConfig: config,
465
+ };
466
+ await runTests(adapter, testOptions);
467
+ return baselineDir;
468
+ }
469
+ /**
470
+ * Run a test suite for a single module update verification.
471
+ * If vrBaselineDir is provided, includes VR comparison against pre-update baselines.
472
+ */
473
+ async function runModuleTest(adapter, config, siteUrl, vrBaselineDir) {
474
+ // Remove visual-regression from skip list when we have baselines to compare.
475
+ const skipList = vrBaselineDir
476
+ ? (config.plugins.skip ?? []).filter((p) => p !== 'visual-regression')
477
+ : (config.plugins.skip ?? []);
478
+ const testOptions = {
479
+ url: siteUrl,
480
+ mode: 'full',
481
+ output: 'json',
482
+ exitCode: false,
483
+ failOn: 'major',
484
+ headed: false,
485
+ vrBaseline: vrBaselineDir,
486
+ screenshots: vrBaselineDir ? join(vrBaselineDir, 'post-update') : undefined,
487
+ configSkip: skipList.length > 0 ? skipList : undefined,
488
+ boltConfig: config,
489
+ };
490
+ const report = await runTests(adapter, testOptions);
491
+ const failCount = report.results.filter((r) => r.status === 'fail').length;
492
+ const passCount = report.results.filter((r) => r.status === 'pass').length;
493
+ const suppressedCount = report.results.filter((r) => r.status === 'suppressed').length;
494
+ return { passed: failCount === 0, failCount, passCount, suppressedCount };
207
495
  }
208
496
  export async function updateCommand(options) {
209
497
  const startTime = Date.now();
@@ -213,6 +501,13 @@ export async function updateCommand(options) {
213
501
  const results = [];
214
502
  process.stderr.write('\nbolt update\n');
215
503
  process.stderr.write('━'.repeat(50) + '\n\n');
504
+ const baseBranch = config.refresh.base_branch || 'main';
505
+ // --- Git safety checks (skip for dry-run) ---
506
+ if (!options.dryRun) {
507
+ const gitState = await checkGitState(baseBranch);
508
+ requireCleanTree(gitState); // Blocks with exit 2 if dirty — update uses git clean for rollback.
509
+ warnUnpushedCommits(gitState, 'will be left behind after branch switch');
510
+ }
216
511
  // Verify DDEV connectivity.
217
512
  const connected = await adapter.canConnect();
218
513
  if (!connected) {
@@ -226,8 +521,29 @@ export async function updateCommand(options) {
226
521
  process.stderr.write('Error: Could not detect site URL from DDEV.\n');
227
522
  process.exit(2);
228
523
  }
524
+ // --- AI mode detection ---
525
+ let useAi = false;
526
+ if (!options.noAi) {
527
+ const claudeReady = await isClaudeAvailable();
528
+ if (claudeReady) {
529
+ useAi = true;
530
+ logOk('AI mode enabled (Claude Code detected)');
531
+ }
532
+ else {
533
+ log('Claude Code not found — using scripted fallback');
534
+ }
535
+ }
536
+ else {
537
+ log('AI mode disabled (--no-ai)');
538
+ }
539
+ // Get core version for AI compatibility checks.
540
+ const coreVersion = useAi ? await getCoreVersion() : 'unknown';
541
+ // Track baseline counts for AI context.
542
+ let baselinePassCount = 0;
543
+ let baselineSuppressedCount = 0;
544
+ // VR baseline directory (captured pre-update, compared post-update per module).
545
+ let vrBaselineDir;
229
546
  // --- Pre-flight ---
230
- const baseBranch = config.refresh.base_branch || 'main';
231
547
  // 1a. Pre-update refresh (code sync).
232
548
  if (!options.skipRefresh) {
233
549
  log('Pre-update refresh...');
@@ -273,12 +589,24 @@ export async function updateCommand(options) {
273
589
  'Fix the failures or run `bolt suppress` to baseline known issues.\n');
274
590
  process.exit(1);
275
591
  }
592
+ baselinePassCount = baseline.passCount;
593
+ baselineSuppressedCount = baseline.suppressedCount;
276
594
  logOk(`Baseline test passed (${baseline.passCount} passed, ${baseline.suppressedCount} suppressed)`);
277
595
  }
278
596
  catch (err) {
279
597
  logFail(`Baseline test error: ${err instanceof Error ? err.message : String(err)}`);
280
598
  process.exit(2);
281
599
  }
600
+ // 1b-vr. Capture VR baselines from current (pre-update) state.
601
+ log('Capturing visual regression baselines...');
602
+ try {
603
+ vrBaselineDir = await captureVrBaselines(adapter, config, siteUrl);
604
+ logOk('VR baselines captured (will compare after each update)');
605
+ }
606
+ catch (err) {
607
+ logWarn(`VR baseline capture failed: ${err instanceof Error ? err.message : String(err)}`);
608
+ log('Updates will proceed without visual regression checks.');
609
+ }
282
610
  }
283
611
  // 1c. Get outdated packages.
284
612
  log('Checking for outdated packages...');
@@ -369,62 +697,117 @@ export async function updateCommand(options) {
369
697
  });
370
698
  continue;
371
699
  }
372
- // Composer update.
700
+ // --- Update path: AI or scripted ---
373
701
  log(`Updating ${candidate.name}...`);
374
- const updateResult = await exec('ddev', ['composer', 'update', candidate.name, '--with-dependencies'], 300_000);
375
- if (updateResult.exitCode !== 0) {
376
- logFail(`composer update failed: ${updateResult.stderr.trim()}`);
377
- // Rollback.
378
- await exec('git', ['checkout', '.']);
379
- await exec('git', ['clean', '-fd']);
380
- await exec('git', ['checkout', baseBranch]);
381
- await exec('git', ['branch', '-D', branchName]);
382
- results.push({
383
- package: candidate.name,
384
- oldVersion: candidate.currentVersion,
385
- newVersion: candidate.latestVersion,
386
- status: 'conflict',
387
- branch: branchName,
388
- error: updateResult.stderr.trim(),
389
- duration: Date.now() - moduleStart,
390
- });
391
- continue;
702
+ let resolvedVersion = candidate.latestVersion;
703
+ let dbUpdates = 0;
704
+ let updateStrategy = '';
705
+ let updateAttempts;
706
+ let aiUsedSuccessfully = false;
707
+ if (useAi) {
708
+ // AI path — Claude reasons through the update autonomously.
709
+ const aiResult = await aiUpdate(candidate, coreVersion, branchName, siteUrl, baselinePassCount, baselineSuppressedCount);
710
+ if (aiResult && aiResult.success) {
711
+ aiUsedSuccessfully = true;
712
+ resolvedVersion = aiResult.newVersion || candidate.latestVersion;
713
+ dbUpdates = aiResult.dbUpdatesApplied ? 1 : 0;
714
+ updateStrategy = `AI: ${aiResult.strategy}`;
715
+ logOk(`AI updated to ${resolvedVersion} (${aiResult.strategy})`);
716
+ if (aiResult.watchdogErrors.length > 0) {
717
+ logWarn(`Watchdog errors detected: ${aiResult.watchdogErrors.length}`);
718
+ for (const err of aiResult.watchdogErrors.slice(0, 3)) {
719
+ log(` ${err}`);
720
+ }
721
+ }
722
+ }
723
+ else if (aiResult && !aiResult.success) {
724
+ logWarn(`AI update failed: ${aiResult.error ?? 'unknown error'}`);
725
+ log('Falling back to scripted resolution...');
726
+ }
727
+ else {
728
+ logWarn('AI did not return a result. Falling back to scripted resolution...');
729
+ }
392
730
  }
393
- // Database updates.
394
- log('Running database updates...');
395
- const updbResult = await exec('ddev', ['drush', 'updb', '-y']);
396
- const dbUpdates = updbResult.stdout.includes('No pending updates') ? 0 : 1;
397
- if (updbResult.exitCode !== 0) {
398
- logFail(`drush updb failed: ${updbResult.stderr.trim()}`);
399
- // Rollback.
400
- await exec('git', ['checkout', '.']);
401
- await exec('git', ['clean', '-fd']);
402
- await exec('git', ['checkout', baseBranch]);
403
- await exec('git', ['branch', '-D', branchName]);
404
- results.push({
405
- package: candidate.name,
406
- oldVersion: candidate.currentVersion,
407
- newVersion: candidate.latestVersion,
408
- status: 'fail',
409
- branch: branchName,
410
- error: `drush updb failed: ${updbResult.stderr.trim()}`,
411
- duration: Date.now() - moduleStart,
412
- });
413
- continue;
731
+ // Scripted fallback (or primary path when --no-ai).
732
+ if (!aiUsedSuccessfully) {
733
+ const resolution = await resolveComposerUpdate(candidate.name, candidate.latestVersion);
734
+ if (!resolution.success) {
735
+ const strategiesTried = resolution.attempts.length;
736
+ logFail(`All ${strategiesTried} resolution strategies failed for ${candidate.name}`);
737
+ // Show the last meaningful error.
738
+ const lastAttempt = resolution.attempts[resolution.attempts.length - 1];
739
+ if (lastAttempt?.output) {
740
+ const errorLines = lastAttempt.output.trim().split('\n').slice(0, 3);
741
+ for (const line of errorLines) {
742
+ log(` ${line}`);
743
+ }
744
+ }
745
+ // Rollback (already clean from resolver, but ensure we're on base branch).
746
+ await exec('git', ['checkout', baseBranch]);
747
+ await exec('git', ['branch', '-D', branchName]);
748
+ results.push({
749
+ package: candidate.name,
750
+ oldVersion: candidate.currentVersion,
751
+ newVersion: candidate.latestVersion,
752
+ status: 'conflict',
753
+ branch: branchName,
754
+ error: `${strategiesTried} strategies failed. Last: ${lastAttempt?.strategy}`,
755
+ attempts: resolution.attempts.map((a) => a.strategy),
756
+ duration: Date.now() - moduleStart,
757
+ });
758
+ continue;
759
+ }
760
+ // Resolution succeeded — log which strategy worked.
761
+ const winningStrategy = resolution.attempts.find((a) => a.succeeded);
762
+ resolvedVersion = resolution.resolvedVersion ?? candidate.latestVersion;
763
+ updateStrategy = winningStrategy?.strategy ?? 'standard update';
764
+ updateAttempts = resolution.attempts.length > 1
765
+ ? resolution.attempts.map((a) => a.strategy)
766
+ : undefined;
767
+ if (resolution.attempts.length > 1) {
768
+ logOk(`Resolved via: ${winningStrategy?.strategy} (version ${resolvedVersion})`);
769
+ }
770
+ else {
771
+ logOk(`Updated to ${resolvedVersion}`);
772
+ }
773
+ // Database updates.
774
+ log('Running database updates...');
775
+ const updbResult = await exec('ddev', ['drush', 'updb', '-y']);
776
+ dbUpdates = updbResult.stdout.includes('No pending updates') ? 0 : 1;
777
+ if (updbResult.exitCode !== 0) {
778
+ logFail(`drush updb failed: ${updbResult.stderr.trim()}`);
779
+ // Rollback.
780
+ await exec('git', ['checkout', '.']);
781
+ await exec('git', ['clean', '-fd', '-e', '.boltrc.yml', '-e', '.bolt/']);
782
+ await exec('git', ['checkout', baseBranch]);
783
+ await exec('git', ['branch', '-D', branchName]);
784
+ results.push({
785
+ package: candidate.name,
786
+ oldVersion: candidate.currentVersion,
787
+ newVersion: candidate.latestVersion,
788
+ status: 'fail',
789
+ branch: branchName,
790
+ error: `drush updb failed: ${updbResult.stderr.trim()}`,
791
+ duration: Date.now() - moduleStart,
792
+ });
793
+ continue;
794
+ }
795
+ // Cache rebuild.
796
+ await exec('ddev', ['drush', 'cr']);
414
797
  }
415
- // Cache rebuild.
416
- await exec('ddev', ['drush', 'cr']);
798
+ // Ensure bolt_inspect is available (composer update can displace it).
799
+ await ensureBoltInspect();
417
800
  // Run tests.
418
801
  log('Running regression tests...');
419
802
  let testResult;
420
803
  try {
421
- testResult = await runModuleTest(adapter, config, siteUrl);
804
+ testResult = await runModuleTest(adapter, config, siteUrl, vrBaselineDir);
422
805
  }
423
806
  catch (err) {
424
807
  logFail(`Test error: ${err instanceof Error ? err.message : String(err)}`);
425
808
  // Rollback.
426
809
  await exec('git', ['checkout', '.']);
427
- await exec('git', ['clean', '-fd']);
810
+ await exec('git', ['clean', '-fd', '-e', '.boltrc.yml', '-e', '.bolt/']);
428
811
  await exec('git', ['checkout', baseBranch]);
429
812
  await exec('git', ['branch', '-D', branchName]);
430
813
  results.push({
@@ -442,7 +825,7 @@ export async function updateCommand(options) {
442
825
  logFail(`Tests failed: ${testResult.failCount} failure(s)`);
443
826
  // Rollback.
444
827
  await exec('git', ['checkout', '.']);
445
- await exec('git', ['clean', '-fd']);
828
+ await exec('git', ['clean', '-fd', '-e', '.boltrc.yml', '-e', '.bolt/']);
446
829
  await exec('git', ['checkout', baseBranch]);
447
830
  await exec('git', ['branch', '-D', branchName]);
448
831
  results.push({
@@ -476,11 +859,17 @@ export async function updateCommand(options) {
476
859
  }
477
860
  // Stage and commit.
478
861
  log('Committing...');
479
- await exec('git', ['add', '-A']);
862
+ await exec('git', ['add', '-A', '--', '.', ':!.boltrc.yml', ':!.bolt/']);
863
+ const actualVersion = resolvedVersion;
864
+ const resolutionNote = aiUsedSuccessfully
865
+ ? `Resolution: ${updateStrategy}`
866
+ : updateAttempts && updateAttempts.length > 1
867
+ ? `Resolution: ${updateStrategy} (${updateAttempts.length} strategies tried)`
868
+ : `Composer: ddev composer update ${candidate.name} --with-dependencies`;
480
869
  const commitMessage = [
481
- `Update ${candidate.name} from ${candidate.currentVersion} to ${candidate.latestVersion}`,
870
+ `Update ${candidate.name} from ${candidate.currentVersion} to ${actualVersion}`,
482
871
  '',
483
- `Composer: ddev composer update ${candidate.name} --with-dependencies`,
872
+ resolutionNote,
484
873
  `Database: drush updb (${dbUpdates > 0 ? 'updates applied' : 'no pending updates'})`,
485
874
  `Config: drush cex (${configChanges.length > 0 ? `${configChanges.length} files changed` : 'no changes'})`,
486
875
  '',
@@ -499,7 +888,7 @@ export async function updateCommand(options) {
499
888
  results.push({
500
889
  package: candidate.name,
501
890
  oldVersion: candidate.currentVersion,
502
- newVersion: candidate.latestVersion,
891
+ newVersion: actualVersion,
503
892
  status: 'pass',
504
893
  branch: branchName,
505
894
  commitHash,
@@ -510,12 +899,22 @@ export async function updateCommand(options) {
510
899
  },
511
900
  configChanges,
512
901
  dbUpdates,
902
+ attempts: updateAttempts,
513
903
  duration: Date.now() - moduleStart,
514
904
  });
515
905
  // Return to base branch for next module.
516
906
  await exec('git', ['checkout', baseBranch]);
517
907
  process.stderr.write('\n');
518
908
  }
909
+ // Clean up VR baseline temp directory.
910
+ if (vrBaselineDir && existsSync(vrBaselineDir)) {
911
+ try {
912
+ rmSync(vrBaselineDir, { recursive: true, force: true });
913
+ }
914
+ catch {
915
+ // Non-fatal.
916
+ }
917
+ }
519
918
  // --- Report ---
520
919
  const totalDuration = Date.now() - startTime;
521
920
  const updated = results.filter((r) => r.status === 'pass').length;