A first run writes local/.env, asking what it cannot guess

A project could run bench for months without a manager/local/.env:
everything fell back to core/.env.example, so the two settings that
change what bench is — claim-on-move and syncing through origin/main —
were invisible to anyone who had not read that file.

install.py now writes it on a first run. It asks three questions no
default can be right about (solo or team, which agent adapter, the
project's test command) and writes core/.env.example with the answers
substituted into their lines, comments and all keys intact — so the
written file is where the project reads what else it can change.

- Runs after first_boot_clean: .env is one of the two things the
  first-boot guard reads, so writing it earlier would skip the clean.
- 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 and
  carries on rather than blocking a board start on an invisible prompt.
  --dry-run reports the questions and writes nothing.
- An existing .env is never touched; --setup is the only way back to
  the questions, pre-filling from the current file and rewriting it in
  place, so start.sh's fallback BOARD_PORT line survives.
- Bare Enter takes every default (the result is the example verbatim,
  i.e. today's behaviour exactly); Ctrl-D skips the rest.

start.sh needed no change — it already calls install.py before the port
dance, which is the right order. Tests drive the interactive runs over a
real pty and the non-interactive ones with /dev/null on stdin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
istos
2026-07-31 07:55:57 +02:00
co-authored by Claude Opus 5
parent 96fcae7d8c
commit 635205486b
5 changed files with 494 additions and 11 deletions
+32 -2
View File
@@ -130,6 +130,7 @@ the tarball from the manifest, tags `v<VERSION>` 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.
+7
View File
@@ -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.
+203 -2
View File
@@ -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
+7
View File
@@ -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
+245 -7
View File
@@ -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()