# Local-as-Dev Tier Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Stand up a self-contained local dev tier — `mysql` + `api-server` + `web-view` in Docker — and a stdlib Python CLI (`app/bot_cli.py`) plus two shell scripts that mirror the Telegram bot's manual-trigger surface. **Architecture:** Add `mysql:8.0` to `docker-compose.override.yml` published to `127.0.0.1:3306`, gate `telegram-bot`/`transfer-bot` behind a compose `bots` profile, write seed/schema SQL into `docker/mysql/init.d/`. Build `app/bot_cli.py` test-first using `unittest` + `unittest.mock` to swap `CM_BOT_HAL`. Add `scripts/dev.sh` (lifecycle) and `scripts/bot_cli.sh` (env-loading wrapper that overrides `DB_HOST=127.0.0.1`). Default-on TUI menu when CLI invoked with no args. **Tech Stack:** Python 3.9 (containers) / 3.12 (local venv), Flask 2.3.3, MySQL 8.0, Docker Compose v2, `unittest` + `unittest.mock` (stdlib), `argparse` (stdlib). No new dependencies. **Spec:** [docs/superpowers/specs/2026-05-02-local-as-dev-design.md](../specs/2026-05-02-local-as-dev-design.md) --- ## File Map | File | Operation | Purpose | |---|---|---| | `tests/test_bot_cli.py` | Create | Unittest cases for parser, every `cmd_X`, and the TUI loop. | | `app/bot_cli.py` | Create | argparse + stdlib TUI; `python -m app.bot_cli` entry point. | | `docker-compose.override.yml` | Modify | Add `mysql` service, `depends_on: { mysql: { condition: service_healthy } }` to api-server, `profiles: ["bots"]` on bots, `mysql-data` volume. | | `docker/mysql/init.d/01-schema.sql` | Create | DDL for `acc` + `user` tables. | | `docker/mysql/init.d/02-seed.sql` | Create | Four seed `acc` rows matching `CM_PREFIX_PATTERN=13c`. | | `scripts/dev.sh` | Create | Lifecycle: `up`/`down`/`reset-db`/`logs`/`status`. | | `scripts/bot_cli.sh` | Create | Env-loading wrapper around `python -m app.bot_cli`. | | `envs/dev/.env.example` | Create | Committed template for dev `.env`. | | `.gitignore` | Modify | Add `envs/dev/.env`. | | `AGENTS.md` | Modify | Dev tier section. | The `app/bot_cli.py` module is grown test-first across Tasks 1–9. Each command function is added in a single task alongside its subparser registration and its tests. --- ## Task 1: Skeleton `app/bot_cli.py` and parser sanity test **Files:** - Create: `tests/test_bot_cli.py` - Create: `app/bot_cli.py` - [ ] **Step 1: Write the failing test** Create `tests/test_bot_cli.py`: ```python """Tests for the bot CLI (app.bot_cli). The CLI mirrors the Telegram bot's manual-trigger surface (Telegram handlers /1, /2, /3) plus the operational ops (credit, transfer, monitor-once). With no args, it drops into a stdlib TUI menu. These tests mock app.bot_cli.CM_BOT_HAL so they never touch the database or cm99.net. The HAL class is imported at module load (which is a pure import — no env reads), so we can patch the symbol bound on app.bot_cli without affecting other tests. """ import argparse import contextlib import io import os import sys import unittest from unittest import mock import app.bot_cli as bot_cli class ParserSanityTests(unittest.TestCase): def test_build_parser_returns_argument_parser(self): parser = bot_cli.build_parser() self.assertIsInstance(parser, argparse.ArgumentParser) def test_main_with_no_args_dispatches_to_interactive(self): # When invoked with no subcommand, main() should drop into the # TUI loop. We verify the dispatch by patching cmd_interactive to # a no-op recorder. with mock.patch.object(bot_cli, "cmd_interactive", return_value=0) as mocked: rc = bot_cli.main([]) mocked.assert_called_once() self.assertEqual(rc, 0) if __name__ == "__main__": unittest.main() ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ .venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -8 ``` Expected: `ImportError: No module named 'app.bot_cli'` (or similar). - [ ] **Step 3: Create the skeleton module** Create `app/bot_cli.py`: ```python """Local dev CLI for the CM bot. Mirrors Telegram /1, /2, /3 plus operational commands. No-arg invocation drops into a stdlib TUI menu. """ import argparse import sys from .cm_bot_hal import CM_BOT_HAL def cmd_interactive(_args): # Real implementation lands in Task 9. Stub raises so a regression # that drops into interactive mode by accident is loud, not silent. raise NotImplementedError("cmd_interactive is implemented in a later task") def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( prog="bot_cli", description="CM Bot dev CLI (mirrors Telegram triggers).", ) p.add_subparsers(dest="command") return p def main(argv=None) -> int: parser = build_parser() args = parser.parse_args(argv) if args.command is None: return cmd_interactive(args) or 0 return args.func(args) or 0 if __name__ == "__main__": sys.exit(main()) ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ .venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -8 ``` Expected: `OK` (2 tests pass). - [ ] **Step 5: Commit** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ git add tests/test_bot_cli.py app/bot_cli.py && \ git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \ commit -m "feat(bot_cli): add module skeleton with parser sanity tests" ``` --- ## Task 2: `cmd_register` (Telegram /1 analog) **Files:** - Modify: `tests/test_bot_cli.py` (add a new test class) - Modify: `app/bot_cli.py` (add `cmd_register` and register `register`/`get-acc` subparser) - [ ] **Step 1: Write the failing test** Append to `tests/test_bot_cli.py`: ```python class CmdRegisterTests(unittest.TestCase): @mock.patch.object(bot_cli, "CM_BOT_HAL") def test_prints_username_password_link(self, mock_hal_class): mock_hal = mock_hal_class.return_value mock_hal.get_user_api.return_value = { "username": "13c1234", "password": "abc12345", "link": "https://example.com/r/foo", } out = io.StringIO() with contextlib.redirect_stdout(out): bot_cli.cmd_register(argparse.Namespace()) text = out.getvalue() self.assertIn("Username: 13c1234", text) self.assertIn("Password: abc12345", text) self.assertIn("Link: https://example.com/r/foo", text) mock_hal.get_user_api.assert_called_once_with() def test_register_subparser_dispatches_to_cmd_register(self): parser = bot_cli.build_parser() args = parser.parse_args(["register"]) self.assertIs(args.func, bot_cli.cmd_register) def test_get_acc_alias_dispatches_to_cmd_register(self): parser = bot_cli.build_parser() args = parser.parse_args(["get-acc"]) self.assertIs(args.func, bot_cli.cmd_register) ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ .venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -10 ``` Expected: failures for the three new tests; `cmd_register` is missing on `bot_cli`, and the parser doesn't know about `register`. - [ ] **Step 3: Implement `cmd_register` and register the subparser** In `app/bot_cli.py`, add the helper and command function after the `cmd_interactive` stub but before `build_parser`: ```python def _print_user(user: dict) -> None: print(f"Username: {user['username']}") print(f"Password: {user['password']}") print(f"Link: {user['link']}") def cmd_register(_args): bot = CM_BOT_HAL() _print_user(bot.get_user_api()) ``` Update `build_parser()` to add the subparser: ```python def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( prog="bot_cli", description="CM Bot dev CLI (mirrors Telegram triggers).", ) sub = p.add_subparsers(dest="command") sp = sub.add_parser("register", aliases=["get-acc"], help="Get next available account (Telegram /1).") sp.set_defaults(func=cmd_register) return p ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ .venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -10 ``` Expected: `OK` (5 tests now passing). - [ ] **Step 5: Commit** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ git add tests/test_bot_cli.py app/bot_cli.py && \ git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \ commit -m "feat(bot_cli): add register subcommand (Telegram /1 analog)" ``` --- ## Task 3: `cmd_set_pin` (Telegram /2 analog) with local name resolution **Files:** - Modify: `tests/test_bot_cli.py` - Modify: `app/bot_cli.py` - [ ] **Step 1: Write the failing test** Append to `tests/test_bot_cli.py`: ```python class CmdSetPinTests(unittest.TestCase): @mock.patch.object(bot_cli, "CM_BOT_HAL") def test_rejects_invalid_whatsapp_url(self, mock_hal_class): mock_hal = mock_hal_class.return_value mock_hal.is_whatsapp_url.return_value = False with self.assertRaises(SystemExit) as cm: bot_cli.cmd_set_pin(argparse.Namespace(link="https://not-whatsapp.example/x")) self.assertEqual(cm.exception.code, 2) # Must NOT have called set_security_pin_api mock_hal.set_security_pin_api.assert_not_called() @mock.patch.object(bot_cli, "CM_BOT_HAL") def test_extracts_names_locally_and_succeeds(self, mock_hal_class): mock_hal = mock_hal_class.return_value mock_hal.is_whatsapp_url.return_value = True mock_hal.get_whatsapp_link_username.return_value = ("t_user_42", "f_user_42") # The HAL returns a bool from set_security_pin_api today (latent # contract bug noted in the spec). Confirm the CLI tolerates it. mock_hal.set_security_pin_api.return_value = True out = io.StringIO() with contextlib.redirect_stdout(out): bot_cli.cmd_set_pin(argparse.Namespace(link="https://chat.whatsapp.com/abc")) text = out.getvalue() self.assertIn("f_username=f_user_42", text) self.assertIn("t_username=t_user_42", text) mock_hal.set_security_pin_api.assert_called_once_with("https://chat.whatsapp.com/abc") @mock.patch.object(bot_cli, "CM_BOT_HAL") def test_falsy_set_security_pin_result_exits_nonzero(self, mock_hal_class): mock_hal = mock_hal_class.return_value mock_hal.is_whatsapp_url.return_value = True mock_hal.get_whatsapp_link_username.return_value = ("t", "f") mock_hal.set_security_pin_api.return_value = False with self.assertRaises(SystemExit) as cm: bot_cli.cmd_set_pin(argparse.Namespace(link="https://chat.whatsapp.com/abc")) self.assertEqual(cm.exception.code, 1) def test_set_pin_subparser_dispatches(self): parser = bot_cli.build_parser() args = parser.parse_args(["set-pin", "https://chat.whatsapp.com/abc"]) self.assertIs(args.func, bot_cli.cmd_set_pin) self.assertEqual(args.link, "https://chat.whatsapp.com/abc") ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ .venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -15 ``` Expected: failures for `cmd_set_pin` not defined and `set-pin` subcommand unknown. - [ ] **Step 3: Implement `cmd_set_pin`** In `app/bot_cli.py`, add after `cmd_register`: ```python def cmd_set_pin(args): bot = CM_BOT_HAL() if not bot.is_whatsapp_url(args.link): print(f"ERROR: not a WhatsApp URL: {args.link}", file=sys.stderr) sys.exit(2) # Resolve names locally so we have something useful to print regardless # of what set_security_pin_api returns. The HAL currently returns a bool # from the trailing insert_user_to_table_user; the Telegram handler in # cm_telegram.py:87 has a latent bug accessing result['f_username']. We # avoid depending on the return shape here. t_username, f_username = bot.get_whatsapp_link_username(args.link) success = bot.set_security_pin_api(args.link) if not success: print("ERROR: set_security_pin_api returned a falsy result", file=sys.stderr) sys.exit(1) print(f"OK: f_username={f_username} t_username={t_username}") ``` In `build_parser()`, add after the `register` subparser: ```python sp = sub.add_parser("set-pin", help="Set security PIN from a WhatsApp link (Telegram /2).") sp.add_argument("link") sp.set_defaults(func=cmd_set_pin) ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ .venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -12 ``` Expected: `OK` (9 tests now passing). - [ ] **Step 5: Commit** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ git add tests/test_bot_cli.py app/bot_cli.py && \ git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \ commit -m "feat(bot_cli): add set-pin subcommand with local name resolution" ``` --- ## Task 4: `cmd_insert_user` (Telegram /3 analog) **Files:** - Modify: `tests/test_bot_cli.py` - Modify: `app/bot_cli.py` - [ ] **Step 1: Write the failing test** Append to `tests/test_bot_cli.py`: ```python class CmdInsertUserTests(unittest.TestCase): @mock.patch.object(bot_cli, "CM_BOT_HAL") def test_inserts_using_password_lookup_and_security_pin(self, mock_hal_class): mock_hal = mock_hal_class.return_value mock_hal.get_user_pass_from_acc.return_value = "abc12345" mock_hal.security_pin = "999111" mock_hal.insert_user_to_table_user.return_value = True out = io.StringIO() with contextlib.redirect_stdout(out): bot_cli.cmd_insert_user(argparse.Namespace(f_username="13c1234", t_username="player_x")) self.assertIn("OK: inserted 13c1234 → player_x", out.getvalue()) mock_hal.insert_user_to_table_user.assert_called_once_with({ "f_username": "13c1234", "f_password": "abc12345", "t_username": "player_x", "t_password": "999111", }) @mock.patch.object(bot_cli, "CM_BOT_HAL") def test_no_password_for_f_user_exits_2(self, mock_hal_class): mock_hal = mock_hal_class.return_value mock_hal.get_user_pass_from_acc.return_value = None with self.assertRaises(SystemExit) as cm: bot_cli.cmd_insert_user(argparse.Namespace(f_username="missing", t_username="player_x")) self.assertEqual(cm.exception.code, 2) mock_hal.insert_user_to_table_user.assert_not_called() @mock.patch.object(bot_cli, "CM_BOT_HAL") def test_insert_failure_exits_1(self, mock_hal_class): mock_hal = mock_hal_class.return_value mock_hal.get_user_pass_from_acc.return_value = "abc" mock_hal.security_pin = "000" mock_hal.insert_user_to_table_user.return_value = False with self.assertRaises(SystemExit) as cm: bot_cli.cmd_insert_user(argparse.Namespace(f_username="13c1234", t_username="player_x")) self.assertEqual(cm.exception.code, 1) def test_insert_user_subparser_dispatches(self): parser = bot_cli.build_parser() args = parser.parse_args(["insert-user", "13c1234", "player_x"]) self.assertIs(args.func, bot_cli.cmd_insert_user) self.assertEqual(args.f_username, "13c1234") self.assertEqual(args.t_username, "player_x") ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ .venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -15 ``` Expected: failures for `cmd_insert_user` not defined. - [ ] **Step 3: Implement `cmd_insert_user`** In `app/bot_cli.py`, add after `cmd_set_pin`: ```python def cmd_insert_user(args): bot = CM_BOT_HAL() f_password = bot.get_user_pass_from_acc(args.f_username) if not f_password: print(f"ERROR: no password for {args.f_username}", file=sys.stderr) sys.exit(2) success = bot.insert_user_to_table_user({ "f_username": args.f_username, "f_password": f_password, "t_username": args.t_username, "t_password": bot.security_pin, }) if not success: print("ERROR: insert failed", file=sys.stderr) sys.exit(1) print(f"OK: inserted {args.f_username} → {args.t_username}") ``` In `build_parser()`, after the `set-pin` subparser: ```python sp = sub.add_parser("insert-user", help="Insert into user table (Telegram /3).") sp.add_argument("f_username") sp.add_argument("t_username") sp.set_defaults(func=cmd_insert_user) ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ .venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -12 ``` Expected: `OK` (13 tests now passing). - [ ] **Step 5: Commit** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ git add tests/test_bot_cli.py app/bot_cli.py && \ git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \ commit -m "feat(bot_cli): add insert-user subcommand (Telegram /3 analog)" ``` --- ## Task 5: `cmd_credit` and `cmd_transfer` **Files:** - Modify: `tests/test_bot_cli.py` - Modify: `app/bot_cli.py` - [ ] **Step 1: Write the failing test** Append to `tests/test_bot_cli.py`: ```python class CmdCreditTests(unittest.TestCase): @mock.patch.object(bot_cli, "CM_BOT_HAL") def test_prints_credit(self, mock_hal_class): mock_hal = mock_hal_class.return_value mock_hal.get_user_credit.return_value = 42.5 out = io.StringIO() with contextlib.redirect_stdout(out): bot_cli.cmd_credit(argparse.Namespace(username="13c1234", password="abc")) self.assertIn("Credit: 42.5", out.getvalue()) mock_hal.get_user_credit.assert_called_once_with("13c1234", "abc") def test_credit_subparser_dispatches(self): parser = bot_cli.build_parser() args = parser.parse_args(["credit", "13c1234", "abc"]) self.assertIs(args.func, bot_cli.cmd_credit) class CmdTransferTests(unittest.TestCase): @mock.patch.object(bot_cli, "CM_BOT_HAL") def test_prints_transfer_result(self, mock_hal_class): mock_hal = mock_hal_class.return_value mock_hal.transfer_credit_api.return_value = "Successfully transfer amount: 10.0 from 13c1234 to player_x" out = io.StringIO() with contextlib.redirect_stdout(out): bot_cli.cmd_transfer(argparse.Namespace( f_username="13c1234", f_password="abc", t_username="player_x", t_password="0000", )) self.assertIn("Successfully transfer", out.getvalue()) mock_hal.transfer_credit_api.assert_called_once_with("13c1234", "abc", "player_x", "0000") def test_transfer_subparser_dispatches(self): parser = bot_cli.build_parser() args = parser.parse_args(["transfer", "13c1234", "abc", "player_x", "0000"]) self.assertIs(args.func, bot_cli.cmd_transfer) ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ .venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -15 ``` Expected: failures for `cmd_credit` / `cmd_transfer` not defined. - [ ] **Step 3: Implement both commands** In `app/bot_cli.py`, add after `cmd_insert_user`: ```python def cmd_credit(args): bot = CM_BOT_HAL() print(f"Credit: {bot.get_user_credit(args.username, args.password)}") def cmd_transfer(args): bot = CM_BOT_HAL() print(bot.transfer_credit_api( args.f_username, args.f_password, args.t_username, args.t_password, )) ``` In `build_parser()`, after the `insert-user` subparser: ```python sp = sub.add_parser("credit", help="Read account credit balance.") sp.add_argument("username") sp.add_argument("password") sp.set_defaults(func=cmd_credit) sp = sub.add_parser("transfer", help="One-shot credit transfer.") sp.add_argument("f_username") sp.add_argument("f_password") sp.add_argument("t_username") sp.add_argument("t_password") sp.set_defaults(func=cmd_transfer) ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ .venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -12 ``` Expected: `OK` (17 tests now passing). - [ ] **Step 5: Commit** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ git add tests/test_bot_cli.py app/bot_cli.py && \ git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \ commit -m "feat(bot_cli): add credit and transfer subcommands" ``` --- ## Task 6: `cmd_monitor_once` **Files:** - Modify: `tests/test_bot_cli.py` - Modify: `app/bot_cli.py` - [ ] **Step 1: Write the failing test** Append to `tests/test_bot_cli.py`: ```python class CmdMonitorOnceTests(unittest.TestCase): @mock.patch.object(bot_cli, "CM_BOT_HAL") def test_does_nothing_when_already_at_target(self, mock_hal_class): mock_hal = mock_hal_class.return_value mock_hal.get_all_available_acc.return_value = [{"username": f"u{i}"} for i in range(20)] out = io.StringIO() with contextlib.redirect_stdout(out): bot_cli.cmd_monitor_once(argparse.Namespace(target=20)) text = out.getvalue() self.assertIn("Available accounts: 20", text) self.assertIn("Already at target", text) mock_hal.create_new_acc.assert_not_called() @mock.patch.object(bot_cli, "CM_BOT_HAL") def test_creates_accounts_until_target(self, mock_hal_class): mock_hal = mock_hal_class.return_value mock_hal.get_all_available_acc.return_value = [{"username": "u1"}, {"username": "u2"}] # Two existing → target 5 → create 3. mock_hal.create_new_acc.side_effect = [ {"username": "u3", "password": "p3", "link": "l3"}, {"username": "u4", "password": "p4", "link": "l4"}, {"username": "u5", "password": "p5", "link": "l5"}, ] out = io.StringIO() with contextlib.redirect_stdout(out): bot_cli.cmd_monitor_once(argparse.Namespace(target=5)) text = out.getvalue() self.assertEqual(mock_hal.create_new_acc.call_count, 3) self.assertIn("Created: u3", text) self.assertIn("Created: u4", text) self.assertIn("Created: u5", text) @mock.patch.object(bot_cli, "CM_BOT_HAL") def test_create_failure_exits_1(self, mock_hal_class): mock_hal = mock_hal_class.return_value mock_hal.get_all_available_acc.return_value = [] mock_hal.create_new_acc.side_effect = RuntimeError("fail login") with self.assertRaises(SystemExit) as cm: bot_cli.cmd_monitor_once(argparse.Namespace(target=1)) self.assertEqual(cm.exception.code, 1) def test_monitor_once_subparser_dispatches(self): parser = bot_cli.build_parser() args = parser.parse_args(["monitor-once", "--target", "7"]) self.assertIs(args.func, bot_cli.cmd_monitor_once) self.assertEqual(args.target, 7) def test_monitor_alias_dispatches(self): parser = bot_cli.build_parser() args = parser.parse_args(["monitor"]) self.assertIs(args.func, bot_cli.cmd_monitor_once) self.assertEqual(args.target, 20) ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ .venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -15 ``` Expected: failures for `cmd_monitor_once` not defined. - [ ] **Step 3: Implement `cmd_monitor_once`** In `app/bot_cli.py`, add after `cmd_transfer`: ```python def cmd_monitor_once(args): bot = CM_BOT_HAL() available = bot.get_all_available_acc() print(f"Available accounts: {len(available)} (target: {args.target})") if len(available) >= args.target: print("Already at target; nothing to do.") return for _ in range(len(available), args.target): try: user = bot.create_new_acc() print(f"Created: {user['username']}") except Exception as exc: print(f"ERROR creating account: {exc}", file=sys.stderr) sys.exit(1) ``` In `build_parser()`, after the `transfer` subparser: ```python sp = sub.add_parser("monitor-once", aliases=["monitor"], help="One iteration of the auto-create monitor.") sp.add_argument("--target", type=int, default=20) sp.set_defaults(func=cmd_monitor_once) ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ .venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -12 ``` Expected: `OK` (22 tests now passing). - [ ] **Step 5: Commit** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ git add tests/test_bot_cli.py app/bot_cli.py && \ git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \ commit -m "feat(bot_cli): add monitor-once subcommand" ``` --- ## Task 7: TUI interactive mode (replaces `cmd_interactive` stub) **Files:** - Modify: `tests/test_bot_cli.py` - Modify: `app/bot_cli.py` - [ ] **Step 1: Write the failing test** Append to `tests/test_bot_cli.py`: ```python class CmdInteractiveTests(unittest.TestCase): @mock.patch("builtins.input", side_effect=["q"]) @mock.patch.object(bot_cli, "CM_BOT_HAL") def test_q_exits_cleanly(self, mock_hal_class, mock_input): out = io.StringIO() with contextlib.redirect_stdout(out): bot_cli.cmd_interactive(argparse.Namespace()) self.assertIn("CM Bot CLI", out.getvalue()) @mock.patch("builtins.input", side_effect=["", "q"]) @mock.patch.object(bot_cli, "CM_BOT_HAL") def test_blank_line_continues_loop(self, mock_hal_class, mock_input): out = io.StringIO() with contextlib.redirect_stdout(out): bot_cli.cmd_interactive(argparse.Namespace()) # Blank line keeps the menu visible — menu should appear at least # twice (once on entry, once after the blank line). self.assertGreaterEqual(out.getvalue().count("Register / get next account"), 2) @mock.patch("builtins.input", side_effect=EOFError) @mock.patch.object(bot_cli, "CM_BOT_HAL") def test_eof_exits_cleanly(self, mock_hal_class, mock_input): out = io.StringIO() with contextlib.redirect_stdout(out): bot_cli.cmd_interactive(argparse.Namespace()) # No traceback; we just exit. Menu should still have printed. self.assertIn("CM Bot CLI", out.getvalue()) @mock.patch("builtins.input", side_effect=["1", "q"]) @mock.patch.object(bot_cli, "CM_BOT_HAL") def test_alias_1_dispatches_to_register(self, mock_hal_class, mock_input): mock_hal = mock_hal_class.return_value mock_hal.get_user_api.return_value = { "username": "u", "password": "p", "link": "l", } out = io.StringIO() with contextlib.redirect_stdout(out): bot_cli.cmd_interactive(argparse.Namespace()) self.assertIn("Username: u", out.getvalue()) mock_hal.get_user_api.assert_called_once_with() @mock.patch("builtins.input", side_effect=["nonsense", "q"]) @mock.patch.object(bot_cli, "CM_BOT_HAL") def test_unknown_subcommand_keeps_loop_alive(self, mock_hal_class, mock_input): # An invalid command should print an argparse error to stderr but # NOT exit the REPL. We verify by reaching 'q' on the next iteration. out = io.StringIO() err = io.StringIO() with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): bot_cli.cmd_interactive(argparse.Namespace()) # Reached 'q' → cm_bot_cli exited normally. If the loop had died # we would have raised before assigning `out.getvalue()`. self.assertIn("CM Bot CLI", out.getvalue()) ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ .venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -15 ``` Expected: failures because `cmd_interactive` currently raises `NotImplementedError`. - [ ] **Step 3: Implement `cmd_interactive`** In `app/bot_cli.py`, replace the `cmd_interactive` stub at the top with the real implementation: ```python # Map TUI shortcuts to argparse subcommand names so the REPL reuses the # same dispatch table as one-shot invocations. _TUI_ALIASES = {"1": "register", "2": "set-pin", "3": "insert-user"} def cmd_interactive(_args): """Telegram-style menu in a TTY loop. stdlib only.""" print("CM Bot CLI — interactive (type 'q' to quit, '?' for menu)") while True: print() print(" 1 Register / get next account") print(" 2 Set security PIN") print(" 3 Insert into user table") print(" credit Read account credit") print(" transfer One-shot credit transfer") print(" monitor [N] Run monitor once (default 20)") print(" q Quit") try: line = input("> ").strip() except (EOFError, KeyboardInterrupt): print() return if not line: continue if line in ("q", "quit", "exit"): return if line in ("?", "help", "menu"): continue argv = line.split() argv[0] = _TUI_ALIASES.get(argv[0], argv[0]) try: args = build_parser().parse_args(argv) args.func(args) except SystemExit: # argparse calls sys.exit() on parse error; swallow it to keep # the REPL alive. continue except Exception as exc: print(f"ERROR: {exc}", file=sys.stderr) ``` This replaces the stub from Task 1. The `_TUI_ALIASES` constant lives at module level so it's testable in isolation if a future task wants to assert on it. - [ ] **Step 4: Run test to verify it passes** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ .venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -10 ``` Expected: `OK` (27 tests now passing). - [ ] **Step 5: Smoke-test from the command line** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ .venv/bin/python -m app.bot_cli --help 2>&1 | head -25 ``` Expected: argparse usage block listing all six subparsers (`register`, `set-pin`, `insert-user`, `credit`, `transfer`, `monitor-once`, `interactive`). - [ ] **Step 6: Commit** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ git add tests/test_bot_cli.py app/bot_cli.py && \ git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \ commit -m "feat(bot_cli): implement interactive TUI menu" ``` --- ## Task 8: MySQL service in `docker-compose.override.yml` plus init scripts **Files:** - Modify: `docker-compose.override.yml` - Create: `docker/mysql/init.d/01-schema.sql` - Create: `docker/mysql/init.d/02-seed.sql` - [ ] **Step 1: Create the schema script** Create `docker/mysql/init.d/01-schema.sql`: ```sql -- Schema for the CM bot dev DB. Mounted at -- /docker-entrypoint-initdb.d/01-schema.sql in the mysql:8.0 container; -- runs once on first volume initialization. CREATE TABLE IF NOT EXISTS acc ( username VARCHAR(64) PRIMARY KEY, password VARCHAR(128) NOT NULL, status VARCHAR(32) DEFAULT '', link VARCHAR(512) DEFAULT '' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE IF NOT EXISTS user ( f_username VARCHAR(64) PRIMARY KEY, f_password VARCHAR(128) NOT NULL, t_username VARCHAR(64) NOT NULL, t_password VARCHAR(128) NOT NULL, last_update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ``` - [ ] **Step 2: Create the seed script** Create `docker/mysql/init.d/02-seed.sql`: ```sql -- Dev-only seed. Four acc rows matching CM_PREFIX_PATTERN=13c so -- get_next_username has something to anchor on. Passwords are placeholder -- strings — never real cm99.net credentials. INSERT INTO acc (username, password, status, link) VALUES ('13c1000', 'seedpass', '', ''), ('13c1001', 'seedpass', '', ''), ('13c1002', 'seedpass', '', ''), ('13c1003', 'seedpass', '', ''); ``` - [ ] **Step 3: Add the mysql service and dev-only directives to `docker-compose.override.yml`** The current file (33 lines) only has `build:` directives plus an environment override on `transfer-bot`. Append at the bottom: ```yaml mysql: image: mysql:8.0 container_name: ${CM_DEPLOY_NAME:-cm}-mysql restart: unless-stopped environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-devroot} MYSQL_DATABASE: ${DB_NAME} MYSQL_USER: ${DB_USER} MYSQL_PASSWORD: ${DB_PASSWORD} ports: - "127.0.0.1:3306:3306" volumes: - mysql-data:/var/lib/mysql - ./docker/mysql/init.d:/docker-entrypoint-initdb.d:ro - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-devroot}"] interval: 5s timeout: 3s retries: 12 networks: - bot-network api-server: depends_on: mysql: condition: service_healthy telegram-bot: profiles: ["bots"] transfer-bot: profiles: ["bots"] volumes: mysql-data: name: ${CM_DEPLOY_NAME:-cm}-mysql-data ``` The mysql service joins `bot-network`, which is defined in the base `docker-compose.yml`. The override does not redefine `networks:` because compose merges that section from the base file. - [ ] **Step 4: Validate the override file parses with the base** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ .venv/bin/python -c " import yaml with open('docker-compose.override.yml') as f: cfg = yaml.safe_load(f) services = list(cfg.get('services', {}).keys()) print('services in override:', services) assert 'mysql' in services, 'mysql service missing' mysql = cfg['services']['mysql'] assert mysql['ports'] == ['127.0.0.1:3306:3306'], f'wrong port binding: {mysql[\"ports\"]}' assert mysql['image'] == 'mysql:8.0' assert cfg['services']['telegram-bot']['profiles'] == ['bots'] assert cfg['services']['transfer-bot']['profiles'] == ['bots'] assert cfg['services']['api-server']['depends_on']['mysql']['condition'] == 'service_healthy' print('override structure OK') " ``` Expected: `services in override: [...]` listing 5 services (`telegram-bot`, `api-server`, `web-view`, `transfer-bot`, `mysql`); `override structure OK`. - [ ] **Step 5: Verify base compose file still renders alone (Portainer parity)** The Portainer flow uses `docker-compose.yml` only, no override. Make sure base hasn't been touched: ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ git diff --stat docker-compose.yml ``` Expected: empty output (no changes to base file). - [ ] **Step 6: Commit** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ git add docker-compose.override.yml docker/mysql/init.d/01-schema.sql docker/mysql/init.d/02-seed.sql && \ git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \ commit -m "feat(compose): add dev mysql service, init scripts, profile-gate bots" ``` --- ## Task 9: `scripts/dev.sh` lifecycle wrapper **Files:** - Create: `scripts/dev.sh` - [ ] **Step 1: Create the script** Create `scripts/dev.sh`: ```bash #!/usr/bin/env bash # Lifecycle commands for the local dev stack (mysql + api-server + web-view). # Bots (telegram-bot, transfer-bot) are gated behind a compose 'bots' profile # and do not start with 'up'. Status is used by scripts/bot_cli.sh. set -euo pipefail usage() { cat <<'EOF' Lifecycle for the dev stack. Usage: scripts/dev.sh up Start mysql + api-server + web-view in the background. scripts/dev.sh down Stop the stack. mysql volume kept (DB persists). scripts/dev.sh reset-db Stop the stack AND drop the mysql volume; then start. scripts/dev.sh logs Tail logs from the running stack. scripts/dev.sh status Print 'OK' if mysql is running, else exit 1. Environment: NO_SUDO=1 Skip the 'sudo' prefix (use if your user is in the docker group). EOF } ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "${ROOT_DIR}" SUDO="sudo" [[ "${NO_SUDO:-0}" == "1" ]] && SUDO="" # shellcheck disable=SC2206 COMPOSE=(${SUDO} docker compose -f docker-compose.yml -f docker-compose.override.yml) if [[ ! -f .env ]]; then echo "ERROR: .env not found at repo root. Run: cp envs/dev/.env.example .env (then edit)." >&2 exit 2 fi case "${1:-}" in up) "${COMPOSE[@]}" up -d --build mysql api-server web-view "${COMPOSE[@]}" ps ;; down) "${COMPOSE[@]}" down ;; reset-db) "${COMPOSE[@]}" down --volumes "${COMPOSE[@]}" up -d --build mysql api-server web-view ;; logs) "${COMPOSE[@]}" logs -f mysql api-server web-view ;; status) if "${COMPOSE[@]}" ps --status running --services 2>/dev/null | grep -q '^mysql$'; then echo OK else echo "ERROR: dev stack not running. Run 'scripts/dev.sh up' first." >&2 exit 1 fi ;; -h|--help|help) usage exit 0 ;; "") usage >&2 exit 1 ;; *) echo "unknown command: $1" >&2 usage >&2 exit 1 ;; esac ``` - [ ] **Step 2: Make it executable** ```bash chmod +x /home/yiekheng/projects/cm_bot_v2/scripts/dev.sh ``` - [ ] **Step 3: Bash syntax-check** ```bash bash -n /home/yiekheng/projects/cm_bot_v2/scripts/dev.sh && echo "syntax OK" ``` Expected: `syntax OK`. - [ ] **Step 4: Help-text smoke test** ```bash /home/yiekheng/projects/cm_bot_v2/scripts/dev.sh --help | head -8 ``` Expected: usage block with the five subcommands listed. - [ ] **Step 5: Commit** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ git add scripts/dev.sh && \ git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \ commit -m "feat(scripts): add dev.sh lifecycle wrapper" ``` --- ## Task 10: `scripts/bot_cli.sh` env-loading wrapper **Files:** - Create: `scripts/bot_cli.sh` - [ ] **Step 1: Create the script** Create `scripts/bot_cli.sh`: ```bash #!/usr/bin/env bash # Run the bot CLI in the local venv. With no args, drops into the TUI menu. # Requires: dev stack up (run scripts/dev.sh up first), .venv with deps. set -euo pipefail usage() { cat <<'EOF' Run the bot CLI (app.bot_cli) in the local venv. Usage: scripts/bot_cli.sh Drop into the TUI menu. scripts/bot_cli.sh [args] One-shot subcommand. Try --help. Examples: scripts/bot_cli.sh register scripts/bot_cli.sh credit 13c1234 abc12345 scripts/bot_cli.sh monitor-once --target 5 Environment: NO_SUDO=1 Skip 'sudo' when checking 'dev.sh status'. PYTHON_BIN Override the python interpreter (default: .venv/bin/python). EOF } if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then usage exit 0 fi ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "${ROOT_DIR}" # E2: bail if the dev stack is not running. if ! NO_SUDO="${NO_SUDO:-0}" bash scripts/dev.sh status >/dev/null 2>&1; then echo "ERROR: dev stack not running. Run 'scripts/dev.sh up' first." >&2 exit 2 fi if [[ ! -f .env ]]; then echo "ERROR: .env not found. cp envs/dev/.env.example .env (then edit)." >&2 exit 2 fi # Load .env into the environment (export everything between 'set -a' and 'set +a'). set -a # shellcheck disable=SC1091 source .env set +a # Override DB host/port for the local CLI: docker mysql is published on # 127.0.0.1:3306, even though api-server in-network reaches it as mysql:3306. export DB_HOST=127.0.0.1 export DB_PORT=3306 PYTHON_BIN="${PYTHON_BIN:-${ROOT_DIR}/.venv/bin/python}" if [[ ! -x "${PYTHON_BIN}" ]]; then echo "ERROR: ${PYTHON_BIN} not found." >&2 echo "Create the venv: python3 -m venv .venv && .venv/bin/pip install -r requirements.txt" >&2 exit 2 fi exec "${PYTHON_BIN}" -m app.bot_cli "$@" ``` - [ ] **Step 2: Make it executable** ```bash chmod +x /home/yiekheng/projects/cm_bot_v2/scripts/bot_cli.sh ``` - [ ] **Step 3: Bash syntax-check** ```bash bash -n /home/yiekheng/projects/cm_bot_v2/scripts/bot_cli.sh && echo "syntax OK" ``` Expected: `syntax OK`. - [ ] **Step 4: Help-text smoke test** ```bash /home/yiekheng/projects/cm_bot_v2/scripts/bot_cli.sh --help | head -10 ``` Expected: usage block listing examples. - [ ] **Step 5: E2 enforcement test (no .env, stack not up)** If `.env` and the dev stack are both absent, the script must exit 2 with the dev-stack error first: ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ [[ ! -f .env ]] && bash scripts/bot_cli.sh register; echo "exit $?" ``` Expected: `ERROR: dev stack not running. Run 'scripts/dev.sh up' first.` followed by `exit 2`. (If `.env` already exists, this verifies only the status check; either error message is acceptable.) - [ ] **Step 6: Commit** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ git add scripts/bot_cli.sh && \ git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \ commit -m "feat(scripts): add bot_cli.sh wrapper with E2 strict-mode check" ``` --- ## Task 11: `envs/dev/.env.example` and `.gitignore` update **Files:** - Create: `envs/dev/.env.example` - Modify: `.gitignore` - [ ] **Step 1: Create the env example** Create `envs/dev/.env.example`: ``` # Local development environment variables. # Copy this file to repo-root .env and fill in real cm99.net agent credentials # only if you want CLI ops (register/set-pin/credit/transfer/monitor-once) # to actually call cm99.net. The DB is local-only via docker-compose.override.yml. # === Runtime === CM_DEBUG=true # === Deployment Identity === CM_DEPLOY_NAME=dev-cm CM_WEB_HOST_PORT=8000 # === Docker Registry / Build === CM_IMAGE_PREFIX=local DOCKER_IMAGE_TAG=dev # === Telegram (unused in A2 — telegram-bot is gated by 'bots' profile) === TELEGRAM_BOT_TOKEN=fill-only-if-running-bots-profile TELEGRAM_ALERT_CHAT_ID= TELEGRAM_ALERT_BOT_TOKEN= # === Database (dev mysql in docker; bot_cli.sh overrides DB_HOST=127.0.0.1) === DB_HOST=mysql DB_USER=cm DB_PASSWORD=devpassword DB_NAME=cm DB_PORT=3306 DB_CONNECTION_TIMEOUT=8 DB_CONNECT_RETRIES=5 DB_CONNECT_RETRY_DELAY=2 MYSQL_ROOT_PASSWORD=devroot # === Bot Config === # CM_PREFIX_PATTERN=13c MUST match the seed in docker/mysql/init.d/02-seed.sql. CM_PREFIX_PATTERN=13c CM_AGENT_ID=fill-with-real-agent-id-to-test-cm99-calls CM_AGENT_PASSWORD=fill-with-real-agent-password-to-test-cm99-calls CM_SECURITY_PIN=000000 CM_BOT_BASE_URL=https://cm99.net ``` - [ ] **Step 2: Add `envs/dev/.env` to `.gitignore`** The current `.gitignore` is: ``` __pycache__ .DS_Store *.html logs ``` Append `envs/dev/.env` so an operator's filled-in copy can never be committed: ``` __pycache__ .DS_Store *.html logs envs/dev/.env ``` - [ ] **Step 3: Verify `.env` itself stays untracked (existing behavior)** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ git ls-files .env ``` Expected: empty output (root `.env` is not — and was not — tracked). - [ ] **Step 4: Verify `envs/rex/.env` and `envs/siong/.env` remain tracked (R2 will rotate later)** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ git ls-files envs/ ``` Expected: lists `envs/rex/.env` and `envs/siong/.env`. We are intentionally NOT changing their tracking — that belongs to sub-project R2. - [ ] **Step 5: Commit** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ git add envs/dev/.env.example .gitignore && \ git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \ commit -m "feat(envs): add dev .env.example and gitignore the filled-in copy" ``` --- ## Task 12: Update `AGENTS.md` for the dev tier **Files:** - Modify: `AGENTS.md` - [ ] **Step 1: Replace the "Reproduce From Scratch" SQL block with a pointer to the new flow** In `AGENTS.md`, find the existing section header `## Reproduce From Scratch (Clean Machine)` and the SQL example beneath it (lines 14-65 in the current file). Replace the steps starting with "Prepare MySQL schema" through "Configure DB connection values" with a pointer to the dev tier: Find: ``` 4. Prepare MySQL schema (minimum required): ``` Replace from that line through the line ` - For reliable reproduction, add `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_PORT` to service `environment:` in compose files (at minimum `api-server`, `telegram-bot`, `transfer-bot`).` (the end of step 6) with: ``` 4. Prepare the local dev DB and stack: ```bash cp envs/dev/.env.example .env # Edit .env if you want the bot CLI to actually call cm99.net # (CM_AGENT_ID / CM_AGENT_PASSWORD / CM_SECURITY_PIN). bash scripts/dev.sh up ``` This brings up `mysql` (port `127.0.0.1:3306`), `api-server`, and `web-view`. The schema and a 4-row seed are applied automatically from `docker/mysql/init.d/`. Bots (`telegram-bot`, `transfer-bot`) are gated behind a compose `bots` profile and do not start in dev. ``` - [ ] **Step 2: Add a new "Dev Tier" subsection above "Build, Test, and Development Commands"** Insert immediately above `## Build, Test, and Development Commands`: ``` ## Dev Tier (Local Development) - Lifecycle: `bash scripts/dev.sh {up,down,reset-db,logs,status}`. - Bot CLI: `bash scripts/bot_cli.sh` (drops into the TUI menu) or `bash scripts/bot_cli.sh ` (e.g., `register`, `set-pin `, `monitor-once --target 5`). The CLI runs in your local `.venv` and connects to the dev mysql at `127.0.0.1:3306`. - The auto-create monitor does NOT run in dev (it lives in `telegram-bot`, which is gated by the `bots` profile). Use `bot_cli.sh monitor-once` to exercise the same code path manually. - Tests: `.venv/bin/python -m unittest tests.test_debug_enabled tests.test_bot_cli -v`. ``` - [ ] **Step 3: Refresh the verification checklist** In the existing `## Verification Checklist`, replace the existing line: ``` - Web UI loads: open `http://localhost:8001` ``` with: ``` - Web UI loads: open `http://localhost:8000` (dev) or `http://localhost:8001` (rex prod) / `http://localhost:8005` (siong prod). ``` - [ ] **Step 4: Commit** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ git add AGENTS.md && \ git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \ commit -m "docs(agents): document the local-as-dev tier and bot CLI" ``` --- ## Task 13: Integration verification This task corresponds to the verification scenarios in the spec. No commits — these are smoke checks. If anything fails, debug before declaring done. **Files:** none modified. **Prerequisites:** docker compose v2 plugin installed on the host. The plan author's environment did not have it, so this task's commands must be run by an engineer on the deploy/dev host with docker compose available. - [ ] **Step 1: Cold start** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ cp envs/dev/.env.example .env && \ .venv/bin/pip install -r requirements.txt && \ bash scripts/dev.sh up ``` Wait ~15s for the mysql healthcheck. Expected: `docker compose ps` shows `mysql`, `api-server`, `web-view` all `running`. Verify status: ```bash bash scripts/dev.sh status ``` Expected: `OK`. - [ ] **Step 2: Schema and seed are present** ```bash mysql -h 127.0.0.1 -u cm -pdevpassword cm -e "SELECT username FROM acc ORDER BY username" ``` Expected: four rows: `13c1000`, `13c1001`, `13c1002`, `13c1003`. (If the local mysql client is missing, equivalent: `docker exec dev-cm-mysql mysql -u cm -pdevpassword cm -e "SELECT username FROM acc ORDER BY username"`.) - [ ] **Step 3: API and web-view smoke** ```bash curl -sf http://localhost:3000/acc/ | head -c 200; echo curl -sf "http://localhost:${CM_WEB_HOST_PORT:-8000}/api/acc/" | head -c 200; echo ``` Expected: both return JSON arrays containing the four seed usernames. If either fails with a 5xx, check `bash scripts/dev.sh logs` for the underlying error. - [ ] **Step 4: CLI no-args drops into the TUI menu** ```bash echo "q" | bash scripts/bot_cli.sh ``` Expected: prints `CM Bot CLI — interactive ...` and the menu, then exits cleanly on `q`. - [ ] **Step 5: CLI subcommand parity (no cm99.net call)** The `monitor-once --target 0` op never calls cm99.net (target is already met by the four seed rows): ```bash bash scripts/bot_cli.sh monitor-once --target 0 ``` Expected: `Available accounts: 4 (target: 0)` followed by `Already at target; nothing to do.` - [ ] **Step 6: E2 strict-mode** ```bash bash scripts/dev.sh down && \ bash scripts/bot_cli.sh register; echo "exit code: $?" ``` Expected: `ERROR: dev stack not running. Run 'scripts/dev.sh up' first.` followed by `exit code: 2`. - [ ] **Step 7: Reset-db reapplies seed** ```bash bash scripts/dev.sh up # Insert a row that should be wiped on reset. mysql -h 127.0.0.1 -u cm -pdevpassword cm -e "INSERT INTO acc (username, password) VALUES ('13c9999', 'temp')" mysql -h 127.0.0.1 -u cm -pdevpassword cm -e "SELECT COUNT(*) AS n FROM acc" # n should be 5 bash scripts/dev.sh reset-db sleep 15 mysql -h 127.0.0.1 -u cm -pdevpassword cm -e "SELECT COUNT(*) AS n FROM acc" # n should be 4 (only the seed) ``` Expected: count goes 5 → 4. The reset wiped the manually-inserted row and re-applied the seed from `docker/mysql/init.d/`. - [ ] **Step 8: Prod compose untouched (Portainer parity)** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ docker compose -f docker-compose.yml config | grep -E "mysql|profiles|127.0.0.1:3306" || echo "no dev artifacts in base config" ``` Expected: `no dev artifacts in base config`. The dev mysql, profile gates, and `127.0.0.1:3306` binding all live in the override only. - [ ] **Step 9: Full unit test suite still passes** ```bash cd /home/yiekheng/projects/cm_bot_v2 && \ .venv/bin/python -m unittest tests.test_debug_enabled tests.test_bot_cli -v 2>&1 | tail -8 ``` Expected: `OK`. Combined: 2 tests for debug-mode parity + 27 tests for bot_cli (parser, six commands, TUI). --- ## Spec Coverage Check (self-review) | Spec requirement | Task | |---|---| | `mysql:8.0` service in `docker-compose.override.yml`, healthcheck, `127.0.0.1:3306` only | Task 8 | | `api-server` waits for mysql healthcheck | Task 8 | | `bots` profile gates `telegram-bot` + `transfer-bot` in dev | Task 8 | | `docker/mysql/init.d/01-schema.sql` (acc + user, utf8mb4) | Task 8 | | `docker/mysql/init.d/02-seed.sql` (4 rows matching `13c`) | Task 8 | | `app/bot_cli.py` argparse + dispatch | Tasks 1–6 | | `cmd_register` (Telegram /1) | Task 2 | | `cmd_set_pin` with local name resolution (workaround for HAL bug) | Task 3 | | `cmd_insert_user` (Telegram /3) | Task 4 | | `cmd_credit`, `cmd_transfer` | Task 5 | | `cmd_monitor_once` with target | Task 6 | | `cmd_interactive` TUI with `1`/`2`/`3` aliases, q/eof exit | Task 7 | | `scripts/dev.sh` lifecycle (up/down/reset-db/logs/status) | Task 9 | | `scripts/bot_cli.sh` env-loading wrapper, E2 strict mode, DB_HOST override | Task 10 | | `envs/dev/.env.example` committed | Task 11 | | `envs/dev/.env` gitignored | Task 11 | | `AGENTS.md` dev-tier docs | Task 12 | | Verification: cold start, schema, API, web, CLI no-args, E2, reset, base parity, unit tests | Task 13 | No gaps. No placeholders, no "implement later", no "similar to Task N" without code repeated. Function and class names are consistent across tasks (`build_parser`, `cmd_register`, `cmd_set_pin`, `cmd_insert_user`, `cmd_credit`, `cmd_transfer`, `cmd_monitor_once`, `cmd_interactive`, `_TUI_ALIASES`).