mirror of
https://github.com/bmad-code-org/BMAD-METHOD.git
synced 2026-08-29 03:44:18 +08:00
fix(setup): refuse to replace a symlinked _bmad with a clear message
setup and doctor rewrite _bmad wholesale via replace_dir, so running them through a symlinked _bmad crashed mid-flight with a raw IsADirectoryError from the backup rename. Reads through the symlink are the point of symlinking and stay untouched; only the write paths now fail fast, naming the link target and the --project-root to use against the real installation. The doctor flow rejects in its missing-_bmad pre-flight so --list-config-questions --doctor fails before interrogating the user.
This commit is contained in:
@@ -190,6 +190,7 @@ def setup(
|
||||
module_answers: dict[tuple[str, str], str] | None = None,
|
||||
module_answers_source: Path | None = None,
|
||||
) -> None:
|
||||
reject_symlinked_bmad(project_root)
|
||||
scripts_src, config_src = payload(skill_root)
|
||||
template_text = fill_team_config(
|
||||
config_src.read_text(encoding="utf-8"), project_root
|
||||
@@ -262,7 +263,19 @@ def pending_config_questions(
|
||||
return find_pending_questions(modules, merged, project_root)
|
||||
|
||||
|
||||
def reject_symlinked_bmad(project_root: Path) -> None:
|
||||
bmad = project_root / "_bmad"
|
||||
if bmad.is_symlink():
|
||||
target = bmad.resolve()
|
||||
raise Exception(
|
||||
f"{bmad} is a symlink to {target}; setup and doctor replace "
|
||||
f"_bmad in place, so run them with --project-root "
|
||||
f"{target.parent} to fix the real installation"
|
||||
)
|
||||
|
||||
|
||||
def missing_bmad_report(project_root: Path) -> dict[str, object] | None:
|
||||
reject_symlinked_bmad(project_root)
|
||||
bmad = project_root / "_bmad"
|
||||
if not bmad.exists():
|
||||
return {
|
||||
|
||||
@@ -1403,6 +1403,41 @@ class BmadSetupTests(unittest.TestCase):
|
||||
self.assertFalse((project / "_bmad").exists())
|
||||
self.assertEqual(list(project.glob("_bmad.setup-*")), [])
|
||||
|
||||
def test_symlinked_bmad_is_rejected_before_any_write(self):
|
||||
if not symlink_to_temp_dir_succeeds():
|
||||
self.skipTest("symlinks not available")
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
project = root / "proj"
|
||||
project.mkdir()
|
||||
skill = write_dest_bmad(root)
|
||||
real = root / "real-install" / "_bmad"
|
||||
write(real / "config.toml", "[core]\nkeep = 1\n")
|
||||
os.symlink(real, project / "_bmad", target_is_directory=True)
|
||||
before = {
|
||||
path.relative_to(real): path.read_bytes()
|
||||
for path in real.rglob("*")
|
||||
if path.is_file()
|
||||
}
|
||||
|
||||
for extra in ((), ("--doctor",), ("--list-config-questions", "--doctor")):
|
||||
with self.subTest(extra=extra):
|
||||
result = run_setup_python(project, skill, *extra)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("symlink", result.stderr)
|
||||
self.assertIn(str(real.resolve().parent), result.stderr)
|
||||
|
||||
self.assertTrue((project / "_bmad").is_symlink())
|
||||
self.assertEqual(
|
||||
{
|
||||
path.relative_to(real): path.read_bytes()
|
||||
for path in real.rglob("*")
|
||||
if path.is_file()
|
||||
},
|
||||
before,
|
||||
)
|
||||
self.assertEqual(list(project.glob("_bmad.*-*")), [])
|
||||
|
||||
def test_declared_script_read_error_is_source_specific_and_atomic(self):
|
||||
setup = load_setup()
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
|
||||
Reference in New Issue
Block a user