Skip to content

Latest commit

 

History

History
731 lines (551 loc) · 29.3 KB

File metadata and controls

731 lines (551 loc) · 29.3 KB

About the bash.social project

This document explains in detail how the project is organized: the architecture, every folder and file, what each file is responsible for, and its main functions and classes. It is written for developers who want to understand the codebase or run the project on their own. If anything is unclear, start here, then look at README.md (quick start) and .env.example (configuration).


1. What this project is

bash.social is a browser-based "messenger terminal". Instead of buttons and a mouse there is a command line: the user types /register, /msg, /group create and so on, and the server replies with terminal-style lines. Messages arrive in real time over WebSocket.

Key features:

  • Two-step login: /login <nick> then /password <password>.
  • Friend requests with confirmation (/adduser then /accept / /reject).
  • Private and group chats with unread counters and live notifications.
  • Messages are encrypted at rest in the database (Fernet).
  • Configurable terminal themes (17 themes with CRT effects).
  • Real security: Argon2, rate limits, security headers, WebSocket origin checks, input sanitization.
  • No telemetry and no external requests; privacy is a priority.

Stack: Python 3.12 · FastAPI · SQLAlchemy 2 (async) · PostgreSQL · Redis · Docker.


2. Architecture at a glance

The project is a monolith in a single Docker image (a FastAPI application). Data is split between PostgreSQL and Redis by storage type:

  • PostgreSQL holds durable data: users, friendships, groups, members, messages.
  • Redis holds short-lived data: sessions, rate limits, the currently open chat, command history, presence (who is online), pending confirmations.

General data flow:

Browser (static/)
  │  /api/command (HTTP) or /ws (WebSocket)
  ▼
app/api/  (http.py, ws.py) — entry points, builds the TermContext
  │  dispatch(ctx, raw)
  ▼
app/cli/dispatcher.py — routing: command / plain text / confirmation
  │  handler call
  ▼
