diff --git a/skills/bmad/scripts/setup.py b/skills/bmad/scripts/setup.py index 35039a1db..23235ec8f 100644 --- a/skills/bmad/scripts/setup.py +++ b/skills/bmad/scripts/setup.py @@ -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 { diff --git a/tools/tests/test_bmad_setup.py b/tools/tests/test_bmad_setup.py index 182f93e45..bd2644a94 100644 --- a/tools/tests/test_bmad_setup.py +++ b/tools/tests/test_bmad_setup.py @@ -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: