From c6742d1537cd60bea74fa0d304b6d60a7a6fe767 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sat, 2 May 2026 16:57:17 +0800 Subject: [PATCH] Add implementation plan for local-as-dev tier 13 bite-sized tasks: 7 TDD tasks for app/bot_cli.py (parser, six subcommands, TUI), then mysql + init scripts, dev.sh + bot_cli.sh, envs/dev/.env.example, AGENTS.md, and integration verification. Uses unittest stdlib + unittest.mock; no new deps. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-02-local-as-dev.md | 1558 +++++++++++++++++ 1 file changed, 1558 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-02-local-as-dev.md diff --git a/docs/superpowers/plans/2026-05-02-local-as-dev.md b/docs/superpowers/plans/2026-05-02-local-as-dev.md new file mode 100644 index 0000000..9b27bf3 --- /dev/null +++ b/docs/superpowers/plans/2026-05-02-local-as-dev.md @@ -0,0 +1,1558 @@ +# 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`).