diff --git a/AGENTS.md b/AGENTS.md index 875bf99..fd577a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,6 +130,7 @@ the tarball from the manifest, tags `v` and publishes via `gh`. ```bash python3 .task-manager/install.py # idempotent; --dry-run to preview +python3 .task-manager/install.py --setup # ask the settings questions again ``` Checks that the containing directory is a `.claude`-initialised project and @@ -140,6 +141,33 @@ partial, stale (old `.tasks/` paths) or duplicated → repaired in place. Other hooks and settings are never touched, so it is safe to run any time — e.g. after dropping `.task-manager/` into a new repo. +### The first run writes local/.env + +A project with no `manager/local/.env` runs on the documented defaults, so +the two settings that change what bench *is* — claim-on-move and syncing +through origin/main — stay invisible to anyone who has not read +`core/.env.example`. So the first run asks, and writes the file: **solo or +team** (team turns `BOARD_COMMIT_MOVES` and `BOARD_SYNC` on together, and +the question says what that costs), **which agent adapter** (enumerated +from the adapter directories, so a project's own `local/adapters/` entry +is offered), and **what command runs this project's tests** +(`BOARD_AGENT_COMMANDS` — the one a headless agent cannot work around). +Bare Enter takes the default, Ctrl-D skips the rest, and what lands is +`core/.env.example` with the answers substituted into their lines: every +other key, every comment, so the written file is where the project reads +what else it can change. The cost of writing the whole example is that it +snapshots it — an update that adds a key does not add it to your copy. + +It runs **after** the first-boot clean, because `.env` is one of the two +things the first-boot guard reads. It never asks without a terminal on +stdin: `install.py` sits on the path of `start.sh`, `update.sh` and every +hook, so no TTY prints one line (defaults apply, `--setup` asks) and +carries on rather than blocking a board start with a prompt nobody can +see. `--dry-run` reports the questions and writes nothing. An existing +`.env` is never touched — no repair, no merging in new keys — and +`--setup` is the only way back to the questions, offering the current +file's values as the defaults and rewriting it in place. + ## Seeing the board ```bash @@ -179,8 +207,10 @@ All settings live in `manager/core/.env.example` with their defaults documented the port, the binaries agents launch with, the commands agents may run, the worktrees directory, whether moves claim and commit themselves, whether boards sync through origin/main and how often, the -watch interval and the in-memory caps. Copy it to `manager/local/.env` -(gitignored) to override locally; real environment +watch interval and the in-memory caps. The first `install.py` copies it to +`manager/local/.env` (gitignored) with a few answers substituted in — see +"The first run writes local/.env" above — and that copy is where a project +overrides anything; real environment variables beat `.env`, which beats the defaults. The hook bridge reads the same `.env`, so changing `BOARD_PORT` moves the board, the agents and the hooks together. diff --git a/README.md b/README.md index 6ded7a0..4f59c4e 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,13 @@ mkdir .task-manager && curl -L \ No token, no clone: releases are curated artifacts that never contained bench's own cards or settings, so the board starts empty by construction. +The first run asks three questions it cannot answer for you — solo or +team, which agent adapter, what command runs your tests — and writes +`manager/local/.env` from the documented example, so every other setting +is discoverable in your own copy. Bare Enter takes the default +throughout; with no terminal (CI, a script) it asks nothing and +`install.py --setup` asks later. + 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. diff --git a/install.py b/install.py index da91a03..ec1a480 100755 --- a/install.py +++ b/install.py @@ -3,6 +3,7 @@ python3 .task-manager/install.py # apply (idempotent) python3 .task-manager/install.py --dry-run # report only, change nothing + python3 .task-manager/install.py --setup # ask the settings questions again Vendor-specific wiring belongs to the configured agent adapter: this script resolves the adapter (BOARD_AGENT_ADAPTER in manager/local/.env, default @@ -16,6 +17,18 @@ 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. + +A project with no manager/local/.env then gets one written, because the +settings that change what bench *is* — claim-on-move and syncing through +origin/main — are otherwise invisible to anyone who has not read +manager/core/.env.example. Setup asks the few questions it cannot answer +for the project and writes that example file with the answers substituted +in, so the rest of the settings are discoverable by opening the result. +It runs after first_boot_clean (writing .env early would flip the +first-boot guard and leave the distribution's cards in a host project), it +never asks without a terminal on stdin — install.py sits on the path of +start.sh, update.sh and anything automated — and an existing .env is never +touched except by an explicit --setup. """ from __future__ import annotations @@ -108,6 +121,188 @@ def first_boot_clean(dry_run: bool) -> None: (LOCAL / "state").mkdir(parents=True, exist_ok=True) +# ── First-run settings ──────────────────────────────────────────────── +# +# Everything not asked about is written at its documented default, so the +# answers are only the ones no default can be right about: how this +# project works (solo or team), which agent runs its headless jobs, and +# what command runs its tests. + +ENV_EXAMPLE = CORE / ".env.example" +ENV_FILE = LOCAL / ".env" + +TEAM_NOTE = """\ + Team mode: moves claim and commit themselves, and boards converge + through origin/main. It wants a shared origin, merge rights for whoever + merges, and a local main that only ever advances through the board. + Solo — today's default — does none of it.""" + +COMMANDS_NOTE = """\ + Headless agents may only run the command prefixes named here, so a test + runner missing from the list is a test the work agent cannot run. + Comma-separate several.""" + + +class _Skipped(Exception): + """Ctrl-D: stop asking. Answers already given stand, the rest of the + file stays at its documented defaults.""" + + +def _rel(path: Path) -> str: + """A path as a reader would type it, relative to the project root.""" + try: + return str(path.relative_to(PROJECT)) + except ValueError: + return str(path) + + +def env_values(text: str) -> dict[str, str]: + """KEY=VALUE lines, # comments, optional quotes — config._load_env's + parser, minus the process environment (this is about the file).""" + values: dict[str, str] = {} + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + values[key.strip()] = value.strip().strip("'\"") + return values + + +def env_on(value: str) -> bool: + """config.flag's rule: anything but empty/0/false/no/off is on.""" + return value.strip().lower() not in ("", "0", "false", "no", "off") + + +def substitute(base: str, answers: dict[str, str]) -> str: + """`base` with the answered keys rewritten in place — every comment and + every other key intact, which is the point: the written file is where + the project reads what else it can change. A key the base does not + mention is appended rather than lost.""" + out, placed = [], set() + for line in base.splitlines(): + stripped = line.strip() + key = ("" if stripped.startswith("#") or "=" not in stripped + else stripped.partition("=")[0].strip()) + if key in answers: + out.append(f"{key}={answers[key]}") + placed.add(key) + else: + out.append(line) + for key in [k for k in answers if k not in placed]: + out.append(f"{key}={answers[key]}") + return "\n".join(out).rstrip("\n") + "\n" + + +def adapter_choices() -> list[str]: + """The adapters actually present, core's plus this project's own — + enumerated, never hardcoded, so a local/adapters/ entry shows up.""" + names: list[str] = [] + for base in (CORE / "adapters", LOCAL / "adapters"): + if base.is_dir(): + for child in sorted(base.iterdir()): + if (child / "run").is_file() and child.name not in names: + names.append(child.name) + return names + + +def _ask(question: str, default: str, note: str = "") -> str: + if note: + print(f"\n{note}") + try: + answer = input(f" {question} [{default}]: ").strip() + except EOFError: + print() + raise _Skipped from None + return answer or default + + +def ask_questions(current: dict[str, str], answers: dict[str, str]) -> None: + """Fill `answers` in place — in place because a Ctrl-D part-way through + keeps what was already answered.""" + team = env_on(current.get("BOARD_SYNC", "")) or env_on( + current.get("BOARD_COMMIT_MOVES", "")) + while True: + reply = _ask("solo or team?", "team" if team else "solo", + note=TEAM_NOTE).lower() + if reply in ("solo", "s", "team", "t"): + break + print(" answer solo or team.") + team = reply.startswith("t") + answers["BOARD_COMMIT_MOVES"] = "1" if team else "" + answers["BOARD_SYNC"] = "1" if team else "" + + choices = adapter_choices() + default_adapter = current.get("BOARD_AGENT_ADAPTER") or "claude" + if choices: + allowed = choices + ([default_adapter] + if default_adapter not in choices else []) + while True: + reply = _ask("which agent adapter?", default_adapter, + note=" Which coding agent runs headless jobs — " + f"here: {', '.join(choices)}.") + if reply in allowed: + break + print(f" no such adapter here — one of: {', '.join(allowed)}.") + answers["BOARD_AGENT_ADAPTER"] = reply + + answers["BOARD_AGENT_COMMANDS"] = _ask( + "what command runs this project's tests?", + current.get("BOARD_AGENT_COMMANDS", "python3 -m unittest"), + note=COMMANDS_NOTE) + + +def setup(dry_run: bool, forced: bool) -> None: + """Write manager/local/.env when there is none — or rewrite it, from + its own current values, when asked to with --setup. Silent and + side-effect-free in every other case.""" + exists = ENV_FILE.is_file() + if (exists and not forced) or not ENV_EXAMPLE.is_file(): + return + verb = "rewrite" if exists else "write" + if dry_run: + print(f"would ask: solo or team, which agent adapter, the test " + f"command — and {verb} {_rel(ENV_FILE)} from " + f"{_rel(ENV_EXAMPLE)}.\nDry run — nothing written.\n") + return + if not sys.stdin.isatty(): + if exists: + print(f"--setup asks questions and there is no terminal to ask " + f"on — {_rel(ENV_FILE)} left as it is.\n") + else: + print(f"no {_rel(ENV_FILE)} — the defaults in {_rel(ENV_EXAMPLE)} " + f"apply; `python3 {_rel(Path(__file__).resolve())} --setup` " + f"asks the questions that write one.\n") + return + + example = ENV_EXAMPLE.read_text(encoding="utf-8") + base = ENV_FILE.read_text(encoding="utf-8") if exists else example + current = env_values(example) + current.update(env_values(base)) + + if exists: + print(f"Rewriting {_rel(ENV_FILE)} — its current values are the " + f"defaults below.") + else: + print(f"No {_rel(ENV_FILE)} yet — a few questions and bench writes " + f"one.") + print("Enter takes the default in [brackets]; Ctrl-D skips the rest.") + + answers: dict[str, str] = {} + try: + ask_questions(current, answers) + except _Skipped: + print(" skipped — the rest stay at their documented defaults.") + except KeyboardInterrupt: + print(f"\n\nCancelled — {_rel(ENV_FILE)} not written.\n") + return + + ENV_FILE.parent.mkdir(parents=True, exist_ok=True) + ENV_FILE.write_text(substitute(base, answers), encoding="utf-8") + print(f"\nWrote {_rel(ENV_FILE)} — every other setting is in there, " + f"commented; edit it any time.\n") + + def adapter_name() -> str: if os.environ.get("BOARD_AGENT_ADAPTER"): return os.environ["BOARD_AGENT_ADAPTER"] @@ -121,13 +316,19 @@ def adapter_name() -> str: def main() -> int: - first_boot_clean(dry_run="--dry-run" in sys.argv[1:]) + args = sys.argv[1:] + # Order is load-bearing: setup writes local/.env, which is one of the + # two things first_boot() reads as "this project has been here before". + first_boot_clean(dry_run="--dry-run" in args) + setup(dry_run="--dry-run" in args, forced="--setup" in args) name = adapter_name() + passthrough = [a for a in args if a != "--setup"] + sys.stdout.flush() # the wire's output is a child's: keep the order for base in (LOCAL / "adapters", CORE / "adapters"): wire = base / name / "wire" if wire.is_file(): return subprocess.call( - [sys.executable, str(wire), str(PROJECT), *sys.argv[1:]]) + [sys.executable, str(wire), str(PROJECT), *passthrough]) print(f"agent adapter '{name}' has no wire script — looked in " f"{LOCAL / 'adapters' / name} and {CORE / 'adapters' / name}.") return 1 diff --git a/manager/core/.env.example b/manager/core/.env.example index d6ccc07..197d40b 100644 --- a/manager/core/.env.example +++ b/manager/core/.env.example @@ -1,5 +1,12 @@ # Task manager settings — copy to manager/local/.env (gitignored) and edit. # Precedence: process environment > local/.env > these defaults. +# +# A first run of install.py with no local/.env writes this file there for +# you, asking only what it cannot guess (solo or team, which agent +# adapter, the project's test command) and leaving every other key at the +# default below. `install.py --setup` asks again later; nothing else ever +# rewrites your copy, so new keys added here by an update are yours to +# adopt by hand. # Port the board serves on. Pinned by default so the URL is bookmarkable; # adapters and drivers read the same value, so changing it here changes it diff --git a/tests/test_install_first_boot.py b/tests/test_install_first_boot.py index 4fa77cd..b9675f8 100644 --- a/tests/test_install_first_boot.py +++ b/tests/test_install_first_boot.py @@ -1,14 +1,19 @@ -"""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: +"""install.py's first run: it 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), then asks the handful of settings questions it cannot answer +for the project and writes manager/local/.env. 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. +what a real first boot does to disk. The questions need a terminal on +stdin, so those runs get a real pty; every other run gets /dev/null, +which is also the non-interactive case the board must never block in. """ +import importlib.util import os +import pty import shutil import subprocess import sys @@ -19,11 +24,23 @@ from pathlib import Path REPO = Path(__file__).resolve().parents[1] STAGES = ["backlog", "to-do", "in-progress", "review", "done"] KEEP = {".gitkeep", "task-template.md"} +EXAMPLE = REPO / "manager" / "core" / ".env.example" +CTRL_D = "\x04" # end of transmission: the one keystroke that skips + + +def load_install(): + """install.py as a module, for its pure helpers.""" + spec = importlib.util.spec_from_file_location( + "bench_install", REPO / "install.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module 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.""" + install.py, .env.example and claude adapter, plus the distribution's + shipped cards.""" host = root / "host" (host / ".claude").mkdir(parents=True) tm = host / ".task-manager" @@ -31,6 +48,7 @@ def make_host(root: Path) -> Path: shutil.copy(REPO / "install.py", tm / "install.py") shutil.copytree(REPO / "manager" / "core" / "adapters" / "claude", tm / "manager" / "core" / "adapters" / "claude") + shutil.copy(EXAMPLE, tm / "manager" / "core" / ".env.example") for stage in STAGES + ["archive"]: d = tm / "tasks" / stage d.mkdir(parents=True) @@ -47,11 +65,42 @@ def make_host(root: Path) -> Path: return tm +def clean_env() -> dict: + return {k: v for k, v in os.environ.items() if not k.startswith("BOARD_")} + + def run_install(tm: Path, *args: str) -> subprocess.CompletedProcess: - env = {k: v for k, v in os.environ.items() if not k.startswith("BOARD_")} + """A run with nothing on stdin — a hook, update.sh, CI, or a developer + piping the output somewhere. Setup must never ask here.""" return subprocess.run( [sys.executable, str(tm / "install.py"), *args], - capture_output=True, text=True, cwd=tm.parent, env=env) + capture_output=True, text=True, cwd=tm.parent, env=clean_env(), + stdin=subprocess.DEVNULL) + + +def run_install_tty(tm: Path, answers: list[str], *args: str) -> str: + """The interactive run: stdin is a real terminal, so setup asks, and + `answers` are typed at it in order (an empty string = bare Enter, the + default). Returns stdout+stderr; the answers are echoed by the tty to + the master side, not into what is captured here.""" + master, slave = pty.openpty() + proc = subprocess.Popen( + [sys.executable, str(tm / "install.py"), *args], + stdin=slave, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, cwd=tm.parent, env=clean_env()) + os.close(slave) + try: + for answer in answers: + os.write(master, answer.encode() if answer == CTRL_D + else (answer + "\n").encode()) + out, _ = proc.communicate(timeout=60) + except subprocess.TimeoutExpired: # a question we did not answer + proc.kill() + out, _ = proc.communicate() + raise AssertionError(f"install.py never finished. Output:\n{out}") + finally: + os.close(master) + return out def shipped_files(tm: Path) -> list[Path]: @@ -139,5 +188,194 @@ class FirstBoot(unittest.TestCase): self.assertFalse((self.tm / "manager" / "local" / "state").exists()) +class FirstRunSettings(unittest.TestCase): + """The questions a first run asks, and the manager/local/.env it + writes from the answers.""" + + def setUp(self): + self.scratch = Path(tempfile.mkdtemp()).resolve() + self.addCleanup(shutil.rmtree, self.scratch, True) + self.tm = make_host(self.scratch) + self.env_file = self.tm / "manager" / "local" / ".env" + self.example = (EXAMPLE).read_text(encoding="utf-8") + + def values(self) -> dict: + return load_install().env_values( + self.env_file.read_text(encoding="utf-8")) + + def test_bare_enter_everywhere_writes_the_shipped_defaults(self): + """Every question defaulted → the file is the example verbatim, so + the board behaves exactly as it does with no .env at all.""" + out = run_install_tty(self.tm, ["", "", ""]) + self.assertIn("solo or team?", out) + self.assertIn("which agent adapter?", out) + self.assertIn("what command runs this project's tests?", out) + self.assertEqual(self.env_file.read_text(encoding="utf-8"), + self.example) + + def test_answers_are_substituted_into_the_whole_example(self): + out = run_install_tty(self.tm, ["team", "claude", "npm test"]) + written = self.env_file.read_text(encoding="utf-8") + self.assertEqual(self.values()["BOARD_COMMIT_MOVES"], "1") + self.assertEqual(self.values()["BOARD_SYNC"], "1") + self.assertEqual(self.values()["BOARD_AGENT_COMMANDS"], "npm test") + # Every other key and every comment survives — the written file is + # where the project reads what else it can change. + self.assertEqual(sorted(self.values()), + sorted(load_install().env_values(self.example))) + self.assertIn("# Seconds between disk polls of the stage directories.", + written) + self.assertEqual(self.values()["BOARD_PORT"], "26071") + self.assertIn("Wrote .task-manager/manager/local/.env", out) + + def test_solo_leaves_both_team_settings_empty(self): + run_install_tty(self.tm, ["solo", "", ""]) + self.assertEqual(self.values()["BOARD_COMMIT_MOVES"], "") + self.assertEqual(self.values()["BOARD_SYNC"], "") + + def test_an_invalid_answer_is_asked_again(self): + out = run_install_tty(self.tm, ["both", "team", "", ""]) + self.assertIn("answer solo or team.", out) + self.assertEqual(self.values()["BOARD_SYNC"], "1") + + def test_ctrl_d_skips_the_rest_and_writes_the_defaults(self): + out = run_install_tty(self.tm, ["team", CTRL_D]) + self.assertIn("skipped", out) + self.assertEqual(self.values()["BOARD_SYNC"], "1") + self.assertEqual(self.values()["BOARD_AGENT_COMMANDS"], + "python3 -m unittest") + + def test_the_adapter_question_enumerates_the_directories(self): + """Adapters are listed from disk, so a project's own local one is + offered beside the shipped ones.""" + for name in ["opencode"]: + d = self.tm / "manager" / "core" / "adapters" / name + d.mkdir(parents=True) + (d / "run").write_text("#!/bin/sh\n", encoding="utf-8") + mine = self.tm / "manager" / "local" / "adapters" / "mine" + mine.mkdir(parents=True) + (mine / "run").write_text("#!/bin/sh\n", encoding="utf-8") + out = run_install_tty(self.tm, ["", "mine", ""]) + self.assertIn("here: claude, opencode, mine.", out) + self.assertEqual(self.values()["BOARD_AGENT_ADAPTER"], "mine") + + def test_an_existing_env_is_never_touched_and_the_run_stays_quiet(self): + run_install_tty(self.tm, ["team", "", ""]) + written = self.env_file.read_text(encoding="utf-8") + result = run_install(self.tm) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("ok", result.stdout) + self.assertNotIn("solo or team", result.stdout) + self.assertEqual(self.env_file.read_text(encoding="utf-8"), written) + # And with a terminal too: an .env present means no questions, so + # this run must finish without anything typed at it. + out = run_install_tty(self.tm, []) + self.assertNotIn("solo or team", out) + self.assertEqual(self.env_file.read_text(encoding="utf-8"), written) + + def test_without_a_terminal_it_says_so_and_writes_nothing(self): + result = run_install(self.tm) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("no .task-manager/manager/local/.env", result.stdout) + self.assertIn(".task-manager/install.py --setup", result.stdout) + self.assertFalse(self.env_file.exists()) + + def test_dry_run_reports_the_questions_and_writes_nothing(self): + result = run_install(self.tm, "--dry-run") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("would ask", result.stdout) + self.assertFalse(self.env_file.exists()) + + def test_setup_rewrites_an_existing_file_from_its_own_values(self): + """--setup is the only way back to the questions, and it offers + what the file says today — including keys it never asks about, + which survive untouched.""" + self.env_file.parent.mkdir(parents=True, exist_ok=True) + self.env_file.write_text( + self.example.replace("BOARD_PORT=26071", "BOARD_PORT=26099") + .replace("BOARD_SYNC=\n", "BOARD_SYNC=1\n") + .replace("BOARD_AGENT_COMMANDS=python3 -m unittest", + "BOARD_AGENT_COMMANDS=make test"), + encoding="utf-8") + out = run_install_tty(self.tm, ["", "", ""], "--setup") + self.assertIn("[team]", out) # the current file's answer… + self.assertIn("[make test]", out) # …offered as the default + self.assertEqual(self.values()["BOARD_SYNC"], "1") + self.assertEqual(self.values()["BOARD_PORT"], "26099") + + out = run_install_tty(self.tm, ["solo", "", ""], "--setup") + self.assertEqual(self.values()["BOARD_SYNC"], "") + self.assertEqual(self.values()["BOARD_COMMIT_MOVES"], "") + self.assertEqual(self.values()["BOARD_PORT"], "26099") + self.assertEqual(self.values()["BOARD_AGENT_COMMANDS"], "make test") + + def test_setup_without_a_terminal_leaves_the_file_alone(self): + self.env_file.parent.mkdir(parents=True, exist_ok=True) + self.env_file.write_text("BOARD_PORT=26099\n", encoding="utf-8") + result = run_install(self.tm, "--setup") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.env_file.read_text(encoding="utf-8"), + "BOARD_PORT=26099\n") + + def test_first_boot_both_clears_the_cards_and_writes_the_env(self): + """The order is load-bearing: .env is one of the two things the + first-boot guard reads, so writing it early would skip the clean.""" + out = run_install_tty(self.tm, ["", "", ""]) + self.assertIn("removed tasks/backlog/00-shipped-card.md", out) + self.assertEqual(shipped_files(self.tm), []) + self.assertTrue(self.env_file.is_file()) + 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.assertNotIn("removed", result.stdout) + self.assertTrue(card.is_file()) + + def test_start_sh_port_fallback_survives_the_written_file(self): + """start.sh persists a fallback port into the same file. It is the + one other writer, and it must leave the rest of it intact.""" + run_install_tty(self.tm, ["team", "", ""]) + text = (REPO / "start.sh").read_text(encoding="utf-8") + snippet = (text.split("Persisting BOARD_PORT", 1)[1] + .split("<<'PY'\n", 1)[1].split("\nPY\n", 1)[0]) + result = subprocess.run( + [sys.executable, "-c", snippet, str(self.env_file), "26072"], + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.values()["BOARD_PORT"], "26072") + self.assertEqual(self.values()["BOARD_SYNC"], "1") + self.assertIn("# Seconds between disk polls of the stage directories.", + self.env_file.read_text(encoding="utf-8")) + + +class EnvFileHelpers(unittest.TestCase): + """The pure halves of setup, unit-tested directly.""" + + def setUp(self): + self.install = load_install() + + def test_substitute_rewrites_in_place_and_keeps_everything_else(self): + base = "# a comment\nBOARD_PORT=26071\n\n# another\nBOARD_SYNC=\n" + out = self.install.substitute(base, {"BOARD_SYNC": "1"}) + self.assertEqual( + out, "# a comment\nBOARD_PORT=26071\n\n# another\nBOARD_SYNC=1\n") + + def test_substitute_appends_a_key_the_base_never_mentions(self): + out = self.install.substitute("BOARD_PORT=26071\n", + {"BOARD_SYNC": "1"}) + self.assertEqual(out, "BOARD_PORT=26071\nBOARD_SYNC=1\n") + + def test_env_values_reads_quotes_and_ignores_comments(self): + values = self.install.env_values( + "# BOARD_SYNC=1\nBOARD_AGENT_COMMANDS='npm test'\nBOARD_SYNC=\n") + self.assertEqual(values, + {"BOARD_AGENT_COMMANDS": "npm test", "BOARD_SYNC": ""}) + + def test_env_on_follows_the_flag_rule(self): + for off in ["", " ", "0", "false", "No", "OFF"]: + self.assertFalse(self.install.env_on(off), off) + for on in ["1", "yes", "true", "anything"]: + self.assertTrue(self.install.env_on(on), on) + + if __name__ == "__main__": unittest.main()