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) <noreply@anthropic.com>
1559 lines
51 KiB
Markdown
1559 lines
51 KiB
Markdown
# 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 <whatsapp_link> Set security PIN")
|
||
print(" 3 <f_username> <t_username> Insert into user table")
|
||
print(" credit <username> <password> Read account credit")
|
||
print(" transfer <fu> <fp> <tu> <tp> 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 <subcommand> [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 <subcommand>` (e.g., `register`, `set-pin <link>`,
|
||
`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`).
|