A small, private TODO system for personal use, families, and small teams,
with two front-ends sharing the same REST API: a Telegram bot and an orbit
CLI (with an interactive TUI mode).
- Three-package split: thin Telegram bot ·
orbitCLI · REST API (Prisma-owned) - Webhook-based bot (no long polling); PAT-authenticated public CLI
- PostgreSQL storage (Prisma, owned by API only)
- Inline keyboards in Telegram · arrow-key TUI in the CLI
- Create tasks with
/add <text>, or just send any text - Lists:
- ⏳ В работе (open) sorted by due date (tasks with
dueAtsurface first, NULLS LAST) - 🗂️ Выполненные (done)
- ⏳ В работе (open) sorted by due date (tasks with
- Task actions via inline buttons:
- Done / Reopen
- Edit title
- Set due date (
DD.MM.YYYY [HH:MM]) - Delete (cascades to pending sessions)
- Russian date rendering (
сегодня,завтра,через 3 дня,15 мая) - Atomic completion of state-machine flows (single API transaction)
- Minimal logs (no secrets, truncated to 120 chars)
- Node.js 24 (ESM)
- pnpm 10 workspace
- grammY (Telegram Bot API)
- Express 5 (webhook + REST)
- Prisma 6 + PostgreSQL
- Zod (shared wire contracts)
- Vitest (workspace tests; testcontainers for API integration)
orbit-todo/
├── apps/
│ ├── bot/ # @orbit/bot — Telegram webhook, pure HTTP client
│ ├── api/ # @orbit/api — REST + Prisma
│ └── cli/ # @orbit/cli — `orbit` binary (commands + Ink TUI)
├── packages/
│ ├── contracts/ # @orbit/contracts — Zod schemas + TS types
│ └── api-client/ # @orbit/api-client — framework-free HTTP client
├── docs/
│ └── railway-deploy.md # Deployment runbook
└── pnpm-workspace.yaml
@orbit/contracts is the single source of truth for the HTTP wire format
shared between bot and api.
The bot has no database access. Every persistent operation goes through
@orbit/api over Railway's private network.
Base: http://<api-host>/v1. All endpoints require:
Authorization: Bearer <orbit_pat_…>— a Personal Access Token. User PATs (minted via/cli_link) resolve to the bound user; the bot's PAT (canImpersonate=true, source IP must be insideBOT_PAT_ALLOWED_CIDR) impersonates the user named by theX-Telegram-User-Idheader.
| Method | Path | Notes |
|---|---|---|
GET |
/healthz |
Public, no auth |
GET |
/v1/users/me |
Upserts viewer |
GET |
/v1/tasks?mode=my|done&page=N |
List |
GET |
/v1/tasks/:numId |
404 on owner mismatch |
POST |
/v1/tasks |
Idempotency-Key |
PATCH |
/v1/tasks/:numId |
404 on owner mismatch |
DELETE |
/v1/tasks/:numId |
Cascades to pending sessions |
GET |
/v1/sessions/latest?kind=… |
Opaque payload |
POST |
/v1/sessions |
Opaque payload |
PATCH |
/v1/sessions/:id |
|
DELETE |
/v1/sessions/:id |
|
POST |
/v1/sessions/:id/commit |
Atomic task update + session delete (taskPatch required; use DELETE /v1/sessions/:id for plain removal) |
BOT_TOKEN— Telegram bot token (BotFather)WEBHOOK_SECRET— random secret for Telegram webhook validationAPI_BASE_URL— e.g.http://<api-private-host>:8080BOT_PAT— bot's Personal Access Token plaintext (orbit_pat_…); replaces the legacyAPI_BOT_TOKEN
DATABASE_URL— PostgreSQL connection stringBOT_PAT_USER_ID— fixed UUID for the bot's system user row (seeded on first deploy)BOT_PAT_SHA256— lowercase hex SHA-256 ofBOT_PAT(seeded into DB; plaintext never stored)BOT_PAT_ALLOWED_CIDR— comma-separated CIDRs allowed for bot-PAT impersonation (e.g.fd00::/8,::1)API_PUBLIC_EXPOSURE—trueto mount/v1/*on the public domain;false(default) for internal-onlyPORT— Railway injects this; defaults to 8080
See .env.example for a complete template.
- Node.js 24 (see
.nvmrc) - pnpm 10 (
corepack enable; version pinned inpackage.json#packageManager) - PostgreSQL reachable via
DATABASE_URL(e.g. a Railway preview DB) - Docker (only for
pnpm --filter @orbit/api test—@testcontainers/postgresql)
pnpm install --frozen-lockfile
pnpm --filter @orbit/contracts build
pnpm --filter @orbit/api exec prisma generate# API (port 8080)
pnpm --filter @orbit/api dev
# Bot (port 3000), in a separate shell
API_BASE_URL=http://localhost:8080 BOT_PAT=orbit_pat_… \
pnpm --filter @orbit/bot devAPI endpoints:
POST /telegram/webhook(bot)GET /healthz(both services)
This repo uses Prisma migrations. They live in apps/api/prisma/migrations/
and are owned by @orbit/api only.
pnpm --filter @orbit/api exec prisma generate
pnpm --filter @orbit/api exec prisma migrate deployIn production, prisma migrate deploy runs from the API's Docker CMD
on every container start (Prisma's advisory lock makes it parallel-safe).
pnpm -r typecheck
pnpm -r testAll five workspaces run their own Vitest suite; apps/api uses
@testcontainers/postgresql for real-DB integration tests.
pnpm --filter @orbit/bot lint # ESLint blocks @prisma/client importsSee docs/railway-deploy.md for the full Railway runbook — two services, env matrix, IPv6 binding, migrations-in-CMD, rollback procedure.
After services are healthy, register the Telegram webhook once:
curl -X POST "https://api.telegram.org/bot${BOT_TOKEN}/setWebhook" \
-H "Content-Type: application/json" \
-d "{\"url\":\"https://<bot-public-url>/telegram/webhook\",\"secret_token\":\"${WEBHOOK_SECRET}\"}"- Do not commit
.env(.env.exampleis the template). - Webhook logs are truncated to 120 chars — keep that invariant.
- List queries are scoped by
ownerId = viewer.idin API (mutations return 404, not 403, on owner mismatch — privacy preserved).
# 1. Build
pnpm --filter @orbit/contracts build
pnpm --filter @orbit/api-client build
pnpm --filter @orbit/cli build
# 2. Link globally (run from the repo root — pnpm link --global must be run inside the package dir)
cd apps/cli && pnpm link --global && cd -This exposes an orbit binary on your PATH via pnpm's global bin directory.
Run pnpm bin --global to print the directory (typically
~/.local/share/pnpm); pnpm setup adds it to your shell rc automatically.
Verify with which orbit && orbit --version.
npm publishis a future follow-up (F4). For now the dev recipe above is the only install path.
Obtain a PAT first:
- In Telegram:
/cli_link [optional label]. The bot DMs both the raw PAT and a ready-to-pasteorbit login --token <pat>command (shown once). - Paste that command into your shell — it saves config at
$XDG_CONFIG_HOME/orbit/config.json(default~/.config/orbit/config.json). orbit whoami— confirms the connection.
| Command | Purpose |
|---|---|
orbit |
Launch interactive TUI (in a TTY). See Interactive TUI below. |
orbit login --token <pat> |
Save a PAT minted via /cli_link in the bot |
orbit logout |
Delete local config (does not revoke the PAT server-side) |
orbit whoami [--json] |
Show authenticated user |
orbit list [--mode my|done] [--page N | --all] [--json] |
List tasks; prints a Страница N из M footer in human mode, --all collects every page |
orbit show <numId> [--json] |
Show one task by numeric id |
orbit add <text...> [--due "DD.MM.YYYY [HH:MM]"] [--json] |
Create a task |
orbit done <numId> [--json] |
Mark a task done |
orbit reopen <numId> [--json] |
Reopen a done task |
orbit edit <numId> --title T | --due D | --no-due [--json] |
Update title or due date |
orbit rm <numId> --yes |
Delete a task (requires --yes) |
orbit tokens list [--json] |
List your PATs |
orbit tokens revoke <id> |
Revoke a PAT by id |
Exit codes: 0 ok · 1 generic · 2 auth · 3 not-found · 4 network.
--json is supported on every command. --idempotency-key <key> is accepted
on every mutation (add, done, reopen, edit, rm, tokens revoke);
omit to auto-generate a randomUUID().
Config file: $XDG_CONFIG_HOME/orbit/config.json (default
~/.config/orbit/config.json), written with mode 0600. Add to .gitignore:
.config/orbit/config.json
Env overrides: ORBIT_API_BASE_URL, ORBIT_TOKEN (override config
without touching the file — useful for scripting and CI).
Running orbit with no subcommand in a TTY launches a full-screen interactive
view (Ink-based). In non-TTY contexts (pipes, CI) it falls through to the
standard --help output, so scripts are unaffected.
🪐 Orbit · Мои задачи
Страница: 1 / 3
1. 5 букв · ⏰ сегодня
> 2. Стрим "Разбираемся с навайбкоженным" · ⏰ через 2 дня
3. Прогнать Дашин договор через Claude
…
List view
| Key | Action |
|---|---|
↑ / ↓ (or k / j) |
Move cursor |
← / → (or h / l) |
Previous / next page |
enter |
Open the task card |
d |
Mark selected task done |
o |
Reopen selected (done) task |
m |
Cycle mode: my ↔ done |
g / r |
Refresh |
q / esc |
Exit |
Task card
| Key | Action |
|---|---|
d / o |
Toggle status |
e |
Edit title (inline; enter saves, esc cancels) |
t |
Edit due date (DD.MM.YYYY [HH:MM]; empty buffer clears the date) |
x / X |
Delete (then y to confirm, n / esc to cancel) |
q / esc |
Back to list |
scripts/smoke.sh exercises the deployed Railway API end-to-end. Requires a
real PAT (mint one via /cli_link in the bot):
ORBIT_TOKEN=orbit_pat_… bash scripts/smoke.sh
# or with a custom base URL:
ORBIT_TOKEN=orbit_pat_… ORBIT_API_BASE_URL=https://orbit-todo-api.up.railway.app bash scripts/smoke.shThis script is intentionally not wired into CI (it requires live secrets). Run it manually after deploy to verify the production API.
MIT