Merge branch 'task/01-install-ships-pristine-board'
This commit is contained in:
@@ -13,6 +13,10 @@ git clone <this repo> .task-manager && rm -rf .task-manager/.git
|
||||
./.task-manager/start.sh # wires the project (idempotent) and serves
|
||||
```
|
||||
|
||||
The first `start.sh` clears the distribution's own cards from `tasks/`,
|
||||
`plans/` and `reference/` (printing each removal), so a fresh install
|
||||
starts with an empty board.
|
||||
|
||||
Commit `.task-manager/` into the host repo — core is vendored on purpose,
|
||||
so clones work offline and updates show up in the host's own diffs.
|
||||
|
||||
|
||||
+65
@@ -9,11 +9,19 @@ resolves the adapter (BOARD_AGENT_ADAPTER in manager/local/.env, default
|
||||
"claude"; local/adapters/ overrides core/adapters/) and runs its `wire`
|
||||
executable against the project root. Safe to run any time — after dropping
|
||||
.task-manager/ into a new repo, and after every update.sh.
|
||||
|
||||
The distribution repo tracks its own development on its own board, so a
|
||||
fresh clone arrives carrying those cards. The very first run in a host
|
||||
project — vendored, before manager/local/ has ever been populated —
|
||||
clears the stage directories, tasks/archive/, plans/ and reference/
|
||||
(keeping task-template.md and .gitkeep files, printing every removal) and
|
||||
then stamps manager/local/state/ so the guard is false on every later run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -22,6 +30,9 @@ TM = Path(__file__).resolve().parent
|
||||
LOCAL = TM / "manager" / "local"
|
||||
CORE = TM / "manager" / "core"
|
||||
|
||||
STAGE_DIRS = ["backlog", "to-do", "in-progress", "review", "done"]
|
||||
KEEP = {".gitkeep", "task-template.md"}
|
||||
|
||||
|
||||
def _project_root() -> Path:
|
||||
"""The host project's root: the git toplevel seen from the manager's
|
||||
@@ -44,6 +55,59 @@ def _project_root() -> Path:
|
||||
PROJECT = _project_root()
|
||||
|
||||
|
||||
def _content_dirs(tm: Path) -> list[Path]:
|
||||
tasks = tm / "tasks"
|
||||
return ([tasks / stage for stage in STAGE_DIRS]
|
||||
+ [tasks / "archive", tm / "plans", tm / "reference"])
|
||||
|
||||
|
||||
def first_boot(tm: Path, project: Path) -> bool:
|
||||
"""True only on a vendored install's very first run — the one moment
|
||||
anything in the stage directories can only be the distribution's own.
|
||||
False in every other situation:
|
||||
|
||||
- self-hosted (the manager IS the repo): tasks/ is that repo's own
|
||||
history, never distribution residue — including a fresh dev clone;
|
||||
- already wired (local/.env or local/state/ exists): anything in the
|
||||
stage directories can only be the host project's own work."""
|
||||
if project.resolve() == tm.resolve():
|
||||
return False
|
||||
local = tm / "manager" / "local"
|
||||
return not (local / ".env").exists() and not (local / "state").exists()
|
||||
|
||||
|
||||
def first_boot_leftovers(tm: Path) -> list[Path]:
|
||||
"""The distribution's shipped cards, plans and reference documents —
|
||||
the paths a first boot must clear."""
|
||||
return [child
|
||||
for d in _content_dirs(tm) if d.is_dir()
|
||||
for child in sorted(d.iterdir()) if child.name not in KEEP]
|
||||
|
||||
|
||||
def first_boot_clean(dry_run: bool) -> None:
|
||||
"""First boot only: remove the distribution's shipped content and stamp
|
||||
local/state/ so this never runs again — even if the adapter wire fails
|
||||
(a host without .claude/ still gets the board via start.sh) or the host
|
||||
creates cards before the next run. Off first boot nothing is touched,
|
||||
not even the stamp."""
|
||||
if not first_boot(TM, PROJECT):
|
||||
return
|
||||
leftovers = first_boot_leftovers(TM)
|
||||
if leftovers:
|
||||
print("first boot — clearing the distribution's own cards:")
|
||||
verb = "would remove" if dry_run else "removed"
|
||||
for path in leftovers:
|
||||
print(f" {verb} {path.relative_to(TM)}")
|
||||
if not dry_run:
|
||||
if path.is_dir() and not path.is_symlink():
|
||||
shutil.rmtree(path)
|
||||
else:
|
||||
path.unlink()
|
||||
print()
|
||||
if not dry_run:
|
||||
(LOCAL / "state").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def adapter_name() -> str:
|
||||
if os.environ.get("BOARD_AGENT_ADAPTER"):
|
||||
return os.environ["BOARD_AGENT_ADAPTER"]
|
||||
@@ -57,6 +121,7 @@ def adapter_name() -> str:
|
||||
|
||||
|
||||
def main() -> int:
|
||||
first_boot_clean(dry_run="--dry-run" in sys.argv[1:])
|
||||
name = adapter_name()
|
||||
for base in (LOCAL / "adapters", CORE / "adapters"):
|
||||
wire = base / name / "wire"
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user