Skip to content

fix(installer): replace fs-extra with native fs to prevent non-deterministic file loss#1780

Open
adambiggs wants to merge 4 commits intobmad-code-org:mainfrom
adambiggs:fix/installer-nondeterministic-file-loss
Open

fix(installer): replace fs-extra with native fs to prevent non-deterministic file loss#1780
adambiggs wants to merge 4 commits intobmad-code-org:mainfrom
adambiggs:fix/installer-nondeterministic-file-loss

Conversation

@adambiggs
Copy link
Contributor

@adambiggs adambiggs commented Feb 26, 2026

What

Replace fs-extra entirely with a thin native node:fs / node:fs/promises wrapper to fix non-deterministic file loss during multi-module installation.

Why

fs-extra routes all operations through graceful-fs, which globally monkey-patches node:fs with an EMFILE retry queue (setTimeout(retry, 0)). Even though the installer code is fully sequential (every fs.copy() is individually awaited in for...of loops), graceful-fs's deferred retries can fire after an await resolves.

Each module installation starts with fs.remove(targetPath) followed by file-by-file copying into the same location. Deferred retry-queued unlink operations from the remove phase can fire after subsequent copies have written files, silently deleting them. Single-module installs (~200 file ops) are unlikely to trigger this, but multi-module installs (~580 file ops for bmm+bmb+tea) hit it frequently — ~50% of runs lost 26+ files in our testing.

Fixes #1779

How

  • Add tools/cli/lib/fs.js — thin native wrapper providing the same API surface (copy, remove, move, ensureDir, pathExists, readJsonSync, etc.) backed by node:fs and node:fs/promises directly, bypassing graceful-fs
  • Copy and remove operations use synchronous native calls (copyFileSync, mkdirSync, rmSync) so when an await resolves, the operation is definitively complete with no deferred background work
  • Update all 40 source files + 1 test file to require the wrapper instead of fs-extra
  • Remove fs-extra from package.json dependencies

Testing

  • Reproduced the bug on both 6.0.2 and 6.0.3, in both interactive and non-interactive modes
  • Verified 8+ consecutive multi-module install runs with zero file loss after the fix
  • Added 37-test suite (test/test-fs-wrapper.js) covering all wrapper methods including a 250-file bulk copy determinism test
  • All existing 108 tests pass, ESLint clean, markdownlint clean, prettier clean

@augmentcode
Copy link

augmentcode bot commented Feb 26, 2026

🤖 Augment PR Summary

Summary: Replaces fs-extra usage in the CLI installer/tooling with a thin wrapper around native node:fs/node:fs/promises to eliminate non-deterministic file loss during multi-module installs.

Changes:

  • Adds tools/cli/lib/fs.js implementing the fs-extra methods this repo relies on (copy, remove, move, ensureDir, pathExists, readJsonSync) while re-exporting native fs members.
  • Updates CLI commands, installers, and helper scripts to import the wrapper instead of fs-extra.
  • Makes copy/remove operations deterministic by using synchronous native primitives internally while keeping async-compatible call sites.
  • Adds a dedicated wrapper test suite (test/test-fs-wrapper.js) and wires it into npm test via test:fs.
  • Removes fs-extra from package.json dependencies.

Technical Notes: This avoids graceful-fs's deferred retry queue behavior, preventing queued unlinks from firing after subsequent copies and silently deleting freshly written files.

🤖 Was this summary useful? React with 👍 or 👎

Copy link

@augmentcode augmentcode bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review completed. 2 suggestions posted.

Fix All in Augment

Comment augment review to trigger a new review at any time.

@coderabbitai
Copy link

coderabbitai bot commented Feb 26, 2026

📝 Walkthrough

Walkthrough

Removes fs-extra dependency and replaces it with a custom native Node.js fs wrapper module to eliminate non-deterministic file loss on macOS. All codebase imports are updated to use the new local wrapper instead of the external package.

Changes

Cohort / File(s) Summary
Dependency Removal & Test Scripts
package.json
Removed fs-extra from dependencies; added test:fs script running node test/test-fs-wrapper.js; updated test script to include npm run test:fs.
Custom fs Wrapper Implementation
tools/cli/lib/fs.js
New module providing drop-in fs-extra replacement using native Node.js fs and fs/promises. Exports all native fs members and implements async utilities (ensureDir, pathExists, copy, remove, move, readJsonSync) with sync/async variants and overwrite/cross-device semantics.
CLI Commands & Migration Tools
tools/cli/commands/status.js, tools/cli/commands/uninstall.js, tools/migrate-custom-module-paths.js
Updated require paths from fs-extra to local ../lib/fs or ./cli/lib/fs.
Core Library Modules
tools/cli/lib/activation-builder.js, tools/cli/lib/agent-analyzer.js, tools/cli/lib/agent-party-generator.js, tools/cli/lib/config.js, tools/cli/lib/file-ops.js, tools/cli/lib/platform-codes.js, tools/cli/lib/project-root.js, tools/cli/lib/ui.js, tools/cli/lib/xml-handler.js, tools/cli/lib/yaml-xml-builder.js
Replaced require('fs-extra') with require('./fs') for local wrapper usage.
Installer Core Modules
tools/cli/installers/lib/core/config-collector.js, tools/cli/installers/lib/core/custom-module-cache.js, tools/cli/installers/lib/core/dependency-resolver.js, tools/cli/installers/lib/core/detector.js, tools/cli/installers/lib/core/ide-config-manager.js, tools/cli/installers/lib/core/installer.js, tools/cli/installers/lib/core/manifest-generator.js, tools/cli/installers/lib/core/manifest.js
Updated fs imports from fs-extra to ../../../lib/fs.
Installer Custom & IDE Handlers
tools/cli/installers/lib/custom/handler.js, tools/cli/installers/lib/ide/_base-ide.js, tools/cli/installers/lib/ide/_config-driven.js, tools/cli/installers/lib/ide/codex.js, tools/cli/installers/lib/ide/github-copilot.js, tools/cli/installers/lib/ide/kilo.js, tools/cli/installers/lib/ide/manager.js, tools/cli/installers/lib/ide/platform-codes.js, tools/cli/installers/lib/ide/rovodev.js
Replaced fs-extra requires with ../../../lib/fs imports.
Installer IDE Shared Utilities
tools/cli/installers/lib/ide/shared/agent-command-generator.js, tools/cli/installers/lib/ide/shared/bmad-artifacts.js, tools/cli/installers/lib/ide/shared/module-injections.js, tools/cli/installers/lib/ide/shared/task-tool-command-generator.js, tools/cli/installers/lib/ide/shared/workflow-command-generator.js
Updated fs imports from fs-extra to ../../../../lib/fs.
Other Installer Modules
tools/cli/installers/lib/message-loader.js, tools/cli/installers/lib/modules/external-manager.js, tools/cli/installers/lib/modules/manager.js
Replaced fs-extra with local fs wrapper; manager.js also includes minor formatting updates (multiline option objects, const/let adjustments).
Test Suite
test/test-fs-wrapper.js, test/test-installation-components.js
Added comprehensive fs wrapper test suite validating API compatibility; updated test-installation-components.js to use new local fs module and reformatted buildAgent calls to multiline options.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested reviewers

  • alexeyv
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main change: replacing fs-extra with native fs to fix file loss on macOS.
Linked Issues check ✅ Passed The PR fully implements the objectives from #1779: replacing fs-extra with a native fs wrapper, using synchronous operations to eliminate race conditions, adding comprehensive tests, and verifying deterministic behavior across multiple runs.
Out of Scope Changes check ✅ Passed All changes are directly scoped to replacing fs-extra with the native fs wrapper. The 40+ file updates, new fs.js module, test additions, and package.json removal are all necessary and in-scope for fixing the identified issue.
Description check ✅ Passed The PR description clearly relates to the changeset, describing the replacement of fs-extra with a native fs wrapper and detailing the reasons, approach, and testing.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (40)
tools/cli/lib/agent-analyzer.js (2)

41-44: ⚠️ Potential issue | 🟠 Major

exec.route type is not validated before string ops.

Line 43 calls .endsWith() on exec.route when truthy; non-string values will crash analysis. Gate this with typeof exec.route === 'string'.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/lib/agent-analyzer.js` around lines 41 - 44, The code calls
exec.route.endsWith(...) without ensuring exec.route is a string; update the
check in the agent-analyzer logic so you first verify typeof exec.route ===
'string' before calling .endsWith(), and only then add
profile.usedAttributes.add('workflow'); this prevents runtime errors for
non-string route values while keeping the existing workflow detection behavior.

21-27: ⚠️ Potential issue | 🟠 Major

Missing guard for malformed parsed YAML.

analyzeAgentObject assumes agentYaml is an object. If parsing yields null/scalar, Line 21 will throw. Add an early shape check.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/lib/agent-analyzer.js` around lines 21 - 27, analyzeAgentObject
currently assumes agentYaml is an object and will throw if YAML parsing returns
null or a scalar; add an early shape check at the start of analyzeAgentObject to
verify agentYaml is a non-null object (and that agentYaml.agent is an object)
and bail out or set safe defaults (e.g., leave profile flags false and menuItems
= []) if the check fails; update code paths that set profile.hasPrompts and
compute menuItems (the existing references to agentYaml.agent,
profile.hasPrompts, and menuItems) to run only after this guard so malformed
parsed YAML cannot cause a runtime exception.
tools/cli/installers/lib/core/manifest.js (4)

156-168: ⚠️ Potential issue | 🔴 Critical

update() silently ignores customModules updates.

addCustomModule() and removeCustomModule() pass { customModules: ... }, but update() never applies that field, so changes are dropped.

Proposed fix
     if (updates.ides) {
       manifest.ides = updates.ides;
     }
