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:
Alex Verkhovsky
2026-08-21 02:30:47 -07:00
parent ed9ad6d53c
commit ce3c64dbb3
2 changed files with 48 additions and 0 deletions
+13
View File
@@ -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 {
+35
View File
@@ -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: