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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user