+    if (updates.customModules) {
+      manifest.customModules = updates.customModules;
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/core/manifest.js` around lines 156 - 168, The
update() path in manifest.js currently ignores updates.customModules so calls
from addCustomModule() and removeCustomModule() are dropped; modify update() to
apply updates.customModules (e.g. set or merge into manifest.customModules) by
checking if (updates.customModules) and updating manifest.customModules
accordingly so custom module changes from addCustomModule()/removeCustomModule()
persist.

941-956: ⚠️ Potential issue | 🟠 Major

HTTP fallback has no timeout and no status validation.

This request can hang and blocks update checks. Add a timeout + non-200 handling before parsing payload.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/core/manifest.js` around lines 941 - 956, The HTTPS
lookup Promise around https.get(`https://registry.npmjs.org/${packageName}`)
lacks a request timeout and doesn't validate HTTP status before JSON.parse,
which can hang or parse error on non-200 responses; update the promise to set a
request timeout (e.g., using req.setTimeout or a manual timer that calls
req.abort()/resolve(null) after a short interval) and check res.statusCode !==
200 (or not in 2xx) in the response handler to immediately resolve(null) before
attempting to accumulate/JSON.parse the body, and also ensure error and timeout
paths both resolve(null) and clear any timers to avoid leaks.

22-22: ⚠️ Potential issue | 🟠 Major

Version lookup from process.cwd() is brittle and context-dependent.

Line 22 can read the wrong package.json when CLI is invoked from another directory. Use getProjectRoot() (already imported) for deterministic behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/core/manifest.js` at line 22, The version lookup
uses process.cwd() which can point to the wrong package.json; update the
fallback in manifest.js so that bmadVersion uses getProjectRoot() instead of
process.cwd() — i.e., replace the require(path.join(process.cwd(),
'package.json')).version reference with one that requires package.json from
getProjectRoot() so bmadVersion deterministically reads the project root
package.json.

933-939: ⚠️ Potential issue | 🟡 Minor

Use execFileSync with array arguments instead of template string interpolation for the npm command.

The package names passed to this function come from external-official-modules.yaml (project-controlled configuration) and user-provided module.yaml files. While npm's strict naming rules restrict package names to lowercase letters, numbers, hyphens, underscores, dots, and forward slashes—preventing injection of shell metacharacters—using execFileSync('npm', ['view', packageName, 'version'], {...}) follows security best practices and eliminates dependency on npm's validation as a control. Apply this for defense-in-depth.

Recommended fix
-      const { execSync } = require('node:child_process');
+      const { execFileSync } = require('node:child_process');

       // Try using npm view first (more reliable)
       try {
-        const result = execSync(`npm view ${packageName} version`, {
+        const result = execFileSync('npm', ['view', packageName, 'version'], {
           encoding: 'utf8',
           stdio: 'pipe',
           timeout: 10_000,
         });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/core/manifest.js` around lines 933 - 939, Replace
the execSync call that interpolates packageName into a shell string with
execFileSync invoked with the npm executable and an argument array to avoid
shell interpretation; specifically, change the call that currently does
execSync(`npm view ${packageName} version`, { encoding, stdio, timeout }) to
execFileSync('npm', ['view', packageName, 'version'], { encoding: 'utf8', stdio:
'pipe', timeout: 10_000 }) while preserving the same options and return
semantics around the packageName variable and existing try/catch in manifest.js.
tools/cli/installers/lib/modules/external-manager.js (1)

29-31: ⚠️ Potential issue | 🟠 Major

Null parse result can crash module listing.

If YAML is empty/invalid-but-parseable to null, this.cachedModules = config stores null, then Line 46 accesses config.modules and throws. Normalize parsed output before caching.

Proposed fix
-      const config = yaml.parse(content);
-      this.cachedModules = config;
-      return config;
+      const parsed = yaml.parse(content);
+      const config = parsed && typeof parsed === 'object' ? parsed : { modules: {} };
+      if (!config.modules || typeof config.modules !== 'object') {
+        config.modules = {};
+      }
+      this.cachedModules = config;
+      return config;

Also applies to: 46-46

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/modules/external-manager.js` around lines 29 - 31,
yaml.parse(content) can return null, which then sets this.cachedModules to null
and later causes an exception when accessing config.modules; change the logic
around yaml.parse(content) in external-manager.js to normalize the parsed result
before caching (e.g., let config = yaml.parse(content) || {}; ensure
config.modules is at least an empty array if code expects it) and assign
this.cachedModules = config so subsequent accesses like config.modules are safe.
tools/cli/installers/lib/core/manifest-generator.js (3)

213-216: ⚠️ Potential issue | 🟡 Minor

Workflow manifest paths can contain // for top-level files.

When relativePath is empty, Lines 215-216 generate .../workflows//workflow.yaml. Normalize this branch to avoid double separators in manifests.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/core/manifest-generator.js` around lines 213 - 216,
The installPath construction sometimes produces a double slash when relativePath
is empty (yielding .../workflows//workflow.yaml); update the logic that builds
installPath (the expression using moduleName, this.bmadFolderName, relativePath
and entry.name) to normalize or omit the extra separator when relativePath is
empty—either by using a path-joining helper or by conditional concatenation so
the path becomes .../workflows/workflow.yaml for top-level files.

1036-1038: ⚠️ Potential issue | 🟠 Major

files-manifest.csv rows are written without escaping.

Manual interpolation at Line 1037 breaks CSV when any value contains quotes, commas, or newlines. Reuse the same escaping helper used elsewhere.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/core/manifest-generator.js` around lines 1036 -
1038, The CSV row construction in the loop over allFiles currently interpolates
values directly into csv (csv += `"${file.type}",...`) which breaks when fields
contain quotes, commas or newlines; update the loop in manifest-generator.js to
call the existing CSV field-escaping helper used elsewhere (e.g., escapeCsvField
or the project's CSV escape utility) for each file property (file.type,
file.name, file.module, file.path, file.hash) and then join those escaped values
with commas and append a newline so rows are written safely.

776-785: ⚠️ Potential issue | 🟠 Major

Existing CSV parse is unguarded and can fail the entire manifest write.

If agent-manifest.csv is malformed, csv.parse throws and aborts generation. This should degrade gracefully (same issue pattern applies in task/tool manifest reads).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/core/manifest-generator.js` around lines 776 - 785,
The agent-manifest.csv read/parsing is unguarded: wrap the csv.parse call and
subsequent record loop in a try/catch so a malformed CSV doesn't throw and abort
generation; specifically surround the fs.readFile + csv.parse + for (const
record of records) { existingEntries.set(`${record.module}:${record.name}`,
record); } sequence with error handling that logs the parse error (including
csvPath/agent-manifest.csv) and continues with an empty or previously
accumulated existingEntries, and apply the same guarded parse pattern to the
task/tool manifest reads that also call csv.parse.
tools/cli/lib/config.js (3)

122-130: ⚠️ Potential issue | 🟠 Major

validateConfig trusts inputs and can throw on null/invalid args.

If config or schema is null/non-object, checks like field in config can throw. Add a defensive guard and return a structured validation error.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/lib/config.js` around lines 122 - 130, The validateConfig function
currently assumes config and schema are objects and may throw when called with
null/invalid args; add a defensive guard at the start of validateConfig to check
that both config and schema are non-null objects (e.g., typeof === 'object' &&
config !== null), and if not return a structured validation result (errors array
containing a clear message like "Invalid or missing config/schema" and empty or
appropriate warnings) instead of continuing to run checks like schema.required
and using `field in config`; ensure all subsequent code in validateConfig uses
the validated local variables.

59-64: ⚠️ Potential issue | 🟠 Major

Placeholder replacement can mutate unintended text.

Line 59 merges raw replacements keys into allReplacements; if caller passes keys like root, this loop replaces every root occurrence globally, not just template tokens.

Proposed fix
-    const allReplacements = { ...standardReplacements, ...replacements };
+    const allReplacements = { ...standardReplacements, ...replacements };

     for (const [placeholder, value] of Object.entries(allReplacements)) {
-      if (typeof placeholder === 'string' && typeof value === 'string') {
+      if (
+        typeof placeholder === 'string' &&
+        placeholder.startsWith('{') &&
+        placeholder.endsWith('}') &&
+        typeof value === 'string'
+      ) {
         const regex = new RegExp(placeholder.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`), 'g');
         content = content.replace(regex, value);
       }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/lib/config.js` around lines 59 - 64, The replacement loop currently
treats raw keys (placeholder) as plain text, causing unintended global
replacements; update the loop that builds regex (involving allReplacements,
standardReplacements, replacements, placeholder, and content.replace) to target
only explicit template tokens instead of bare words—e.g., construct the token
form you expect (such as `{{${placeholder}}}` or another delimiter used by
standardReplacements), escape that token for regex, and replace only that token
instance globally; ensure placeholder.replaceAll escaping remains but is applied
to the token string rather than the raw placeholder so only template markers are
replaced.

30-34: ⚠️ Potential issue | 🔴 Critical

Change yaml.dump() to yaml.stringify()dump is not available in yaml@2.7.0.

Line 30 uses yaml.dump(), but the installed version (yaml@2.7.0) provides only yaml.stringify(). Additionally, noRefs is not a valid option in eemeli/yaml; it's specific to js-yaml. Remove it since other stringify calls in the codebase don't use it.

Proposed fix
-    const yamlContent = yaml.dump(config, {
+    const yamlContent = yaml.stringify(config, {
       indent: 2,
       lineWidth: 120,
-      noRefs: true,
     });

Note: tools/migrate-custom-module-paths.js (line 66) has the same issue.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/lib/config.js` around lines 30 - 34, Replace the unsupported
yaml.dump call with yaml.stringify and remove the invalid noRefs option: update
the assignment that creates yamlContent to call yaml.stringify(config, { indent:
2, lineWidth: 120 }) instead of yaml.dump(..., { indent: 2, lineWidth: 120,
noRefs: true }); apply the same change to the other occurrence that uses dump
(e.g., the similar assignment near the migrate flow) so all YAML serialization
uses yaml.stringify with only valid options.
tools/cli/lib/xml-handler.js (2)

130-132: ⚠️ Potential issue | 🔴 Critical

injectActivationSimple also dereferences undefined error.

Same runtime failure pattern here: this method throws before doing any useful work.

Safer fallback
   injectActivationSimple(agentContent, metadata = {}) {
-    console.error('Error in simple injection:', error);
+    return this.injectActivation(agentContent, metadata);
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/lib/xml-handler.js` around lines 130 - 132, The function
injectActivationSimple incorrectly references an undefined variable error,
causing a runtime crash; update injectActivationSimple(agentContent, metadata =
{}) to perform its intended injection logic and remove the stray
console.error('Error in simple injection:', error) or replace it with a proper
try/catch that logs the caught error (e.g., catch (err) {
console.error('injectActivationSimple failed:', err) }); ensure any error
variable used comes from the catch block or a passed-in parameter and keep the
metadata/default handling intact.

46-48: ⚠️ Potential issue | 🔴 Critical

loadActivationTemplate is broken and throws immediately.

Line 47 references error out of scope, causing ReferenceError every time this method runs. This is a hard runtime failure.

Minimal safe fix
   async loadActivationTemplate() {
-    console.error('Failed to load activation template:', error);
+    throw new Error('loadActivationTemplate is not implemented');
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/lib/xml-handler.js` around lines 46 - 48, The
loadActivationTemplate method currently references an out-of-scope variable
`error`, causing a ReferenceError; wrap the template-loading logic in a
try/catch inside the loadActivationTemplate function and move the console.error
call into the catch block using the caught variable (e.g., catch (error) {
console.error('Failed to load activation template:', error); }), or otherwise
ensure the logged variable is the caught exception; keep the same function name
loadActivationTemplate and preserve existing success behavior (rethrow or return
a safe value as appropriate).
tools/cli/installers/lib/ide/github-copilot.js (3)

581-585: ⚠️ Potential issue | 🟠 Major

YAML tools list can break on unescaped custom tool names.

Custom tool names are injected into single-quoted YAML scalars without escaping '. One bad entry can corrupt frontmatter output.

Proposed fix
   getToolsForFile(fileName) {
     const defaultTools = ['read', 'edit', 'search', 'execute'];
     const tools = (this.existingToolPermissions && this.existingToolPermissions.get(fileName)) || defaultTools;
-    return '[' + tools.map((t) => `'${t}'`).join(', ') + ']';
+    return '[' + tools.map((t) => `'${this.escapeYamlSingleQuote(String(t))}'`).join(', ') + ']';
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/ide/github-copilot.js` around lines 581 - 585, The
getToolsForFile method injects tool names into single-quoted YAML scalars
without escaping, so any tool containing a single quote breaks the frontmatter;
update getToolsForFile (in tools/cli/installers/lib/ide/github-copilot.js) to
escape single quotes in each tool name before wrapping with single quotes (e.g.,
replace each ' with '' per YAML single-quote escaping) when building the tools
list string so the returned value remains valid YAML even for custom tool names.

47-47: ⚠️ Potential issue | 🟡 Minor

cleanup() is called without forwarding caller options.

Line 47 drops silent/mode flags from setup(), so cleanup logging and behavior can diverge from requested run mode.

Proposed fix
-    await this.cleanup(projectDir);
+    await this.cleanup(projectDir, options);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/ide/github-copilot.js` at line 47, The call to
cleanup(projectDir) inside setup() drops caller options (e.g., silent/mode
flags), causing cleanup to run with wrong logging/behavior; update the setup
implementation to forward the same options object/flags it received into
this.cleanup (e.g., pass the options or this.options like silent/mode) so
cleanup receives and honors the caller's flags; locate the setup method and the
cleanup(projectDir) invocation and change it to call cleanup with the propagated
options (matching cleanup's signature) so logging and behavior remain
consistent.

206-210: ⚠️ Potential issue | 🟠 Major

Prompt filename is not sanitized before path join.

entry.command is used directly in a filename. Path separators or traversal tokens can escape promptsDir or create invalid files.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/ide/github-copilot.js` around lines 206 - 210, The
code uses entry.command directly to build promptFileName, allowing path
separators/traversal; sanitize entry.command before producing promptFileName
(e.g., use path.basename to strip dirs, then replace or whitelist characters to
allow only [A-Za-z0-9._-] or run a slugify/encode routine), ensure removal of
".." and path.sep, enforce a non-empty safe fallback, then use that sanitized
value when calling getToolsForFile, createWorkflowPromptContent, building
promptPath with path.join(promptsDir, sanitizedName) and passing to writeFile;
update variable names (promptFileName -> sanitizedPromptFileName) and add a
validation step that throws or logs on invalid commands.
tools/cli/lib/project-root.js (1)

35-36: ⚠️ Potential issue | 🟠 Major

Fallback to process.cwd() can write to the wrong project.

When discovery fails, silently returning CWD risks installing/manipulating files in an unrelated directory. This should fail fast with a clear error.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/lib/project-root.js` around lines 35 - 36, The current fallback in
project-root.js that silently returns process.cwd() when project discovery fails
should be changed to fail fast: update the function that currently returns
process.cwd() (the module's project discovery/get-root function) to throw a
clear, descriptive Error (or reject a Promise) indicating the project root could
not be found instead of returning CWD; include guidance in the error text about
running the command from a project directory or initializing the project, and
ensure callers of this function (any code invoking the project-root module)
handle the thrown error appropriately.
tools/migrate-custom-module-paths.js (1)

44-52: ⚠️ Potential issue | 🟡 Minor

Bug: Logging deleted property on line 52.

Line 50 deletes customModule.relativePath, but line 52 attempts to log it. This will output undefined in the success message.

🐛 Proposed fix
   if (customModule.relativePath && !customModule.sourcePath) {
     // Convert relative path to absolute
     const absolutePath = path.resolve(projectRoot, customModule.relativePath);
+    const originalRelativePath = customModule.relativePath;
     customModule.sourcePath = absolutePath;

     // Remove the old relativePath
     delete customModule.relativePath;

-    console.log(chalk.green(`  ✓ Updated ${customModule.id}: ${customModule.relativePath} → ${absolutePath}`));
+    console.log(chalk.green(`  ✓ Updated ${customModule.id}: ${originalRelativePath} → ${absolutePath}`));
     updated = true;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/migrate-custom-module-paths.js` around lines 44 - 52, The success log
references customModule.relativePath after it was deleted, causing "undefined";
before deleting or overwriting, capture the original relative path (e.g., store
it in a variable like originalRelative = customModule.relativePath) and use that
in the console.log message when reporting the update for the customModule.id and
absolutePath, then delete customModule.relativePath and set
customModule.sourcePath as currently done.
tools/cli/commands/uninstall.js (2)

57-60: ⚠️ Potential issue | 🟠 Major

Guard bmadDir before calling fs.pathExists.

Line 59 assumes installer.findBmadDir(projectDir) always returns a valid path. If it returns null/undefined, this call can throw and bypass the intended “No BMAD installation found” path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/commands/uninstall.js` around lines 57 - 60, Check the result of
installer.findBmadDir(projectDir) for null/undefined before calling
fs.pathExists: if bmadDir is falsy, call prompts.log.warn('No BMAD installation
found.') and return/exit early; otherwise proceed to await
fs.pathExists(bmadDir). This prevents calling fs.pathExists with an invalid path
and preserves the intended "No BMAD installation found" flow.

121-142: ⚠️ Potential issue | 🟠 Major

Spinner cleanup is not exception-safe.

Each phase starts a spinner and stops it only on success. If Line 124/132/140 throws, the spinner is left active and output becomes messy.

Proposed pattern
       if (removeIdeConfigs) {
         const s = await prompts.spinner();
-        s.start('Removing IDE integrations...');
-        await installer.uninstallIdeConfigs(projectDir, existingInstall, { silent: true });
-        s.stop(`Removed IDE integrations (${ides || 'none'})`);
+        s.start('Removing IDE integrations...');
+        try {
+          await installer.uninstallIdeConfigs(projectDir, existingInstall, { silent: true });
+          s.stop(`Removed IDE integrations (${ides || 'none'})`);
+        } catch (e) {
+          s.stop('Failed removing IDE integrations');
+          throw e;
+        }
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/commands/uninstall.js` around lines 121 - 142, The spinner started
via prompts.spinner() around each phase (see calls to prompts.spinner(), s.start
and s.stop) is not exception-safe; if installer.uninstallIdeConfigs,
installer.uninstallOutputFolder, or installer.uninstallModules throws the
spinner remains active. Fix by creating the spinner before each phase and
wrapping the async phase call in try/finally so s.stop(...) is always invoked
(optionally with a different message on error in a catch block), e.g., for the
blocks around installer.uninstallIdeConfigs, installer.uninstallOutputFolder and
installer.uninstallModules ensure s.stop is called in finally so the spinner is
cleaned up on success or failure.
tools/cli/installers/lib/core/detector.js (2)

93-94: ⚠️ Potential issue | 🟠 Major

Detection can crash on unreadable directories.

Both directory scans call fs.readdir(...) without protective try/catch. A permission/transient error will throw and fail detection instead of returning partial/empty results.

Also applies to: 159-160

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/core/detector.js` around lines 93 - 94, The two
fs.readdir calls in detector.js (the one that populates the local variable
entries at the bmadDir scan and the other at the later scan around lines
159-160) can throw on unreadable directories; wrap each fs.readdir(...) call in
a try/catch and fall back to an empty array on error (optionally log a
debug/warn via the same logger used in this module) so detection continues
instead of crashing; update the blocks that iterate over entries (the for (const
entry of entries) loops) to work with the fallback empty array.

65-67: ⚠️ Potential issue | 🟠 Major

Validate manifestData.modules as an array before iteration.

Current guard checks truthiness and .length, then iterates. A string/object value can pass and produce garbage module records.

Proposed fix
-    if (manifestData && manifestData.modules && manifestData.modules.length > 0) {
+    if (manifestData && Array.isArray(manifestData.modules) && manifestData.modules.length > 0) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/core/detector.js` around lines 65 - 67, The loop
over manifestData.modules can iterate non-array values; change the guard to
explicitly verify Array.isArray(manifestData.modules) (and that length > 0)
before iterating so only a real array is used. Locate the block that checks
manifestData && manifestData.modules && manifestData.modules.length and replace
the truthy check with an Array.isArray check for manifestData.modules to prevent
strings/objects from producing invalid module records.
tools/cli/installers/lib/ide/_config-driven.js (2)

320-335: ⚠️ Potential issue | 🟠 Major

Template path inputs are not constrained to the template root.

header_template / body_template are joined directly into filesystem paths. Values like ../... can escape templates/split and read arbitrary files.

Proposed containment check
-      const headerPath = path.join(__dirname, 'templates', 'split', headerTpl);
+      const splitRoot = path.join(__dirname, 'templates', 'split');
+      const headerPath = path.resolve(splitRoot, headerTpl);
+      if (!headerPath.startsWith(splitRoot + path.sep)) {
+        throw new Error(`Invalid header_template path: ${headerTpl}`);
+      }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/ide/_config-driven.js` around lines 320 - 335, The
header_template/body_template values (headerTpl, bodyTpl) are joined directly to
form headerPath/bodyPath allowing path traversal; fix by resolving the candidate
path against the templates root (the resolved root for 'templates/split') and
validate it does not escape that root (use path.resolve and either path.relative
or startsWith on the resolved root) before reading; apply the same containment
check for headerPath, defaultHeaderPath (when using templateType) and bodyPath,
and throw/skip with a clear error if the resolved path is outside the templates
root.

38-40: ⚠️ Potential issue | 🟠 Major

Ancestor conflict detection ignores multi-target installers.

findAncestorConflict() checks only installerConfig.target_dir. For configs using targets[], conflicts in ancestor directories for those targets are never checked, so duplicate command trees can still be installed.

Also applies to: 570-572

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/ide/_config-driven.js` around lines 38 - 40, The
ancestor-conflict check only examines installerConfig.target_dir and therefore
misses configs that declare multiple targets in installerConfig.targets; update
the logic around the call to findAncestorConflict(projectDir) (and the duplicate
call at the other location around lines noted) to iterate through
installerConfig.targets (or normalize targets into a single array) and call
findAncestorConflict(projectDir, targetDir) for each targetDir (or modify
findAncestorConflict to accept a targetDir parameter and check ancestor paths
for that target); ensure the check short-circuits and returns/confirms a
conflict if any target reports an ancestor conflict.
tools/cli/installers/lib/ide/codex.js (2)

33-35: ⚠️ Potential issue | 🟠 Major

setup() does a full artifact collection twice and reports mixed counters.

First pass (collectClaudeArtifacts) is used for counts/artifacts; second pass is used for actual writes. This can drift under filesystem changes and overstates work done in logs.

Also applies to: 47-92

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/ide/codex.js` around lines 33 - 35, The setup()
function currently calls collectClaudeArtifacts twice which causes mismatched
artifacts and counts; change setup() so it calls
collectClaudeArtifacts(projectDir, bmadDir, options) only once, keep the
returned { artifacts, counts } and reuse that artifacts list for the subsequent
write/cleanup steps instead of re-running collection; update any logging to use
the single counts value and remove the second invocation so
collectClaudeArtifacts, artifacts, and counts remain the single source of truth.

52-52: ⚠️ Potential issue | 🟠 Major

Tasks are incorrectly module-filtered in IDE install flow.

Tasks are fetched with selectedModules at Lines 52 and 147. In this layer, only agents should be module-scoped; workflows/tasks are intended to be global.

Proposed fix
-    const tasks = await getTasksFromBmad(bmadDir, options.selectedModules || []);
+    const tasks = await getTasksFromBmad(bmadDir);
...
-    const tasks = await getTasksFromBmad(bmadDir, selectedModules);
+    const tasks = await getTasksFromBmad(bmadDir);
Based on learnings: "In the BMAD installer, for files under tools/cli/installers/lib/ide/, treat workflows and tasks as global artifacts that are always installed regardless of selected modules. Only agents are module-scoped and should be filtered by selectedModules."

Also applies to: 147-147

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/ide/codex.js` at line 52, The code calls
getTasksFromBmad(bmadDir, options.selectedModules || []) (at both occurrences)
which incorrectly limits tasks to selected modules; change these calls to fetch
tasks/workflows globally (remove the options.selectedModules argument so
getTasksFromBmad is invoked without module-filtering) while ensuring only
agent-related fetches still receive options.selectedModules for module scoping;
update both getTasksFromBmad invocations (the ones passing
options.selectedModules) to call without that second parameter so
workflows/tasks are always installed.
tools/cli/installers/lib/ide/shared/agent-command-generator.js (1)

97-102: ⚠️ Potential issue | 🟠 Major

Nested agent launchers can overwrite each other.

Lines 100-101 always write ${artifact.name}.md under <module>/agents. If two agents share name in different nested folders, one overwrites the other.

Proposed fix (preserve nested path)
-        const launcherPath = path.join(moduleAgentsDir, `${artifact.name}.md`);
+        const modulePrefix = path.join(artifact.module, 'agents') + path.sep;
+        const relativeInModule =
+          artifact.relativePath?.startsWith(modulePrefix)
+            ? artifact.relativePath.slice(modulePrefix.length)
+            : `${artifact.name}.md`;
+        const launcherPath = path.join(moduleAgentsDir, relativeInModule);
+        await fs.ensureDir(path.dirname(launcherPath));
         await fs.writeFile(launcherPath, artifact.content);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/ide/shared/agent-command-generator.js` around lines
97 - 102, The code always writes launcher files to moduleAgentsDir using
`${artifact.name}.md`, causing collisions when agents with the same name live in
nested folders; change the write path to preserve the agent's nested path (e.g.,
use artifact.path or artifact.relativePath within the module) by building
launcherPath with path.join(moduleAgentsDir, artifact.path,
`${artifact.name}.md`) or path.join(moduleAgentsDir, artifact.relativePath) and
ensure the parent directory exists (fs.ensureDir on path.dirname(launcherPath))
before fs.writeFile; keep using writtenCount++ after the successful write.
tools/cli/installers/lib/custom/handler.js (3)

316-322: ⚠️ Potential issue | 🟠 Major

Ensure customize directory exists before writing template file.

Line 321 writes customizePath without creating .../_config/agents. On first-run installs this can fail with ENOENT and skip customization file creation.

Proposed fix
         if (!(await fs.pathExists(customizePath))) {
           const { getSourcePath } = require('../../../lib/project-root');
           const genericTemplatePath = getSourcePath('utility', 'agent-components', 'agent.customize.template.yaml');
           if (await fs.pathExists(genericTemplatePath)) {
             let templateContent = await fs.readFile(genericTemplatePath, 'utf8');
+            await fs.ensureDir(path.dirname(customizePath));
             await fs.writeFile(customizePath, templateContent, 'utf8');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/custom/handler.js` around lines 316 - 322, The code
writes the customization file at customizePath without guaranteeing its parent
directory exists, causing ENOENT on first-run; before calling
fs.writeFile(customizePath, ...) ensure the containing directory (e.g.,
path.dirname(customizePath)) is created (use fs.ensureDir or fs.mkdir with {
recursive: true }) after the fs.pathExists check for genericTemplatePath; keep
the rest of the logic (getSourcePath/genericTemplatePath/templateContent) the
same and then write the file once the directory has been ensured.

47-48: ⚠️ Potential issue | 🟠 Major

Path exclusion check is overly broad and can skip valid directories.

fullPath.startsWith(exclude) will also exclude siblings like <root>/src2 when <root>/src is excluded.

Proposed boundary-safe check
-          if (excludePaths.some((exclude) => fullPath.startsWith(exclude))) {
+          if (excludePaths.some((exclude) => fullPath === exclude || fullPath.startsWith(exclude + path.sep))) {
             continue;
           }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/custom/handler.js` around lines 47 - 48, The
exclusion check using fullPath.startsWith(exclude) is too broad and will wrongly
exclude siblings (e.g., /root/src2 when /root/src is excluded); update the .some
callback that checks excludePaths so it only matches exact directory boundaries:
resolve both exclude and fullPath (use path.resolve) and then test either
fullPath === exclude OR fullPath.startsWith(exclude + path.sep), or use
path.relative(exclude, fullPath) and ensure the result does not start with '..'
(and is not equal to '..') to confirm fullPath is inside exclude; apply this
change to the callback that references excludePaths and fullPath in handler.js.

89-112: ⚠️ Potential issue | 🟠 Major

Validate YAML parse result before reading fields.

After yaml.parse, code assumes an object and accesses config.code, config.name, etc. Non-object YAML (null/string/list) will throw and turn parse issues into generic read errors.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/custom/handler.js` around lines 89 - 112, After
parsing YAML into config, ensure config is an object before reading fields:
check that config is not null, is of type 'object', and not an Array (validate
in the handler where yaml.parse is called and before accessing config.code,
config.name, config.description, config.default_selected). If the validation
fails, log a clear warning (include configPath and the type/value found) and
return null (same failure path as parse errors) so non-object YAML
(null/string/list) does not cause runtime exceptions when building the returned
object.
tools/cli/installers/lib/ide/rovodev.js (2)

163-170: ⚠️ Potential issue | 🟠 Major

Cleanup has unhandled directory-read failures.

fs.readdir(workflowsPath) is not wrapped in try/catch. Permission or transient FS errors will abort cleanup instead of best-effort removal.

Also applies to: 205-210

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/ide/rovodev.js` around lines 163 - 170, The
directory read can throw and abort cleanup; wrap the fs.readdir(workflowsPath)
call in a try/catch so permission/transient errors are caught and do not stop
best-effort removal: inside the block where you check
this.pathExists(workflowsPath) (and the similar block around the second
occurrence), try to await fs.readdir(workflowsPath) in a try/catch, log or
ignore the error and return/continue gracefully, and only iterate/remove entries
(using fs.remove(path.join(workflowsPath, entry))) when readdir succeeded; this
ensures bmad-* entries are still removed when possible without crashing on
readdir failures.

99-108: ⚠️ Potential issue | 🟠 Major

prompts.yml can receive duplicate name entries.

_collectPromptEntries() pushes flattened names blindly. If two artifacts normalize to the same dash name, prompts.yml gets duplicate command names and ambiguous behavior.

Also applies to: 138-147

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/ide/rovodev.js` around lines 99 - 108, The code in
_collectPromptEntries creates duplicate prompt names by deriving name from
toDashPath(artifact.relativePath) without checking uniqueness; update
_collectPromptEntries to detect name collisions before pushing to writtenFiles
(use a Set or map of existing names) and, when a collision occurs, generate a
deterministic unique name (e.g., append a numeric suffix like -1, -2 or include
more of the relative path) and use that as the name while keeping
description/contentFile unchanged; apply the same deduplication change to the
other identical prompt-collection logic referenced in the file so prompts.yml
cannot receive duplicate name entries.
tools/cli/installers/lib/ide/platform-codes.js (1)

22-24: ⚠️ Potential issue | 🟠 Major

Validate parsed YAML shape before dereferencing config.platforms.

Line 23 accepts any YAML value; Lines 46/58/69/80 assume config.platforms is an object. A malformed or scalar YAML will throw at runtime.

Proposed hardening
   const content = await fs.readFile(PLATFORM_CODES_PATH, 'utf8');
   _cachedPlatformCodes = yaml.parse(content);
+  if (
+    !_cachedPlatformCodes ||
+    typeof _cachedPlatformCodes !== 'object' ||
+    !_cachedPlatformCodes.platforms ||
+    typeof _cachedPlatformCodes.platforms !== 'object'
+  ) {
+    throw new Error(`Invalid platform codes configuration at: ${PLATFORM_CODES_PATH}`);
+  }
   return _cachedPlatformCodes;

Also applies to: 45-60

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/ide/platform-codes.js` around lines 22 - 24, After
parsing PLATFORM_CODES_PATH into _cachedPlatformCodes via yaml.parse, validate
that the parse result is a non-null plain object and that config.platforms (or
_cachedPlatformCodes.platforms) exists and is an object before any dereference;
if the shape is invalid, either throw a clear error or normalize to a safe
default (e.g., _cachedPlatformCodes = { platforms: {} }) so downstream code that
accesses config.platforms (used around the checks at lines referencing
config.platforms) won't throw; implement this check immediately after yaml.parse
and before returning _cachedPlatformCodes, referencing the symbols
_cachedPlatformCodes, yaml.parse, and config.platforms in the change.
tools/cli/installers/lib/modules/manager.js (5)

1009-1027: ⚠️ Potential issue | 🔴 Critical

isUpdate check occurs after the file is written, so it will always be true.

Line 1024 checks if targetMdPath exists to determine if this is an update. However, the file was already written at line 1009 with fs.writeFile(targetMdPath, xml, 'utf8'). This means isUpdate will always evaluate to true, defeating the purpose of the update-safe handling logic in copySidecarToMemory.

The check should occur before writing the file:

🐛 Proposed fix
+        // Determine if this is an update BEFORE writing (check if agent already exists)
+        const isUpdate = await fs.pathExists(targetMdPath);
+
         // Compile with customizations if any
         const { xml } = await compileAgent(yamlContent, answers, agentName, relativePath, { config: this.coreConfig || {} });

         // Write the compiled agent
         await fs.writeFile(targetMdPath, xml, 'utf8');

         // Handle sidecar copying if present
         if (hasSidecar) {
           // Get the agent's directory to look for sidecar
           const agentDir = path.dirname(agentFile);
           const sidecarDirName = `${agentName}-sidecar`;
           const sourceSidecarPath = path.join(agentDir, sidecarDirName);

           // Check if sidecar directory exists
           if (await fs.pathExists(sourceSidecarPath)) {
             // Memory is always in _bmad/_memory
             const bmadMemoryPath = path.join(bmadDir, '_memory');

-            // Determine if this is an update (by checking if agent already exists)
-            const isUpdate = await fs.pathExists(targetMdPath);
-
             // Copy sidecar to memory location with update-safe handling
             const copiedFiles = await this.copySidecarToMemory(sourceSidecarPath, agentName, bmadMemoryPath, isUpdate, bmadDir, installer);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/modules/manager.js` around lines 1009 - 1027, The
isUpdate detection is done after writing the agent metadata (fs.writeFile to
targetMdPath), so isUpdate will always be true; move the existence check
(isUpdate = await fs.pathExists(targetMdPath)) to before the fs.writeFile call,
compute isUpdate using the pre-write state, then call copySidecarToMemory with
that value and proceed to write the file (or adjust order so copySidecarToMemory
receives the correct pre-existing flag); reference variables/functions:
targetMdPath, fs.writeFile, isUpdate, copySidecarToMemory, sourceSidecarPath,
agentFile, bmadDir, installer.

1095-1113: ⚠️ Potential issue | 🟠 Major

Dead code: processAgentFiles function body is entirely commented out.

The entire implementation of processAgentFiles is commented out, leaving an empty function. This is still called from install() (line 586) and update() (line 645). Either remove the dead code and the calls, or restore the functionality if it's still needed.

Leaving large blocks of commented code creates maintenance burden and confusion about intended behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/modules/manager.js` around lines 1095 - 1113, The
processAgentFiles function currently contains only commented-out code, leaving
it inert while install() and update() still call it; restore its intended
behavior or remove both the dead code and caller invocations. Either (A)
uncomment and re-enable the original logic inside processAgentFiles (use
path.join, fs.pathExists, this.findAgentMdFiles, loop over agent files, check
for '.md', read/write with fs, and call this.xmlHandler.injectActivationSimple
when content contains '<agent' but not '<activation') so callers keep working,
or (B) remove the commented block entirely and update install() and update() to
stop calling processAgentFiles (and delete the empty method) to avoid no-op
calls and confusion. Ensure references to processAgentFiles, install, update,
this.findAgentMdFiles, and this.xmlHandler.injectActivationSimple are updated
consistently.

570-573: ⚠️ Potential issue | 🟠 Major

Silent removal before install could lose user data without warning.

When reinstalling a module, if targetPath exists, it's silently removed at line 572 before copying. If the user had modified files in that module directory, those modifications are lost without any warning or backup.

Consider either:

  1. Warning the user before removal
  2. Creating a backup
  3. Using the same update-safe logic used elsewhere in this file
// Check if already installed
if (await fs.pathExists(targetPath)) {
  await fs.remove(targetPath); // User modifications silently lost
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/modules/manager.js` around lines 570 - 573, The code
silently removes an existing installation by calling fs.remove when
fs.pathExists(targetPath) is true, which can destroy user changes; change this
to either prompt the user before deletion, create a timestamped backup of
targetPath (e.g., move/rename to targetPath + '.bak.' + timestamp) before
removing, or invoke the existing update-safe logic used elsewhere in this module
instead of directly calling fs.remove; update the block that currently checks
fs.pathExists(targetPath) to perform one of these safer actions and ensure
subsequent code operates on the new path or backup accordingly.

876-880: ⚠️ Potential issue | 🟡 Minor

Silent fallback masks errors in YAML processing.

When YAML processing fails in copyWorkflowYamlStripped, the catch block logs a warning and falls back to fs.copy. However, this could mask serious issues:

  • The warning message doesn't include the error details
  • If fs.copy also fails, the error chain is lost
  • The user has no way to know their web_bundle section wasn't stripped
} catch {
  // If anything fails, just copy the file as-is
  await prompts.log.warn(`  Could not process ${path.basename(sourceFile)}, copying as-is`);
  await fs.copy(sourceFile, targetFile, { overwrite: true });
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/modules/manager.js` around lines 876 - 880, The
catch block in copyWorkflowYamlStripped currently swallows the original error
and silently falls back to fs.copy; change it to catch the error as e, include
e.message/stack in the prompts.log.warn so the original YAML processing error is
visible, then perform the fs.copy inside its own try/catch so any copy failure
is logged and rethrown (or returned) to preserve the error chain, and add a
clear log note that the web_bundle section was not stripped when falling back.
Ensure you update the catch in copyWorkflowYamlStripped to reference the error
variable and handle fs.copy errors explicitly.

1193-1208: ⚠️ Potential issue | 🟡 Minor

Regex patterns for parsing workflow paths are fragile and may not match all valid formats.

The regex at line 1193 sourceWorkflowPath.match(/\{project-root\}\/(?:_bmad)\/([^/]+)\/workflows\/(.+)/) only matches paths with _bmad prefix, but the comment at line 1191 says it should handle "hardcoded 'bmad'" too. The regex doesn't actually match the bmad/ variant.

Similarly, line 1204's regex only matches _bmad, not other folder names.

♻️ Proposed fix to match both patterns
-        const sourceMatch = sourceWorkflowPath.match(/\{project-root\}\/(?:_bmad)\/([^/]+)\/workflows\/(.+)/);
+        const sourceMatch = sourceWorkflowPath.match(/\{project-root\}\/(?:_bmad|bmad)\/([^/]+)\/workflows\/(.+)/);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/modules/manager.js` around lines 1193 - 1208, The
path-parsing regexes for sourceWorkflowPath and installWorkflowPath are too
strict and only match `_bmad` not `bmad`; update both regexes (used where
sourceWorkflowPath.match(...) producing sourceMatch and
installWorkflowPath.match(...) producing installMatch) to accept either `_bmad`
or `bmad` (e.g. use a pattern that allows an optional leading underscore like
_?bmad), and ensure the capture groups still yield the same values used later
(sourceModule, sourceWorkflowSubPath and the corresponding install captures) so
downstream code extracting installMatch[...] continues to work.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5ad35d6 and f5cf288.

📒 Files selected for processing (42)
  • package.json
  • test/test-fs-wrapper.js
  • test/test-installation-components.js
  • tools/cli/commands/status.js
  • tools/cli/commands/uninstall.js
  • tools/cli/installers/lib/core/config-collector.js
  • tools/cli/installers/lib/core/custom-module-cache.js
  • tools/cli/installers/lib/core/dependency-resolver.js
  • tools/cli/installers/lib/core/detector.js
  • tools/cli/installers/lib/core/ide-config-manager.js
  • tools/cli/installers/lib/core/installer.js
  • tools/cli/installers/lib/core/manifest-generator.js
  • tools/cli/installers/lib/core/manifest.js
  • tools/cli/installers/lib/custom/handler.js
  • tools/cli/installers/lib/ide/_base-ide.js
  • tools/cli/installers/lib/ide/_config-driven.js
  • tools/cli/installers/lib/ide/codex.js
  • tools/cli/installers/lib/ide/github-copilot.js
  • tools/cli/installers/lib/ide/kilo.js
  • tools/cli/installers/lib/ide/manager.js
  • tools/cli/installers/lib/ide/platform-codes.js
  • tools/cli/installers/lib/ide/rovodev.js
  • tools/cli/installers/lib/ide/shared/agent-command-generator.js
  • tools/cli/installers/lib/ide/shared/bmad-artifacts.js
  • tools/cli/installers/lib/ide/shared/module-injections.js
  • tools/cli/installers/lib/ide/shared/task-tool-command-generator.js
  • tools/cli/installers/lib/ide/shared/workflow-command-generator.js
  • tools/cli/installers/lib/message-loader.js
  • tools/cli/installers/lib/modules/external-manager.js
  • tools/cli/installers/lib/modules/manager.js
  • tools/cli/lib/activation-builder.js
  • tools/cli/lib/agent-analyzer.js
  • tools/cli/lib/agent-party-generator.js
  • tools/cli/lib/config.js
  • tools/cli/lib/file-ops.js
  • tools/cli/lib/fs.js
  • tools/cli/lib/platform-codes.js
  • tools/cli/lib/project-root.js
  • tools/cli/lib/ui.js
  • tools/cli/lib/xml-handler.js
  • tools/cli/lib/yaml-xml-builder.js
  • tools/migrate-custom-module-paths.js

@adambiggs adambiggs force-pushed the fix/installer-nondeterministic-file-loss branch from f5cf288 to 17f9cdf Compare February 26, 2026 20:59
…inistic file loss

fs-extra routes all async operations through graceful-fs, whose EMFILE
retry queue causes non-deterministic file loss on macOS APFS during bulk
copy operations (~500+ files). Approximately 50% of install runs lose
26+ files from _bmad/.

Replace fs-extra entirely with a thin native wrapper (tools/cli/lib/fs.js)
that provides the same API surface backed by node:fs and node:fs/promises.
Copy and remove operations use synchronous native calls to eliminate the
race condition. Verified across 8+ consecutive runs with zero file loss.

- Add tools/cli/lib/fs.js native wrapper with full fs-extra API compat
- Update all 40 files to require the wrapper instead of fs-extra
- Remove fs-extra from package.json dependencies
- Add 37-test suite including 250-file bulk copy determinism test
@adambiggs adambiggs force-pushed the fix/installer-nondeterministic-file-loss branch from 17f9cdf to 373c06b Compare February 26, 2026 21:04
Copy link

@augmentcode augmentcode bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review completed. 2 suggestions posted.

Fix All in Augment

Comment augment review to trigger a new review at any time.

adambiggs and others added 3 commits February 26, 2026 13:29
…endency

- copy() overwrite:false catch now only ignores ENOENT/ENOTDIR,
  consistent with pathExists(); permission errors propagate correctly
- 'copy creates parent directories' test creates its own fixture
  instead of depending on state from a previous test
@bmadcode
Copy link
Collaborator

bmadcode commented Mar 1, 2026

Thanks for this @adambiggs - we will review soon!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Installer non-deterministic file loss on macOS due to fs-extra/graceful-fs

2 participants