app/cli/commands/*.py — command handlers (dialogue logic only)
  │
  ├─► app/services/*.py — business logic and DB access (framework-free)
  ├─► app/security/*.py — crypto, sanitization, headers
  └─► app/utils/*.py    — small helpers (output lines, validation, rate limit)
  │
  ▼
response: a dict {"lines": [...], "prompt", "chat", ...}
  │
  ▼
The browser renders the lines via terminal.js (textContent — no XSS vector)

The logic is split into layers with explicit boundaries:

  • The command layer (app/cli/commands) only knows about the "dialogue": collects arguments, calls a service, returns lines to display.
  • The service layer (app/services) is independent of FastAPI and the CLI: it takes db (a SQLAlchemy session), redis and manager as explicit arguments and does the actual work with the data.
  • The security layer (app/security) isolates everything related to cryptography, HTTP headers and input validation.
  • The utilities (app/utils) are small reusable helpers.

3. Project structure

bash.social/
├── app/                        # the whole application
│   ├── api/                    # HTTP and WebSocket entry points
│   │   ├── http.py             # REST: /api/me, /api/command, /api/commands
│   │   └── ws.py               # WebSocket /ws: real time
│   ├── cli/                    # the server-side "command line"
│   │   ├── commands/           # command handlers (registered via decorator)
│   │   ├── confirm.py          # action confirmation mechanism (/y /n)
│   │   ├── context.py          # TermContext and the prompt
│   │   ├── dispatcher.py       # the main router for every typed line
│   │   ├── helpers.py          # usage_hint, convert_cli
│   │   ├── parser.py           # splits a line into a command and arguments
│   │   ├── registry.py         # command registry (@command)
│   │   └── theme_meta.py       # terminal theme catalogue
│   ├── models/                 # SQLAlchemy models (tables)
│   ├── security/               # security: crypto, headers, sanitization, WS
│   ├── services/               # business logic (DB and Redis access)
│   ├── static/                 # frontend: HTML, CSS, JS
│   ├── tests/                  # automated tests
│   ├── config.py               # settings (BS_ prefix)
│   ├── database.py             # SQLAlchemy engine and table creation
│   ├── exceptions.py           # the CliError domain error
│   ├── main.py                 # FastAPI entry point
│   ├── realtime.py             # WebSocket connection manager
│   └── redis_client.py         # async Redis client
├── .env.example                # configuration template (all BS_ variables)
├── Dockerfile                  # image build
├── docker-compose.yml          # the stack: app + db + redis
├── Makefile                    # commands: compose, run, test, logs, down
├── pyproject.toml              # dependencies and pytest config
└── README.md                   # quick start and the command list

4. Entry point and the framework

app/main.py — building the application

The file uvicorn launches (uvicorn app.main:app).

  • lifespan — an async context: on startup it calls init_db() (table creation), on shutdown it disposes the DB engine.
  • app = FastAPI(...) — the application instance.
  • app.add_middleware(SecurityHeadersMiddleware) — global security headers.
  • app.mount("/static", ...) — serves static assets (CSS/JS).
  • app.include_router(http_router) and app.include_router(ws_router).
  • GET / — returns index.html (the terminal itself).

app/config.py — settings

The Settings class (pydantic-settings). It reads environment variables with the BS_ prefix and the .env file. get_settings() with lru_cache returns a single settings object for the whole application.

What can be configured (see section 10 for details):

  • connections: BS_DATABASE_URL, BS_REDIS_URL;
  • sessions: BS_SESSION_TTL, BS_MAX_SESSIONS;
  • rate limits: BS_REGISTER_*, BS_LOGIN_*, BS_PASSWORD_*, BS_MSG_*, BS_CMD_*, BS_WS_*;
  • input limits: BS_MAX_INPUT, BS_MAX_MSG, BS_MAX_WS_MESSAGE, BS_PAGE_SIZE, BS_HIST_LEN;
  • language, theme, registration: BS_DEFAULT_LANG, BS_DEFAULT_THEME, BS_REGISTER_ENABLED;
  • name and password bounds: BS_NICK_MIN/BS_NICK_MAX, BS_PASSWORD_MIN/BS_PASSWORD_MAX, BS_GROUP_NAME_MAX;
  • security: BS_ALLOWED_ORIGINS, BS_ENCRYPTION_KEY, BS_CONFIRM_TTL.

app/database.py — the database

  • Base — the SQLAlchemy declarative base every model inherits from.
  • engine — the async engine (asyncpg for PostgreSQL, aiosqlite for tests).
  • session_factory — the async session factory.
  • init_db()Base.metadata.create_all on startup. It currently replaces migrations; Alembic is planned.

app/redis_client.py — the Redis client

redis_client — an async Redis client (redis.asyncio) with decode_responses=True. Used by all services for sessions, rate limits, presence and so on.

app/realtime.py — real time

The ConnectionManager class maps user_id → set of WebSockets:

  • connect(uid, ws) / disconnect(uid, ws) — attach and detach a socket;
  • is_online(uid) — presence check;
  • send_to_user(uid, payload) — push to one user on all their sockets;
  • send_to_members(ids, skip_id, payload) — push to a group except the sender.

manager is a process-wide singleton. Note: this is an in-memory manager, it only works within a single uvicorn process (for multiple processes you would need Redis Pub/Sub).

app/exceptions.py — domain errors

CliError(*items) — the error of the service and command layers. items is a mix of plain strings and ready-made output line dicts. The dispatcher turns it into red lines for the user; internals never leak out.


5. The API layer (app/api/)

app/api/http.py — REST routes

  • GET /api/me — session restore: given a token (Authorization: Bearer or ?token=) it returns the profile, prompt, open chat and theme.
  • GET /api/commands — the list of all commands (name, aliases, usage, help) for the client's hint bar and tab completion.
  • POST /api/command — a fallback transport without WebSocket: body {"input": "...", "token": "..."}; builds a TermContext, calls dispatch() and returns the payload. Errors are logged (logger.exception) without showing internals to the user.

Helpers here: _ip_of(request) and _bearer_token(request).

app/api/ws.py — the WebSocket channel

/ws — the main real-time channel. What happens to each socket:

  1. Origin check (origin_is_allowed) and a connection rate limit.
  2. A read loop. There are two message types:
    • {"type": "auth", "token": ...} — bind the socket to a user (registered in manager, added to presence:online);
    • {"type": "cmd", "input": ..., "token": ...} — a command: dispatch() is called, the response goes via send_json.
  3. The raw message size is checked before JSON parsing; the limit is settings.max_ws_message.
  4. On socket close the user is detached and removed from presence.

Helpers: _bind_socket (auth + presence), _drop_presence, _client_ip.


6. The command line (app/cli/)

app/cli/parser.py — line parsing

  • split_command(raw) — returns (name, rest). A line starting with / is a command, anything else is "plain text" (a chat message).
  • split_args(rest) — splits into arguments via shlex.split (respects quotes).
  • strip_quotes(text) — removes one wrapping pair of quotes.
  • parse_page(token) — safe page-number parsing.

app/cli/registry.py — the command registry

  • Command — a dataclass: name, handler, usage, help, group, aliases, auth, public, self_help.
  • COMMANDS — a dict name → Command (including aliases).
  • REGISTERED — a list in registration order (for /help and /api/commands).
  • command(...) — the decorator that registers a handler. This is the main extension mechanism: to add a command, just wrap a function.

app/cli/context.py — request context

  • build_prompt(nick, chat) — the prompt string, e.g. alex@bash.social:team$ or guest@bash.social:~$.
  • load_chat(redis, uid) / save_chat(redis, uid, chat) — which chat the user has open (stored in Redis under chat:user:{uid}).
  • TermContext — a dataclass alive for the duration of one command: redis, db_factory, manager, ip, token, user, chat, cmd, theme, lang, session_payload, logout. The open_db() method is shorthand for getting a DB session.

app/cli/dispatcher.py — the main router

dispatch(ctx, raw) is the entry point for any typed line:

  1. Restores the user from the token (_load_user) and loads the open chat.
  2. Writes the command to the server history (only "historable" commands — private /password, /msg, /contact and plain text are never recorded).
  3. Applies the command rate limit.
  4. Routing:
    • plain text → send_text (a message in the active chat);
    • /y and /n_handle_confirm (action confirmation);
    • a command from COMMANDS → its handler;
    • an unknown command → a polite error.
  5. Wraps the result into a payload: output lines, prompt, chat, theme, language, session and a logout flag.

Also here: _help_of (generic /command help), _convert_error (CliError → lines) and out() (payload assembly for the client).

app/cli/confirm.py — action confirmation

A mechanism for dangerous actions (for example /delete_account):

  • request(redis, uid, action, **extra) — stores the pending action in Redis under confirm:{uid} with a confirm_ttl TTL.
  • peek(redis, uid) — check whether an action is pending.
  • resolve(redis, uid) — fetch and delete it (the action runs only once).

app/cli/helpers.py — small helpers

  • usage_hint(ctx) — the standard "missing arguments" reply with usage.
  • convert_cli(exc) — turns a CliError into a list of output lines.

app/cli/theme_meta.py — the theme catalogue

THEMES is a list of (name, title, description) tuples for 17 themes (kali, macos, green, amber, crt, dos, matrix, retro, hacker, paper, solarized, phantom, vscode, vim, xcode, tokyonight, dracula). theme_exists(name) and theme_desc(name) check and describe a theme.

app/cli/commands/ — command handlers

Each file registers its commands with the @command(...) decorator. The shared contract is async def handler(ctx, args) -> list (a list of line dicts). If a command requires auth by default (auth=True), the dispatcher checks ctx.user itself.

auth.py — account:

  • cmd_register/register <nick> <password> (auto-login).
  • cmd_login / cmd_password — two-step login.
  • cmd_logout — end the session (blocked inside a chat).
  • cmd_whoami — profile: friends, unread, theme, policies.
  • cmd_whois — the public card of another user.

friends.py — search and friends:

  • cmd_adduser — send a friend request + a live notification to the target.
  • cmd_accept — accept a request + a notification to the sender.
  • cmd_reject (alias deny) — decline a request + a notification.
  • cmd_requests — incoming and outgoing requests.
  • cmd_deluser — remove a friend / cancel your own request.
  • cmd_find — search by part of a nick.
  • cmd_ls — universal lists: user, my_users (friends), groups, unread. Helpers: ls_users, ls_friends, ls_groups, ls_unread.

chat.py — messages:

  • send_text(ctx, text) — the common sender for plain text and /msg (limit checks, sanitization, encryption, push to the receiver).
  • cmd_contact (alias open) — open a private chat and show the history.
  • cmd_msg — send a message in the current chat.
  • cmd_leave — leave a chat + notify the peer/members.

groups.py — groups:

  • cmd_group — subcommands: help, create, join, invite, leave, kick, rename, members, delete; a group code opens it directly.
  • cmd_mk_group — shorthand for creating.
  • Internals: group_create, group_enter, group_invite, group_leave, group_delete, group_kick, group_rename, group_members_list, group_help_lines.

settings.py — settings:

  • cmd_settings — show settings; subcommands set, security, help.
  • show_settings — output of the current values.
  • settings_help — the reference table.

themes.py — themes:

  • cmd_theme — current theme, help/ls (the list), switching themes.

account.py — security and language:

  • cmd_delete_account — a red warning + confirmation via /y or /n.
  • cmd_security (alias sec) — encryption, password, sessions, language.
  • cmd_language (alias lang) — switch the UI language (ru/en).

system.py — system commands:

  • cmd_help — the command list grouped by section, or help for one command.
  • cmd_clear (alias cls) — clear the screen.
  • cmd_history — recent commands from the server history.
  • GROUP_ORDER — the section order in /help.

__init__.py — imports every command submodule (that is the only way the decorators fire and commands reach the registry).


7. Services (app/services/)

Services are unaware of FastAPI and the CLI — they take db, redis and manager as explicit arguments. This keeps them easy to test.

auth_service.py — registration and login

  • register_user(db, redis, nick, password, ip) — validation, rate limit, nick uniqueness, Argon2 hashing, session creation.
  • start_login(redis, ip, raw_nick) — step one: stores the nick in Redis (login:{ip}) with a TTL and applies a rate limit.
  • finish_login(db, redis, ip, password) — step two: password verification, session creation, removal of the pending login.
  • _reject_nick_in_password — the nick must not appear in the password.

session_service.py — sessions in Redis

Keys: sess:{token}user_id and user_sess:{uid} → a list of tokens.

  • create_session(redis, user) — a new random token, a TTL write, an index entry and trimming of excess sessions (_trim_sessions).
  • _trim_sessions — keeps at most max_sessions sessions; the oldest is evicted so a stolen token cannot live forever.
  • destroy_session(redis, token) — removes the token and its index slot.
  • session_user(db, redis, token) — restore the user from a token.

user_service.py — users

  • get_user_by_nick(db, nick) — exact nick lookup.
  • list_users(db, page) / search_users(db, term, page) — listings and search with pagination.
  • pages_for(total) — the number of pages.

friendship_service.py — friendship and requests

The friendship model is one row per pair. Accepted links are symmetric and stored canonically (min, max); requests are stored as (sender, target) so the direction is known. Statuses: pending (request) and accepted (friends).

  • pair_row(db, a, b) — the link row in either direction.
  • are_friends(db, a, b) — true only if the status is accepted.
  • send_request(db, me, nick) — create a request (with checks: yourself, already friends, a request already exists).
  • accept_request(db, me, nick) / reject_request(db, me, nick) — accept or decline an incoming request.
  • remove_friend(db, me, nick) — remove a friend or cancel your own request.
  • friend_ids_of, friends_count, list_friends — friends with the status accepted only.
  • incoming_requests, outgoing_requests, pending_incoming_count — requests for /requests.

chat_service.py — messages

  • send_dm(db, manager, sender, receiver, text) — a private message: receiver policy check, sanitization, encryption, persistence, live push.
  • send_group_message(db, manager, sender, group_id, text) — a group message plus a push to all members except the sender.
  • message_text(msg) — decrypts text for display.
  • fetch_dm_history / fetch_group_history — history (last N).
  • mark_dm_read / mark_group_read — clearing unread markers.
  • unread_per_sender, unread_dm_total, group_member_ids — counters.
  • Helpers: peer_key(a, b) — the canonical dm key "min:max", fmt_clock(dt) / fmt_date(dt) — time formatting.

group_service.py — groups

  • create_group — creation with a unique slug (the join code).
  • join_group — join by code.
  • invite_group — only the creator may invite, respecting allow_invites.
  • delete_group — destroys the room together with messages and members.
  • leave_group, kick_group, rename_group — the other operations.
  • role_of — the member role (owner / admin / member).
  • group_by_slug / group_by_id — lookup.
  • group_members, group_member_count, my_groups — listings.
  • unread_group / mark_group_read — unread via the last_read_id cursor.

settings_service.py — user settings

  • get_settings(user) — a dict of the current values.
  • set_setting(db, user, key, value) — a validated update: allow_messages (everyone/friends/nobody), allow_invites (on/off), lang (ru/en). An unknown key raises CliError.

account_service.py — account deletion

  • delete_account(db, redis, user) — complete removal: private messages in both directions, own groups (with messages and members), membership in other people's groups, friendships and the user row itself; in Redis — chat state, history, confirmations, presence and every session.

8. Data models (app/models/)

Models inherit from app.database.Base. Importing all models in models/__init__.py ensures create_all sees them all.

user.pyUser (table users)

  • nick — a unique nick (5–15 chars).
  • password_hash — an Argon2 hash (never leaves the service layer).
  • theme — the terminal theme name.
  • lang — the UI language (ru/en).
  • allow_messages — the dm policy: everyone / friends / nobody.
  • allow_invites — whether the user can be invited to groups.
  • created_at — registration date.

friendship.pyFriendship (table friendships)

  • user_id / friend_id — the user pair.
  • statuspending or accepted.
  • Pair uniqueness (uq_friendship_pair).

group.pyGroup and GroupMember

Group (table groups):

  • name — the group name (up to 40 chars).
  • slug — the unique invite code (6 base36 chars).
  • owner_id — the creator.

GroupMember (table group_members):

  • group_id / user_id — a member.
  • roleowner / admin / member.
  • last_read_id — the read cursor for the unread counter.

message.pyMessage (table messages)

  • kinddm or group.
  • sender_id — the sender.
  • peer_key — the canonical dm key "min:max" (for dm).
  • receiver_id — the receiver (for dm).
  • group_id — the group (for group).
  • text — the encrypted message text.
  • created_at — the timestamp.
  • read_at — the read marker (NULL = unread).
  • Indexes: ix_msg_peer_time, ix_msg_group_time.

9. Security (app/security/)

crypto.py — cryptography

  • hash_password / verify_password — Argon2 (time_cost=3, memory_cost=65536, parallelism=4; never lower these).
  • new_session_token — a random session token (secrets.token_urlsafe(32)).
  • new_slug — a group code (base36).
  • encrypt_text / decrypt_text — Fernet (AES-128-CBC + HMAC). The key is a SHA-256 digest of BS_ENCRYPTION_KEY; if no key is set, a dev fallback is used. decrypt_text returns the string as-is on failure so legacy unencrypted rows do not turn into garbage.

middleware.py — HTTP headers

SecurityHeadersMiddleware (Starlette) adds to every response: CSP (strict 'self'), X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, COOP/CORP, and HSTS when the request came over HTTPS.

sanitize.py — text sanitization

sanitize_text(text) strips control characters (keeping \n and \t) so terminal escape sequences cannot be smuggled into chat output.

websocket.py — WebSocket guards

  • origin_is_allowed(websocket, allowed_origins) — Origin check: by default the same host plus localhost/127.0.0.1; a custom allow-list is supported.
  • payload_too_big(raw, limit) — size check before JSON parsing.

__init__.py — the facade

Re-exports everything needed (hashes, tokens, encryption, middleware, sanitization, WS checks) so outside code does not depend on internal paths.


10. Utilities (app/utils/)

lines.py — output line builders

Every command returns a list of such dicts and the client renders them. Styles: plain, ok, err, warn, accent, dim, head, banner, clear. For multi-color lines use seg(style, text) and mixed(*segs) (for example "nick: text time").

ratelimit.py — rate limiting on Redis

check(redis, key, limit, window) — a fixed window: increment the counter, set a TTL on the first call. Returns True if the limit is not exceeded. reset(redis, key) — reset.

validators.py — validation

  • normalize_nick / validate_nick — a nick: 5–15 chars, latin/digits/_.
  • validate_password — a password of 8 to 128 characters.

11. Frontend (app/static/)

index.html

Terminal markup: a "window" with a title bar, an output area, an input row, a hint bar, a status bar (WebSocket status, theme, clock) and CRT effect layers. It loads terminal.css, themes.css, effects.js, terminal.js. The CSS cache is busted with ?v=N — bump it whenever you edit the CSS.

css/terminal.css

Base terminal layout: window, lines, style colors (plain, err, ok, dim, accent, notice, echo), and the data-theme attribute used to pick a palette.

css/themes.css

Palettes for all 17 themes (keyed by data-theme="<name>") and the visual effects (scanlines, vignette, glass, flicker, noise).

js/terminal.js

The main client:

  • state — token, theme, nick, prompt, open chat, history;
  • init — session restore (/api/me), command list load (/api/commands), WebSocket connect, clock start;
  • connect / scheduleReconnect — WebSocket with auto-reconnect;
  • send — sending: over WS, or via POST /api/command when the socket is down;
  • handle — routing inbound messages: auth_ok, out, msg, notice, err;
  • handleOut — applies a command payload: session, logout, theme, prompt, chat, output lines;
  • handleMsg — a live message: printed inline when viewing that chat, otherwise a notification with a reply hint;
  • handleNotice — notifications: friend request, accept/decline, chat leave;
  • submit / onKey / navHist / complete — input, history (7 commands in sessionStorage), tab completion;
  • PRIVATE_CMDS/password, /msg, /contact are never remembered; /password is masked as ******;
  • applyTheme — applies the theme and its effects.

js/effects.js

Per-theme CRT effects: scanlines, vignette, convex glass, flicker, noise (drawNoise), matrix rain (drawMatrix). Works through canvas and CSS classes; activates the right set via applyThemeFx(name).


12. Tests (app/tests/)

Run with make test or python -m pytest -q. Tests use the env fixture, which spins up an in-memory SQLite and stubs for Redis and the socket manager.

conftest.py

  • FakeRedis — an in-memory Redis stub with only the commands that are really used (get/set/delete/incr/expire, lists, sets).
  • StubManager — instead of real sockets it records pushes in manager.sent as ("user", uid, payload) or ("group", member_ids, skip_id, payload).
  • env — the fixture: SQLite engine, session factory, make_ctx(ip, token) for building command contexts.

test_terminal.py

Flow tests through dispatch(): registration, login/logout, friends with requests, private and group chats, message policies, settings, /ls.

test_notifications.py

Checks live notifications: friend requests and their accept/decline, chat leave notices.

test_security.py

Security: nick-in-password rejection, session cap, history privacy, text sanitization, security headers and WebSocket checks.


13. Configuration and Docker

Settings through .env

All variables have the BS_ prefix (see .env.example). Defaults live in app/config.py, so almost nothing is required to run — but in production you must set BS_ENCRYPTION_KEY and BS_DATABASE_URL.

docker-compose.yml

Three services:

  • db — PostgreSQL 16 (postgres:16-alpine), user/password/database bs. Host port 5433 → container 5432 (host port 5432 is taken by the system PostgreSQL — leave it alone). The pgdata volume stores the data.
  • redis — Redis 7 with appendonly yes (disk persistence).
  • app — built from Dockerfile, env from .env, waits for db and redis via healthchecks. Port 8000.

Dockerfile

Python 3.12-slim. First installs dependencies (pip install .), then copies the app/ source — so the layer cache survives source edits. The run command is uvicorn app.main:app --host 0.0.0.0 --port 8000.

Makefile

  • make compose — build and start the stack;
  • make setup — local install with dev dependencies;
  • make run — local uvicorn;
  • make test — run the tests;
  • make logs / make down — logs and teardown.

14. Redis: keys in use

Key Format Purpose
sess:{token} string session: token → user_id (TTL session_ttl)
user_sess:{uid} list the user's active-session index
login:{ip} string a pending second login step (/login/password)
rl:* counter rate limits: rl:register:{ip}, rl:login:{ip}, rl:pass:{ip}, rl:msg:{uid}, rl:cmd:{uid}, rl:ws:{ip}
chat:user:{uid} JSON the user's open chat
hist:{uid} list the server command history (non-private)
presence:online set who is online (user_id)
confirm:{uid} JSON a pending action confirmation (/delete_account)

15. How to add a new command

  1. Create a file in app/cli/commands/ (or add a function to a fitting one).
  2. Wrap the handler with the @command(...) decorator:
    @command(
        "mycmd",
        usage="/mycmd <argument>",
        help="A short description.",
        group="SECTION NAME",
    )
    async def cmd_mycmd(ctx, args):
        if not args:
            return usage_hint(ctx)
        # business logic, work with ctx.open_db() and ctx.redis
        return [ok("Done.")]
  3. Import the module in app/cli/commands/__init__.py so the decorator fires.
  4. The command automatically appears in /help and /api/commands (client tab completion).
  5. If the command has many subcommands, set self_help=True and handle help yourself, like /theme and /group do.

If the command mutates data, do not write SQL in the command: put a function into the appropriate service under app/services/.


16. How to run it

cp .env.example .env          # configure if needed
make compose                  # docker compose up -d --build
# open http://localhost:8000

Without Docker:

make setup                    # pip install -e ".[dev]"
redis-server --daemonize yes
BS_DATABASE_URL="sqlite+aiosqlite:///./dev.db" make run

Tests:

make test