Merge 9799d10123 into e74dd8040d
This commit is contained in:
commit
b9dea6f6e5
|
|
@ -1,12 +1,30 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Resolve BMad's central config using four-layer TOML merge.
|
||||
Resolve BMad's central config using a layered TOML merge.
|
||||
|
||||
Reads from four layers (highest priority last):
|
||||
1. {project-root}/_bmad/config.toml (installer-owned team)
|
||||
2. {project-root}/_bmad/config.user.toml (installer-owned user)
|
||||
3. {project-root}/_bmad/custom/config.toml (human-authored team, committed)
|
||||
4. {project-root}/_bmad/custom/config.user.toml (human-authored user, gitignored)
|
||||
Reads from up to seven tiers (highest priority last):
|
||||
0. {project-root}/_bmad/{module}/module.toml (shipped module defaults — floor)
|
||||
1. {global-dir}/config.toml (global team / machine defaults)
|
||||
2. {global-dir}/config.user.toml (global personal defaults)
|
||||
3. {project-root}/_bmad/config.toml (installer-owned team)
|
||||
4. {project-root}/_bmad/config.user.toml (installer-owned user)
|
||||
5. {project-root}/_bmad/custom/config.toml (human-authored team, committed)
|
||||
6. {project-root}/_bmad/custom/config.user.toml (human-authored user, gitignored)
|
||||
|
||||
Tier 0 ("module floor") carries each installed module's shipped defaults:
|
||||
its [modules.X] paths and [agents.X] roster. Authors write module.yaml at
|
||||
source; the installer converts to module.toml at install-time, giving the
|
||||
resolver a TOML-only read path (no PyYAML dependency). Discovery is by
|
||||
glob: any subdirectory of _bmad/ containing a module.toml counts.
|
||||
|
||||
All layers are optional. If a file is missing it is silently skipped.
|
||||
If no file is found anywhere, an empty object is emitted.
|
||||
|
||||
{global-dir} resolves to $BMAD_HOME if set, otherwise ~/.bmad. Path.home()
|
||||
gives the right answer on macOS, Linux, WSL, and Windows.
|
||||
|
||||
--project-root is optional. With no project root, only the global layers
|
||||
are consulted (useful for standalone skill invocations).
|
||||
|
||||
Outputs merged JSON to stdout. Errors go to stderr.
|
||||
|
||||
|
|
@ -16,6 +34,7 @@ no virtualenv — plain `python3` is sufficient.
|
|||
python3 resolve_config.py --project-root /abs/path/to/project
|
||||
python3 resolve_config.py --project-root ... --key core
|
||||
python3 resolve_config.py --project-root ... --key agents
|
||||
python3 resolve_config.py # global only
|
||||
|
||||
Merge rules (same as resolve_customization.py):
|
||||
- Scalars: override wins
|
||||
|
|
@ -26,6 +45,7 @@ Merge rules (same as resolve_customization.py):
|
|||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -123,6 +143,61 @@ def deep_merge(base, override):
|
|||
return override
|
||||
|
||||
|
||||
def resolve_global_dir() -> Path:
|
||||
"""Locate the cross-platform global BMad config directory.
|
||||
|
||||
Honors $BMAD_HOME (useful for CI, multi-account setups, or relocating
|
||||
config off a slow network home). Otherwise ~/.bmad — Path.home() does
|
||||
the right thing on macOS, Linux, WSL, and Windows.
|
||||
"""
|
||||
override = os.environ.get("BMAD_HOME")
|
||||
if override:
|
||||
return Path(override).expanduser().resolve()
|
||||
return Path.home() / ".bmad"
|
||||
|
||||
|
||||
def collect_module_layers(project_root: Path | None) -> list[Path]:
|
||||
"""Return per-module module.toml paths discovered in this project.
|
||||
|
||||
Floor of the resolver chain — these are the shipped module defaults
|
||||
that the installer realizes from source module.yaml on install. Authors
|
||||
don't edit module.toml directly; it's a build artifact.
|
||||
|
||||
Discovery is purely file-system based: any direct subdirectory of
|
||||
_bmad/ that contains a module.toml is treated as an installed module.
|
||||
Returned in sorted order for deterministic merge order (irrelevant
|
||||
in practice because modules don't share keys — each writes its own
|
||||
[modules.{code}] and own [agents.{agent-code}] entries — but
|
||||
determinism is cheap).
|
||||
"""
|
||||
if project_root is None:
|
||||
return []
|
||||
bmad_dir = project_root / "_bmad"
|
||||
if not bmad_dir.is_dir():
|
||||
return []
|
||||
return sorted(bmad_dir.glob("*/module.toml"))
|
||||
|
||||
|
||||
def collect_config_layers(project_root: Path | None, global_dir: Path) -> list[tuple[str, Path]]:
|
||||
"""Return (label, path) pairs in lowest→highest priority order.
|
||||
|
||||
All layers are optional; load_toml returns {} for any missing file.
|
||||
"""
|
||||
layers: list[tuple[str, Path]] = [
|
||||
("global team", global_dir / "config.toml"),
|
||||
("global user", global_dir / "config.user.toml"),
|
||||
]
|
||||
if project_root is not None:
|
||||
bmad_dir = project_root / "_bmad"
|
||||
layers.extend([
|
||||
("project team", bmad_dir / "config.toml"),
|
||||
("project user", bmad_dir / "config.user.toml"),
|
||||
("project custom team", bmad_dir / "custom" / "config.toml"),
|
||||
("project custom user", bmad_dir / "custom" / "config.user.toml"),
|
||||
])
|
||||
return layers
|
||||
|
||||
|
||||
def extract_key(data, dotted_key: str):
|
||||
parts = dotted_key.split(".")
|
||||
current = data
|
||||
|
|
@ -136,11 +211,12 @@ def extract_key(data, dotted_key: str):
|
|||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Resolve BMad central config using four-layer TOML merge.",
|
||||
description="Resolve BMad central config using a layered TOML merge.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--project-root", "-p", required=True,
|
||||
help="Absolute path to the project root (contains _bmad/)",
|
||||
"--project-root", "-p", required=False, default=None,
|
||||
help="Absolute path to the project root (contains _bmad/). Optional — "
|
||||
"if omitted, only global layers ($BMAD_HOME or ~/.bmad) are read.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--key", "-k", action="append", default=[],
|
||||
|
|
@ -148,17 +224,27 @@ def main():
|
|||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
project_root = Path(args.project_root).resolve()
|
||||
bmad_dir = project_root / "_bmad"
|
||||
project_root = Path(args.project_root).resolve() if args.project_root else None
|
||||
global_dir = resolve_global_dir()
|
||||
|
||||
base_team = load_toml(bmad_dir / "config.toml", required=True)
|
||||
base_user = load_toml(bmad_dir / "config.user.toml")
|
||||
custom_team = load_toml(bmad_dir / "custom" / "config.toml")
|
||||
custom_user = load_toml(bmad_dir / "custom" / "config.user.toml")
|
||||
# If the caller explicitly named a project root, that's a promise it exists
|
||||
# and has been installed. Fail loudly on a missing _bmad/ rather than
|
||||
# silently returning {} — that masked broken installs in the old required=
|
||||
# True behavior. Global-only mode (no --project-root) stays permissive.
|
||||
if project_root is not None and not (project_root / "_bmad").is_dir():
|
||||
sys.stderr.write(
|
||||
f"error: --project-root {project_root} has no _bmad/ directory "
|
||||
f"(install not present, or wrong path)\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
merged = deep_merge(base_team, base_user)
|
||||
merged = deep_merge(merged, custom_team)
|
||||
merged = deep_merge(merged, custom_user)
|
||||
merged: dict = {}
|
||||
# Floor: per-module shipped defaults (lowest priority).
|
||||
for module_toml in collect_module_layers(project_root):
|
||||
merged = deep_merge(merged, load_toml(module_toml))
|
||||
# Then global → project → custom config layers on top.
|
||||
for _label, path in collect_config_layers(project_root, global_dir):
|
||||
merged = deep_merge(merged, load_toml(path))
|
||||
|
||||
if args.key:
|
||||
output = {}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,41 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Resolve customization for a BMad skill using three-layer TOML merge.
|
||||
Resolve customization for a BMad skill using a layered TOML merge.
|
||||
|
||||
Reads customization from three layers (highest priority first):
|
||||
1. {project-root}/_bmad/custom/{name}.user.toml (personal, gitignored)
|
||||
2. {project-root}/_bmad/custom/{name}.toml (team/org, committed)
|
||||
3. {skill-root}/customize.toml (skill defaults)
|
||||
Reads from (lowest → highest priority):
|
||||
1. {skill-root}/customize.toml (skill author defaults)
|
||||
2. [skills.X] sections inside four customize layers (lowest→highest):
|
||||
a. {global-dir}/customize.toml (global team / machine)
|
||||
b. {global-dir}/customize.user.toml (global personal)
|
||||
c. {project-root}/_bmad/custom/customize.toml (project team, committed)
|
||||
d. {project-root}/_bmad/custom/customize.user.toml (project personal, gitignored)
|
||||
3. {project-root}/_bmad/custom/{name}.toml (per-skill team override)
|
||||
4. {project-root}/_bmad/custom/{name}.user.toml (per-skill personal override)
|
||||
|
||||
Skill name is derived from the basename of the skill directory.
|
||||
config.toml is NOT consulted by this resolver — identity/agents live there,
|
||||
skill behavior overrides live in customize.toml. Clean split.
|
||||
|
||||
There is no installer-tier customize.toml — the installer manages identity
|
||||
(config.toml), not skill behavior. customize.{,user.}toml is purely human
|
||||
or `bmad-customize`-skill-authored.
|
||||
|
||||
Skill name is derived from the basename of the skill directory. If the
|
||||
skill lives under `{module}-skills/...`, a qualified name `{module}/{skill}`
|
||||
is also computed and used for pattern matching.
|
||||
|
||||
Inside a customize layer, [skills.X] sections cascade by specificity (most
|
||||
specific wins within the layer):
|
||||
[skills."*"] # catchall
|
||||
[skills."bmad-*"] # bare-name glob
|
||||
[skills."bmm/*"] # module-scoped glob
|
||||
[skills.bmad-prd] # bare exact
|
||||
[skills."bmm/bmad-prd"] # qualified exact (most specific)
|
||||
|
||||
Patterns are matched against both the bare skill name and (if available)
|
||||
the qualified `module/skill` name. Specificity scoring: exact > wildcard,
|
||||
longer-pattern > shorter, `*` is the lowest.
|
||||
|
||||
{global-dir} = $BMAD_HOME if set, otherwise ~/.bmad (cross-platform).
|
||||
|
||||
Outputs merged JSON to stdout. Errors go to stderr.
|
||||
|
||||
|
|
@ -34,7 +62,10 @@ description/prompt.
|
|||
"""
|
||||
|
||||
import argparse
|
||||
import fnmatch
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -166,6 +197,93 @@ def deep_merge(base, override):
|
|||
return override
|
||||
|
||||
|
||||
_MODULE_SKILLS_RE = re.compile(r"^(?P<module>[A-Za-z0-9_-]+)-skills$")
|
||||
|
||||
|
||||
def detect_skill_module(skill_dir: Path) -> str | None:
|
||||
"""Walk up from the skill dir looking for an ancestor named `{module}-skills`.
|
||||
|
||||
Returns the module slug (e.g. 'bmm', 'core') or None if the skill isn't
|
||||
inside a recognizable module tree (standalone skill, test fixture, etc.).
|
||||
"""
|
||||
for ancestor in skill_dir.parents:
|
||||
match = _MODULE_SKILLS_RE.match(ancestor.name)
|
||||
if match:
|
||||
return match.group("module")
|
||||
return None
|
||||
|
||||
|
||||
def resolve_global_dir() -> Path:
|
||||
"""Locate the cross-platform global BMad config directory ($BMAD_HOME or ~/.bmad)."""
|
||||
override = os.environ.get("BMAD_HOME")
|
||||
if override:
|
||||
return Path(override).expanduser().resolve()
|
||||
return Path.home() / ".bmad"
|
||||
|
||||
|
||||
def collect_customize_layers(project_root: Path | None, global_dir: Path) -> list[Path]:
|
||||
"""Return customize.toml file paths in lowest→highest priority order.
|
||||
|
||||
Four layers (all optional):
|
||||
1. {global-dir}/customize.toml — global team / machine
|
||||
2. {global-dir}/customize.user.toml — global personal
|
||||
3. _bmad/custom/customize.toml — project team, committed
|
||||
4. _bmad/custom/customize.user.toml — project personal, gitignored
|
||||
|
||||
Installer-managed _bmad/config*.toml is NOT scanned: customize is a
|
||||
separate concern from identity, and the installer does not author it.
|
||||
"""
|
||||
layers: list[Path] = [
|
||||
global_dir / "customize.toml",
|
||||
global_dir / "customize.user.toml",
|
||||
]
|
||||
if project_root is not None:
|
||||
custom_dir = project_root / "_bmad" / "custom"
|
||||
layers.extend([
|
||||
custom_dir / "customize.toml",
|
||||
custom_dir / "customize.user.toml",
|
||||
])
|
||||
return layers
|
||||
|
||||
|
||||
def _pattern_specificity(pattern: str) -> tuple[int, int]:
|
||||
"""Score a [skills.X] pattern for specificity ordering (ascending = less specific).
|
||||
|
||||
Tier 2 (exact, no wildcards): most specific. Tie-break by pattern length.
|
||||
Tier 1 (wildcard, not pure '*'): mid. Longer patterns are more specific.
|
||||
Tier 0 (bare '*'): catchall.
|
||||
"""
|
||||
if pattern == "*":
|
||||
return (0, 0)
|
||||
if "*" in pattern or "?" in pattern or "[" in pattern:
|
||||
return (1, len(pattern))
|
||||
return (2, len(pattern))
|
||||
|
||||
|
||||
def extract_skill_overrides(layer_data: dict, qualified: str | None, bare: str) -> dict:
|
||||
"""Build a single-skill override dict from a layer's [skills.X] table.
|
||||
|
||||
Matches every pattern that fnmatches against the bare or qualified name,
|
||||
then deep-merges them in ascending specificity so the most specific wins.
|
||||
"""
|
||||
skills_table = layer_data.get("skills")
|
||||
if not isinstance(skills_table, dict):
|
||||
return {}
|
||||
matched: list[tuple[tuple[int, int], dict]] = []
|
||||
for pattern, override in skills_table.items():
|
||||
if not isinstance(override, dict):
|
||||
continue
|
||||
if fnmatch.fnmatchcase(bare, pattern) or (
|
||||
qualified is not None and fnmatch.fnmatchcase(qualified, pattern)
|
||||
):
|
||||
matched.append((_pattern_specificity(pattern), override))
|
||||
matched.sort(key=lambda item: item[0])
|
||||
result: dict = {}
|
||||
for _, override in matched:
|
||||
result = deep_merge(result, override)
|
||||
return result
|
||||
|
||||
|
||||
def extract_key(data, dotted_key: str):
|
||||
parts = dotted_key.split(".")
|
||||
current = data
|
||||
|
|
@ -187,7 +305,7 @@ def write_json_stdout(output):
|
|||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Resolve customization for a BMad skill using three-layer TOML merge.",
|
||||
description="Resolve customization for a BMad skill using a layered TOML merge with [skills.X] cascade.",
|
||||
add_help=True,
|
||||
)
|
||||
parser.add_argument(
|
||||
|
|
@ -202,6 +320,8 @@ def main():
|
|||
|
||||
skill_dir = Path(args.skill).resolve()
|
||||
skill_name = skill_dir.name
|
||||
module = detect_skill_module(skill_dir)
|
||||
qualified = f"{module}/{skill_name}" if module else None
|
||||
defaults_path = skill_dir / "customize.toml"
|
||||
|
||||
defaults = load_toml(defaults_path, required=True)
|
||||
|
|
@ -211,16 +331,27 @@ def main():
|
|||
# for standalone skills invoked directly). Using cwd first is unsafe when
|
||||
# an ancestor of cwd happens to have a stray _bmad/ from another project.
|
||||
project_root = find_project_root(skill_dir) or find_project_root(Path.cwd())
|
||||
global_dir = resolve_global_dir()
|
||||
|
||||
team = {}
|
||||
user = {}
|
||||
merged = defaults
|
||||
|
||||
# Walk the customize layers low→high, applying any [skills.X] sections
|
||||
# that match this skill. This is the cross-cutting override surface:
|
||||
# users can cascade values across all skills, all skills in a module, or
|
||||
# one specific skill without editing per-skill files.
|
||||
for layer_path in collect_customize_layers(project_root, global_dir):
|
||||
layer_data = load_toml(layer_path)
|
||||
skill_override = extract_skill_overrides(layer_data, qualified, skill_name)
|
||||
if skill_override:
|
||||
merged = deep_merge(merged, skill_override)
|
||||
|
||||
# Per-skill override files (highest priority — most explicit). These keep
|
||||
# working for back-compat and remain the right tool when an override is
|
||||
# large or wholly skill-specific.
|
||||
if project_root:
|
||||
custom_dir = project_root / "_bmad" / "custom"
|
||||
team = load_toml(custom_dir / f"{skill_name}.toml")
|
||||
user = load_toml(custom_dir / f"{skill_name}.user.toml")
|
||||
|
||||
merged = deep_merge(defaults, team)
|
||||
merged = deep_merge(merged, user)
|
||||
merged = deep_merge(merged, load_toml(custom_dir / f"{skill_name}.toml"))
|
||||
merged = deep_merge(merged, load_toml(custom_dir / f"{skill_name}.user.toml"))
|
||||
|
||||
if args.key:
|
||||
output = {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,197 @@
|
|||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "resolve_config.py"
|
||||
|
||||
|
||||
def run_raw(args, env_overrides=None):
|
||||
"""Run resolver, return CompletedProcess-like object with decoded streams.
|
||||
Use this when the test expects a non-zero exit (e.g. fail-fast checks)."""
|
||||
env = os.environ.copy()
|
||||
# Force BMAD_HOME to a guaranteed-missing path so any value the developer
|
||||
# has set in their shell can't leak the real ~/.bmad into a test that
|
||||
# expected an empty global. Tests that need a populated global pass it via
|
||||
# env_overrides below.
|
||||
env["BMAD_HOME"] = "/nonexistent-bmad-home-default"
|
||||
if env_overrides:
|
||||
env.update(env_overrides)
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), *args],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
result.stdout = result.stdout.decode("utf-8", errors="replace")
|
||||
result.stderr = result.stderr.decode("utf-8", errors="replace")
|
||||
return result
|
||||
|
||||
|
||||
def run(args, env_overrides=None):
|
||||
result = run_raw(args, env_overrides=env_overrides)
|
||||
if result.returncode != 0:
|
||||
raise AssertionError(f"resolve_config failed ({result.returncode}): {result.stderr}")
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
class ResolveConfigTests(unittest.TestCase):
|
||||
def test_no_project_root_no_global_returns_empty(self):
|
||||
with tempfile.TemporaryDirectory() as empty_global:
|
||||
data = run([], env_overrides={"BMAD_HOME": empty_global})
|
||||
self.assertEqual(data, {})
|
||||
|
||||
def test_global_only_when_no_project_root(self):
|
||||
with tempfile.TemporaryDirectory() as global_dir:
|
||||
(Path(global_dir) / "config.toml").write_text(
|
||||
'[core]\nuser_name = "Globie"\n', encoding="utf-8"
|
||||
)
|
||||
data = run([], env_overrides={"BMAD_HOME": global_dir})
|
||||
self.assertEqual(data["core"]["user_name"], "Globie")
|
||||
|
||||
def test_project_overrides_global(self):
|
||||
with tempfile.TemporaryDirectory() as global_dir, tempfile.TemporaryDirectory() as proj:
|
||||
(Path(global_dir) / "config.toml").write_text(
|
||||
'[core]\nuser_name = "Globie"\ncommunication_language = "French"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
bmad = Path(proj) / "_bmad"
|
||||
bmad.mkdir()
|
||||
(bmad / "config.toml").write_text(
|
||||
'[core]\nuser_name = "ProjectUser"\n', encoding="utf-8"
|
||||
)
|
||||
data = run(
|
||||
["--project-root", proj], env_overrides={"BMAD_HOME": global_dir}
|
||||
)
|
||||
# Project wins on user_name; global fills in communication_language
|
||||
self.assertEqual(data["core"]["user_name"], "ProjectUser")
|
||||
self.assertEqual(data["core"]["communication_language"], "French")
|
||||
|
||||
def test_custom_user_beats_everything(self):
|
||||
with tempfile.TemporaryDirectory() as global_dir, tempfile.TemporaryDirectory() as proj:
|
||||
(Path(global_dir) / "config.user.toml").write_text(
|
||||
'[core]\nuser_name = "Globie"\n', encoding="utf-8"
|
||||
)
|
||||
bmad = Path(proj) / "_bmad"
|
||||
(bmad / "custom").mkdir(parents=True)
|
||||
(bmad / "config.toml").write_text(
|
||||
'[core]\nuser_name = "Installer"\n', encoding="utf-8"
|
||||
)
|
||||
(bmad / "custom" / "config.user.toml").write_text(
|
||||
'[core]\nuser_name = "Pinned"\n', encoding="utf-8"
|
||||
)
|
||||
data = run(
|
||||
["--project-root", proj], env_overrides={"BMAD_HOME": global_dir}
|
||||
)
|
||||
self.assertEqual(data["core"]["user_name"], "Pinned")
|
||||
|
||||
def test_project_config_optional(self):
|
||||
# _bmad/ exists (installed project) but no config.toml inside — that's
|
||||
# the lean / global-only case and must not error.
|
||||
with tempfile.TemporaryDirectory() as proj:
|
||||
(Path(proj) / "_bmad").mkdir()
|
||||
data = run(["--project-root", proj])
|
||||
self.assertEqual(data, {})
|
||||
|
||||
def test_project_root_without_bmad_dir_errors(self):
|
||||
# --project-root pointing at a directory with no _bmad/ is treated
|
||||
# as a broken install (typo, wiped install) — resolver exits non-zero
|
||||
# rather than silently returning {}. Global-only mode (no
|
||||
# --project-root) keeps the permissive behavior.
|
||||
with tempfile.TemporaryDirectory() as proj:
|
||||
result = run_raw(["--project-root", proj])
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("no _bmad/ directory", result.stderr)
|
||||
|
||||
def test_module_floor_contributes_when_no_overrides(self):
|
||||
# Module-shipped defaults from _bmad/{module}/module.toml should
|
||||
# appear when nothing else specifies them.
|
||||
with tempfile.TemporaryDirectory() as proj:
|
||||
bmad = Path(proj) / "_bmad" / "bmm"
|
||||
bmad.mkdir(parents=True)
|
||||
(bmad / "module.toml").write_text(
|
||||
'[modules.bmm]\nplanning_artifacts = "/from/module/yaml"\n'
|
||||
'[agents.bmad-agent-analyst]\nname = "Mary"\nmodule = "bmm"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
data = run(["--project-root", proj])
|
||||
self.assertEqual(data["modules"]["bmm"]["planning_artifacts"], "/from/module/yaml")
|
||||
self.assertEqual(data["agents"]["bmad-agent-analyst"]["name"], "Mary")
|
||||
|
||||
def test_config_toml_overrides_module_floor(self):
|
||||
# Config layers sit above the module floor — explicit overrides win.
|
||||
with tempfile.TemporaryDirectory() as proj:
|
||||
bmad = Path(proj) / "_bmad"
|
||||
mod = bmad / "bmm"
|
||||
mod.mkdir(parents=True)
|
||||
(mod / "module.toml").write_text(
|
||||
'[modules.bmm]\nplanning_artifacts = "/module/default"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(bmad / "config.toml").write_text(
|
||||
'[modules.bmm]\nplanning_artifacts = "/user/override"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
data = run(["--project-root", proj])
|
||||
self.assertEqual(
|
||||
data["modules"]["bmm"]["planning_artifacts"], "/user/override"
|
||||
)
|
||||
|
||||
def test_multiple_modules_merge_independently(self):
|
||||
# Each module writes its own [modules.X] / [agents.X] subtree;
|
||||
# the merge should not collide across modules.
|
||||
with tempfile.TemporaryDirectory() as proj:
|
||||
bmad = Path(proj) / "_bmad"
|
||||
(bmad / "bmm").mkdir(parents=True)
|
||||
(bmad / "bmb").mkdir(parents=True)
|
||||
(bmad / "bmm" / "module.toml").write_text(
|
||||
'[modules.bmm]\nplanning_artifacts = "/bmm/path"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(bmad / "bmb" / "module.toml").write_text(
|
||||
'[modules.bmb]\nbmad_builder_output_folder = "/bmb/path"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
data = run(["--project-root", proj])
|
||||
self.assertEqual(data["modules"]["bmm"]["planning_artifacts"], "/bmm/path")
|
||||
self.assertEqual(
|
||||
data["modules"]["bmb"]["bmad_builder_output_folder"], "/bmb/path"
|
||||
)
|
||||
|
||||
def test_module_floor_ignored_when_no_project_root(self):
|
||||
# Without --project-root, no per-project files (including module
|
||||
# floor) should be read. Just global.
|
||||
with tempfile.TemporaryDirectory() as g:
|
||||
(Path(g) / "config.toml").write_text(
|
||||
'[core]\nuser_name = "Globie"\n', encoding="utf-8"
|
||||
)
|
||||
data = run([], env_overrides={"BMAD_HOME": g})
|
||||
self.assertEqual(data, {"core": {"user_name": "Globie"}})
|
||||
|
||||
def test_non_module_dirs_skipped(self):
|
||||
# _bmad/_config, _bmad/custom, _bmad/scripts must NOT be treated as
|
||||
# modules even though they're direct children of _bmad/.
|
||||
with tempfile.TemporaryDirectory() as proj:
|
||||
bmad = Path(proj) / "_bmad"
|
||||
for sub in ("_config", "custom", "scripts"):
|
||||
(bmad / sub).mkdir(parents=True)
|
||||
# No module.toml anywhere — resolver should return {}, not error
|
||||
data = run(["--project-root", proj])
|
||||
self.assertEqual(data, {})
|
||||
|
||||
def test_bmad_home_env_var_honored(self):
|
||||
with tempfile.TemporaryDirectory() as global_dir:
|
||||
(Path(global_dir) / "config.toml").write_text(
|
||||
'[core]\nuser_name = "FromEnv"\n', encoding="utf-8"
|
||||
)
|
||||
data = run([], env_overrides={"BMAD_HOME": global_dir})
|
||||
self.assertEqual(data["core"]["user_name"], "FromEnv")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "resolve_customization.py"
|
||||
|
||||
|
||||
def make_skill(parent: Path, name: str, defaults_toml: str, module: str | None = None) -> Path:
|
||||
"""Create a fake skill dir, optionally under a `{module}-skills/` ancestor."""
|
||||
if module:
|
||||
base = parent / f"{module}-skills"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
base = parent
|
||||
skill_dir = base / name
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "customize.toml").write_text(defaults_toml, encoding="utf-8")
|
||||
return skill_dir
|
||||
|
||||
|
||||
def run(skill_dir: Path, key=None, env_overrides=None):
|
||||
env = os.environ.copy()
|
||||
# Force BMAD_HOME to a guaranteed-missing path so the developer's real
|
||||
# ~/.bmad never leaks into a test expecting an empty global. Tests that
|
||||
# need a populated global override via env_overrides below.
|
||||
env["BMAD_HOME"] = "/nonexistent-bmad-home-default"
|
||||
if env_overrides:
|
||||
env.update(env_overrides)
|
||||
args = [sys.executable, str(SCRIPT), "--skill", str(skill_dir)]
|
||||
if key:
|
||||
args.extend(["--key", key])
|
||||
result = subprocess.run(
|
||||
args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, check=False
|
||||
)
|
||||
stderr = result.stderr.decode("utf-8", errors="replace")
|
||||
if result.returncode != 0:
|
||||
raise AssertionError(f"resolve_customization failed ({result.returncode}): {stderr}")
|
||||
return json.loads(result.stdout.decode("utf-8"))
|
||||
|
||||
|
||||
class ResolveCustomizationCascadeTests(unittest.TestCase):
|
||||
def test_defaults_pass_through_when_no_overrides(self):
|
||||
with tempfile.TemporaryDirectory() as t:
|
||||
skill = make_skill(Path(t), "bmad-prd", '[knobs]\ndepth = "low"\n')
|
||||
data = run(skill)
|
||||
self.assertEqual(data["knobs"]["depth"], "low")
|
||||
|
||||
def test_skills_section_in_global_customize_overrides_default(self):
|
||||
with tempfile.TemporaryDirectory() as t, tempfile.TemporaryDirectory() as global_dir:
|
||||
skill = make_skill(Path(t), "bmad-prd", '[knobs]\ndepth = "low"\n')
|
||||
(Path(global_dir) / "customize.toml").write_text(
|
||||
'[skills.bmad-prd.knobs]\ndepth = "high"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
data = run(skill, env_overrides={"BMAD_HOME": global_dir})
|
||||
self.assertEqual(data["knobs"]["depth"], "high")
|
||||
|
||||
def test_config_toml_is_not_consulted_for_skills_section(self):
|
||||
# [skills.X] in config.toml must NOT be honored — only customize.toml is.
|
||||
with tempfile.TemporaryDirectory() as t, tempfile.TemporaryDirectory() as g:
|
||||
skill = make_skill(Path(t), "bmad-prd", '[knobs]\ndepth = "low"\n')
|
||||
(Path(g) / "config.toml").write_text(
|
||||
'[skills.bmad-prd.knobs]\ndepth = "should-be-ignored"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
data = run(skill, env_overrides={"BMAD_HOME": g})
|
||||
self.assertEqual(data["knobs"]["depth"], "low")
|
||||
|
||||
def test_specificity_within_layer(self):
|
||||
# Within ONE customize layer: exact skill name beats wildcard.
|
||||
with tempfile.TemporaryDirectory() as t, tempfile.TemporaryDirectory() as g:
|
||||
skill = make_skill(Path(t), "bmad-prd", '[knobs]\ndepth = "low"\n')
|
||||
(Path(g) / "customize.toml").write_text(
|
||||
"[skills.\"*\".knobs]\ndepth = \"catchall\"\n"
|
||||
"[skills.bmad-prd.knobs]\ndepth = \"exact\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
data = run(skill, env_overrides={"BMAD_HOME": g})
|
||||
self.assertEqual(data["knobs"]["depth"], "exact")
|
||||
|
||||
def test_layer_precedence_overrides_specificity(self):
|
||||
# Across layers: higher layer wins even with less-specific pattern.
|
||||
# Project custom user (highest customize layer) uses '*';
|
||||
# global (lowest) uses exact — project custom should still win.
|
||||
with tempfile.TemporaryDirectory() as t, tempfile.TemporaryDirectory() as g:
|
||||
project = Path(t) / "project"
|
||||
bmad = project / "_bmad"
|
||||
(bmad / "custom").mkdir(parents=True)
|
||||
skill = make_skill(project, "bmad-prd", '[knobs]\ndepth = "low"\n')
|
||||
|
||||
(Path(g) / "customize.toml").write_text(
|
||||
"[skills.bmad-prd.knobs]\ndepth = \"global-exact\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(bmad / "custom" / "customize.user.toml").write_text(
|
||||
"[skills.\"*\".knobs]\ndepth = \"custom-wildcard\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
data = run(skill, env_overrides={"BMAD_HOME": g})
|
||||
self.assertEqual(data["knobs"]["depth"], "custom-wildcard")
|
||||
|
||||
def test_per_skill_custom_file_beats_customize_section(self):
|
||||
# Per-skill _bmad/custom/{skill}.user.toml is still the highest tier.
|
||||
with tempfile.TemporaryDirectory() as t:
|
||||
project = Path(t) / "project"
|
||||
bmad = project / "_bmad"
|
||||
(bmad / "custom").mkdir(parents=True)
|
||||
skill = make_skill(project, "bmad-prd", '[knobs]\ndepth = "low"\n')
|
||||
|
||||
(bmad / "custom" / "customize.user.toml").write_text(
|
||||
"[skills.bmad-prd.knobs]\ndepth = \"via-section\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(bmad / "custom" / "bmad-prd.user.toml").write_text(
|
||||
"[knobs]\ndepth = \"via-per-skill-file\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
data = run(skill)
|
||||
self.assertEqual(data["knobs"]["depth"], "via-per-skill-file")
|
||||
|
||||
def test_qualified_module_pattern_matches(self):
|
||||
# Skill under bmm-skills/ → qualified name 'bmm/bmad-prd'.
|
||||
# Pattern '[skills."bmm/*"]' should match.
|
||||
with tempfile.TemporaryDirectory() as t, tempfile.TemporaryDirectory() as g:
|
||||
skill = make_skill(
|
||||
Path(t), "bmad-prd", '[knobs]\ndepth = "low"\n', module="bmm"
|
||||
)
|
||||
(Path(g) / "customize.toml").write_text(
|
||||
"[skills.\"bmm/*\".knobs]\ndepth = \"all-bmm\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
data = run(skill, env_overrides={"BMAD_HOME": g})
|
||||
self.assertEqual(data["knobs"]["depth"], "all-bmm")
|
||||
|
||||
def test_qualified_exact_beats_module_wildcard(self):
|
||||
# Within one layer: 'bmm/bmad-prd' (exact) beats 'bmm/*' (wildcard).
|
||||
with tempfile.TemporaryDirectory() as t, tempfile.TemporaryDirectory() as g:
|
||||
skill = make_skill(
|
||||
Path(t), "bmad-prd", '[knobs]\ndepth = "low"\n', module="bmm"
|
||||
)
|
||||
(Path(g) / "customize.toml").write_text(
|
||||
"[skills.\"bmm/*\".knobs]\ndepth = \"module-wide\"\n"
|
||||
"[skills.\"bmm/bmad-prd\".knobs]\ndepth = \"pinned\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
data = run(skill, env_overrides={"BMAD_HOME": g})
|
||||
self.assertEqual(data["knobs"]["depth"], "pinned")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -1693,7 +1693,7 @@ async function runTests() {
|
|||
console.log('');
|
||||
|
||||
// ============================================================
|
||||
// Test Suite 35: Central Config Emission
|
||||
// Test Suite 35: Central Config Emission (lean overrides + global routing)
|
||||
// ============================================================
|
||||
console.log(`${colors.yellow}Test Suite 35: Central Config Emission${colors.reset}\n`);
|
||||
|
||||
|
|
@ -1702,6 +1702,12 @@ async function runTests() {
|
|||
// getModulePath). Only the destination bmadDir is a temp dir, which the
|
||||
// installer writes config.toml / config.user.toml / custom/ into.
|
||||
const tempBmadDir35 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-central-config-'));
|
||||
// Phase 1: writeCentralConfig now writes scope:user core values to
|
||||
// ~/.bmad/config.user.toml. Isolate via BMAD_HOME so we don't pollute
|
||||
// the developer's real global config.
|
||||
const tempGlobalDir35 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-global-'));
|
||||
const priorBmadHome35 = process.env.BMAD_HOME;
|
||||
process.env.BMAD_HOME = tempGlobalDir35;
|
||||
|
||||
try {
|
||||
const moduleConfigs = {
|
||||
|
|
@ -1764,43 +1770,54 @@ async function runTests() {
|
|||
assert(await fs.pathExists(teamPath), 'config.toml is written to disk');
|
||||
assert(await fs.pathExists(userPath), 'config.user.toml is written to disk');
|
||||
|
||||
// generateManifests would call writeGlobalUserCore right after this; we
|
||||
// call it directly here to test the full contract.
|
||||
const globalCorePath = await generator35.writeGlobalUserCore(moduleConfigs);
|
||||
assert(globalCorePath !== null, 'writeGlobalUserCore writes a file when scope:user values exist');
|
||||
assert(globalCorePath.startsWith(tempGlobalDir35), 'writeGlobalUserCore targets $BMAD_HOME');
|
||||
assert(await fs.pathExists(globalCorePath), '~/.bmad/config.user.toml is written to disk');
|
||||
|
||||
const teamContent = await fs.readFile(teamPath, 'utf8');
|
||||
const userContent = await fs.readFile(userPath, 'utf8');
|
||||
const globalContent = await fs.readFile(globalCorePath, 'utf8');
|
||||
|
||||
// [core] — team-scoped keys land in config.toml
|
||||
assert(teamContent.includes('[core]'), 'config.toml has [core] section');
|
||||
assert(teamContent.includes('document_output_language = "English"'), 'Team-scope core key lands in config.toml');
|
||||
assert(teamContent.includes('output_folder = "_bmad-output"'), 'Team-scope output_folder lands in config.toml');
|
||||
assert(teamContent.includes('project_name = "demo-project"'), 'project_name lands in [core] (core key as of #2279)');
|
||||
// [core] — lean: only deltas from module.yaml defaults remain.
|
||||
// project_name = "demo-project" differs from default ({directory_name}).
|
||||
// document_output_language = "English" EQUALS default — stripped.
|
||||
// output_folder = "_bmad-output" EQUALS default — stripped.
|
||||
assert(teamContent.includes('[core]'), 'config.toml has [core] section (carries project_name delta)');
|
||||
assert(teamContent.includes('project_name = "demo-project"'), 'Delta core key (project_name) lands in config.toml');
|
||||
assert(!teamContent.includes('document_output_language'), 'Default core values are stripped from config.toml (Task F)');
|
||||
assert(!teamContent.includes('user_name'), 'user_name (scope: user) is absent from config.toml');
|
||||
assert(!teamContent.includes('communication_language'), 'communication_language (scope: user) is absent from config.toml');
|
||||
|
||||
// [core] — user-scoped keys land in config.user.toml
|
||||
assert(userContent.includes('[core]'), 'config.user.toml has [core] section');
|
||||
assert(userContent.includes('user_name = "TestUser"'), 'user_name lands in config.user.toml');
|
||||
assert(userContent.includes('communication_language = "Spanish"'), 'communication_language lands in config.user.toml');
|
||||
assert(!userContent.includes('document_output_language'), 'Team-scope key is absent from config.user.toml');
|
||||
// [core] user-scope no longer goes to project user file — it's routed
|
||||
// to ~/.bmad/config.user.toml (Task D).
|
||||
assert(!userContent.includes('user_name'), 'config.user.toml does NOT contain user_name (Task D: routed to global)');
|
||||
assert(
|
||||
!userContent.includes('communication_language'),
|
||||
'config.user.toml does NOT contain communication_language (Task D: routed to global)',
|
||||
);
|
||||
|
||||
// [modules.bmm] — core-key pollution stripped; own user-scope key routed to user file
|
||||
const bmmTeamMatch = teamContent.match(/\[modules\.bmm\][\s\S]*?(?=\n\[|$)/);
|
||||
assert(bmmTeamMatch !== null, 'config.toml has [modules.bmm] section');
|
||||
if (bmmTeamMatch) {
|
||||
const bmmTeamBlock = bmmTeamMatch[0];
|
||||
assert(bmmTeamBlock.includes('planning_artifacts'), 'bmm-owned team-scope key (planning_artifacts) lands under [modules.bmm]');
|
||||
assert(!bmmTeamBlock.includes('project_name'), 'project_name stripped from [modules.bmm] (now a core key, #2279)');
|
||||
assert(!bmmTeamBlock.includes('stale-bmm-copy'), 'stale bmm-copy of project_name not leaked into config.toml');
|
||||
assert(!bmmTeamBlock.includes('user_name'), 'user_name stripped from [modules.bmm] (core-key pollution)');
|
||||
assert(!bmmTeamBlock.includes('communication_language'), 'communication_language stripped from [modules.bmm]');
|
||||
assert(!bmmTeamBlock.includes('user_skill_level'), 'user_skill_level (scope: user) absent from [modules.bmm] in config.toml');
|
||||
}
|
||||
// Global file at ~/.bmad/config.user.toml carries the scope:user core values
|
||||
assert(globalContent.includes('[core]'), '~/.bmad/config.user.toml has [core] section');
|
||||
assert(globalContent.includes('user_name = "TestUser"'), 'user_name lands in ~/.bmad/config.user.toml');
|
||||
assert(globalContent.includes('communication_language = "Spanish"'), 'communication_language lands in ~/.bmad/config.user.toml');
|
||||
|
||||
// [modules.bmm] — bmm answers in this fixture are ALL defaults (the test
|
||||
// intentionally seeds the same values module.yaml defaults to). With
|
||||
// Task F, an all-defaults section is omitted entirely.
|
||||
// user_skill_level = "expert" IS a delta from default "intermediate"
|
||||
// → that one key remains in [modules.bmm] in config.user.toml.
|
||||
assert(!teamContent.includes('[modules.bmm]'), 'config.toml has NO [modules.bmm] section when all values equal defaults (Task F)');
|
||||
const bmmUserMatch = userContent.match(/\[modules\.bmm\][\s\S]*?(?=\n\[|$)/);
|
||||
assert(bmmUserMatch !== null, 'config.user.toml has [modules.bmm] section');
|
||||
assert(bmmUserMatch !== null, 'config.user.toml has [modules.bmm] section (carries the user_skill_level delta)');
|
||||
if (bmmUserMatch) {
|
||||
assert(bmmUserMatch[0].includes('user_skill_level = "expert"'), 'user_skill_level lands in config.user.toml [modules.bmm]');
|
||||
assert(bmmUserMatch[0].includes('user_skill_level = "expert"'), 'user_skill_level delta lands in config.user.toml');
|
||||
}
|
||||
|
||||
// [modules.external-mod] — unknown schema, falls through as team; core keys still stripped
|
||||
// [modules.external-mod] — unknown schema, no defaults to compare. Falls
|
||||
// through as team; core-key pollution still stripped.
|
||||
const extMatch = teamContent.match(/\[modules\.external-mod\][\s\S]*?(?=\n\[|$)/);
|
||||
assert(extMatch !== null, 'Unknown-schema module survives with its own [modules.*] section');
|
||||
if (extMatch) {
|
||||
|
|
@ -1810,20 +1827,54 @@ async function runTests() {
|
|||
assert(!extBlock.includes('communication_language'), 'All core-key pollution stripped from unknown-schema module');
|
||||
}
|
||||
|
||||
// [agents.*] — agent roster from bmm module.yaml baked into config.toml (team-only)
|
||||
assert(teamContent.includes('[agents.bmad-agent-analyst]'), 'config.toml has [agents.bmad-agent-analyst] table');
|
||||
assert(teamContent.includes('[agents.bmad-agent-dev]'), 'config.toml has [agents.bmad-agent-dev] table');
|
||||
assert(teamContent.includes('module = "bmm"'), 'Agent entry serializes module field');
|
||||
assert(teamContent.includes('team = "software-development"'), 'Agent entry serializes team field');
|
||||
assert(teamContent.includes('name = "Mary"'), 'Agent entry serializes name');
|
||||
assert(teamContent.includes('icon = "📊"'), 'Agent entry serializes icon');
|
||||
// [agents.*] — Task F: NEVER emitted to config.toml. Roster lives in
|
||||
// _bmad/{module}/module.toml floor (written by writeModuleTomls).
|
||||
assert(!teamContent.includes('[agents.'), 'config.toml has NO [agents.*] sections (Task F)');
|
||||
assert(!userContent.includes('[agents.'), '[agents.*] tables are never written to config.user.toml');
|
||||
|
||||
// Header comments present on both files
|
||||
// Header comments present on both project files
|
||||
assert(teamContent.includes('Installer-managed. Regenerated on every install'), 'config.toml has installer-managed header');
|
||||
assert(userContent.includes('Holds install answers scoped to YOU personally.'), 'config.user.toml header clarifies user scope');
|
||||
assert(globalContent.includes('Global personal BMad config'), '~/.bmad/config.user.toml has global-personal header');
|
||||
|
||||
// writeGlobalUserCore must preserve hand-edits in shapes the old
|
||||
// round-trip parser silently dropped — arrays, single-quoted strings,
|
||||
// dotted/quoted keys, \uXXXX escapes, custom sections.
|
||||
const handEdited = [
|
||||
'# user-authored — should survive installer rewrites',
|
||||
'[core]',
|
||||
'user_name = "WillBeOverwritten"',
|
||||
'nickname = "blank-on-purpose"',
|
||||
'',
|
||||
'[custom_section]',
|
||||
"literal = 'single-quoted string'",
|
||||
'tags = ["a", "b", "c"]',
|
||||
String.raw`unicode = "Märy"`,
|
||||
'"weird.key" = "quoted-dotted key"',
|
||||
'',
|
||||
].join('\n');
|
||||
await fs.writeFile(globalCorePath, handEdited, 'utf8');
|
||||
await generator35.writeGlobalUserCore({ core: { user_name: 'Updated', communication_language: 'Italian' } });
|
||||
const afterReplay = await fs.readFile(globalCorePath, 'utf8');
|
||||
assert(afterReplay.includes('user_name = "Updated"'), 'writeGlobalUserCore updates the key we own');
|
||||
assert(afterReplay.includes('communication_language = "Italian"'), 'writeGlobalUserCore writes new scope:user key');
|
||||
assert(afterReplay.includes("literal = 'single-quoted string'"), 'writeGlobalUserCore preserves single-quoted string values');
|
||||
assert(afterReplay.includes('tags = ["a", "b", "c"]'), 'writeGlobalUserCore preserves array values');
|
||||
assert(afterReplay.includes(String.raw`unicode = "Märy"`), String.raw`writeGlobalUserCore preserves \uXXXX escapes verbatim`);
|
||||
assert(afterReplay.includes('"weird.key" = "quoted-dotted key"'), 'writeGlobalUserCore preserves quoted/dotted keys');
|
||||
assert(afterReplay.includes('[custom_section]'), 'writeGlobalUserCore preserves user-authored sections');
|
||||
assert(
|
||||
afterReplay.includes('nickname = "blank-on-purpose"'),
|
||||
'writeGlobalUserCore preserves hand-written keys outside the installer schema',
|
||||
);
|
||||
} finally {
|
||||
await fs.remove(tempBmadDir35).catch(() => {});
|
||||
await fs.remove(tempGlobalDir35).catch(() => {});
|
||||
if (priorBmadHome35 === undefined) {
|
||||
delete process.env.BMAD_HOME;
|
||||
} else {
|
||||
process.env.BMAD_HOME = priorBmadHome35;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1866,66 +1917,56 @@ async function runTests() {
|
|||
console.log('');
|
||||
|
||||
// ============================================================
|
||||
// Test Suite 37: Agent Preservation for Non-Contributing Modules
|
||||
// Test Suite 37: Agent roster routes through module.toml, not config.toml
|
||||
// ============================================================
|
||||
console.log(`${colors.yellow}Test Suite 37: Agent Preservation for Non-Contributing Modules${colors.reset}\n`);
|
||||
console.log(`${colors.yellow}Test Suite 37: Agent roster routes through module.toml${colors.reset}\n`);
|
||||
|
||||
{
|
||||
// Scenario: quickUpdate preserves a module whose source isn't available
|
||||
// (e.g. external/marketplace). Its module.yaml isn't read, so its agents
|
||||
// aren't in this.agents. writeCentralConfig must read the prior config.toml
|
||||
// and keep those [agents.*] blocks so the roster doesn't silently shrink.
|
||||
const tempBmadDir37 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-agent-preserve-'));
|
||||
// Phase 1 (Task F): writeCentralConfig no longer emits [agents.*] blocks.
|
||||
// The roster lives in _bmad/{module}/module.toml (the resolver floor).
|
||||
// This suite verifies the relocation — same data, different home.
|
||||
//
|
||||
// Preservation across reinstalls (the OLD concern of this suite) is now
|
||||
// a non-issue: writeModuleTomls always reads the source module.yaml at
|
||||
// install time and rewrites module.toml from scratch. There's no "stale
|
||||
// entry" to preserve because the file gets fully regenerated.
|
||||
const tempBmadDir37 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-agents-floor-'));
|
||||
const tempGlobalDir37 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-global-37-'));
|
||||
const priorBmadHome37 = process.env.BMAD_HOME;
|
||||
process.env.BMAD_HOME = tempGlobalDir37;
|
||||
|
||||
try {
|
||||
// Seed a prior config.toml with an agent from an external module
|
||||
const priorToml = [
|
||||
'# prior',
|
||||
'',
|
||||
'[agents.bmad-agent-analyst]',
|
||||
'module = "bmm"',
|
||||
'team = "bmm"',
|
||||
'name = "Stale Mary"',
|
||||
'',
|
||||
'[agents.external-hero]',
|
||||
'module = "external-mod"',
|
||||
'team = "external-mod"',
|
||||
'name = "Hero"',
|
||||
'title = "External Agent"',
|
||||
'icon = "🦸"',
|
||||
'description = "Ships with the marketplace module."',
|
||||
'',
|
||||
].join('\n');
|
||||
await fs.writeFile(path.join(tempBmadDir37, 'config.toml'), priorToml);
|
||||
// Set up bmm/ directory so writeModuleTomls has a place to write
|
||||
await fs.ensureDir(path.join(tempBmadDir37, 'bmm'));
|
||||
|
||||
const generator37 = new ManifestGenerator();
|
||||
generator37.bmadDir = tempBmadDir37;
|
||||
generator37.bmadFolderName = path.basename(tempBmadDir37);
|
||||
generator37.updatedModules = ['core', 'bmm', 'external-mod'];
|
||||
generator37.updatedModules = ['core', 'bmm'];
|
||||
|
||||
// bmm source is available; external-mod is not — it's a preserved module
|
||||
await generator37.collectAgentsFromModuleYaml();
|
||||
const freshModules = new Set(generator37.agents.map((a) => a.module));
|
||||
assert(freshModules.has('bmm'), 'bmm contributes fresh agents from src module.yaml');
|
||||
assert(!freshModules.has('external-mod'), 'external-mod source is unavailable (preserved-module scenario)');
|
||||
|
||||
await generator37.writeCentralConfig(tempBmadDir37, { core: {}, bmm: {}, 'external-mod': {} });
|
||||
await generator37.writeCentralConfig(tempBmadDir37, { core: {}, bmm: {} });
|
||||
await generator37.writeModuleTomls(tempBmadDir37);
|
||||
|
||||
const teamContent = await fs.readFile(path.join(tempBmadDir37, 'config.toml'), 'utf8');
|
||||
const bmmTomlContent = await fs.readFile(path.join(tempBmadDir37, 'bmm', 'module.toml'), 'utf8');
|
||||
|
||||
assert(
|
||||
teamContent.includes('[agents.external-hero]'),
|
||||
'Preserved [agents.external-hero] block survives rewrite even though external-mod source was unavailable',
|
||||
);
|
||||
assert(teamContent.includes('Ships with the marketplace module.'), 'Preserved block keeps its original description');
|
||||
assert(teamContent.includes('module = "external-mod"'), 'Preserved block keeps its module field');
|
||||
// Task F: config.toml carries no [agents.*] sections
|
||||
assert(!teamContent.includes('[agents.'), 'config.toml has no [agents.*] sections (Task F)');
|
||||
|
||||
// Freshly collected agents win over stale entries with the same code
|
||||
const maryMatches = teamContent.match(/\[agents\.bmad-agent-analyst\]/g) || [];
|
||||
assert(maryMatches.length === 1, 'bmad-agent-analyst emitted exactly once (fresh wins; stale not duplicated)');
|
||||
assert(!teamContent.includes('Stale Mary'), 'Stale name from prior config.toml is discarded when fresh module.yaml is read');
|
||||
// The roster lives in module.toml instead
|
||||
assert(bmmTomlContent.includes('[agents.bmad-agent-analyst]'), 'bmm/module.toml carries [agents.bmad-agent-analyst]');
|
||||
assert(bmmTomlContent.includes('[agents.bmad-agent-dev]'), 'bmm/module.toml carries [agents.bmad-agent-dev]');
|
||||
assert(bmmTomlContent.includes('module = "bmm"'), 'Agent block in module.toml has module field');
|
||||
assert(bmmTomlContent.includes('name = "Mary"'), 'Agent block in module.toml has name');
|
||||
} finally {
|
||||
await fs.remove(tempBmadDir37).catch(() => {});
|
||||
await fs.remove(tempGlobalDir37).catch(() => {});
|
||||
if (priorBmadHome37 === undefined) {
|
||||
delete process.env.BMAD_HOME;
|
||||
} else {
|
||||
process.env.BMAD_HOME = priorBmadHome37;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2006,17 +2047,46 @@ async function runTests() {
|
|||
assert(byCode.get('bmad-fake-ext-agent-one').module === 'fake-ext', 'agent.module matches the owning external module name');
|
||||
assert(byCode.get('bmad-fake-ext-agent-one').team === 'fake', 'explicit team from module.yaml is preserved');
|
||||
|
||||
await generator38.writeCentralConfig(tempBmadDir38, {
|
||||
core: {},
|
||||
bmm: {},
|
||||
'fake-ext': {},
|
||||
'fake-skills': {},
|
||||
});
|
||||
// Set up per-module dirs so writeModuleTomls has somewhere to write
|
||||
await fs.ensureDir(path.join(tempBmadDir38, 'fake-ext'));
|
||||
await fs.ensureDir(path.join(tempBmadDir38, 'fake-skills'));
|
||||
|
||||
const teamContent = await fs.readFile(path.join(tempBmadDir38, 'config.toml'), 'utf8');
|
||||
assert(teamContent.includes('[agents.bmad-fake-ext-agent-one]'), 'external-module agents land in config.toml [agents.*] section');
|
||||
assert(teamContent.includes('[agents.bmad-fake-skills-agent]'), 'skills-layout external module agents also land in config.toml');
|
||||
assert(teamContent.includes('First fake external agent.'), 'agent description from external module.yaml is written');
|
||||
// Isolate BMAD_HOME for the global-config write step
|
||||
const tempGlobalDir38 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-global-38-'));
|
||||
const priorBmadHome38 = process.env.BMAD_HOME;
|
||||
process.env.BMAD_HOME = tempGlobalDir38;
|
||||
|
||||
try {
|
||||
await generator38.writeCentralConfig(tempBmadDir38, {
|
||||
core: {},
|
||||
bmm: {},
|
||||
'fake-ext': {},
|
||||
'fake-skills': {},
|
||||
});
|
||||
await generator38.writeModuleTomls(tempBmadDir38);
|
||||
|
||||
const teamContent = await fs.readFile(path.join(tempBmadDir38, 'config.toml'), 'utf8');
|
||||
// Task F: agents NEVER land in config.toml anymore
|
||||
assert(!teamContent.includes('[agents.'), 'External module agents are NOT in config.toml (Task F)');
|
||||
|
||||
// Instead, each external module gets its own _bmad/{module}/module.toml floor
|
||||
const fakeExtToml = await fs.readFile(path.join(tempBmadDir38, 'fake-ext', 'module.toml'), 'utf8');
|
||||
const fakeSkillsToml = await fs.readFile(path.join(tempBmadDir38, 'fake-skills', 'module.toml'), 'utf8');
|
||||
|
||||
assert(fakeExtToml.includes('[agents.bmad-fake-ext-agent-one]'), 'external-module agents land in their own module.toml floor');
|
||||
assert(
|
||||
fakeSkillsToml.includes('[agents.bmad-fake-skills-agent]'),
|
||||
'skills-layout external module agents land in their own module.toml floor',
|
||||
);
|
||||
assert(fakeExtToml.includes('First fake external agent.'), 'agent description from external module.yaml lands in module.toml');
|
||||
} finally {
|
||||
await fs.remove(tempGlobalDir38).catch(() => {});
|
||||
if (priorBmadHome38 === undefined) {
|
||||
delete process.env.BMAD_HOME;
|
||||
} else {
|
||||
process.env.BMAD_HOME = priorBmadHome38;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (priorCacheEnv === undefined) {
|
||||
delete process.env.BMAD_EXTERNAL_MODULES_CACHE;
|
||||
|
|
@ -2988,6 +3058,12 @@ async function runTests() {
|
|||
// Test Suite 44: --set <module>.<key>=<value> CLI overrides (#1663)
|
||||
// ============================================================
|
||||
console.log(`${colors.yellow}Test Suite 44: --set CLI overrides${colors.reset}\n`);
|
||||
// Isolate $BMAD_HOME for the whole suite — applySetOverrides now consults
|
||||
// ~/.bmad/config.user.toml for routing decisions, and we don't want tests
|
||||
// touching the developer's actual global identity.
|
||||
const tempGlobalDir44 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-applyset-global-'));
|
||||
const priorBmadHome44 = process.env.BMAD_HOME;
|
||||
process.env.BMAD_HOME = tempGlobalDir44;
|
||||
try {
|
||||
const { parseSetEntry, parseSetEntries, applySetOverrides, upsertTomlKey, tomlString } = require('../tools/installer/set-overrides');
|
||||
const { discoverOfficialModuleYamls, formatOptionsList } = require('../tools/installer/list-options');
|
||||
|
|
@ -3046,6 +3122,17 @@ async function runTests() {
|
|||
assert(tomlString(String.raw`back\slash`) === String.raw`"back\\slash"`, 'tomlString escapes backslashes');
|
||||
assert(tomlString('line1\nline2') === String.raw`"line1\nline2"`, 'tomlString escapes newlines');
|
||||
|
||||
// ---- tomlString: type inference (--set bmm.workers=4 must be int) ----
|
||||
assert(tomlString('true') === 'true', 'tomlString emits bare bool for "true"');
|
||||
assert(tomlString('false') === 'false', 'tomlString emits bare bool for "false"');
|
||||
assert(tomlString('4') === '4', 'tomlString emits bare integer for digit string');
|
||||
assert(tomlString('-17') === '-17', 'tomlString emits bare integer for negative');
|
||||
assert(tomlString('3.14') === '3.14', 'tomlString emits bare float for decimal');
|
||||
assert(tomlString('-0.5') === '-0.5', 'tomlString emits bare float for negative decimal');
|
||||
assert(tomlString('1e10') === '"1e10"', 'tomlString quotes scientific notation (not in inferred set)');
|
||||
assert(tomlString('4.') === '"4."', 'tomlString quotes incomplete float (preserves as string)');
|
||||
assert(tomlString('"true"') === String.raw`"\"true\""`, 'tomlString preserves explicitly-quoted "true" as string');
|
||||
|
||||
// ---- upsertTomlKey: insert into existing section ---------------------
|
||||
{
|
||||
const before = `[core]\nuser_name = "Brian"\n\n[modules.bmm]\nproject_knowledge = "{project-root}/docs"\n`;
|
||||
|
|
@ -3087,6 +3174,26 @@ async function runTests() {
|
|||
assert(!withoutTrailing.endsWith('\n'), 'upsertTomlKey preserves absence of trailing newline');
|
||||
}
|
||||
|
||||
// ---- upsertTomlKey: `#` inside string value is NOT a comment ---------
|
||||
// Per TOML spec, basic strings may contain unescaped `#`. The previous
|
||||
// /\s+#/ scanner truncated such values, producing malformed TOML.
|
||||
{
|
||||
const before = `[core]\nproject_name = "hello # world"\n`;
|
||||
const after = upsertTomlKey(before, '[core]', 'project_name', '"updated # value"');
|
||||
assert(after.includes('project_name = "updated # value"'), 'upsertTomlKey writes value containing # intact');
|
||||
assert(!after.includes('# world'), 'upsertTomlKey does not preserve a fake comment that lived inside the old string');
|
||||
}
|
||||
|
||||
// ---- upsertTomlKey: section header with inline comment is found -----
|
||||
{
|
||||
const before = `[core] # personal identity\nuser_name = "old"\n`;
|
||||
const after = upsertTomlKey(before, '[core]', 'user_name', '"Brian"');
|
||||
assert(after.includes('user_name = "Brian"'), 'upsertTomlKey updates key under header with inline comment');
|
||||
// Should not have appended a duplicate [core] block at EOF.
|
||||
const headerOccurrences = (after.match(/^\[core]/gm) || []).length;
|
||||
assert(headerOccurrences === 1, `upsertTomlKey reuses existing [core] header (got ${headerOccurrences} headers)`);
|
||||
}
|
||||
|
||||
// ---- applySetOverrides happy path ------------------------------------
|
||||
{
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-applyset-'));
|
||||
|
|
@ -3152,15 +3259,24 @@ async function runTests() {
|
|||
await fs.writeFile(path.join(bmadDir, 'config.toml'), '[core]\nuser_name = "Brian"\n', 'utf8');
|
||||
await fs.ensureDir(path.join(bmadDir, 'core'));
|
||||
await fs.writeFile(path.join(bmadDir, 'core', 'config.yaml'), 'user_name: Brian\n', 'utf8');
|
||||
// Override targets a key only in team config; routes to team. user.toml
|
||||
// never gets created in this case (correct — no user-scope writes).
|
||||
// Override targets a core scope:user key. Per Task D's 3-tier routing,
|
||||
// core.user_name lands in ~/.bmad/config.user.toml regardless of whether
|
||||
// project user.toml exists — that's where the resolver picks it up.
|
||||
// Project files (team and user) are left untouched.
|
||||
await applySetOverrides({ core: { user_name: 'Updated' } }, bmadDir);
|
||||
const team = await fs.readFile(path.join(bmadDir, 'config.toml'), 'utf8');
|
||||
assert(team.includes('user_name = "Updated"'), 'applySetOverrides updates team key when user.toml is absent');
|
||||
const globalContent = await fs.readFile(path.join(tempGlobalDir44, 'config.user.toml'), 'utf8');
|
||||
assert(
|
||||
globalContent.includes('user_name = "Updated"'),
|
||||
'applySetOverrides routes core.user_name to global when project user.toml is absent',
|
||||
);
|
||||
assert(!team.includes('user_name = "Updated"'), 'applySetOverrides does not write core scope:user keys to project team config');
|
||||
assert(
|
||||
!(await fs.pathExists(path.join(bmadDir, 'config.user.toml'))),
|
||||
'applySetOverrides does not create config.user.toml unnecessarily',
|
||||
'applySetOverrides does not create project config.user.toml for scope:user core keys',
|
||||
);
|
||||
// Reset global file for the next test block.
|
||||
await fs.remove(path.join(tempGlobalDir44, 'config.user.toml')).catch(() => {});
|
||||
await fs.remove(tmp).catch(() => {});
|
||||
}
|
||||
|
||||
|
|
@ -3174,11 +3290,17 @@ async function runTests() {
|
|||
await fs.writeFile(path.join(bmadDir, 'core', 'config.yaml'), 'user_name: Brian\n', 'utf8');
|
||||
// bmm is not installed (no `_bmad/bmm/config.yaml`). The override for
|
||||
// bmm should be silently skipped, no `[modules.bmm]` section created.
|
||||
// core.user_name is scope:user → routed to global (~/.bmad).
|
||||
const applied = await applySetOverrides({ bmm: { foo: 'bar' }, core: { user_name: 'Updated' } }, bmadDir);
|
||||
const team = await fs.readFile(path.join(bmadDir, 'config.toml'), 'utf8');
|
||||
const globalContent = await fs.readFile(path.join(tempGlobalDir44, 'config.user.toml'), 'utf8');
|
||||
assert(!team.includes('[modules.bmm]'), 'applySetOverrides does NOT create section for uninstalled module');
|
||||
assert(team.includes('user_name = "Updated"'), 'applySetOverrides still applies overrides for installed modules');
|
||||
assert(
|
||||
globalContent.includes('user_name = "Updated"'),
|
||||
'applySetOverrides still applies overrides for installed modules (routed to global for scope:user core)',
|
||||
);
|
||||
assert(applied.length === 1 && applied[0].module === 'core', 'applySetOverrides reports only the installed-module entries');
|
||||
await fs.remove(path.join(tempGlobalDir44, 'config.user.toml')).catch(() => {});
|
||||
await fs.remove(tmp).catch(() => {});
|
||||
}
|
||||
|
||||
|
|
@ -3234,6 +3356,264 @@ async function runTests() {
|
|||
console.log(`${colors.red}Test Suite 44 setup failed: ${error.message}${colors.reset}`);
|
||||
console.log(error.stack);
|
||||
failed++;
|
||||
} finally {
|
||||
if (priorBmadHome44 === undefined) {
|
||||
delete process.env.BMAD_HOME;
|
||||
} else {
|
||||
process.env.BMAD_HOME = priorBmadHome44;
|
||||
}
|
||||
await fs.remove(tempGlobalDir44).catch(() => {});
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// ============================================================
|
||||
// Test Suite 45: Per-module module.toml floor (writeModuleTomls)
|
||||
// ============================================================
|
||||
console.log(`${colors.yellow}Test Suite 45: Per-module module.toml floor${colors.reset}\n`);
|
||||
|
||||
try {
|
||||
const tempBmadDir45 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-module-toml-'));
|
||||
|
||||
try {
|
||||
// Real bmm src tree provides module.yaml. Installer expects each
|
||||
// installed module directory to exist before writeModuleTomls runs.
|
||||
await fs.ensureDir(path.join(tempBmadDir45, 'bmm'));
|
||||
await fs.ensureDir(path.join(tempBmadDir45, 'core'));
|
||||
|
||||
const generator45 = new ManifestGenerator();
|
||||
generator45.bmadDir = tempBmadDir45;
|
||||
generator45.bmadFolderName = path.basename(tempBmadDir45);
|
||||
generator45.updatedModules = ['core', 'bmm'];
|
||||
|
||||
// Collect agents (needed for [agents.X] blocks per module)
|
||||
await generator45.collectAgentsFromModuleYaml();
|
||||
assert(generator45.agents.length >= 6, 'agents collected before writeModuleTomls');
|
||||
|
||||
const written = await generator45.writeModuleTomls(tempBmadDir45);
|
||||
assert(Array.isArray(written), 'writeModuleTomls returns array of written paths');
|
||||
assert(written.length > 0, 'writeModuleTomls writes at least one module.toml');
|
||||
|
||||
const bmmTomlPath = path.join(tempBmadDir45, 'bmm', 'module.toml');
|
||||
assert(await fs.pathExists(bmmTomlPath), 'bmm/module.toml is written to disk');
|
||||
|
||||
const bmmContent = await fs.readFile(bmmTomlPath, 'utf8');
|
||||
|
||||
// Header comment present
|
||||
assert(bmmContent.includes('Module-shipped defaults'), 'module.toml has header comment');
|
||||
assert(bmmContent.includes('Build artifact'), 'module.toml warns against hand-editing');
|
||||
|
||||
// [modules.bmm] section with defaults from module.yaml
|
||||
assert(bmmContent.includes('[modules.bmm]'), 'bmm/module.toml has [modules.bmm] section');
|
||||
assert(/planning_artifacts\s*=\s*"[^"]+"/.test(bmmContent), '[modules.bmm] carries planning_artifacts default');
|
||||
// {project-root} preserved literal (runtime substitution); {output_folder}
|
||||
// resolved at install time against core's default output_folder. Final
|
||||
// shape matches the legacy config.yaml convention.
|
||||
assert(
|
||||
bmmContent.includes('{project-root}/_bmad-output/planning-artifacts'),
|
||||
'Cross-key placeholders resolved at install time ({output_folder} → "_bmad-output"); {project-root} preserved',
|
||||
);
|
||||
assert(!bmmContent.includes('{output_folder}'), '{output_folder} placeholder is NOT left literal — resolved against module defaults');
|
||||
|
||||
// [agents.X] blocks for module-owned agents
|
||||
assert(bmmContent.includes('[agents.bmad-agent-analyst]'), 'bmm/module.toml has [agents.bmad-agent-analyst]');
|
||||
assert(bmmContent.includes('[agents.bmad-agent-dev]'), 'bmm/module.toml has [agents.bmad-agent-dev]');
|
||||
assert(bmmContent.includes('module = "bmm"'), 'Agent block carries module field');
|
||||
assert(bmmContent.includes('name = "Mary"'), 'Agent block carries name');
|
||||
assert(bmmContent.includes('icon = "📊"'), 'Agent block carries emoji icon');
|
||||
|
||||
// module.toml is SCOPED to one module — bmm should not carry core's agents
|
||||
// (core/module.yaml ships no agents today, but the principle stands)
|
||||
const coreTomlPath = path.join(tempBmadDir45, 'core', 'module.toml');
|
||||
assert(await fs.pathExists(coreTomlPath), 'core/module.toml is written even without agents');
|
||||
|
||||
const coreContent = await fs.readFile(coreTomlPath, 'utf8');
|
||||
// bmm agents must NOT appear in core/module.toml
|
||||
assert(!coreContent.includes('[agents.bmad-agent-analyst]'), 'bmm-owned agents do NOT leak into core/module.toml');
|
||||
|
||||
// [core] questions (user_name, project_name, etc.) are core's defaults
|
||||
// and must live at top-level [core] — resolvers + consumers read core.*
|
||||
// from that namespace, not from a nested [modules.core] subtree.
|
||||
assert(coreContent.includes('[core]'), 'core/module.toml has top-level [core] section');
|
||||
assert(!coreContent.includes('[modules.core]'), 'core defaults do NOT live under [modules.core]');
|
||||
assert(coreContent.includes('user_name = "BMad"'), 'core/module.toml carries user_name default ("BMad" from module.yaml)');
|
||||
assert(coreContent.includes('document_output_language = "English"'), 'core/module.toml carries document_output_language default');
|
||||
|
||||
// Question UI machinery NEVER appears as a TOML key in module.toml
|
||||
// (Match on line-start "key =" so we don't catch the word "prompt" inside
|
||||
// a description string — e.g. "Speaks like a terminal prompt".)
|
||||
assert(!/^prompt\s*=/m.test(bmmContent), 'No prompt key in module.toml');
|
||||
assert(!/^scope\s*=/m.test(bmmContent), 'No scope key in module.toml');
|
||||
assert(!bmmContent.includes('single-select'), 'No single-select machinery in module.toml');
|
||||
} finally {
|
||||
await fs.remove(tempBmadDir45).catch(() => {});
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`${colors.red}Test Suite 45 setup failed: ${error.message}${colors.reset}`);
|
||||
console.log(error.stack);
|
||||
failed++;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// ============================================================
|
||||
// Test Suite 46: Phase 1 end-to-end — fresh machine + second project
|
||||
// ============================================================
|
||||
console.log(`${colors.yellow}Test Suite 46: Phase 1 end-to-end (fresh machine + second project)${colors.reset}\n`);
|
||||
|
||||
try {
|
||||
const tempGlobalDir46 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-e2e-global-'));
|
||||
const priorBmadHome46 = process.env.BMAD_HOME;
|
||||
process.env.BMAD_HOME = tempGlobalDir46;
|
||||
|
||||
try {
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Part A: Fresh-machine install, project #1
|
||||
// ────────────────────────────────────────────────────────────
|
||||
const project1 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-e2e-proj1-'));
|
||||
const bmad1 = path.join(project1, '_bmad');
|
||||
await fs.ensureDir(path.join(bmad1, 'core'));
|
||||
await fs.ensureDir(path.join(bmad1, 'bmm'));
|
||||
|
||||
// Simulated installer output for project #1: user kept defaults for
|
||||
// every core key EXCEPT project_name, which is per-project.
|
||||
const project1Configs = {
|
||||
core: {
|
||||
user_name: 'Brian',
|
||||
project_name: 'first-project',
|
||||
communication_language: 'English',
|
||||
document_output_language: 'English',
|
||||
output_folder: '{project-root}/_bmad-output',
|
||||
},
|
||||
bmm: {
|
||||
user_skill_level: 'intermediate',
|
||||
planning_artifacts: '{project-root}/_bmad-output/planning-artifacts',
|
||||
implementation_artifacts: '{project-root}/_bmad-output/implementation-artifacts',
|
||||
project_knowledge: '{project-root}/docs',
|
||||
},
|
||||
};
|
||||
|
||||
const gen1 = new ManifestGenerator();
|
||||
gen1.bmadDir = bmad1;
|
||||
gen1.bmadFolderName = path.basename(bmad1);
|
||||
gen1.updatedModules = ['core', 'bmm'];
|
||||
await gen1.collectAgentsFromModuleYaml();
|
||||
await gen1.writeCentralConfig(bmad1, project1Configs);
|
||||
await gen1.writeModuleTomls(bmad1);
|
||||
await gen1.writeGlobalUserCore(project1Configs);
|
||||
|
||||
// Project #1: config.toml is lean — only project_name (the one delta)
|
||||
const team1 = await fs.readFile(path.join(bmad1, 'config.toml'), 'utf8');
|
||||
assert(team1.includes('project_name = "first-project"'), 'P1 config.toml carries project_name delta');
|
||||
assert(!team1.includes('user_name'), 'P1 config.toml has no user_name (scope:user routed to global)');
|
||||
assert(!team1.includes('document_output_language'), 'P1 config.toml strips default document_output_language');
|
||||
assert(!team1.includes('[agents.'), 'P1 config.toml has NO [agents.*] sections');
|
||||
assert(!team1.includes('[modules.bmm]'), 'P1 config.toml has NO [modules.bmm] section (all defaults)');
|
||||
|
||||
// Project #1: config.user.toml is just the header
|
||||
const user1 = await fs.readFile(path.join(bmad1, 'config.user.toml'), 'utf8');
|
||||
assert(!user1.includes('user_name'), 'P1 config.user.toml has no user_name');
|
||||
assert(!user1.includes('[modules.bmm]'), 'P1 config.user.toml has no [modules.bmm]');
|
||||
|
||||
// Global file populated with scope:user core values from project #1
|
||||
const globalPath = path.join(tempGlobalDir46, 'config.user.toml');
|
||||
assert(await fs.pathExists(globalPath), 'Global ~/.bmad/config.user.toml created on first install');
|
||||
const global1 = await fs.readFile(globalPath, 'utf8');
|
||||
assert(global1.includes('user_name = "Brian"'), 'Global identity carries Brian after P1');
|
||||
assert(global1.includes('communication_language = "English"'), 'Global identity carries language after P1');
|
||||
|
||||
// module.toml floor: cross-key placeholders resolved at install time
|
||||
const bmm1 = await fs.readFile(path.join(bmad1, 'bmm', 'module.toml'), 'utf8');
|
||||
assert(
|
||||
bmm1.includes('planning_artifacts = "{project-root}/_bmad-output/planning-artifacts"'),
|
||||
'P1 bmm/module.toml has cross-key-resolved planning_artifacts',
|
||||
);
|
||||
assert(bmm1.includes('[agents.bmad-agent-analyst]'), 'P1 bmm/module.toml carries agent roster');
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Part B: Second project install on the same machine
|
||||
// - Global identity from P1 should be picked up by the OfficialModules
|
||||
// loader (loadGlobalConfig) as a silent default-source for scope:user
|
||||
// core questions (D) and a seed for non-user-scope ones (E).
|
||||
// - Resolver chain at runtime: global.core.user_name → reaches skills
|
||||
// even though P2 never wrote it locally.
|
||||
// ────────────────────────────────────────────────────────────
|
||||
const { OfficialModules } = require('../tools/installer/modules/official-modules');
|
||||
|
||||
const project2 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-e2e-proj2-'));
|
||||
const bmad2 = path.join(project2, '_bmad');
|
||||
await fs.ensureDir(bmad2);
|
||||
|
||||
const om = new OfficialModules();
|
||||
// Simulate what collectAllConfigurations does on entry: load global.
|
||||
om.globalConfig = await require('../tools/installer/global-config').loadGlobalConfig();
|
||||
assert(om.globalConfig.merged.core, 'OfficialModules.loadGlobalConfig returns populated [core]');
|
||||
assert(om.globalConfig.merged.core.user_name === 'Brian', 'Global config from P1 visible to OfficialModules on P2 install');
|
||||
assert(om.globalConfig.merged.core.communication_language === 'English', 'Global identity round-trips through TOML correctly');
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Part C: Python resolver sees identity via global even when P2's
|
||||
// project _bmad has no [core] user_name. Validates the full layered
|
||||
// resolver chain end-to-end.
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Write a minimal P2 install (just project_name in config.toml)
|
||||
const gen2 = new ManifestGenerator();
|
||||
gen2.bmadDir = bmad2;
|
||||
gen2.bmadFolderName = path.basename(bmad2);
|
||||
gen2.updatedModules = ['core', 'bmm'];
|
||||
await fs.ensureDir(path.join(bmad2, 'core'));
|
||||
await fs.ensureDir(path.join(bmad2, 'bmm'));
|
||||
const project2Configs = {
|
||||
core: {
|
||||
user_name: 'Brian', // Reused from global silently (D) — would normally bypass write
|
||||
project_name: 'second-project',
|
||||
communication_language: 'English',
|
||||
document_output_language: 'English',
|
||||
output_folder: '{project-root}/_bmad-output',
|
||||
},
|
||||
bmm: {
|
||||
user_skill_level: 'intermediate',
|
||||
planning_artifacts: '{project-root}/_bmad-output/planning-artifacts',
|
||||
implementation_artifacts: '{project-root}/_bmad-output/implementation-artifacts',
|
||||
project_knowledge: '{project-root}/docs',
|
||||
},
|
||||
};
|
||||
await gen2.collectAgentsFromModuleYaml();
|
||||
await gen2.writeCentralConfig(bmad2, project2Configs);
|
||||
await gen2.writeModuleTomls(bmad2);
|
||||
// Note: do NOT call writeGlobalUserCore again — values would be a no-op
|
||||
// merge, but in the real install they'd silently skip.
|
||||
|
||||
const { execSync } = require('node:child_process');
|
||||
const resolverPath = path.resolve(__dirname, '..', 'src/scripts/resolve_config.py');
|
||||
const resolved = JSON.parse(
|
||||
execSync(
|
||||
`python3 ${resolverPath} --project-root ${project2} ` +
|
||||
`--key core.user_name --key core.project_name --key agents.bmad-agent-analyst.name`,
|
||||
{ encoding: 'utf8', env: { ...process.env, BMAD_HOME: tempGlobalDir46 } },
|
||||
),
|
||||
);
|
||||
assert(resolved['core.user_name'] === 'Brian', 'Resolver returns user_name from global layer (P2 lean config.toml omitted it)');
|
||||
assert(
|
||||
resolved['core.project_name'] === 'second-project',
|
||||
'Resolver returns project_name from P2 config.toml (project layer beats global)',
|
||||
);
|
||||
assert(resolved['agents.bmad-agent-analyst.name'] === 'Mary', 'Resolver returns agent name from P2 module.toml floor');
|
||||
|
||||
await fs.remove(project1).catch(() => {});
|
||||
await fs.remove(project2).catch(() => {});
|
||||
} finally {
|
||||
await fs.remove(tempGlobalDir46).catch(() => {});
|
||||
if (priorBmadHome46 === undefined) {
|
||||
delete process.env.BMAD_HOME;
|
||||
} else {
|
||||
process.env.BMAD_HOME = priorBmadHome46;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`${colors.red}Test Suite 46 setup failed: ${error.message}${colors.reset}`);
|
||||
console.log(error.stack);
|
||||
failed++;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ const fs = require('../fs-native');
|
|||
const yaml = require('yaml');
|
||||
const crypto = require('node:crypto');
|
||||
const { resolveInstalledModuleYaml } = require('../project-root');
|
||||
const { globalUserConfigPath, loadGlobalConfig } = require('../global-config');
|
||||
const { upsertTomlKey } = require('../set-overrides');
|
||||
const prompts = require('../prompts');
|
||||
|
||||
// Load package.json for version info
|
||||
|
|
@ -82,11 +84,20 @@ class ManifestGenerator {
|
|||
|
||||
// Write manifest files and collect their paths
|
||||
const [teamConfigPath, userConfigPath] = await this.writeCentralConfig(bmadDir, options.moduleConfigs || {});
|
||||
// Per-module module.toml floor — shipped defaults + agent roster, read
|
||||
// by resolve_config.py as the lowest-priority layer. Independent of the
|
||||
// central config.toml; remains stable across user customizations.
|
||||
const moduleTomlPaths = await this.writeModuleTomls(bmadDir);
|
||||
// Task D: route scope:user core answers to ~/.bmad/config.user.toml.
|
||||
// Identity persists across projects on this machine, so re-installs in
|
||||
// other directories don't re-prompt for the same name/language.
|
||||
await this.writeGlobalUserCore(options.moduleConfigs || {});
|
||||
const manifestFiles = [
|
||||
await this.writeMainManifest(cfgDir),
|
||||
await this.writeSkillManifest(cfgDir),
|
||||
teamConfigPath,
|
||||
userConfigPath,
|
||||
...moduleTomlPaths,
|
||||
await this.writeFilesManifest(cfgDir),
|
||||
];
|
||||
|
||||
|
|
@ -419,25 +430,47 @@ class ManifestGenerator {
|
|||
}
|
||||
|
||||
/**
|
||||
* Write central _bmad/config.toml with [core], [modules.<code>], [agents.<code>] tables.
|
||||
* Install-owned. Team-scope answers → config.toml; user-scope answers → config.user.toml.
|
||||
* Both files are regenerated on every install. User overrides live in
|
||||
* _bmad/custom/config.toml and _bmad/custom/config.user.toml (never touched by installer).
|
||||
* Write central _bmad/config.toml as a LEAN OVERRIDE FILE — only values
|
||||
* the user actually changed from defaults land here. Defaults flow through
|
||||
* the module.toml floor (written by writeModuleTomls) and the global
|
||||
* config layer (~/.bmad/...).
|
||||
*
|
||||
* Specifically (Phase 1 — tasks D + F):
|
||||
* - [core] team-scope: emit only keys whose value differs from BOTH the
|
||||
* module.yaml default AND the global value (if any). Identity that
|
||||
* equals the global wins via the resolver chain regardless.
|
||||
* - [core] user-scope (user_name, communication_language): NEVER written
|
||||
* here. Routed to ~/.bmad/config.user.toml via writeGlobalUserCore.
|
||||
* - [modules.X]: emit only keys whose value differs from the module's
|
||||
* processed default. Skip the section entirely if all keys are default.
|
||||
* - [agents.X]: NEVER emitted. The roster lives in module.toml floor;
|
||||
* custom agents go in _bmad/custom/config.toml (never touched by us).
|
||||
*
|
||||
* Install-owned: both files are regenerated on every install. User
|
||||
* overrides live in _bmad/custom/config.toml and _bmad/custom/config.user.toml
|
||||
* (never touched by installer).
|
||||
*
|
||||
* @returns {string[]} Paths to the written config files
|
||||
*/
|
||||
async writeCentralConfig(bmadDir, moduleConfigs) {
|
||||
const teamPath = path.join(bmadDir, 'config.toml');
|
||||
const userPath = path.join(bmadDir, 'config.user.toml');
|
||||
|
||||
// Load each module's source module.yaml to determine scope per prompt key.
|
||||
// Default scope is 'team' when the prompt doesn't declare one.
|
||||
// When a module.yaml is unreadable we warn — for known official modules
|
||||
// this means user-scoped keys (e.g. user_name) could mis-file into the
|
||||
// team config, so the operator should notice.
|
||||
// Load each module's source module.yaml to determine:
|
||||
// 1. scope per prompt key (team vs user)
|
||||
// 2. the canonical module code (for [modules.{code}] section names)
|
||||
// 3. processed defaults per key (for delta detection)
|
||||
//
|
||||
// Pass 1: parse every module.yaml and capture its raw shipped defaults.
|
||||
// We use those — NOT the user's answered moduleConfigs — to resolve
|
||||
// cross-key placeholders like `{output_folder}`. Otherwise a user override
|
||||
// of output_folder would make every derived default (e.g. planning_artifacts)
|
||||
// match the user's value and get stripped from config.toml as "default",
|
||||
// even though module.toml's floor still carries the shipped path.
|
||||
const scopeByModuleKey = {};
|
||||
// Maps installer moduleName (may be full display name) → module code field
|
||||
// from module.yaml, so TOML sections use [modules.<code>] not [modules.<name>].
|
||||
const codeByModuleName = {};
|
||||
const defaultsByModuleKey = {};
|
||||
const parsedByModule = {};
|
||||
for (const moduleName of this.updatedModules) {
|
||||
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
|
||||
if (!moduleYamlPath) {
|
||||
|
|
@ -450,13 +483,8 @@ class ManifestGenerator {
|
|||
try {
|
||||
const parsed = yaml.parse(await fs.readFile(moduleYamlPath, 'utf8'));
|
||||
if (!parsed || typeof parsed !== 'object') continue;
|
||||
parsedByModule[moduleName] = parsed;
|
||||
if (parsed.code) codeByModuleName[moduleName] = parsed.code;
|
||||
scopeByModuleKey[moduleName] = {};
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (value && typeof value === 'object' && 'prompt' in value) {
|
||||
scopeByModuleKey[moduleName][key] = value.scope === 'user' ? 'user' : 'team';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[warn] writeCentralConfig: could not parse module.yaml for '${moduleName}' (${error.message}). ` +
|
||||
|
|
@ -465,6 +493,41 @@ class ManifestGenerator {
|
|||
}
|
||||
}
|
||||
|
||||
// Build the cross-key defaults map (same logic writeModuleTomls uses).
|
||||
// Shipped defaults only — never user answers.
|
||||
const crossKeyDefaults = {};
|
||||
for (const parsed of Object.values(parsedByModule)) {
|
||||
const raw = extractModuleDefaults(parsed);
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (crossKeyDefaults[key] !== undefined) continue;
|
||||
let stripped = value;
|
||||
if (typeof stripped === 'string' && stripped.startsWith('{project-root}/')) {
|
||||
stripped = stripped.slice('{project-root}/'.length);
|
||||
}
|
||||
crossKeyDefaults[key] = stripped;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: compute scopes and processed defaults using the symmetric map.
|
||||
for (const [moduleName, parsed] of Object.entries(parsedByModule)) {
|
||||
scopeByModuleKey[moduleName] = {};
|
||||
defaultsByModuleKey[moduleName] = {};
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (!value || typeof value !== 'object' || !('prompt' in value)) continue;
|
||||
scopeByModuleKey[moduleName][key] = value.scope === 'user' ? 'user' : 'team';
|
||||
const processedDefault = computeProcessedDefault(value, crossKeyDefaults);
|
||||
if (processedDefault !== undefined) {
|
||||
defaultsByModuleKey[moduleName][key] = processedDefault;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load the global config snapshot for [core] delta detection. If a key's
|
||||
// current value equals the global value, no need to duplicate it into the
|
||||
// project file — the resolver finds it globally.
|
||||
const globalSnapshot = await loadGlobalConfig().catch(() => ({ merged: {} }));
|
||||
const globalCore = (globalSnapshot.merged && globalSnapshot.merged.core) || {};
|
||||
|
||||
// Core keys are always known (core module.yaml is built-in). These are
|
||||
// the only keys allowed in [core]; they must be stripped from every
|
||||
// non-core module bucket because legacy _bmad/{mod}/config.yaml files
|
||||
|
|
@ -494,6 +557,23 @@ class ManifestGenerator {
|
|||
return { team, user };
|
||||
};
|
||||
|
||||
// Drop entries whose value equals an already-known default. Tasks F + D
|
||||
// both want config.toml to be a *delta* file — anything that matches
|
||||
// either the module.yaml default or the global config gets resolved
|
||||
// through the layer chain at read time, so writing it here is dead weight.
|
||||
const stripDefaults = (entries, perKeyDefaults = {}, fallbackDefaults = {}) => {
|
||||
const result = {};
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
const moduleDefault = perKeyDefaults[key];
|
||||
const fallbackDefault = fallbackDefaults[key];
|
||||
const isDefault =
|
||||
(moduleDefault !== undefined && deepEqualScalar(moduleDefault, value)) ||
|
||||
(fallbackDefault !== undefined && deepEqualScalar(fallbackDefault, value));
|
||||
if (!isDefault) result[key] = value;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const teamHeader = [
|
||||
'# ─────────────────────────────────────────────────────────────────',
|
||||
'# Installer-managed. Regenerated on every install — treat as read-only.',
|
||||
|
|
@ -526,9 +606,10 @@ class ManifestGenerator {
|
|||
const teamLines = [...teamHeader];
|
||||
const userLines = [...userHeader];
|
||||
|
||||
// [core] — split into team and user
|
||||
// [core] team — emit only deltas from module.yaml default AND global value.
|
||||
const coreConfig = moduleConfigs.core || {};
|
||||
const { team: coreTeam, user: coreUser } = partition('core', coreConfig);
|
||||
const { team: coreTeamRaw } = partition('core', coreConfig);
|
||||
const coreTeam = stripDefaults(coreTeamRaw, defaultsByModuleKey.core || {}, globalCore);
|
||||
if (Object.keys(coreTeam).length > 0) {
|
||||
teamLines.push('[core]');
|
||||
for (const [key, value] of Object.entries(coreTeam)) {
|
||||
|
|
@ -536,28 +617,24 @@ class ManifestGenerator {
|
|||
}
|
||||
teamLines.push('');
|
||||
}
|
||||
if (Object.keys(coreUser).length > 0) {
|
||||
userLines.push('[core]');
|
||||
for (const [key, value] of Object.entries(coreUser)) {
|
||||
userLines.push(`${key} = ${formatTomlValue(value)}`);
|
||||
}
|
||||
userLines.push('');
|
||||
}
|
||||
// [core] user-scope: never written to the project user.toml. Task D routes
|
||||
// these to ~/.bmad/config.user.toml via writeGlobalUserCore (called by
|
||||
// generateManifests after this method returns). config.user.toml stays
|
||||
// empty unless the user has manually pinned a per-project override.
|
||||
|
||||
// [modules.<code>] — split per module
|
||||
// [modules.<code>] — emit only deltas; skip section if no deltas.
|
||||
for (const moduleName of this.updatedModules) {
|
||||
if (moduleName === 'core') continue;
|
||||
const cfg = moduleConfigs[moduleName];
|
||||
if (!cfg || Object.keys(cfg).length === 0) continue;
|
||||
// Use the module's code field from module.yaml as the TOML key so the
|
||||
// section is [modules.mdo] not [modules.MDO: Maxio DevOps Operations].
|
||||
const sectionKey = codeByModuleName[moduleName] || moduleName;
|
||||
// Only filter out spread-from-core pollution when we actually know
|
||||
// this module's prompt schema. For external/marketplace modules whose
|
||||
// module.yaml isn't in the src tree, fall through as all-team so we
|
||||
// don't drop their real answers.
|
||||
const haveSchema = Object.keys(scopeByModuleKey[moduleName] || {}).length > 0;
|
||||
const { team: modTeam, user: modUser } = partition(moduleName, cfg, haveSchema);
|
||||
const { team: modTeamRaw, user: modUserRaw } = partition(moduleName, cfg, haveSchema);
|
||||
|
||||
const moduleDefaults = defaultsByModuleKey[moduleName] || {};
|
||||
const modTeam = stripDefaults(modTeamRaw, moduleDefaults);
|
||||
const modUser = stripDefaults(modUserRaw, moduleDefaults);
|
||||
|
||||
if (Object.keys(modTeam).length > 0) {
|
||||
teamLines.push(`[modules.${sectionKey}]`);
|
||||
for (const [key, value] of Object.entries(modTeam)) {
|
||||
|
|
@ -574,43 +651,10 @@ class ManifestGenerator {
|
|||
}
|
||||
}
|
||||
|
||||
// [agents.<code>] — always team (agent roster is organizational).
|
||||
// Freshly collected agents come from module.yaml this run. If a module
|
||||
// was preserved (e.g. during quickUpdate when its source isn't available),
|
||||
// its module.yaml wasn't read — so its agents aren't in `this.agents` and
|
||||
// would silently disappear from the roster. Preserve those existing
|
||||
// [agents.*] blocks verbatim from the prior config.toml.
|
||||
const freshAgentCodes = new Set(this.agents.map((a) => a.code));
|
||||
const contributingModules = new Set(this.agents.map((a) => a.module));
|
||||
const preservedModules = this.updatedModules.filter((m) => !contributingModules.has(m));
|
||||
const preservedBlocks = [];
|
||||
if (preservedModules.length > 0 && (await fs.pathExists(teamPath))) {
|
||||
try {
|
||||
const prev = await fs.readFile(teamPath, 'utf8');
|
||||
for (const block of extractAgentBlocks(prev)) {
|
||||
if (freshAgentCodes.has(block.code)) continue;
|
||||
if (block.module && preservedModules.includes(block.module)) {
|
||||
preservedBlocks.push(block.body);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[warn] writeCentralConfig: could not read prior config.toml to preserve agents: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const agent of this.agents) {
|
||||
const agentLines = [`[agents.${agent.code}]`, `module = ${formatTomlValue(agent.module)}`, `team = ${formatTomlValue(agent.team)}`];
|
||||
if (agent.name) agentLines.push(`name = ${formatTomlValue(agent.name)}`);
|
||||
if (agent.title) agentLines.push(`title = ${formatTomlValue(agent.title)}`);
|
||||
if (agent.icon) agentLines.push(`icon = ${formatTomlValue(agent.icon)}`);
|
||||
if (agent.description) agentLines.push(`description = ${formatTomlValue(agent.description)}`);
|
||||
agentLines.push('');
|
||||
teamLines.push(...agentLines);
|
||||
}
|
||||
|
||||
for (const body of preservedBlocks) {
|
||||
teamLines.push(body, '');
|
||||
}
|
||||
// [agents.<code>] — intentionally NOT emitted (Task F). The roster lives
|
||||
// in the per-module module.toml floor. Users who want to override or
|
||||
// add agents per-project edit _bmad/custom/config.toml; that file is
|
||||
// never touched by the installer.
|
||||
|
||||
const teamContent = teamLines.join('\n').replace(/\n+$/, '\n');
|
||||
const userContent = userLines.join('\n').replace(/\n+$/, '\n');
|
||||
|
|
@ -619,6 +663,198 @@ class ManifestGenerator {
|
|||
return [teamPath, userPath];
|
||||
}
|
||||
|
||||
/**
|
||||
* Write scope:user core values to ~/.bmad/config.user.toml (Task D).
|
||||
* Merge-preserves any existing global content the user hand-edited.
|
||||
*
|
||||
* Why a global write step at all? Identity values (user_name,
|
||||
* communication_language) are properties of the human, not the project.
|
||||
* Asking them at every install is friction. Phase 1 stores them globally
|
||||
* so they're answered once per machine.
|
||||
*
|
||||
* @param {object} moduleConfigs - the fully-resolved moduleConfigs map
|
||||
* @returns {Promise<string|null>} the path written, or null if no values
|
||||
*/
|
||||
async writeGlobalUserCore(moduleConfigs) {
|
||||
const coreScopes = {};
|
||||
// We need core's module.yaml scope map. Build it lazily here so this
|
||||
// method is callable without re-doing the writeCentralConfig setup.
|
||||
const coreYamlPath = await resolveInstalledModuleYaml('core');
|
||||
if (!coreYamlPath) return null;
|
||||
try {
|
||||
const parsed = yaml.parse(await fs.readFile(coreYamlPath, 'utf8'));
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (value && typeof value === 'object' && 'prompt' in value) {
|
||||
coreScopes[key] = value.scope === 'user' ? 'user' : 'team';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userScopeValues = {};
|
||||
for (const [key, value] of Object.entries(moduleConfigs.core || {})) {
|
||||
if (coreScopes[key] === 'user' && value !== undefined && value !== null && value !== '') {
|
||||
userScopeValues[key] = value;
|
||||
}
|
||||
}
|
||||
if (Object.keys(userScopeValues).length === 0) return null;
|
||||
|
||||
const globalPath = globalUserConfigPath();
|
||||
await fs.ensureDir(path.dirname(globalPath));
|
||||
|
||||
// Line-surgery upsert into the existing file (or a fresh one with the
|
||||
// installer header). We only touch the [core] keys we own. Every other
|
||||
// section, comment, and value passes through byte-for-byte — including
|
||||
// shapes the previous round-trip parser quietly dropped (arrays,
|
||||
// single-quoted strings, dotted/quoted keys, \uXXXX escapes, etc.).
|
||||
let content;
|
||||
if (await fs.pathExists(globalPath)) {
|
||||
content = await fs.readFile(globalPath, 'utf8');
|
||||
} else {
|
||||
content =
|
||||
[
|
||||
'# ─────────────────────────────────────────────────────────────────',
|
||||
'# Global personal BMad config — values tied to YOU as a user, not',
|
||||
'# any specific project. Installer writes scope:user identity here',
|
||||
'# (user_name, communication_language) so re-installs across projects',
|
||||
"# don't re-ask the same questions.",
|
||||
'#',
|
||||
'# Location precedence: $BMAD_HOME if set, else ~/.bmad',
|
||||
'# Resolver tier: lower than project-level _bmad/*.toml.',
|
||||
'# ─────────────────────────────────────────────────────────────────',
|
||||
'',
|
||||
].join('\n') + '\n';
|
||||
}
|
||||
for (const [key, value] of Object.entries(userScopeValues)) {
|
||||
content = upsertTomlKey(content, '[core]', key, formatTomlValue(value));
|
||||
}
|
||||
await fs.writeFile(globalPath, content);
|
||||
return globalPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write per-module `_bmad/{module}/module.toml` files — the "module floor"
|
||||
* read by resolve_config.py as the lowest-priority layer.
|
||||
*
|
||||
* Each file contains the shipped defaults for one module:
|
||||
* [modules.{code}] paths and other module-shape values (from
|
||||
* module.yaml question defaults, with `result:`
|
||||
* template applied + cross-key placeholders like
|
||||
* `{output_folder}` resolved against other modules'
|
||||
* defaults at install time)
|
||||
* [agents.{agent-code}] one block per agent owned by this module
|
||||
*
|
||||
* `{project-root}` is preserved literally — runtime substitution by skills.
|
||||
* Other cross-key references resolve against module.yaml DEFAULTS only,
|
||||
* not the user's actual answers. This keeps module.toml stable as a "what
|
||||
* the module ships" snapshot independent of per-project customization.
|
||||
* User overrides land in _bmad/config.toml above the floor.
|
||||
*
|
||||
* Source of truth is the authored module.yaml. This file is a build artifact
|
||||
* — regenerated on every install, never hand-edited.
|
||||
*
|
||||
* @param {string} bmadDir
|
||||
* @returns {Promise<string[]>} Paths to all written module.toml files
|
||||
*/
|
||||
async writeModuleTomls(bmadDir) {
|
||||
// Pass 1: parse every installed module.yaml, extract its raw defaults
|
||||
// (just `{value}` substituted; cross-key placeholders left literal).
|
||||
const moduleData = [];
|
||||
for (const moduleName of this.updatedModules) {
|
||||
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
|
||||
if (!moduleYamlPath) continue;
|
||||
|
||||
let moduleDef;
|
||||
try {
|
||||
moduleDef = yaml.parse(await fs.readFile(moduleYamlPath, 'utf8'));
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[warn] writeModuleTomls: could not parse module.yaml for '${moduleName}' (${error.message}). ` +
|
||||
`Skipping module.toml for this module.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!moduleDef || typeof moduleDef !== 'object') continue;
|
||||
|
||||
const moduleDir = path.join(bmadDir, moduleName);
|
||||
if (!(await fs.pathExists(moduleDir))) continue;
|
||||
|
||||
const moduleCode = typeof moduleDef.code === 'string' ? moduleDef.code : moduleName;
|
||||
const rawDefaults = extractModuleDefaults(moduleDef);
|
||||
moduleData.push({ moduleName, moduleCode, moduleDir, rawDefaults });
|
||||
}
|
||||
|
||||
// Build a flat cross-key lookup map from every module's raw defaults.
|
||||
// First-define wins (deterministic given sorted updatedModules input).
|
||||
// Values are stripped of a leading `{project-root}/` so substitutions
|
||||
// re-compose cleanly when consumed in a `{project-root}/{key}/...` slot
|
||||
// — matches the installer's processResultTemplate convention.
|
||||
const crossKeyMap = {};
|
||||
for (const { rawDefaults } of moduleData) {
|
||||
for (const [key, value] of Object.entries(rawDefaults)) {
|
||||
if (crossKeyMap[key] !== undefined) continue;
|
||||
let stripped = value;
|
||||
if (typeof stripped === 'string' && stripped.startsWith('{project-root}/')) {
|
||||
stripped = stripped.slice('{project-root}/'.length);
|
||||
}
|
||||
crossKeyMap[key] = stripped;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: resolve cross-key placeholders in each module's defaults and
|
||||
// write the final module.toml file.
|
||||
const written = [];
|
||||
for (const { moduleName, moduleCode, moduleDir, rawDefaults } of moduleData) {
|
||||
const resolvedDefaults = {};
|
||||
for (const [key, value] of Object.entries(rawDefaults)) {
|
||||
resolvedDefaults[key] = resolveCrossKeyPlaceholders(value, crossKeyMap);
|
||||
}
|
||||
|
||||
const lines = [
|
||||
'# Module-shipped defaults. Build artifact — do not edit by hand.',
|
||||
"# Source: this module's module.yaml (authored at source).",
|
||||
'# Regenerated on every install.',
|
||||
'#',
|
||||
'# Read by _bmad/scripts/resolve_config.py as the lowest-priority',
|
||||
'# floor of the config layer chain. Project _bmad/config.toml and',
|
||||
'# user overrides in _bmad/custom/ sit above this and win.',
|
||||
'',
|
||||
];
|
||||
|
||||
if (Object.keys(resolvedDefaults).length > 0) {
|
||||
// Core's defaults belong under top-level [core] — that's where
|
||||
// writeCentralConfig emits core deltas and where resolve_config.py
|
||||
// consumers read core.* from. Everything else gets the per-module
|
||||
// [modules.<code>] namespace.
|
||||
const sectionHeader = moduleCode === 'core' ? '[core]' : `[modules.${moduleCode}]`;
|
||||
lines.push(sectionHeader);
|
||||
for (const [key, value] of Object.entries(resolvedDefaults)) {
|
||||
lines.push(`${key} = ${formatTomlValue(value)}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const moduleAgents = this.agents.filter((a) => a.module === moduleName);
|
||||
for (const agent of moduleAgents) {
|
||||
lines.push(`[agents.${agent.code}]`, `module = ${formatTomlValue(agent.module)}`, `team = ${formatTomlValue(agent.team)}`);
|
||||
if (agent.name) lines.push(`name = ${formatTomlValue(agent.name)}`);
|
||||
if (agent.title) lines.push(`title = ${formatTomlValue(agent.title)}`);
|
||||
if (agent.icon) lines.push(`icon = ${formatTomlValue(agent.icon)}`);
|
||||
if (agent.description) lines.push(`description = ${formatTomlValue(agent.description)}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const outputPath = path.join(moduleDir, 'module.toml');
|
||||
const content = lines.join('\n').replace(/\n+$/, '\n');
|
||||
await fs.writeFile(outputPath, content);
|
||||
written.push(outputPath);
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty _bmad/custom/config.toml and _bmad/custom/config.user.toml stubs
|
||||
* on first install only. Installer never touches these files again after creation.
|
||||
|
|
@ -806,6 +1042,130 @@ class ManifestGenerator {
|
|||
* Handles strings (quoted + escaped), booleans, numbers, and arrays of scalars.
|
||||
* Objects are not expected at this emit path.
|
||||
*/
|
||||
/**
|
||||
* Compute the processed default value for a module.yaml question item.
|
||||
* Resolves `{key}` cross-references against the flat `crossKeyDefaults` lookup
|
||||
* (shipped defaults, never user answers — see writeCentralConfig comment).
|
||||
* Used by writeCentralConfig to detect default-equal values that should NOT
|
||||
* be re-emitted into the lean config.toml. Matches the lookup table that
|
||||
* writeModuleTomls uses, so module.toml's floor and config.toml's delta
|
||||
* detection agree on what "default" means.
|
||||
*
|
||||
* Steps:
|
||||
* 1. Substitute {key} references against crossKeyDefaults (with leading
|
||||
* "{project-root}/" stripped, matching the installer's
|
||||
* processResultTemplate behavior).
|
||||
* 2. Apply the result: template with {value} substituted.
|
||||
*
|
||||
* Returns undefined for items without a default.
|
||||
*
|
||||
* @param {object} item - one module.yaml question schema
|
||||
* @param {Record<string, *>} crossKeyDefaults - flat shipped-defaults lookup
|
||||
* @returns {*} processed default value (string/scalar) or undefined
|
||||
*/
|
||||
function computeProcessedDefault(item, crossKeyDefaults) {
|
||||
if (!item || item.default === undefined || item.default === null) return;
|
||||
let value = item.default;
|
||||
if (typeof value === 'string') {
|
||||
value = value.replaceAll(/{([^}]+)}/g, (match, refKey) => {
|
||||
if (refKey === 'project-root' || refKey === 'value' || refKey === 'directory_name') {
|
||||
return match;
|
||||
}
|
||||
const replacement = (crossKeyDefaults || {})[refKey];
|
||||
return replacement === undefined ? match : String(replacement);
|
||||
});
|
||||
}
|
||||
if (typeof item.result === 'string' && value !== undefined) {
|
||||
return item.result.replaceAll('{value}', String(value));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `{key}` cross-references in a string value against a flat
|
||||
* `{key: value}` lookup map. `{project-root}` and `{directory_name}` are
|
||||
* preserved literal — they're runtime placeholders, substituted by the skill
|
||||
* or resolver when the value is consumed. Unknown keys are left literal too.
|
||||
*
|
||||
* Used by writeModuleTomls so that module.toml's [modules.X] keys carry the
|
||||
* same shape as the installer's resolved config (e.g.
|
||||
* `"{project-root}/_bmad-output/planning-artifacts"`) — making the floor a
|
||||
* drop-in for the central config when the latter omits a value as default.
|
||||
*
|
||||
* @param {*} value - typically a string; non-strings returned unchanged
|
||||
* @param {Record<string, *>} crossKeyMap
|
||||
* @returns {*}
|
||||
*/
|
||||
function resolveCrossKeyPlaceholders(value, crossKeyMap) {
|
||||
if (typeof value !== 'string') return value;
|
||||
return value.replaceAll(/{([^}]+)}/g, (match, key) => {
|
||||
if (key === 'project-root' || key === 'directory_name' || key === 'value') {
|
||||
return match;
|
||||
}
|
||||
const replacement = crossKeyMap[key];
|
||||
return replacement === undefined ? match : String(replacement);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scalar / shallow equality for delta detection. Handles strings, numbers,
|
||||
* booleans, and arrays of scalars (the only shapes module.yaml defaults
|
||||
* produce). Different types compare unequal.
|
||||
*/
|
||||
function deepEqualScalar(a, b) {
|
||||
if (a === b) return true;
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((item, i) => deepEqualScalar(item, b[i]));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Module.yaml top-level keys that are metadata, not question knobs. These
|
||||
* never appear in a module's [modules.{code}] floor section.
|
||||
*/
|
||||
const MODULE_DEFAULTS_SKIP = new Set([
|
||||
'code',
|
||||
'name',
|
||||
'description',
|
||||
'default_selected',
|
||||
'header',
|
||||
'subheader',
|
||||
'agents',
|
||||
'directories',
|
||||
'dependencies',
|
||||
'prompt',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Extract shipped defaults from a parsed module.yaml. For each question-style
|
||||
* key (object with a `default` field), capture the default and apply its
|
||||
* `result:` template with `{value}` substituted. Cross-key placeholders like
|
||||
* `{output_folder}` are left as literal strings — see writeModuleTomls() doc
|
||||
* for why and how that's handled in phase 1.
|
||||
*
|
||||
* @param {object} moduleDef - parsed module.yaml content
|
||||
* @returns {Record<string, string|number|boolean|Array<*>>}
|
||||
*/
|
||||
function extractModuleDefaults(moduleDef) {
|
||||
const defaults = {};
|
||||
for (const [key, value] of Object.entries(moduleDef)) {
|
||||
if (MODULE_DEFAULTS_SKIP.has(key)) continue;
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
|
||||
if (!('default' in value)) continue;
|
||||
|
||||
let resolved = value.default;
|
||||
if (typeof value.result === 'string' && resolved !== undefined) {
|
||||
// Apply `result:` template with `{value}` substituted by default. Leave
|
||||
// other placeholders (`{project-root}`, `{output_folder}`, ...) literal.
|
||||
resolved = value.result.replaceAll('{value}', String(resolved));
|
||||
}
|
||||
defaults[key] = resolved;
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
function formatTomlValue(value) {
|
||||
if (value === null || value === undefined) return '""';
|
||||
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
||||
|
|
@ -821,39 +1181,4 @@ function formatTomlValue(value) {
|
|||
return `"${escaped}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract [agents.<code>] blocks from a previously-emitted config.toml.
|
||||
* We only need this for roster preservation — the file is our own controlled
|
||||
* output, so a simple line scanner is safer than adding a TOML parser
|
||||
* dependency. Each block runs from its `[agents.<code>]` header until the
|
||||
* next `[` heading or EOF; the `module = "..."` line inside drives which
|
||||
* entries we keep on the next write.
|
||||
* @returns {Array<{code: string, module: string | null, body: string}>}
|
||||
*/
|
||||
function extractAgentBlocks(tomlContent) {
|
||||
const blocks = [];
|
||||
const lines = tomlContent.split('\n');
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const header = lines[i].match(/^\[agents\.([^\]]+)]\s*$/);
|
||||
if (!header) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const code = header[1];
|
||||
const blockLines = [lines[i]];
|
||||
let moduleName = null;
|
||||
i++;
|
||||
while (i < lines.length && !lines[i].startsWith('[')) {
|
||||
blockLines.push(lines[i]);
|
||||
const m = lines[i].match(/^module\s*=\s*"((?:[^"\\]|\\.)*)"\s*$/);
|
||||
if (m) moduleName = m[1];
|
||||
i++;
|
||||
}
|
||||
while (blockLines.length > 1 && blockLines.at(-1) === '') blockLines.pop();
|
||||
blocks.push({ code, module: moduleName, body: blockLines.join('\n') });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
module.exports = { ManifestGenerator };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,180 @@
|
|||
/**
|
||||
* Helpers for the cross-platform global BMad config directory.
|
||||
*
|
||||
* The "global" tier is read-only to most installer code paths — only core
|
||||
* scope:user answers (user_name, communication_language) and identity defaults
|
||||
* are written there, and only by the post-install global-write step. Everything
|
||||
* else reads.
|
||||
*
|
||||
* Location precedence:
|
||||
* 1. $BMAD_HOME (for CI / corporate / multi-account setups)
|
||||
* 2. ~/.bmad
|
||||
*
|
||||
* Works on macOS, Linux, WSL, and Windows: os.homedir() returns the
|
||||
* platform-appropriate home (and on WSL, the Linux home — each WSL distro has
|
||||
* its own global config).
|
||||
*/
|
||||
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const fs = require('./fs-native');
|
||||
|
||||
function resolveGlobalDir() {
|
||||
const override = process.env.BMAD_HOME;
|
||||
if (override && override.trim()) {
|
||||
return path.resolve(expandTilde(override.trim()));
|
||||
}
|
||||
return path.join(os.homedir(), '.bmad');
|
||||
}
|
||||
|
||||
// JS counterpart to Python's Path.expanduser() — keeps installer/resolver
|
||||
// agreement when BMAD_HOME is set in non-shell contexts (Docker, .env files,
|
||||
// Windows env var GUI) where the shell never expands `~`.
|
||||
function expandTilde(input) {
|
||||
if (input === '~') return os.homedir();
|
||||
if (input.startsWith('~/') || input.startsWith('~\\')) {
|
||||
return path.join(os.homedir(), input.slice(2));
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
function globalTeamConfigPath() {
|
||||
return path.join(resolveGlobalDir(), 'config.toml');
|
||||
}
|
||||
|
||||
function globalUserConfigPath() {
|
||||
return path.join(resolveGlobalDir(), 'config.user.toml');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a minimal subset of TOML — enough for the installer-owned files:
|
||||
* top-level tables ([section] / [section.sub]) and simple scalar values
|
||||
* (string, number, boolean). No arrays of tables, inline tables, datetimes,
|
||||
* or multiline strings — those don't appear in files we author. Reader stays
|
||||
* dependency-free; we only consume what we emit.
|
||||
*
|
||||
* For an unrecognized shape, the offending line is silently dropped (rather
|
||||
* than erroring) to keep the installer resilient against hand-edits that
|
||||
* went slightly outside the documented schema.
|
||||
*/
|
||||
function parseSimpleToml(content) {
|
||||
const result = {};
|
||||
let currentTable = result;
|
||||
|
||||
for (const rawLine of content.split('\n')) {
|
||||
const line = stripInlineComment(rawLine).trim();
|
||||
if (!line) continue;
|
||||
|
||||
const sectionMatch = line.match(/^\[([^\]]+)]\s*$/);
|
||||
if (sectionMatch) {
|
||||
const parts = sectionMatch[1].split('.').map((p) => p.trim());
|
||||
currentTable = result;
|
||||
for (const part of parts) {
|
||||
if (!currentTable[part] || typeof currentTable[part] !== 'object' || Array.isArray(currentTable[part])) {
|
||||
currentTable[part] = {};
|
||||
}
|
||||
currentTable = currentTable[part];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const kvMatch = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(.+)$/);
|
||||
if (kvMatch) {
|
||||
const [, key, rawValue] = kvMatch;
|
||||
const parsed = parseTomlScalar(rawValue.trim());
|
||||
if (parsed !== undefined) {
|
||||
currentTable[key] = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a trailing `# comment` from a TOML line, but only when the `#` lives
|
||||
* outside a double-quoted string. We don't author multiline strings or
|
||||
* literal strings, so a single double-quote scanner is sufficient.
|
||||
*/
|
||||
function stripInlineComment(line) {
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
if (ch === '#' && !inString) {
|
||||
return line.slice(0, i);
|
||||
}
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function parseTomlScalar(raw) {
|
||||
if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) {
|
||||
// Single-pass unescape — sequential replaceAll lets `\\n` (backslash + n)
|
||||
// collapse into a newline because the second pass sees the just-produced
|
||||
// `\n` and treats it as the escape sequence. One regex avoids that.
|
||||
const escapes = { '\\\\': '\\', '\\"': '"', '\\n': '\n', '\\r': '\r', '\\t': '\t' };
|
||||
return raw.slice(1, -1).replaceAll(/\\["\\nrt]/g, (m) => escapes[m] ?? m);
|
||||
}
|
||||
if (raw === 'true') return true;
|
||||
if (raw === 'false') return false;
|
||||
if (/^-?\d+$/.test(raw)) return Number.parseInt(raw, 10);
|
||||
if (/^-?\d+\.\d+$/.test(raw)) return Number.parseFloat(raw);
|
||||
return; // dropped silently — see header comment
|
||||
}
|
||||
|
||||
/**
|
||||
* Load both global TOML files. Either may be missing; returns merged result.
|
||||
* Files are read but never written by this helper.
|
||||
*
|
||||
* @returns {Promise<{ team: object, user: object, merged: object }>}
|
||||
*/
|
||||
async function loadGlobalConfig() {
|
||||
const team = await readTomlFile(globalTeamConfigPath());
|
||||
const user = await readTomlFile(globalUserConfigPath());
|
||||
// Shallow-deep merge: user table wins over team at every key path. The
|
||||
// installer only consults the merged view for default-seeding, so this is
|
||||
// sufficient (we don't need the full structural-merge of resolve_config.py).
|
||||
const merged = mergeDeep(team, user);
|
||||
return { team, user, merged };
|
||||
}
|
||||
|
||||
async function readTomlFile(filePath) {
|
||||
if (!(await fs.pathExists(filePath))) return {};
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
return parseSimpleToml(content);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function mergeDeep(base, override) {
|
||||
if (!override || typeof override !== 'object' || Array.isArray(override)) return override === undefined ? base : override;
|
||||
if (!base || typeof base !== 'object' || Array.isArray(base)) return override;
|
||||
const result = { ...base };
|
||||
for (const [key, value] of Object.entries(override)) {
|
||||
result[key] = mergeDeep(result[key], value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveGlobalDir,
|
||||
globalTeamConfigPath,
|
||||
globalUserConfigPath,
|
||||
parseSimpleToml,
|
||||
loadGlobalConfig,
|
||||
};
|
||||
|
|
@ -5,6 +5,7 @@ const prompts = require('../prompts');
|
|||
const { getProjectRoot, getSourcePath, getModulePath } = require('../project-root');
|
||||
const { CLIUtils } = require('../cli-utils');
|
||||
const { ExternalModuleManager } = require('./external-manager');
|
||||
const { loadGlobalConfig } = require('../global-config');
|
||||
|
||||
class OfficialModules {
|
||||
constructor(options = {}) {
|
||||
|
|
@ -1019,11 +1020,26 @@ class OfficialModules {
|
|||
* @param {string} projectDir - Target project directory
|
||||
* @param {Object} options - Additional options
|
||||
* @param {boolean} options.skipPrompts - Skip prompts and use defaults (for --yes flag)
|
||||
*
|
||||
* Non-core modules are always silent: they accept module.yaml defaults
|
||||
* without prompting. Users adjust values later via the `bmad-customize`
|
||||
* skill or direct edits to _bmad/custom/config.toml / customize.toml.
|
||||
* Core is still fully prompted (identity questions live there) — though
|
||||
* scope:user core values that are already in ~/.bmad/config.user.toml
|
||||
* are silently reused (see below).
|
||||
*/
|
||||
async collectAllConfigurations(modules, projectDir, options = {}) {
|
||||
this.skipPrompts = options.skipPrompts || false;
|
||||
this.modulesToCustomize = undefined;
|
||||
await this.loadExistingConfig(projectDir);
|
||||
// Read the cross-platform global config (~/.bmad or $BMAD_HOME). Used to:
|
||||
// - Skip core scope:user questions whose value is already known globally
|
||||
// (task D — ask identity once per machine, not once per project).
|
||||
// - Seed defaults for non-user-scope core questions (task E — let users
|
||||
// pre-pin default project_name/output_folder/etc. globally if they want).
|
||||
// Project-installed values still beat global at resolve time; this is
|
||||
// purely about what we ASK during install.
|
||||
this.globalConfig = await loadGlobalConfig();
|
||||
|
||||
// Check if core was already collected (e.g., in early collection phase)
|
||||
const coreAlreadyCollected = this.collectedConfig.core && Object.keys(this.collectedConfig.core).length > 0;
|
||||
|
|
@ -1045,44 +1061,17 @@ class OfficialModules {
|
|||
await this.collectModuleConfig(moduleName, projectDir);
|
||||
}
|
||||
|
||||
// Show batch configuration gateway for non-core modules
|
||||
// Scan all non-core module schemas for display names and config metadata
|
||||
// Non-core modules are always silent: accept module.yaml defaults without
|
||||
// prompting. Users adjust via `bmad-customize` later or by editing
|
||||
// _bmad/custom/{config,customize}.toml. Setting modulesToCustomize to an
|
||||
// empty Set bypasses the legacy per-module "Accept Defaults?" confirm
|
||||
// (collectModuleConfig only fires that when modulesToCustomize is
|
||||
// undefined). scanModuleSchemas is still called so the spinner can show
|
||||
// friendly display names while applying defaults.
|
||||
let scannedModules = [];
|
||||
if (!this.skipPrompts && nonCoreModules.length > 0) {
|
||||
this.modulesToCustomize = new Set();
|
||||
scannedModules = await this.scanModuleSchemas(nonCoreModules);
|
||||
const customizableModules = scannedModules.filter((m) => m.questionCount > 0);
|
||||
|
||||
if (customizableModules.length > 0) {
|
||||
const configMode = await prompts.select({
|
||||
message: 'Module configuration',
|
||||
choices: [
|
||||
{ name: 'Express Setup', value: 'express', hint: 'accept all defaults (recommended)' },
|
||||
{ name: 'Customize', value: 'customize', hint: 'choose modules to configure' },
|
||||
],
|
||||
default: 'express',
|
||||
});
|
||||
|
||||
if (configMode === 'customize') {
|
||||
const choices = customizableModules.map((m) => ({
|
||||
name: `${m.displayName} (${m.questionCount} option${m.questionCount === 1 ? '' : 's'})`,
|
||||
value: m.moduleName,
|
||||
hint: m.hasFieldsWithoutDefaults ? 'has fields without defaults' : undefined,
|
||||
checked: m.hasFieldsWithoutDefaults,
|
||||
}));
|
||||
const selected = await prompts.multiselect({
|
||||
message: 'Select modules to customize:',
|
||||
choices,
|
||||
required: false,
|
||||
});
|
||||
this.modulesToCustomize = new Set(selected);
|
||||
} else {
|
||||
// Express mode: no modules to customize
|
||||
this.modulesToCustomize = new Set();
|
||||
}
|
||||
} else {
|
||||
// All non-core modules have zero config - no gateway needed
|
||||
this.modulesToCustomize = new Set();
|
||||
}
|
||||
}
|
||||
|
||||
// Collect remaining non-core modules
|
||||
|
|
@ -1155,6 +1144,18 @@ class OfficialModules {
|
|||
if (!this._existingConfig) {
|
||||
await this.loadExistingConfig(projectDir);
|
||||
}
|
||||
// Lazy-load global config so identity fallbacks below can consult
|
||||
// ~/.bmad/config.user.toml. quickUpdate doesn't go through
|
||||
// collectAllConfigurations, so this.globalConfig would otherwise be unset
|
||||
// and user_name would silently default to the OS username — overwriting
|
||||
// the value the user previously committed to global.
|
||||
if (!this.globalConfig) {
|
||||
try {
|
||||
this.globalConfig = await loadGlobalConfig();
|
||||
} catch {
|
||||
this.globalConfig = { merged: {} };
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize allAnswers if not already initialized
|
||||
if (!this.allAnswers) {
|
||||
|
|
@ -1231,12 +1232,14 @@ class OfficialModules {
|
|||
}
|
||||
this.collectedConfig[moduleName] = { ...this._existingConfig[moduleName] };
|
||||
|
||||
// Special handling for user_name: ensure it has a value
|
||||
// Special handling for user_name: ensure it has a value. Prefer the
|
||||
// global value (~/.bmad/config.user.toml) before the OS username, or
|
||||
// we'll silently overwrite the user's prior global identity.
|
||||
if (
|
||||
moduleName === 'core' &&
|
||||
(!this.collectedConfig[moduleName].user_name || this.collectedConfig[moduleName].user_name === '[USER_NAME]')
|
||||
) {
|
||||
this.collectedConfig[moduleName].user_name = this.getDefaultUsername();
|
||||
this.collectedConfig[moduleName].user_name = this._identityFallback('user_name');
|
||||
}
|
||||
|
||||
// Also populate allAnswers for cross-referencing
|
||||
|
|
@ -1244,18 +1247,20 @@ class OfficialModules {
|
|||
// Ensure user_name is properly set in allAnswers too
|
||||
let finalValue = value;
|
||||
if (moduleName === 'core' && key === 'user_name' && (!value || value === '[USER_NAME]')) {
|
||||
finalValue = this.getDefaultUsername();
|
||||
finalValue = this._identityFallback('user_name');
|
||||
}
|
||||
this.allAnswers[`${moduleName}_${key}`] = finalValue;
|
||||
}
|
||||
} else if (moduleName === 'core') {
|
||||
// No existing core config - ensure we at least have user_name
|
||||
// No existing core config - ensure we at least have user_name.
|
||||
// Same global-first preference as above.
|
||||
if (!this.collectedConfig[moduleName]) {
|
||||
this.collectedConfig[moduleName] = {};
|
||||
}
|
||||
if (!this.collectedConfig[moduleName].user_name) {
|
||||
this.collectedConfig[moduleName].user_name = this.getDefaultUsername();
|
||||
this.allAnswers[`${moduleName}_user_name`] = this.getDefaultUsername();
|
||||
const fallback = this._identityFallback('user_name');
|
||||
this.collectedConfig[moduleName].user_name = fallback;
|
||||
this.allAnswers[`${moduleName}_user_name`] = fallback;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1433,6 +1438,22 @@ class OfficialModules {
|
|||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fall back through identity sources for a core scope:user key. Prefers the
|
||||
* global value (~/.bmad/config.user.toml) so quickUpdate / re-install never
|
||||
* silently overwrites a previously-set identity with the OS username.
|
||||
* Only user_name has an OS-derived ultimate fallback; other keys return
|
||||
* undefined so the caller can decide.
|
||||
*/
|
||||
_identityFallback(key) {
|
||||
const globalCore = (this.globalConfig && this.globalConfig.merged && this.globalConfig.merged.core) || {};
|
||||
if (globalCore[key] !== undefined && globalCore[key] !== '' && globalCore[key] !== null) {
|
||||
return globalCore[key];
|
||||
}
|
||||
if (key === 'user_name') return this.getDefaultUsername();
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect configuration for a single module
|
||||
* @param {string} moduleName - Module name
|
||||
|
|
@ -1507,6 +1528,79 @@ class OfficialModules {
|
|||
}
|
||||
}
|
||||
|
||||
// Tasks D + E: for the core module, consult the global config (~/.bmad).
|
||||
// D — scope:user keys (user_name, communication_language): if already
|
||||
// known globally, accept silently. Don't prompt; store the final
|
||||
// form directly in collectedConfig so cross-references still work.
|
||||
// E — non-user-scope keys (project_name, output_folder, ...): if known
|
||||
// globally, seed the prompt default from there. User can still
|
||||
// change it per-project.
|
||||
// Track silently-reused keys so the user knows where the values came from
|
||||
// (otherwise they'd see questions they previously answered just disappear).
|
||||
const reusedFromGlobal = [];
|
||||
const seededFromGlobal = [];
|
||||
if (moduleName === 'core' && this.globalConfig && this.globalConfig.merged && this.globalConfig.merged.core) {
|
||||
const globalCore = this.globalConfig.merged.core;
|
||||
const remaining = [];
|
||||
|
||||
for (const question of questions) {
|
||||
const key = question.name.replace(`${moduleName}_`, '');
|
||||
const item = moduleConfig[key];
|
||||
const globalValue = globalCore[key];
|
||||
|
||||
if (globalValue === undefined || globalValue === null || globalValue === '') {
|
||||
remaining.push(question);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item && item.scope === 'user') {
|
||||
// D: silent reuse. Stash final form into collectedConfig and skip
|
||||
// adding this question to the prompt list. The post-answer
|
||||
// result-template loop never sees this key.
|
||||
this.collectedConfig[moduleName] = this.collectedConfig[moduleName] || {};
|
||||
this.collectedConfig[moduleName][key] = globalValue;
|
||||
reusedFromGlobal.push({ key, value: globalValue });
|
||||
continue;
|
||||
}
|
||||
|
||||
// E: pre-seed the prompt default. Strip {project-root}/ for clean
|
||||
// display — the result: template will add it back after the user
|
||||
// accepts or edits.
|
||||
question.default = this.cleanPromptValue(globalValue);
|
||||
seededFromGlobal.push({ key, value: question.default });
|
||||
remaining.push(question);
|
||||
}
|
||||
|
||||
questions.length = 0;
|
||||
questions.push(...remaining);
|
||||
}
|
||||
|
||||
// Tell the user when global values are in play. Silent reuse (D) is the
|
||||
// important one — otherwise a question that fired on a prior install
|
||||
// would just vanish, leaving the user wondering what happened. Seeded
|
||||
// defaults (E) get a softer mention since the user still sees the value
|
||||
// in the prompt.
|
||||
if (!this.skipPrompts && (reusedFromGlobal.length > 0 || seededFromGlobal.length > 0)) {
|
||||
const { globalUserConfigPath } = require('../global-config');
|
||||
const globalPath = globalUserConfigPath();
|
||||
const lines = [];
|
||||
if (reusedFromGlobal.length > 0) {
|
||||
lines.push('Using values from your global BMad config:');
|
||||
for (const { key, value } of reusedFromGlobal) {
|
||||
lines.push(` ${key} = ${JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
if (seededFromGlobal.length > 0) {
|
||||
if (lines.length > 0) lines.push('');
|
||||
lines.push('Defaults pre-filled from your global BMad config:');
|
||||
for (const { key, value } of seededFromGlobal) {
|
||||
lines.push(` ${key} = ${JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
lines.push('', `To change these, edit ${globalPath}`, '(or unset them there to be prompted on the next install).');
|
||||
await prompts.log.info(lines.join('\n'));
|
||||
}
|
||||
|
||||
// Collect all answers (static + prompted)
|
||||
let allAnswers = { ...staticAnswers };
|
||||
|
||||
|
|
@ -1870,9 +1964,10 @@ class OfficialModules {
|
|||
existingValue = this.normalizeExistingValueForPrompt(existingValue, moduleName, item, moduleConfig);
|
||||
}
|
||||
|
||||
// Special handling for user_name: default to system user
|
||||
// Special handling for user_name: prefer global identity (~/.bmad) over
|
||||
// OS username so the prompt's default reflects what the user already chose.
|
||||
if (moduleName === 'core' && key === 'user_name' && !existingValue) {
|
||||
item.default = this.getDefaultUsername();
|
||||
item.default = this._identityFallback('user_name');
|
||||
}
|
||||
|
||||
// Determine question type and default value
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ const PROTOTYPE_POLLUTING_NAMES = new Set(['__proto__', 'prototype', 'constructo
|
|||
const path = require('node:path');
|
||||
const fs = require('./fs-native');
|
||||
const yaml = require('yaml');
|
||||
const { globalUserConfigPath } = require('./global-config');
|
||||
const { resolveInstalledModuleYaml } = require('./project-root');
|
||||
|
||||
/**
|
||||
* Parse a single `--set <module>.<key>=<value>` entry.
|
||||
|
|
@ -83,11 +85,25 @@ function parseSetEntries(entries) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Encode a JS string as a TOML basic string (double-quoted with escapes).
|
||||
* @param {string} value
|
||||
* Encode a `--set` value as a TOML literal. Types are inferred from the value
|
||||
* so `--set bmm.workers=4` writes `workers = 4` (integer), not `"4"` (string).
|
||||
*
|
||||
* Rules (mirror how TOML would interpret the literal hand-typed in a config):
|
||||
* - `true` / `false` → boolean
|
||||
* - `-?\d+` → integer
|
||||
* - `-?\d+\.\d+` → float
|
||||
* - everything else → quoted basic string
|
||||
*
|
||||
* To force a string that looks like a bool/number, wrap in literal quotes:
|
||||
* --set foo.x='"true"' → x = "true"
|
||||
*
|
||||
* @param {string} value raw value as received from the --set flag
|
||||
*/
|
||||
function tomlString(value) {
|
||||
const s = String(value);
|
||||
if (s === 'true' || s === 'false') return s;
|
||||
if (/^-?\d+$/.test(s)) return s;
|
||||
if (/^-?\d+\.\d+$/.test(s)) return s;
|
||||
// Per the TOML spec, basic strings escape `\`, `"`, and control characters.
|
||||
return (
|
||||
'"' +
|
||||
|
|
@ -142,8 +158,10 @@ function upsertTomlKey(content, section, key, valueToml) {
|
|||
const hadTrailingNewline = lines.length > 0 && lines.at(-1) === '';
|
||||
if (hadTrailingNewline) lines.pop();
|
||||
|
||||
// Locate the target section.
|
||||
const sectionStart = lines.findIndex((line) => line.trim() === section);
|
||||
// Locate the target section. Tolerates a trailing inline comment on the
|
||||
// header (`[core] # personal`) and a header line with non-newline
|
||||
// trailing whitespace — `line.trim() === section` would miss both.
|
||||
const sectionStart = lines.findIndex((line) => isSectionHeader(line, section));
|
||||
if (sectionStart === -1) {
|
||||
// Section doesn't exist — append a new block. Pad with a blank line if
|
||||
// the file is non-empty so sections stay visually separated.
|
||||
|
|
@ -168,11 +186,12 @@ function upsertTomlKey(content, section, key, valueToml) {
|
|||
const match = lines[i].match(keyPattern);
|
||||
if (match) {
|
||||
const indent = match[1];
|
||||
// Preserve trailing comment if present. We split on the first `#` that
|
||||
// is preceded by whitespace — TOML strings can't contain unescaped `#`
|
||||
// in basic-string form so this is safe for the values we emit.
|
||||
// Preserve trailing comment if present. `findInlineCommentStart` tracks
|
||||
// double-quoted string state so a `#` inside a value like
|
||||
// `"path with # hash"` isn't mistaken for a comment marker (per TOML
|
||||
// spec, basic strings may contain unescaped `#`).
|
||||
const tail = match[2];
|
||||
const commentIdx = tail.search(/\s+#/);
|
||||
const commentIdx = findInlineCommentStart(tail);
|
||||
const commentSuffix = commentIdx === -1 ? '' : tail.slice(commentIdx);
|
||||
lines[i] = `${indent}${key} = ${valueToml}${commentSuffix}`;
|
||||
return lines.join('\n') + (hadTrailingNewline ? '\n' : '');
|
||||
|
|
@ -194,6 +213,51 @@ function escapeRegExp(s) {
|
|||
return s.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a TOML section header line against a target like `[core]`. Tolerates
|
||||
* leading/trailing whitespace and a trailing inline comment, which the
|
||||
* previous `line.trim() === section` check missed.
|
||||
*/
|
||||
function isSectionHeader(line, target) {
|
||||
const trimmed = line.trimStart();
|
||||
if (!trimmed.startsWith(target)) return false;
|
||||
const after = trimmed.slice(target.length);
|
||||
return /^\s*(?:#.*)?$/.test(after);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the start index of an inline `# comment` in a TOML value tail,
|
||||
* tracking double-quoted string state so a `#` inside a string literal is
|
||||
* not treated as a comment. Returns the index of the whitespace run that
|
||||
* precedes the `#` (matching the contract of the old `/\s+#/` regex), or
|
||||
* -1 if there's no inline comment.
|
||||
*/
|
||||
function findInlineCommentStart(text) {
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
if (ch === '#' && !inString) {
|
||||
let j = i;
|
||||
while (j > 0 && /\s/.test(text[j - 1])) j--;
|
||||
if (j < i) return j;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up `[section] key` in a TOML file. Returns true if the file exists,
|
||||
* the section is present, and `key` is set within it. Used by
|
||||
|
|
@ -205,29 +269,66 @@ async function tomlHasKey(filePath, section, key) {
|
|||
if (!(await fs.pathExists(filePath))) return false;
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
const sectionStart = lines.findIndex((line) => line.trim() === section);
|
||||
if (sectionStart === -1) return false;
|
||||
const keyPattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`);
|
||||
for (let i = sectionStart + 1; i < lines.length; i++) {
|
||||
if (/^\s*\[/.test(lines[i])) return false;
|
||||
if (keyPattern.test(lines[i])) return true;
|
||||
// Walk every line tracking whether we're inside the target section. This
|
||||
// both tolerates inline-commented headers (`[core] # personal`) and
|
||||
// handles the edge case where the same section appears more than once
|
||||
// (legal in TOML — tomllib merges them — but the previous `findIndex`
|
||||
// only checked the first block, misrouting `--set`).
|
||||
let inTargetSection = false;
|
||||
for (const line of lines) {
|
||||
if (/^\s*\[/.test(line)) {
|
||||
inTargetSection = isSectionHeader(line, section);
|
||||
continue;
|
||||
}
|
||||
if (inTargetSection && keyPattern.test(line)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up which prompt keys in `core/module.yaml` are declared `scope: user`.
|
||||
* Used so `--set` routes core scope:user keys (user_name, communication_language)
|
||||
* to the global identity file the installer's writeGlobalUserCore writes to,
|
||||
* rather than polluting project config.toml as a team-scope key.
|
||||
* Returns an empty set if core isn't installed or the schema can't be parsed.
|
||||
*/
|
||||
async function loadCoreUserScopeKeys() {
|
||||
const result = new Set();
|
||||
try {
|
||||
const corePath = await resolveInstalledModuleYaml('core');
|
||||
if (!corePath) return result;
|
||||
const parsed = yaml.parse(await fs.readFile(corePath, 'utf8'));
|
||||
if (!parsed || typeof parsed !== 'object') return result;
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (value && typeof value === 'object' && 'prompt' in value && value.scope === 'user') {
|
||||
result.add(key);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Schema unavailable — fall back to two-tier routing.
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply parsed `--set` overrides to the central TOML files written by the
|
||||
* installer. Called at the end of an install / quick-update.
|
||||
*
|
||||
* Routing per (module, key):
|
||||
* 1. If `_bmad/config.user.toml` already has `[section] key`, update there
|
||||
* (user-scope key like `core.user_name`, `bmm.user_skill_level`).
|
||||
* 2. Otherwise update `_bmad/config.toml` (team scope, the default).
|
||||
* 1. If `~/.bmad/config.user.toml` already has `[section] key`, update there
|
||||
* (global identity store — same place writeGlobalUserCore writes to).
|
||||
* 2. Else if `_bmad/config.user.toml` already has `[section] key`, update
|
||||
* there (project-scoped personal override).
|
||||
* 3. Else if the key is a known core scope:user key (user_name,
|
||||
* communication_language per core/module.yaml), route to global. Otherwise
|
||||
* writeCentralConfig's next-install partition would strip the value out
|
||||
* of project files.
|
||||
* 4. Otherwise update `_bmad/config.toml` (team scope, the default).
|
||||
*
|
||||
* The schema-correct user/team partition lives in `manifest-generator`. We
|
||||
* intentionally don't re-read module schemas here — the only goal is to
|
||||
* match the file the installer just wrote the key to. For brand-new keys
|
||||
* (not in either file yet), team scope is the safe default.
|
||||
* The schema-correct partition lives in `manifest-generator`. We only reach
|
||||
* for the core schema (small, always present) so the first-run case for
|
||||
* `--set core.user_name=...` doesn't land in the wrong file.
|
||||
*
|
||||
* @param {Object<string, Object<string, string>>} overrides
|
||||
* @param {string} bmadDir absolute path to `_bmad/`
|
||||
|
|
@ -240,6 +341,8 @@ async function applySetOverrides(overrides, bmadDir) {
|
|||
|
||||
const teamPath = path.join(bmadDir, 'config.toml');
|
||||
const userPath = path.join(bmadDir, 'config.user.toml');
|
||||
const globalPath = globalUserConfigPath();
|
||||
const coreUserKeys = await loadCoreUserScopeKeys();
|
||||
|
||||
for (const moduleCode of Object.keys(overrides)) {
|
||||
// Skip overrides for modules not actually installed. The installer writes
|
||||
|
|
@ -258,16 +361,35 @@ async function applySetOverrides(overrides, bmadDir) {
|
|||
const value = moduleOverrides[key];
|
||||
const valueToml = tomlString(value);
|
||||
|
||||
const userOwnsIt = await tomlHasKey(userPath, section, key);
|
||||
const targetPath = userOwnsIt ? userPath : teamPath;
|
||||
// 3-tier routing: prefer the file that already owns the key; otherwise
|
||||
// honor core's user-scope partition (so `--set core.user_name` lands in
|
||||
// ~/.bmad on a fresh install, not in project team config).
|
||||
const globalOwnsIt = moduleCode === 'core' && (await tomlHasKey(globalPath, section, key));
|
||||
const userOwnsIt = !globalOwnsIt && (await tomlHasKey(userPath, section, key));
|
||||
const isCoreUserScope = moduleCode === 'core' && coreUserKeys.has(key) && !userOwnsIt;
|
||||
let targetPath;
|
||||
let scope;
|
||||
if (globalOwnsIt || isCoreUserScope) {
|
||||
targetPath = globalPath;
|
||||
scope = 'user';
|
||||
} else if (userOwnsIt) {
|
||||
targetPath = userPath;
|
||||
scope = 'user';
|
||||
} else {
|
||||
targetPath = teamPath;
|
||||
scope = 'team';
|
||||
}
|
||||
|
||||
// The team file always exists post-install; the user file only exists
|
||||
// if the install wrote at least one user-scope key. If we're routing to
|
||||
// it but it doesn't exist yet, create it with a minimal header so it
|
||||
// has the same shape as installer-written user toml.
|
||||
// The team file always exists post-install; the user/global files only
|
||||
// exist once the installer has reason to write to them. If we're routing
|
||||
// to one that doesn't exist yet, create it with a minimal header so it
|
||||
// has the same shape as installer-written files.
|
||||
let content = '';
|
||||
if (await fs.pathExists(targetPath)) {
|
||||
content = await fs.readFile(targetPath, 'utf8');
|
||||
} else if (targetPath === globalPath) {
|
||||
await fs.ensureDir(path.dirname(globalPath));
|
||||
content = '# Global personal BMad config (see ~/.bmad).\n';
|
||||
} else {
|
||||
content = '# Personal overrides for _bmad/config.toml.\n';
|
||||
}
|
||||
|
|
@ -277,8 +399,8 @@ async function applySetOverrides(overrides, bmadDir) {
|
|||
applied.push({
|
||||
module: moduleCode,
|
||||
key,
|
||||
scope: userOwnsIt ? 'user' : 'team',
|
||||
file: path.basename(targetPath),
|
||||
scope,
|
||||
file: targetPath === globalPath ? '~/.bmad/config.user.toml' : path.basename(targetPath),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue