The first-boot guard is now its own predicate gating both the clean and the local/state/ stamp, so a disarmed run (pre-existing local/.env, or self-hosted) touches nothing at all — before, it still created the stamp directory on any vendored run. Removal unlinks symlinks instead of following them into rmtree. Tests filter BOARD_* out of the subprocess environment, assert the stamp stays absent when .env disarms the guard, and cover the symlink case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
144 lines
6.2 KiB
Python
144 lines
6.2 KiB
Python
"""install.py's first-boot cleaning: a vendored clone's very first run
|
|
clears the distribution's own cards so a new host starts with a pristine
|
|
board, and no later run ever touches the host's own. Run with:
|
|
python3 -m unittest discover -s tests
|
|
|
|
install.py is exercised end-to-end as a subprocess against scratch host
|
|
layouts — the same entry point start.sh uses — so what is asserted is
|
|
what a real first boot does to disk.
|
|
"""
|
|
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
STAGES = ["backlog", "to-do", "in-progress", "review", "done"]
|
|
KEEP = {".gitkeep", "task-template.md"}
|
|
|
|
|
|
def make_host(root: Path) -> Path:
|
|
"""A host project with a freshly vendored .task-manager: the real
|
|
install.py and claude adapter, plus the distribution's shipped cards."""
|
|
host = root / "host"
|
|
(host / ".claude").mkdir(parents=True)
|
|
tm = host / ".task-manager"
|
|
tm.mkdir()
|
|
shutil.copy(REPO / "install.py", tm / "install.py")
|
|
shutil.copytree(REPO / "manager" / "core" / "adapters" / "claude",
|
|
tm / "manager" / "core" / "adapters" / "claude")
|
|
for stage in STAGES + ["archive"]:
|
|
d = tm / "tasks" / stage
|
|
d.mkdir(parents=True)
|
|
(d / ".gitkeep").touch()
|
|
(d / "00-shipped-card.md").write_text("# Shipped\n", encoding="utf-8")
|
|
(tm / "tasks" / "task-template.md").write_text("# Template\n", encoding="utf-8")
|
|
for extra in ["plans", "reference"]:
|
|
d = tm / extra
|
|
d.mkdir()
|
|
(d / ".gitkeep").touch()
|
|
(d / "shipped.md").write_text("shipped\n", encoding="utf-8")
|
|
(tm / "reference" / "shots").mkdir()
|
|
(tm / "reference" / "shots" / "board.png").write_bytes(b"png")
|
|
return tm
|
|
|
|
|
|
def run_install(tm: Path, *args: str) -> subprocess.CompletedProcess:
|
|
env = {k: v for k, v in os.environ.items() if not k.startswith("BOARD_")}
|
|
return subprocess.run(
|
|
[sys.executable, str(tm / "install.py"), *args],
|
|
capture_output=True, text=True, cwd=tm.parent, env=env)
|
|
|
|
|
|
def shipped_files(tm: Path) -> list[Path]:
|
|
"""Every file under the cleaned directories that first boot should
|
|
have removed — empty means the board is pristine."""
|
|
return [p
|
|
for top in [tm / "tasks", tm / "plans", tm / "reference"]
|
|
for p in top.rglob("*")
|
|
if p.is_file() and p.name not in KEEP]
|
|
|
|
|
|
class FirstBoot(unittest.TestCase):
|
|
def setUp(self):
|
|
self.scratch = Path(tempfile.mkdtemp()).resolve()
|
|
self.addCleanup(shutil.rmtree, self.scratch, True)
|
|
self.tm = make_host(self.scratch)
|
|
|
|
def test_first_run_clears_shipped_content_and_prints_each_removal(self):
|
|
result = run_install(self.tm)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
self.assertEqual(shipped_files(self.tm), [])
|
|
for stage in STAGES + ["archive"]:
|
|
self.assertTrue((self.tm / "tasks" / stage / ".gitkeep").is_file())
|
|
self.assertTrue((self.tm / "tasks" / "task-template.md").is_file())
|
|
self.assertTrue((self.tm / "plans" / ".gitkeep").is_file())
|
|
self.assertTrue((self.tm / "reference" / ".gitkeep").is_file())
|
|
for line in ["tasks/backlog/00-shipped-card.md",
|
|
"tasks/archive/00-shipped-card.md",
|
|
"plans/shipped.md", "reference/shots"]:
|
|
self.assertIn(f"removed {line}", result.stdout)
|
|
self.assertTrue((self.tm / "manager" / "local" / "state").is_dir())
|
|
|
|
def test_second_run_removes_nothing_and_host_cards_survive(self):
|
|
run_install(self.tm)
|
|
card = self.tm / "tasks" / "backlog" / "20-host-card.md"
|
|
card.write_text("# The host's own\n", encoding="utf-8")
|
|
result = run_install(self.tm)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
self.assertNotIn("removed", result.stdout)
|
|
self.assertTrue(card.is_file())
|
|
self.assertIn("ok", result.stdout)
|
|
|
|
def test_dry_run_lists_without_removing(self):
|
|
result = run_install(self.tm, "--dry-run")
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
self.assertIn("would remove tasks/backlog/00-shipped-card.md",
|
|
result.stdout)
|
|
self.assertNotIn("removed ", result.stdout)
|
|
self.assertNotEqual(shipped_files(self.tm), [])
|
|
self.assertFalse((self.tm / "manager" / "local" / "state").exists())
|
|
|
|
def test_existing_env_file_disarms_the_guard(self):
|
|
local = self.tm / "manager" / "local"
|
|
local.mkdir(parents=True)
|
|
(local / ".env").write_text("BOARD_PORT=26071\n", encoding="utf-8")
|
|
result = run_install(self.tm)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
self.assertNotIn("removed", result.stdout)
|
|
self.assertNotEqual(shipped_files(self.tm), [])
|
|
self.assertFalse((local / "state").exists())
|
|
|
|
def test_symlinked_leftover_is_unlinked_not_followed(self):
|
|
"""A symlink among the leftovers is removed as a link — the
|
|
directory it points to survives untouched."""
|
|
outside = self.scratch / "outside"
|
|
outside.mkdir()
|
|
(outside / "precious.md").write_text("keep me\n", encoding="utf-8")
|
|
link = self.tm / "tasks" / "backlog" / "10-linked"
|
|
link.symlink_to(outside)
|
|
result = run_install(self.tm)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
self.assertFalse(link.is_symlink())
|
|
self.assertFalse(link.exists())
|
|
self.assertTrue((outside / "precious.md").is_file())
|
|
|
|
def test_self_hosted_repo_is_never_cleaned(self):
|
|
"""When the manager IS the repo (bench itself, or a dev clone of
|
|
it), tasks/ is that repo's history — even unwired, never touched."""
|
|
subprocess.run(["git", "init", "-q", str(self.tm)], check=True,
|
|
capture_output=True)
|
|
(self.tm / ".claude").mkdir()
|
|
result = run_install(self.tm)
|
|
self.assertNotIn("removed", result.stdout)
|
|
self.assertNotEqual(shipped_files(self.tm), [])
|
|
self.assertFalse((self.tm / "manager" / "local" / "state").exists())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|